diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 547c1a4..f47366a 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -22,10 +22,10 @@ jobs: fetch-depth: 0 - name: Install graphviz run: sudo apt-get install graphviz - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Build with Gradle - run: ./gradlew -Prepo.access.token=${{ secrets.REPO_ACCESS_TOKEN }} stage + run: ./gradlew -Pwebsite.push.token=${{ secrets.WEBSITE_PUSH_TOKEN }} stage diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml new file mode 100644 index 0000000..d0e4ec9 --- /dev/null +++ b/.github/workflows/jekyll.yml @@ -0,0 +1,89 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# Sample workflow for building and deploying a Jekyll site to GitHub Pages +name: Deploy Jekyll site to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between +# the run in-progress and latest queued. However, do NOT cancel +# in-progress runs as we want to allow these production deployments +# to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' # Not needed with a .ruby-version file + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + cache-version: 0 # Increment this number if you need to re-download cached gems + working-directory: webpages + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + - name: Build with Jekyll + # Outputs to the './_site' directory by default + run: cd webpages && bundle exec jekyll build + env: + JEKYLL_ENV: production + - name: Install graphviz + run: sudo apt-get install graphviz + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + - name: Build apidocs + run: ./gradlew apidocs + - name: Copy javadoc + run: cp -a build/javadoc webpages/_site/ + - name: Generate the sitemap + uses: cicirello/generate-sitemap@v1 + with: + path-to-root: webpages/_site + base-url-path: https://vm-operator.jdrupes.org + - name: Index pagefind + run: cd webpages && npx pagefind --source "_site" + - name: Upload artifact + # Automatically uploads an artifact from the './_site' directory by default + uses: actions/upload-pages-artifact@v3 + with: + path: './webpages/_site' + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e6b3c9..beab0c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: main - name: Install graphviz run: sudo apt-get install graphviz - name: Install podman @@ -32,10 +31,10 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Push with Gradle - run: ./gradlew -Prepo.access.token=${{ secrets.REPO_ACCESS_TOKEN }} -Pdocker.registry=ghcr.io/${{ github.actor }} stage pushImages + run: ./gradlew -Pwebsite.push.token=${{ secrets.WEBSITE_PUSH_TOKEN }} -Pdocker.registry=ghcr.io/${{ github.actor }} stage publishImage diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..6ed5002 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,30 @@ +# See [rules](https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml) + +# Default state for all rules +default: true + +# MD007/ul-indent : Unordered list indentation : +# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md007.md +MD007: + # Spaces for indent + indent: 2 + # Whether to indent the first level of the list + start_indented: true + # Spaces for first level indent (when start_indented is set) + start_indent: 2 + +# MD025/single-title/single-h1 : Multiple top-level headings in the same document : +# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md025.md +MD025: + # Heading level + level: 1 + # RegExp for matching title in front matter (disable) + front_matter_title: "" + +# MD036/no-emphasis-as-heading : Emphasis used instead of a heading : +# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md036.md +MD036: false + +# MD043/required-headings : Required heading structure : +# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md043.md +MD043: false diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs index d0fed22..72733d9 100644 --- a/.settings/org.eclipse.buildship.core.prefs +++ b/.settings/org.eclipse.buildship.core.prefs @@ -1,4 +1,4 @@ -arguments=--init-script /home/mnl/.config/Code/User/globalStorage/redhat.java/1.18.0/config_linux/org.eclipse.osgi/51/0/.cp/gradle/init/init.gradle --init-script /home/mnl/.config/Code/User/globalStorage/redhat.java/1.18.0/config_linux/org.eclipse.osgi/51/0/.cp/gradle/protobuf/init.gradle +arguments=--init-script /home/mnl/.config/Code/User/globalStorage/redhat.java/1.24.0/config_linux/org.eclipse.osgi/55/0/.cp/gradle/init/init.gradle --init-script /home/mnl/.config/Code/User/globalStorage/redhat.java/1.24.0/config_linux/org.eclipse.osgi/55/0/.cp/gradle/protobuf/init.gradle auto.sync=false build.scans.enabled=false connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..56a575c --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,38 @@ +when: +- event: push + evaluate: 'CI_SYSTEM_HOST == "woodpecker.mnl.de"' + +clone: +- name: git + image: woodpeckerci/plugin-git + settings: + partial: false + tags: true + depth: 0 + +steps: +- name: prepare + image: alpine + commands: + # Because we run the next step as user 1000 to make podman work: + - mkdir /woodpecker/workflow + - chown 1000:1000 /woodpecker/workflow + - chown -R 1000:1000 $CI_WORKSPACE + +- name: build-jars + image: registry.mnl.de/mnl/jdk21-builder:v4 + environment: + HOME: /woodpecker/workflow + REGISTRY: registry.mnl.de + REGISTRY_USER: mnl + REGISTRY_TOKEN: + from_secret: REGISTRY_TOKEN + commands: + - echo $REGISTRY_TOKEN | podman login -u $REGISTRY_USER --password-stdin $REGISTRY + - ./gradlew -Pdocker.registry=$REGISTRY/$REGISTRY_USER build apidocs publishImage + backend_options: + kubernetes: + securityContext: + privileged: true + runAsUser: 1000 + runAsGroup: 1000 diff --git a/README.md b/README.md index 1895bbb..09fcd25 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,25 @@ -[![Java CI with Gradle](https://github.com/mnlipp/VM-Operator/actions/workflows/gradle.yml/badge.svg)](https://github.com/mnlipp/VM-Operator/actions/workflows/gradle.yml) +[![Java CI with Gradle](https://github.com/mnlipp/VM-Operator/actions/workflows/gradle.yml/badge.svg)](https://github.com/mnlipp/VM-Operator/actions/workflows/gradle.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/2277842dac894de4b663c6aa2779077e)](https://app.codacy.com/gh/mnlipp/VM-Operator/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) ![Latest Manager](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=manager*&label=latest) ![Latest Runner](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=runner-qemu*&label=latest) -# Run Qemu in Kubernetes Pods +# Run QEMU/KVM in Kubernetes Pods -The goal of this project is to provide the means for running Qemu -based VMs in Kubernetes pods. +![Overview picture](webpages/index-pic.svg) -See the [project's home page](https://mnlipp.github.io/VM-Operator/) +This project provides an easy to use and flexible solution for running +QEMU/KVM based VMs in Kubernetes pods. + +The central component of this solution is the kubernetes operator that +manages "runners". These run in pods and are used to start and manage +the QEMU/KVM process for the VMs (optionally together with a SW-TPM). + +A web GUI for administrators provides an overview of the VMs together +with some basic control over the VMs. A web GUI for users provides an +interface to access and optionally start, stop and reset the VMs. + +Advanced features of the operator include pooling of VMs and automatic +login. + +See the [project's home page](https://vm-operator.jdrupes.org/) for details. - diff --git a/build.gradle b/build.gradle index 1a11881..eb8e59a 100644 --- a/build.gradle +++ b/build.gradle @@ -5,9 +5,10 @@ buildscript { } plugins { - id 'org.ajoberstar.grgit' version '5.2.0' apply false + id 'org.ajoberstar.grgit' version '5.2.0' id 'org.ajoberstar.git-publish' version '4.2.0' apply false - id 'pl.allegro.tech.build.axion-release' version '1.15.0' apply false + id 'pl.allegro.tech.build.axion-release' version '1.17.2' apply false + id 'org.jdrupes.vmoperator.versioning-conventions' id 'org.jdrupes.vmoperator.java-doc-conventions' id 'eclipse' id "com.github.node-gradle.node" version "7.0.1" @@ -18,7 +19,7 @@ allprojects { } task stage { - description = 'To be executed by CI, build and update JavaDoc.' + description = 'To be executed by CI.' group = 'build' // Build everything first @@ -26,11 +27,6 @@ task stage { dependsOn subprojects.tasks.collect { tc -> tc.findByName("build") }.flatten() } - - if (JavaVersion.current() == JavaVersion.VERSION_17) { - // Publish JavaDoc - dependsOn gitPublishPush - } } eclipse { diff --git a/buildSrc/.settings/org.eclipse.jdt.core.prefs b/buildSrc/.settings/org.eclipse.jdt.core.prefs index 68fda12..b25073a 100644 --- a/buildSrc/.settings/org.eclipse.jdt.core.prefs +++ b/buildSrc/.settings/org.eclipse.jdt.core.prefs @@ -1,9 +1,7 @@ +# +#Wed Oct 02 14:48:43 CEST 2024 eclipse.preferences.version=1 -org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull -org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable -org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled -org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate org.eclipse.jdt.core.compiler.codegen.targetPlatform=21 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve org.eclipse.jdt.core.compiler.compliance=21 @@ -11,12 +9,5 @@ org.eclipse.jdt.core.compiler.debug.lineNumber=generate org.eclipse.jdt.core.compiler.debug.localVariable=generate org.eclipse.jdt.core.compiler.debug.sourceFile=generate org.eclipse.jdt.core.compiler.problem.assertIdentifier=error -org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled org.eclipse.jdt.core.compiler.problem.enumIdentifier=error -org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error -org.eclipse.jdt.core.compiler.problem.nullReference=warning -org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error -org.eclipse.jdt.core.compiler.problem.potentialNullReference=ignore -org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning -org.eclipse.jdt.core.compiler.release=disabled org.eclipse.jdt.core.compiler.source=21 diff --git a/buildSrc/.settings/org.eclipse.jdt.groovy.core.prefs b/buildSrc/.settings/org.eclipse.jdt.groovy.core.prefs index bf0ca13..71b5e37 100644 --- a/buildSrc/.settings/org.eclipse.jdt.groovy.core.prefs +++ b/buildSrc/.settings/org.eclipse.jdt.groovy.core.prefs @@ -1,3 +1,3 @@ eclipse.preferences.version=1 -groovy.compiler.level=40 +groovy.compiler.level=-1 groovy.script.filters=**/*.dsld,y,**/*.gradle,n diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index a9fb634..4a5db6d 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -1,9 +1,3 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This project uses @Incubating APIs which are subject to change. - */ - plugins { // Support convention plugins written in Groovy. Convention plugins // are build scripts in 'src/main' that automatically become available @@ -14,52 +8,24 @@ plugins { id 'eclipse' } -repositories { - // Use the plugin portal to apply community plugins in convention plugins. - gradlePluginPortal() -} - sourceSets { - main { - groovy { - srcDirs = ['src'] - } - } - - test { - groovy { - srcDirs = ['test'] - } - } + main { + groovy { + srcDirs = ['src'] + } + resources { + srcDirs = ['resources'] + } + } } eclipse { - project { - file { - // closure executed after .project content is loaded from existing file - // and before gradle build information is merged - beforeMerged { project -> - project.natures.clear() - project.buildCommands.clear() - } - - project.natures += 'org.eclipse.buildship.core.gradleprojectnature' - // Don't build, result not used by Eclipse anyway - // project.buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' - } - } - - classpath { - downloadJavadoc = true - downloadSources = true - } - jdt { file { withProperties { properties -> def formatterPrefs = new Properties() - rootProject.file("gradle/org.eclipse.jdt.core.formatter.prefs") + rootProject.file("../gradle/org.eclipse.jdt.core.formatter.prefs") .withInputStream { formatterPrefs.load(it) } properties.putAll(formatterPrefs) } diff --git a/buildSrc/settings.gradle b/buildSrc/settings.gradle deleted file mode 100644 index 3f67e42..0000000 --- a/buildSrc/settings.gradle +++ /dev/null @@ -1,7 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This settings file is used to specify which projects to include in your build-logic build. - */ - -rootProject.name = 'buildSrc' diff --git a/buildSrc/src/org.jdrupes.vmoperator.java-common-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.java-common-conventions.gradle index 50aebae..605dc09 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.java-common-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.java-common-conventions.gradle @@ -5,6 +5,11 @@ */ plugins { + // Apply the common versioning conventions. + // Put this at the start, because accessing project.version before + // this is applied makes things fail. + id 'org.jdrupes.vmoperator.versioning-conventions' + // Apply the java Plugin to add support for Java. id 'java' @@ -13,15 +18,11 @@ plugins { // Access to git information id 'org.ajoberstar.grgit' - - // Apply the common versioning conventions. - id 'org.jdrupes.vmoperator.versioning-conventions' } repositories { // Use Maven Central for resolving dependencies. mavenCentral() - mavenLocal() } dependencies { @@ -54,21 +55,25 @@ sourceSets { java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } jar { manifest { - inputs.property("gitDescriptor", { grgit.describe(always: true) }) + def matchExpr = [ project.tagName + "*" ] + + inputs.property("gitDescriptor", + { grgit.describe(always: true, match: matchExpr) }) // Set Git revision information in the manifests of built bundles + def gitDesc = grgit.describe(always: true, match: matchExpr) attributes([ "Implementation-Title": project.name, - "Implementation-Version": "$project.version (built from ${grgit.describe(always: true)})", + "Implementation-Version": "$project.version (built from ${gitDesc})", "Implementation-Vendor": grgit.repository.jgit.repository.config.getString("user", null, "name") + " (" + grgit.repository.jgit.repository.config.getString("user", null, "email") + ")", - "Git-Descriptor": grgit.describe(always: true), + "Git-Descriptor": gitDesc, "Git-SHA": grgit.head().id, ]) } diff --git a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle index 95d7eff..6af8fa7 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle @@ -22,31 +22,28 @@ configurations { } dependencies { - markdownDoclet "org.jdrupes.mdoclet:doclet:3.1.0" - javadocTaglets "org.jdrupes.taglets:plantuml-taglet:2.1.0" -} - -task javadocResources(type: Copy) { - into file(docDestinationDir) - from ("${rootProject.rootDir}/misc") { - include '*.woff2' - } + markdownDoclet "org.jdrupes.mdoclet:doclet:4.0.0" + javadocTaglets "org.jdrupes.taglets:plantuml-taglet:3.0.0" } task apidocs (type: JavaExec) { // Does not work on JitPack, no /usr/bin/dot - enabled = JavaVersion.current() == JavaVersion.VERSION_17 - - dependsOn javadocResources + enabled = JavaVersion.current() == JavaVersion.VERSION_21 outputs.dir(docDestinationDir) inputs.file rootProject.file('overview.md') - inputs.file "${rootProject.rootDir}/misc/stylesheet.css" + inputs.file "${rootProject.rootDir}/misc/javadoc-overwrites.css" - jvmArgs = ['--add-exports=jdk.javadoc/jdk.javadoc.internal.tool=ALL-UNNAMED', - '--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED'] - main = 'jdk.javadoc.internal.tool.Main' + jvmArgs = ['--add-exports=jdk.compiler/com.sun.tools.doclint=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED', + '--add-exports=jdk.javadoc/jdk.javadoc.internal.tool=ALL-UNNAMED', + '--add-exports=jdk.javadoc/jdk.javadoc.internal.doclets.toolkit=ALL-UNNAMED', + '--add-opens=jdk.javadoc/jdk.javadoc.internal.doclets.toolkit.resources.releases=ALL-UNNAMED', + '-Duser.language=en', '-Duser.region=US'] + mainClass = 'jdk.javadoc.internal.tool.Main' gradle.projectsEvaluated { // Make sure that other projects' compileClasspaths are resolved @@ -69,8 +66,8 @@ task apidocs (type: JavaExec) { '-package', '-use', '-linksource', - '-link', 'https://docs.oracle.com/en/java/javase/17/docs/api/', - '-link', 'https://mnlipp.github.io/jgrapes/latest-release/javadoc/', + '-link', 'https://docs.oracle.com/en/java/javase/21/docs/api/', + '-link', 'https://jgrapes.org/latest-release/javadoc/', '-link', 'https://freemarker.apache.org/docs/api/', '--add-exports', 'jdk.javadoc/jdk.javadoc.internal.tool=ALL-UNNAMED', '--add-exports', 'jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED', @@ -88,7 +85,7 @@ task apidocs (type: JavaExec) { '-bottom', rootProject.file("misc/javadoc.bottom.txt").text, '--allow-script-in-comments', '-Xdoclint:-html', - '--main-stylesheet', "${rootProject.rootDir}/misc/stylesheet.css", + '--add-stylesheet', "${rootProject.rootDir}/misc/javadoc-overwrites.css", '--add-exports=jdk.javadoc/jdk.javadoc.internal.doclets.formats.html=ALL-UNNAMED', '-quiet' ] @@ -97,34 +94,27 @@ task apidocs (type: JavaExec) { ignoreExitValue true } +task testJavadoc(type: Javadoc) { + enabled = JavaVersion.current() == JavaVersion.VERSION_21 + + source = fileTree(dir: 'testfiles', include: '**/*.java') + destinationDir = project.file("build/testfiles-gradle") + options.docletpath = configurations.markdownDoclet.files.asType(List) + options.doclet = 'org.jdrupes.mdoclet.MDoclet' + options.overview = 'testfiles/overview.md' + options.addStringOption('Xdoclint:-html', '-quiet') + + options.setJFlags([ + '--add-exports=jdk.compiler/com.sun.tools.doclint=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED', + '--add-exports=jdk.javadoc/jdk.javadoc.internal.tool=ALL-UNNAMED', + '--add-exports=jdk.javadoc/jdk.javadoc.internal.doclets.toolkit=ALL-UNNAMED', + '--add-opens=jdk.javadoc/jdk.javadoc.internal.doclets.toolkit.resources.releases=ALL-UNNAMED']) +} // Prepare github authentication for plugins if (System.properties['org.ajoberstar.grgit.auth.username'] == null) { System.setProperty('org.ajoberstar.grgit.auth.username', - project.rootProject.properties['repo.access.token'] ?: "nouser") -} - -gitPublish { - repoUri = 'https://github.com/mnlipp/VM-Operator.git' - branch = 'gh-pages' - contents { - from("${rootProject.buildDir}/javadoc") { - into 'javadoc' - } - if (!findProject(':org.jdrupes.vmoperator.runner.qemu').isSnapshot - && !findProject(':org.jdrupes.vmoperator.manager').isSnapshot) { - from("${rootProject.buildDir}/javadoc") { - into 'latest-release/javadoc' - } - } - } - preserve { include '**/*' } - commitMessage = "Updated." -} - -gradle.projectsEvaluated { - tasks.gitPublishReset.mustRunAfter subprojects.tasks - .collect { tc -> tc.findByName("build") }.flatten() - tasks.gitPublishReset.mustRunAfter subprojects.tasks - .collect { tc -> tc.findByName("test") }.flatten() - tasks.gitPublishCopy.dependsOn apidocs + project.rootProject.properties['website.push.token'] ?: "nouser") } diff --git a/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle index 114db51..49b6f74 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle @@ -11,21 +11,26 @@ plugins { id 'pl.allegro.tech.build.axion-release' } +def shortened = project.name.startsWith(project.group + ".") ? + project.name.substring(project.group.length() + 1) : project.name +if (shortened == "manager") { + shortened = "manager-app"; +} +var tagName = shortened.replace('.', '-') + "-" +if (grgit.branch.current.name != "main" + && grgit.branch.current.name != "HEAD" + && !grgit.branch.current.name.startsWith("testing") + && !grgit.branch.current.name.startsWith("release") + && !grgit.branch.current.name.startsWith("develop")) { + tagName = tagName + grgit.branch.current.name.replace('/', '-') + "-" +} +project.ext.tagName = tagName + scmVersion { versionIncrementer 'incrementMinor' tag { - def shortened = project.name.startsWith(project.group + ".") ? - project.name.substring(project.group.length() + 1) : project.name - if (shortened == "manager") { - shortened = "manager-app"; - } - var p = shortened.replace('.', '-') + "-" - if (grgit.branch.current.name != "main" - && !grgit.branch.current.name.startsWith("release")) { - p = p + grgit.branch.current.name.replace('/', '-') + "-" - } - prefix = p + prefix = project.tagName } } -version = scmVersion.version +project.version = scmVersion.version ext.isSnapshot = version.endsWith('-SNAPSHOT') diff --git a/checkstyle.xml b/checkstyle.xml index b5a60d3..088e543 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -30,8 +30,11 @@ + + + @@ -50,10 +53,9 @@ - - + diff --git a/deploy/crds/vmpools-crd.yaml b/deploy/crds/vmpools-crd.yaml new file mode 100644 index 0000000..2144940 --- /dev/null +++ b/deploy/crds/vmpools-crd.yaml @@ -0,0 +1,74 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: vmpools.vmoperator.jdrupes.org +spec: + group: vmoperator.jdrupes.org + # list of versions supported by this CustomResourceDefinition + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + retention: + description: >- + Defines the timeout for assignments. The time may be + specified as ISO 8601 time or duration. When specifying + a duration, it will be added to the last time the VM's + console was used to obtain the timeout. + type: string + pattern: '^(?:\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3])(?:|:?[0-5]\d))|P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+[Hh])?(?:\d+[Mm])?(?:\d+(?:\.\d{1,9})?[Ss])?)?)$' + default: "PT1h" + loginOnAssignment: + description: >- + If set to true, the user will be automatically logged in + to the VM's console when the VM is assigned to him. + type: boolean + default: false + permissions: + type: array + description: >- + Defines permissions for accessing and manipulating the Pool. + items: + type: object + description: >- + Permissions can be granted to a user or to a role. + oneOf: + - required: + - user + - required: + - role + properties: + user: + type: string + role: + type: string + may: + type: array + items: + type: string + enum: + - start + - stop + - reset + - accessConsole + - "*" + default: ["accessConsole"] + required: + - permissions + # either Namespaced or Cluster + scope: Namespaced + names: + # plural name to be used in the URL: /apis/// + plural: vmpools + # singular name to be used as an alias on the CLI and for display + singular: vmpool + # kind is normally the CamelCased singular type. Your resource manifests use this. + kind: VmPool + listKind: VmPoolList diff --git a/deploy/crds/vms-crd.yaml b/deploy/crds/vms-crd.yaml index 7e321da..c2a7a66 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -933,6 +933,12 @@ spec: update: type: boolean default: true + guestShutdownStops: + description: >- + If true, sets the VM's state to "Stopped" when + the VM terminates due to a shutdown by the guest. + type: boolean + default: false loadBalancerService: description: >- Data to be merged with the loadBalancerService @@ -965,6 +971,71 @@ spec: additionalProperties: type: string nullable: true + cloudInit: + type: object + description: >- + Provides data for generating a cloud-init ISO + image that is attached to the VM. + properties: + metaData: + description: Copied to cloud-init's meta-data file. + type: object + additionalProperties: + type: string + userData: + description: Copied to cloud-init's user-data file. + type: object + x-kubernetes-preserve-unknown-fields: true + networkConfig: + description: Copied to cloud-init's network-config file. + type: object + x-kubernetes-preserve-unknown-fields: true + permissions: + type: array + description: >- + Defines permissions for accessing and manipulating the VM. + The meaning of most permissions should be obvious. The + difference between "accessConsole" and "takeConsole" is + that "takeConsole" allows the user to take control of + the console even if it is already in use by another user. + items: + type: object + description: >- + Permissions can be granted to a user or to a role. + oneOf: + - required: + - user + - required: + - role + properties: + user: + type: string + role: + type: string + may: + type: array + items: + type: string + enum: + - start + - stop + - reset + - accessConsole + - takeConsole + - "*" + default: [] + pools: + type: array + description: >- + List of pools this VM belongs to. + items: + type: string + default: [] + loggingProperties: + type: string + description: >- + Override the default logging properties for + the runner for this VM. vm: type: object description: Defines the VM. @@ -1356,13 +1427,36 @@ spec: display: type: object properties: + outputs: + type: integer + default: 1 + loggedInUser: + description: >- + The name of a user that should be automatically + logged in on the display. Note that this requires + support from an agent in the guest OS. + type: string spice: type: object properties: port: + description: >- + Port number used for the Spice server. type: integer default: 5900 - ticket: + server: + description: >- + Server (address) to use for connecting + to the spice server. Defaults to the address + of the node that the VM is running on. + type: string + generateSecret: + type: boolean + default: true + proxyUrl: + description: >- + If specified, is copied to the generated + viewer configuration files. type: string streamingVideo: type: string @@ -1376,6 +1470,10 @@ spec: type: object default: {} properties: + runnerVersion: + description: >- + The version string of the runner. + type: string cpus: description: >- Number of CPUs currently in use. @@ -1386,6 +1484,50 @@ spec: Amount of memory in use. type: string default: "0" + consoleClient: + description: >- + The hostname of the currently connected client. + type: string + default: "" + consoleUser: + description: >- + The id of the user who has last requested a console + connection. + type: string + default: "" + loggedInUser: + description: >- + The name of a user that is currently logged in by the + VM operator agent. + type: string + displayPasswordSerial: + description: >- + Counts changes of the display password. Set to -1 + by the runner if password protection is not enabled. + type: integer + default: 0 + osinfo: + description: Copy of the OS info provided by the guest agent. + type: object + x-kubernetes-preserve-unknown-fields: true + assignment: + description: >- + The assignment of this VM to a a particular user. + type: object + properties: + pool: + description: >- + The pool this VM is taken from. + type: string + user: + description: >- + The user this VM is assigned to. + type: string + lastUsed: + description: >- + The last time this VM was used by the user. + type: string + default: {} conditions: description: >- List of component conditions observed @@ -1396,6 +1538,30 @@ spec: lastTransitionTime: "1970-01-01T00:00:00Z" reason: Creation message: "Creation of CR" + - type: Booted + status: "False" + observedGeneration: 1 + lastTransitionTime: "1970-01-01T00:00:00Z" + reason: Creation + message: "Creation of CR" + - type: VmopAgentConnected + status: "False" + observedGeneration: 1 + lastTransitionTime: "1970-01-01T00:00:00Z" + reason: Creation + message: "Creation of CR" + - type: UserLoggedIn + status: "False" + observedGeneration: 1 + lastTransitionTime: "1970-01-01T00:00:00Z" + reason: Creation + message: "Creation of CR" + - type: ConsoleConnected + status: "False" + observedGeneration: 1 + lastTransitionTime: "1970-01-01T00:00:00Z" + reason: Creation + message: "Creation of CR" type: array items: type: object diff --git a/deploy/vmop-deployment.yaml b/deploy/vmop-deployment.yaml index 648cc39..08316f6 100644 --- a/deploy/vmop-deployment.yaml +++ b/deploy/vmop-deployment.yaml @@ -21,22 +21,31 @@ spec: - name: vm-operator image: >- ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest + imagePullPolicy: Always + env: + - name: JAVA_OPTS + # The VM operator needs about 25 MB of memory, plus 1 MB for + # each VM. The reason is that for the sake of effeciency, we + # have to keep a parsed representation of the CRD in memory, + # which requires about 512 KB per VM. While handling updates, + # we temporarily have the old and the new version of the CRD + # in memory, so we need another 512 KB per VM. + value: "-Xmx128m" + resources: + requests: + cpu: 100m + memory: 128Mi volumeMounts: - name: config mountPath: /etc/opt/vmoperator - name: vmop-image-repository mountPath: /var/local/vmop-image-repository - imagePullPolicy: Always securityContext: capabilities: drop: - ALL readOnlyRootFilesystem: true allowPrivilegeEscalation: false - resources: - requests: - cpu: 100m - memory: 128Mi volumes: - name: config configMap: diff --git a/deploy/vmop-role.yaml b/deploy/vmop-role.yaml index e1ea85b..e1ae7bc 100644 --- a/deploy/vmop-role.yaml +++ b/deploy/vmop-role.yaml @@ -9,8 +9,15 @@ rules: - vmoperator.jdrupes.org resources: - vms + - vmpools verbs: - '*' +- apiGroups: + - vmoperator.jdrupes.org + resources: + - vms/status + verbs: + - patch - apiGroups: - apps resources: @@ -28,8 +35,12 @@ rules: - apiGroups: - "" resources: + - persistentvolumeclaims - pods verbs: + - watch - list + - get + - create - delete - patch diff --git a/deploy/vmrunner-role.yaml b/deploy/vmrunner-role.yaml index 54e8742..c6df666 100644 --- a/deploy/vmrunner-role.yaml +++ b/deploy/vmrunner-role.yaml @@ -12,9 +12,16 @@ rules: verbs: - list - get + - patch - apiGroups: - vmoperator.jdrupes.org resources: - vms/status verbs: - patch +- apiGroups: + - events.k8s.io + resources: + - events + verbs: + - create diff --git a/dev-example/.gitignore b/dev-example/.gitignore new file mode 100644 index 0000000..1e31cc5 --- /dev/null +++ b/dev-example/.gitignore @@ -0,0 +1,4 @@ +/test-vm-ci.yaml +/kubeconfig.yaml +/crds/ +/.vm-operator-cmd.rc diff --git a/dev-example/Readme.md b/dev-example/Readme.md index dfcd3e8..d794b24 100644 --- a/dev-example/Readme.md +++ b/dev-example/Readme.md @@ -1,16 +1,16 @@ # Example setup for development -The CRD must be deployed independently. Apart from that, the -`kustomize.yaml` +The CRD must be deployed independently. Apart from that, the +`kustomize.yaml` + + * creates a small cdrom image repository and + + * deploys the operator in namespace `vmop-dev` with a replica of 0. -* creates a small cdrom image repository and - -* deploys the operator in namespace `vmop-dev` with a replica of 0. - This allows you to run the manager in your IDE. The `kustomize.yaml` also changes the container image repository for -the operator to a private repository for development. You have to +the operator to a private repository for development. You have to adapt this to your own repository if you also want to test your development version in a container. diff --git a/dev-example/config.yaml b/dev-example/config.yaml index 4a471a8..2a72bc8 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -1,12 +1,34 @@ # Used for running manager outside Kubernetes. # Keep in sync with kustomize.yaml "/Manager": + # If provided, is shown at top left before namespace + # clusterName: "test" # The controller manages the VM "/Controller": namespace: vmop-dev "/Reconciler": - runnerData: - storageClassName: null + runnerDataPvc: + storageClassName: rook-cephfs + loadBalancerService: + labels: + label1: label1 + label2: toBeReplaced + annotations: + metallb.universe.tf/loadBalancerIPs: 192.168.168.1 + metallb.universe.tf/ip-allocated-from-pool: single-common + metallb.universe.tf/allow-shared-ip: single-common + loggingProperties: | + # Defaults for namespace (VM domain) + handlers=java.util.logging.ConsoleHandler + + #org.jgrapes.level=FINE + #org.jgrapes.core.handlerTracking.level=FINER + + org.jdrupes.vmoperator.runner.qemu.level=FINEST + + java.util.logging.ConsoleHandler.level=ALL + java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter + java.util.logging.SimpleFormatter.format=%1$tb %1$td %1$tT %4$s %5$s%6$s%n "/GuiSocketServer": port: 8888 "/GuiHttpServer": @@ -15,17 +37,34 @@ "/WebConsole": "/LoginConlet": users: - admin: - fullName: Administrator - password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - test: - fullName: Test Account - password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: operator + fullName: Operator + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test1 + fullName: Test Account 1 + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account 2 + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 + fullName: Test Account 3 + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": rolesByUser: # User admin has role admin admin: - admin + operator: + - operator + test1: + - user + test2: + - user + test3: + - user # All users have role other "*": - other @@ -35,6 +74,17 @@ # Admins can use all conlets admin: - "*" + operator: + - org.jdrupes.vmoperator.vmmgmt.VmMgmt + - org.jdrupes.vmoperator.vmaccess.VmAccess + user: + - org.jdrupes.vmoperator.vmaccess.VmAccess # Others cannot use any conlet (except login conlet to log out) other: - - org.jgrapes.webconlet.locallogin.LoginConlet + - org.jgrapes.webconlet.oidclogin.LoginConlet + "/ComponentCollector": + "/VmAccess": + displayResource: + preferredIpVersion: ipv4 + syncPreviewsFor: + - role: user diff --git a/dev-example/gen-pool-vm-crds b/dev-example/gen-pool-vm-crds new file mode 100755 index 0000000..f9cf692 --- /dev/null +++ b/dev-example/gen-pool-vm-crds @@ -0,0 +1,47 @@ +#!/bin/bash + +function usage() { + cat >&2 <&2 "Unknown option: $1"; exit 1;; + *) template="$1";; + esac + shift +done + +if [ -z "$template" ]; then + usage +fi + +if [ "$count" = "0" ]; then + exit 0 +fi +for number in $(seq 1 $count); do + if [ -z "$prefix" ]; then + prefix=$(basename $template .tpl.yaml) + fi + name="$prefix$(printf %03d $number)" + index=$(($number - 1)) + esh -o $destination/$name.yaml $template number=$number index=$index +done diff --git a/dev-example/kustomization.yaml b/dev-example/kustomization.yaml index ae36fe1..975d95f 100644 --- a/dev-example/kustomization.yaml +++ b/dev-example/kustomization.yaml @@ -29,11 +29,20 @@ patches: # Keep in sync with config.yaml config.yaml: | "/Manager": + # clusterName: "test" "/Controller": namespace: vmop-dev "/Reconciler": runnerData: storageClassName: null + loadBalancerService: + labels: + label1: label1 + label2: toBeReplaced + annotations: + metallb.universe.tf/loadBalancerIPs: 192.168.168.1 + metallb.universe.tf/ip-allocated-from-pool: single-common + metallb.universe.tf/allow-shared-ip: single-common "/GuiSocketServer": port: 8888 "/GuiHttpServer": @@ -42,17 +51,29 @@ patches: "/WebConsole": "/LoginConlet": users: - admin: - fullName: Administrator - password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - test: - fullName: Test Account - password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test1 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" "/RoleConfigurator": rolesByUser: # User admin has role admin admin: - admin + test1: + - user + test2: + - user + test3: + - user # All users have role other "*": - other @@ -62,10 +83,17 @@ patches: # Admins can use all conlets admin: - "*" + user: + - org.jdrupes.vmoperator.vmviewer.VmViewer # Others cannot use any conlet (except login conlet to log out) other: - org.jgrapes.webconlet.locallogin.LoginConlet - + "/ComponentCollector": + "/VmAccess": + displayResource: + preferredIpVersion: ipv4 + syncPreviewsFor: + - role: user - target: group: apps version: v1 diff --git a/dev-example/pool-action b/dev-example/pool-action new file mode 100755 index 0000000..bc8fbce --- /dev/null +++ b/dev-example/pool-action @@ -0,0 +1,66 @@ +#!/bin/bash + +function usage() { + cat >&2 <&2 "Unknown option: $1"; exit 1;; + *) if [ ! -v pool ]; then + pool="$1" + elif [ ! -v action ]; then + action="$1" + else + usage + fi;; + esac + shift +done + +if [ ! -v pool -o ! -v "action" -o ! -v context ]; then + echo >&2 "Missing arguments or context not set." + echo >&2 + usage +fi +case "$action" in + "start"|"stop"|"delete"|"delete-disks") ;; + *) usage;; +esac + +kubectl --context="$context" -n "$namespace" get vms -o json \ + | jq -r '.items[] | select(.spec.pools | contains(["'${pool}'"])) | .metadata.name' \ +| while read vmName; do + case "$action" in + start) kubectl --context="$context" -n "$namespace" patch vms "$vmName" \ + --type='merge' -p '{"spec":{"vm":{"state":"Running"}}}';; + stop) kubectl --context="$context" -n "$namespace" patch vms "$vmName" \ + --type='merge' -p '{"spec":{"vm":{"state":"Stopped"}}}';; + delete) kubectl --context="$context" -n "$namespace" delete vm/"$vmName";; + delete-disks) kubectl --context="$context" -n "$namespace" delete \ + pvc -l app.kubernetes.io/instance="$vmName" ;; + esac +done diff --git a/dev-example/test-pool.yaml b/dev-example/test-pool.yaml new file mode 100644 index 0000000..497aaf7 --- /dev/null +++ b/dev-example/test-pool.yaml @@ -0,0 +1,17 @@ +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VmPool +metadata: + namespace: vmop-dev + name: test-vms +spec: + retention: "PT1m" + loginOnAssignment: true + permissions: + - user: admin + may: + - accessConsole + - start + - role: user + may: + - accessConsole + - start diff --git a/dev-example/test-vm-display-secret.yaml b/dev-example/test-vm-display-secret.yaml new file mode 100644 index 0000000..a6f0fe6 --- /dev/null +++ b/dev-example/test-vm-display-secret.yaml @@ -0,0 +1,13 @@ +kind: Secret +apiVersion: v1 +metadata: + name: test-vm-display-secret + namespace: vmop-dev + labels: + app.kubernetes.io/name: vm-runner + app.kubernetes.io/instance: test-vm + app.kubernetes.io/component: display-secret +type: Opaque +data: + display-password: dGVzdC12bQ== + password-expiry: KzMw diff --git a/dev-example/test-vm-shell.yaml b/dev-example/test-vm-shell.yaml new file mode 100644 index 0000000..8137694 --- /dev/null +++ b/dev-example/test-vm-shell.yaml @@ -0,0 +1,30 @@ +kind: Pod +apiVersion: v1 +metadata: + name: test-vm-shell + namespace: vmop-dev +spec: + volumes: + - name: test-vm-system-disk + persistentVolumeClaim: + claimName: system-disk-test-vm-0 + - name: vmop-image-repository + persistentVolumeClaim: + claimName: vmop-image-repository + containers: + - name: test-vm-shell + image: archlinux/archlinux + args: + - bash + imagePullPolicy: Always + stdin: true + stdinOnce: true + tty: true + volumeDevices: + - name: test-vm-system-disk + devicePath: /dev/test-vm-system-disk + volumeMounts: + - name: vmop-image-repository + mountPath: /var/local/vmop-image-repository + securityContext: + privileged: true diff --git a/dev-example/test-vm-snapshot.yaml b/dev-example/test-vm-snapshot.yaml new file mode 100644 index 0000000..fd60a25 --- /dev/null +++ b/dev-example/test-vm-snapshot.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshot +metadata: + namespace: vmop-dev + name: test-vm-system-disk-snapshot +spec: + volumeSnapshotClassName: csi-rbdplugin-snapclass + source: + persistentVolumeClaimName: test-vm-system-disk diff --git a/dev-example/test-vm.tpl.yaml b/dev-example/test-vm.tpl.yaml new file mode 100644 index 0000000..76adfba --- /dev/null +++ b/dev-example/test-vm.tpl.yaml @@ -0,0 +1,66 @@ +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VirtualMachine +metadata: + namespace: vmop-dev + name: test-vm<%= $(printf "%02d" ${number}) %> + annotations: + argocd.argoproj.io/sync-wave: "20" + +spec: + image: + source: ghcr.io/mnlipp/org.jdrupes.vmoperator.runner.qemu-arch:latest +# source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing +# source: docker-registry.lan.mnl.de/vmoperator/org.jdrupes.vmoperator.runner.qemu-arch:latest + pullPolicy: Always + + runnerTemplate: + update: true + + permissions: + - role: admin + may: + - "*" + + guestShutdownStops: true + + cloudInit: + metaData: {} + + pools: + - test-vms + + vm: + # state: Running + bootMenu: true + maximumCpus: 4 + currentCpus: 2 + maximumRam: 6Gi + currentRam: 4Gi + + networks: + # No bridge on TC1 + # - tap: {} + - user: {} + + disks: + - volumeClaimTemplate: + metadata: + name: system + spec: + storageClassName: ceph-rbd3slow + dataSource: + name: test-vm-system-disk-snapshot + kind: VolumeSnapshot + apiGroup: snapshot.storage.k8s.io + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 40Gi + - cdrom: + image: "" + # image: https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso + + display: + spice: + port: <%= $((5910 + number)) %> diff --git a/dev-example/test-vm.yaml b/dev-example/test-vm.yaml index 0cd820b..aa75bc3 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -5,15 +5,23 @@ metadata: name: test-vm spec: image: - repository: docker-registry.lan.mnl.de - path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing pullPolicy: Always + permissions: + - user: admin + may: + - "*" + resources: requests: cpu: 1 memory: 2Gi - + + guestShutdownStops: true + + cloudInit: {} + vm: # state: Running bootMenu: yes @@ -24,8 +32,9 @@ spec: currentCpus: 4 networks: - - tap: - mac: "00:16:3e:33:58:10" + # No bridge on test cluster + - user: {} + disks: - volumeClaimTemplate: metadata: @@ -48,3 +57,6 @@ spec: display: spice: port: 5810 + generateSecret: true + + loadBalancerService: {} diff --git a/dev-example/vmop-agent/99-vmop-agent.rules b/dev-example/vmop-agent/99-vmop-agent.rules new file mode 100644 index 0000000..4a18472 --- /dev/null +++ b/dev-example/vmop-agent/99-vmop-agent.rules @@ -0,0 +1,2 @@ +SUBSYSTEM=="virtio-ports", ATTR{name}=="org.jdrupes.vmop_agent.0", \ + TAG+="systemd" ENV{SYSTEMD_WANTS}="vmop-agent.service" diff --git a/dev-example/vmop-agent/gdm/PostLogin/Default b/dev-example/vmop-agent/gdm/PostLogin/Default new file mode 100755 index 0000000..8a70890 --- /dev/null +++ b/dev-example/vmop-agent/gdm/PostLogin/Default @@ -0,0 +1,3 @@ +#!/bin/sh + +sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent new file mode 100755 index 0000000..9f4d9e7 --- /dev/null +++ b/dev-example/vmop-agent/vmop-agent @@ -0,0 +1,146 @@ +#!/usr/bin/bash + +# Note that this script requires "jq" to be installed and a version +# of loginctl that accepts the "-j" option. + +while [ "$#" -gt 0 ]; do + case "$1" in + --path) shift; ttyPath="$1";; + --path=*) IFS='=' read -r option value <<< "$1"; ttyPath="$value";; + esac + shift +done + +ttyPath="${ttyPath:-/dev/virtio-ports/org.jdrupes.vmop_agent.0}" + +if [ ! -w "$ttyPath" ]; then + echo >&2 "Device $ttyPath not writable" + exit 1 +fi + +# Create fd for the tty in variable con +if ! exec {con}<>"$ttyPath"; then + echo >&2 "Cannot open device $ttyPath" + exit 1 +fi + +# Temporary file for logging error messages, clear tty and signal ready +temperr=$(mktemp) +clear >/dev/tty1 +echo >&${con} "220 Hello" + +# This script uses the (shared) home directory as "dictonary" for +# synchronizing the username and the uid between hosts. +# +# Every user has a directory with his username. The directory is +# owned by root to prevent changes of access rights by the user. +# The uid and gid of the directory are equal. Thus the name of the +# directory and the id from the group ownership also provide the +# association between the username and the uid. + +# Add the user with name $1 to the host's "user database". This +# may not be invoked concurrently. +createUser() { + local missing=$1 + local uid + local userHome="/home/$missing" + local createOpts="" + + # Retrieve or create the uid for the username + if [ -d "$userHome" ]; then + # If a home directory exists, use the id from the group ownership as uid + uid=$(ls -ldn "$userHome" | head -n 1 | awk '{print $4}') + createOpts="--no-create-home" + else + # Else get the maximum of all ids from the group ownership +1 + uid=$(ls -ln "/home" | tail -n +2 | awk '{print $4}' | sort | tail -1) + uid=$(( $uid + 1 )) + if [ $uid -lt 1100 ]; then + uid=1100 + fi + createOpts="--create-home" + fi + groupadd -g $uid $missing + useradd $missing -u $uid -g $uid $createOpts +} + +# Login the user, i.e. create a desktopn for the user. +doLogin() { + user=$1 + if [ "$user" = "root" ]; then + echo >&${con} "504 Won't log in root" + return + fi + + # Check if this user is already logged in on tty2 + curUser=$(loginctl -j | jq -r '.[] | select(.tty=="tty2") | .user') + if [ "$curUser" = "$user" ]; then + echo >&${con} "201 User already logged in" + return + fi + + # Terminate a running desktop (fail safe) + attemptLogout + + # Check if username is known on this host. If not, create user + uid=$(id -u ${user} 2>/dev/null) + if [ $? != 0 ]; then + ( flock 200 + createUser ${user} + ) 200>/home/.gen-uid-lock + + # This should now work, else something went wrong + uid=$(id -u ${user} 2>/dev/null) + if [ $? != 0 ]; then + echo >&${con} "451 Cannot determine uid" + return + fi + fi + + # Configure user as auto login user + sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf + sed -i '/\[daemon\]/a AutomaticLoginEnable=true\nAutomaticLogin='$user \ + /etc/gdm/custom.conf + + # Activate user + systemctl restart gdm + if [ $? -eq 0 ]; then + echo >&${con} "201 User logged in successfully" + else + echo >&${con} "451 $(tr '\n' ' ' <${temperr})" + fi +} + +# Attempt to log out a user currently using tty1. This is an intermediate +# operation that can be invoked from other operations +attemptLogout() { + sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf + systemctl stop gdm + echo >&${con} "102 Desktop stopped" +} + +# Log out any user currently using tty1. This is invoked when executing +# the logout command and therefore sends back a 2xx return code. +# Also try to restart gdm, if it is not running. +doLogout() { + attemptLogout + systemctl restart gdm + echo >&${con} "202 User logged out" +} + +while read line <&${con}; do + case $line in + "login "*) IFS=' ' read -ra args <<< "$line"; doLogin ${args[1]};; + "logout") doLogout;; + esac +done + +onExit() { + doLogout + if [ -n "$temperr" ]; then + rm -f $temperr + fi + echo >&${con} "240 Quit" +} + +trap onExit EXIT diff --git a/dev-example/vmop-agent/vmop-agent.service b/dev-example/vmop-agent/vmop-agent.service new file mode 100644 index 0000000..11c64f2 --- /dev/null +++ b/dev-example/vmop-agent/vmop-agent.service @@ -0,0 +1,15 @@ +[Unit] +Description=VM-Operator (Guest) Agent +BindsTo=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device +After=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device multi-user.target +IgnoreOnIsolate=True + +[Service] +UMask=0077 +#EnvironmentFile=/etc/sysconfig/vmop-agent +ExecStart=/usr/local/libexec/vmop-agent +Restart=always +RestartSec=0 + +[Install] +WantedBy=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device diff --git a/example/local-path/Readme.md b/example/local-path/Readme.md index 7afb948..bdba8cc 100644 --- a/example/local-path/Readme.md +++ b/example/local-path/Readme.md @@ -1,17 +1,17 @@ # Example setup -The CRD must be deployed independently. +The CRD must be deployed independently. ```sh kubectl apply -f https://github.com/mnlipp/VM-Operator/raw/main/deploy/crds/vms-crd.yaml ``` -Apart from that, the `kustomize.yaml` defines a namespace for the manager +Apart from that, the `kustomize.yaml` defines a namespace for the manager (and the VMs managed by it) and patches the repository PVC to create a small volume using local-path. -A second patch provides a new configuration file for the manager -that makes it use the local-path storage class when creating the +A second patch provides a new configuration file for the manager +that makes it use the local-path storage class when creating the small volume for a runner's data. The `kustomize.yaml` does not include the test VM. Before creating diff --git a/example/rook-ceph/Readme.md b/example/rook-ceph/Readme.md index 1d2cfc6..3756e93 100644 --- a/example/rook-ceph/Readme.md +++ b/example/rook-ceph/Readme.md @@ -1,12 +1,12 @@ # Example setup -The CRD must be deployed independently. +The CRD must be deployed independently. ```sh kubectl apply -f https://github.com/mnlipp/VM-Operator/raw/main/deploy/crds/vms-crd.yaml ``` -Apart from that, the `kustomize.yaml` defines a namespace for the manager +Apart from that, the `kustomize.yaml` defines a namespace for the manager (and the VMs managed by it) and applies patches to use `rook-cephfs` as storage class (instead of the default storage class). diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f97ebb7 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7..e644113 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8707e8b..a441313 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 79a61d4..b740cf1 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -83,10 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,10 +131,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. @@ -144,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -197,11 +198,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/gradlew.bat b/gradlew.bat index 93e3f59..25da30d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -43,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail diff --git a/misc/DejaVuSans-Bold.woff2 b/misc/DejaVuSans-Bold.woff2 deleted file mode 100644 index 373095f..0000000 Binary files a/misc/DejaVuSans-Bold.woff2 and /dev/null differ diff --git a/misc/DejaVuSans.woff2 b/misc/DejaVuSans.woff2 deleted file mode 100644 index 8437d4e..0000000 Binary files a/misc/DejaVuSans.woff2 and /dev/null differ diff --git a/misc/DejaVuSansMono-Bold.woff2 b/misc/DejaVuSansMono-Bold.woff2 deleted file mode 100644 index f2b469a..0000000 Binary files a/misc/DejaVuSansMono-Bold.woff2 and /dev/null differ diff --git a/misc/DejaVuSansMono.woff2 b/misc/DejaVuSansMono.woff2 deleted file mode 100644 index cf200e1..0000000 Binary files a/misc/DejaVuSansMono.woff2 and /dev/null differ diff --git a/misc/DejaVuSerif-Bold.woff2 b/misc/DejaVuSerif-Bold.woff2 deleted file mode 100644 index 655ac56..0000000 Binary files a/misc/DejaVuSerif-Bold.woff2 and /dev/null differ diff --git a/misc/DejaVuSerif.woff2 b/misc/DejaVuSerif.woff2 deleted file mode 100644 index 238566d..0000000 Binary files a/misc/DejaVuSerif.woff2 and /dev/null differ diff --git a/misc/javadoc-overwrites.css b/misc/javadoc-overwrites.css new file mode 100644 index 0000000..7eed81f --- /dev/null +++ b/misc/javadoc-overwrites.css @@ -0,0 +1,2 @@ +:root { --body-font-size: 16px;} +:root { --code-font-size: 16px;} diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt index bf7dd56..d5589ac 100644 --- a/misc/javadoc.bottom.txt +++ b/misc/javadoc.bottom.txt @@ -4,26 +4,33 @@ TermsPrivacy

+ + + + + \ No newline at end of file diff --git a/misc/stylesheet.css b/misc/stylesheet.css deleted file mode 100644 index e21b9b2..0000000 --- a/misc/stylesheet.css +++ /dev/null @@ -1,912 +0,0 @@ -/* - * Javadoc style sheet - */ - -@font-face { - font-family: 'DejaVu Serif'; - src: local('DejaVu Serif'), url('DejaVuSerif.woff2'); -} - -@font-face { - font-family: 'DejaVu Serif'; - font-weight: bold; - src: local('DejaVu Serif Bold'), url('DejaVuSerif-Bold.woff2'); -} - -@font-face { - font-family: 'DejaVu Sans'; - src: local('DejaVu Sans'), url('DejaVuSans.woff2'); -} - -@font-face { - font-family: 'DejaVu Sans'; - font-weight: bold; - src: local('DejaVu Sans Bold'), url('DejaVuSans-Bold.woff2'); -} - -@font-face { - font-family: 'DejaVu Sans Mono'; - src: local('DejaVu Sans Mono'), url('DejaVuSansMono.woff2'); -} - -@font-face { - font-family: 'DejaVu Sans Mono'; - font-weight: bold; - src: local('DejaVu Sans Mono Bold'), url('DejaVuSansMono-Bold.woff2'); -} - -/* - * Styles for individual HTML elements. - * - * These are styles that are specific to individual HTML elements. Changing them affects the style of a particular - * HTML element throughout the page. - */ - -body { - background-color:#ffffff; - color:#353833; - font: normal 16px/1.5 "DejaVu Serif", serif; - margin:0; - padding:0; - height:100%; - width:100%; -} -iframe { - margin:0; - padding:0; - height:100%; - width:100%; - overflow-y:scroll; - border:none; -} -a:link, a:visited { - text-decoration:none; - color:#4A6782; -} -a[href]:hover, a[href]:focus { - text-decoration:none; - color:#bb7a2a; -} -a[name] { - color:#353833; -} -pre { - font-family: "DejaVu Sans Mono", monospace; -} -h1 { - font-family: "DejaVu Sans", sans; - font-size:20px; -} -h2 { - font-family: "DejaVu Sans", sans; - font-size:18px; -} -h3 { - font-family: "DejaVu Sans", sans; - font-size:16px; -} -h4 { - font-family: "DejaVu Sans", sans; - font-size:15px; -} -h5 { - font-family: "DejaVu Sans", sans; - font-size:14px; -} -h6 { - font-family: "DejaVu Sans", sans; - font-size:13px; -} -ul { - list-style-type:disc; -} -code, tt { - font-family: "DejaVu Sans Mono", monospace; -} -:not(h1, h2, h3, h4, h5, h6) > code, -:not(h1, h2, h3, h4, h5, h6) > tt { - /* font-size:14px; */ - padding-top:4px; - margin-top:8px; - line-height:1.4em; -} -dt code { - font-family: "DejaVu Sans Mono", monospace; - font-size:14px; - padding-top:4px; -} -.summary-table dt code { - font-family: "DejaVu Sans Mono", monospace; - font-size:14px; - vertical-align:top; - padding-top:4px; -} -sup { - font-size:8px; -} - -/* - * Styles for HTML generated by javadoc. - * - * These are style classes that are used by the standard doclet to generate HTML documentation. - */ - -/* - * Styles for document title and copyright. - */ -.clear { - clear:both; - height:0; - overflow:hidden; -} -.about-language { - float:right; - padding:0 21px 8px 8px; - font-size:11px; - margin-top:-9px; - height:2.9em; -} -.legal-copy { - margin-left:.5em; -} -.tab { - background-color:#0066FF; - color:#ffffff; - padding:8px; - width:5em; - font-weight:bold; -} -/* - * Styles for navigation bar. - */ -@media screen { - .flex-box { - position:fixed; - display:flex; - flex-direction:column; - height: 100%; - width: 100%; - } - .flex-header { - flex: 0 0 auto; - } - .flex-content { - flex: 1 1 auto; - overflow-y: auto; - } -} -.top-nav { - background-color:#4D7A97; - color:#FFFFFF; - float:left; - padding:0; - width:100%; - clear:right; - min-height:2.8em; - padding-top:10px; - overflow:hidden; - font-family: "DejaVu Sans", sans; - font-size:80%; -} -.sub-nav { - background-color:#dee3e9; - float:left; - width:100%; - overflow:hidden; - font-family: "DejaVu Sans", sans; - font-size:80%; -} -.sub-nav div { - clear:left; - float:left; - padding:0 0 5px 6px; - text-transform:uppercase; -} -.sub-nav .nav-list { - padding-top:5px; -} -ul.nav-list { - display:block; - margin:0 25px 0 0; - padding:0; -} -ul.sub-nav-list { - float:left; - margin:0 25px 0 0; - padding:0; -} -ul.nav-list li { - list-style:none; - float:left; - padding: 5px 6px; - text-transform:uppercase; -} -.sub-nav .nav-list-search { - float:right; - margin:0 0 0 0; - padding:5px 6px; - clear:none; -} -.nav-list-search label { - position:relative; - right:-16px; -} -ul.sub-nav-list li { - list-style:none; - float:left; - padding-top:10px; -} -.top-nav a:link, .top-nav a:active, .top-nav a:visited { - color:#FFFFFF; - text-decoration:none; - text-transform:uppercase; -} -.top-nav a:hover { - text-decoration:none; - color:#bb7a2a; - text-transform:uppercase; -} -.nav-bar-cell1-rev { - background-color:#F8981D; - color:#253441; - margin: auto 5px; -} -.skip-nav { - position:absolute; - top:auto; - left:-9999px; - overflow:hidden; -} -/* - * Hide navigation links and search box in print layout - */ -@media print { - ul.nav-list, div.sub-nav { - display:none; - } -} -/* - * Styles for page header and footer. - */ -.title { - color:#2c4557; - margin:10px 0; -} -.sub-title { - margin:5px 0 0 0; -} -.header ul { - margin:0 0 15px 0; - padding:0; -} -.header ul li, .footer ul li { - list-style:none; - font-size:80%; -} -/* - * Styles for headings. - */ -body.class-declaration-page .summary h2, -body.class-declaration-page .details h2, -body.class-use-page h2, -body.module-declaration-page .block-list h2 { - font-style: italic; - padding:0; - margin:15px 0; -} -body.class-declaration-page .summary h3, -body.class-declaration-page .details h3, -body.class-declaration-page .summary .inherited-list h2 { - background-color:#dee3e9; - border:1px solid #d0d9e0; - margin:0 0 6px -8px; - padding:7px 5px; -} -/* - * Styles for page layout containers. - */ -main { - clear:both; - padding:10px 20px; - position:relative; -} -dl.notes > dt { - font-family: "DejaVu Sans", sans; - font-weight:bold; - margin:10px 0 0 0; - color:#4E4E4E; -} -dl.notes > dd { - margin:5px 10px 10px 0; -} -dl.name-value > dt { - margin-left:1px; - /* font-size:1.1em; */ - display:inline; - font-weight:bold; -} -dl.name-value > dd { - margin:0 0 0 1px; - /* font-size:1.1em; */ - display:inline; -} -/* - * Styles for lists. - */ -li.circle { - list-style:circle; -} -ul.horizontal li { - display:inline; - /* font-size:0.9em; */ -} -div.inheritance { - margin:0; - padding:0; -} -div.inheritance div.inheritance { - margin-left:2em; -} -ul.block-list, -ul.details-list, -ul.member-list, -ul.summary-list { - margin:10px 0 10px 0; - padding:0; -} -ul.block-list > li, -ul.details-list > li, -ul.member-list > li, -ul.summary-list > li { - list-style:none; - margin-bottom:15px; - line-height:1.4; -} -.summary-table dl, .summary-table dl dt, .summary-table dl dd { - margin-top:0; - margin-bottom:1px; -} -ul.see-list, ul.see-list-long { - padding-left: 0; - list-style: none; -} -ul.see-list li { - display: inline; -} -ul.see-list li:not(:last-child):after, -ul.see-list-long li:not(:last-child):after { - content: ", "; - white-space: pre-wrap; -} -/* - * Styles for tables. - */ -.summary-table, .details-table { - width:100%; - border-spacing:0; - border-left:1px solid #EEE; - border-right:1px solid #EEE; - border-bottom:1px solid #EEE; - padding:0; -} -.caption { - position:relative; - text-align:left; - background-repeat:no-repeat; - color:#253441; - font-weight:bold; - clear:none; - overflow:hidden; - padding:0; - padding-top:10px; - padding-left:1px; - margin:0; - white-space:pre; - font-family: 'DejaVu Sans'; -} -.caption a:link, .caption a:visited { - color:#1f389c; -} -.caption a:hover, -.caption a:active { - color:#FFFFFF; -} -.caption span { - white-space:nowrap; - padding-top:5px; - padding-left:12px; - padding-right:12px; - padding-bottom:7px; - display:inline-block; - float:left; - background-color:#F8981D; - border: none; - height:16px; -} -div.table-tabs { - padding:10px 0 0 1px; - margin:0; -} -div.table-tabs > button { - border: none; - cursor: pointer; - padding: 5px 12px 7px 12px; - font-weight: bold; - margin-right: 3px; -} -div.table-tabs > button.active-table-tab { - background: #F8981D; - color: #253441; -} -div.table-tabs > button.table-tab { - background: #4D7A97; - color: #FFFFFF; -} -.two-column-summary { - display: grid; - grid-template-columns: minmax(15%, max-content) minmax(15%, auto); -} -.three-column-summary { - display: grid; - grid-template-columns: minmax(10%, max-content) minmax(15%, max-content) minmax(15%, auto); -} -#method-summary-table .three-column-summary { - grid-template-columns: minmax(10%, 20%) minmax(15%, max-content) minmax(15%, auto); -} -.four-column-summary { - display: grid; - grid-template-columns: minmax(10%, max-content) minmax(10%, max-content) minmax(10%, max-content) minmax(10%, auto); -} -@media screen and (max-width: 600px) { - .two-column-summary { - display: grid; - grid-template-columns: 1fr; - } -} -@media screen and (max-width: 800px) { - .three-column-summary { - display: grid; - grid-template-columns: minmax(10%, max-content) minmax(25%, auto); - } - .three-column-summary .col-last { - grid-column-end: span 2; - } -} -@media screen and (max-width: 1000px) { - .four-column-summary { - display: grid; - grid-template-columns: minmax(15%, max-content) minmax(15%, auto); - } -} -.summary-table > div, .details-table > div { - text-align:left; - padding: 8px 3px 3px 7px; -} -.col-first, .col-second, .col-last, .col-constructor-name, .col-summary-item-name { - vertical-align:top; - padding-right:0; - padding-top:8px; - padding-bottom:3px; -} -.table-header { - background:#dee3e9; - font-family: 'DejaVu Sans'; - font-weight: bold; -} -/* -.col-first, .col-first { - font-size:13px; -} -.col-second, .col-second, .col-last, .col-constructor-name, .col-summary-item-name, .col-last { - font-size:13px; -} -*/ -.col-first, .col-second, .col-constructor-name { - vertical-align:top; - overflow: auto; -} -.col-last { - white-space:normal; -} -/* -.col-first a:link, .col-first a:visited, -.col-second a:link, .col-second a:visited, -.col-first a:link, .col-first a:visited, -.col-second a:link, .col-second a:visited, -.col-constructor-name a:link, .col-constructor-name a:visited, -.col-summary-item-name a:link, .col-summary-item-name a:visited, -.constant-values-container a:link, .constant-values-container a:visited, -.all-classes-container a:link, .all-classes-container a:visited, -.all-packages-container a:link, .all-packages-container a:visited { - font-weight:bold; -} -*/ -.table-sub-heading-color { - background-color:#EEEEFF; -} -.even-row-color, .even-row-color .table-header { - background-color:#FFFFFF; -} -.odd-row-color, .odd-row-color .table-header { - background-color:#EEEEEF; -} -/* - * Styles for contents. - */ -.deprecated-content { - margin:0; - padding:10px 0; -} -/* -div.block { - font-size:14px; - font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif; -} -*/ -.col-last div { - padding-top:0; -} -.col-last a { - padding-bottom:3px; -} -.module-signature, -.package-signature, -.type-signature, -.member-signature { - font-family: "DejaVu Sans Mono", monospace; - /* font-size:14px; */ - margin:14px 0; - white-space: pre-wrap; -} -.module-signature, -.package-signature, -.type-signature { - margin-top: 0; -} -.member-signature .type-parameters-long, -.member-signature .parameters, -.member-signature .exceptions { - display: inline-block; - vertical-align: top; - white-space: pre; -} -.member-signature .type-parameters { - white-space: normal; -} -/* - * Styles for formatting effect. - */ -.source-line-no { - color:green; - padding:0 30px 0 0; -} -h1.hidden { - visibility:hidden; - overflow:hidden; - /* font-size:10px; */ -} -.block { - display:block; - margin:0 10px 5px 0; - color:#474747; -} -.deprecated-label, .descfrm-type-label, .implementation-label, .member-name-label, .member-name-link, -.module-label-in-package, .module-label-in-type, .override-specify-label, .package-label-in-type, -.package-hierarchy-label, .type-name-label, .type-name-link, .search-tag-link, .preview-label { - font-family: "DejaVu Sans", sans; - font-weight:bold; -} -.sub-title, .inheritance, .all-packages-table-tab1.col-first, - .summary-table .col-first { - font-family: "DejaVu Sans", sans; -} -.deprecation-comment, .help-footnote, .preview-comment { - font-style:italic; -} -.deprecation-block { - /* font-size:14px; */ - font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif; - border-style:solid; - border-width:thin; - border-radius:10px; - padding:10px; - margin-bottom:10px; - margin-right:10px; - display:inline-block; -} -.preview-block { - /* font-size:14px; */ - font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif; - border-style:solid; - border-width:thin; - border-radius:10px; - padding:10px; - margin-bottom:10px; - margin-right:10px; - display:inline-block; -} -div.block div.deprecation-comment { - font-style:normal; -} -/* - * Styles specific to HTML5 elements. - */ -main, nav, header, footer, section { - display:block; -} -/* - * Styles for javadoc search. - */ -.ui-autocomplete-category { - font-weight:bold; - /* font-size:15px; */ - padding:7px 0 7px 3px; - background-color:#4D7A97; - color:#FFFFFF; -} -.result-item { - /* font-size:13px; */ -} -.ui-autocomplete { - max-height:85%; - max-width:65%; - overflow-y:scroll; - overflow-x:scroll; - white-space:nowrap; - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); -} -ul.ui-autocomplete { - position:fixed; - z-index:999999; -} -ul.ui-autocomplete li { - float:left; - clear:both; - width:100%; -} -.result-highlight { - font-weight:bold; -} -#search-input { - background-image:url('resources/glass.png'); - background-size:13px; - background-repeat:no-repeat; - background-position:2px 3px; - padding-left:20px; - position:relative; - right:-18px; - width:400px; -} -#reset-button { - background-color: rgb(255,255,255); - background-image:url('resources/x.png'); - background-position:center; - background-repeat:no-repeat; - background-size:12px; - border:0 none; - width:16px; - height:16px; - position:relative; - left:-4px; - top:-4px; - font-size:0px; -} -.watermark { - color:#545454; -} -.search-tag-desc-result { - font-style:italic; - /* font-size:11px; */ -} -.search-tag-holder-result { - font-style:italic; - /* font-size:12px; */ -} -.search-tag-result:target { - background-color:yellow; -} -.module-graph span { - display:none; - position:absolute; -} -.module-graph:hover span { - display:block; - margin: -100px 0 0 100px; - z-index: 1; -} -.inherited-list { - margin: 10px 0 10px 0; -} -section.class-description { - line-height: 1.4; -} -.summary section[class$="-summary"], .details section[class$="-details"], -.class-uses .detail, .serialized-class-details { - padding: 0px 20px 5px 10px; - border: 1px solid #ededed; - background-color: #f8f8f8; -} -.inherited-list, section[class$="-details"] .detail { - padding:0 0 5px 8px; - background-color:#ffffff; - border:none; -} -.vertical-separator { - padding: 0 5px; -} -ul.help-section-list { - margin: 0; -} -ul.help-subtoc > li { - display: inline-block; - padding-right: 5px; - /* font-size: smaller; */ -} -ul.help-subtoc > li::before { - content: "\2022" ; - padding-right:2px; -} -span.help-note { - font-style: italic; -} -/* - * Indicator icon for external links. - */ -main a[href*="://"]::after { - content:""; - display:inline-block; - background-image:url('data:image/svg+xml; utf8, \ - \ - \ - '); - background-size:100% 100%; - width:7px; - height:7px; - margin-left:2px; - margin-bottom:4px; -} -main a[href*="://"]:hover::after, -main a[href*="://"]:focus::after { - background-image:url('data:image/svg+xml; utf8, \ - \ - \ - '); -} - -/* - * Styles for user-provided tables. - * - * borderless: - * No borders, vertical margins, styled caption. - * This style is provided for use with existing doc comments. - * In general, borderless tables should not be used for layout purposes. - * - * plain: - * Plain borders around table and cells, vertical margins, styled caption. - * Best for small tables or for complex tables for tables with cells that span - * rows and columns, when the "striped" style does not work well. - * - * striped: - * Borders around the table and vertical borders between cells, striped rows, - * vertical margins, styled caption. - * Best for tables that have a header row, and a body containing a series of simple rows. - */ - -table.borderless, -table.plain, -table.striped { - margin-top: 10px; - margin-bottom: 10px; -} -table.borderless > caption, -table.plain > caption, -table.striped > caption { - font-weight: bold; - /* font-size: smaller; */ -} -table.borderless th, table.borderless td, -table.plain th, table.plain td, -table.striped th, table.striped td { - padding: 2px 5px; -} -table.borderless, -table.borderless > thead > tr > th, table.borderless > tbody > tr > th, table.borderless > tr > th, -table.borderless > thead > tr > td, table.borderless > tbody > tr > td, table.borderless > tr > td { - border: none; -} -table.borderless > thead > tr, table.borderless > tbody > tr, table.borderless > tr { - background-color: transparent; -} -table.plain { - border-collapse: collapse; - border: 1px solid black; -} -table.plain > thead > tr, table.plain > tbody tr, table.plain > tr { - background-color: transparent; -} -table.plain > thead > tr > th, table.plain > tbody > tr > th, table.plain > tr > th, -table.plain > thead > tr > td, table.plain > tbody > tr > td, table.plain > tr > td { - border: 1px solid black; -} -table.striped { - border-collapse: collapse; - border: 1px solid black; -} -table.striped > thead { - background-color: #E3E3E3; -} -table.striped > thead > tr > th, table.striped > thead > tr > td { - border: 1px solid black; -} -table.striped > tbody > tr:nth-child(even) { - background-color: #EEE -} -table.striped > tbody > tr:nth-child(odd) { - background-color: #FFF -} -table.striped > tbody > tr > th, table.striped > tbody > tr > td { - border-left: 1px solid black; - border-right: 1px solid black; -} -table.striped > tbody > tr > th { - font-weight: normal; -} -/** - * Tweak font sizes and paddings for small screens. - */ -@media screen and (max-width: 1050px) { - #search-input { - width: 300px; - } -} -@media screen and (max-width: 800px) { - #search-input { - width: 200px; - } - .top-nav, - .bottom-nav { - font-size: 80%; - padding-top: 6px; - } - .sub-nav { - font-size: 80%; - } - .about-language { - padding-right: 16px; - } - ul.nav-list li, - .sub-nav .nav-list-search { - padding: 6px; - } - ul.sub-nav-list li { - padding-top: 5px; - } - main { - padding: 10px; - } - .summary section[class$="-summary"], .details section[class$="-details"], - .class-uses .detail, .serialized-class-details { - padding: 0 8px 5px 8px; - } - body { - -webkit-text-size-adjust: none; - } -} -@media screen and (max-width: 500px) { - #search-input { - width: 150px; - } - .top-nav, - .bottom-nav { - font-size: 80%; - } - .sub-nav { - font-size: 80%; - } - .about-language { - font-size: 80%; - padding-right: 12px; - } -} diff --git a/org.jdrupes.vmoperator.common/.eclipse-pmd b/org.jdrupes.vmoperator.common/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.common/.eclipse-pmd +++ b/org.jdrupes.vmoperator.common/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs b/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs new file mode 100644 index 0000000..8b8b906 --- /dev/null +++ b/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs @@ -0,0 +1,7 @@ +add_header=true +eclipse.preferences.version=1 +header_text=/*\n * VM-Operator\n * Copyright (C) 2024 Michael N. Lipp\n * \n * This program is free software\: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see .\n */ +project_specific_settings=true +visibility_package=false +visibility_private=false +visibility_protected=false diff --git a/org.jdrupes.vmoperator.common/build.gradle b/org.jdrupes.vmoperator.common/build.gradle index ed082a1..e72cb14 100644 --- a/org.jdrupes.vmoperator.common/build.gradle +++ b/org.jdrupes.vmoperator.common/build.gradle @@ -10,5 +10,8 @@ plugins { dependencies { api project(':org.jdrupes.vmoperator.util') - api 'io.kubernetes:client-java:[18.0.0,19)' + api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' + api 'io.kubernetes:client-java:[19.0.0,20.0.0)' + api 'org.yaml:snakeyaml' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]' } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index 3ebe29d..b9de69f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java @@ -21,6 +21,7 @@ package org.jdrupes.vmoperator.common; /** * Some constants. */ +@SuppressWarnings("PMD.DataClass") public class Constants { /** The Constant APP_NAME. */ @@ -29,9 +30,98 @@ public class Constants { /** The Constant VM_OP_NAME. */ public static final String VM_OP_NAME = "vm-operator"; - /** The Constant VM_OP_GROUP. */ - public static final String VM_OP_GROUP = "vmoperator.jdrupes.org"; + /** + * Constants related to the CRD. + */ + @SuppressWarnings("PMD.ShortClassName") + public static class Crd { + /** The Constant GROUP. */ + public static final String GROUP = "vmoperator.jdrupes.org"; - /** The Constant VM_OP_KIND_VM. */ - public static final String VM_OP_KIND_VM = "VirtualMachine"; + /** The Constant KIND_VM. */ + public static final String KIND_VM = "VirtualMachine"; + + /** The Constant KIND_VM_POOL. */ + public static final String KIND_VM_POOL = "VmPool"; + } + + /** + * Status related constants. + */ + public static class Status { + /** The Constant RUNNER_VERSION. */ + public static final String RUNNER_VERSION = "runnerVersion"; + + /** The Constant CPUS. */ + public static final String CPUS = "cpus"; + + /** The Constant RAM. */ + public static final String RAM = "ram"; + + /** The Constant OSINFO. */ + public static final String OSINFO = "osinfo"; + + /** The Constant DISPLAY_PASSWORD_SERIAL. */ + public static final String DISPLAY_PASSWORD_SERIAL + = "displayPasswordSerial"; + + /** The Constant LOGGED_IN_USER. */ + public static final String LOGGED_IN_USER = "loggedInUser"; + + /** The Constant CONSOLE_CLIENT. */ + public static final String CONSOLE_CLIENT = "consoleClient"; + + /** The Constant CONSOLE_USER. */ + public static final String CONSOLE_USER = "consoleUser"; + + /** The Constant ASSIGNMENT. */ + public static final String ASSIGNMENT = "assignment"; + + /** + * Conditions used in Status. + */ + public static class Condition { + /** The Constant COND_RUNNING. */ + public static final String RUNNING = "Running"; + + /** The Constant COND_BOOTED. */ + public static final String BOOTED = "Booted"; + + /** The Constant COND_VMOP_AGENT. */ + public static final String VMOP_AGENT = "VmopAgentConnected"; + + /** The Constant COND_USER_LOGGED_IN. */ + public static final String USER_LOGGED_IN = "UserLoggedIn"; + + /** The Constant COND_CONSOLE. */ + public static final String CONSOLE_CONNECTED = "ConsoleConnected"; + + /** + * Reasons used in conditions. + */ + public static class Reason { + /** The Constant NOT_REQUESTED. */ + public static final String NOT_REQUESTED = "NotRequested"; + + /** The Constant USER_LOGGED_IN. */ + public static final String LOGGED_IN = "LoggedIn"; + } + } + } + + /** + * DisplaySecret related constants. + */ + public static class DisplaySecret { + + /** The Constant NAME. */ + public static final String NAME = "display-secret"; + + /** The Constant PASSWORD. */ + public static final String PASSWORD = "display-password"; + + /** The Constant EXPIRY. */ + public static final String EXPIRY = "password-expiry"; + + } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java index 47b7208..68f52eb 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java @@ -32,13 +32,11 @@ import java.util.regex.Pattern; public class Convertions { @SuppressWarnings({ "PMD.UseConcurrentHashMap", - "PMD.FieldNamingConventions", "PMD.VariableNamingConventions" }) + "PMD.FieldNamingConventions" }) private static final Map unitMap = new HashMap<>(); - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final List> unitMappings; - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final Pattern memorySize = Pattern.compile("^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*"); @@ -69,7 +67,6 @@ public class Convertions { * @param amount the amount * @return the big integer */ - @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public static BigInteger parseMemory(Object amount) { if (amount == null) { return (BigInteger) amount; diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java new file mode 100644 index 0000000..d21eed4 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java @@ -0,0 +1,197 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.InstanceCreator; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import io.kubernetes.client.openapi.ApiClient; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Type; + +/** + * A factory for creating objects. + * + * @param the generic type + * @param the generic type + */ +public class DynamicTypeAdapterFactory> implements TypeAdapterFactory { + + private final Class objectClass; + private final Class objectListClass; + + /** + * Make sure that this adapter is registered. + * + * @param client the client + */ + public void register(ApiClient client) { + if (!ModelCreator.class + .equals(client.getJSON().getGson().getAdapter(objectClass) + .getClass()) + || !ModelsCreator.class.equals(client.getJSON().getGson() + .getAdapter(objectListClass).getClass())) { + Gson gson = client.getJSON().getGson(); + client.getJSON().setGson(gson.newBuilder() + .registerTypeAdapterFactory(this).create()); + } + } + + /** + * Instantiates a new generic type adapter factory. + * + * @param objectClass the object class + * @param objectListClass the object list class + */ + public DynamicTypeAdapterFactory(Class objectClass, + Class objectListClass) { + this.objectClass = objectClass; + this.objectListClass = objectListClass; + } + + /** + * Creates a type adapter for the given type. + * + * @param the generic type + * @param gson the gson + * @param typeToken the type token + * @return the type adapter or null if the type is not handles by + * this factory + */ + @SuppressWarnings("unchecked") + @Override + public TypeAdapter create(Gson gson, TypeToken typeToken) { + if (TypeToken.get(objectClass).equals(typeToken)) { + return (TypeAdapter) new ModelCreator(gson); + } + if (TypeToken.get(objectListClass).equals(typeToken)) { + return (TypeAdapter) new ModelsCreator(gson); + } + return null; + } + + /** + * The Class ModelCreator. + */ + private class ModelCreator extends TypeAdapter + implements InstanceCreator { + private final Gson delegate; + + /** + * Instantiates a new object state creator. + * + * @param delegate the delegate + */ + public ModelCreator(Gson delegate) { + this.delegate = delegate; + } + + @Override + public O createInstance(Type type) { + try { + return objectClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, null); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + + @Override + public void write(JsonWriter jsonWriter, O state) + throws IOException { + jsonWriter.jsonValue(delegate.toJson(state.data())); + } + + @Override + public O read(JsonReader jsonReader) + throws IOException { + try { + return objectClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, + delegate.fromJson(jsonReader, JsonObject.class)); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + } + + /** + * The Class ModelsCreator. + */ + private class ModelsCreator extends TypeAdapter + implements InstanceCreator { + + private final Gson delegate; + + /** + * Instantiates a new object states creator. + * + * @param delegate the delegate + */ + public ModelsCreator(Gson delegate) { + this.delegate = delegate; + } + + @Override + public L createInstance(Type type) { + try { + return objectListClass + .getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, null); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + + @Override + public void write(JsonWriter jsonWriter, L states) + throws IOException { + jsonWriter.jsonValue(delegate.toJson(states.data())); + } + + @Override + public L read(JsonReader jsonReader) + throws IOException { + try { + return objectListClass + .getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, + delegate.fromJson(jsonReader, JsonObject.class)); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java index 5a87ecd..3870337 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2024 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,168 +18,141 @@ package org.jdrupes.vmoperator.common; +import com.google.gson.JsonObject; +import io.kubernetes.client.Discovery; +import io.kubernetes.client.Discovery.APIResource; import io.kubernetes.client.common.KubernetesListObject; import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.common.KubernetesType; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.apis.ApisApi; -import io.kubernetes.client.openapi.apis.CustomObjectsApi; -import io.kubernetes.client.openapi.models.V1APIGroup; -import io.kubernetes.client.openapi.models.V1ConfigMap; -import io.kubernetes.client.openapi.models.V1ConfigMapList; -import io.kubernetes.client.openapi.models.V1GroupVersionForDiscovery; +import io.kubernetes.client.openapi.apis.EventsV1Api; +import io.kubernetes.client.openapi.models.EventsV1Event; import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.openapi.models.V1PersistentVolumeClaim; -import io.kubernetes.client.openapi.models.V1PersistentVolumeClaimList; -import io.kubernetes.client.openapi.models.V1Pod; -import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.openapi.models.V1ObjectReference; +import io.kubernetes.client.util.Strings; import io.kubernetes.client.util.generic.GenericKubernetesApi; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.options.DeleteOptions; +import io.kubernetes.client.util.generic.KubernetesApiResponse; import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.time.OffsetDateTime; +import java.util.Map; import java.util.Optional; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Helpers for K8s API. */ -@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass", - "PMD.DataflowAnomalyAnalysis" }) +@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass" }) public class K8s { /** - * Given a groupVersion, returns only the version. - * - * @param groupVersion the group version - * @return the string - */ - public static String version(String groupVersion) { - return groupVersion.substring(groupVersion.lastIndexOf('/') + 1); - } - - /** - * Get PVC API. - * - * @param client the client - * @return the generic kubernetes api - */ - public static GenericKubernetesApi pvcApi(ApiClient client) { - return new GenericKubernetesApi<>(V1PersistentVolumeClaim.class, - V1PersistentVolumeClaimList.class, "", "v1", - "persistentvolumeclaims", client); - } - - /** - * Get config map API. - * - * @param client the client - * @return the generic kubernetes api - */ - public static GenericKubernetesApi cmApi(ApiClient client) { - return new GenericKubernetesApi<>(V1ConfigMap.class, - V1ConfigMapList.class, "", "v1", "configmaps", client); - } - - /** - * Get pod API. - * - * @param client the client - * @return the generic kubernetes api - */ - public static GenericKubernetesApi - podApi(ApiClient client) { - return new GenericKubernetesApi<>(V1Pod.class, V1PodList.class, "", - "v1", "pods", client); - } - - /** - * Get the API for a custom resource. - * - * @param client the client - * @param group the group - * @param kind the kind - * @param namespace the namespace - * @param name the name - * @return the dynamic kubernetes api - * @throws ApiException the api exception - */ - @SuppressWarnings("PMD.UseObjectForClearerAPI") - public static Optional crApi(ApiClient client, - String group, String kind, String namespace, String name) - throws ApiException { - var apis = new ApisApi(client).getAPIVersions(); - var crdVersions = apis.getGroups().stream() - .filter(g -> g.getName().equals(group)).findFirst() - .map(V1APIGroup::getVersions).stream().flatMap(l -> l.stream()) - .map(V1GroupVersionForDiscovery::getVersion).toList(); - var coa = new CustomObjectsApi(client); - for (var crdVersion : crdVersions) { - var crdApiRes = coa.getAPIResources(group, crdVersion) - .getResources().stream().filter(r -> kind.equals(r.getKind())) - .findFirst(); - if (crdApiRes.isEmpty()) { - continue; - } - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var crApi = new DynamicKubernetesApi(group, - crdVersion, crdApiRes.get().getName(), client); - var customResource = crApi.get(namespace, name); - if (customResource.isSuccess()) { - return Optional.of(crApi); - } - } - return Optional.empty(); - } - - /** - * Get an object from its metadata. + * Returns the result from an API call as {@link Optional} if the + * call was successful. Returns an empty `Optional` if the status + * code is 404 (not found). Else throws an exception. * * @param the generic type - * @param the generic type - * @param api the api - * @param meta the meta - * @return the object + * @param response the response + * @return the optional + * @throws ApiException the API exception */ - public static - Optional - get(GenericKubernetesApi api, V1ObjectMeta meta) { - var response = api.get(meta.getNamespace(), meta.getName()); + public static Optional + optional(KubernetesApiResponse response) throws ApiException { if (response.isSuccess()) { return Optional.of(response.getObject()); } + if (response.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { + return Optional.empty(); + } + response.throwsApiException(); + // Never reached return Optional.empty(); } /** - * Delete an object. + * Returns a new context with the given version as preferred version. * - * @param the generic type - * @param the generic type - * @param api the api - * @param object the object + * @param context the context + * @param version the version + * @return the API resource */ - public static - void delete(GenericKubernetesApi api, T object) - throws ApiException { - api.delete(object.getMetadata().getNamespace(), - object.getMetadata().getName()).throwsApiException(); + public static APIResource preferred(APIResource context, String version) { + assert context.getVersions().contains(version); + return new APIResource(context.getGroup(), + context.getVersions(), version, context.getKind(), + context.getNamespaced(), context.getResourcePlural(), + context.getResourceSingular()); } /** - * Delete an object. + * Return a string representation of the context (API resource). * - * @param the generic type - * @param the generic type - * @param api the api - * @param object the object + * @param context the context + * @return the string */ - public static - void delete(GenericKubernetesApi api, T object, - DeleteOptions options) throws ApiException { - api.delete(object.getMetadata().getNamespace(), - object.getMetadata().getName(), options).throwsApiException(); + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public static String toString(APIResource context) { + return (Strings.isNullOrEmpty(context.getGroup()) ? "" + : context.getGroup() + "/") + + context.getPreferredVersion().toUpperCase() + + context.getKind(); + } + + /** + * Convert Yaml to Json. + * + * @param client the client + * @param yaml the yaml + * @return the json element + */ + public static JsonObject yamlToJson(ApiClient client, Reader yaml) { + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + Map yamlData + = new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml); + + // There's no short-cut from Java (collections) to Gson + var gson = client.getJSON().getGson(); + var jsonText = gson.toJson(yamlData); + return gson.fromJson(jsonText, JsonObject.class); + } + + /** + * Lookup the specified API resource. If the version is `null` or + * empty, the preferred version in the result is the default + * returned from the server. + * + * @param client the client + * @param group the group + * @param version the version + * @param kind the kind + * @return the optional + * @throws ApiException the api exception + */ + public static Optional context(ApiClient client, + String group, String version, String kind) throws ApiException { + var apiMatch = new Discovery(client).findAll().stream() + .filter(r -> r.getGroup().equals(group) && r.getKind().equals(kind) + && (Strings.isNullOrEmpty(version) + || r.getVersions().contains(version))) + .findFirst(); + if (apiMatch.isEmpty()) { + return Optional.empty(); + } + var apiRes = apiMatch.get(); + if (!Strings.isNullOrEmpty(version)) { + if (!apiRes.getVersions().contains(version)) { + return Optional.empty(); + } + apiRes = new APIResource(apiRes.getGroup(), apiRes.getVersions(), + version, apiRes.getKind(), apiRes.getNamespaced(), + apiRes.getResourcePlural(), apiRes.getResourceSingular()); + } + return Optional.of(apiRes); } /** @@ -190,8 +163,10 @@ public class K8s { * @param api the api * @param existing the existing * @param update the update + * @return the t * @throws ApiException the api exception */ + @SuppressWarnings("PMD.GenericsNaming") public static T apply(GenericKubernetesApi api, T existing, String update) throws ApiException { @@ -204,4 +179,71 @@ public class K8s { return response.getObject(); } + /** + * Create an object reference. + * + * @param object the object + * @return the v 1 object reference + */ + public static V1ObjectReference + objectReference(KubernetesObject object) { + return new V1ObjectReference().apiVersion(object.getApiVersion()) + .kind(object.getKind()) + .namespace(object.getMetadata().getNamespace()) + .name(object.getMetadata().getName()) + .resourceVersion(object.getMetadata().getResourceVersion()) + .uid(object.getMetadata().getUid()); + } + + /** + * Creates an event related to the object, adding reasonable defaults. + * + * * If `kind` is not set, it is set to "Event". + * * If `metadata.namespace` is not set, it is set + * to the object's namespace. + * * If neither `metadata.name` nor `matadata.generateName` are set, + * set `generateName` to the object's name with a dash appended. + * * If `reportingInstance` is not set, set it to the object's name. + * * If `eventTime` is not set, set it to now. + * * If `type` is not set, set it to "Normal" + * * If `regarding` is not set, set it to the given object. + * + * @param client the client + * @param object the object + * @param event the event + * @throws ApiException the api exception + */ + @SuppressWarnings("PMD.NPathComplexity") + public static void createEvent(ApiClient client, + KubernetesObject object, EventsV1Event event) + throws ApiException { + if (Strings.isNullOrEmpty(event.getKind())) { + event.kind("Event"); + } + if (event.getMetadata() == null) { + event.metadata(new V1ObjectMeta()); + } + if (Strings.isNullOrEmpty(event.getMetadata().getNamespace())) { + event.getMetadata().namespace(object.getMetadata().getNamespace()); + } + if (Strings.isNullOrEmpty(event.getMetadata().getName()) + && Strings.isNullOrEmpty(event.getMetadata().getGenerateName())) { + event.getMetadata() + .generateName(object.getMetadata().getName() + "-"); + } + if (Strings.isNullOrEmpty(event.getReportingInstance())) { + event.reportingInstance(object.getMetadata().getName()); + } + if (event.getEventTime() == null) { + event.eventTime(OffsetDateTime.now()); + } + if (Strings.isNullOrEmpty(event.getType())) { + event.type("Normal"); + } + if (event.getRegarding() == null) { + event.regarding(objectReference(object)); + } + new EventsV1Api(client).createNamespacedEvent( + object.getMetadata().getNamespace(), event, null, null, null, null); + } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java new file mode 100644 index 0000000..272da2b --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java @@ -0,0 +1,954 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.openapi.ApiCallback; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.ApiResponse; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.Pair; +import io.kubernetes.client.openapi.auth.Authentication; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.text.DateFormat; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import javax.net.ssl.KeyManager; +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * A client with some additional properties. + */ +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods", + "checkstyle:LineLength", "PMD.CouplingBetweenObjects", "PMD.GodClass" }) +public class K8sClient extends ApiClient { + + private ApiClient apiClient; + private PatchOptions defaultPatchOptions; + + /** + * Instantiates a new client. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + public K8sClient() throws IOException { + defaultPatchOptions = new PatchOptions(); + defaultPatchOptions.setFieldManager("kubernetes-java-kubectl-apply"); + } + + private ApiClient apiClient() { + if (apiClient == null) { + try { + apiClient = ClientBuilder.standard().build(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + return apiClient; + } + + /** + * Gets the default patch options. + * + * @return the defaultPatchOptions + */ + public PatchOptions defaultPatchOptions() { + return defaultPatchOptions; + } + + /** + * Changes the default patch options. + * + * @param patchOptions the patch options + * @return the client + */ + public K8sClient with(PatchOptions patchOptions) { + defaultPatchOptions = patchOptions; + return this; + } + + /** + * Gets the base path. + * + * @return the base path + * @see ApiClient#getBasePath() + */ + @Override + public String getBasePath() { + return apiClient().getBasePath(); + } + + /** + * Sets the base path. + * + * @param basePath the base path + * @return the api client + * @see ApiClient#setBasePath(java.lang.String) + */ + @Override + public ApiClient setBasePath(String basePath) { + return apiClient().setBasePath(basePath); + } + + /** + * Gets the http client. + * + * @return the http client + * @see ApiClient#getHttpClient() + */ + @Override + public OkHttpClient getHttpClient() { + return apiClient().getHttpClient(); + } + + /** + * Sets the http client. + * + * @param newHttpClient the new http client + * @return the api client + * @see ApiClient#setHttpClient(okhttp3.OkHttpClient) + */ + @Override + public ApiClient setHttpClient(OkHttpClient newHttpClient) { + return apiClient().setHttpClient(newHttpClient); + } + + /** + * Gets the json. + * + * @return the json + * @see ApiClient#getJSON() + */ + @SuppressWarnings("abbreviationAsWordInName") + @Override + public JSON getJSON() { + return apiClient().getJSON(); + } + + /** + * Sets the JSON. + * + * @param json the json + * @return the api client + * @see ApiClient#setJSON(io.kubernetes.client.openapi.JSON) + */ + @SuppressWarnings("abbreviationAsWordInName") + @Override + public ApiClient setJSON(JSON json) { + return apiClient().setJSON(json); + } + + /** + * Checks if is verifying ssl. + * + * @return true, if is verifying ssl + * @see ApiClient#isVerifyingSsl() + */ + @Override + public boolean isVerifyingSsl() { + return apiClient().isVerifyingSsl(); + } + + /** + * Sets the verifying ssl. + * + * @param verifyingSsl the verifying ssl + * @return the api client + * @see ApiClient#setVerifyingSsl(boolean) + */ + @Override + public ApiClient setVerifyingSsl(boolean verifyingSsl) { + return apiClient().setVerifyingSsl(verifyingSsl); + } + + /** + * Gets the ssl ca cert. + * + * @return the ssl ca cert + * @see ApiClient#getSslCaCert() + */ + @Override + public InputStream getSslCaCert() { + return apiClient().getSslCaCert(); + } + + /** + * Sets the ssl ca cert. + * + * @param sslCaCert the ssl ca cert + * @return the api client + * @see ApiClient#setSslCaCert(java.io.InputStream) + */ + @Override + public ApiClient setSslCaCert(InputStream sslCaCert) { + return apiClient().setSslCaCert(sslCaCert); + } + + /** + * Gets the key managers. + * + * @return the key managers + * @see ApiClient#getKeyManagers() + */ + @Override + public KeyManager[] getKeyManagers() { + return apiClient().getKeyManagers(); + } + + /** + * Sets the key managers. + * + * @param managers the managers + * @return the api client + * @see ApiClient#setKeyManagers(javax.net.ssl.KeyManager[]) + */ + @Override + public ApiClient setKeyManagers(KeyManager[] managers) { + return apiClient().setKeyManagers(managers); + } + + /** + * Gets the date format. + * + * @return the date format + * @see ApiClient#getDateFormat() + */ + @Override + public DateFormat getDateFormat() { + return apiClient().getDateFormat(); + } + + /** + * Sets the date format. + * + * @param dateFormat the date format + * @return the api client + * @see ApiClient#setDateFormat(java.text.DateFormat) + */ + @Override + public ApiClient setDateFormat(DateFormat dateFormat) { + return apiClient().setDateFormat(dateFormat); + } + + /** + * Sets the sql date format. + * + * @param dateFormat the date format + * @return the api client + * @see ApiClient#setSqlDateFormat(java.text.DateFormat) + */ + @Override + public ApiClient setSqlDateFormat(DateFormat dateFormat) { + return apiClient().setSqlDateFormat(dateFormat); + } + + /** + * Sets the offset date time format. + * + * @param dateFormat the date format + * @return the api client + * @see ApiClient#setOffsetDateTimeFormat(java.time.format.DateTimeFormatter) + */ + @Override + public ApiClient setOffsetDateTimeFormat(DateTimeFormatter dateFormat) { + return apiClient().setOffsetDateTimeFormat(dateFormat); + } + + /** + * Sets the local date format. + * + * @param dateFormat the date format + * @return the api client + * @see ApiClient#setLocalDateFormat(java.time.format.DateTimeFormatter) + */ + @Override + public ApiClient setLocalDateFormat(DateTimeFormatter dateFormat) { + return apiClient().setLocalDateFormat(dateFormat); + } + + /** + * Sets the lenient on json. + * + * @param lenientOnJson the lenient on json + * @return the api client + * @see ApiClient#setLenientOnJson(boolean) + */ + @Override + public ApiClient setLenientOnJson(boolean lenientOnJson) { + return apiClient().setLenientOnJson(lenientOnJson); + } + + /** + * Gets the authentications. + * + * @return the authentications + * @see ApiClient#getAuthentications() + */ + @Override + public Map getAuthentications() { + return apiClient().getAuthentications(); + } + + /** + * Gets the authentication. + * + * @param authName the auth name + * @return the authentication + * @see ApiClient#getAuthentication(java.lang.String) + */ + @Override + public Authentication getAuthentication(String authName) { + return apiClient().getAuthentication(authName); + } + + /** + * Sets the username. + * + * @param username the new username + * @see ApiClient#setUsername(java.lang.String) + */ + @Override + public void setUsername(String username) { + apiClient().setUsername(username); + } + + /** + * Sets the password. + * + * @param password the new password + * @see ApiClient#setPassword(java.lang.String) + */ + @Override + public void setPassword(String password) { + apiClient().setPassword(password); + } + + /** + * Sets the api key. + * + * @param apiKey the new api key + * @see ApiClient#setApiKey(java.lang.String) + */ + @Override + public void setApiKey(String apiKey) { + apiClient().setApiKey(apiKey); + } + + /** + * Sets the api key prefix. + * + * @param apiKeyPrefix the new api key prefix + * @see ApiClient#setApiKeyPrefix(java.lang.String) + */ + @Override + public void setApiKeyPrefix(String apiKeyPrefix) { + apiClient().setApiKeyPrefix(apiKeyPrefix); + } + + /** + * Sets the access token. + * + * @param accessToken the new access token + * @see ApiClient#setAccessToken(java.lang.String) + */ + @Override + public void setAccessToken(String accessToken) { + apiClient().setAccessToken(accessToken); + } + + /** + * Sets the user agent. + * + * @param userAgent the user agent + * @return the api client + * @see ApiClient#setUserAgent(java.lang.String) + */ + @Override + public ApiClient setUserAgent(String userAgent) { + return apiClient().setUserAgent(userAgent); + } + + /** + * To string. + * + * @return the string + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return apiClient().toString(); + } + + /** + * Adds the default header. + * + * @param key the key + * @param value the value + * @return the api client + * @see ApiClient#addDefaultHeader(java.lang.String, java.lang.String) + */ + @Override + public ApiClient addDefaultHeader(String key, String value) { + return apiClient().addDefaultHeader(key, value); + } + + /** + * Adds the default cookie. + * + * @param key the key + * @param value the value + * @return the api client + * @see ApiClient#addDefaultCookie(java.lang.String, java.lang.String) + */ + @Override + public ApiClient addDefaultCookie(String key, String value) { + return apiClient().addDefaultCookie(key, value); + } + + /** + * Checks if is debugging. + * + * @return true, if is debugging + * @see ApiClient#isDebugging() + */ + @Override + public boolean isDebugging() { + return apiClient().isDebugging(); + } + + /** + * Sets the debugging. + * + * @param debugging the debugging + * @return the api client + * @see ApiClient#setDebugging(boolean) + */ + @Override + public ApiClient setDebugging(boolean debugging) { + return apiClient().setDebugging(debugging); + } + + /** + * Gets the temp folder path. + * + * @return the temp folder path + * @see ApiClient#getTempFolderPath() + */ + @Override + public String getTempFolderPath() { + return apiClient().getTempFolderPath(); + } + + /** + * Sets the temp folder path. + * + * @param tempFolderPath the temp folder path + * @return the api client + * @see ApiClient#setTempFolderPath(java.lang.String) + */ + @Override + public ApiClient setTempFolderPath(String tempFolderPath) { + return apiClient().setTempFolderPath(tempFolderPath); + } + + /** + * Gets the connect timeout. + * + * @return the connect timeout + * @see ApiClient#getConnectTimeout() + */ + @Override + public int getConnectTimeout() { + return apiClient().getConnectTimeout(); + } + + /** + * Sets the connect timeout. + * + * @param connectionTimeout the connection timeout + * @return the api client + * @see ApiClient#setConnectTimeout(int) + */ + @Override + public ApiClient setConnectTimeout(int connectionTimeout) { + return apiClient().setConnectTimeout(connectionTimeout); + } + + /** + * Gets the read timeout. + * + * @return the read timeout + * @see ApiClient#getReadTimeout() + */ + @Override + public int getReadTimeout() { + return apiClient().getReadTimeout(); + } + + /** + * Sets the read timeout. + * + * @param readTimeout the read timeout + * @return the api client + * @see ApiClient#setReadTimeout(int) + */ + @Override + public ApiClient setReadTimeout(int readTimeout) { + return apiClient().setReadTimeout(readTimeout); + } + + /** + * Gets the write timeout. + * + * @return the write timeout + * @see ApiClient#getWriteTimeout() + */ + @Override + public int getWriteTimeout() { + return apiClient().getWriteTimeout(); + } + + /** + * Sets the write timeout. + * + * @param writeTimeout the write timeout + * @return the api client + * @see ApiClient#setWriteTimeout(int) + */ + @Override + public ApiClient setWriteTimeout(int writeTimeout) { + return apiClient().setWriteTimeout(writeTimeout); + } + + /** + * Parameter to string. + * + * @param param the param + * @return the string + * @see ApiClient#parameterToString(java.lang.Object) + */ + @Override + public String parameterToString(Object param) { + return apiClient().parameterToString(param); + } + + /** + * Parameter to pair. + * + * @param name the name + * @param value the value + * @return the list + * @see ApiClient#parameterToPair(java.lang.String, java.lang.Object) + */ + @Override + public List parameterToPair(String name, Object value) { + return apiClient().parameterToPair(name, value); + } + + /** + * Parameter to pairs. + * + * @param collectionFormat the collection format + * @param name the name + * @param value the value + * @return the list + * @see ApiClient#parameterToPairs(java.lang.String, java.lang.String, java.util.Collection) + */ + @SuppressWarnings({ "rawtypes", "PMD.AvoidDuplicateLiterals" }) + @Override + public List parameterToPairs(String collectionFormat, String name, + Collection value) { + return apiClient().parameterToPairs(collectionFormat, name, value); + } + + /** + * Collection path parameter to string. + * + * @param collectionFormat the collection format + * @param value the value + * @return the string + * @see ApiClient#collectionPathParameterToString(java.lang.String, java.util.Collection) + */ + @SuppressWarnings("rawtypes") + @Override + public String collectionPathParameterToString(String collectionFormat, + Collection value) { + return apiClient().collectionPathParameterToString(collectionFormat, + value); + } + + /** + * Sanitize filename. + * + * @param filename the filename + * @return the string + * @see ApiClient#sanitizeFilename(java.lang.String) + */ + @Override + public String sanitizeFilename(String filename) { + return apiClient().sanitizeFilename(filename); + } + + /** + * Checks if is json mime. + * + * @param mime the mime + * @return true, if is json mime + * @see ApiClient#isJsonMime(java.lang.String) + */ + @Override + public boolean isJsonMime(String mime) { + return apiClient().isJsonMime(mime); + } + + /** + * Select header accept. + * + * @param accepts the accepts + * @return the string + * @see ApiClient#selectHeaderAccept(java.lang.String[]) + */ + @Override + public String selectHeaderAccept(String[] accepts) { + return apiClient().selectHeaderAccept(accepts); + } + + /** + * Select header content type. + * + * @param contentTypes the content types + * @return the string + * @see ApiClient#selectHeaderContentType(java.lang.String[]) + */ + @Override + public String selectHeaderContentType(String[] contentTypes) { + return apiClient().selectHeaderContentType(contentTypes); + } + + /** + * Escape string. + * + * @param str the str + * @return the string + * @see ApiClient#escapeString(java.lang.String) + */ + @Override + public String escapeString(String str) { + return apiClient().escapeString(str); + } + + /** + * Deserialize. + * + * @param the generic type + * @param response the response + * @param returnType the return type + * @return the t + * @throws ApiException the api exception + * @see ApiClient#deserialize(okhttp3.Response, java.lang.reflect.Type) + */ + @Override + public T deserialize(Response response, Type returnType) + throws ApiException { + return apiClient().deserialize(response, returnType); + } + + /** + * Serialize. + * + * @param obj the obj + * @param contentType the content type + * @return the request body + * @throws ApiException the api exception + * @see ApiClient#serialize(java.lang.Object, java.lang.String) + */ + @Override + public RequestBody serialize(Object obj, String contentType) + throws ApiException { + return apiClient().serialize(obj, contentType); + } + + /** + * Download file from response. + * + * @param response the response + * @return the file + * @throws ApiException the api exception + * @see ApiClient#downloadFileFromResponse(okhttp3.Response) + */ + @Override + public File downloadFileFromResponse(Response response) + throws ApiException { + return apiClient().downloadFileFromResponse(response); + } + + /** + * Prepare download file. + * + * @param response the response + * @return the file + * @throws IOException Signals that an I/O exception has occurred. + * @see ApiClient#prepareDownloadFile(okhttp3.Response) + */ + @Override + public File prepareDownloadFile(Response response) throws IOException { + return apiClient().prepareDownloadFile(response); + } + + /** + * Execute. + * + * @param the generic type + * @param call the call + * @return the api response + * @throws ApiException the api exception + * @see ApiClient#execute(okhttp3.Call) + */ + @Override + public ApiResponse execute(Call call) throws ApiException { + return apiClient().execute(call); + } + + /** + * Execute. + * + * @param the generic type + * @param call the call + * @param returnType the return type + * @return the api response + * @throws ApiException the api exception + * @see ApiClient#execute(okhttp3.Call, java.lang.reflect.Type) + */ + @Override + public ApiResponse execute(Call call, Type returnType) + throws ApiException { + return apiClient().execute(call, returnType); + } + + /** + * Execute async. + * + * @param the generic type + * @param call the call + * @param callback the callback + * @see ApiClient#executeAsync(okhttp3.Call, io.kubernetes.client.openapi.ApiCallback) + */ + @Override + public void executeAsync(Call call, ApiCallback callback) { + apiClient().executeAsync(call, callback); + } + + /** + * Execute async. + * + * @param the generic type + * @param call the call + * @param returnType the return type + * @param callback the callback + * @see ApiClient#executeAsync(okhttp3.Call, java.lang.reflect.Type, io.kubernetes.client.openapi.ApiCallback) + */ + @Override + public void executeAsync(Call call, Type returnType, + ApiCallback callback) { + apiClient().executeAsync(call, returnType, callback); + } + + /** + * Handle response. + * + * @param the generic type + * @param response the response + * @param returnType the return type + * @return the t + * @throws ApiException the api exception + * @see ApiClient#handleResponse(okhttp3.Response, java.lang.reflect.Type) + */ + @Override + public T handleResponse(Response response, Type returnType) + throws ApiException { + return apiClient().handleResponse(response, returnType); + } + + /** + * Builds the call. + * + * @param path the path + * @param method the method + * @param queryParams the query params + * @param collectionQueryParams the collection query params + * @param body the body + * @param headerParams the header params + * @param cookieParams the cookie params + * @param formParams the form params + * @param authNames the auth names + * @param callback the callback + * @return the call + * @throws ApiException the api exception + * @see ApiClient#buildCall(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback) + */ + @SuppressWarnings({ "rawtypes" }) + @Override + public Call buildCall(String path, String method, List queryParams, + List collectionQueryParams, Object body, + Map headerParams, Map cookieParams, + Map formParams, String[] authNames, + ApiCallback callback) throws ApiException { + return apiClient().buildCall(path, method, queryParams, + collectionQueryParams, body, headerParams, cookieParams, formParams, + authNames, callback); + } + + /** + * Builds the request. + * + * @param path the path + * @param method the method + * @param queryParams the query params + * @param collectionQueryParams the collection query params + * @param body the body + * @param headerParams the header params + * @param cookieParams the cookie params + * @param formParams the form params + * @param authNames the auth names + * @param callback the callback + * @return the request + * @throws ApiException the api exception + * @see ApiClient#buildRequest(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback) + */ + @SuppressWarnings({ "rawtypes" }) + @Override + public Request buildRequest(String path, String method, + List queryParams, List collectionQueryParams, + Object body, Map headerParams, + Map cookieParams, Map formParams, + String[] authNames, ApiCallback callback) throws ApiException { + return apiClient().buildRequest(path, method, queryParams, + collectionQueryParams, body, headerParams, cookieParams, formParams, + authNames, callback); + } + + /** + * Builds the url. + * + * @param path the path + * @param queryParams the query params + * @param collectionQueryParams the collection query params + * @return the string + * @see ApiClient#buildUrl(java.lang.String, java.util.List, java.util.List) + */ + @Override + public String buildUrl(String path, List queryParams, + List collectionQueryParams) { + return apiClient().buildUrl(path, queryParams, collectionQueryParams); + } + + /** + * Process header params. + * + * @param headerParams the header params + * @param reqBuilder the req builder + * @see ApiClient#processHeaderParams(java.util.Map, okhttp3.Request.Builder) + */ + @Override + public void processHeaderParams(Map headerParams, + Builder reqBuilder) { + apiClient().processHeaderParams(headerParams, reqBuilder); + } + + /** + * Process cookie params. + * + * @param cookieParams the cookie params + * @param reqBuilder the req builder + * @see ApiClient#processCookieParams(java.util.Map, okhttp3.Request.Builder) + */ + @Override + public void processCookieParams(Map cookieParams, + Builder reqBuilder) { + apiClient().processCookieParams(cookieParams, reqBuilder); + } + + /** + * Update params for auth. + * + * @param authNames the auth names + * @param queryParams the query params + * @param headerParams the header params + * @param cookieParams the cookie params + * @see ApiClient#updateParamsForAuth(java.lang.String[], java.util.List, java.util.Map, java.util.Map) + */ + @Override + public void updateParamsForAuth(String[] authNames, List queryParams, + Map headerParams, + Map cookieParams) { + apiClient().updateParamsForAuth(authNames, queryParams, headerParams, + cookieParams); + } + + /** + * Builds the request body form encoding. + * + * @param formParams the form params + * @return the request body + * @see ApiClient#buildRequestBodyFormEncoding(java.util.Map) + */ + @Override + public RequestBody + buildRequestBodyFormEncoding(Map formParams) { + return apiClient().buildRequestBodyFormEncoding(formParams); + } + + /** + * Builds the request body multipart. + * + * @param formParams the form params + * @return the request body + * @see ApiClient#buildRequestBodyMultipart(java.util.Map) + */ + @Override + public RequestBody + buildRequestBodyMultipart(Map formParams) { + return apiClient().buildRequestBodyMultipart(formParams); + } + + /** + * Guess content type from file. + * + * @param file the file + * @return the string + * @see ApiClient#guessContentTypeFromFile(java.io.File) + */ + @Override + public String guessContentTypeFromFile(File file) { + return apiClient().guessContentTypeFromFile(file); + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java new file mode 100644 index 0000000..59b4d12 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java @@ -0,0 +1,396 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Strings; +import io.kubernetes.client.util.generic.GenericKubernetesApi; +import io.kubernetes.client.util.generic.options.GetOptions; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * A stub for cluster scoped objects. This stub provides the + * functions common to all Kubernetes objects, but uses variables + * for all types. This class should be used as base class only. + * + * @param the generic type + * @param the generic type + */ +@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) +public class K8sClusterGenericStub { + protected final K8sClient client; + private final GenericKubernetesApi api; + protected final APIResource context; + protected final String name; + + /** + * Instantiates a new stub for the object specified. If the object + * exists in the context specified, the version (see + * {@link #version()} is bound to the existing object's version. + * Else the stub is dangling with the version set to the context's + * preferred version. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param name the name + */ + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + protected K8sClusterGenericStub(Class objectClass, + Class objectListClass, K8sClient client, APIResource context, + String name) { + this.client = client; + this.name = name; + + // Bind version + var foundVersion = context.getPreferredVersion(); + GenericKubernetesApi testApi = null; + GetOptions mdOpts + = new GetOptions().isPartialObjectMetadataRequest(true); + for (var version : candidateVersions(context)) { + testApi = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + if (testApi.get(name, mdOpts).isSuccess()) { + foundVersion = version; + break; + } + } + if (foundVersion.equals(context.getPreferredVersion())) { + this.context = context; + } else { + this.context = K8s.preferred(context, foundVersion); + } + + api = Optional.ofNullable(testApi) + .orElseGet(() -> new GenericKubernetesApi<>(objectClass, + objectListClass, group(), version(), plural(), client)); + } + + /** + * Gets the context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Gets the group. + * + * @return the group + */ + public String group() { + return context.getGroup(); + } + + /** + * Gets the version. + * + * @return the version + */ + public String version() { + return context.getPreferredVersion(); + } + + /** + * Gets the kind. + * + * @return the kind + */ + public String kind() { + return context.getKind(); + } + + /** + * Gets the plural. + * + * @return the plural + */ + public String plural() { + return context.getResourcePlural(); + } + + /** + * Gets the name. + * + * @return the name + */ + public String name() { + return name; + } + + /** + * Delete the Kubernetes object. + * + * @throws ApiException the API exception + */ + public void delete() throws ApiException { + var result = api.delete(name); + if (result.isSuccess() + || result.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { + return; + } + result.throwsApiException(); + } + + /** + * Retrieves and returns the current state of the object. + * + * @return the object's state + * @throws ApiException the api exception + */ + public Optional model() throws ApiException { + return K8s.optional(api.get(name)); + } + + /** + * Updates the object's status. + * + * @param object the current state of the object (passed to `status`) + * @param status function that returns the new status + * @return the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(O object, + Function status) throws ApiException { + return K8s.optional(api.updateStatus(object, status)); + } + + /** + * Updates the status. + * + * @param status the status + * @return the kubernetes api response + * the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(Function status) + throws ApiException { + return updateStatus(api.get(name).throwsApiException().getObject(), + status); + } + + /** + * Patch the object. + * + * @param patchType the patch type + * @param patch the patch + * @param options the options + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public Optional patch(String patchType, V1Patch patch, + PatchOptions options) throws ApiException { + return K8s + .optional(api.patch(name, patchType, patch, options)); + } + + /** + * Patch the object using default options. + * + * @param patchType the patch type + * @param patch the patch + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public Optional + patch(String patchType, V1Patch patch) throws ApiException { + PatchOptions opts = new PatchOptions(); + return patch(patchType, patch, opts); + } + + /** + * A supplier for generic stubs. + * + * @param the object type + * @param the object list type + * @param the result type + */ + @FunctionalInterface + public interface GenericSupplier> { + + /** + * Gets a new stub. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the API resource + * @param name the name + * @return the result + */ + R get(Class objectClass, Class objectListClass, K8sClient client, + APIResource context, String name); + } + + @Override + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public String toString() { + return (Strings.isNullOrEmpty(group()) ? "" : group() + "/") + + version().toUpperCase() + kind() + " " + name; + } + + /** + * Get an object stub. If the version in parameter + * `gvk` is an empty string, the stub refers to the first object + * found with matching group and kind. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param gvk the group, version and kind + * @param name the name + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static > + R get(Class objectClass, Class objectListClass, + K8sClient client, GroupVersionKind gvk, String name, + GenericSupplier provider) throws ApiException { + var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(), + gvk.getKind()); + if (context.isEmpty()) { + throw new ApiException("No known API for " + gvk.getGroup() + + "/" + gvk.getVersion() + " " + gvk.getKind()); + } + return provider.get(objectClass, objectListClass, client, context.get(), + name); + } + + /** + * Get an object stub. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param name the name + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static > + R get(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String name, + GenericSupplier provider) { + return provider.get(objectClass, objectListClass, client, context, + name); + } + + /** + * Get an object stub for a newly created object. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param model the model + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static > + R create(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, O model, + GenericSupplier provider) throws ApiException { + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), context.getPreferredVersion(), + context.getResourcePlural(), client); + api.create(model).throwsApiException(); + return provider.get(objectClass, objectListClass, client, + context, model.getMetadata().getName()); + } + + /** + * Get the stubs for the objects that match + * the criteria from the given options. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param options the options + * @param provider the provider + * @return the collection + * @throws ApiException the api exception + */ + public static > + Collection list(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, + ListOptions options, GenericSupplier provider) + throws ApiException { + var result = new ArrayList(); + for (var version : candidateVersions(context)) { + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + var objs = api.list(options).throwsApiException(); + for (var item : objs.getObject().getItems()) { + result.add(provider.get(objectClass, objectListClass, client, + context, item.getMetadata().getName())); + } + } + return result; + } + + private static List candidateVersions(APIResource context) { + var result = new LinkedList<>(context.getVersions()); + result.remove(context.getPreferredVersion()); + result.add(0, context.getPreferredVersion()); + return result; + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java new file mode 100644 index 0000000..2392d3e --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java @@ -0,0 +1,113 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.models.V1ObjectMeta; + +/** + * Represents a Kubernetes object using a JSON data structure. + * Some information that is common to all Kubernetes objects, + * notably the metadata, is made available through the methods + * defined by {@link KubernetesObject}. + */ +public class K8sDynamicModel implements KubernetesObject { + + private final V1ObjectMeta metadata; + private final JsonObject data; + + /** + * Instantiates a new model from the JSON representation. + * + * @param delegate the gson instance to use for extracting structured data + * @param json the JSON + */ + public K8sDynamicModel(Gson delegate, JsonObject json) { + this.data = json; + metadata = delegate.fromJson(data.get("metadata"), V1ObjectMeta.class); + } + + @Override + public String getApiVersion() { + return apiVersion(); + } + + /** + * Gets the API version. (Abbreviated method name for convenience.) + * + * @return the API version + */ + public String apiVersion() { + return data.get("apiVersion").getAsString(); + } + + @Override + public String getKind() { + return kind(); + } + + /** + * Gets the kind. (Abbreviated method name for convenience.) + * + * @return the kind + */ + public String kind() { + return data.get("kind").getAsString(); + } + + @Override + public V1ObjectMeta getMetadata() { + return metadata; + } + + /** + * Gets the metadata. (Abbreviated method name for convenience.) + * + * @return the metadata + */ + public V1ObjectMeta metadata() { + return metadata; + } + + /** + * Gets the data. + * + * @return the data + */ + public JsonObject data() { + return data; + } + + /** + * Convenience method for getting the status. + * + * @return the JSON object describing the status + */ + public JsonObject statusJson() { + return data.getAsJsonObject("status"); + } + + @Override + public String toString() { + return data.toString(); + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java new file mode 100644 index 0000000..d165c10 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java @@ -0,0 +1,44 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.kubernetes.client.common.KubernetesListObject; + +/** + * Represents a list of Kubernetes objects each of which is + * represented using a JSON data structure. + * Some information that is common to all Kubernetes objects, + * notably the metadata, is made available through the methods + * defined by {@link KubernetesListObject}. + */ +public class K8sDynamicModels extends K8sDynamicModelsBase { + + /** + * Initialize the object list using the given JSON data. + * + * @param delegate the gson instance to use for extracting structured data + * @param data the data + */ + public K8sDynamicModels(Gson delegate, JsonObject data) { + super(K8sDynamicModel.class, delegate, data); + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java new file mode 100644 index 0000000..4e21c0e --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java @@ -0,0 +1,174 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.models.V1ListMeta; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Represents a list of Kubernetes objects each of which is + * represented using a JSON data structure. + * Some information that is common to all Kubernetes objects, + * notably the metadata, is made available through the methods + * defined by {@link KubernetesListObject}. + */ +public class K8sDynamicModelsBase + implements KubernetesListObject { + + private final JsonObject data; + private final V1ListMeta metadata; + private final List items; + + /** + * Initialize the object list using the given JSON data. + * + * @param itemClass the item class + * @param delegate the gson instance to use for extracting structured data + * @param data the data + */ + public K8sDynamicModelsBase(Class itemClass, Gson delegate, + JsonObject data) { + this.data = data; + metadata = delegate.fromJson(data.get("metadata"), V1ListMeta.class); + items = new ArrayList<>(); + for (JsonElement e : data.get("items").getAsJsonArray()) { + try { + items.add(itemClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, e.getAsJsonObject())); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException exc) { + throw new IllegalArgumentException(exc); + } + } + } + + @Override + public String getApiVersion() { + return apiVersion(); + } + + /** + * Gets the API version. (Abbreviated method name for convenience.) + * + * @return the API version + */ + public String apiVersion() { + return data.get("apiVersion").getAsString(); + } + + @Override + public String getKind() { + return kind(); + } + + /** + * Gets the kind. (Abbreviated method name for convenience.) + * + * @return the kind + */ + public String kind() { + return data.get("kind").getAsString(); + } + + @Override + public V1ListMeta getMetadata() { + return metadata; + } + + /** + * Gets the metadata. (Abbreviated method name for convenience.) + * + * @return the metadata + */ + public V1ListMeta metadata() { + return metadata; + } + + /** + * Returns the JSON representation of this object. + * + * @return the JOSN representation + */ + public JsonObject data() { + return data; + } + + @Override + public List getItems() { + return items; + } + + /** + * Sets the api version. + * + * @param apiVersion the new api version + */ + public void setApiVersion(String apiVersion) { + data.addProperty("apiVersion", apiVersion); + } + + /** + * Sets the kind. + * + * @param kind the new kind + */ + public void setKind(String kind) { + data.addProperty("kind", kind); + } + + /** + * Sets the metadata. + * + * @param objectMeta the new metadata + */ + public void setMetadata(V1ListMeta objectMeta) { + data.add("metadata", + Configuration.getDefaultApiClient().getJSON().getGson() + .toJsonTree(objectMeta)); + } + + @Override + public int hashCode() { + return Objects.hash(data); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + K8sDynamicModelsBase other = (K8sDynamicModelsBase) obj; + return Objects.equals(data, other.data); + } +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java new file mode 100644 index 0000000..c0303c2 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java @@ -0,0 +1,152 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.Reader; +import java.util.Collection; + +/** + * A stub for namespaced custom objects. It uses a dynamic model + * (see {@link K8sDynamicModel}) for representing the object's + * state and can therefore be used for any kind of object, especially + * custom objects. + */ +public class K8sDynamicStub + extends K8sDynamicStubBase { + + private static DynamicTypeAdapterFactory taf = new K8sDynamicModelTypeAdapterFactory(); + + /** + * Instantiates a new dynamic stub. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + public K8sDynamicStub(K8sClient client, + APIResource context, String namespace, String name) { + super(K8sDynamicModel.class, K8sDynamicModels.class, taf, client, + context, namespace, name); + } + + /** + * Get a dynamic object stub. If the version in parameter + * `gvk` is an empty string, the stub refers to the first object with + * matching group and kind. + * + * @param client the client + * @param gvk the group, version and kind + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static K8sDynamicStub get(K8sClient client, + GroupVersionKind gvk, String namespace, String name) + throws ApiException { + return new K8sDynamicStub(client, apiResource(client, gvk), namespace, + name); + } + + /** + * Get a dynamic object stub. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static K8sDynamicStub get(K8sClient client, + APIResource context, String namespace, String name) { + return new K8sDynamicStub(client, context, namespace, name); + } + + /** + * Creates a stub from yaml. + * + * @param client the client + * @param context the context + * @param yaml the yaml + * @return the k 8 s dynamic stub + * @throws ApiException the api exception + */ + public static K8sDynamicStub createFromYaml(K8sClient client, + APIResource context, Reader yaml) throws ApiException { + var model = new K8sDynamicModel(client.getJSON().getGson(), + K8s.yamlToJson(client, yaml)); + return K8sGenericStub.create(K8sDynamicModel.class, + K8sDynamicModels.class, client, context, model, + (c, ns, n) -> new K8sDynamicStub(c, context, ns, n)); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace, ListOptions options) + throws ApiException { + return K8sGenericStub.list(K8sDynamicModel.class, + K8sDynamicModels.class, client, context, namespace, options, + (c, ns, n) -> new K8sDynamicStub(c, context, ns, n)); + } + + /** + * Get the stubs for the objects in the given namespace. + * + * @param client the client + * @param namespace the namespace + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace) + throws ApiException { + return list(client, context, namespace, new ListOptions()); + } + + /** + * A factory for creating K8sDynamicModel(s) objects. + */ + public static class K8sDynamicModelTypeAdapterFactory extends + DynamicTypeAdapterFactory { + + /** + * Instantiates a new dynamic model type adapter factory. + */ + public K8sDynamicModelTypeAdapterFactory() { + super(K8sDynamicModel.class, K8sDynamicModels.class); + } + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java new file mode 100644 index 0000000..ae3f012 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; + +/** + * A stub for namespaced custom objects. It uses a dynamic model + * (see {@link K8sDynamicModel}) for representing the object's + * state and can therefore be used for any kind of object, especially + * custom objects. + */ +public abstract class K8sDynamicStubBase> extends K8sGenericStub { + + /** + * Instantiates a new dynamic stub. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + public K8sDynamicStubBase(Class objectClass, + Class objectListClass, DynamicTypeAdapterFactory taf, + K8sClient client, APIResource context, String namespace, + String name) { + super(objectClass, objectListClass, client, context, namespace, name); + taf.register(client); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java new file mode 100644 index 0000000..9ba376f --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java @@ -0,0 +1,474 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Strings; +import io.kubernetes.client.util.generic.GenericKubernetesApi; +import io.kubernetes.client.util.generic.KubernetesApiResponse; +import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import io.kubernetes.client.util.generic.options.GetOptions; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import io.kubernetes.client.util.generic.options.UpdateOptions; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * A stub for namespaced custom objects. This stub provides the + * functions common to all Kubernetes objects, but uses variables + * for all types. This class should be used as base class only. + * + * @param the generic type + * @param the generic type + */ +@SuppressWarnings({ "PMD.TooManyMethods" }) +public class K8sGenericStub { + protected final K8sClient client; + private final GenericKubernetesApi api; + protected final APIResource context; + protected final String namespace; + protected final String name; + + /** + * Instantiates a new stub for the object specified. If the object + * exists in the context specified, the version (see + * {@link #version()} is bound to the existing object's version. + * Else the stub is dangling with the version set to the context's + * preferred version. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + protected K8sGenericStub(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + String name) { + this.client = client; + this.namespace = namespace; + this.name = name; + + // Bind version + var foundVersion = context.getPreferredVersion(); + GenericKubernetesApi testApi = null; + GetOptions mdOpts + = new GetOptions().isPartialObjectMetadataRequest(true); + for (var version : candidateVersions(context)) { + testApi = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + if (testApi.get(namespace, name, mdOpts) + .isSuccess()) { + foundVersion = version; + break; + } + } + if (foundVersion.equals(context.getPreferredVersion())) { + this.context = context; + } else { + this.context = K8s.preferred(context, foundVersion); + } + + api = Optional.ofNullable(testApi) + .orElseGet(() -> new GenericKubernetesApi<>(objectClass, + objectListClass, group(), version(), plural(), client)); + } + + /** + * Gets the context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Gets the group. + * + * @return the group + */ + public String group() { + return context.getGroup(); + } + + /** + * Gets the version. + * + * @return the version + */ + public String version() { + return context.getPreferredVersion(); + } + + /** + * Gets the kind. + * + * @return the kind + */ + public String kind() { + return context.getKind(); + } + + /** + * Gets the plural. + * + * @return the plural + */ + public String plural() { + return context.getResourcePlural(); + } + + /** + * Gets the namespace. + * + * @return the namespace + */ + public String namespace() { + return namespace; + } + + /** + * Gets the name. + * + * @return the name + */ + public String name() { + return name; + } + + /** + * Delete the Kubernetes object. + * + * @throws ApiException the API exception + */ + public void delete() throws ApiException { + var result = api.delete(namespace, name); + if (result.isSuccess() + || result.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { + return; + } + result.throwsApiException(); + } + + /** + * Retrieves and returns the current state of the object. + * + * @return the object's state + * @throws ApiException the api exception + */ + public Optional model() throws ApiException { + return K8s.optional(api.get(namespace, name)); + } + + /** + * Updates the object's status. Does not retry in case of conflict. + * + * @param object the current state of the object (passed to `status`) + * @param updater function that returns the new status + * @return the updated model or empty if the object was not found + * @throws ApiException the api exception + */ + public Optional updateStatus(O object, Function updater) + throws ApiException { + return K8s.optional(api.updateStatus(object, updater)); + } + + /** + * Updates the status of the given object. In case of conflict, + * get the current version of the object and tries again. Retries + * up to `retries` times. + * + * @param updater the function updating the status + * @param current the current state of the object, used for the first + * attempt to update + * @param retries the retries in case of conflict + * @return the updated model or empty if the object was not found + * @throws ApiException the api exception + */ + @SuppressWarnings({ "PMD.AssignmentInOperand" }) + public Optional updateStatus(Function updater, O current, + int retries) throws ApiException { + while (true) { + try { + if (current == null) { + current = api.get(namespace, name) + .throwsApiException().getObject(); + } + return updateStatus(current, updater); + } catch (ApiException e) { + if (HttpURLConnection.HTTP_CONFLICT != e.getCode() + || retries-- <= 0) { + throw e; + } + // Get current version for new attempt + current = null; + } + } + } + + /** + * Gets the object and updates the status. In case of conflict, retries + * up to `retries` times. + * + * @param updater the function updating the status + * @param retries the retries in case of conflict + * @return the updated model or empty if the object was not found + * @throws ApiException the api exception + */ + public Optional updateStatus(Function updater, int retries) + throws ApiException { + return updateStatus(updater, null, retries); + } + + /** + * Updates the status of the given object. In case of conflict, + * get the current version of the object and tries again. Retries + * up to `retries` times. + * + * @param updater the function updating the status + * @param current the current + * @return the kubernetes api response + * the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(Function updater, O current) + throws ApiException { + return updateStatus(updater, current, 16); + } + + /** + * Updates the status. In case of conflict, retries up to 16 times. + * + * @param updater the function updating the status + * @return the kubernetes api response + * the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(Function updater) + throws ApiException { + return updateStatus(updater, null); + } + + /** + * Patch the object. + * + * @param patchType the patch type + * @param patch the patch + * @param options the options + * @return the kubernetes api response if successful + * @throws ApiException the api exception + */ + public Optional patch(String patchType, V1Patch patch, + PatchOptions options) throws ApiException { + return K8s + .optional(api.patch(namespace, name, patchType, patch, options) + .throwsApiException()); + } + + /** + * Patch the object using default options. + * + * @param patchType the patch type + * @param patch the patch + * @return the kubernetes api response if successful + * @throws ApiException the api exception + */ + public Optional + patch(String patchType, V1Patch patch) throws ApiException { + PatchOptions opts = new PatchOptions(); + return patch(patchType, patch, opts); + } + + /** + * Apply the given definition. + * + * @param def the def + * @return the kubernetes api response if successful + * @throws ApiException the api exception + */ + public Optional apply(DynamicKubernetesObject def) throws ApiException { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + return patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(client.getJSON().serialize(def)), opts); + } + + /** + * Update the object. + * + * @param object the object + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public KubernetesApiResponse update(O object) throws ApiException { + return api.update(object).throwsApiException(); + } + + /** + * Update the object. + * + * @param object the object + * @param options the options + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public KubernetesApiResponse update(O object, UpdateOptions options) + throws ApiException { + return api.update(object, options).throwsApiException(); + } + + /** + * A supplier for generic stubs. + * + * @param the object type + * @param the object list type + * @param the result type + */ + @FunctionalInterface + public interface GenericSupplier> { + + /** + * Gets a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the result + */ + R get(K8sClient client, String namespace, String name); + } + + @Override + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public String toString() { + return (Strings.isNullOrEmpty(group()) ? "" : group() + "/") + + version().toUpperCase() + kind() + " " + namespace + ":" + name; + } + + /** + * Get a namespaced object stub for a newly created object. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param model the model + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static > + R create(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, O model, + GenericSupplier provider) throws ApiException { + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), context.getPreferredVersion(), + context.getResourcePlural(), client); + api.create(model).throwsApiException(); + return provider.get(client, model.getMetadata().getNamespace(), + model.getMetadata().getName()); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param options the options + * @param provider the provider + * @return the collection + * @throws ApiException the api exception + */ + public static > + Collection list(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + ListOptions options, GenericSupplier provider) + throws ApiException { + var result = new ArrayList(); + for (var version : candidateVersions(context)) { + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + var objs = api.list(namespace, options).throwsApiException(); + for (var item : objs.getObject().getItems()) { + result.add(provider.get(client, namespace, + item.getMetadata().getName())); + } + } + return result; + } + + private static List candidateVersions(APIResource context) { + var result = new LinkedList<>(context.getVersions()); + result.remove(context.getPreferredVersion()); + result.add(0, context.getPreferredVersion()); + return result; + } + + /** + * Api resource. + * + * @param client the client + * @param gvk the gvk + * @return the API resource + * @throws ApiException the api exception + */ + public static APIResource apiResource(K8sClient client, + GroupVersionKind gvk) throws ApiException { + var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(), + gvk.getKind()); + if (context.isEmpty()) { + throw new ApiException("No known API for " + gvk.getGroup() + + "/" + gvk.getVersion() + " " + gvk.getKind()); + } + return context.get(); + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java new file mode 100644 index 0000000..9e22382 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java @@ -0,0 +1,237 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.GenericKubernetesApi; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jgrapes.core.Components; + +/** + * An observer that watches namespaced resources in a given context and + * invokes a handler on changes. + * + * @param the object type for the context + * @param the object list type for the context + */ +public class K8sObserver { + + /** + * The type of change reported by {@link Response} as enum. + */ + public enum ResponseType { + ADDED, MODIFIED, DELETED + } + + protected final Logger logger = Logger.getLogger(getClass().getName()); + + protected final K8sClient client; + protected final GenericKubernetesApi api; + protected final APIResource context; + protected final String namespace; + protected final ListOptions options; + protected final Thread thread; + protected BiConsumer> handler; + protected BiConsumer, Throwable> onTerminated; + + /** + * Create and start a new observer for objects in the given context + * (using preferred version) and namespace with the given options. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param options the options + */ + @SuppressWarnings({ "PMD.AvoidCatchingThrowable", + "PMD.CognitiveComplexity", "PMD.AvoidCatchingGenericException" }) + public K8sObserver(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + ListOptions options) { + this.client = client; + this.context = context; + this.namespace = namespace; + this.options = options; + + api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), context.getPreferredVersion(), + context.getResourcePlural(), client); + thread = (Components.useVirtualThreads() ? Thread.ofVirtual() + : Thread.ofPlatform()).unstarted(() -> { + try { + logger.fine(() -> "Observing " + context.getResourcePlural() + + " (" + context.getPreferredVersion() + ")" + + Optional.ofNullable(options.getLabelSelector()) + .map(ls -> " with labels " + ls).orElse("") + + " in " + namespace); + + // Watch sometimes terminates without apparent reason. + while (!Thread.currentThread().isInterrupted()) { + Instant startedAt = Instant.now(); + try { + var changed + = api.watch(namespace, options).iterator(); + while (changed.hasNext()) { + var response = changed.next(); + logger.fine(() -> "Resource " + + context.getKind() + "/" + + response.object.getMetadata().getName() + + " " + response.type); + handler.accept(client, response); + } + } catch (ApiException | RuntimeException e) { + logger.log(Level.FINE, e, () -> "Problem watching" + + " resource " + context.getKind() + + " (will retry): " + e.getMessage()); + delayRestart(startedAt); + } + } + if (onTerminated != null) { + onTerminated.accept(this, null); + } + } catch (Throwable e) { + logger.log(Level.SEVERE, e, () -> "Probem watching: " + + e.getMessage()); + if (onTerminated != null) { + onTerminated.accept(this, e); + } + } + }); + } + + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + private void delayRestart(Instant started) { + var runningFor = Duration + .between(started, Instant.now()).toMillis(); + if (runningFor < 5000) { + logger.log(Level.FINE, () -> "Waiting... "); + try { + Thread.sleep(5000 - runningFor); + } catch (InterruptedException e1) { // NOPMD + // Retry + } + logger.log(Level.FINE, () -> "Retrying"); + } + } + + /** + * Sets the handler. + * + * @param handler the handler + * @return the observer + */ + public K8sObserver + handler(BiConsumer> handler) { + this.handler = handler; + return this; + } + + /** + * Sets a function to invoke if the observer terminates. First argument + * is this observer, the second is the throwable that caused the + * abnormal termination or `null` if the observer was terminated + * by {@link #stop()}. + * + * @param onTerminated the on terminated + * @return the observer + */ + public K8sObserver onTerminated( + BiConsumer, Throwable> onTerminated) { + this.onTerminated = onTerminated; + return this; + } + + /** + * Start the observer. + * + * @return the observer + */ + public K8sObserver start() { + if (handler == null) { + throw new IllegalStateException("No handler defined"); + } + thread.start(); + return this; + } + + /** + * Stops the observer. + * + * @return the observer + */ + public K8sObserver stop() { + thread.interrupt(); + return this; + } + + /** + * Returns the client. + * + * @return the client + */ + public K8sClient client() { + return client; + } + + /** + * Returns the context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Returns the observed namespace. + * + * @return the namespace + */ + public String getNamespace() { + return namespace; + } + + /** + * Returns the options for object selection. + * + * @return the list options + */ + public ListOptions options() { + return options; + } + + @Override + public String toString() { + return "Observer for " + K8s.toString(context) + " " + namespace; + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java new file mode 100644 index 0000000..07c59b2 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import java.util.List; + +/** + * A stub for config maps (v1). + */ +public class K8sV1ConfigMapStub + extends K8sGenericStub { + + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "ConfigMap", true, "configmaps", "configmap"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1ConfigMapStub(K8sClient client, String namespace, + String name) { + super(V1ConfigMap.class, V1ConfigMapList.class, client, + CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the config map stub + */ + public static K8sV1ConfigMapStub get(K8sClient client, String namespace, + String name) { + return new K8sV1ConfigMapStub(client, namespace, name); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java new file mode 100644 index 0000000..9075a84 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java @@ -0,0 +1,78 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Deployment; +import io.kubernetes.client.openapi.models.V1DeploymentList; +import java.util.List; +import java.util.Optional; + +/** + * A stub for pods (v1). + */ +public class K8sV1DeploymentStub + extends K8sGenericStub { + + /** The deployment's context. */ + public static final APIResource CONTEXT = new APIResource("apps", + List.of("v1"), "v1", "Pod", true, "deployments", "deployment"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1DeploymentStub(K8sClient client, String namespace, + String name) { + super(V1Deployment.class, V1DeploymentList.class, client, + CONTEXT, namespace, name); + } + + /** + * Scales the deployment. + * + * @param replicas the replicas + * @return the new model or empty if not successful + * @throws ApiException the API exception + */ + public Optional scale(int replicas) throws ApiException { + return patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/replicas" + + "\", \"value\": " + replicas + "}]"), + client.defaultPatchOptions()); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the deployment stub + */ + public static K8sV1DeploymentStub get(K8sClient client, String namespace, + String name) { + return new K8sV1DeploymentStub(client, namespace, name); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java new file mode 100644 index 0000000..ea1237d --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java @@ -0,0 +1,83 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Node; +import io.kubernetes.client.openapi.models.V1NodeList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for nodes (v1). + */ +public class K8sV1NodeStub extends K8sClusterGenericStub { + + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "Node", true, "nodes", "node"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param name the name + */ + protected K8sV1NodeStub(K8sClient client, String name) { + super(V1Node.class, V1NodeList.class, client, CONTEXT, name); + } + + /** + * Gets the stub for the given name. + * + * @param client the client + * @param name the name + * @return the config map stub + */ + public static K8sV1NodeStub get(K8sClient client, String name) { + return new K8sV1NodeStub(client, name); + } + + /** + * Get the stubs for the objects that match + * the criteria from the given options. + * + * @param client the client + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + ListOptions options) throws ApiException { + return K8sClusterGenericStub.list(V1Node.class, V1NodeList.class, + client, CONTEXT, options, K8sV1NodeStub::getGeneric); + } + + /** + * Provide {@link GenericSupplier}. + */ + @SuppressWarnings({ "PMD.UnusedFormalParameter" }) + private static K8sV1NodeStub getGeneric(Class objectClass, + Class objectListClass, K8sClient client, + APIResource context, String name) { + return new K8sV1NodeStub(client, name); + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java new file mode 100644 index 0000000..f21bb47 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java @@ -0,0 +1,78 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for pods (v1). + */ +public class K8sV1PodStub extends K8sGenericStub { + + /** The pods' context. */ + public static final APIResource CONTEXT + = new APIResource("", List.of("v1"), "v1", "Pod", true, "pods", "pod"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1PodStub(K8sClient client, String namespace, String name) { + super(V1Pod.class, V1PodList.class, client, CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the kpod stub + */ + public static K8sV1PodStub get(K8sClient client, String namespace, + String name) { + return new K8sV1PodStub(client, namespace, name); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + String namespace, ListOptions options) throws ApiException { + return K8sGenericStub.list(V1Pod.class, V1PodList.class, client, + CONTEXT, namespace, options, (clnt, nscp, + name) -> new K8sV1PodStub(clnt, nscp, name)); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java new file mode 100644 index 0000000..c46a60f --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java @@ -0,0 +1,81 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1PersistentVolumeClaim; +import io.kubernetes.client.openapi.models.V1PersistentVolumeClaimList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for pods (v1). + */ +public class K8sV1PvcStub extends + K8sGenericStub { + + /** The pods' context. */ + public static final APIResource CONTEXT + = new APIResource("", List.of("v1"), "v1", "PersistentVolumeClaim", + true, "persistentvolumeclaims", "persistentvolumeclaim"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1PvcStub(K8sClient client, String namespace, String name) { + super(V1PersistentVolumeClaim.class, V1PersistentVolumeClaimList.class, + client, CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the kpod stub + */ + public static K8sV1PvcStub get(K8sClient client, String namespace, + String name) { + return new K8sV1PvcStub(client, namespace, name); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + String namespace, ListOptions options) throws ApiException { + return K8sGenericStub.list(V1PersistentVolumeClaim.class, + V1PersistentVolumeClaimList.class, client, CONTEXT, namespace, + options, (clnt, nscp, name) -> new K8sV1PvcStub(clnt, nscp, name)); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java new file mode 100644 index 0000000..9c1c086 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java @@ -0,0 +1,92 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1SecretList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for secrets (v1). + */ +public class K8sV1SecretStub extends K8sGenericStub { + + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "Secret", true, "secrets", "secret"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1SecretStub(K8sClient client, String namespace, + String name) { + super(V1Secret.class, V1SecretList.class, client, + CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the config map stub + */ + public static K8sV1SecretStub get(K8sClient client, String namespace, + String name) { + return new K8sV1SecretStub(client, namespace, name); + } + + /** + * Creates an object stub from a model. + * + * @param client the client + * @param model the model + * @return the k 8 s dynamic stub + * @throws ApiException the api exception + */ + public static K8sV1SecretStub create(K8sClient client, V1Secret model) + throws ApiException { + return K8sGenericStub.create(V1Secret.class, + V1SecretList.class, client, CONTEXT, model, K8sV1SecretStub::new); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + String namespace, ListOptions options) throws ApiException { + return K8sGenericStub.list(V1Secret.class, V1SecretList.class, client, + CONTEXT, namespace, options, K8sV1SecretStub::new); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java new file mode 100644 index 0000000..863f86f --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java @@ -0,0 +1,79 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Service; +import io.kubernetes.client.openapi.models.V1ServiceList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for secrets (v1). + */ +public class K8sV1ServiceStub extends K8sGenericStub { + + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "Service", true, "services", "service"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1ServiceStub(K8sClient client, String namespace, + String name) { + super(V1Service.class, V1ServiceList.class, client, CONTEXT, namespace, + name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the config map stub + */ + public static K8sV1ServiceStub get(K8sClient client, String namespace, + String name) { + return new K8sV1ServiceStub(client, namespace, name); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + String namespace, ListOptions options) throws ApiException { + return K8sGenericStub.list(V1Service.class, V1ServiceList.class, client, + CONTEXT, namespace, options, + (clnt, nscp, name) -> new K8sV1ServiceStub(clnt, nscp, name)); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java new file mode 100644 index 0000000..be30b00 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java @@ -0,0 +1,62 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.models.V1StatefulSet; +import io.kubernetes.client.openapi.models.V1StatefulSetList; +import java.util.List; + +/** + * A stub for stateful sets (v1). + */ +public class K8sV1StatefulSetStub + extends K8sGenericStub { + + /** The stateful sets' context */ + public static final APIResource CONTEXT + = new APIResource("apps", List.of("v1"), "v1", "StatefulSet", true, + "statefulsets", "statefulset"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1StatefulSetStub(K8sClient client, String namespace, + String name) { + super(V1StatefulSet.class, V1StatefulSetList.class, client, CONTEXT, + namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the stateful set stub + */ + public static K8sV1StatefulSetStub get(K8sClient client, String namespace, + String name) { + return new K8sV1StatefulSetStub(client, namespace, name); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java new file mode 100644 index 0000000..a0b66bf --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -0,0 +1,499 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.models.V1Condition; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.Constants.Status.Condition; +import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason; +import org.jdrupes.vmoperator.util.DataPath; + +/** + * Represents a VM definition. + */ +@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) +public class VmDefinition extends K8sDynamicModel { + + @SuppressWarnings({ "unused" }) + private static final Logger logger + = Logger.getLogger(VmDefinition.class.getName()); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final Gson gson = new JSON().getGson(); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); + + private final Model model; + private VmExtraData extraData; + + /** + * The VM state from the VM definition. + */ + public enum RequestedVmState { + STOPPED, RUNNING + } + + /** + * Permissions for accessing and manipulating the VM. + */ + public enum Permission { + START("start"), STOP("stop"), RESET("reset"), + ACCESS_CONSOLE("accessConsole"), TAKE_CONSOLE("takeConsole"); + + @SuppressWarnings("PMD.UseConcurrentHashMap") + private static Map reprs = new HashMap<>(); + + static { + for (var value : EnumSet.allOf(Permission.class)) { + reprs.put(value.repr, value); + } + } + + private final String repr; + + Permission(String repr) { + this.repr = repr; + } + + /** + * Create permission from representation in CRD. + * + * @param value the value + * @return the permission + */ + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + public static Set parse(String value) { + if ("*".equals(value)) { + return EnumSet.allOf(Permission.class); + } + return Set.of(reprs.get(value)); + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return repr; + } + } + + /** + * Permissions granted to a user or role. + * + * @param user the user + * @param role the role + * @param may the may + */ + public record Grant(String user, String role, Set may) { + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (user != null) { + builder.append("User ").append(user); + } else { + builder.append("Role ").append(role); + } + builder.append(" may=").append(may).append(']'); + return builder.toString(); + } + } + + /** + * The assignment information. + * + * @param pool the pool + * @param user the user + * @param lastUsed the last used + */ + public record Assignment(String pool, String user, Instant lastUsed) { + } + + /** + * Instantiates a new vm definition. + * + * @param delegate the delegate + * @param json the json + */ + public VmDefinition(Gson delegate, JsonObject json) { + super(delegate, json); + model = gson.fromJson(json, Model.class); + } + + /** + * Gets the spec. + * + * @return the spec + */ + public Map spec() { + return model.getSpec(); + } + + /** + * Get a value from the spec using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromSpec(Object... selectors) { + return DataPath.get(spec(), selectors); + } + + /** + * The pools that this VM belongs to. + * + * @return the list + */ + public List pools() { + return this.> fromSpec("pools") + .orElse(Collections.emptyList()); + } + + /** + * Get a value from the `spec().get("vm")` using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromVm(Object... selectors) { + return DataPath.get(spec(), "vm") + .flatMap(vm -> DataPath.get(vm, selectors)); + } + + /** + * Gets the status. + * + * @return the status + */ + public Map status() { + return model.getStatus(); + } + + /** + * Get a value from the status using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromStatus(Object... selectors) { + return DataPath.get(status(), selectors); + } + + /** + * The assignment information. + * + * @return the optional + */ + public Optional assignment() { + return this.> fromStatus(Status.ASSIGNMENT) + .filter(m -> !m.isEmpty()).map(a -> new Assignment( + a.get("pool").toString(), a.get("user").toString(), + Instant.parse(a.get("lastUsed").toString()))); + } + + /** + * Return a condition from the status. + * + * @param name the condition's name + * @return the status, if the condition is defined + */ + public Optional condition(String name) { + return this.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(name::equals).orElse(false)) + .findFirst() + .map(cond -> objectMapper.convertValue(cond, V1Condition.class)); + } + + /** + * Return a condition's status. + * + * @param name the condition's name + * @return the status, if the condition is defined + */ + public Optional conditionStatus(String name) { + return this.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(name::equals).orElse(false)) + .findFirst().map(cond -> DataPath.get(cond, "status") + .map("True"::equals).orElse(false)); + } + + /** + * Return true if the console is in use. + * + * @return true, if successful + */ + public boolean consoleConnected() { + return conditionStatus("ConsoleConnected").orElse(false); + } + + /** + * Return the last known console user. + * + * @return the optional + */ + public Optional consoleUser() { + return this. fromStatus(Status.CONSOLE_USER); + } + + /** + * Set extra data (unknown to kubernetes). + * @return the VM definition + */ + /* default */ VmDefinition extra(VmExtraData extraData) { + this.extraData = extraData; + return this; + } + + /** + * Return the extra data. + * + * @return the data + */ + public VmExtraData extra() { + return extraData; + } + + /** + * Returns the definition's name. + * + * @return the string + */ + public String name() { + return metadata().getName(); + } + + /** + * Returns the definition's namespace. + * + * @return the string + */ + public String namespace() { + return metadata().getNamespace(); + } + + /** + * Return the requested VM state. + * + * @return the string + */ + public RequestedVmState vmState() { + return fromVm("state") + .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING + : RequestedVmState.STOPPED) + .orElse(RequestedVmState.STOPPED); + } + + /** + * Collect all permissions for the given user with the given roles. + * If permission "takeConsole" is granted, the result will also + * contain "accessConsole" to simplify checks. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + var result = this.>> fromSpec("permissions") + .orElse(Collections.emptyList()).stream() + .filter(p -> DataPath.get(p, "user").map(u -> u.equals(user)) + .orElse(false) + || DataPath.get(p, "role").map(roles::contains).orElse(false)) + .map(p -> DataPath.> get(p, "may") + .orElse(Collections.emptyList()).stream()) + .flatMap(Function.identity()) + .map(Permission::parse).map(Set::stream) + .flatMap(Function.identity()) + .collect(Collectors.toCollection(HashSet::new)); + + // Take console implies access console, simplify checks + if (result.contains(Permission.TAKE_CONSOLE)) { + result.add(Permission.ACCESS_CONSOLE); + } + return result; + } + + /** + * Check if the console is accessible. Always returns `true` if + * the VM is running and the permissions allow taking over the + * console. Else, returns `true` if + * + * * the permissions allow access to the console and + * + * * the VM is running and + * + * * the console is currently unused or used by the given user and + * + * * if user login is requested, the given user is logged in. + * + * @param user the user + * @param permissions the permissions + * @return true, if successful + */ + @SuppressWarnings("PMD.SimplifyBooleanReturns") + public boolean consoleAccessible(String user, Set permissions) { + // Basic checks + if (!conditionStatus(Condition.RUNNING).orElse(false)) { + return false; + } + if (permissions.contains(Permission.TAKE_CONSOLE)) { + return true; + } + if (!permissions.contains(Permission.ACCESS_CONSOLE)) { + return false; + } + + // If the console is in use by another user, deny access + if (conditionStatus(Condition.CONSOLE_CONNECTED).orElse(false) + && !consoleUser().map(cu -> cu.equals(user)).orElse(false)) { + return false; + } + + // If no login is requested, allow access, else check if user matches + if (condition(Condition.USER_LOGGED_IN).map(V1Condition::getReason) + .map(r -> Reason.NOT_REQUESTED.equals(r)).orElse(false)) { + return true; + } + return user.equals(status().get(Status.LOGGED_IN_USER)); + } + + /** + * Get the display password serial. + * + * @return the optional + */ + public Optional displayPasswordSerial() { + return this. fromStatus(Status.DISPLAY_PASSWORD_SERIAL) + .map(Number::longValue); + } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + return Objects.hash(metadata().getNamespace(), metadata().getName()); + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + VmDefinition other = (VmDefinition) obj; + return Objects.equals(metadata().getNamespace(), + other.metadata().getNamespace()) + && Objects.equals(metadata().getName(), other.metadata().getName()); + } + + /** + * The Class Model. + */ + public static class Model { + + private Map spec; + private Map status; + + /** + * Gets the spec. + * + * @return the spec + */ + public Map getSpec() { + return spec; + } + + /** + * Sets the spec. + * + * @param spec the spec to set + */ + public void setSpec(Map spec) { + this.spec = spec; + } + + /** + * Gets the status. + * + * @return the status + */ + public Map getStatus() { + return status; + } + + /** + * Sets the status. + * + * @param status the status to set + */ + public void setStatus(Map status) { + this.status = status; + } + + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java new file mode 100644 index 0000000..377220a --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java @@ -0,0 +1,152 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.Reader; +import java.util.Collection; + +/** + * A stub for namespaced custom objects. It uses a dynamic model + * (see {@link K8sDynamicModel}) for representing the object's + * state and can therefore be used for any kind of object, especially + * custom objects. + */ +public class VmDefinitionStub + extends K8sDynamicStubBase { + + private static DynamicTypeAdapterFactory taf = new VmDefintionModelTypeAdapterFactory(); + + /** + * Instantiates a new stub for VM defintions. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + public VmDefinitionStub(K8sClient client, APIResource context, + String namespace, String name) { + super(VmDefinition.class, VmDefinitions.class, taf, client, + context, namespace, name); + } + + /** + * Get a dynamic object stub. If the version in parameter + * `gvk` is an empty string, the stub refers to the first object with + * matching group and kind. + * + * @param client the client + * @param gvk the group, version and kind + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static VmDefinitionStub get(K8sClient client, + GroupVersionKind gvk, String namespace, String name) + throws ApiException { + return new VmDefinitionStub(client, apiResource(client, gvk), namespace, + name); + } + + /** + * Get a dynamic object stub. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static VmDefinitionStub get(K8sClient client, + APIResource context, String namespace, String name) { + return new VmDefinitionStub(client, context, namespace, name); + } + + /** + * Creates a stub from yaml. + * + * @param client the client + * @param context the context + * @param yaml the yaml + * @return the k 8 s dynamic stub + * @throws ApiException the api exception + */ + public static VmDefinitionStub createFromYaml(K8sClient client, + APIResource context, Reader yaml) throws ApiException { + var model = new VmDefinition(client.getJSON().getGson(), + K8s.yamlToJson(client, yaml)); + return K8sGenericStub.create(VmDefinition.class, + VmDefinitions.class, client, context, model, + (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace, ListOptions options) + throws ApiException { + return K8sGenericStub.list(VmDefinition.class, + VmDefinitions.class, client, context, namespace, options, + (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); + } + + /** + * Get the stubs for the objects in the given namespace. + * + * @param client the client + * @param namespace the namespace + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace) + throws ApiException { + return list(client, context, namespace, new ListOptions()); + } + + /** + * A factory for creating VmDefinitionModel(s) objects. + */ + public static class VmDefintionModelTypeAdapterFactory extends + DynamicTypeAdapterFactory { + + /** + * Instantiates a new dynamic model type adapter factory. + */ + public VmDefintionModelTypeAdapterFactory() { + super(VmDefinition.class, VmDefinitions.class); + } + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java new file mode 100644 index 0000000..c79654e --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java @@ -0,0 +1,39 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * Represents a list of {@link VmDefinition}s. + */ +public class VmDefinitions + extends K8sDynamicModelsBase { + + /** + * Initialize the object list using the given JSON data. + * + * @param delegate the gson instance to use for extracting structured data + * @param data the data + */ + public VmDefinitions(Gson delegate, JsonObject data) { + super(VmDefinition.class, delegate, data); + } +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java new file mode 100644 index 0000000..e1565c5 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java @@ -0,0 +1,179 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.util.Strings; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Represents internally used dynamic data associated with a + * {@link VmDefinition}. + */ +public class VmExtraData { + + private static final Logger logger + = Logger.getLogger(VmExtraData.class.getName()); + + private final VmDefinition vmDef; + private String nodeName = ""; + private List nodeAddresses = Collections.emptyList(); + private long resetCount; + + /** + * Initializes a new instance. + * + * @param vmDef the VM definition + */ + public VmExtraData(VmDefinition vmDef) { + this.vmDef = vmDef; + vmDef.extra(this); + } + + /** + * Sets the node info. + * + * @param name the name + * @param addresses the addresses + * @return the VM extra data + */ + public VmExtraData nodeInfo(String name, List addresses) { + nodeName = name; + nodeAddresses = addresses; + return this; + } + + /** + * Return the node name. + * + * @return the string + */ + public String nodeName() { + return nodeName; + } + + /** + * Gets the node addresses. + * + * @return the nodeAddresses + */ + public List nodeAddresses() { + return nodeAddresses; + } + + /** + * Sets the reset count. + * + * @param resetCount the reset count + * @return the vm extra data + */ + public VmExtraData resetCount(long resetCount) { + this.resetCount = resetCount; + return this; + } + + /** + * Returns the reset count. + * + * @return the long + */ + public long resetCount() { + return resetCount; + } + + /** + * Create a connection file. + * + * @param password the password + * @param preferredIpVersion the preferred IP version + * @param deleteConnectionFile the delete connection file + * @return the string + */ + public Optional connectionFile(String password, + Class preferredIpVersion, boolean deleteConnectionFile) { + var addr = displayIp(preferredIpVersion); + if (addr.isEmpty()) { + logger + .severe(() -> "Failed to find display IP for " + vmDef.name()); + return Optional.empty(); + } + var port = vmDef. fromVm("display", "spice", "port") + .map(Number::longValue); + if (port.isEmpty()) { + logger + .severe(() -> "No port defined for display of " + vmDef.name()); + return Optional.empty(); + } + StringBuffer data = new StringBuffer(100) + .append("[virt-viewer]\ntype=spice\nhost=") + .append(addr.get().getHostAddress()).append("\nport=") + .append(port.get().toString()) + .append('\n'); + if (password != null) { + data.append("password=").append(password).append('\n'); + } + vmDef. fromVm("display", "spice", "proxyUrl") + .ifPresent(u -> { + if (!Strings.isNullOrEmpty(u)) { + data.append("proxy=").append(u).append('\n'); + } + }); + if (deleteConnectionFile) { + data.append("delete-this-file=1\n"); + } + return Optional.of(data.toString()); + } + + private Optional displayIp(Class preferredIpVersion) { + Optional server = vmDef.fromVm("display", "spice", "server"); + if (server.isPresent()) { + var srv = server.get(); + try { + var addr = InetAddress.getByName(srv); + logger.fine(() -> "Using IP address from CRD for " + + vmDef.metadata().getName() + ": " + addr); + return Optional.of(addr); + } catch (UnknownHostException e) { + logger.log(Level.SEVERE, e, () -> "Invalid server address " + + srv + ": " + e.getMessage()); + return Optional.empty(); + } + } + var addrs = nodeAddresses.stream().map(a -> { + try { + return InetAddress.getByName(a); + } catch (UnknownHostException e) { + logger.warning(() -> "Invalid IP address: " + a); + return null; + } + }).filter(Objects::nonNull).toList(); + logger.fine( + () -> "Known IP addresses for " + vmDef.name() + ": " + addrs); + return addrs.stream() + .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) + .findFirst().or(() -> addrs.stream().findFirst()); + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java new file mode 100644 index 0000000..f7aaa67 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java @@ -0,0 +1,226 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmDefinition.Grant; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; +import org.jdrupes.vmoperator.util.DataPath; + +/** + * Represents a VM pool. + */ +public class VmPool { + + private final String name; + private String retention; + private boolean loginOnAssignment; + private boolean defined; + private List permissions = Collections.emptyList(); + private final Set vms + = Collections.synchronizedSet(new HashSet<>()); + + /** + * Instantiates a new vm pool. + * + * @param name the name + */ + public VmPool(String name) { + this.name = name; + } + + /** + * Fill the properties of a provisionally created pool from + * the definition. + * + * @param definition the definition + */ + public void defineFrom(VmPool definition) { + retention = definition.retention(); + permissions = definition.permissions(); + loginOnAssignment = definition.loginOnAssignment(); + defined = true; + } + + /** + * Returns the name. + * + * @return the name + */ + public String name() { + return name; + } + + /** + * Checks if is login on assignment. + * + * @return the loginOnAssignment + */ + public boolean loginOnAssignment() { + return loginOnAssignment; + } + + /** + * Checks if is defined. + * + * @return the result + */ + public boolean isDefined() { + return defined; + } + + /** + * Marks the pool as undefined. + */ + public void setUndefined() { + defined = false; + } + + /** + * Gets the retention. + * + * @return the retention + */ + public String retention() { + return retention; + } + + /** + * Permissions granted for a VM from the pool. + * + * @return the permissions + */ + public List permissions() { + return permissions; + } + + /** + * Returns the VM names. + * + * @return the vms + */ + public Set vms() { + return vms; + } + + /** + * Collect all permissions for the given user with the given roles. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + return permissions.stream() + .filter(g -> DataPath.get(g, "user").map(u -> u.equals(user)) + .orElse(false) + || DataPath.get(g, "role").map(roles::contains).orElse(false)) + .map(g -> DataPath.> get(g, "may") + .orElse(Collections.emptySet()).stream()) + .flatMap(Function.identity()).collect(Collectors.toSet()); + } + + /** + * Checks if the given VM belongs to the pool and is not in use. + * + * @param vmDef the vm def + * @return true, if is assignable + */ + @SuppressWarnings("PMD.SimplifyBooleanReturns") + public boolean isAssignable(VmDefinition vmDef) { + // Check if the VM is in the pool + if (!vmDef.pools().contains(name)) { + return false; + } + + // Check if the VM is not in use + if (vmDef.consoleConnected()) { + return false; + } + + // If not assigned, it's usable + if (vmDef.assignment().isEmpty()) { + return true; + } + + // Check if it is to be retained + if (vmDef.assignment().map(Assignment::lastUsed).map(this::retainUntil) + .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { + return false; + } + + // Additional check in case lastUsed has not been updated + // by PoolMonitor#onVmResourceChanged() yet ("race condition") + if (vmDef.condition("ConsoleConnected") + .map(cc -> cc.getLastTransitionTime().toInstant()) + .map(this::retainUntil) + .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { + return false; + } + return true; + } + + /** + * Return the instant until which an assignment should be retained. + * + * @param lastUsed the last used + * @return the instant + */ + public Instant retainUntil(Instant lastUsed) { + if (retention.startsWith("P")) { + return lastUsed.plus(Duration.parse(retention)); + } + return Instant.parse(retention); + } + + /** + * To string. + * + * @return the string + */ + @Override + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AvoidSynchronizedStatement" }) + public String toString() { + StringBuilder builder = new StringBuilder(50); + builder.append("VmPool [name=").append(name).append(", permissions=") + .append(permissions).append(", vms="); + if (vms.size() <= 3) { + builder.append(vms); + } else { + synchronized (vms) { + builder.append('[').append(vms.stream().limit(3) + .map(s -> s + ",").collect(Collectors.joining())) + .append("...]"); + } + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/.eclipse-pmd b/org.jdrupes.vmoperator.manager.events/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.manager.events/.eclipse-pmd +++ b/org.jdrupes.vmoperator.manager.events/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.manager.events/build.gradle b/org.jdrupes.vmoperator.manager.events/build.gradle index 56c364f..bb4b8d8 100644 --- a/org.jdrupes.vmoperator.manager.events/build.gradle +++ b/org.jdrupes.vmoperator.manager.events/build.gradle @@ -9,6 +9,5 @@ plugins { } dependencies { - api 'org.jgrapes:org.jgrapes.core:[1.19.0,2)' api project(':org.jdrupes.vmoperator.common') } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java new file mode 100644 index 0000000..7252c6a --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; +import org.jgrapes.core.Event; + +/** + * Assign a VM from a pool to a user. + */ +public class AssignVm extends Event { + + private final String fromPool; + private final String toUser; + + /** + * Instantiates a new event. + * + * @param fromPool the from pool + * @param toUser the to user + */ + public AssignVm(String fromPool, String toUser) { + this.fromPool = fromPool; + this.toUser = toUser; + } + + /** + * Gets the pool to assign from. + * + * @return the pool + */ + public String fromPool() { + return fromPool; + } + + /** + * Gets the user to assign to. + * + * @return the to user + */ + public String toUser() { + return toUser; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java new file mode 100644 index 0000000..2b23532 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java @@ -0,0 +1,112 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import org.jgrapes.core.Channel; + +/** + * Supports the lookup of a channel by a name (an id). As a convenience, + * it is possible to additionally associate arbitrary data with the entry + * (and thus with the channel). Note that this interface defines a + * read-only view of the dictionary. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public interface ChannelDictionary { + + /** + * Combines the channel and the associated data. + * + * @param the channel type + * @param the type of the associated data + * @param channel the channel + * @param associated the associated + */ + public record Value(C channel, A associated) { + } + + /** + * Returns all known keys. + * + * @return the keys + */ + Set keys(); + + /** + * Return all known values. + * + * @return the collection + */ + Collection> values(); + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no entry exists. + * + * @param key the key + * @return the result + */ + Optional> value(K key); + + /** + * Return all known channels. + * + * @return the collection + */ + default Collection channels() { + return values().stream().map(v -> v.channel).toList(); + } + + /** + * Returns the channel registered for the key or an empty optional + * if no mapping exists. + * + * @param key the key + * @return the optional + */ + default Optional channel(K key) { + return value(key).map(b -> b.channel); + } + + /** + * Returns all known associated data. + * + * @return the collection + */ + default Collection associated() { + return values().stream() + .filter(v -> v.associated() != null) + .map(v -> v.associated).toList(); + } + + /** + * Return the data associated with the entry for the channel. + * + * @param key the key + * @return the data + */ + default Optional associated(K key) { + return value(key).map(b -> b.associated); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java new file mode 100644 index 0000000..da36123 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java @@ -0,0 +1,179 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.jgrapes.core.Channel; + +/** + * Provides an actively managed implementation of the {@link ChannelDictionary}. + * + * The {@link ChannelManager} can be used for housekeeping by any component + * that creates channels. It can be shared between this component and + * some other component, preferably passing it as {@link ChannelDictionary} + * (the read-only view) to the second component. Alternatively, the other + * component can use a {@link ChannelTracker} to track the mappings using + * events. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public class ChannelManager + implements ChannelDictionary { + + private final Map> entries = new ConcurrentHashMap<>(); + private final Function supplier; + + /** + * Instantiates a new channel manager. + * + * @param supplier the supplier that creates new channels + */ + public ChannelManager(Function supplier) { + this.supplier = supplier; + } + + /** + * Instantiates a new channel manager without a default supplier. + */ + public ChannelManager() { + this(k -> null); + } + + /** + * Return all keys. + * + * @return the keys. + */ + @Override + public Set keys() { + return entries.keySet(); + } + + /** + * Return all known values. + * + * @return the collection + */ + @Override + public Collection> values() { + return entries.values(); + } + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no mapping exists. + * + * @param key the key + * @return the result + */ + public Optional> value(K key) { + return Optional.ofNullable(entries.get(key)); + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @param associated the associated + * @return the channel manager + */ + public ChannelManager put(K key, C channel, A associated) { + entries.put(key, new Value<>(channel, associated)); + return this; + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @return the channel manager + */ + public ChannelManager put(K key, C channel) { + put(key, channel, null); + return this; + } + + /** + * Creates a new channel without adding it to the channel manager. + * After fully initializing the channel, it should be added to the + * manager using {@link #put(K, C)}. + * + * @param key the key + * @return the c + */ + public C createChannel(K key) { + return supplier.apply(key); + } + + /** + * Returns the {@link Channel} for the given name, creating it using + * the supplier passed to the constructor if it doesn't exist yet. + * + * @param key the key + * @return the channel + */ + public C channelGet(K key) { + return computeIfAbsent(key, supplier); + } + + /** + * Returns the {@link Channel} for the given name, creating it using + * the given supplier if it doesn't exist yet. + * + * @param key the key + * @param supplier the supplier + * @return the channel + */ + public C computeIfAbsent(K key, Function supplier) { + return entries.computeIfAbsent(key, + k -> new Value<>(supplier.apply(k), null)).channel(); + } + + /** + * Associate the entry for the channel with the given data. The entry + * for the channel must already exist. + * + * @param key the key + * @param data the data + * @return the channel manager + */ + public ChannelManager associate(K key, A data) { + Optional.ofNullable(entries.computeIfPresent(key, + (k, existing) -> new Value<>(existing.channel(), data))); + return this; + } + + /** + * Removes the channel with the given name. + * + * @param name the name + */ + public void remove(String name) { + entries.remove(name); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java new file mode 100644 index 0000000..8a41908 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java @@ -0,0 +1,161 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.jgrapes.core.Channel; + +/** + * Used to track mapping from a key to a channel. Entries must + * be maintained by handlers for "add/remove" (or "open/close") + * events delivered on the channels that are to be + * made available by the tracker. + * + * The channels are stored in the dictionary using {@link WeakReference}s. + * Removing entries is therefore best practice but not an absolute necessity + * as entries for cleared references are removed when one of the methods + * {@link #values()}, {@link #channels()} or {@link #associated()} is called. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public class ChannelTracker + implements ChannelDictionary { + + private final Map> entries = new ConcurrentHashMap<>(); + + /** + * Combines the channel and associated data. + * + * @param the generic type + * @param the generic type + */ + @SuppressWarnings("PMD.ShortClassName") + private static class Data { + public final WeakReference channel; + public A associated; + + /** + * Instantiates a new value. + * + * @param channel the channel + */ + public Data(C channel) { + this.channel = new WeakReference<>(channel); + } + } + + @Override + public Set keys() { + return entries.keySet(); + } + + @Override + public Collection> values() { + var result = new ArrayList>(); + for (var itr = entries.entrySet().iterator(); itr.hasNext();) { + var value = itr.next().getValue(); + var channel = value.channel.get(); + if (channel == null) { + itr.remove(); + continue; + } + result.add(new Value<>(channel, value.associated)); + } + return result; + } + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no mapping exists. + * + * @param key the key + * @return the result + */ + public Optional> value(K key) { + var value = entries.get(key); + if (value == null) { + return Optional.empty(); + } + var channel = value.channel.get(); + if (channel == null) { + // Cleanup old reference + entries.remove(key); + return Optional.empty(); + } + return Optional.of(new Value<>(channel, value.associated)); + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @param associated the associated + * @return the channel manager + */ + public ChannelTracker put(K key, C channel, A associated) { + Data data = new Data<>(channel); + data.associated = associated; + entries.put(key, data); + return this; + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @return the channel manager + */ + public ChannelTracker put(K key, C channel) { + put(key, channel, null); + return this; + } + + /** + * Associate the entry for the channel with the given data. The entry + * for the channel must already exist. + * + * @param key the key + * @param data the data + * @return the channel manager + */ + public ChannelTracker associate(K key, A data) { + Optional.ofNullable(entries.get(key)) + .ifPresent(v -> v.associated = data); + return this; + } + + /** + * Removes the channel with the given name. + * + * @param name the name + */ + public void remove(String name) { + entries.remove(name); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java new file mode 100644 index 0000000..1c11a4e --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java @@ -0,0 +1,43 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jgrapes.core.events.Stop; + +/** + * Like {@link Stop}, but sets an exit status. + */ +@SuppressWarnings("PMD.ShortClassName") +public class Exit extends Stop { + + private final int exitStatus; + + /** + * Instantiates a new exit. + * + * @param exitStatus the exit status + */ + public Exit(int exitStatus) { + this.exitStatus = exitStatus; + } + + public int exitStatus() { + return exitStatus; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java new file mode 100644 index 0000000..dc47b4a --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java @@ -0,0 +1,92 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the current display secret and optionally updates it. + */ +public class GetDisplaySecret extends Event { + + private final VmDefinition vmDef; + private final String user; + + /** + * Instantiates a new request for the display secret. + * After handling the event, a result of `null` means that + * no secret is needed. No result means that the console + * is not accessible. + * + * @param vmDef the vm name + * @param user the requesting user + */ + public GetDisplaySecret(VmDefinition vmDef, String user) { + this.vmDef = vmDef; + this.user = user; + } + + /** + * Gets the VM definition. + * + * @return the VM definition + */ + public VmDefinition vmDefinition() { + return vmDef; + } + + /** + * Return the id of the user who has requested the password. + * + * @return the string + */ + public String user() { + return user; + } + + /** + * Returns `true` if a password is available. May only be called + * when the event is completed. Note that the password returned + * by {@link #secret()} may be `null`, indicating that no password + * is needed. + * + * @return true, if successful + */ + public boolean secretAvailable() { + if (!isDone()) { + throw new IllegalStateException("Event is not done."); + } + return !currentResults().isEmpty(); + } + + /** + * Return the secret. May only be called when the event has been + * completed with a valid result (see {@link #secretAvailable()}). + * + * @return the password. A value of `null` means that no password + * is required. + */ + public String secret() { + if (!isDone() || currentResults().isEmpty()) { + throw new IllegalStateException("Event is not done."); + } + return currentResults().get(0); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java new file mode 100644 index 0000000..b563c9c --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java @@ -0,0 +1,87 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Event; + +/** + * Gets the known pools' definitions. + */ +public class GetPools extends Event> { + + private String name; + private String user; + private List roles = Collections.emptyList(); + + /** + * Return only the pool with the given name. + * + * @param name the name + * @return the returns the vms + */ + public GetPools withName(String name) { + this.name = name; + return this; + } + + /** + * Return only {@link VmPool}s that are accessible by + * the given user or roles. + * + * @param user the user + * @param roles the roles + * @return the event + */ + public GetPools accessibleFor(String user, List roles) { + this.user = user; + this.roles = roles; + return this; + } + + /** + * Returns the name filter criterion, if set. + * + * @return the optional + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional forUser() { + return Optional.ofNullable(user); + } + + /** + * Returns the roles criterion. + * + * @return the list + */ + public List forRoles() { + return roles; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java new file mode 100644 index 0000000..0e24013 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java @@ -0,0 +1,138 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the known VMs' definitions and channels. + */ +public class GetVms extends Event> { + + private String name; + private String user; + private List roles = Collections.emptyList(); + private String fromPool; + private String toUser; + + /** + * Return only the VMs with the given name. + * + * @param name the name + * @return the returns the vms + */ + public GetVms withName(String name) { + this.name = name; + return this; + } + + /** + * Return only {@link VmDefinition}s that are accessible by + * the given user or roles. + * + * @param user the user + * @param roles the roles + * @return the event + */ + public GetVms accessibleFor(String user, List roles) { + this.user = user; + this.roles = roles; + return this; + } + + /** + * Return only {@link VmDefinition}s that are assigned from the given pool. + * + * @param pool the pool + * @return the returns the vms + */ + public GetVms assignedFrom(String pool) { + this.fromPool = pool; + return this; + } + + /** + * Return only {@link VmDefinition}s that are assigned to the given user. + * + * @param user the user + * @return the returns the vms + */ + public GetVms assignedTo(String user) { + this.toUser = user; + return this; + } + + /** + * Returns the name filter criterion, if set. + * + * @return the optional + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional user() { + return Optional.ofNullable(user); + } + + /** + * Returns the roles criterion. + * + * @return the list + */ + public List roles() { + return roles; + } + + /** + * Returns the pool filter criterion, if set. + * + * @return the optional + */ + public Optional fromPool() { + return Optional.ofNullable(fromPool); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional toUser() { + return Optional.ofNullable(toUser); + } + + /** + * Return tuple. + * + * @param definition the definition + * @param channel the channel + */ + public record VmData(VmDefinition definition, VmChannel channel) { + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java index 8f735da..9e19255 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java @@ -24,7 +24,6 @@ import org.jgrapes.core.Event; /** * Modifies a VM. */ -@SuppressWarnings("PMD.DataClass") public class ModifyVm extends Event { private final String name; diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java new file mode 100644 index 0000000..8bbcfe8 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java @@ -0,0 +1,75 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import io.kubernetes.client.openapi.models.V1Pod; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Indicates a change in a pod that runs a VM. + */ +public class PodChanged extends Event { + + private final V1Pod pod; + private final K8sObserver.ResponseType type; + + /** + * Instantiates a new VM changed event. + * + * @param pod the pod + * @param type the type + */ + public PodChanged(V1Pod pod, K8sObserver.ResponseType type) { + this.pod = pod; + this.type = type; + } + + /** + * Gets the pod. + * + * @return the pod + */ + public V1Pod pod() { + return pod; + } + + /** + * Returns the type. + * + * @return the type + */ + public K8sObserver.ResponseType type() { + return type; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(pod.getMetadata().getName()).append(' ').append(type); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java new file mode 100644 index 0000000..778820e --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java @@ -0,0 +1,47 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jgrapes.core.Event; + +/** + * Triggers a reset of the VM. + */ +public class ResetVm extends Event { + + private final String vmName; + + /** + * Instantiates a new event. + * + * @param vmName the vm name + */ + public ResetVm(String vmName) { + this.vmName = vmName; + } + + /** + * Gets the vm name. + * + * @return the vm name + */ + public String vmName() { + return vmName; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java new file mode 100644 index 0000000..b4fcf56 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Event; + +/** + * Note the assignment to a user in the VM status. + */ +public class UpdateAssignment extends Event { + + private final VmPool fromPool; + private final String toUser; + + /** + * Instantiates a new event. + * + * @param fromPool the pool from which the VM was assigned + * @param toUser the to user + */ + public UpdateAssignment(VmPool fromPool, String toUser) { + this.fromPool = fromPool; + this.toUser = toUser; + } + + /** + * Gets the pool from which the VM was assigned. + * + * @return the pool + */ + public VmPool fromPool() { + return fromPool; + } + + /** + * Gets the user to whom the VM was assigned. + * + * @return the to user + */ + public String toUser() { + return toUser; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java index bc06e68..73507ae 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java @@ -18,21 +18,21 @@ package org.jdrupes.vmoperator.manager.events; -import io.kubernetes.client.openapi.ApiClient; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; import org.jgrapes.core.EventPipeline; import org.jgrapes.core.Subchannel.DefaultSubchannel; /** * A subchannel used to send the events related to a specific VM. */ -@SuppressWarnings("PMD.DataClass") public class VmChannel extends DefaultSubchannel { private final EventPipeline pipeline; - private final ApiClient client; - private DynamicKubernetesObject vmDefinition; + private final K8sClient client; + private VmDefinition definition; private long generation = -1; /** @@ -43,7 +43,7 @@ public class VmChannel extends DefaultSubchannel { * @param client the client */ public VmChannel(Channel mainChannel, EventPipeline pipeline, - ApiClient client) { + K8sClient client) { super(mainChannel); this.pipeline = pipeline; this.client = client; @@ -55,19 +55,18 @@ public class VmChannel extends DefaultSubchannel { * @param definition the definition * @return the watch channel */ - @SuppressWarnings("PMD.LinguisticNaming") - public VmChannel setVmDefinition(DynamicKubernetesObject definition) { - this.vmDefinition = definition; + public VmChannel setVmDefinition(VmDefinition definition) { + this.definition = definition; return this; } /** * Returns the last known definition of the VM. * - * @return the json object + * @return the defintion */ - public DynamicKubernetesObject vmDefinition() { - return vmDefinition; + public VmDefinition vmDefinition() { + return definition; } /** @@ -86,7 +85,6 @@ public class VmChannel extends DefaultSubchannel { * @param generation the generation to set * @return true if value has changed */ - @SuppressWarnings("PMD.LinguisticNaming") public boolean setGeneration(long generation) { if (this.generation == generation) { return false; @@ -104,12 +102,25 @@ public class VmChannel extends DefaultSubchannel { return pipeline; } + /** + * Fire the given event on this channel, using the associated + * {@link #pipeline()}. + * + * @param the generic type + * @param event the event + * @return the t + */ + public > T fire(T event) { + pipeline.fire(event, this); + return event; + } + /** * Returns the API client. * * @return the API client */ - public ApiClient client() { + public K8sClient client() { return client; } } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java new file mode 100644 index 0000000..0c3f3a1 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java @@ -0,0 +1,87 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Indicates a change in a pool configuration. + */ +public class VmPoolChanged extends Event { + + private final VmPool vmPool; + private final boolean deleted; + + /** + * Instantiates a new VM changed event. + * + * @param pool the pool + * @param deleted true, if the pool was deleted + */ + public VmPoolChanged(VmPool pool, boolean deleted) { + vmPool = pool; + this.deleted = deleted; + } + + /** + * Instantiates a new VM changed event for an existing pool. + * + * @param pool the pool + */ + public VmPoolChanged(VmPool pool) { + this(pool, false); + } + + /** + * Returns the VM pool. + * + * @return the vm pool + */ + public VmPool vmPool() { + return vmPool; + } + + /** + * Pool has been deleted. + * + * @return true, if successful + */ + public boolean deleted() { + return deleted; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(30); + builder.append(Components.objectName(this)) + .append(" ["); + if (deleted) { + builder.append("Deleted: "); + } + builder.append(vmPool); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java similarity index 56% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java index fd5d43c..eac30fb 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java @@ -18,48 +18,42 @@ package org.jdrupes.vmoperator.manager.events; -import io.kubernetes.client.openapi.models.V1APIResource; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; /** - * Indicates a change in a VM definition. Note that the definition - * consists of the metadata (mostly immutable), the "spec" and the - * "status" parts. Consumers that are only interested in "spec" - * changes should check {@link #specChanged()} before processing - * the event any further. + * Indicates a change in a VM "resource". Note that the resource + * combines the VM CR's metadata (mostly immutable), the VM CR's + * "spec" part, the VM CR's "status" subresource and state information + * from the pod. Consumers that are only interested in "spec" changes + * should check {@link #specChanged()} before processing the event any + * further. */ @SuppressWarnings("PMD.DataClass") -public class VmDefChanged extends Event { +public class VmResourceChanged extends Event { - /** - * The type of change. - */ - public enum Type { - ADDED, MODIFIED, DELETED - } - - private final Type type; + private final K8sObserver.ResponseType type; + private final VmDefinition vmDefinition; private final boolean specChanged; - private final V1APIResource crd; - private final DynamicKubernetesObject vmDef; + private final boolean podChanged; /** * Instantiates a new VM changed event. * * @param type the type - * @param specChanged the spec part changed - * @param crd the crd * @param vmDefinition the VM definition + * @param specChanged the spec part changed */ - public VmDefChanged(Type type, boolean specChanged, V1APIResource crd, - DynamicKubernetesObject vmDefinition) { + public VmResourceChanged(K8sObserver.ResponseType type, + VmDefinition vmDefinition, boolean specChanged, + boolean podChanged) { this.type = type; + this.vmDefinition = vmDefinition; this.specChanged = specChanged; - this.crd = crd; - this.vmDef = vmDefinition; + this.podChanged = podChanged; } /** @@ -67,10 +61,19 @@ public class VmDefChanged extends Event { * * @return the type */ - public Type type() { + public K8sObserver.ResponseType type() { return type; } + /** + * Return the VM definition. + * + * @return the VM definition + */ + public VmDefinition vmDefinition() { + return vmDefinition; + } + /** * Indicates if the "spec" part changed. */ @@ -79,31 +82,19 @@ public class VmDefChanged extends Event { } /** - * Returns the Crd. - * - * @return the v 1 API resource + * Indicates if the pod status changed. */ - public V1APIResource crd() { - return crd; - } - - /** - * Returns the object. - * - * @return the object. - */ - public DynamicKubernetesObject vmDefinition() { - return vmDef; + public boolean podChanged() { + return podChanged; } @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append(Components.objectName(this)).append(" [") - .append(vmDef.getMetadata().getName()).append(' ').append(type); + .append(vmDefinition.name()).append(' ').append(type); if (channels() != null) { - builder.append(", channels="); - builder.append(Channel.toString(channels())); + builder.append(", channels=").append(Channel.toString(channels())); } builder.append(']'); return builder.toString(); diff --git a/org.jdrupes.vmoperator.manager/.eclipse-pmd b/org.jdrupes.vmoperator.manager/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.manager/.eclipse-pmd +++ b/org.jdrupes.vmoperator.manager/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.manager/.gitignore b/org.jdrupes.vmoperator.manager/.gitignore new file mode 100644 index 0000000..50a6b62 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/.gitignore @@ -0,0 +1 @@ +/logging.properties diff --git a/org.jdrupes.vmoperator.manager/.settings/net.sf.jautodoc.prefs b/org.jdrupes.vmoperator.manager/.settings/net.sf.jautodoc.prefs index 6f3b6d4..03e8200 100644 --- a/org.jdrupes.vmoperator.manager/.settings/net.sf.jautodoc.prefs +++ b/org.jdrupes.vmoperator.manager/.settings/net.sf.jautodoc.prefs @@ -1,6 +1,6 @@ add_header=true eclipse.preferences.version=1 -header_text=/*\n * VM-Operator\n * Copyright (C) 2023 Michael N. Lipp\n * \n * This program is free software\: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see .\n */ +header_text=/*\n * VM-Operator\n * Copyright (C) 2024 Michael N. Lipp\n * \n * This program is free software\: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see .\n */ project_specific_settings=true replacements=\n\n\nReturns the\nSets the\nAdds the\nEdits the\nRemoves the\nInits the\nParses the\nCreates the\nBuilds the\nChecks if is\nPrints the\nChecks for\n\n\n visibility_package=false diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index 9385a6c..4ce4ed0 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -13,17 +13,16 @@ dependencies { implementation 'commons-cli:commons-cli:1.5.0' - implementation 'org.jgrapes:org.jgrapes.core:[1.19.0,2)' - implementation 'org.jgrapes:org.jgrapes.io:[2.7.0,3)' - implementation 'org.jgrapes:org.jgrapes.http:[3.1.0,4)' - implementation 'org.jgrapes:org.jgrapes.util:[1.31.0,2)' + implementation 'org.jgrapes:org.jgrapes.util:[1.38.1,2)' + implementation 'org.jgrapes:org.jgrapes.io:[2.12.1,3)' + implementation 'org.jgrapes:org.jgrapes.http:[3.5.0,4)' - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.3.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.5.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.0.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconlet.locallogin:[1.0.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.3.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.8.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.4.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconlet.markdowndisplay:[1.2.0,2)' - runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.locallogin:[0.1.0,2)' runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.sysinfo:[1.4.0,2)' runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.logviewer:[0.2.0,2)' @@ -32,82 +31,82 @@ dependencies { runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)' runtimeOnly 'org.apache.logging.log4j:log4j-to-jul:2.20.0' - runtimeOnly project(':org.jdrupes.vmoperator.vmconlet') - - testImplementation 'io.fabric8:kubernetes-client:[6.8.1,6.9)' + runtimeOnly project(':org.jdrupes.vmoperator.vmmgmt') + runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') } application { applicationName = 'vm-manager' - applicationDefaultJvmArgs = ['-Xmx64m', '-XX:+UseParallelGC', + applicationDefaultJvmArgs = ['-Xmx128m', '-XX:+UseParallelGC', '-Djava.util.logging.manager=org.jdrupes.vmoperator.util.LongLoggingManager' ] // Define the main class for the application. mainClass = 'org.jdrupes.vmoperator.manager.Manager' } +project.ext.gitBranch = grgit.branch.current.name.replace('/', '-') +def registry = "${project.rootProject.properties['docker.registry']}" +def rootVersion = rootProject.version + task buildImage(type: Exec) { dependsOn installDist inputs.files 'src/org/jdrupes/vmoperator/manager/Containerfile' - commandLine 'podman', 'build', '-t', "${project.name}:${project.version}",\ + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}:${project.gitBranch}",\ '-f', 'src/org/jdrupes/vmoperator/manager/Containerfile', '.' } -task tagLatestImage(type: Exec) { - dependsOn buildImage - - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'tag', "${project.name}:${project.version}",\ - "${project.name}:latest" -} - -task buildLatestImage { - dependsOn buildImage - dependsOn tagLatestImage -} - task pushImage(type: Exec) { dependsOn buildImage + // Don't push without testing first + dependsOn test commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}:${project.version}" + "${project.name}:${project.gitBranch}", \ + "${registry}/${project.name}:${project.gitBranch}" } -task pushLatestImage(type: Exec) { - dependsOn buildLatestImage +task tagWithVersion(type: Exec) { + dependsOn pushImage - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ + enabled = !rootVersion.contains("SNAPSHOT") + + commandLine 'podman', 'push', \ + "${project.name}:${project.gitBranch}",\ + "${registry}/${project.name}:${project.version}" +} - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}:latest" +task tagAsLatest(type: Exec) { + dependsOn tagWithVersion + + enabled = !rootVersion.contains("SNAPSHOT") + && !rootVersion.contains("alpha") \ + && !rootVersion.contains("beta") \ + || project.rootProject.properties['docker.testRegistry'] \ + && project.rootProject.properties['docker.registry'] \ + == project.rootProject.properties['docker.testRegistry'] + + commandLine 'podman', 'push', \ + "${project.name}:${project.gitBranch}",\ + "${registry}/${project.name}:latest" +} + +task publishImage { + dependsOn pushImage + dependsOn tagWithVersion + dependsOn tagAsLatest } task pushForTest(type: Exec) { dependsOn buildImage commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ + "${project.name}:${project.gitBranch}", \ + "${project.rootProject.properties['docker.testRegistry']}" \ + "/${project.name}:test" } -task pushImages { - // Don't push without testing first - dependsOn test - dependsOn pushImage - dependsOn pushLatestImage -} - test { enabled = project.hasProperty("k8s.testCluster") diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md new file mode 100644 index 0000000..b6b9efa --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md @@ -0,0 +1,5 @@ +You can use the "puzzle piece" icon on the top right corner of the +page to add display widgets (conlets) to the overview tab. + +Use the "full screen" icon on the top right corner of any +conlet (if available) to get a detailed view. diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md new file mode 100644 index 0000000..bec5f3e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md @@ -0,0 +1,6 @@ +Verwenden Sie das "Puzzle"-Icon auf der rechten oberen Ecke +der Seite, um Anzeige-Widgets (Conlets) hinzuzufügen. + +Wenn sich in der rechten oberen Ecke eines Conlets ein Vollbild-Icon +befindet, können Sie es verwenden, um eine Detailansicht in einem neuen +Register anzufordern. diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html index a81ee0a..9c9de88 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html @@ -1,2 +1,5 @@ ${_("consoleTitle")} \ No newline at end of file + src="${renderSupport.consoleResource('VM-Operator.svg')}" + >${_("consoleTitle")}  + (<#if clusterName()??>${clusterName() + "/"}${ namespace() }) \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html index 8147dca..72596d5 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html @@ -1,3 +1,3 @@
-Copyright © Michael N. Lipp 2023 +Copyright © Michael N. Lipp 2023, 2025
diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties index ec22a06..6bcc3a2 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties @@ -17,3 +17,4 @@ # consoleTitle = VM-Operator +introTitle = Usage \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties new file mode 100644 index 0000000..dcbba93 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties @@ -0,0 +1,19 @@ +# +# VM-Operator +# Copyright (C) 2024 Michael N. Lipp +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +# for more details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program; if not, see . +# + +introTitle = Benutzung diff --git a/org.jdrupes.vmoperator.manager/logging.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties similarity index 87% rename from org.jdrupes.vmoperator.manager/logging.properties rename to org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties index 9e6d0f5..2a16af6 100644 --- a/org.jdrupes.vmoperator.manager/logging.properties +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties @@ -1,6 +1,6 @@ # # VM-Operator -# Copyright (C) 2023 Michael N. Lipp +# Copyright (C) 2025 Michael N. Lipp # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by @@ -19,10 +19,7 @@ handlers=java.util.logging.ConsoleHandler, \ org.jgrapes.webconlet.logviewer.LogViewerHandler -org.jgrapes.level=FINE -org.jgrapes.core.handlerTracking.level=FINER - -org.jdrupes.vmoperator.manager.level=FINE +org.jdrupes.vmoperator.level=FINE java.util.logging.ConsoleHandler.level=ALL java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml index d49e705..0200021 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml @@ -1,114 +1,138 @@ apiVersion: v1 kind: ConfigMap metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } - kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + - apiVersion: ${ cr.apiVersion() } + kind: ${ constants.Crd.KIND_VM } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } controller: false - + data: config.yaml: | "/Runner": # The directory used to store data files. Defaults to (depending on # values available): - # * $XDG_DATA_HOME/vmrunner/${ cr.metadata.name.asString } - # * $HOME/.local/share/vmrunner/${ cr.metadata.name.asString } - # * ./${ cr.metadata.name.asString } + # * $XDG_DATA_HOME/vmrunner/${ cr.name() } + # * $HOME/.local/share/vmrunner/${ cr.name() } + # * ./${ cr.name() } dataDir: /var/local/vm-data # The directory used to store runtime files. Defaults to (depending on # values available): - # * $XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString } - # * /tmp/$USER/vmrunner/${ cr.metadata.name.asString } - # * /tmp/vmrunner/${ cr.metadata.name.asString } - # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString }" + # * $XDG_RUNTIME_DIR/vmrunner/${ cr.name() } + # * /tmp/$USER/vmrunner/${ cr.name() } + # * /tmp/vmrunner/${ cr.name() } + # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.name() }" + <#assign spec = cr.spec() /> # The template to use. Resolved relative to /usr/share/vmrunner/templates. # template: "Standard-VM-latest.ftl.yaml" - <#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.source?? > - template: ${ cr.spec.runnerTemplate.source.asString } + <#if spec.runnerTemplate?? && spec.runnerTemplate.source?? > + template: ${ spec.runnerTemplate.source } # The template is copied to the data diretory when the VM starts for # the first time. Subsequent starts use the copy unless this option is set. - <#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.update?? > - updateTemplate: ${ cr.spec.runnerTemplate.update.asBoolean?c } + <#if spec.runnerTemplate?? && spec.runnerTemplate.update?? > + updateTemplate: ${ spec.runnerTemplate.update?c } - + + # Whether a shutdown initiated by the guest stops the pod deployment + guestShutdownStops: ${ (spec.guestShutdownStops!false)?c } + + # When incremented, the VM is reset. The value has no default value, + # i.e. if you start the VM without a value for this property, and + # decide to trigger a reset later, you have to first set the value + # and then inrement it. + resetCounter: ${ cr.extra().resetCount()?c } + + # Forward the cloud-init data if provided + <#if spec.cloudInit??> + cloudInit: + metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData!{}, cr.metadata())) } + <#if spec.cloudInit.userData??> + userData: ${ toJson(spec.cloudInit.userData) } + <#else> + userData: {} + + <#if spec.cloudInit.networkConfig??> + networkConfig: ${ toJson(spec.cloudInit.networkConfig) } + + + # Define the VM (required) vm: # The VM's name (required) - name: ${ cr.metadata.name.asString } + name: ${ cr.name() } # The machine's uuid. If none is specified, a uuid is generated # and stored in the data directory. If the uuid is important # (e.g. because licenses depend on it) it is recommaned to specify # it here explicitly or to carefully backup the data directory. # uuid: "generated uuid" - <#if cr.spec.vm.machineUuid??> - uuid: "${ cr.spec.vm.machineUuid.asString }" + <#if spec.vm.machineUuid??> + uuid: "${ spec.vm.machineUuid }" # Whether to provide a software TPM (defaults to false) # useTpm: false - useTpm: ${ cr.spec.vm.useTpm.asBoolean?c } + useTpm: ${ spec.vm.useTpm?c } # How to boot (see https://github.com/mnlipp/VM-Operator/blob/main/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml): # * bios # * uefi[-4m] # * secure[-4m] - firmware: ${ cr.spec.vm.firmware.asString } + firmware: ${ spec.vm.firmware } # Whether to show a boot menu. # bootMenu: false - bootMenu: ${ cr.spec.vm.bootMenu.asBoolean?c } + bootMenu: ${ spec.vm.bootMenu?c } # When terminating, a graceful powerdown is attempted. If it # doesn't succeed within the given timeout (seconds) SIGTERM # is sent to Qemu. # powerdownTimeout: 900 - powerdownTimeout: ${ cr.spec.vm.powerdownTimeout.asLong?c } + powerdownTimeout: ${ spec.vm.powerdownTimeout?c } # CPU settings - cpuModel: ${ cr.spec.vm.cpuModel.asString } + cpuModel: ${ spec.vm.cpuModel } # Setting maximumCpus to 1 omits the "-smp" options. The defaults (0) # cause the corresponding property to be omitted from the "-smp" option. # If currentCpus is greater than maximumCpus, the latter is adjusted. - <#if cr.spec.vm.maximumCpus?? > - maximumCpus: ${ parseQuantity(cr.spec.vm.maximumCpus.asString)?c } + <#if spec.vm.maximumCpus?? > + maximumCpus: ${ parseQuantity(spec.vm.maximumCpus)?c } - <#if cr.spec.vm.cpuTopology?? > - sockets: ${ cr.spec.vm.cpuTopology.sockets.asInt?c } - diesPerSocket: ${ cr.spec.vm.cpuTopology.diesPerSocket.asInt?c } - coresPerDie: ${ cr.spec.vm.cpuTopology.coresPerDie.asInt?c } - threadsPerCore: ${ cr.spec.vm.cpuTopology.threadsPerCore.asInt?c } + <#if spec.vm.cpuTopology?? > + sockets: ${ spec.vm.cpuTopology.sockets?c } + diesPerSocket: ${ spec.vm.cpuTopology.diesPerSocket?c } + coresPerDie: ${ spec.vm.cpuTopology.coresPerDie?c } + threadsPerCore: ${ spec.vm.cpuTopology.threadsPerCore?c } - <#if cr.spec.vm.currentCpus?? > - currentCpus: ${ parseQuantity(cr.spec.vm.currentCpus.asString)?c } + <#if spec.vm.currentCpus?? > + currentCpus: ${ parseQuantity(spec.vm.currentCpus)?c } # RAM settings # Maximum defaults to 1G - maximumRam: "${ formatMemory(parseQuantity(cr.spec.vm.maximumRam.asString)) }" - <#if cr.spec.vm.currentRam?? > - currentRam: "${ formatMemory(parseQuantity(cr.spec.vm.currentRam.asString)) }" + maximumRam: "${ formatMemory(parseQuantity(spec.vm.maximumRam)) }" + <#if spec.vm.currentRam?? > + currentRam: "${ formatMemory(parseQuantity(spec.vm.currentRam)) }" # RTC settings. # rtcBase: utc # rtcClock: rt - rtcBase: ${ cr.spec.vm.rtcBase.asString } - rtcClock: ${ cr.spec.vm.rtcClock.asString } + rtcBase: ${ spec.vm.rtcBase } + rtcClock: ${ spec.vm.rtcClock } # Network settings # Supported types are "tap" and "user" (for debugging). Type "user" @@ -120,19 +144,19 @@ data: # mac: (undefined) network: <#assign nwCounter = 0/> - <#list cr.spec.vm.networks.asList() as itf> + <#list spec.vm.networks as itf> <#if itf.tap??> - type: tap - device: ${ itf.tap.device.asString } - bridge: ${ itf.tap.bridge.asString } + device: ${ itf.tap.device } + bridge: ${ itf.tap.bridge } <#if itf.tap.mac??> - mac: "${ itf.tap.mac.asString }" + mac: "${ itf.tap.mac }" <#elseif itf.user??> - type: user - device: ${ itf.user.device.asString } + device: ${ itf.user.device } <#if itf.user.net??> - net: "${ itf.user.net.asString }" + net: "${ itf.user.net }" <#assign nwCounter += 1/> @@ -148,11 +172,11 @@ data: # file: (undefined) drives: <#assign drvCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> + <#list spec.vm.disks as disk> <#if disk.volumeClaimTemplate?? && disk.volumeClaimTemplate.metadata?? && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> + <#assign diskName = disk.volumeClaimTemplate.metadata.name + "-disk"> <#else> <#assign diskName = "disk-" + drvCounter> @@ -160,30 +184,36 @@ data: - type: raw resource: /dev/${ diskName } <#if disk.bootindex??> - bootindex: ${ disk.bootindex.asInt?c } + bootindex: ${ disk.bootindex?c } <#assign drvCounter = drvCounter + 1/> <#if disk.cdrom??> - type: ide-cd - file: "${ disk.cdrom.image.asString }" + file: "${ imageLocation(disk.cdrom.image) }" <#if disk.bootindex??> - bootindex: ${ disk.bootindex.asInt?c } + bootindex: ${ disk.bootindex?c } display: - <#if cr.spec.vm.display.spice??> + <#if spec.vm.display.outputs?? > + outputs: ${ spec.vm.display.outputs?c } + + <#if loginRequestedFor?? > + loggedInUser: "${ loginRequestedFor }" + + <#if spec.vm.display.spice??> spice: - port: ${ cr.spec.vm.display.spice.port.asInt?c } - <#if cr.spec.vm.display.spice.ticket??> - ticket: "${ cr.spec.vm.display.spice.ticket.asString }" + port: ${ spec.vm.display.spice.port?c } + <#if spec.vm.display.spice.ticket??> + ticket: "${ spec.vm.display.spice.ticket }" - <#if cr.spec.vm.display.spice.streamingVideo??> - ticket: "${ cr.spec.vm.display.spice.streamingVideo.asString }" + <#if spec.vm.display.spice.streamingVideo??> + streaming-video: "${ spec.vm.display.spice.streamingVideo }" - usbRedirects: ${ cr.spec.vm.display.spice.usbRedirects.asInt?c } + usbRedirects: ${ spec.vm.display.spice.usbRedirects?c } logging.properties: | diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml new file mode 100644 index 0000000..ddb638c --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml @@ -0,0 +1,18 @@ +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + namespace: ${ cr.namespace() } + name: ${ runnerDataPvcName } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + app.kubernetes.io/instance: ${ cr.name() } + app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } +spec: + accessModes: + - ReadWriteOnce + <#if reconciler.runnerDataPvc?? && reconciler.runnerDataPvc.storageClassName??> + storageClassName: ${ reconciler.runnerDataPvc.storageClassName } + + resources: + requests: + storage: 1Mi diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml new file mode 100644 index 0000000..8258d55 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml @@ -0,0 +1,16 @@ +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + namespace: ${ cr.namespace() } + name: ${ disk.generatedPvcName } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + app.kubernetes.io/instance: ${ cr.name() } + app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } + <#if disk.volumeClaimTemplate.metadata?? + && disk.volumeClaimTemplate.metadata.annotations??> + annotations: + ${ toJson(disk.volumeClaimTemplate.metadata.annotations) } + +spec: + ${ toJson(disk.volumeClaimTemplate.spec) } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml index 2c32aa6..b7215a5 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml @@ -1,26 +1,26 @@ apiVersion: v1 kind: Service metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } - kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + - apiVersion: ${ cr.apiVersion() } + kind: ${ constants.Crd.KIND_VM } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } controller: false spec: type: LoadBalancer ports: - name: spice - port: ${ cr.spec.vm.display.spice.port.asInt?c } + port: ${ cr.spec().vm.display.spice.port?c } selector: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml new file mode 100644 index 0000000..7518ad3 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml @@ -0,0 +1,135 @@ +kind: Pod +apiVersion: v1 +metadata: + namespace: ${ cr.namespace() } + name: ${ cr.name() } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + app.kubernetes.io/instance: ${ cr.name() } + app.kubernetes.io/component: ${ constants.APP_NAME } + app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } + annotations: + # Triggers update of config map mounted in pod + # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ + vmrunner.jdrupes.org/cmVersion: "${ configMapResourceVersion }" + vmoperator.jdrupes.org/version: ${ managerVersion } + ownerReferences: + - apiVersion: ${ cr.apiVersion() } + kind: ${ constants.Crd.KIND_VM } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } + blockOwnerDeletion: true + controller: false +<#assign spec = cr.spec() /> +spec: + containers: + - name: ${ cr.name() } + <#assign image = spec.image> + <#if image.source??> + image: ${ image.source } + <#else> + image: ${ image.repository }/${ image.path }<#if image.version??>:${ image.version } + + <#if image.pullPolicy??> + imagePullPolicy: ${ image.pullPolicy } + + <#if spec.vm.display.spice??> + ports: + <#if spec.vm.display.spice??> + - name: spice + containerPort: ${ spec.vm.display.spice.port?c } + protocol: TCP + + + volumeMounts: + # Not needed because pod is priviledged: + # - mountPath: /dev/kvm + # name: dev-kvm + # - mountPath: /dev/net/tun + # name: dev-tun + # - mountPath: /sys/fs/cgroup + # name: cgroup + - name: config + mountPath: /etc/opt/vmrunner + - name: runner-data + mountPath: /var/local/vm-data + - name: vmop-image-repository + mountPath: ${ constants.IMAGE_REPO_PATH } + volumeDevices: + <#list spec.vm.disks as disk> + <#if disk.volumeClaimTemplate??> + - name: ${ disk.generatedDiskName } + devicePath: /dev/${ disk.generatedDiskName } + + + securityContext: + privileged: true + <#if spec.resources??> + resources: ${ toJson(spec.resources) } + <#else> + <#if spec.vm.currentCpus?? || spec.vm.currentRam?? > + resources: + requests: + <#if spec.vm.currentCpus?? > + <#assign factor = 2.0 /> + <#if reconciler.cpuOvercommit??> + <#assign factor = reconciler.cpuOvercommit * 1.0 /> + + cpu: ${ (parseQuantity(spec.vm.currentCpus) / factor)?c } + + <#if spec.vm.currentRam?? > + <#assign factor = 1.25 /> + <#if reconciler.ramOvercommit??> + <#assign factor = reconciler.ramOvercommit * 1.0 /> + + memory: ${ (parseQuantity(spec.vm.currentRam) / factor)?floor?c } + + + + volumes: + # Not needed because pod is priviledged: + # - name: dev-kvm + # hostPath: + # path: /dev/kvm + # type: CharDevice + # - hostPath: + # path: /dev/net/tun + # type: CharDevice + # name: dev-tun + # - name: cgroup + # hostPath: + # path: /sys/fs/cgroup + - name: config + projected: + sources: + - configMap: + name: ${ cr.name() } + <#if displaySecret??> + - secret: + name: ${ displaySecret } + + - name: vmop-image-repository + persistentVolumeClaim: + claimName: vmop-image-repository + - name: runner-data + persistentVolumeClaim: + claimName: ${ runnerDataPvcName } + <#list spec.vm.disks as disk> + <#if disk.volumeClaimTemplate??> + - name: ${ disk.generatedDiskName } + persistentVolumeClaim: + claimName: ${ disk.generatedPvcName } + + + hostNetwork: true + terminationGracePeriodSeconds: ${ (spec.vm.powerdownTimeout + 5)?c } + <#if spec.nodeName??> + nodeName: ${ spec.nodeName } + + <#if spec.nodeSelector??> + nodeSelector: ${ toJson(spec.nodeSelector) } + + <#if spec.affinity??> + affinity: ${ toJson(spec.affinity) } + + serviceAccountName: vm-runner diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml deleted file mode 100644 index ac1178a..0000000 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml +++ /dev/null @@ -1,186 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - annotations: - vmoperator.jdrupes.org/version: ${ managerVersion } - ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } - kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } - blockOwnerDeletion: true - controller: false - -spec: - selector: - matchLabels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - replicas: ${ (cr.spec.vm.state.asString == "Running")?then(1, 0) } - template: - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/component: ${ constants.APP_NAME } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - annotations: - # Triggers update of config map mounted in pod - # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ - vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion.asString }" - vmoperator.jdrupes.org/version: ${ managerVersion } - spec: - containers: - - name: ${ cr.metadata.name.asString } - <#assign image = cr.spec.image> - <#if image.source??> - image: ${ image.source.asString } - <#else> - image: ${ image.repository.asString }/${ image.path.asString }<#if image.version??>:${ image.version.asString } - - <#if image.pullPolicy??> - imagePullPolicy: ${ image.pullPolicy.asString } - - <#if cr.spec.vm.display.spice??> - ports: - <#if cr.spec.vm.display.spice??> - - name: spice - containerPort: ${ cr.spec.vm.display.spice.port.asInt?c } - protocol: TCP - - - volumeMounts: - # Not needed because pod is priviledged: - # - mountPath: /dev/kvm - # name: dev-kvm - # - mountPath: /dev/net/tun - # name: dev-tun - # - mountPath: /sys/fs/cgroup - # name: cgroup - - name: config - mountPath: /etc/opt/vmrunner - - name: runner-data - mountPath: /var/local/vm-data - - name: vmop-image-repository - mountPath: ${ constants.IMAGE_REPO_PATH } - volumeDevices: - <#assign diskCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> - <#if disk.volumeClaimTemplate??> - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> - <#else> - <#assign diskName = "disk-" + diskCounter> - - - name: ${ diskName } - devicePath: /dev/${ diskName } - <#assign diskCounter = diskCounter + 1/> - - - securityContext: - privileged: true - <#if cr.spec.resources??> - resources: ${ cr.spec.resources.toString() } - <#else> - <#if cr.spec.vm.currentCpus?? || cr.spec.vm.currentRam?? > - resources: - requests: - <#if cr.spec.vm.currentCpus?? > - <#assign factor = 2.0 /> - <#if reconciler.cpuOvercommit??> - <#assign factor = reconciler.cpuOvercommit * 1.0 /> - - cpu: ${ (parseQuantity(cr.spec.vm.currentCpus.asString) / factor)?c } - - <#if cr.spec.vm.currentRam?? > - <#assign factor = 1.25 /> - <#if reconciler.ramOvercommit??> - <#assign factor = reconciler.ramOvercommit * 1.0 /> - - memory: ${ (parseQuantity(cr.spec.vm.currentRam.asString) / factor)?floor?c } - - - - volumes: - # Not needed because pod is priviledged: - # - name: dev-kvm - # hostPath: - # path: /dev/kvm - # type: CharDevice - # - hostPath: - # path: /dev/net/tun - # type: CharDevice - # name: dev-tun - # - name: cgroup - # hostPath: - # path: /sys/fs/cgroup - - name: config - configMap: - name: ${ cr.metadata.name.asString } - - name: vmop-image-repository - persistentVolumeClaim: - claimName: vmop-image-repository - hostNetwork: true - terminationGracePeriodSeconds: ${ (cr.spec.vm.powerdownTimeout.asInt + 5)?c } - <#if cr.spec.nodeName??> - nodeName: ${ cr.spec.nodeName.asString } - - <#if cr.spec.nodeSelector??> - nodeSelector: ${ cr.spec.nodeSelector.toString() } - - <#if cr.spec.affinity??> - affinity: ${ cr.spec.affinity.toString() } - - serviceAccountName: vm-runner - volumeClaimTemplates: - - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: runner-data - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - spec: - accessModes: - - ReadWriteOnce - <#if reconciler.runnerDataPvc?? && reconciler.runnerDataPvc.storageClassName??> - storageClassName: ${ reconciler.runnerDataPvc.storageClassName } - - resources: - requests: - storage: 1Mi - <#assign diskCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> - <#if disk.volumeClaimTemplate??> - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> - <#else> - <#assign diskName = "disk-" + diskCounter> - - - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ diskName } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.annotations??> - annotations: - ${ disk.volumeClaimTemplate.metadata.annotations.toString() } - - spec: - ${ disk.volumeClaimTemplate.spec.toString() } - <#assign diskCounter = diskCounter + 1/> - - diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java new file mode 100644 index 0000000..c10752e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java @@ -0,0 +1,251 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.manager.events.Exit; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.Components; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.util.events.ConfigurationUpdate; + +/** + * A base class for monitoring VM related resources. When started, + * it creates observers for all versions of the the {@link APIResource} + * configured by {@link #context(APIResource)}. The APIResource is not + * passed to the constructor because in some cases it has to be + * evaluated lazily. + * + * @param the object type for the context + * @param the object list type for the context + */ +public abstract class AbstractMonitor extends Component { + + private final Class objectClass; + private final Class objectListClass; + private K8sClient client; + private APIResource context; + private String namespace; + private ListOptions options = new ListOptions(); + private final AtomicInteger observerCounter = new AtomicInteger(0); + + /** + * Initializes the instance. + * + * @param componentChannel the component channel + * @param objectClass the class of the Kubernetes object to watch + * @param objectListClass the class of the list of Kubernetes objects + * to watch + */ + protected AbstractMonitor(Channel componentChannel, + Class objectClass, Class objectListClass) { + super(componentChannel); + this.objectClass = objectClass; + this.objectListClass = objectListClass; + } + + /** + * Return the client. + * + * @return the client + */ + public K8sClient client() { + return client; + } + + /** + * Sets the client to be used. + * + * @param client the client + * @return the abstract monitor + */ + public AbstractMonitor client(K8sClient client) { + this.client = client; + return this; + } + + /** + * Return the observed namespace. + * + * @return the namespace + */ + public String namespace() { + return namespace; + } + + /** + * Sets the namespace to be observed. + * + * @param namespace the namespaceToWatch to set + * @return the abstract monitor + */ + public AbstractMonitor namespace(String namespace) { + this.namespace = namespace; + return this; + } + + /** + * Returns the options for selecting the objects to observe. + * + * @return the options + */ + public ListOptions options() { + return options; + } + + /** + * Sets the options for selecting the objects to observe. + * + * @param options the options to set + * @return the abstract monitor + */ + public AbstractMonitor options(ListOptions options) { + this.options = options; + return this; + } + + /** + * Returns the observed context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Sets the context to observe. + * + * @param context the context + * @return the abstract monitor + */ + public AbstractMonitor context(APIResource context) { + this.context = context; + return this; + } + + /** + * Looks for a key "namespace" in the configuration and, if found, + * sets the namespace to its value. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(Components.manager(parent()).componentPath()) + .ifPresent(c -> { + if (c.containsKey("namespace")) { + namespace = (String) c.get("namespace"); + } + }); + } + + /** + * Handle the start event. Configures the namespace, invokes + * {@link #prepareMonitoring()} and starts the observers. + * + * @param event the event + */ + @Handler(priority = 10) + public void onStart(Start event) { + try { + // Get namespace + if (namespace == null) { + var path = Path + .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); + if (Files.isReadable(path)) { + namespace + = Files.lines(path).findFirst().orElse(null); + } + } + + // Additional preparations by derived class + prepareMonitoring(); + assert client != null; + assert context != null; + assert namespace != null; + + // Monitor all versions + for (var version : context.getVersions()) { + createObserver(version); + } + registerAsGenerator(); + } catch (IOException | ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot watch VMs, terminating."); + event.cancel(true); + fire(new Exit(1)); + } + } + + private void createObserver(String version) { + observerCounter.incrementAndGet(); + new K8sObserver<>(objectClass, objectListClass, client, + K8s.preferred(context, version), namespace, options) + .handler(this::handleChange).onTerminated((o, t) -> { + if (observerCounter.decrementAndGet() == 0) { + unregisterAsGenerator(); + } + // Exception has been logged already + if (t != null) { + fire(new Stop()); + } + }).start(); + } + + /** + * Invoked by {@link #onStart(Start)} after the namespace has + * been configured and before starting the observer. This is + * the last opportunity to invoke {@link #context(APIResource)}. + * + * @throws IOException Signals that an I/O exception has occurred. + * @throws ApiException the api exception + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void prepareMonitoring() throws IOException, ApiException { + // To be overridden by derived class. + } + + /** + * Handle an observed change. The method is invoked by the observer + * thread(s). It is the responsibility of the implementing class to + * fire derived events on the appropriate event pipeline. + * + * @param client the client + * @param change the change + */ + protected abstract void handleChange(K8sClient client, Response change); +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java index 000a21e..912b623 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java @@ -18,11 +18,17 @@ package org.jdrupes.vmoperator.manager; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Collections; +import java.util.ResourceBundle; +import java.util.stream.Collectors; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.webconsole.base.Conlet; +import org.jgrapes.webconlet.markdowndisplay.MarkdownDisplayConlet; +import org.jgrapes.webconsole.base.Conlet.RenderMode; import org.jgrapes.webconsole.base.ConsoleConnection; import org.jgrapes.webconsole.base.events.AddConletRequest; import org.jgrapes.webconsole.base.events.ConsoleConfigured; @@ -63,10 +69,13 @@ public class AvoidEmptyPolicy extends Component { * @param event the event * @param connection the connection */ - @Handler + @Handler(priority = 100) public void onRenderConlet(RenderConlet event, ConsoleConnection connection) { - connection.session().put(renderedFlagName, true); + if (event.renderAs().contains(RenderMode.Preview) + || event.renderAs().contains(RenderMode.View)) { + connection.session().put(renderedFlagName, true); + } } /** @@ -76,18 +85,42 @@ public class AvoidEmptyPolicy extends Component { * @param connection the console connection * @throws InterruptedException the interrupted exception */ - @Handler + @Handler(priority = -100) public void onConsoleConfigured(ConsoleConfigured event, ConsoleConnection connection) throws InterruptedException, IOException { - if ((Boolean) connection.session().getOrDefault( - renderedFlagName, false)) { + if ((Boolean) connection.session().getOrDefault(renderedFlagName, + false)) { return; } + var resourceBundle = ResourceBundle.getBundle( + getClass().getPackage().getName() + ".l10n", connection.locale(), + getClass().getClassLoader(), + ResourceBundle.Control.getNoFallbackControl( + ResourceBundle.Control.FORMAT_DEFAULT)); + var locale = resourceBundle.getLocale().toString(); + String shortDesc; + try (BufferedReader shortDescReader + = new BufferedReader(new InputStreamReader( + AvoidEmptyPolicy.class.getResourceAsStream( + "ManagerIntro-Preview" + (locale.isEmpty() ? "" + : "_" + locale) + ".md"), + "utf-8"))) { + shortDesc + = shortDescReader.lines().collect(Collectors.joining("\n")); + } fire(new AddConletRequest(event.event().event().renderSupport(), - "org.jdrupes.vmoperator.vmconlet.VmConlet", - Conlet.RenderMode - .asSet(Conlet.RenderMode.Preview, Conlet.RenderMode.View)), + MarkdownDisplayConlet.class.getName(), + RenderMode.asSet(RenderMode.Preview)) + .addProperty(MarkdownDisplayConlet.CONLET_ID, + getClass().getName()) + .addProperty(MarkdownDisplayConlet.TITLE, + resourceBundle.getString("consoleTitle")) + .addProperty(MarkdownDisplayConlet.PREVIEW_SOURCE, + shortDesc) + .addProperty(MarkdownDisplayConlet.DELETABLE, true) + .addProperty(MarkdownDisplayConlet.EDITABLE_BY, + Collections.EMPTY_SET), connection); } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java index 0fbb3a7..0ca6312 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java @@ -18,11 +18,18 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; +import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; import freemarker.template.TemplateException; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.utility.DeepUnwrap; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; @@ -30,13 +37,18 @@ import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.io.StringWriter; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8s; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.util.DataPath; +import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -44,7 +56,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Delegee for reconciling the config map */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") /* default */ class ConfigMapReconciler { protected final Logger logger = Logger.getLogger(getClass().getName()); @@ -62,34 +73,72 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile. * - * @param event the event * @param model the model * @param channel the channel - * @return the dynamic kubernetes object + * @param modelChanged the model has changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception - * @throws ApiException the api exception + * @throws ApiException the API exception */ - public DynamicKubernetesObject reconcile(VmDefChanged event, - Map model, VmChannel channel) + public void reconcile(Map model, VmChannel channel, + boolean modelChanged) throws IOException, TemplateException, ApiException { - // Get API - DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", - "configmaps", channel.client()); + // Check if an update is needed + var prevData = channel.associated(PrevData.class) + .orElseGet(() -> new PrevData(null, new HashMap<>())); + Object newInputs = model.get("loginRequestedFor"); + if (!modelChanged && Objects.equals(prevData.inputs, newInputs)) { + // Make added data available in new model + model.putAll(prevData.added); + return; + } + prevData = new PrevData(newInputs, prevData.added); + channel.setAssociated(PrevData.class, prevData); // Combine template and data and parse result + logger.fine(() -> "Create/update configmap " + + DataPath. get(model, "cr", "name").orElse("unknown")); + model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); + prevData.added.put("adjustCloudInitMeta", adjustCloudInitMetaModel); var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); // Avoid Yaml.load due to // https://github.com/kubernetes-client/java/issues/2741 - var mapDef = Dynamics.newFromYaml( + var newCm = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + // Maybe override logging.properties from reconciler configuration. + DataPath. get(model, "reconciler", "loggingProperties") + .ifPresent(props -> { + GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") + .get().addProperty("logging.properties", props); + }); + + // Maybe override logging.properties from VM definition. + DataPath. get(model, "cr", "spec", "loggingProperties") + .ifPresent(props -> { + GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") + .get().addProperty("logging.properties", props); + }); + + // Get API and update + DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", + "configmaps", channel.client()); + // Apply and maybe force pod update - var newState = K8s.apply(cmApi, mapDef, out.toString()); - maybeForceUpdate(channel.client(), newState); - return newState; + var updatedCm = K8s.apply(cmApi, newCm, newCm.getRaw().toString()); + maybeForceUpdate(channel.client(), updatedCm); + model.put("configMapResourceVersion", + updatedCm.getMetadata().getResourceVersion()); + prevData.added.put("configMapResourceVersion", + updatedCm.getMetadata().getResourceVersion()); + } + + /** + * Key for association. + */ + private record PrevData(Object inputs, Map added) { } /** @@ -104,14 +153,16 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," - + "app.kubernetes.io/name=" + APP_NAME); + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + newCm.getMetadata() + .getLabels().get("app.kubernetes.io/instance")); // Get pod, selected by label var podApi = new DynamicKubernetesApi("", "v1", "pods", client); var pods = podApi .list(newCm.getMetadata().getNamespace(), listOpts).getObject(); // If the VM is being created, the pod may not exist yet. - if (pods == null || pods.getItems().size() == 0) { + if (pods == null || pods.getItems().isEmpty()) { return; } var pod = pods.getItems().get(0); @@ -133,4 +184,27 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } } + private final TemplateMethodModelEx adjustCloudInitMetaModel + = new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + @SuppressWarnings("unchecked") + var res = new HashMap<>((Map) DeepUnwrap + .unwrap((TemplateModel) arguments.get(0))); + var metadata + = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) + .getAdaptedObject(Object.class); + if (!res.containsKey("instance-id")) { + res.put("instance-id", + Optional.ofNullable(metadata.getGeneration()) + .map(s -> "v" + s).orElse("v1")); + } + if (!res.containsKey("local-hostname")) { + res.put("local-hostname", metadata.getName()); + } + return res; + } + }; + } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile index c212945..08c4bff 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile @@ -1,4 +1,4 @@ -FROM docker.io/eclipse-temurin:17-jre-alpine +FROM docker.io/eclipse-temurin:21-jre-alpine COPY build/install/vm-manager /opt/vmmanager diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 3f7badb..ce14488 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023, 2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,26 +18,42 @@ package org.jdrupes.vmoperator.manager; -import io.kubernetes.client.custom.V1Patch; +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.Configuration; -import io.kubernetes.client.util.Config; -import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; +import java.util.Comparator; +import java.util.Optional; import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; -import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.AssignVm; +import org.jdrupes.vmoperator.manager.events.ChannelManager; +import org.jdrupes.vmoperator.manager.events.Exit; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.GetVms; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jdrupes.vmoperator.manager.events.ModifyVm; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.PodChanged; +import org.jdrupes.vmoperator.manager.events.UpdateAssignment; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; +import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; -import org.jgrapes.core.events.Stop; import org.jgrapes.util.events.ConfigurationUpdate; /** @@ -45,8 +61,8 @@ import org.jgrapes.util.events.ConfigurationUpdate; * [Operator Whitepaper](https://github.com/cncf/tag-app-delivery/blob/eece8f7307f2970f46f100f51932db106db46968/operator-wg/whitepaper/Operator-WhitePaper_v1-0.md#operator-components-in-kubernetes). * * The implementation splits the controller in two components. The - * {@link VmWatcher} and the {@link Reconciler}. The former watches - * the VM definitions (CRs) and generates {@link VmDefChanged} events + * {@link VmMonitor} and the {@link Reconciler}. The former watches + * the VM definitions (CRs) and generates {@link VmResourceChanged} events * when they change. The latter handles the changes and reconciles the * resources in the cluster. * @@ -79,15 +95,33 @@ import org.jgrapes.util.events.ConfigurationUpdate; public class Controller extends Component { private String namespace; + private final ChannelManager chanMgr; /** * Creates a new instance. */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public Controller(Channel componentChannel) { super(componentChannel); // Prepare component tree - attach(new VmWatcher(channel())); + chanMgr = new ChannelManager<>(name -> { + try { + return new VmChannel(channel(), newEventPipeline(), + new K8sClient()); + } catch (IOException e) { + logger.log(Level.SEVERE, e, () -> "Failed to create client" + + " for handling changes: " + e.getMessage()); + return null; + } + }); + attach(new VmMonitor(channel(), chanMgr)); + attach(new DisplaySecretMonitor(channel(), chanMgr)); + // Currently, we don't use the IP assigned by the load balancer + // to access the VM's console. Might change in the future. + // attach(new ServiceMonitor(channel()).channelManager(chanMgr)); attach(new Reconciler(channel())); + attach(new PoolMonitor(channel())); + attach(new PodMonitor(channel(), chanMgr)); } /** @@ -138,55 +172,157 @@ public class Controller extends Component { .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); if (Files.isReadable(path)) { namespace = Files.lines(path).findFirst().orElse(null); + fire(new ConfigurationUpdate().add(componentPath(), "namespace", + namespace)); } } if (namespace == null) { logger.severe(() -> "Namespace to control not configured and" + " no file in kubernetes directory."); event.cancel(true); - fire(new Stop()); + fire(new Exit(2)); return; } - logger.fine(() -> "Controlling namespace \"" + namespace + "\"."); + logger.config(() -> "Controlling namespace \"" + namespace + "\"."); } /** - * On modify vm. + * Returns the VM data. + * + * @param event the event + */ + @Handler + public void onGetVms(GetVms event) { + event.setResult(chanMgr.channels().stream() + .filter(c -> event.name().isEmpty() + || c.vmDefinition().name().equals(event.name().get())) + .filter(c -> event.user().isEmpty() && event.roles().isEmpty() + || !c.vmDefinition().permissionsFor(event.user().orElse(null), + event.roles()).isEmpty()) + .filter(c -> event.fromPool().isEmpty() + || c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool().get())).orElse(false)) + .filter(c -> event.toUser().isEmpty() + || c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser().get())).orElse(false)) + .map(c -> new VmData(c.vmDefinition(), c)) + .toList()); + } + + /** + * Assign a VM if not already assigned. * * @param event the event * @throws ApiException the api exception - * @throws IOException Signals that an I/O exception has occurred. + * @throws InterruptedException */ @Handler - public void onModigyVm(ModifyVm event) throws ApiException, IOException { - patchVmSpec(event.name(), event.path(), event.value()); + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onAssignVm(AssignVm event) + throws ApiException, InterruptedException { + while (true) { + // Search for existing assignment. + var vmQuery = chanMgr.channels().stream() + .filter(c -> c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool())).orElse(false)) + .filter(c -> c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser())).orElse(false)) + .findFirst(); + if (vmQuery.isPresent()) { + var vmDef = vmQuery.get().vmDefinition(); + event.setResult(new VmData(vmDef, vmQuery.get())); + return; + } + + // Get the pool definition for checking possible assignment + VmPool vmPool = newEventPipeline().fire(new GetPools() + .withName(event.fromPool())).get().stream().findFirst() + .orElse(null); + if (vmPool == null) { + return; + } + + // Find available VM. + vmQuery = chanMgr.channels().stream() + .filter(c -> vmPool.isAssignable(c.vmDefinition())) + .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() + .assignment().map(Assignment::lastUsed) + .orElse(Instant.ofEpochSecond(0))) + .thenComparing(preferRunning)) + .findFirst(); + + // None found + if (vmQuery.isEmpty()) { + return; + } + + // Assign to user + var chosenVm = vmQuery.get(); + if (Optional.ofNullable(chosenVm.fire(new UpdateAssignment( + vmPool, event.toUser())).get()).orElse(false)) { + var vmDef = chosenVm.vmDefinition(); + event.setResult(new VmData(vmDef, chosenVm)); + + // Make sure that a newly assigned VM is running. + chosenVm.fire(new ModifyVm(vmDef.name(), "state", "Running")); + return; + } + } } - private void patchVmSpec(String name, String path, Object value) - throws ApiException, IOException { - var crApi = K8s.crApi(Config.defaultClient(), VM_OP_GROUP, - VM_OP_KIND_VM, namespace, name); - if (crApi.isEmpty()) { - logger.warning(() -> "Trying to patch " + namespace + "/" + name - + " which does not exist."); + private static Comparator preferRunning + = new Comparator<>() { + @Override + public int compare(VmChannel ch1, VmChannel ch2) { + if (ch1.vmDefinition().conditionStatus("Running").orElse(false) + && !ch2.vmDefinition().conditionStatus("Running") + .orElse(false)) { + return -1; + } + return 0; + } + }; + + /** + * When s pool is deleted, remove all related assignments. + * + * @param event the event + * @throws InterruptedException + */ + @Handler + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onPoolChanged(VmPoolChanged event) throws InterruptedException { + if (!event.deleted()) { return; } - - // Patch running - PatchOptions patchOpts = new PatchOptions(); - patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); - String valueAsText = value instanceof String - ? "\"" + value + "\"" - : value.toString(); - var res = crApi.get().patch(namespace, name, - V1Patch.PATCH_FORMAT_JSON_PATCH, - new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/" - + path + "\", \"value\": " + valueAsText + "}]"), - patchOpts); - if (!res.isSuccess()) { - logger.warning( - () -> "Cannot patch pod annotations: " + res.getStatus()); + var vms = newEventPipeline() + .fire(new GetVms().assignedFrom(event.vmPool().name())).get(); + for (var vm : vms) { + vm.channel().fire(new UpdateAssignment(event.vmPool(), null)); } + } + /** + * Remove runner version from status when pod is deleted + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onPodChange(PodChanged event, VmChannel channel) + throws ApiException { + if (event.type() == ResponseType.DELETED) { + // Remove runner info from status + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.RUNNER_VERSION); + return status; + }); + } } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java new file mode 100644 index 0000000..b094b79 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -0,0 +1,121 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1SecretList; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.IOException; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.manager.events.ChannelDictionary; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jgrapes.core.Channel; + +/** + * Watches for changes of display secrets. Updates an artifical attribute + * of the pod running the VM in response to force an update of the files + * in the pod that reflect the information from the secret. + */ +public class DisplaySecretMonitor + extends AbstractMonitor { + + private final ChannelDictionary channelDictionary; + + /** + * Instantiates a new display secrets monitor. + * + * @param componentChannel the component channel + * @param channelDictionary the channel dictionary + */ + public DisplaySecretMonitor(Channel componentChannel, + ChannelDictionary channelDictionary) { + super(componentChannel, V1Secret.class, V1SecretList.class); + this.channelDictionary = channelDictionary; + context(K8sV1SecretStub.CONTEXT); + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME); + options(options); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + } + + @Override + protected void handleChange(K8sClient client, Response change) { + String vmName = change.object.getMetadata().getLabels() + .get("app.kubernetes.io/instance"); + if (vmName == null) { + return; + } + var channel = channelDictionary.channel(vmName).orElse(null); + if (channel == null || channel.vmDefinition() == null) { + return; + } + + try { + patchPod(client, change); + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Cannot patch pod annotations: " + e.getMessage()); + } + } + + private void patchPod(K8sClient client, Response change) + throws ApiException { + // Force update for pod + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + change.object.getMetadata() + .getLabels().get("app.kubernetes.io/instance")); + // Get pod, selected by label + var pods = K8sV1PodStub.list(client, namespace(), listOpts); + + // If the VM is being created, the pod may not exist yet. + if (pods.isEmpty()) { + return; + } + var pod = pods.iterator().next(); + + // Patch pod annotation + PatchOptions patchOpts = new PatchOptions(); + patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); + pod.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": " + + "\"/metadata/annotations/vmrunner.jdrupes.org~1dpVersion\", " + + "\"value\": \"" + + change.object.getMetadata().getResourceVersion() + + "\"}]"), + patchOpts); + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java new file mode 100644 index 0000000..1e3eb0f --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -0,0 +1,342 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import com.google.gson.JsonObject; +import freemarker.template.TemplateException; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Scanner; +import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jdrupes.vmoperator.util.DataPath; +import org.jgrapes.core.Channel; +import org.jgrapes.core.CompletionLock; +import org.jgrapes.core.Component; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jose4j.base64url.Base64; + +/** + * The properties of the display secret do not only depend on the + * VM definition, but also on events that occur during runtime. + * The reconciler for the display secret is therefore a separate + * component. + * + * The reconciler supports the following configuration properties: + * + * * `passwordValidity`: the validity of the random password in seconds. + * Used to calculate the password expiry time in the generated secret. + */ +public class DisplaySecretReconciler extends Component { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + private int passwordValidity = 10; + private final List pendingPrepares + = Collections.synchronizedList(new LinkedList<>()); + + /** + * Instantiates a new display secret reconciler. + * + * @param componentChannel the component channel + */ + public DisplaySecretReconciler(Channel componentChannel) { + super(componentChannel); + } + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()) + // for backward compatibility + .or(() -> { + var oldConfig = event + .structured("/Manager/Controller/DisplaySecretMonitor"); + if (oldConfig.isPresent()) { + logger.warning(() -> "Using configuration with old " + + "path '/Manager/Controller/DisplaySecretMonitor' " + + "for `passwordValidity`, please update " + + "the configuration."); + } + return oldConfig; + }).ifPresent(c -> { + try { + Optional.ofNullable(c.get("passwordValidity")) + .map(p -> p instanceof Integer ? (Integer) p + : Integer.valueOf((String) p)) + .ifPresent(p -> { + passwordValidity = p; + }); + } catch (NumberFormatException e) { + logger.warning( + () -> "Malformed configuration: " + e.getMessage()); + } + }); + } + + /** + * Reconcile. If the configuration prevents generating a secret + * or the secret already exists, do nothing. Else generate a new + * secret with a random password and immediate expiration, thus + * preventing access to the display. + * + * @param vmDef the VM definition + * @param model the model + * @param channel the channel + * @param specChanged the spec changed + * @throws IOException Signals that an I/O exception has occurred. + * @throws TemplateException the template exception + * @throws ApiException the api exception + */ + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) + throws IOException, TemplateException, ApiException { + // Nothing to do unless spec changed + if (!specChanged) { + return; + } + + // Secret needed at all? + var display = vmDef.fromVm("display").get(); + if (!DataPath. get(display, "spice", "generateSecret") + .orElse(true)) { + return; + } + + // Check if exists + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); + if (!stubs.isEmpty()) { + return; + } + + // Create secret + var secretName = vmDef.name() + "-" + DisplaySecret.NAME; + logger.fine(() -> "Create/update secret " + secretName); + var secret = new V1Secret(); + secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) + .name(secretName) + .putLabelsItem("app.kubernetes.io/name", APP_NAME) + .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) + .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); + secret.setType("Opaque"); + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, + DisplaySecret.EXPIRY, "now")); + K8sV1SecretStub.create(channel.client(), secret); + } + + /** + * Prepares access to the console for the user from the event. + * Generates a new password and sends it to the runner. + * Requests the VM (via the runner) to login the user if specified + * in the event. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onGetDisplaySecret(GetDisplaySecret event, VmChannel channel) + throws ApiException { + // Get VM definition and check if running + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + event.vmDefinition().namespace(), event.vmDefinition().name()); + var vmDef = vmStub.model().orElse(null); + if (vmDef == null || !vmDef.conditionStatus("Running").orElse(false)) { + return; + } + + // Update console user in status + vmDef = vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.CONSOLE_USER, event.user()); + return status; + }).get(); + + // Get secret and update password in secret + var stub = getSecretStub(event, channel, vmDef); + if (stub == null) { + return; + } + var secret = stub.model().get(); + if (!updatePassword(secret, event)) { + return; + } + + // Register wait for confirmation (by VM status change, + // after secret update) + var pending = new PendingRequest(event, + event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, + new CompletionLock(event, 1500)); + pendingPrepares.add(pending); + Event.onCompletion(event, e -> { + pendingPrepares.remove(pending); + }); + + // Update, will (eventually) trigger confirmation + stub.update(secret).getObject(); + } + + private K8sV1SecretStub getSecretStub(GetDisplaySecret event, + VmChannel channel, VmDefinition vmDef) throws ApiException { + // Look for secret + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); + if (stubs.isEmpty()) { + // No secret means no password for this VM wanted + event.setResult(null); + return null; + } + return stubs.iterator().next(); + } + + private boolean updatePassword(V1Secret secret, GetDisplaySecret event) { + var expiry = Optional.ofNullable(secret.getData() + .get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DisplaySecret.PASSWORD) != null + && stillValid(expiry)) { + // Fixed secret, don't touch + event.setResult( + new String(secret.getData().get(DisplaySecret.PASSWORD))); + return false; + } + + // Generate password and set expiry + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, + DisplaySecret.EXPIRY, + Long.toString(Instant.now().getEpochSecond() + passwordValidity))); + event.setResult(password); + return true; + } + + private boolean stillValid(String expiry) { + if (expiry == null || "never".equals(expiry)) { + return true; + } + @SuppressWarnings({ "PMD.CloseResource", "resource" }) + var scanner = new Scanner(expiry); + if (!scanner.hasNextLong()) { + return false; + } + long expTime = scanner.nextLong(); + return expTime > Instant.now().getEpochSecond() + passwordValidity; + } + + /** + * On vm def changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onVmResourceChanged(VmResourceChanged event, Channel channel) { + synchronized (pendingPrepares) { + String vmName = event.vmDefinition().name(); + for (var pending : pendingPrepares) { + if (pending.event.vmDefinition().name().equals(vmName) + && event.vmDefinition().displayPasswordSerial() + .map(s -> s >= pending.expectedSerial).orElse(false)) { + pending.lock.remove(); + // pending will be removed from pendingGest by + // waiting thread, see updatePassword + continue; + } + } + } + } + + /** + * The Class PendingGet. + */ + private static class PendingRequest { + public final GetDisplaySecret event; + public final long expectedSerial; + public final CompletionLock lock; + + /** + * Instantiates a new pending get. + * + * @param event the event + * @param expectedSerial the expected serial + */ + public PendingRequest(GetDisplaySecret event, long expectedSerial, + CompletionLock lock) { + super(); + this.event = event; + this.expectedSerial = expectedSerial; + this.lock = lock; + } + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java index efa95f4..a66b432 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java @@ -18,24 +18,25 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonObject; +import com.google.gson.Gson; import freemarker.template.Configuration; import freemarker.template.TemplateException; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1APIService; import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; import java.io.IOException; import java.io.StringWriter; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.logging.Logger; -import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sV1ServiceStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; @@ -44,7 +45,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Delegee for reconciling the service */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") /* default */ class LoadBalancerReconciler { private static final String LOAD_BALANCER_SERVICE = "loadBalancerService"; @@ -68,32 +68,47 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile. * - * @param event the event + * @param vmDef the VM definition * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefChanged event, - Map model, VmChannel channel) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { - // Check if to be generated - @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) - var lbs = Optional.of(model) - .map(m -> (Map) m.get("reconciler")) - .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE); - if (lbs instanceof Boolean isOn && !isOn) { + // Nothing to do unless spec changed + if (!specChanged) { return; } - if (!(lbs instanceof Map)) { + + // Check if to be generated + @SuppressWarnings({ "unchecked" }) + var lbsDef = Optional.of(model) + .map(m -> (Map) m.get("reconciler")) + .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE); + if (!(lbsDef instanceof Map) && !(lbsDef instanceof Boolean)) { logger.warning(() -> "\"" + LOAD_BALANCER_SERVICE + "\" in configuration must be boolean or mapping but is " - + lbs.getClass() + "."); + + lbsDef.getClass() + "."); + return; + } + if (lbsDef instanceof Boolean isOn && !isOn) { + return; + } + + // Load balancer can also be turned off for VM + if (vmDef + .>> fromSpec(LOAD_BALANCER_SERVICE) + .map(m -> m.isEmpty()).orElse(false)) { return; } // Combine template and data and parse result + logger.fine(() -> "Create/update load balancer service for " + + DataPath. get(model, "cr", "name").orElse("unknown")); var fmTemplate = fmConfig.getTemplate("runnerLoadBalancer.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -101,63 +116,78 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // https://github.com/kubernetes-client/java/issues/2741 var svcDef = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - mergeMetadata(svcDef, lbs, channel); + @SuppressWarnings("unchecked") + var defaults = lbsDef instanceof Map + ? (Map>) lbsDef + : null; + var client = channel.client(); + mergeMetadata(client.getJSON().getGson(), svcDef, defaults, vmDef); // Apply - DynamicKubernetesApi svcApi = new DynamicKubernetesApi("", "v1", - "services", channel.client()); - K8s.apply(svcApi, svcDef, svcDef.getRaw().toString()); - } - - @SuppressWarnings("unchecked") - private void mergeMetadata(DynamicKubernetesObject svcDef, - Object lbsConfig, VmChannel channel) { - // Get metadata from config - Map asmData = Collections.emptyMap(); - if (lbsConfig instanceof Map config) { - asmData = (Map) config; + var svcStub = K8sV1ServiceStub + .get(client, vmDef.namespace(), vmDef.name()); + if (svcStub.apply(svcDef).isEmpty()) { + logger.warning( + () -> "Could not patch service for " + svcStub.name()); } - var json = channel.client().getJSON(); - JsonObject cfgMeta - = json.deserialize(json.serialize(asmData), JsonObject.class); - - // Get metadata from VM definition - var vmMeta = GsonPtr.to(channel.vmDefinition().getRaw()).to("spec") - .get(JsonObject.class, LOAD_BALANCER_SERVICE) - .map(JsonObject::deepCopy).orElseGet(() -> new JsonObject()); - - // Merge Data from VM definition into config data - mergeReplace(GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class), - GsonPtr.to(vmMeta).to(LABELS).get(JsonObject.class)); - mergeReplace( - GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class), - GsonPtr.to(vmMeta).to(ANNOTATIONS).get(JsonObject.class)); - - // Merge additional data into service definition - var svcMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA); - mergeIfAbsent(svcMeta.to(LABELS).get(JsonObject.class), - GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class)); - mergeIfAbsent(svcMeta.to(ANNOTATIONS).get(JsonObject.class), - GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class)); } - private void mergeReplace(JsonObject dest, JsonObject src) { + private void mergeMetadata(Gson gson, DynamicKubernetesObject svcDef, + Map> defaults, + VmDefinition vmDefinition) { + // Get specific load balancer metadata from VM definition + var vmLbMeta = vmDefinition + .>> fromSpec(LOAD_BALANCER_SERVICE) + .orElse(Collections.emptyMap()); + + // Merge + var svcMeta = svcDef.getMetadata(); + var svcJsonMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA); + Optional.ofNullable(mergeIfAbsent(svcMeta.getLabels(), + mergeReplace(defaults.get(LABELS), vmLbMeta.get(LABELS)))) + .ifPresent(lbls -> svcJsonMeta.set(LABELS, gson.toJsonTree(lbls))); + Optional.ofNullable(mergeIfAbsent(svcMeta.getAnnotations(), + mergeReplace(defaults.get(ANNOTATIONS), vmLbMeta.get(ANNOTATIONS)))) + .ifPresent(as -> svcJsonMeta.set(ANNOTATIONS, gson.toJsonTree(as))); + } + + private Map mergeReplace(Map dest, + Map src) { + if (src == null) { + return dest; + } + if (dest == null) { + dest = new LinkedHashMap<>(); + } else { + dest = new LinkedHashMap<>(dest); + } for (var e : src.entrySet()) { - if (e.getValue().isJsonNull()) { + if (e.getValue() == null) { dest.remove(e.getKey()); continue; } - dest.add(e.getKey(), e.getValue()); + dest.put(e.getKey(), e.getValue()); } + return dest; } - private void mergeIfAbsent(JsonObject dest, JsonObject src) { + private Map mergeIfAbsent(Map dest, + Map src) { + if (src == null) { + return dest; + } + if (dest == null) { + dest = new LinkedHashMap<>(); + } else { + dest = new LinkedHashMap<>(dest); + } for (var e : src.entrySet()) { - if (dest.has(e.getKey())) { + if (dest.containsKey(e.getKey())) { continue; } - dest.add(e.getKey(), e.getValue()); + dest.put(e.getKey(), e.getValue()); } + return dest; } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java index d39718f..f431c9d 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java @@ -18,6 +18,8 @@ package org.jdrupes.vmoperator.manager; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModelException; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -27,6 +29,9 @@ import java.net.URISyntaxException; import java.nio.file.Files; import java.util.Arrays; import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; @@ -36,6 +41,7 @@ import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.manager.events.Exit; import org.jdrupes.vmoperator.util.FsdUtils; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -44,18 +50,23 @@ import org.jgrapes.core.NamedChannel; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Stop; +import org.jgrapes.http.HttpConnector; import org.jgrapes.http.HttpServer; import org.jgrapes.http.InMemorySessionManager; import org.jgrapes.http.LanguageSelector; import org.jgrapes.http.events.Request; import org.jgrapes.io.NioDispatcher; import org.jgrapes.io.util.PermitsPool; +import org.jgrapes.net.SocketConnector; import org.jgrapes.net.SocketServer; +import org.jgrapes.net.SslCodec; import org.jgrapes.util.ComponentCollector; import org.jgrapes.util.FileSystemWatcher; import org.jgrapes.util.YamlConfigurationStore; +import org.jgrapes.util.events.ConfigurationUpdate; import org.jgrapes.util.events.WatchFile; -import org.jgrapes.webconlet.locallogin.LoginConlet; +import org.jgrapes.webconlet.oidclogin.LoginConlet; +import org.jgrapes.webconlet.oidclogin.OidcClient; import org.jgrapes.webconsole.base.BrowserLocalBackedKVStore; import org.jgrapes.webconsole.base.ConletComponentFactory; import org.jgrapes.webconsole.base.ConsoleWeblet; @@ -64,41 +75,48 @@ import org.jgrapes.webconsole.base.PageResourceProviderFactory; import org.jgrapes.webconsole.base.WebConsole; import org.jgrapes.webconsole.rbac.RoleConfigurator; import org.jgrapes.webconsole.rbac.RoleConletFilter; +import org.jgrapes.webconsole.rbac.UserLogger; import org.jgrapes.webconsole.vuejs.VueJsConsoleWeblet; /** * The application class. */ +@SuppressWarnings({ "PMD.ExcessiveImports" }) public class Manager extends Component { + private static String version; private static Manager app; + private String clusterName; + private String namespace = "unknown"; + private static int exitStatus; /** * Instantiates a new manager. * @param cmdLine * * @throws IOException Signals that an I/O exception has occurred. + * @throws URISyntaxException */ - @SuppressWarnings("PMD.TooFewBranchesForASwitchStatement") - public Manager(CommandLine cmdLine) throws IOException { + @SuppressWarnings({ "PMD.NcssCount", + "PMD.ConstructorCallsOverridableMethod" }) + public Manager(CommandLine cmdLine) throws IOException, URISyntaxException { + super(new NamedChannel("manager")); // Prepare component tree attach(new NioDispatcher()); - Channel mgrChannel = new NamedChannel("manager"); - attach(new FileSystemWatcher(mgrChannel)); - attach(new Controller(mgrChannel)); + attach(new FileSystemWatcher(channel())); + attach(new Controller(channel())); // Configuration store with file in /etc/opt (default) File cfgFile = new File(cmdLine.getOptionValue('c', - "/etc/opt/" + VM_OP_NAME.replace("-", "") + "/config.yaml")) - .getCanonicalFile(); + "/etc/opt/" + VM_OP_NAME.replace("-", "") + "/config.yaml")); logger.config(() -> "Using configuration from: " + cfgFile.getPath()); // Don't rely on night config to produce a good exception // for this simple case if (!Files.isReadable(cfgFile.toPath())) { throw new IOException("Cannot read configuration file " + cfgFile); } - attach(new YamlConfigurationStore(mgrChannel, cfgFile, false)); - fire(new WatchFile(cfgFile.toPath())); + attach(new YamlConfigurationStore(channel(), cfgFile, false)); + fire(new WatchFile(cfgFile.toPath()), channel()); // Prepare GUI Channel httpTransport = new NamedChannel("guiTransport"); @@ -108,9 +126,19 @@ public class Manager extends Component { .setServerAddress(new InetSocketAddress(8080)) .setName("GuiSocketServer")); + // Channel for HTTP application layer + Channel httpChannel = new NamedChannel("guiHttp"); + + // Create network channels for client requests. + Channel requestChannel = attach(new SocketConnector(SELF)); + Channel secReqChannel + = attach(new SslCodec(SELF, requestChannel, true)); + // Support for making HTTP requests + attach(new HttpConnector(httpChannel, requestChannel, + secReqChannel)); + // Create an HTTP server as converter between transport and application // layer. - Channel httpChannel = new NamedChannel("guiHttp"); HttpServer guiHttpServer = attach(new HttpServer(httpChannel, httpTransport, Request.In.Get.class, Request.In.Post.class)); guiHttpServer.setName("GuiHttpServer"); @@ -126,7 +154,12 @@ public class Manager extends Component { return; } ConsoleWeblet consoleWeblet = guiHttpServer - .attach(new VueJsConsoleWeblet(httpChannel, Channel.SELF, rootUri)) + .attach(new VueJsConsoleWeblet(httpChannel, SELF, rootUri) { + @Override + protected Map createConsoleBaseModel() { + return augmentBaseModel(super.createConsoleBaseModel()); + } + }) .prependClassTemplateLoader(getClass()) .prependResourceBundleProvider(getClass()) .prependConsoleResourceProvider(getClass()); @@ -139,9 +172,14 @@ public class Manager extends Component { console.attach(new RoleConfigurator(console.channel())); console.attach(new RoleConletFilter(console.channel())); console.attach(new LoginConlet(console.channel())); + console.attach(new OidcClient(console.channel(), httpChannel, + httpChannel, new URI("/oauth/callback"), 1500)); + console.attach(new UserLogger(console.channel())); + // Add all available page resource providers console.attach(new ComponentCollector<>( PageResourceProviderFactory.class, console.channel())); + // Add all available conlets console.attach(new ComponentCollector<>( ConletComponentFactory.class, console.channel(), type -> { @@ -154,6 +192,46 @@ public class Manager extends Component { })); } + private Map augmentBaseModel(Map base) { + base.put("version", version); + base.put("clusterName", new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + return clusterName; + } + }); + base.put("namespace", new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + return namespace; + } + }); + return base; + } + + /** + * Configure the component. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()).ifPresent(c -> { + if (c.containsKey("clusterName")) { + clusterName = (String) c.get("clusterName"); + } else { + clusterName = null; + } + }); + event.structured(componentPath() + "/Controller").ifPresent(c -> { + if (c.containsKey("namespace")) { + namespace = (String) c.get("namespace"); + } + }); + } + /** * Log the exception when a handling error is reported. * @@ -168,6 +246,16 @@ public class Manager extends Component { event.stop(); } + /** + * On exit. + * + * @param event the event + */ + @Handler + public void onExit(Exit event) { + exitStatus = event.exitStatus(); + } + /** * On stop. * @@ -175,7 +263,7 @@ public class Manager extends Component { */ @Handler(priority = -1000) public void onStop(Stop event) { - logger.fine(() -> "Application stopped."); + logger.info(() -> "Application stopped."); } static { @@ -202,13 +290,14 @@ public class Manager extends Component { * @param args the arguments * @throws Exception the exception */ - @SuppressWarnings("PMD.SignatureDeclareThrowsException") public static void main(String[] args) { try { // Instance logger is not available yet. var logger = Logger.getLogger(Manager.class.getName()); - logger.config(() -> "Version: " - + Manager.class.getPackage().getImplementationVersion()); + version = Optional.ofNullable( + Manager.class.getPackage().getImplementationVersion()) + .orElse("unknown"); + logger.config(() -> "Version: " + version); logger.config(() -> "running on " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.version") + ")" @@ -229,14 +318,18 @@ public class Manager extends Component { try { app.fire(new Stop()); Components.awaitExhaustion(); - } catch (InterruptedException e) { + } catch (InterruptedException e) { // NOPMD // Cannot do anything about this. } })); // Start the application Components.start(app); - } catch (IOException | InterruptedException + + // Wait for (regular) termination + Components.awaitExhaustion(); + System.exit(exitStatus); + } catch (IOException | InterruptedException | URISyntaxException | org.apache.commons.cli.ParseException e) { Logger.getLogger(Manager.class.getName()).log(Level.SEVERE, e, () -> "Failed to start manager: " + e.getMessage()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java new file mode 100644 index 0000000..cfb49e5 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java @@ -0,0 +1,139 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.manager.events.ChannelDictionary; +import org.jdrupes.vmoperator.manager.events.PodChanged; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; + +/** + * Watches for changes of pods that run VMs. + */ +public class PodMonitor extends AbstractMonitor { + + private final ChannelDictionary channelDictionary; + + private final Map pendingChanges + = new ConcurrentHashMap<>(); + + /** + * Instantiates a new pod monitor. + * + * @param componentChannel the component channel + * @param channelDictionary the channel dictionary + */ + public PodMonitor(Channel componentChannel, + ChannelDictionary channelDictionary) { + super(componentChannel, V1Pod.class, V1PodList.class); + this.channelDictionary = channelDictionary; + context(K8sV1PodStub.CONTEXT); + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + APP_NAME + "," + + "app.kubernetes.io/managed-by=" + VM_OP_NAME); + options(options); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + } + + @Override + protected void handleChange(K8sClient client, Response change) { + String vmName = change.object.getMetadata().getLabels() + .get("app.kubernetes.io/instance"); + if (vmName == null) { + return; + } + var channel = channelDictionary.channel(vmName).orElse(null); + var responseType = ResponseType.valueOf(change.type); + if (channel != null && channel.vmDefinition() != null) { + pendingChanges.remove(vmName); + channel.fire(new PodChanged(change.object, responseType)); + return; + } + + // VM definition not available yet, may happen during startup + if (responseType == ResponseType.DELETED) { + return; + } + purgePendingChanges(); + logger.finer(() -> "Add pending pod change for " + vmName); + pendingChanges.put(vmName, new PendingChange(Instant.now(), change)); + } + + private void purgePendingChanges() { + Instant tooOld = Instant.now().minus(Duration.ofMinutes(15)); + for (var itr = pendingChanges.entrySet().iterator(); itr.hasNext();) { + var change = itr.next(); + if (change.getValue().from().isBefore(tooOld)) { + itr.remove(); + logger.finer( + () -> "Cleaned pending pod change for " + change.getKey()); + } + } + } + + /** + * Check for pending changes. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onVmResourceChanged(VmResourceChanged event, + VmChannel channel) { + Optional.ofNullable(pendingChanges.remove(event.vmDefinition().name())) + .map(PendingChange::change).ifPresent(change -> { + logger.finer(() -> "Firing pending pod change for " + + event.vmDefinition().name()); + channel.fire(new PodChanged(change.object, + ResponseType.valueOf(change.type))); + if (logger.isLoggable(Level.FINER) + && pendingChanges.isEmpty()) { + logger.finer("No pending pod changes left."); + } + }); + } + + private record PendingChange(Instant from, Response change) { + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java new file mode 100644 index 0000000..4733e73 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java @@ -0,0 +1,128 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import freemarker.template.Configuration; +import freemarker.template.TemplateException; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.dynamic.Dynamics; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.IOException; +import java.io.StringWriter; +import java.util.Map; +import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Delegee for reconciling the pod. + */ +/* default */ class PodReconciler { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + private final Configuration fmConfig; + + /** + * Instantiates a new pod reconciler. + * + * @param fmConfig the fm config + */ + public PodReconciler(Configuration fmConfig) { + this.fmConfig = fmConfig; + } + + /** + * Reconcile the pod. + * + * @param vmDef the vm def + * @param model the model + * @param channel the channel + * @param specChanged the spec changed + * @throws IOException Signals that an I/O exception has occurred. + * @throws TemplateException the template exception + * @throws ApiException the api exception + */ + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) + throws IOException, TemplateException, ApiException { + // Get pod stub. + var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(), + vmDef.name()); + + // Nothing to do if exists and should be running + if (vmDef.vmState() == RequestedVmState.RUNNING + && podStub.model().isPresent()) { + return; + } + + // Delete if running but should be stopped + if (vmDef.vmState() == RequestedVmState.STOPPED) { + if (podStub.model().isPresent()) { + podStub.delete(); + } + return; + } + + // Create pod. First combine template and data and parse result + logger.fine(() -> "Create/update pod " + podStub.name()); + addDisplaySecret(channel.client(), model, vmDef); + var fmTemplate = fmConfig.getTemplate("runnerPod.ftl.yaml"); + StringWriter out = new StringWriter(); + fmTemplate.process(model, out); + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + var podDef = Dynamics.newFromYaml( + new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + + // Do apply changes + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (podStub.apply(podDef).isEmpty()) { + logger.warning( + () -> "Could not patch pod for " + podStub.name()); + } + } + + private void addDisplaySecret(K8sClient client, Map model, + VmDefinition vmDef) throws ApiException { + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var dsStub = K8sV1SecretStub + .list(client, vmDef.namespace(), options).stream().findFirst(); + if (dsStub.isPresent()) { + dsStub.get().model().ifPresent(m -> { + model.put("displaySecret", m.getMetadata().getName()); + }); + } + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java new file mode 100644 index 0000000..e554d5a --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -0,0 +1,210 @@ +/* + * VM-Operator + * Copyright (C) 2023,2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.K8sDynamicModels; +import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.jgrapes.core.Channel; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Attached; + +/** + * Watches for changes of VM pools. Reports the changes using + * {@link VmPoolChanged} events fired on a special pipeline to + * avoid concurrent change informations. + */ +public class PoolMonitor extends + AbstractMonitor { + + private final Map pools = new ConcurrentHashMap<>(); + private EventPipeline poolPipeline; + + /** + * Instantiates a new VM pool manager. + * + * @param componentChannel the component channel + */ + public PoolMonitor(Channel componentChannel) { + super(componentChannel, K8sDynamicModel.class, + K8sDynamicModels.class); + } + + /** + * On attached. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public void onAttached(Attached event) { + if (event.node() == this) { + poolPipeline = newEventPipeline(); + } + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + + // Get all our API versions + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM_POOL); + if (ctx.isEmpty()) { + logger.severe(() -> "Cannot get CRD context."); + return; + } + context(ctx.get()); + } + + @Override + protected void handleChange(K8sClient client, + Watch.Response response) { + + var type = ResponseType.valueOf(response.type); + var poolName = response.object.metadata().getName(); + + // When pool is deleted, save VMs in pending + if (type == ResponseType.DELETED) { + Optional.ofNullable(pools.get(poolName)).ifPresent(pool -> { + pool.setUndefined(); + if (pool.vms().isEmpty()) { + pools.remove(poolName); + } + poolPipeline.fire(new VmPoolChanged(pool, true)); + }); + return; + } + + // Get full definition + var poolModel = response.object; + if (poolModel.data() == null) { + // ADDED event does not provide data, see + // https://github.com/kubernetes-client/java/issues/3215 + try { + poolModel = K8sDynamicStub.get(client(), context(), namespace(), + poolModel.metadata().getName()).model().orElse(null); + } catch (ApiException e) { + return; + } + } + + // Get pool and merge changes + var vmPool = pools.computeIfAbsent(poolName, k -> new VmPool(poolName)); + vmPool.defineFrom(client().getJSON().getGson().fromJson( + GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class)); + poolPipeline.fire(new VmPoolChanged(vmPool)); + } + + /** + * Track VM definition changes. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onVmResourceChanged(VmResourceChanged event) + throws ApiException { + final var vmDef = event.vmDefinition(); + final String vmName = vmDef.name(); + switch (event.type()) { + case ADDED: + vmDef.> fromSpec("pools") + .orElse(Collections.emptyList()).stream().forEach(p -> { + pools.computeIfAbsent(p, k -> new VmPool(p)) + .vms().add(vmName); + poolPipeline.fire(new VmPoolChanged(pools.get(p))); + }); + break; + case DELETED: + pools.values().stream().forEach(p -> { + if (p.vms().remove(vmName)) { + poolPipeline.fire(new VmPoolChanged(p)); + } + }); + return; + default: + break; + } + + // Sync last usage to console state change if user matches + if (vmDef.assignment().map(Assignment::user) + .map(at -> at.equals(vmDef.consoleUser().orElse(null))) + .orElse(true)) { + return; + } + + var ccChange = vmDef.condition("ConsoleConnected") + .map(cc -> cc.getLastTransitionTime().toInstant()); + if (ccChange + .map(tt -> vmDef.assignment().map(Assignment::lastUsed) + .map(alu -> alu.isAfter(tt)).orElse(true)) + .orElse(true)) { + return; + } + var vmStub = VmDefinitionStub.get(client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + // TODO + JsonObject status = from.statusJson(); + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); + assignment.set("lastUsed", ccChange.get().toString()); + return status; + }); + } + + /** + * Return the requested pools. + * + * @param event the event + */ + @Handler + public void onGetPools(GetPools event) { + event.setResult(pools.values().stream().filter(VmPool::isDefined) + .filter(p -> event.name().isEmpty() + || p.name().equals(event.name().get())) + .filter(p -> event.forUser().isEmpty() && event.forRoles().isEmpty() + || !p.permissionsFor(event.forUser().orElse(null), + event.forRoles()).isEmpty()) + .toList()); + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java new file mode 100644 index 0000000..515bfc9 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -0,0 +1,226 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import freemarker.core.ParseException; +import freemarker.template.Configuration; +import freemarker.template.MalformedTemplateNameException; +import freemarker.template.TemplateException; +import freemarker.template.TemplateNotFoundException; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.dynamic.Dynamics; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.IOException; +import java.io.StringWriter; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sV1PvcStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.util.DataPath; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Delegee for reconciling the stateful set (effectively the pod). + */ +/* default */ class PvcReconciler { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + private final Configuration fmConfig; + + /** + * Instantiates a new pvc reconciler. + * + * @param fmConfig the fm config + */ + public PvcReconciler(Configuration fmConfig) { + this.fmConfig = fmConfig; + } + + /** + * Reconcile the PVCs. + * + * @param vmDef the VM definition + * @param model the model + * @param channel the channel + * @param specChanged the spec changed + * @throws IOException Signals that an I/O exception has occurred. + * @throws TemplateException the template exception + * @throws ApiException the api exception + */ + @SuppressWarnings({ "unchecked" }) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) + throws IOException, TemplateException, ApiException { + Set knownPvcs; + if (!specChanged && channel.associated(this, Set.class).isPresent()) { + knownPvcs = (Set) channel.associated(this, Set.class).get(); + } else { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + knownPvcs = K8sV1PvcStub.list(channel.client(), + vmDef.namespace(), listOpts).stream().map(K8sV1PvcStub::name) + .collect(Collectors.toSet()); + channel.setAssociated(this, knownPvcs); + } + + // Reconcile runner data pvc + reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs, specChanged); + + // Reconcile pvcs for defined disks + var diskDefs = vmDef.>> fromVm("disks") + .orElse(List.of()); + var diskCounter = 0; + for (var diskDef : diskDefs) { + if (!diskDef.containsKey("volumeClaimTemplate")) { + continue; + } + var diskName = DataPath.get(diskDef, "volumeClaimTemplate", + "metadata", "name").map(name -> name + "-disk") + .orElse("disk-" + diskCounter); + diskCounter += 1; + diskDef.put("generatedDiskName", diskName); + + // Don't do anything if pvc with old (sts generated) name exists. + var stsDiskPvcName = diskName + "-" + vmDef.name() + "-0"; + if (knownPvcs.contains(stsDiskPvcName)) { + diskDef.put("generatedPvcName", stsDiskPvcName); + continue; + } + + // Update PVC + reconcileRunnerDiskPvc(vmDef, model, channel, specChanged, diskDef); + } + } + + private void reconcileRunnerDataPvc(VmDefinition vmDef, + Map model, VmChannel channel, + Set knownPvcs, boolean specChanged) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException, TemplateException, ApiException { + + // Look for old (sts generated) name. + var stsRunnerDataPvcName + = "runner-data" + "-" + vmDef.name() + "-0"; + if (knownPvcs.contains(stsRunnerDataPvcName)) { + model.put("runnerDataPvcName", stsRunnerDataPvcName); + return; + } + + // Generate PVC + var runnerDataPvcName = vmDef.name() + "-runner-data"; + logger.fine(() -> "Create/update pvc " + runnerDataPvcName); + model.put("runnerDataPvcName", runnerDataPvcName); + if (!specChanged) { + // Augmenting the model is all we have to do + return; + } + var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml"); + StringWriter out = new StringWriter(); + fmTemplate.process(model, out); + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + var pvcDef = Dynamics.newFromYaml( + new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + + // Do apply changes + var pvcStub = K8sV1PvcStub.get(channel.client(), + vmDef.namespace(), (String) model.get("runnerDataPvcName")); + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) + .isEmpty()) { + logger.warning( + () -> "Could not patch pvc for " + pvcStub.name()); + } + } + + private void reconcileRunnerDiskPvc(VmDefinition vmDef, + Map model, VmChannel channel, boolean specChanged, + Map diskDef) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException, TemplateException, ApiException { + // Generate PVC + var pvcName = vmDef.name() + "-" + diskDef.get("generatedDiskName"); + diskDef.put("generatedPvcName", pvcName); + if (!specChanged) { + // Augmenting the model is all we have to do + return; + } + + // Generate PVC + logger.fine(() -> "Create/update pvc " + pvcName); + model.put("disk", diskDef); + var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml"); + StringWriter out = new StringWriter(); + fmTemplate.process(model, out); + model.remove("disk"); + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + var pvcDef = Dynamics.newFromYaml( + new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + + // Apply changes + var pvcStub + = K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName); + var pvc = pvcStub.model(); + if (pvc.isEmpty() + || !"Bound".equals(pvc.get().getStatus().getPhase())) { + // Does not exist or isn't bound, use apply + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) + .isEmpty()) { + logger.warning( + () -> "Could not patch pvc for " + pvcStub.name()); + } + return; + } + + // If bound, use json merge, omitting immutable fields + var spec = GsonPtr.to(pvcDef.getRaw()).to("spec"); + spec.removeExcept("volumeAttributesClassName", "resources"); + spec.get("resources").ifPresent(p -> p.removeExcept("requests")); + PatchOptions opts = new PatchOptions(); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (pvcStub.patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, + new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) + .isEmpty()) { + logger.warning( + () -> "Could not patch pvc for " + pvcStub.name()); + } + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index c3fee7d..e580c48 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -18,37 +18,40 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import freemarker.core.ParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; -import freemarker.template.DefaultObjectWrapperBuilder; -import freemarker.template.MalformedTemplateNameException; import freemarker.template.SimpleNumber; +import freemarker.template.SimpleScalar; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; -import freemarker.template.TemplateHashModel; import freemarker.template.TemplateMethodModelEx; import freemarker.template.TemplateModelException; -import freemarker.template.TemplateNotFoundException; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import java.io.IOException; +import java.lang.reflect.Modifier; import java.math.BigDecimal; import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.logging.Level; import org.jdrupes.vmoperator.common.Convertions; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; -import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -65,20 +68,25 @@ import org.jgrapes.util.events.ConfigurationUpdate; * * * A [`ConfigMap`](https://kubernetes.io/docs/concepts/configuration/configmap/) * that defines the configuration file for the runner. - * - * * A [`StatefulSet`](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) - * that creates - * * the [`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/) - * with the Runner instance, - * * a PVC for 1 MiB of persistent storage used by the Runner - * (referred to as the "runnerDataPvc") and - * * the PVCs for the VM's disks. - * + * + * * A [`PVC`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) + * for 1 MiB of persistent storage used by the Runner (referred to as the + * "runnerDataPvc") + * + * * The PVCs for the VM's disks. + * + * * A [`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/) with the + * runner instance[^oldSts]. + * * * (Optional) A load balancer * [`Service`](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/) * that allows the user to access a VM's console without knowing which * node it runs on. * + * [^oldSts]: Before version 3.4, the operator created a + * [`StatefulSet`](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) + * that created the pod. + * * The reconciler is part of the {@link Controller} component. It's * configuration properties are therefore defined in * ```yaml @@ -121,15 +129,26 @@ import org.jgrapes.util.events.ConfigurationUpdate; * ``` * This makes all VM consoles available at IP address 192.168.168.1 * with the port numbers from the VM definitions. + * + * * `loggingProperties`: If defined, specifies the default logging + * properties to be used by the runners managed by the controller. + * This property is a string that holds the content of + * a logging.properties file. + * + * @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.AvoidDuplicateLiterals" }) +@SuppressWarnings({ "PMD.AvoidDuplicateLiterals" }) public class Reconciler extends Component { - @SuppressWarnings("PMD.SingularField") + /** The Constant mapper. */ + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + private final Configuration fmConfig; private final ConfigMapReconciler cmReconciler; - private final StatefulSetReconciler stsReconciler; + private final DisplaySecretReconciler dsReconciler; + private final PvcReconciler pvcReconciler; + private final PodReconciler podReconciler; private final LoadBalancerReconciler lbReconciler; @SuppressWarnings("PMD.UseConcurrentHashMap") private final Map config = new HashMap<>(); @@ -139,6 +158,7 @@ public class Reconciler extends Component { * * @param componentChannel the component channel */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public Reconciler(Channel componentChannel) { super(componentChannel); @@ -153,7 +173,9 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); - stsReconciler = new StatefulSetReconciler(fmConfig); + dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); + pvcReconciler = new PvcReconciler(fmConfig); + podReconciler = new PodReconciler(fmConfig); lbReconciler = new LoadBalancerReconciler(fmConfig); } @@ -175,92 +197,131 @@ public class Reconciler extends Component { * @param event the event * @param channel the channel * @throws ApiException the api exception - * @throws IOException - * @throws ParseException - * @throws MalformedTemplateNameException - * @throws TemplateNotFoundException - * @throws TemplateException - * @throws KubectlException + * @throws TemplateException the template exception + * @throws IOException Signals that an I/O exception has occurred. */ @Handler - @SuppressWarnings("PMD.ConfusingTernary") - public void onVmDefChanged(VmDefChanged event, VmChannel channel) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) throws ApiException, TemplateException, IOException { - // We're only interested in "spec" changes. - if (!event.specChanged()) { - return; - } - // Ownership relationships takes care of deletions - var defMeta = event.vmDefinition().getMetadata(); - if (event.type() == Type.DELETED) { - logger.fine(() -> "VM \"" + defMeta.getName() + "\" deleted"); + if (event.type() == K8sObserver.ResponseType.DELETED) { return; } - // Reconcile, use "augmented" vm definition for model - Map model = prepareModel(patchCr(event.vmDefinition())); - var configMap = cmReconciler.reconcile(event, model, channel); - model.put("cm", configMap.getRaw()); - stsReconciler.reconcile(event, model, channel); - lbReconciler.reconcile(event, model, channel); - } + // Create model for processing templates + var vmDef = event.vmDefinition(); + Map model = prepareModel(vmDef); + cmReconciler.reconcile(model, channel, event.specChanged()); - private DynamicKubernetesObject patchCr(DynamicKubernetesObject vmDef) { - var json = vmDef.getRaw().deepCopy(); - // Adjust cdromImage path - var disks - = GsonPtr.to(json).to("spec", "vm", "disks").get(JsonArray.class); - for (var disk : disks) { - var cdrom = (JsonObject) ((JsonObject) disk).get("cdrom"); - if (cdrom == null) { - continue; - } - String image = cdrom.get("image").getAsString(); - if (image.isEmpty()) { - continue; - } - try { - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH - + "/").resolve(image); - if ("file".equals(imageUri.getScheme())) { - cdrom.addProperty("image", imageUri.getPath()); - } else { - cdrom.addProperty("image", imageUri.toString()); - } - } catch (URISyntaxException e) { - logger.warning(() -> "Invalid CDROM image: " + image); - } + // The remaining reconcilers depend only on changes of the spec part + // or the pod state. + if (!event.specChanged() && !event.podChanged()) { + return; } - return new DynamicKubernetesObject(json); + dsReconciler.reconcile(vmDef, model, channel, event.specChanged()); + pvcReconciler.reconcile(vmDef, model, channel, event.specChanged()); + podReconciler.reconcile(vmDef, model, channel, event.specChanged()); + lbReconciler.reconcile(vmDef, model, channel, event.specChanged()); } - @SuppressWarnings("PMD.CognitiveComplexity") - private Map prepareModel(DynamicKubernetesObject vmDef) - throws TemplateModelException { + /** + * Reset the VM by incrementing the reset count and doing a + * partial reconcile (configmap only). + * + * @param event the event + * @param channel the channel + * @throws IOException + * @throws ApiException + * @throws TemplateException + */ + @Handler + public void onResetVm(ResetVm event, VmChannel channel) + throws ApiException, IOException, TemplateException { + var vmDef = channel.vmDefinition(); + var extra = vmDef.extra(); + extra.resetCount(extra.resetCount() + 1); + Map model + = prepareModel(channel.vmDefinition()); + cmReconciler.reconcile(model, channel, true); + } + + private Map prepareModel(VmDefinition vmDef) + throws TemplateModelException, ApiException { @SuppressWarnings("PMD.UseConcurrentHashMap") Map model = new HashMap<>(); model.put("managerVersion", Optional.ofNullable(Reconciler.class.getPackage() .getImplementationVersion()).orElse("(Unknown)")); - model.put("cr", vmDef.getRaw()); - model.put("constants", - (TemplateHashModel) new DefaultObjectWrapperBuilder( - Configuration.VERSION_2_3_32) - .build().getStaticModels() - .get(Constants.class.getName())); + model.put("cr", vmDef); model.put("reconciler", config); + model.put("constants", constantsMap(Constants.class)); + addLoginRequestedFor(model, vmDef); // Methods - model.put("parseQuantity", new TemplateMethodModelEx() { + model.put("parseQuantity", parseQuantityModel); + model.put("formatMemory", formatMemoryModel); + model.put("imageLocation", imgageLocationModel); + model.put("toJson", toJsonModel); + return model; + } + + /** + * Creates a map with constants. Needed because freemarker doesn't support + * nested classes with its static models. + * + * @param clazz the clazz + * @return the map + */ + @SuppressWarnings("PMD.EmptyCatchBlock") + private Map constantsMap(Class clazz) { + @SuppressWarnings("PMD.UseConcurrentHashMap") + Map result = new HashMap<>(); + Arrays.stream(clazz.getFields()).filter(f -> { + var modifiers = f.getModifiers(); + return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers) + && f.getType() == String.class; + }).forEach(f -> { + try { + result.put(f.getName(), f.get(null)); + } catch (IllegalArgumentException | IllegalAccessException e) { + // Should not happen, ignore + } + }); + Arrays.stream(clazz.getClasses()).filter(c -> { + var modifiers = c.getModifiers(); + return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers); + }).forEach(c -> { + result.put(c.getSimpleName(), constantsMap(c)); + }); + return result; + } + + private void addLoginRequestedFor(Map model, + VmDefinition vmDef) { + vmDef.assignment().filter(a -> { + try { + return newEventPipeline() + .fire(new GetPools().withName(a.pool())).get() + .stream().findFirst().map(VmPool::loginOnAssignment) + .orElse(false); + } catch (InterruptedException e) { + logger.log(Level.WARNING, e, e::getMessage); + } + return false; + }).map(Assignment::user) + .or(() -> vmDef.fromSpec("vm", "display", "loggedInUser")) + .ifPresent(u -> model.put("loginRequestedFor", u)); + } + + private final TemplateMethodModelEx parseQuantityModel + = new TemplateMethodModelEx() { @Override @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { var arg = arguments.get(0); - if (arg instanceof Number number) { - return number; + if (arg instanceof SimpleNumber number) { + return number.getAsNumber(); } try { return Quantity.fromString(arg.toString()).getNumber(); @@ -269,10 +330,11 @@ public class Reconciler extends Component { + "specified as \"" + arg + "\": " + e.getMessage()); } } - }); - model.put("formatMemory", new TemplateMethodModelEx() { + }; + + private final TemplateMethodModelEx formatMemoryModel + = new TemplateMethodModelEx() { @Override - @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { var arg = arguments.get(0); @@ -297,8 +359,45 @@ public class Reconciler extends Component { } return Convertions.formatMemory(bigInt); } - }); - return model; - } + }; + private final TemplateMethodModelEx imgageLocationModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + var image = ((SimpleScalar) arguments.get(0)).getAsString(); + if (image.isEmpty()) { + return ""; + } + try { + var imageUri + = new URI("file://" + Constants.IMAGE_REPO_PATH + "/") + .resolve(image); + if ("file".equals(imageUri.getScheme())) { + return imageUri.getPath(); + } + return imageUri.toString(); + } catch (URISyntaxException e) { + logger.warning(() -> "Invalid CDROM image: " + image); + } + return image; + } + }; + + private final TemplateMethodModelEx toJsonModel + = new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + try { + return mapper.writeValueAsString( + ((AdapterTemplateModel) arguments.get(0)) + .getAdaptedObject(Object.class)); + } catch (JsonProcessingException e) { + return "{}"; + } + } + }; } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java deleted file mode 100644 index 3cd6a39..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2023 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager; - -import freemarker.template.Configuration; -import freemarker.template.TemplateException; -import io.kubernetes.client.custom.V1Patch; -import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.dynamic.Dynamics; -import io.kubernetes.client.util.generic.options.PatchOptions; -import java.io.IOException; -import java.io.StringWriter; -import java.util.Map; -import java.util.logging.Logger; -import org.jdrupes.vmoperator.common.K8s; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; -import org.yaml.snakeyaml.LoaderOptions; -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.constructor.SafeConstructor; - -/** - * Delegee for reconciling the stateful set (effectively the pod). - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -/* default */ class StatefulSetReconciler { - - protected final Logger logger = Logger.getLogger(getClass().getName()); - private final Configuration fmConfig; - - /** - * Instantiates a new config map reconciler. - * - * @param fmConfig the fm config - */ - public StatefulSetReconciler(Configuration fmConfig) { - this.fmConfig = fmConfig; - } - - /** - * Reconcile stateful set. - * - * @param event the event - * @param model the model - * @param channel the channel - * @throws IOException Signals that an I/O exception has occurred. - * @throws TemplateException the template exception - * @throws ApiException the api exception - */ - public void reconcile(VmDefChanged event, Map model, - VmChannel channel) - throws IOException, TemplateException, ApiException { - DynamicKubernetesApi stsApi = new DynamicKubernetesApi("apps", "v1", - "statefulsets", channel.client()); - var metadata = event.vmDefinition().getMetadata(); - - // Combine template and data and parse result - var fmTemplate = fmConfig.getTemplate("runnerSts.ftl.yaml"); - StringWriter out = new StringWriter(); - fmTemplate.process(model, out); - // Avoid Yaml.load due to - // https://github.com/kubernetes-client/java/issues/2741 - var stsDef = Dynamics.newFromYaml( - new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - - // If exists apply changes only when transitioning state - // or not running. - var existing = K8s.get(stsApi, metadata); - if (existing.isPresent()) { - var current = GsonPtr.to(existing.get().getRaw()) - .to("spec").getAsInt("replicas").orElse(1); - var desired = GsonPtr.to(stsDef.getRaw()) - .to("spec").getAsInt("replicas").orElse(1); - if (current == 1 && desired == 1) { - return; - } - } - - // Do apply changes - PatchOptions opts = new PatchOptions(); - opts.setForce(true); - opts.setFieldManager("kubernetes-java-kubectl-apply"); - stsApi.patch(stsDef.getMetadata().getNamespace(), - stsDef.getMetadata().getName(), V1Patch.PATCH_FORMAT_APPLY_YAML, - new V1Patch(channel.client().getJSON().serialize(stsDef)), - opts).throwsApiException(); - } - -} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java new file mode 100644 index 0000000..22f083c --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -0,0 +1,326 @@ +/* + * VM-Operator + * Copyright (C) 2023,2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; +import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmDefinitions; +import org.jdrupes.vmoperator.common.VmExtraData; +import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; +import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.manager.events.ChannelManager; +import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PodChanged; +import org.jdrupes.vmoperator.manager.events.UpdateAssignment; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.annotation.Handler; + +/** + * Watches for changes of VM definitions. When a VM definition (CR) + * becomes known, is is registered with a {@link ChannelManager} and thus + * gets an associated {@link VmChannel} and an associated + * {@link EventPipeline}. + * + * The {@link EventPipeline} is used for submitting an action that processes + * the change data from kubernetes, eventually transforming it to a + * {@link VmResourceChanged} event that is handled by another + * {@link EventPipeline} associated with the {@link VmChannel}. This + * event pipeline should be used for all events related to changes of + * a particular VM. + */ +public class VmMonitor extends + AbstractMonitor { + + private final ChannelManager channelManager; + + /** + * Instantiates a new VM definition watcher. + * + * @param componentChannel the component channel + * @param channelManager the channel manager + */ + public VmMonitor(Channel componentChannel, + ChannelManager channelManager) { + super(componentChannel, VmDefinition.class, + VmDefinitions.class); + this.channelManager = channelManager; + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + + // Get all our API versions + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); + if (ctx.isEmpty()) { + logger.severe(() -> "Cannot get CRD context."); + return; + } + context(ctx.get()); + + // Remove left over resources + purge(); + } + + private void purge() throws ApiException { + // Get existing CRs (VMs) + var known = K8sDynamicStub.list(client(), context(), namespace()) + .stream().map(stub -> stub.name()).collect(Collectors.toSet()); + ListOptions opts = new ListOptions(); + opts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME); + for (var context : Set.of(K8sV1StatefulSetStub.CONTEXT, + K8sV1ConfigMapStub.CONTEXT)) { + for (var resStub : K8sDynamicStub.list(client(), context, + namespace(), opts)) { + String instance = resStub.model() + .map(m -> m.metadata().getName()).orElse("(unknown)"); + if (!known.contains(instance)) { + resStub.delete(); + } + } + } + } + + @Override + protected void handleChange(K8sClient client, + Watch.Response response) { + var name = response.object.getMetadata().getName(); + + // Process the response data on a VM specific pipeline to + // increase concurrency when e.g. starting many VMs. + var preparing = channelManager.associated(name) + .orElseGet(() -> newEventPipeline()); + preparing.submit("VmChange[" + name + "]", + () -> processChange(client, response, preparing)); + } + + private void processChange(K8sClient client, + Watch.Response response, EventPipeline preparing) { + // Get full definition and associate with channel as backup + var vmDef = response.object; + if (vmDef.data() == null) { + // ADDED event does not provide data, see + // https://github.com/kubernetes-client/java/issues/3215 + vmDef = getModel(client, vmDef); + } + var name = response.object.getMetadata().getName(); + var channel = channelManager.channel(name) + .orElseGet(() -> channelManager.createChannel(name)); + if (vmDef.data() != null) { + // New data, augment and save + addExtraData(vmDef, channel.vmDefinition()); + channel.setVmDefinition(vmDef); + } else { + // Reuse cached (e.g. if deleted) + vmDef = channel.vmDefinition(); + } + if (vmDef == null) { + logger.warning(() -> "Cannot get defintion for " + + response.object.getMetadata()); + return; + } + channelManager.put(name, channel, preparing); + + // Create and fire changed event. Remove channel from channel + // manager on completion. + VmResourceChanged chgEvt + = new VmResourceChanged(ResponseType.valueOf(response.type), vmDef, + channel.setGeneration(response.object.getMetadata() + .getGeneration()), + false); + if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { + chgEvt = Event.onCompletion(chgEvt, + e -> channelManager.remove(e.vmDefinition().name())); + } + channel.fire(chgEvt); + } + + private VmDefinition getModel(K8sClient client, VmDefinition vmDef) { + try { + return VmDefinitionStub.get(client, context(), namespace(), + vmDef.metadata().getName()).model().orElse(null); + } catch (ApiException e) { + return null; + } + } + + private void addExtraData(VmDefinition vmDef, VmDefinition prevState) { + var extra = new VmExtraData(vmDef); + var prevExtra = Optional.ofNullable(prevState).map(VmDefinition::extra); + + // Maintain (or initialize) the resetCount + extra.resetCount(prevExtra.map(VmExtraData::resetCount).orElse(0L)); + + // Maintain node info + prevExtra + .ifPresent(e -> extra.nodeInfo(e.nodeName(), e.nodeAddresses())); + } + + /** + * On pod changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onPodChanged(PodChanged event, VmChannel channel) { + var vmDef = channel.vmDefinition(); + + // Make sure that this is properly sync'd with VM CR changes. + channelManager.associated(vmDef.name()) + .orElseGet(() -> activeEventPipeline()) + .submit("NodeInfo[" + vmDef.name() + "]", + () -> { + updateNodeInfo(event, vmDef); + channel.fire(new VmResourceChanged(ResponseType.MODIFIED, + vmDef, false, true)); + }); + } + + private void updateNodeInfo(PodChanged event, VmDefinition vmDef) { + var extra = vmDef.extra(); + if (event.type() == ResponseType.DELETED) { + // The status of a deleted pod is the status before deletion, + // i.e. the node info is still cached and must be removed. + extra.nodeInfo("", Collections.emptyList()); + return; + } + + // Get current node info from pod + var pod = event.pod(); + var nodeName = Optional + .ofNullable(pod.getSpec().getNodeName()).orElse(""); + logger.finer(() -> "Adding node name " + nodeName + + " to VM info for " + vmDef.name()); + var addrs = new ArrayList(); + Optional.ofNullable(pod.getStatus().getPodIPs()) + .orElse(Collections.emptyList()).stream() + .map(ip -> ip.getIp()).forEach(addrs::add); + logger.finer(() -> "Adding node addresses " + addrs + + " to VM info for " + vmDef.name()); + extra.nodeInfo(nodeName, addrs); + } + + /** + * On modify vm. + * + * @param event the event + * @throws ApiException the api exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onModifyVm(ModifyVm event, VmChannel channel) + throws ApiException, IOException { + patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), + event.value()); + } + + private void patchVmDef(K8sClient client, String name, String path, + Object value) throws ApiException, IOException { + var vmStub = K8sDynamicStub.get(client, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace(), + name); + + // Patch running + String valueAsText = value instanceof String + ? "\"" + value + "\"" + : value.toString(); + var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/" + + path + "\", \"value\": " + valueAsText + "}]"), + client.defaultPatchOptions()); + if (!res.isPresent()) { + logger.warning( + () -> "Cannot patch definition for Vm " + vmStub.name()); + } + } + + /** + * Attempt to Update the assignment information in the status of the + * VM CR. Returns true if successful. The handler does not attempt + * retries, because in case of failure it will be necessary to + * re-evaluate the chosen VM. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) + throws ApiException { + try { + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + if (vmStub.updateStatus(vmDef, from -> { + JsonObject status = from.statusJson(); + if (event.toUser() == null) { + ((JsonObject) GsonPtr.to(status).get()) + .remove(Status.ASSIGNMENT); + } else { + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); + assignment.set("pool", event.fromPool().name()); + assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); + } + return status; + }).isPresent()) { + event.setResult(true); + } + } catch (ApiException e) { + // Log exceptions except for conflict, which can be expected + if (HttpURLConnection.HTTP_CONFLICT != e.getCode()) { + throw e; + } + } + event.setResult(false); + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java deleted file mode 100644 index 7f8b0c4..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java +++ /dev/null @@ -1,338 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2023 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; -import io.kubernetes.client.openapi.ApiClient; -import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.apis.ApisApi; -import io.kubernetes.client.openapi.apis.CustomObjectsApi; -import io.kubernetes.client.openapi.models.V1APIGroup; -import io.kubernetes.client.openapi.models.V1APIResource; -import io.kubernetes.client.openapi.models.V1GroupVersionForDiscovery; -import io.kubernetes.client.openapi.models.V1Namespace; -import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.util.Config; -import io.kubernetes.client.util.Watch; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; -import io.kubernetes.client.util.generic.options.ListOptions; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.time.Instant; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import org.jdrupes.vmoperator.common.K8s; -import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type; -import org.jdrupes.vmoperator.util.GsonPtr; -import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; -import org.jgrapes.core.Components; -import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; -import org.jgrapes.core.events.Stop; -import org.jgrapes.util.events.ConfigurationUpdate; - -/** - * Watches for changes of VM definitions. - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class VmWatcher extends Component { - - private String namespaceToWatch; - private final Map channels = new ConcurrentHashMap<>(); - - /** - * Instantiates a new VM definition watcher. - * - * @param componentChannel the component channel - */ - public VmWatcher(Channel componentChannel) { - super(componentChannel); - } - - /** - * Configure the component. - * - * @param event the event - */ - @Handler - public void onConfigurationUpdate(ConfigurationUpdate event) { - event.structured(Components.manager(parent()).componentPath()) - .ifPresent(c -> { - if (c.containsKey("namespace")) { - namespaceToWatch = (String) c.get("namespace"); - } - }); - } - - /** - * Handle the start event. - * - * @param event the event - * @throws IOException - * @throws ApiException - */ - @Handler(priority = 10) - public void onStart(Start event) throws IOException, ApiException { - // Get namespace - if (namespaceToWatch == null) { - var path = Path - .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); - if (Files.isReadable(path)) { - namespaceToWatch = Files.lines(path).findFirst().orElse(null); - } - } - // Availability already checked by Controller.onStart - logger.fine(() -> "Watching namespace \"" + namespaceToWatch + "\"."); - - // Get all our API versions - var client = Config.defaultClient(); - var apis = new ApisApi(client).getAPIVersions(); - var vmOpApiVersions = apis.getGroups().stream() - .filter(g -> g.getName().equals(VM_OP_GROUP)).findFirst() - .map(V1APIGroup::getVersions).stream().flatMap(l -> l.stream()) - .map(V1GroupVersionForDiscovery::getVersion).toList(); - - // Remove left overs - var coa = new CustomObjectsApi(client); - purge(client, coa, vmOpApiVersions); - - // Start a watcher thread for each existing CRD version. - // The watcher will send us an "ADDED" for each existing VM. - for (var version : vmOpApiVersions) { - coa.getAPIResources(VM_OP_GROUP, version) - .getResources().stream() - .filter(r -> VM_OP_KIND_VM.equals(r.getKind())) - .findFirst() - .ifPresent(crd -> watchVmDefs(crd, version)); - } - } - - @SuppressWarnings("PMD.CognitiveComplexity") - private void purge(ApiClient client, CustomObjectsApi coa, - List vmOpApiVersions) throws ApiException { - // Get existing CRs (VMs) - Set known = new HashSet<>(); - for (var version : vmOpApiVersions) { - // Get all known CR instances. - coa.getAPIResources(VM_OP_GROUP, version) - .getResources().stream() - .filter(r -> VM_OP_KIND_VM.equals(r.getKind())) - .findFirst() - .ifPresent(crd -> known.addAll(getKnown(client, crd, version))); - } - - ListOptions opts = new ListOptions(); - opts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," - + "app.kubernetes.io/name=" + APP_NAME); - for (String resource : List.of("apps/v1/statefulsets", - "v1/configmaps", "v1/secrets")) { - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", - "PMD.AvoidDuplicateLiterals" }) - var resParts = new LinkedList<>(List.of(resource.split("/"))); - var group = resParts.size() == 3 ? resParts.poll() : ""; - var version = resParts.poll(); - var plural = resParts.poll(); - // Get resources, selected by label - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var api = new DynamicKubernetesApi(group, version, plural, client); - var listObj = api.list(namespaceToWatch, opts).getObject(); - if (listObj == null) { - continue; - } - for (var obj : listObj.getItems()) { - String instance = obj.getMetadata().getLabels() - .get("app.kubernetes.io/instance"); - if (!known.contains(instance)) { - var resName = obj.getMetadata().getName(); - var result = api.delete(namespaceToWatch, resName); - if (!result.isSuccess()) { - logger.warning(() -> "Cannot cleanup resource \"" - + resName + "\": " + result.toString()); - } - } - } - } - } - - private Set getKnown(ApiClient client, V1APIResource crd, - String version) { - Set result = new HashSet<>(); - var api = new DynamicKubernetesApi(VM_OP_GROUP, version, - crd.getName(), client); - for (var item : api.list(namespaceToWatch).getObject().getItems()) { - if (!VM_OP_KIND_VM.equals(item.getKind())) { - continue; - } - result.add(item.getMetadata().getName()); - } - return result; - } - - private void watchVmDefs(V1APIResource crd, String version) { - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", - "PMD.AvoidCatchingThrowable", "PMD.AvoidCatchingGenericException" }) - var watcher = new Thread(() -> { - try { - // Watch sometimes terminates without apparent reason. - while (true) { - Instant startedAt = Instant.now(); - var client = Config.defaultClient(); - var coa = new CustomObjectsApi(client); - var call = coa.listNamespacedCustomObjectCall(VM_OP_GROUP, - version, namespaceToWatch, crd.getName(), null, false, - null, null, null, null, null, null, null, true, null); - try (Watch watch - = Watch.createWatch(client, call, - new TypeToken>() { - }.getType())) { - for (Watch.Response item : watch) { - handleVmDefinitionChange(crd, item); - } - } catch (IOException | ApiException | RuntimeException e) { - logger.log(Level.FINE, e, () -> "Problem watching \"" - + crd.getName() + "\" (will retry): " - + e.getMessage()); - delayRestart(startedAt); - } - } - } catch (Throwable e) { - logger.log(Level.SEVERE, e, () -> "Probem watching: " - + e.getMessage()); - } - fire(new Stop()); - }); - watcher.setDaemon(true); - watcher.start(); - } - - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - private void delayRestart(Instant started) { - var runningFor = Duration - .between(started, Instant.now()).toMillis(); - if (runningFor < 5000) { - logger.log(Level.FINE, () -> "Waiting... "); - try { - Thread.sleep(5000 - runningFor); - } catch (InterruptedException e1) { // NOPMD - // Retry - } - logger.log(Level.FINE, () -> "Retrying"); - } - } - - private void handleVmDefinitionChange(V1APIResource vmsCrd, - Watch.Response vmDefStub) { - V1ObjectMeta metadata = vmDefStub.object.getMetadata(); - VmChannel channel = channels.computeIfAbsent(metadata.getName(), - k -> { - try { - return new VmChannel(channel(), newEventPipeline(), - Config.defaultClient()); - } catch (IOException e) { - logger.log(Level.SEVERE, e, () -> "Failed to create client" - + " for handling changes: " + e.getMessage()); - return null; - } - }); - if (channel == null) { - return; - } - - // Get full definition and associate with channel as backup - var apiVersion = K8s.version(vmDefStub.object.getApiVersion()); - DynamicKubernetesApi vmCrApi = new DynamicKubernetesApi(VM_OP_GROUP, - apiVersion, vmsCrd.getName(), channel.client()); - var curVmDef = K8s.get(vmCrApi, metadata); - curVmDef.ifPresent(def -> { - // Augment with "dynamic" data and associate with channel - addDynamicData(channel.client(), def); - channel.setVmDefinition(def); - }); - - // Get eventual definition to use - var vmDef = curVmDef.orElse(channel.vmDefinition()); - - // Create and fire event - channel.pipeline().fire(new VmDefChanged(VmDefChanged.Type - .valueOf(vmDefStub.type), - channel - .setGeneration(vmDefStub.object.getMetadata().getGeneration()), - vmsCrd, vmDef), channel); - } - - private void addDynamicData(ApiClient client, - DynamicKubernetesObject vmDef) { - var rootNode = GsonPtr.to(vmDef.getRaw()).get(JsonObject.class); - rootNode.addProperty("nodeName", ""); - - // VM definition status changes before the pod terminates. - // This results in pod information being shown for a stopped - // VM which is irritating. So check condition first. - var isRunning = GsonPtr.to(rootNode).to("status", "conditions") - .get(JsonArray.class) - .asList().stream().filter(el -> "Running" - .equals(((JsonObject) el).get("type").getAsString())) - .findFirst().map(el -> "True" - .equals(((JsonObject) el).get("status").getAsString())) - .orElse(false); - if (!isRunning) { - return; - } - var podSearch = new ListOptions(); - podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME - + ",app.kubernetes.io/component=" + APP_NAME - + ",app.kubernetes.io/instance=" + vmDef.getMetadata().getName()); - var podList = K8s.podApi(client).list(namespaceToWatch, podSearch); - podList.getObject().getItems().stream().forEach(pod -> { - rootNode.addProperty("nodeName", pod.getSpec().getNodeName()); - }); - } - - /** - * Remove VM channel when VM is deleted. - * - * @param event the event - * @param channel the channel - */ - @Handler(priority = -10_000) - public void onVmDefChanged(VmDefChanged event, VmChannel channel) { - if (event.type() == Type.DELETED) { - channels.remove(event.vmDefinition().getMetadata().getName()); - } - } - -} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java index 48bc158..1d05ec9 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -57,7 +57,7 @@ * ``` * * Developers may also be interested in the usage of channels - * by the application's component: + * by the application's components: * * ![Main channels](app-channels.svg) * @@ -74,6 +74,8 @@ * * Component NioDispatcher as NioDispatcher <> * [Manager] *-up- [NioDispatcher] + * Component HttpConnector as HttpConnector <> + * [Manager] *-up- [HttpConnector] * Component FileSystemWatcher as FileSystemWatcher <> * [Manager] *-up- [FileSystemWatcher] * Component YamlConfigurationStore as YamlConfigurationStore <> @@ -81,8 +83,18 @@ * [YamlConfigurationStore] *-right[hidden]- [Controller] * * [Manager] *-- [Controller] - * [Controller] *-- [VmWatcher] - * [Controller] *-- [Reconciler] + * Component VmMonitor as VmMonitor <> + * [Controller] *-- [VmMonitor] + * [VmMonitor] -right[hidden]- [PoolMonitor] + * Component PoolMonitor as PoolMonitor <> + * [Controller] *-- [PoolMonitor] + * Component PodMonitor as PodMonitor <> + * [Controller] *-- [PodMonitor] + * [PodMonitor] -up[hidden]- VmMonitor + * Component DisplaySecretMonitor as DisplaySecretMonitor <> + * [Controller] *-- [DisplaySecretMonitor] + * [DisplaySecretMonitor] -up[hidden]- VmMonitor + * [Controller] *-left- [Reconciler] * [Controller] -right[hidden]- [GuiHttpServer] * * [Manager] *-down- [GuiSocketServer:8080] @@ -119,6 +131,7 @@ * [WebConsole] *-- [RoleConfigurator] * [WebConsole] *-- [RoleConletFilter] * [WebConsole] *-left- [LoginConlet] + * [WebConsole] *-right- [OidcClient] * * Component "ComponentCollector\nfor page resources" as cpr <> * [WebConsole] *-- [cpr] @@ -140,27 +153,42 @@ * mgr .left. [FileSystemWatcher] * mgr .right. [YamlConfigurationStore] * mgr .. [Controller] + * mgr .up. [Manager] * mgr .up. [VmWatcher] * mgr .. [Reconciler] * * () "guiTransport" as hT * hT .up. [GuiSocketServer:8080] * hT .down. [GuiHttpServer] + * hT .right[hidden]. [HttpConnector] * * [YamlConfigurationStore] -right[hidden]- hT * * () "guiHttp" as http * http .up. [GuiHttpServer] + * http .up. [HttpConnector] + * note top of [HttpConnector]: transport layer com-\nponents omitted * - * [PreferencesStore] .right. http + * [PreferencesStore] .. http + * [OidcClient] .up. http + * [LanguageSelector] .left. http * [InMemorySessionManager] .up. http - * [LanguageSelector] .up. http * * package "Conceptual WebConsole" { - * [ConsoleWeblet] .left. http + * [ConsoleWeblet] .right. http * [ConsoleWeblet] *-down- [WebConsole] * } * + * [Controller] .down[hidden]. [ConsoleWeblet] + * + * () "console" as console + * console .. WebConsole + * + * [OidcClient] .. console + * [LoginConlet] .right. console + * + * note right of console: More conlets\nconnect here + * * @enduml */ package org.jdrupes.vmoperator.manager; diff --git a/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml new file mode 100644 index 0000000..36054a2 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml @@ -0,0 +1,64 @@ +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VirtualMachine +metadata: + namespace: vmop-test + name: test-vm +spec: + image: + repository: docker-registry.lan.mnl.de + path: vmoperator/this.will.never.start + version: 0.0.0 + + cloudInit: + metaData: {} + + vm: + # state: Running + maximumRam: 4Gi + currentRam: 2Gi + maximumCpus: 4 + currentCpus: 2 + powerdownTimeout: 1 + + networks: + - user: {} + disks: + - cdrom: + image: https://test.com/test.iso + bootindex: 0 + - cdrom: + image: "image.iso" + - volumeClaimTemplate: + metadata: + name: system + annotations: + use_as: system-disk + spec: + storageClassName: local-path + resources: + requests: + storage: 1Gi + - volumeClaimTemplate: + spec: + storageClassName: local-path + resources: + requests: + storage: 1Gi + + display: + outputs: 2 + spice: + port: 5812 + usbRedirects: 2 + + resources: + requests: + cpu: 1 + memory: 2Gi + + loadBalancerService: + labels: + label2: replaced + label3: added + annotations: + anno1: added diff --git a/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml b/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml new file mode 100644 index 0000000..3a8451e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml @@ -0,0 +1,111 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../../deploy + +namespace: vmop-test + +patches: +- patch: |- + kind: PersistentVolumeClaim + apiVersion: v1 + metadata: + name: vmop-image-repository + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: local-path + +- patch: |- + kind: ConfigMap + apiVersion: v1 + metadata: + name: vm-operator + data: + # Keep in sync with config.yaml + config.yaml: | + "/Manager": + # clusterName: "test" + "/Controller": + "/Reconciler": + runnerData: + storageClassName: null + loadBalancerService: + labels: + label1: label1 + label2: toBeReplaced + annotations: + metallb.universe.tf/loadBalancerIPs: 192.168.168.1 + metallb.universe.tf/ip-allocated-from-pool: single-common + metallb.universe.tf/allow-shared-ip: single-common + "/GuiSocketServer": + port: 8888 + "/GuiHttpServer": + # This configures the GUI + "/ConsoleWeblet": + "/WebConsole": + "/LoginConlet": + users: + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test1 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + "/RoleConfigurator": + rolesByUser: + # User admin has role admin + admin: + - admin + test1: + - user + test2: + - user + test3: + - user + # All users have role other + "*": + - other + replace: false + "/RoleConletFilter": + conletTypesByRole: + # Admins can use all conlets + admin: + - "*" + user: + - org.jdrupes.vmoperator.vmviewer.VmViewer + # Others cannot use any conlet (except login conlet to log out) + other: + - org.jgrapes.webconlet.locallogin.LoginConlet + "/ComponentCollector": + "/VmAccess": + displayResource: + preferredIpVersion: ipv4 + syncPreviewsFor: + - role: user +- target: + group: apps + version: v1 + kind: Deployment + name: vm-operator + patch: |- + - op: replace + path: /spec/template/spec/containers/0/image + value: docker-registry.lan.mnl.de/vmoperator/org.jdrupes.vmoperator.manager:test + - op: replace + path: /spec/template/spec/containers/0/imagePullPolicy + value: Always + - op: replace + path: /spec/replicas + value: 0 + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml deleted file mode 100644 index 0d395bd..0000000 --- a/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: "vmoperator.jdrupes.org/v1" -kind: VirtualMachine -metadata: - namespace: vmop-dev - name: unittest-vm -spec: - resources: - requests: - cpu: 1 - memory: 2Gi - - loadBalancerService: - labels: - test2: null - test3: added - - vm: - # state: Running - maximumRam: 4Gi - currentRam: 2Gi - maximumCpus: 4 - currentCpus: 2 - powerdownTimeout: 1 - - networks: - - user: {} - disks: - - cdrom: - # image: "" - image: https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso - # image: "Fedora-Workstation-Live-x86_64-38-1.6.iso" - - display: - spice: - port: 5812 diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index 26eb387..d600d3c 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -1,13 +1,32 @@ package org.jdrupes.vmoperator.manager; -import io.fabric8.kubernetes.client.Config; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClientBuilder; -import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext; +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.custom.Quantity; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.FileReader; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import org.jdrupes.vmoperator.common.Constants; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; +import org.jdrupes.vmoperator.common.K8sV1DeploymentStub; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1PvcStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.K8sV1ServiceStub; +import org.jdrupes.vmoperator.util.DataPath; import org.junit.jupiter.api.AfterAll; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.BeforeAll; @@ -18,8 +37,12 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; class BasicTests { - private static KubernetesClient client; - private static ResourceDefinitionContext vmsContext; + private static K8sClient client; + private static APIResource vmsContext; + private static K8sV1DeploymentStub mgrDeployment; + private static K8sDynamicStub vmStub; + private static final String VM_NAME = "test-vm"; + private static final Object EXISTS = new Object(); @BeforeAll static void setUpBeforeClass() throws Exception { @@ -27,29 +50,44 @@ class BasicTests { assertNotNull(testCluster); // Get client - client = new KubernetesClientBuilder() - .withConfig(Config.autoConfigure(testCluster)).build(); - - // Context for working with our CR - vmsContext = new ResourceDefinitionContext.Builder() - .withGroup("vmoperator.jdrupes.org").withKind("VirtualMachine") - .withPlural("vms").withNamespaced(true).withVersion("v1").build(); - - // Cleanup - var resourcesInNamespace = client.genericKubernetesResources(vmsContext) - .inNamespace("vmop-dev"); - resourcesInNamespace.withName("unittest-vm").delete(); + client = new K8sClient(); // Update manager pod by scaling deployment - client.apps().deployments().inNamespace("vmop-dev") - .withName("vm-operator").scale(0); - client.apps().deployments().inNamespace("vmop-dev") - .withName("vm-operator").scale(1); + mgrDeployment + = K8sV1DeploymentStub.get(client, "vmop-test", "vm-operator"); + mgrDeployment.scale(0); + mgrDeployment.scale(1); + waitForManager(); + // Context for working with our CR + var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM); + assertTrue(apiRes.isPresent()); + vmsContext = apiRes.get(); + + // Cleanup existing VM + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) + .delete(); + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME); + var secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); + for (var secret : secrets) { + secret.delete(); + } + deletePvcs(); + + // Load from Yaml + var rdr = new FileReader("test-resources/basic-vm.yaml"); + vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr); + assertTrue(vmStub.model().isPresent()); + } + + private static void waitForManager() + throws ApiException, InterruptedException { // Wait until available for (int i = 0; i < 10; i++) { - if (client.apps().deployments().inNamespace("vmop-dev") - .withName("vm-operator").get().getStatus().getConditions() + if (mgrDeployment.model().get().getStatus().getConditions() .stream().filter(c -> "Available".equals(c.getType())).findAny() .isPresent()) { return; @@ -59,63 +97,245 @@ class BasicTests { fail("vm-operator not deployed."); } + private static void deletePvcs() throws ApiException { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME); + var knownPvcs = K8sV1PvcStub.list(client, "vmop-test", listOpts); + for (var pvc : knownPvcs) { + pvc.delete(); + } + } + @AfterAll static void tearDownAfterClass() throws Exception { + // Cleanup + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) + .delete(); + deletePvcs(); + // Bring down manager - client.apps().deployments().inNamespace("vmop-dev") - .withName("vm-operator").scale(0); - client.close(); + mgrDeployment.scale(0); } @Test - void test() throws IOException, InterruptedException { - // Load from Yaml - var vm = client.genericKubernetesResources(vmsContext) - .load(Files - .newInputStream(Path.of("test-resources/unittest-vm.yaml"))); - // Create Custom Resource - vm.create(); - - // Wait for created resources - assertTrue(waitForConfigMap()); - assertTrue(waitForStatefulSet()); - + void testConfigMap() + throws IOException, InterruptedException, ApiException { + K8sV1ConfigMapStub stub + = K8sV1ConfigMapStub.get(client, "vmop-test", VM_NAME); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } // Check config map - var config = client.configMaps().inNamespace("vmop-dev") - .withName("unittest-vm").get(); - var yaml = new Yaml(new SafeConstructor(new LoaderOptions())) - .load((String) config.getData().get("config.yaml")); - @SuppressWarnings("unchecked") - var currentRam = ((Map>>) yaml) - .get("/Runner").get("vm").get("maximumRam"); - assertEquals("4 GiB", currentRam); + var config = stub.model().get(); + Map, Object> toCheck = Map.of( + List.of("namespace"), "vmop-test", + List.of("name"), VM_NAME, + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS); + checkProps(config.getMetadata(), toCheck); - // Cleanup - var resourcesInNamespace = client.genericKubernetesResources(vmsContext) - .inNamespace("vmop-dev"); - resourcesInNamespace.withName("unittest-vm").delete(); + toCheck = new LinkedHashMap<>(); + toCheck.put(List.of("/Runner", "guestShutdownStops"), false); + toCheck.put(List.of("/Runner", "cloudInit", "metaData", "instance-id"), + EXISTS); + toCheck.put( + List.of("/Runner", "cloudInit", "metaData", "local-hostname"), + VM_NAME); + toCheck.put(List.of("/Runner", "cloudInit", "userData"), Map.of()); + toCheck.put(List.of("/Runner", "vm", "maximumRam"), "4 GiB"); + toCheck.put(List.of("/Runner", "vm", "currentRam"), "2 GiB"); + toCheck.put(List.of("/Runner", "vm", "maximumCpus"), 4); + toCheck.put(List.of("/Runner", "vm", "currentCpus"), 2); + toCheck.put(List.of("/Runner", "vm", "powerdownTimeout"), 1); + toCheck.put(List.of("/Runner", "vm", "network", 0, "type"), "user"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "file"), + "https://test.com/test.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "bootindex"), 0); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "file"), + "/var/local/vmop-image-repository/image.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "resource"), + "/dev/system-disk"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "resource"), + "/dev/disk-1"); + toCheck.put(List.of("/Runner", "vm", "display", "outputs"), 2); + toCheck.put(List.of("/Runner", "vm", "display", "spice", "port"), 5812); + toCheck.put( + List.of("/Runner", "vm", "display", "spice", "usbRedirects"), 2); + var cm = new Yaml(new SafeConstructor(new LoaderOptions())) + .load(config.getData().get("config.yaml")); + checkProps(cm, toCheck); } - private boolean waitForConfigMap() throws InterruptedException { + @Test + void testDisplaySecret() throws ApiException, InterruptedException { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME); + Collection secrets = null; for (int i = 0; i < 10; i++) { - if (client.configMaps().inNamespace("vmop-dev") - .withName("unittest-vm").get() != null) { - return true; + secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); + if (secrets.size() > 0) { + break; } Thread.sleep(1000); } - return false; + assertEquals(1, secrets.size()); + var secretData = secrets.iterator().next().model().get().getData(); + checkProps(secretData, Map.of( + List.of("display-password"), EXISTS)); + assertEquals("now", new String(secretData.get("password-expiry"))); } - private boolean waitForStatefulSet() throws InterruptedException { + @Test + void testRunnerPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-runner-data"); for (int i = 0; i < 10; i++) { - if (client.apps().statefulSets().inNamespace("vmop-dev") - .withName("unittest-vm").get() != null) { - return true; + if (stub.model().isPresent()) { + break; } Thread.sleep(1000); } - return false; + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Mi"))); + } + + @Test + void testSystemDiskPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-system-disk"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("annotations", "use_as"), "system-disk")); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Gi"))); + } + + @Test + void testDisk1Pvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-disk-1"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Gi"))); + } + + @Test + void testPod() throws ApiException, InterruptedException { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + assertTrue(vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" + + "\", \"value\": \"Running\"}]"), + client.defaultPatchOptions()).isPresent()); + var stub = K8sV1PodStub.get(client, "vmop-test", VM_NAME); + for (int i = 0; i < 20; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pod = stub.model().get(); + checkProps(pod.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/component"), APP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS)); + checkProps(pod.getSpec(), Map.of( + List.of("containers", 0, "image"), EXISTS, + List.of("containers", 0, "name"), VM_NAME, + List.of("containers", 0, "resources", "requests", "cpu"), + Quantity.fromString("1"))); + } + + @Test + public void testLoadBalancer() throws ApiException, InterruptedException { + var stub = K8sV1ServiceStub.get(client, "vmop-test", VM_NAME); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var svc = stub.model().get(); + checkProps(svc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("labels", "label1"), "label1", + List.of("labels", "label2"), "replaced", + List.of("labels", "label3"), "added", + List.of("annotations", "metallb.universe.tf/loadBalancerIPs"), + "192.168.168.1", + List.of("annotations", "anno1"), "added")); + } + + private void checkProps(Object obj, + Map, Object> toCheck) { + for (var entry : toCheck.entrySet()) { + var prop = DataPath.get(obj, entry.getKey().toArray()); + assertTrue(prop.isPresent(), () -> "Property " + entry.getKey() + + " not found in " + obj); + + // Check for existance only + if (entry.getValue() == EXISTS) { + continue; + } + assertEquals(entry.getValue(), prop.get()); + } } } diff --git a/org.jdrupes.vmoperator.runner.qemu/.eclipse-pmd b/org.jdrupes.vmoperator.runner.qemu/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.runner.qemu/.eclipse-pmd +++ b/org.jdrupes.vmoperator.runner.qemu/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.runner.qemu/build.gradle b/org.jdrupes.vmoperator.runner.qemu/build.gradle index ec7de7f..695c815 100644 --- a/org.jdrupes.vmoperator.runner.qemu/build.gradle +++ b/org.jdrupes.vmoperator.runner.qemu/build.gradle @@ -9,14 +9,14 @@ plugins { } dependencies { - implementation 'org.jgrapes:org.jgrapes.core:[1.19.0,2)' - implementation 'org.jgrapes:org.jgrapes.io:[2.7.0,3)' - implementation 'org.jgrapes:org.jgrapes.http:[3.1.0,4)' - implementation 'org.jgrapes:org.jgrapes.util:[1.31.0,2)' + implementation 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' + implementation 'org.jgrapes:org.jgrapes.util:[1.38.1,2)' + implementation 'org.jgrapes:org.jgrapes.io:[2.12.1,3)' + implementation 'org.jgrapes:org.jgrapes.http:[3.5.0,4)' implementation project(':org.jdrupes.vmoperator.common') implementation 'commons-cli:commons-cli:1.5.0' - implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:[2.15.1,3]' + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:[2.16.1]' runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)' } @@ -31,102 +31,99 @@ application { mainClass = 'org.jdrupes.vmoperator.runner.qemu.Runner' } -task buildArchImage(type: Exec) { +project.ext.gitBranch = grgit.branch.current.name.replace('/', '-') +def registry = "${project.rootProject.properties['docker.registry']}" +def rootVersion = rootProject.version + +task buildImageArch(type: Exec) { dependsOn installDist inputs.files 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch' - commandLine 'podman', 'build', '-t', "${project.name}-arch:${project.version}",\ + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}-arch:${project.gitBranch}",\ '-f', 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch', '.' } -task tagLatestArchImage(type: Exec) { - dependsOn buildArchImage +task pushImageArch(type: Exec) { + dependsOn buildImageArch + + commandLine 'podman', 'push', '--tls-verify=false', \ + "${project.name}-arch:${project.gitBranch}", \ + "${registry}/${project.name}-arch:${project.gitBranch}" +} + +task tagWithVersionArch(type: Exec) { + dependsOn pushImageArch + + enabled = !rootVersion.contains("SNAPSHOT") - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'tag', "${project.name}-arch:${project.version}",\ - "${project.name}-arch:latest" + commandLine 'podman', 'push', \ + "${project.name}-arch:${project.gitBranch}",\ + "${registry}/${project.name}-arch:${project.version}" } -task buildLatestArchImage { - dependsOn buildArchImage - dependsOn tagLatestArchImage +task tagAsLatestArch(type: Exec) { + dependsOn tagWithVersionArch + + enabled = !rootVersion.contains("SNAPSHOT") + && !rootVersion.contains("alpha") \ + && !rootVersion.contains("beta") \ + || project.rootProject.properties['docker.testRegistry'] \ + && project.rootProject.properties['docker.registry'] \ + == project.rootProject.properties['docker.testRegistry'] + + commandLine 'podman', 'push', \ + "${project.name}-arch:${project.gitBranch}",\ + "${registry}/${project.name}-arch:latest" } -task pushArchImage(type: Exec) { - dependsOn buildArchImage - - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}-arch:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-arch:${project.version}" -} - -task pushArchLatestImage(type: Exec) { - dependsOn buildLatestArchImage - - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}-arch:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-arch:latest" -} - -task buildAlpineImage(type: Exec) { +task buildImageAlpine(type: Exec) { dependsOn installDist inputs.files 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine' - commandLine 'podman', 'build', '-t', "${project.name}-alpine:${project.version}",\ + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}-alpine:${project.gitBranch}",\ '-f', 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine', '.' } -task tagLatestAlpineImage(type: Exec) { - dependsOn buildAlpineImage +task pushImageAlpine(type: Exec) { + dependsOn buildImageAlpine + + commandLine 'podman', 'push', '--tls-verify=false', \ + "localhost/${project.name}-alpine:${project.gitBranch}", \ + "${registry}/${project.name}-alpine:${project.gitBranch}" +} + +task tagWithVersionAlpine(type: Exec) { + dependsOn pushImageAlpine + + enabled = !rootVersion.contains("SNAPSHOT") - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'tag', "${project.name}-alpine:${project.version}",\ - "${project.name}-alpine:latest" + commandLine 'podman', 'push', \ + "${project.name}-alpine:${project.gitBranch}",\ + "${registry}/${project.name}-alpine:${project.version}" } -task buildLatestAlpineImage { - dependsOn buildAlpineImage - dependsOn tagLatestAlpineImage +task tagAsLatestAlpine(type: Exec) { + dependsOn tagWithVersionAlpine + + enabled = !rootVersion.contains("SNAPSHOT") + && !rootVersion.contains("alpha") \ + && !rootVersion.contains("beta") \ + || project.rootProject.properties['docker.testRegistry'] \ + && project.rootProject.properties['docker.registry'] \ + == project.rootProject.properties['docker.testRegistry'] + + commandLine 'podman', 'push', \ + "${project.name}-alpine:${project.gitBranch}",\ + "${registry}/${project.name}-alpine:latest" } -task pushAlpineImage(type: Exec) { - dependsOn buildAlpineImage - - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}-alpine:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-alpine:${project.version}" +task publishImage { + dependsOn pushImageArch + dependsOn tagWithVersionArch + dependsOn tagAsLatestArch + dependsOn pushImageAlpine + dependsOn tagWithVersionAlpine + dependsOn tagAsLatestAlpine } - -task pushAlpineLatestImage(type: Exec) { - dependsOn buildLatestAlpineImage - - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}-alpine:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-alpine:latest" -} - -task pushImages { - dependsOn pushArchImage - dependsOn pushArchLatestImage - dependsOn pushAlpineImage - dependsOn pushAlpineLatestImage -} - diff --git a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml index 461e79b..e23a2ec 100644 --- a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml @@ -27,6 +27,31 @@ # be set when starting the runner during development e.g. from the IDE. # "namespace": ... + # Defines data for generating a cloud-init ISO image that is + # attached to the VM. + # "cloudInit": + # "metaData": + # ... + # "userData": + # ... + # "networkConfig": + # ... + # + # If .metaData.instance-id is missing, an id is generated from the + # config file's modification timestamp. .userData and .networkConfig + # are optional. + + # Whether a guest initiated shutdown event patches the state + # property in the CRD. + # "guestShutdownStops": + # false + + # When incremented, the VM is reset. The value has no default value, + # i.e. if you start the VM without a value for this property, and + # decide to trigger a reset later, you have to first set the value + # and then inrement it. + # "resetCounter": 1 + # Define the VM (required) "vm": # The VM's name (required) diff --git a/org.jdrupes.vmoperator.runner.qemu/display-password b/org.jdrupes.vmoperator.runner.qemu/display-password new file mode 100644 index 0000000..97c1abb --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/display-password @@ -0,0 +1 @@ +test-vm \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/password-expiry b/org.jdrupes.vmoperator.runner.qemu/password-expiry new file mode 100644 index 0000000..8a606d5 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/password-expiry @@ -0,0 +1 @@ ++1800 \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml b/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml index 9aadbf6..600f0ad 100644 --- a/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml @@ -8,10 +8,16 @@ - "/usr/share/edk2/ovmf/OVMF_CODE.fd" - "/usr/share/edk2/x64/OVMF_CODE.fd" - "/usr/share/OVMF/OVMF_CODE.fd" + # Use 4M version as fallback (if smaller version not available) + - "/usr/share/edk2/ovmf-4m/OVMF_CODE.fd" + - "/usr/share/edk2/x64/OVMF_CODE.4m.fd" "vars": - "/usr/share/edk2/ovmf/OVMF_VARS.fd" - "/usr/share/edk2/x64/OVMF_VARS.fd" - "/usr/share/OVMF/OVMF_VARS.fd" + # Use 4M version as fallback (if smaller version not available) + - "/usr/share/edk2/ovmf-4m/OVMF_VARS.fd" + - "/usr/share/edk2/x64/OVMF_VARS.4m.fd" "uefi-4m": "rom": - "/usr/share/edk2/ovmf-4m/OVMF_CODE.fd" diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java new file mode 100644 index 0000000..6303794 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java @@ -0,0 +1,122 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; + +/** + * A component that handles the communication with an agent + * running in the VM. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public abstract class AgentConnector extends QemuConnector { + + protected String channelId; + + /** + * Instantiates a new agent connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public AgentConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * Extracts the channel id and the socket path from the QEMU + * command line. + * + * @param command the command + * @param chardev the chardev + */ + @SuppressWarnings("PMD.CognitiveComplexity") + protected void configureConnection(List command, String chardev) { + Path socketPath = null; + for (var arg : command) { + if (arg.startsWith("virtserialport,") + && arg.contains("chardev=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("id=")) { + channelId = prop.substring(3); + } + } + } + if (arg.startsWith("socket,") + && arg.contains("id=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("path=")) { + socketPath = Path.of(prop.substring(5)); + } + } + } + } + if (channelId == null || socketPath == null) { + logger.warning(() -> "Definition of chardev " + chardev + + " missing in runner template."); + return; + } + logger.fine(() -> getClass().getSimpleName() + " configured with" + + " channelId=" + channelId); + super.configure(socketPath); + } + + /** + * When the virtual serial port with the configured channel id has + * been opened call {@link #agentConnected()}. + * + * @param event the event + */ + @Handler + public void onVserportChanged(VserportChangeEvent event) { + if (event.id().equals(channelId)) { + if (event.isOpen()) { + agentConnected(); + } else { + agentDisconnected(); + } + } + } + + /** + * Called when the agent in the VM opens the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentConnected() { + // Default is to do nothing. + } + + /** + * Called when the agent in the VM closes the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentDisconnected() { + // Default is to do nothing. + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java index b7c960a..c4ac871 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java @@ -25,9 +25,9 @@ import java.util.concurrent.ConcurrentHashMap; import org.jdrupes.vmoperator.runner.qemu.commands.QmpChangeMedium; import org.jdrupes.vmoperator.runner.qemu.commands.QmpOpenTray; import org.jdrupes.vmoperator.runner.qemu.commands.QmpRemoveMedium; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.TrayMovedEvent; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -36,7 +36,6 @@ import org.jgrapes.core.annotation.Handler; /** * The Class CdMediaController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class CdMediaController extends Component { /** @@ -55,7 +54,6 @@ public class CdMediaController extends Component { * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") public CdMediaController(Channel componentChannel) { super(componentChannel); } @@ -66,10 +64,9 @@ public class CdMediaController extends Component { * @param event the event */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AvoidInstantiatingObjectsInLoops" }) - public void onConfigureQemu(RunnerConfigurationUpdate event) { - if (event.state() == State.TERMINATING) { + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) + public void onConfigureQemu(ConfigureQemu event) { + if (event.runState() == RunState.TERMINATING) { return; } @@ -82,7 +79,7 @@ public class CdMediaController extends Component { } var driveId = "cd" + cdCounter++; var newFile = Optional.ofNullable(drives[i].file).orElse(""); - if (event.state() == State.STARTING) { + if (event.runState() == RunState.STARTING) { current.put(driveId, newFile); continue; } @@ -116,8 +113,8 @@ public class CdMediaController extends Component { */ @Handler public void onTrayMovedEvent(TrayMovedEvent event) { - trayState.put(event.driveId(), event.state()); - if (event.state() == TrayState.OPEN + trayState.put(event.driveId(), event.trayState()); + if (event.trayState() == TrayState.OPEN && pending.containsKey(event.driveId())) { changeMedium(event.driveId()); } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java index fadc4a0..7aec209 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java @@ -42,6 +42,7 @@ class CommandDefinition { for (JsonNode path : jsonData.get("executable")) { if (Files.isExecutable(Path.of(path.asText()))) { command.add(path.asText()); + break; } } if (command.isEmpty()) { @@ -68,4 +69,9 @@ class CommandDefinition { public String name() { return name; } + + @Override + public String toString() { + return "Command " + name + ": " + command; + } } \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java index dda438b..87e8c76 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java @@ -24,6 +24,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.logging.Level; @@ -36,11 +39,14 @@ import org.jdrupes.vmoperator.util.FsdUtils; /** * The configuration information from the configuration file. */ -@SuppressWarnings("PMD.ExcessivePublicCount") public class Configuration implements Dto { - @SuppressWarnings("PMD.FieldNamingConventions") + private static final String CI_INSTANCE_ID = "instance-id"; + protected final Logger logger = Logger.getLogger(getClass().getName()); + /** Configuration timestamp. */ + public Instant asOf; + /** The data dir. */ public Path dataDir; @@ -65,15 +71,42 @@ public class Configuration implements Dto { /** The firmware vars. */ public Path firmwareVars; + /** The display password. */ + public boolean hasDisplayPassword; + + /** Optional cloud-init data. */ + public CloudInit cloudInit; + + /** If guest shutdown changes CRD .vm.state to "Stopped". */ + public boolean guestShutdownStops; + + /** Increments of the reset counter trigger a reset of the VM. */ + public Integer resetCounter; + /** The vm. */ @SuppressWarnings("PMD.ShortVariable") public Vm vm; + /** + * Subsection "cloud-init". + */ + public static class CloudInit implements Dto { + + /** The meta data. */ + public Map metaData; + + /** The user data. */ + public Map userData; + + /** The network config. */ + public Map networkConfig; + } + /** * Subsection "vm". */ @SuppressWarnings({ "PMD.ShortClassName", "PMD.TooManyFields", - "PMD.DataClass" }) + "PMD.DataClass", "PMD.AvoidDuplicateLiterals" }) public static class Vm implements Dto { /** The name. */ @@ -161,6 +194,7 @@ public class Configuration implements Dto { /** * Subsection "network". */ + @SuppressWarnings("PMD.DataClass") public static class Network implements Dto { /** The type. */ @@ -182,6 +216,7 @@ public class Configuration implements Dto { /** * Subsection "drive". */ + @SuppressWarnings("PMD.DataClass") public static class Drive implements Dto { /** The type. */ @@ -204,12 +239,21 @@ public class Configuration implements Dto { * The Class Display. */ public static class Display implements Dto { + + /** The number of outputs. */ + public int outputs = 1; + + /** The logged in user. */ + public String loggedInUser; + + /** The spice. */ public Spice spice; } /** * Subsection "spice". */ + @SuppressWarnings("PMD.DataClass") public static class Spice implements Dto { /** The port. */ @@ -245,11 +289,11 @@ public class Configuration implements Dto { } checkDrives(); + checkCloudInit(); return true; } - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") private void checkDrives() { for (Drive drive : vm.drives) { if (drive.file != null || drive.device != null @@ -269,7 +313,6 @@ public class Configuration implements Dto { } } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkRuntimeDir() { // Runtime directory (sockets etc.) if (runtimeDir == null) { @@ -305,7 +348,6 @@ public class Configuration implements Dto { return true; } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkDataDir() { // Data directory if (dataDir == null) { @@ -358,4 +400,18 @@ public class Configuration implements Dto { return true; } + + private void checkCloudInit() { + if (cloudInit == null) { + return; + } + + // Provide default for instance-id + if (cloudInit.metaData == null) { + cloudInit.metaData = new HashMap<>(); + } + if (!cloudInit.metaData.containsKey(CI_INSTANCE_ID)) { + cloudInit.metaData.put(CI_INSTANCE_ID, "v" + asOf.getEpochSecond()); + } + } } \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java new file mode 100644 index 0000000..b50b481 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -0,0 +1,152 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.EventsV1Event; +import java.io.IOException; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceInitializedEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; + +/** + * A (sub)component that updates the console status in the CR status. + * Created as child of {@link StatusUpdater}. + */ +public class ConsoleTracker extends VmDefUpdater { + + private VmDefinitionStub vmStub; + private String mainChannelClientHost; + private long mainChannelClientPort; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + */ + public ConsoleTracker(Channel componentChannel) { + super(componentChannel); + apiClient = (K8sClient) io.kubernetes.client.openapi.Configuration + .getDefaultApiClient(); + } + + /** + * Handle the start event. + * + * @param event the event + * @throws IOException + * @throws ApiException + */ + @Handler + public void onStart(Start event) { + if (namespace == null) { + return; + } + try { + vmStub = VmDefinitionStub.get(apiClient, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + namespace, vmName); + } catch (ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access VM object, terminating."); + event.cancel(true); + fire(new Exit(1)); + } + } + + /** + * On spice connected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) + public void onSpiceInitialized(SpiceInitializedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process connections using main channel. + if (event.channelType() != 1) { + return; + } + mainChannelClientHost = event.clientHost(); + mainChannelClientPort = event.clientPort(); + vmStub.updateStatus(from -> { + JsonObject status = updateCondition(from, "ConsoleConnected", true, + "Connected", "Connection from " + event.clientHost()); + status.addProperty(Status.CONSOLE_CLIENT, event.clientHost()); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(Crd.GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Connection from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + /** + * On spice disconnected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onSpiceDisconnected(SpiceDisconnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process disconnects from main channel. + if (!event.clientHost().equals(mainChannelClientHost) + || event.clientPort() != mainChannelClientPort) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = updateCondition(from, "ConsoleConnected", false, + "Disconnected", event.clientHost() + " has disconnected"); + status.addProperty(Status.CONSOLE_CLIENT, ""); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(Crd.GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Disconnected from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java new file mode 100644 index 0000000..eac05fa --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +/** + * Some constants. + */ +public class Constants extends org.jdrupes.vmoperator.common.Constants { + + /** + * Process names. + */ + public static class ProcessName { + + /** The Constant QEMU. */ + public static final String QEMU = "qemu"; + + /** The Constant SWTPM. */ + public static final String SWTPM = "swtpm"; + + /** The Constant CLOUD_INIT_IMG. */ + public static final String CLOUD_INIT_IMG = "cloudInitImg"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine index def82ef..d0104f3 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine @@ -2,7 +2,7 @@ FROM docker.io/alpine RUN apk update -RUN apk add qemu-system-x86_64 qemu-modules ovmf swtpm openjdk17 +RUN apk add qemu-system-x86_64 qemu-modules ovmf swtpm openjdk21 mtools RUN mkdir -p /etc/qemu && echo "allow all" > /etc/qemu/bridge.conf diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch index 379537b..0c2fd86 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch @@ -1,10 +1,12 @@ -FROM archlinux/archlinux +FROM docker.io/archlinux/archlinux:latest RUN systemd-firstboot -RUN pacman -Suy --noconfirm \ +RUN pacman-key --init \ + && pacman -Sy --noconfirm archlinux-keyring && pacman -Su --noconfirm \ && pacman -S --noconfirm which qemu-full virtiofsd \ - edk2-ovmf swtpm iproute2 bridge-utils jre17-openjdk-headless \ + edk2-ovmf swtpm iproute2 bridge-utils jre21-openjdk-headless \ + mtools \ && pacman -Scc --noconfirm # Remove all targets. diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java index f0face4..440da91 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java @@ -22,17 +22,18 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import org.jdrupes.vmoperator.runner.qemu.commands.QmpAddCpu; import org.jdrupes.vmoperator.runner.qemu.commands.QmpDelCpu; import org.jdrupes.vmoperator.runner.qemu.commands.QmpQueryHotpluggableCpus; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.CpuAdded; import org.jdrupes.vmoperator.runner.qemu.events.CpuDeleted; import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -40,12 +41,11 @@ import org.jgrapes.core.annotation.Handler; /** * The Class CpuController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class CpuController extends Component { private Integer currentCpus; private Integer desiredCpus; - private RunnerConfigurationUpdate suspendedConfigure; + private ConfigureQemu suspendedConfigure; /** * Instantiates a new CPU controller. @@ -62,8 +62,8 @@ public class CpuController extends Component { * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { - if (event.state() == State.TERMINATING) { + public void onConfigureQemu(ConfigureQemu event) { + if (event.runState() == RunState.TERMINATING) { return; } Optional.ofNullable(event.configuration().vm.currentCpus) @@ -170,7 +170,7 @@ public class CpuController extends Component { private void checkCpus() { if (suspendedConfigure != null && desiredCpus != null - && currentCpus == desiredCpus.intValue()) { + && Objects.equals(currentCpus, desiredCpus)) { suspendedConfigure.resumeHandling(); suspendedConfigure = null; } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java new file mode 100644 index 0000000..c3bec93 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -0,0 +1,171 @@ +/* + * VM-Operator + * Copyright (C) 2023,2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Level; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.FileChanged; +import org.jgrapes.util.events.WatchFile; + +/** + * The Class DisplayController. + */ +public class DisplayController extends Component { + + private String currentPassword; + private String protocol; + private final Path configDir; + private boolean canBeUpdated; + private boolean vmopAgentConnected; + private String loggedInUser; + + /** + * Instantiates a new Display controller. + * + * @param componentChannel the component channel + * @param configDir + */ + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) + public DisplayController(Channel componentChannel, Path configDir) { + super(componentChannel); + this.configDir = configDir; + fire(new WatchFile(configDir.resolve(DisplaySecret.PASSWORD))); + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + public void onConfigureQemu(ConfigureQemu event) { + if (event.runState() == RunState.TERMINATING) { + return; + } + protocol + = event.configuration().vm.display.spice != null ? "spice" : null; + loggedInUser = event.configuration().vm.display.loggedInUser; + configureLogin(); + if (event.runState() == RunState.STARTING) { + configurePassword(); + } + canBeUpdated = true; + } + + /** + * On vmop agent connected. + * + * @param event the event + */ + @Handler + public void onVmopAgentConnected(VmopAgentConnected event) { + vmopAgentConnected = true; + configureLogin(); + } + + private void configureLogin() { + if (!vmopAgentConnected) { + return; + } + Event evt = loggedInUser != null + ? new VmopAgentLogIn(loggedInUser) + : new VmopAgentLogOut(); + fire(evt); + } + + /** + * Watch for changes of the password file. + * + * @param event the event + */ + @Handler + public void onFileChanged(FileChanged event) { + if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) { + logger.fine(() -> "Display password updated"); + if (canBeUpdated) { + configurePassword(); + } + } + } + + private void configurePassword() { + if (protocol == null) { + return; + } + if (setDisplayPassword()) { + setPasswordExpiry(); + } + } + + private boolean setDisplayPassword() { + return readFromFile(DisplaySecret.PASSWORD).map(password -> { + if (Objects.equals(this.currentPassword, password)) { + return true; + } + this.currentPassword = password; + logger.fine(() -> "Updating display password"); + fire(new MonitorCommand( + new QmpSetDisplayPassword(protocol, password))); + return true; + }).orElse(false); + } + + private void setPasswordExpiry() { + readFromFile(DisplaySecret.EXPIRY).ifPresent(expiry -> { + logger.fine(() -> "Updating expiry time to " + expiry); + fire( + new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); + }); + } + + private Optional readFromFile(String dataItem) { + Path path = configDir.resolve(dataItem); + String label = dataItem.replace('-', ' '); + if (path.toFile().canRead()) { + logger.finer(() -> "Found " + label); + try { + return Optional.ofNullable(Files.readString(path)); + } catch (IOException e) { + logger.log(Level.WARNING, e, () -> "Cannot read " + label + ": " + + e.getMessage()); + return Optional.empty(); + } + } else { + logger.finer(() -> "No " + label); + return Optional.empty(); + } + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java new file mode 100644 index 0000000..45d2487 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -0,0 +1,226 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.time.Instant; +import java.util.LinkedList; +import java.util.Queue; +import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestPowerdown; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Components.Timer; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.ProcessExited; + +/** + * A component that handles the communication with the guest agent. + * + * If the log level for this class is set to fine, the messages + * exchanged on the monitor socket are logged. + */ +public class GuestAgentClient extends AgentConnector { + + private boolean connected; + private Instant powerdownStartedAt; + private int powerdownTimeout; + private Timer powerdownTimer; + private final Queue executing = new LinkedList<>(); + private Stop suspendedStop; + + /** + * Instantiates a new guest agent client. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public GuestAgentClient(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * When the agent has connected, request the OS information. + */ + @Override + protected void agentConnected() { + logger.fine(() -> "Guest agent connected"); + connected = true; + rep().fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); + } + + @Override + protected void agentDisconnected() { + logger.fine(() -> "Guest agent disconnected"); + connected = false; + } + + /** + * Process agent input. + * + * @param line the line + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + protected void processInput(String line) throws IOException { + logger.finer(() -> "guest agent(in): " + line); + try { + var response = mapper.readValue(line, ObjectNode.class); + if (response.has("return") || response.has("error")) { + QmpCommand executed = executing.poll(); + logger.finer(() -> String.format("(Previous \"guest agent(in)\"" + + " is result from executing %s)", executed)); + if (executed instanceof QmpGuestGetOsinfo) { + var osInfo = new OsinfoEvent(response.get("return")); + logger.fine(() -> "Guest agent triggers: " + osInfo); + rep().fire(osInfo); + } + } + } catch (JsonProcessingException e) { + throw new IOException(e); + } + } + + /** + * On guest agent command. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) + public void onGuestAgentCommand(GuestAgentCommand event) + throws IOException { + if (qemuChannel() == null) { + return; + } + var command = event.command(); + logger.fine(() -> "Guest handles: " + event); + String asText; + try { + asText = command.asText(); + logger.finer(() -> "guest agent(out): " + asText); + } catch (JsonProcessingException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot serialize Json: " + e.getMessage()); + return; + } + synchronized (executing) { + if (writer().isPresent()) { + executing.add(command); + sendCommand(asText); + } + } + } + + /** + * Shutdown the VM. + * + * @param event the event + */ + @Handler(priority = 200) + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onStop(Stop event) { + if (!connected) { + logger.fine(() -> "No guest agent connection," + + " cannot send shutdown command"); + return; + } + + // We have a connection to the guest agent attempt shutdown. + powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { + var now = Instant.now(); + event.setAssociated(Instant.class, now); + return now; + }); + var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); + if (waitUntil.isBefore(Instant.now())) { + return; + } + event.suspendHandling(); + suspendedStop = event; + logger.fine(() -> "Attempting shutdown through guest agent," + + " waiting for termination until " + waitUntil); + powerdownTimer = Components.schedule(t -> { + logger.fine(() -> "Powerdown timeout reached."); + synchronized (this) { + powerdownTimer = null; + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + }, waitUntil); + rep().fire(new GuestAgentCommand(new QmpGuestPowerdown())); + } + + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onConfigureQemu(ConfigureQemu event) { + int newTimeout = event.configuration().vm.powerdownTimeout; + if (powerdownTimeout != newTimeout) { + powerdownTimeout = newTimeout; + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer + .reschedule(powerdownStartedAt.plusSeconds(newTimeout)); + } + + } + } + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java new file mode 100644 index 0000000..777478e --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java @@ -0,0 +1,249 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.Writer; +import java.lang.reflect.UndeclaredThrowableException; +import java.net.UnixDomainSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.Closed; +import org.jgrapes.io.events.ConnectError; +import org.jgrapes.io.events.Input; +import org.jgrapes.io.events.OpenSocketConnection; +import org.jgrapes.io.util.ByteBufferWriter; +import org.jgrapes.io.util.LineCollector; +import org.jgrapes.net.SocketIOChannel; +import org.jgrapes.net.events.ClientConnected; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.FileChanged; +import org.jgrapes.util.events.WatchFile; + +/** + * A component that handles the communication with QEMU over a socket. + * + * Derived classes should log the messages exchanged on the socket + * if the log level is set to fine. + */ +public abstract class QemuConnector extends Component { + + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + + private EventPipeline rep; + private Path socketPath; + private SocketIOChannel qemuChannel; + + /** + * Instantiates a new QEMU connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public QemuConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param socketPath the socket path + */ + /* default */ void configure(Path socketPath) { + this.socketPath = socketPath; + logger.fine(() -> getClass().getSimpleName() + + " configured with socketPath=" + socketPath); + } + + /** + * Note the runner's event processor and delete the socket. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onStart(Start event) throws IOException { + rep = event.associated(EventPipeline.class).get(); + if (socketPath == null) { + return; + } + Files.deleteIfExists(socketPath); + fire(new WatchFile(socketPath)); + } + + /** + * Return the runner's event pipeline. + * + * @return the event pipeline + */ + protected EventPipeline rep() { + return rep; + } + + /** + * Watch for the creation of the swtpm socket and start the + * qemu process if it has been created. + * + * @param event the event + */ + @Handler + public void onFileChanged(FileChanged event) { + if (event.change() == FileChanged.Kind.CREATED + && event.path().equals(socketPath)) { + // qemu running, open socket + fire(new OpenSocketConnection( + UnixDomainSocketAddress.of(socketPath)) + .setAssociated(this, this)); + } + } + + /** + * Check if this is from opening the agent socket and if true, + * save the socket in the context and associate the channel with + * the context. + * + * @param event the event + * @param channel the channel + */ + @SuppressWarnings("resource") + @Handler + public void onClientConnected(ClientConnected event, + SocketIOChannel channel) { + event.openEvent().associated(this, getClass()).ifPresent(qc -> { + qemuChannel = channel; + channel.setAssociated(this, this); + channel.setAssociated(Writer.class, new ByteBufferWriter( + channel).nativeCharset()); + channel.setAssociated(LineCollector.class, + new LineCollector() + .consumer(line -> { + try { + qc.processInput(line); + } catch (IOException e) { + throw new UndeclaredThrowableException(e); + } + })); + qc.socketConnected(); + }); + } + + /** + * Return the QEMU channel if the connection has been established. + * + * @return the socket IO channel + */ + protected Optional qemuChannel() { + return Optional.ofNullable(qemuChannel); + } + + /** + * Return the {@link Writer} for the connection if the connection + * has been established. + * + * @return the optional + */ + protected Optional writer() { + return qemuChannel().flatMap(c -> c.associated(Writer.class)); + } + + /** + * Send the given command to QEMU. A newline is appended to the + * command automatically. + * + * @param command the command + * @return true, if successful + * @throws IOException Signals that an I/O exception has occurred. + */ + protected boolean sendCommand(String command) throws IOException { + if (writer().isEmpty()) { + return false; + } + writer().get().append(command).append('\n').flush(); + return true; + } + + /** + * Called when the connector has been connected to the socket. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void socketConnected() { + // Default is to do nothing. + } + + /** + * Called when a connection attempt fails. + * + * @param event the event + */ + @Handler + public void onConnectError(ConnectError event) { + event.event().associated(this, getClass()).ifPresent(qc -> { + rep.fire(new Stop()); + }); + } + + /** + * Handle data from the socket connection. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onInput(Input event, SocketIOChannel channel) { + if (channel.associated(this, getClass()).isEmpty()) { + return; + } + channel.associated(LineCollector.class).ifPresent(collector -> { + collector.feed(event); + }); + } + + /** + * Process agent input. + * + * @param line the line + * @throws IOException Signals that an I/O exception has occurred. + */ + protected abstract void processInput(String line) throws IOException; + + /** + * On closed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onClosed(Closed event, SocketIOChannel channel) { + channel.associated(this, getClass()).ifPresent(qc -> { + qemuChannel = null; + }); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java index 3d22b26..feeb76a 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java @@ -19,47 +19,33 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.UndeclaredThrowableException; -import java.net.UnixDomainSocketAddress; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.LinkedList; import java.util.Queue; import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCapabilities; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpPowerdown; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.MonitorEvent; import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady; import org.jdrupes.vmoperator.runner.qemu.events.MonitorResult; import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; import org.jgrapes.core.Components; import org.jgrapes.core.Components.Timer; -import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; import org.jgrapes.core.events.Stop; import org.jgrapes.io.events.Closed; -import org.jgrapes.io.events.ConnectError; -import org.jgrapes.io.events.Input; -import org.jgrapes.io.events.OpenSocketConnection; -import org.jgrapes.io.util.ByteBufferWriter; -import org.jgrapes.io.util.LineCollector; +import org.jgrapes.io.events.ProcessExited; import org.jgrapes.net.SocketIOChannel; -import org.jgrapes.net.events.ClientConnected; import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.FileChanged; -import org.jgrapes.util.events.WatchFile; /** * A component that handles the communication over the Qemu monitor @@ -68,32 +54,29 @@ import org.jgrapes.util.events.WatchFile; * If the log level for this class is set to fine, the messages * exchanged on the monitor socket are logged. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class QemuMonitor extends Component { +public class QemuMonitor extends QemuConnector { - private static ObjectMapper mapper = new ObjectMapper(); - - private EventPipeline rep; - private Path socketPath; private int powerdownTimeout; - private SocketIOChannel monitorChannel; private final Queue executing = new LinkedList<>(); private Instant powerdownStartedAt; private Stop suspendedStop; private Timer powerdownTimer; private boolean powerdownConfirmed; + private boolean monitorReady; /** - * Instantiates a new qemu monitor. + * Instantiates a new QEMU monitor. * * @param componentChannel the component channel + * @param configDir the config dir * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") - public QemuMonitor(Channel componentChannel) throws IOException { + public QemuMonitor(Channel componentChannel, Path configDir) + throws IOException { super(componentChannel); attach(new RamController(channel())); attach(new CpuController(channel())); + attach(new DisplayController(channel(), configDir)); attach(new CdMediaController(channel())); } @@ -107,120 +90,45 @@ public class QemuMonitor extends Component { * @param powerdownTimeout */ /* default */ void configure(Path socketPath, int powerdownTimeout) { - this.socketPath = socketPath; + super.configure(socketPath); this.powerdownTimeout = powerdownTimeout; } /** - * Handle the start event. - * - * @param event the event - * @throws IOException Signals that an I/O exception has occurred. + * When the socket is connected, send the capabilities command. */ - @Handler - public void onStart(Start event) throws IOException { - rep = event.associated(EventPipeline.class).get(); - if (socketPath == null) { - return; - } - Files.deleteIfExists(socketPath); - fire(new WatchFile(socketPath)); + @Override + protected void socketConnected() { + rep().fire(new MonitorCommand(new QmpCapabilities())); } - /** - * Watch for the creation of the swtpm socket and start the - * qemu process if it has been created. - * - * @param event the event - */ - @Handler - public void onFileChanged(FileChanged event) { - if (event.change() == FileChanged.Kind.CREATED - && event.path().equals(socketPath)) { - // qemu running, open socket - fire(new OpenSocketConnection( - UnixDomainSocketAddress.of(socketPath)) - .setAssociated(QemuMonitor.class, this)); - } - } - - /** - * Check if this is from opening the monitor socket and if true, - * save the socket in the context and associate the channel with - * the context. Then send the initial message to the socket. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onClientConnected(ClientConnected event, - SocketIOChannel channel) { - event.openEvent().associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = channel; - channel.setAssociated(QemuMonitor.class, this); - channel.setAssociated(Writer.class, new ByteBufferWriter( - channel).nativeCharset()); - channel.setAssociated(LineCollector.class, - new LineCollector() - .consumer(line -> { - try { - processMonitorInput(line); - } catch (IOException e) { - throw new UndeclaredThrowableException(e); - } - })); - fire(new MonitorCommand(new QmpCapabilities())); - }); - } - - /** - * Called when a connection attempt fails. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onConnectError(ConnectError event, SocketIOChannel channel) { - event.event().associated(QemuMonitor.class).ifPresent(qm -> { - rep.fire(new Stop()); - }); - } - - /** - * Handle data from qemu monitor connection. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(QemuMonitor.class).isEmpty()) { - return; - } - channel.associated(LineCollector.class).ifPresent(collector -> { - collector.feed(event); - }); - } - - private void processMonitorInput(String line) + @Override + protected void processInput(String line) throws IOException { - logger.fine(() -> "monitor(in): " + line); + logger.finer(() -> "monitor(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("QMP")) { - rep.fire(new MonitorReady()); + monitorReady = true; + logger.fine(() -> "QMP connection ready"); + rep().fire(new MonitorReady()); return; } if (response.has("return") || response.has("error")) { QmpCommand executed = executing.poll(); - logger.fine( + logger.finer( () -> String.format("(Previous \"monitor(in)\" is result " + "from executing %s)", executed)); - rep.fire(MonitorResult.from(executed, response)); + var monRes = MonitorResult.from(executed, response); + logger.fine(() -> "QMP triggers: " + monRes); + rep().fire(monRes); return; } if (response.has("event")) { - MonitorEvent.from(response).ifPresent(rep::fire); + MonitorEvent.from(response).ifPresent(me -> { + logger.fine(() -> "QMP triggers: " + me); + rep().fire(me); + }); } } catch (JsonProcessingException e) { throw new IOException(e); @@ -234,17 +142,10 @@ public class QemuMonitor extends Component { */ @Handler public void onClosed(Closed event, SocketIOChannel channel) { - channel.associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = null; - synchronized (this) { - if (powerdownTimer != null) { - powerdownTimer.cancel(); - } - if (suspendedStop != null) { - suspendedStop.resumeHandling(); - suspendedStop = null; - } - } + channel.associated(this, getClass()).ifPresent(qm -> { + super.onClosed(event, channel); + logger.fine(() -> "QMP connection closed."); + monitorReady = false; }); } @@ -252,29 +153,37 @@ public class QemuMonitor extends Component { * On monitor command. * * @param event the event + * @throws IOException */ @Handler - public void onExecQmpCommand(MonitorCommand event) { + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) + public void onMonitorCommand(MonitorCommand event) throws IOException { + // Check prerequisites + if (!monitorReady && !(event.command() instanceof QmpCapabilities)) { + logger.severe(() -> "Premature QMP command (not ready): " + + event.command()); + rep().fire(new Stop()); + return; + } + + // Send the command var command = event.command(); + logger.fine(() -> "QMP handles: " + event.toString()); String asText; try { - asText = mapper.writeValueAsString(command.toJson()); + asText = command.asText(); + logger.finer(() -> "monitor(out): " + asText); } catch (JsonProcessingException e) { logger.log(Level.SEVERE, e, () -> "Cannot serialize Json: " + e.getMessage()); return; } - logger.fine(() -> "monitor(out): " + asText); synchronized (executing) { - monitorChannel.associated(Writer.class).ifPresent(writer -> { - try { - executing.add(command); - writer.append(asText).append('\n').flush(); - } catch (IOException e) { - // Cannot happen, but... - logger.log(Level.WARNING, e, () -> e.getMessage()); - } - }); + if (writer().isPresent()) { + executing.add(command); + sendCommand(asText); + } } } @@ -284,37 +193,51 @@ public class QemuMonitor extends Component { * @param event the event */ @Handler(priority = 100) + @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onStop(Stop event) { - if (monitorChannel != null) { - // We have a connection to Qemu, attempt ACPI shutdown. - event.suspendHandling(); - suspendedStop = event; - - // Attempt powerdown command. If not confirmed, assume - // "hanging" qemu process. - powerdownTimer = Components.schedule(t -> { - // Powerdown not confirmed - logger.fine(() -> "QMP powerdown command has not effect."); - synchronized (this) { - powerdownTimer = null; - if (suspendedStop != null) { - suspendedStop.resumeHandling(); - suspendedStop = null; - } - } - }, Duration.ofSeconds(1)); - logger.fine(() -> "Attempting QMP powerdown."); - powerdownStartedAt = Instant.now(); - fire(new MonitorCommand(new QmpPowerdown())); + if (!monitorReady) { + logger.fine(() -> "Not sending QMP powerdown command" + + " because QMP connection is closed"); + return; } + + // We have a connection to Qemu, attempt ACPI shutdown if time left + powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { + var now = Instant.now(); + event.setAssociated(Instant.class, now); + return now; + }); + if (powerdownStartedAt.plusSeconds(powerdownTimeout) + .isBefore(Instant.now())) { + return; + } + event.suspendHandling(); + suspendedStop = event; + + // Send command. If not confirmed, assume "hanging" qemu process. + powerdownTimer = Components.schedule(t -> { + logger.fine(() -> "QMP powerdown command not confirmed"); + synchronized (this) { + powerdownTimer = null; + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + }, Duration.ofSeconds(5)); + logger.fine(() -> "Attempting QMP (ACPI) powerdown."); + rep().fire(new MonitorCommand(new QmpPowerdown())); } /** - * On powerdown event. + * When the powerdown event is confirmed, wait for termination + * or timeout. Termination is detected by the qemu process exiting + * (see {@link #onProcessExited(ProcessExited)}). * * @param event the event */ @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onPowerdownEvent(PowerdownEvent event) { synchronized (this) { // Cancel confirmation timeout @@ -323,27 +246,54 @@ public class QemuMonitor extends Component { } // (Re-)schedule timer as fallback - logger.fine(() -> "QMP powerdown confirmed, waiting..."); + var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); + logger.fine(() -> "QMP powerdown confirmed, waiting for" + + " termination until " + waitUntil); powerdownTimer = Components.schedule(t -> { logger.fine(() -> "Powerdown timeout reached."); synchronized (this) { + powerdownTimer = null; if (suspendedStop != null) { suspendedStop.resumeHandling(); suspendedStop = null; } } - }, powerdownStartedAt.plusSeconds(powerdownTimeout)); + }, waitUntil); powerdownConfirmed = true; } } + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + /** * On configure qemu. * * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onConfigureQemu(ConfigureQemu event) { int newTimeout = event.configuration().vm.powerdownTimeout; if (powerdownTimeout != newTimeout) { powerdownTimeout = newTimeout; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java index 05fdde6..81a10f9 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java @@ -21,8 +21,8 @@ package org.jdrupes.vmoperator.runner.qemu; import java.math.BigInteger; import java.util.Optional; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetBalloon; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -39,7 +39,6 @@ public class RamController extends Component { * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") public RamController(Channel componentChannel) { super(componentChannel); } @@ -50,7 +49,7 @@ public class RamController extends Component { * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { + public void onConfigureQemu(ConfigureQemu event) { Optional.ofNullable(event.configuration().vm.currentRam) .ifPresent(cr -> { if (currentRam != null && currentRam.equals(cr)) { diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index 4e4ba18..4819dcd 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import freemarker.core.ParseException; import freemarker.template.MalformedTemplateNameException; import freemarker.template.TemplateException; @@ -38,25 +39,34 @@ import java.lang.reflect.UndeclaredThrowableException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Instant; import java.util.Comparator; +import java.util.EnumSet; import java.util.HashMap; -import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.CommandLineParser; import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jdrupes.vmoperator.runner.qemu.events.QmpConfigured; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jdrupes.vmoperator.util.FsdUtils; import org.jgrapes.core.Channel; @@ -108,21 +118,27 @@ import org.jgrapes.util.events.WatchFile; * * state "Starting (Processes)" as StartingProcess { * - * state which <> - * state "Start swtpm" as swtpm * state "Start qemu" as qemu * state "Open monitor" as monitor * state "Configure QMP" as waitForConfigured * state "Configure QEMU" as configure * state success <> * state error <> - * - * which --> swtpm: [use swtpm] - * which --> qemu: [else] - * + * + * state prepFork <> + * state prepJoin <> + * state "Generate cloud-init image" as cloudInit + * prepFork --> cloudInit: [cloud-init data provided] + * swtpm --> prepJoin: FileChanged[swtpm socket created] + * state "Start swtpm" as swtpm + * prepFork --> swtpm: [use swtpm] * swtpm: entry/start swtpm - * swtpm -> qemu: FileChanged[swtpm socket created] - * + * cloudInit --> prepJoin: ProcessExited + * cloudInit: entry/generate cloud-init image + * prepFork --> prepJoin: [else] + * + * prepJoin --> qemu + * * qemu: entry/start qemu * qemu --> monitor : FileChanged[monitor socket created] * @@ -133,14 +149,23 @@ import org.jgrapes.util.events.WatchFile; * waitForConfigured: entry/fire QmpCapabilities * waitForConfigured --> configure: QmpConfigured * - * configure: entry/fire RunnerConfigurationUpdate - * configure --> success: RunnerConfigurationUpdate (last handler)/fire cont command + * configure: entry/fire ConfigureQemu + * configure --> success: ConfigureQemu (last handler)/fire cont command * } * - * Initializing --> which: Started + * Initializing --> prepFork: Started * * success --> Running * + * state Running { + * state Booting + * state Booted + * + * [*] -right-> Booting + * Booting -down-> Booting: VserportChanged[guest agent connected]/fire GetOsinfo + * Booting --> Booted: Osinfo + * } + * * state Terminating { * state terminate <> * state qemuRunning <> @@ -176,7 +201,7 @@ import org.jgrapes.util.events.WatchFile; * */ @SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace", - "PMD.DataflowAnomalyAnalysis" }) + "PMD.TooManyMethods", "PMD.CouplingBetweenObjects" }) public class Runner extends Component { private static final String TEMPLATE_DIR @@ -185,17 +210,37 @@ public class Runner extends Component { = "Standard-VM-latest.ftl.yaml"; private static final String SAVED_TEMPLATE = "VM.ftl.yaml"; private static final String FW_VARS = "fw-vars.fd"; + private static int exitStatus; - private EventPipeline rep; - private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + private final EventPipeline rep = newEventPipeline(); + private final ObjectMapper yamlMapper = new ObjectMapper(YAMLFactory + .builder().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build()); private final JsonNode defaults; - @SuppressWarnings("PMD.UseConcurrentHashMap") - private Configuration config = new Configuration(); + private final File configFile; + private final Path configDir; + private Configuration initialConfig; + private Configuration pendingConfig; private final freemarker.template.Configuration fmConfig; private CommandDefinition swtpmDefinition; + private CommandDefinition cloudInitImgDefinition; private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; - private State state = State.INITIALIZING; + private boolean qmpConfigured; + private final GuestAgentClient guestAgentClient; + private final VmopAgentClient vmopAgentClient; + private Integer resetCounter; + private RunState state = RunState.INITIALIZING; + + /** Preparatory actions for QEMU start */ + @SuppressWarnings("PMD.FieldNamingConventions") + private enum QemuPreps { + Config, + Tpm, + CloudInit + } + + private final Set qemuLatch = EnumSet.noneOf(QemuPreps.class); /** * Instantiates a new runner. @@ -203,7 +248,7 @@ public class Runner extends Component { * @param cmdLine the cmd line * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings("PMD.SystemPrintln") + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) public Runner(CommandLine cmdLine) throws IOException { yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -212,6 +257,17 @@ public class Runner extends Component { defaults = yamlMapper.readValue( Runner.class.getResourceAsStream("defaults.yaml"), JsonNode.class); + // Get the config + configFile = new File(cmdLine.getOptionValue('c', + "/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml")); + // Don't rely on night config to produce a good exception + // for this simple case + if (!Files.isReadable(configFile.toPath())) { + throw new IOException( + "Cannot read configuration file " + configFile); + } + configDir = configFile.getParentFile().toPath().toRealPath(); + // Configure freemarker library fmConfig = new freemarker.template.Configuration( freemarker.template.Configuration.VERSION_2_3_32); @@ -228,19 +284,12 @@ public class Runner extends Component { attach(new FileSystemWatcher(channel())); attach(new ProcessManager(channel())); attach(new SocketConnector(channel())); - attach(qemuMonitor = new QemuMonitor(channel())); + attach(qemuMonitor = new QemuMonitor(channel(), configDir)); + attach(guestAgentClient = new GuestAgentClient(channel())); + attach(vmopAgentClient = new VmopAgentClient(channel())); attach(new StatusUpdater(channel())); - - // Configuration store with file in /etc/opt (default) - File config = new File(cmdLine.getOptionValue('c', - "/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml")); - // Don't rely on night config to produce a good exception - // for this simple case - if (!Files.isReadable(config.toPath())) { - throw new IOException("Cannot read configuration file " + config); - } - attach(new YamlConfigurationStore(channel(), config, false)); - fire(new WatchFile(config.toPath())); + attach(new YamlConfigurationStore(channel(), configFile, false)); + fire(new WatchFile(configFile.toPath())); } /** @@ -258,57 +307,84 @@ public class Runner extends Component { } /** - * On configuration update. + * Process the initial configuration. The initial configuration + * and any subsequent updates will be forwarded to other components + * only when the QMP connection is ready + * (see @link #onQmpConfigured(QmpConfigured)). * * @param event the event */ @Handler public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { - if (event instanceof InitialConfiguration) { - processInitialConfiguration(c); - return; - } - logger.fine(() -> "Updating configuration"); + logger.fine(() -> "Runner configuratation updated"); var newConf = yamlMapper.convertValue(c, Configuration.class); - rep.fire(new RunnerConfigurationUpdate(newConf, state)); + + // Add some values from other sources to configuration + newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); + Path dsPath = configDir.resolve(DisplaySecret.PASSWORD); + newConf.hasDisplayPassword = dsPath.toFile().canRead(); + + // Special actions for initial configuration (startup) + if (event instanceof InitialConfiguration) { + processInitialConfiguration(newConf); + } + + // Check if to be sent immediately or later + if (qmpConfigured) { + rep.fire(new ConfigureQemu(newConf, state)); + } else { + pendingConfig = newConf; + } }); } - private void processInitialConfiguration( - Map runnerConfiguration) { + @SuppressWarnings("PMD.LambdaCanBeMethodReference") + private void processInitialConfiguration(Configuration newConfig) { try { - config = yamlMapper.convertValue(runnerConfiguration, - Configuration.class); - if (!config.check()) { + if (!newConfig.check()) { // Invalid configuration, not used, problems already logged. - config = null; + return; } // Prepare firmware files and add to config - setFirmwarePaths(); + setFirmwarePaths(newConfig); // Obtain more context data from template - var tplData = dataFromTemplate(); - swtpmDefinition = Optional.ofNullable(tplData.get("swtpm")) - .map(d -> new CommandDefinition("swtpm", d)).orElse(null); - qemuDefinition = Optional.ofNullable(tplData.get("qemu")) - .map(d -> new CommandDefinition("qemu", d)).orElse(null); + var tplData = dataFromTemplate(newConfig); + initialConfig = newConfig; + + // Configure + swtpmDefinition + = Optional.ofNullable(tplData.get(ProcessName.SWTPM)) + .map(d -> new CommandDefinition(ProcessName.SWTPM, d)) + .orElse(null); + logger.finest(() -> swtpmDefinition.toString()); + qemuDefinition = Optional.ofNullable(tplData.get(ProcessName.QEMU)) + .map(d -> new CommandDefinition(ProcessName.QEMU, d)) + .orElse(null); + logger.finest(() -> qemuDefinition.toString()); + cloudInitImgDefinition + = Optional.ofNullable(tplData.get(ProcessName.CLOUD_INIT_IMG)) + .map(d -> new CommandDefinition(ProcessName.CLOUD_INIT_IMG, + d)) + .orElse(null); + logger.finest(() -> cloudInitImgDefinition.toString()); // Forward some values to child components - qemuMonitor.configure(config.monitorSocket, - config.vm.powerdownTimeout); + qemuMonitor.configure(initialConfig.monitorSocket, + initialConfig.vm.powerdownTimeout); + guestAgentClient.configureConnection(qemuDefinition.command, + "guest-agent-socket"); + vmopAgentClient.configureConnection(qemuDefinition.command, + "vmop-agent-socket"); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); - // Don't use default configuration - config = null; } } - @SuppressWarnings({ "PMD.CognitiveComplexity", - "PMD.DataflowAnomalyAnalysis" }) - private void setFirmwarePaths() throws IOException { + private void setFirmwarePaths(Configuration config) throws IOException { JsonNode firmware = defaults.path("firmware").path(config.vm.firmware); // Get file for firmware ROM JsonNode codePaths = firmware.path("rom"); @@ -319,6 +395,12 @@ public class Runner extends Component { break; } } + if (codePaths.iterator().hasNext() && config.firmwareRom == null) { + throw new IllegalArgumentException("No ROM found, candidates were: " + + StreamSupport.stream(codePaths.spliterator(), false) + .map(JsonNode::asText).collect(Collectors.joining(", "))); + } + // Get file for firmware vars, if necessary config.firmwareVars = config.dataDir.resolve(FW_VARS); if (!Files.exists(config.firmwareVars)) { @@ -332,7 +414,7 @@ public class Runner extends Component { } } - private JsonNode dataFromTemplate() + private JsonNode dataFromTemplate(Configuration config) throws IOException, TemplateNotFoundException, MalformedTemplateNameException, ParseException, TemplateException, JsonProcessingException, JsonMappingException { @@ -357,20 +439,35 @@ public class Runner extends Component { .map(Object::toString).orElse(null)); model.put("firmwareVars", Optional.ofNullable(config.firmwareVars) .map(Object::toString).orElse(null)); + model.put("hasDisplayPassword", config.hasDisplayPassword); + model.put("cloudInit", config.cloudInit); model.put("vm", config.vm); - if (Optional.ofNullable(config.vm.display) - .map(d -> d.spice).map(s -> s.ticket).isPresent()) { - model.put("ticketPath", config.runtimeDir.resolve("ticket.txt")); - } + logger.finest(() -> "Processing template with model: " + model); // Combine template and data and parse result // (tempting, but no need to use a pipe here) var fmTemplate = fmConfig.getTemplate(templatePath.toString()); StringWriter out = new StringWriter(); fmTemplate.process(model, out); + logger.finest(() -> "Result of processing template: " + out); return yamlMapper.readValue(out.toString(), JsonNode.class); } + /** + * Note ready state and send a {@link ConfigureQemu} event for + * any pending configuration (initial or change). + * + * @param event the event + */ + @Handler + public void onQmpConfigured(QmpConfigured event) { + qmpConfigured = true; + if (pendingConfig != null) { + rep.fire(new ConfigureQemu(pendingConfig, state)); + pendingConfig = null; + } + } + /** * Handle the start event. * @@ -378,7 +475,7 @@ public class Runner extends Component { */ @Handler(priority = 100) public void onStart(Start event) { - if (config == null) { + if (initialConfig == null) { // Missing configuration, fail event.cancel(true); fire(new Stop()); @@ -389,25 +486,24 @@ public class Runner extends Component { // https://github.com/kubernetes-client/java/issues/100 io.kubernetes.client.openapi.Configuration.setDefaultApiClient(null); - // Prepare specific event pipeline to avoid concurrency. - rep = newEventPipeline(); + // Provide specific event pipeline to avoid concurrency. event.setAssociated(EventPipeline.class, rep); try { // Store process id try (var pidFile = Files.newBufferedWriter( - config.runtimeDir.resolve("runner.pid"))) { + initialConfig.runtimeDir.resolve("runner.pid"))) { pidFile.write(ProcessHandle.current().pid() + "\n"); } // Files to watch for - Files.deleteIfExists(config.swtpmSocket); - fire(new WatchFile(config.swtpmSocket)); + Files.deleteIfExists(initialConfig.swtpmSocket); + fire(new WatchFile(initialConfig.swtpmSocket)); // Helper files - var ticket = Optional.ofNullable(config.vm.display) + var ticket = Optional.ofNullable(initialConfig.vm.display) .map(d -> d.spice).map(s -> s.ticket); if (ticket.isPresent()) { - Files.write(config.runtimeDir.resolve("ticket.txt"), + Files.write(initialConfig.runtimeDir.resolve("ticket.txt"), ticket.get().getBytes()); } } catch (IOException e) { @@ -424,21 +520,73 @@ public class Runner extends Component { */ @Handler public void onStarted(Started event) { - state = State.STARTING; + state = RunState.STARTING; rep.fire(new RunnerStateChange(state, "RunnerStarted", "Runner has been started")); - // Start first process - if (config.vm.useTpm && swtpmDefinition != null) { + // Start first process(es) + qemuLatch.add(QemuPreps.Config); + if (initialConfig.vm.useTpm && swtpmDefinition != null) { startProcess(swtpmDefinition); - return; + qemuLatch.add(QemuPreps.Tpm); + } + if (initialConfig.cloudInit != null) { + generateCloudInitImg(initialConfig); + qemuLatch.add(QemuPreps.CloudInit); + } + mayBeStartQemu(QemuPreps.Config); + } + + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + private void mayBeStartQemu(QemuPreps done) { + synchronized (qemuLatch) { + if (qemuLatch.isEmpty()) { + return; + } + qemuLatch.remove(done); + if (qemuLatch.isEmpty()) { + startProcess(qemuDefinition); + } + } + } + + private void generateCloudInitImg(Configuration config) { + try { + var cloudInitDir = config.dataDir.resolve("cloud-init"); + cloudInitDir.toFile().mkdir(); + try (var metaOut + = Files.newBufferedWriter(cloudInitDir.resolve("meta-data"))) { + if (config.cloudInit.metaData != null) { + yamlMapper.writer().writeValue(metaOut, + config.cloudInit.metaData); + } + } + try (var userOut + = Files.newBufferedWriter(cloudInitDir.resolve("user-data"))) { + userOut.write("#cloud-config\n"); + if (config.cloudInit.userData != null) { + yamlMapper.writer().writeValue(userOut, + config.cloudInit.userData); + } + } + if (config.cloudInit.networkConfig != null) { + try (var networkConfig = Files.newBufferedWriter( + cloudInitDir.resolve("network-config"))) { + yamlMapper.writer().writeValue(networkConfig, + config.cloudInit.networkConfig); + } + } + startProcess(cloudInitImgDefinition); + } catch (IOException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot start runner: " + e.getMessage()); + fire(new Stop()); } - startProcess(qemuDefinition); } private boolean startProcess(CommandDefinition toStart) { logger.info( () -> "Starting process: " + String.join(" ", toStart.command)); - fire(new StartProcess(toStart.command) + rep.fire(new StartProcess(toStart.command) .setAssociated(CommandDefinition.class, toStart)); return true; } @@ -452,10 +600,9 @@ public class Runner extends Component { @Handler public void onFileChanged(FileChanged event) { if (event.change() == Kind.CREATED - && event.path().equals(config.swtpmSocket)) { - // swtpm running, start qemu - startProcess(qemuDefinition); - return; + && event.path().equals(initialConfig.swtpmSocket)) { + // swtpm running, maybe start qemu + mayBeStartQemu(QemuPreps.Tpm); } } @@ -468,15 +615,13 @@ public class Runner extends Component { * @throws InterruptedException the interrupted exception */ @Handler - @SuppressWarnings({ "PMD.SwitchStmtsShouldHaveDefault", - "PMD.TooFewBranchesForASwitchStatement" }) public void onProcessStarted(ProcessStarted event, ProcessChannel channel) throws InterruptedException { event.startEvent().associated(CommandDefinition.class) .ifPresent(procDef -> { channel.setAssociated(CommandDefinition.class, procDef); try (var pidFile = Files.newBufferedWriter( - config.runtimeDir.resolve(procDef.name + ".pid"))) { + initialConfig.runtimeDir.resolve(procDef.name + ".pid"))) { pidFile.write(channel.process().toHandle().pid() + "\n"); } catch (IOException e) { throw new UndeclaredThrowableException(e); @@ -509,30 +654,53 @@ public class Runner extends Component { } /** - * On monitor ready. + * Whenever a new QEMU configuration is available, check if it + * is supposed to trigger a reset. * * @param event the event */ @Handler - public void onQmpConfigured(QmpConfigured event) { - rep.fire(new RunnerConfigurationUpdate(config, state)); + public void onConfigureQemu(ConfigureQemu event) { + if (state.vmActive()) { + if (resetCounter != null + && event.configuration().resetCounter != null + && event.configuration().resetCounter > resetCounter) { + fire(new MonitorCommand(new QmpReset())); + } + resetCounter = event.configuration().resetCounter; + } } /** - * On configure qemu. - * + * As last step when handling a new configuration, check if + * QEMU is suspended after startup and should be continued. + * * @param event the event */ @Handler(priority = -1000) - public void onConfigureQemu(RunnerConfigurationUpdate event) { - if (state == State.STARTING) { + public void onConfigureQemuFinal(ConfigureQemu event) { + if (state == RunState.STARTING) { + state = RunState.BOOTING; fire(new MonitorCommand(new QmpCont())); - state = State.RUNNING; rep.fire(new RunnerStateChange(state, "VmStarted", "Qemu has been configured and is continuing")); } } + /** + * Receiving the OSinfo means that the OS has been booted. + * + * @param event the event + */ + @Handler + public void onOsinfo(OsinfoEvent event) { + if (state == RunState.BOOTING) { + state = RunState.BOOTED; + rep.fire(new RunnerStateChange(state, "VmBooted", + "The VM has started the guest agent.")); + } + } + /** * On process exited. * @@ -542,22 +710,43 @@ public class Runner extends Component { @Handler public void onProcessExited(ProcessExited event, ProcessChannel channel) { channel.associated(CommandDefinition.class).ifPresent(procDef -> { - // No process(es) may exit during startup - if (state == State.STARTING) { + if (procDef.equals(cloudInitImgDefinition) + && event.exitValue() == 0) { + // Cloud-init ISO generation was successful. + mayBeStartQemu(QemuPreps.CloudInit); + return; + } + + // No other process(es) may exit during startup + if (state == RunState.STARTING) { logger.severe(() -> "Process " + procDef.name + " has exited with value " + event.exitValue() + " during startup."); rep.fire(new Stop()); return; } - if (procDef.equals(qemuDefinition) && state == State.RUNNING) { - rep.fire(new Stop()); + + // No processes may exit while the VM is running normally + if (procDef.equals(qemuDefinition) && state.vmActive()) { + rep.fire(new Exit(event.exitValue())); } logger.info(() -> "Process " + procDef.name + " has exited with value " + event.exitValue()); }); } + /** + * On exit. + * + * @param event the event + */ + @Handler(priority = 10_001) + public void onExit(Exit event) { + if (exitStatus == 0) { + exitStatus = event.exitStatus(); + } + } + /** * On stop. * @@ -565,9 +754,9 @@ public class Runner extends Component { */ @Handler(priority = 10_000) public void onStopFirst(Stop event) { - state = State.TERMINATING; + state = RunState.TERMINATING; rep.fire(new RunnerStateChange(state, "VmTerminating", - "The VM is being shut down")); + "The VM is being shut down", exitStatus != 0)); } /** @@ -577,13 +766,13 @@ public class Runner extends Component { */ @Handler(priority = -10_000) public void onStopLast(Stop event) { - state = State.STOPPED; + state = RunState.STOPPED; rep.fire(new RunnerStateChange(state, "VmStopped", "The VM has been shut down")); } private void shutdown() { - if (state != State.TERMINATING) { + if (!Set.of(RunState.TERMINATING, RunState.STOPPED).contains(state)) { fire(new Stop()); } try { @@ -592,7 +781,7 @@ public class Runner extends Component { logger.log(Level.WARNING, e, () -> "Proper shutdown failed."); } - Optional.ofNullable(config).map(c -> c.runtimeDir) + Optional.ofNullable(initialConfig).map(c -> c.runtimeDir) .ifPresent(runtimeDir -> { try { Files.walk(runtimeDir).sorted(Comparator.reverseOrder()) @@ -616,6 +805,10 @@ public class Runner extends Component { props = Runner.class.getResourceAsStream("logging.properties"); } LogManager.getLogManager().readConfiguration(props); + Logger.getLogger(Runner.class.getName()).log(Level.CONFIG, + () -> path.isPresent() + ? "Using logging configuration from " + path.get() + : "Using default logging configuration"); } catch (IOException e) { e.printStackTrace(); } @@ -650,6 +843,11 @@ public class Runner extends Component { // Start the application Components.start(app); + + // Wait for (regular) termination + Components.awaitExhaustion(); + System.exit(exitStatus); + } catch (IOException | InterruptedException | org.apache.commons.cli.ParseException e) { Logger.getLogger(Runner.class.getName()).log(Level.SEVERE, e, diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java index 19e252f..127c070 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,54 +18,71 @@ package org.jdrupes.vmoperator.runner.qemu; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.Gson; import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.custom.Quantity.Format; +import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.apis.ApisApi; -import io.kubernetes.client.openapi.apis.CustomObjectsApi; -import io.kubernetes.client.openapi.models.V1APIGroup; -import io.kubernetes.client.openapi.models.V1GroupVersionForDiscovery; -import io.kubernetes.client.util.Config; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; +import java.math.BigInteger; import java.time.Instant; -import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.Constants.Status.Condition; +import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; +import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; +import org.jgrapes.core.Components; +import org.jgrapes.core.Components.Timer; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; -import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.InitialConfiguration; /** * Updates the CR status. */ -public class StatusUpdater extends Component { +@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) +public class StatusUpdater extends VmDefUpdater { - private static final Set RUNNING_STATES - = Set.of(State.RUNNING, State.TERMINATING); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final Gson gson = new JSON().getGson(); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); - private String namespace; - private String vmName; - private DynamicKubernetesApi vmCrApi; - private long observedGeneration; + private boolean guestShutdownStops; + private boolean shutdownByGuest; + private VmDefinitionStub vmStub; + private String loggedInUser; + private BigInteger lastRamValue; + private Instant lastRamChange; + private Timer balloonTimer; + private BigInteger targetRamValue; /** * Instantiates a new status updater. @@ -74,6 +91,7 @@ public class StatusUpdater extends Component { */ public StatusUpdater(Channel componentChannel) { super(componentChannel); + attach(new ConsoleTracker(componentChannel)); } /** @@ -90,43 +108,6 @@ public class StatusUpdater extends Component { } } - /** - * On configuration update. - * - * @param event the event - */ - @Handler - @SuppressWarnings("unchecked") - public void onConfigurationUpdate(ConfigurationUpdate event) { - event.structured("/Runner").ifPresent(c -> { - if (event instanceof InitialConfiguration) { - namespace = (String) c.get("namespace"); - updateNamespace(); - vmName = Optional.ofNullable((Map) c.get("vm")) - .map(vm -> vm.get("name")).orElse(null); - } - }); - } - - private void updateNamespace() { - if (namespace == null) { - var path = Path - .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); - if (Files.isReadable(path)) { - try { - namespace = Files.lines(path).findFirst().orElse(null); - } catch (IOException e) { - logger.log(Level.WARNING, e, - () -> "Cannot read namespace."); - } - } - } - if (namespace == null) { - logger.warning(() -> "Namespace is unknown, some functions" - + " won't be available."); - } - } - /** * Handle the start event. * @@ -135,45 +116,32 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler - @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals" }) - public void onStart(Start event) throws IOException, ApiException { + public void onStart(Start event) { if (namespace == null) { return; } - var client = Config.defaultClient(); - var apis = new ApisApi(client).getAPIVersions(); - var crdVersions = apis.getGroups().stream() - .filter(g -> g.getName().equals(VM_OP_GROUP)).findFirst() - .map(V1APIGroup::getVersions).stream().flatMap(l -> l.stream()) - .map(V1GroupVersionForDiscovery::getVersion).toList(); - var coa = new CustomObjectsApi(client); - for (var crdVersion : crdVersions) { - var crdApiRes = coa.getAPIResources(VM_OP_GROUP, - crdVersion).getResources().stream() - .filter(r -> VM_OP_KIND_VM.equals(r.getKind())).findFirst(); - if (crdApiRes.isEmpty()) { - continue; - } - var crApi = new DynamicKubernetesApi(VM_OP_GROUP, - crdVersion, crdApiRes.get().getName(), client); - var vmCr = crApi.get(namespace, vmName); - if (vmCr.isSuccess()) { - vmCrApi = crApi; - observedGeneration - = vmCr.getObject().getMetadata().getGeneration(); - break; + try { + vmStub = VmDefinitionStub.get(apiClient, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + namespace, vmName); + var vmDef = vmStub.model().orElse(null); + if (vmDef == null) { + return; } + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.RUNNER_VERSION, Optional.ofNullable( + Runner.class.getPackage().getImplementationVersion()) + .orElse("(unknown)")); + status.remove(Status.LOGGED_IN_USER); + return status; + }); + } catch (ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access VM object, terminating."); + event.cancel(true); + fire(new Exit(1)); } - if (vmCrApi == null) { - logger.warning(() -> "Cannot find VM's CR, status will not" - + " be updated."); - } - } - - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private JsonObject currentStatus(DynamicKubernetesObject vmCr) { - return vmCr.getRaw().getAsJsonObject("status").deepCopy(); } /** @@ -183,25 +151,28 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler - public void onRunnerConfigurationUpdate(RunnerConfigurationUpdate event) + public void onConfigureQemu(ConfigureQemu event) throws ApiException { - if (vmCrApi == null) { + guestShutdownStops = event.configuration().guestShutdownStops; + loggedInUser = event.configuration().vm.display.loggedInUser; + targetRamValue = event.configuration().vm.currentRam; + + // Remainder applies only if we have a connection to k8s. + if (vmStub == null) { return; } - // A change of the runner configuration is typically caused - // by a new version of the CR. So we observe the new CR. - var vmCr = vmCrApi.get(namespace, vmName).throwsApiException() - .getObject(); - if (vmCr.getMetadata().getGeneration() == observedGeneration) { - return; - } - vmCrApi.updateStatus(vmCr, from -> { - JsonObject status = currentStatus(from); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + if (!event.configuration().hasDisplayPassword) { + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1); + } status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond).filter(cond -> "Running" + .map(cond -> (JsonObject) cond) + .filter(cond -> Condition.RUNNING .equals(cond.get("type").getAsString())) .forEach(cond -> cond.addProperty("observedGeneration", from.getMetadata().getGeneration())); + updateUserLoggedIn(from); return status; }); } @@ -213,76 +184,146 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler + @SuppressWarnings({ "PMD.AssignmentInOperand" }) public void onRunnerStateChanged(RunnerStateChange event) throws ApiException { - if (vmCrApi == null) { + VmDefinition vmDef; + if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } - var vmCr = vmCrApi.get(namespace, vmName).throwsApiException() - .getObject(); - vmCrApi.updateStatus(vmCr, from -> { - JsonObject status = currentStatus(from); - status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond) - .forEach(cond -> { - if ("Running".equals(cond.get("type").getAsString())) { - updateRunningCondition(event, from, cond); - } - }); - if (event.state() == State.STARTING) { - status.addProperty("ram", GsonPtr.to(from.getRaw()) + vmStub.updateStatus(from -> { + boolean running = event.runState().vmRunning(); + updateCondition(vmDef, Condition.RUNNING, running, event.reason(), + event.message()); + JsonObject status = updateCondition(vmDef, Condition.BOOTED, + event.runState() == RunState.BOOTED, event.reason(), + event.message()); + if (event.runState() == RunState.STARTING) { + status.addProperty(Status.RAM, GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); - status.addProperty("cpus", 1); - } else if (event.state() == State.STOPPED) { - status.addProperty("ram", "0"); - status.addProperty("cpus", 0); + status.addProperty(Status.CPUS, 1); + } else if (event.runState() == RunState.STOPPED) { + status.addProperty(Status.RAM, "0"); + status.addProperty(Status.CPUS, 0); + status.remove(Status.LOGGED_IN_USER); + } + + if (!running) { + // In case console connection was still present + status.addProperty(Status.CONSOLE_CLIENT, ""); + updateCondition(from, Condition.CONSOLE_CONNECTED, false, + "VmStopped", + "The VM is not running"); + + // In case we had an irregular shutdown + updateCondition(from, Condition.USER_LOGGED_IN, false, + "VmStopped", "The VM is not running"); + status.remove(Status.OSINFO); + updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped", + "The VM is not running"); } return status; - }).throwsApiException(); + }, vmDef); + + // Maybe stop VM + if (event.runState() == RunState.TERMINATING && !event.failed() + && guestShutdownStops && shutdownByGuest) { + logger.info(() -> "Stopping VM because of shutdown by guest."); + var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" + + "\", \"value\": \"Stopped\"}]"), + apiClient.defaultPatchOptions()); + if (!res.isPresent()) { + logger.warning( + () -> "Cannot patch pod annotations for: " + vmStub.name()); + } + } + + // Log event + var evt = new EventsV1Event() + .reportingController(Crd.GROUP + "/" + APP_NAME) + .action("StatusUpdate").reason(event.reason()) + .note(event.message()); + K8s.createEvent(apiClient, vmDef, evt); } - private void updateRunningCondition(RunnerStateChange event, - DynamicKubernetesObject from, JsonObject cond) { - boolean reportedRunning - = "True".equals(cond.get("status").getAsString()); - if (RUNNING_STATES.contains(event.state()) - && !reportedRunning) { - cond.addProperty("status", "True"); - cond.addProperty("lastTransitionTime", - Instant.now().toString()); + private void updateUserLoggedIn(VmDefinition from) { + if (loggedInUser == null) { + updateCondition(from, Condition.USER_LOGGED_IN, false, + Reason.NOT_REQUESTED, "No user to be logged in"); + return; } - if (!RUNNING_STATES.contains(event.state()) - && reportedRunning) { - cond.addProperty("status", "False"); - cond.addProperty("lastTransitionTime", - Instant.now().toString()); + if (!from.conditionStatus(Condition.VMOP_AGENT).orElse(false)) { + updateCondition(from, Condition.USER_LOGGED_IN, false, + "VmopAgentDisconnected", "Waiting for VMOP agent to connect"); + return; } - cond.addProperty("reason", event.reason()); - cond.addProperty("message", event.message()); - cond.addProperty("observedGeneration", - from.getMetadata().getGeneration()); + if (!from.fromStatus(Status.LOGGED_IN_USER).map(loggedInUser::equals) + .orElse(false)) { + updateCondition(from, Condition.USER_LOGGED_IN, false, + "Processing", "Waiting for user to be logged in"); + } + updateCondition(from, Condition.USER_LOGGED_IN, true, + Reason.LOGGED_IN, "User is logged in"); } /** - * On ballon change. + * Update the current RAM size in the status. Balloon changes happen + * more than once every second during changes. While this is nice + * to watch, this puts a heavy load on the system. Therefore we + * only update the status once every 15 seconds or when the target + * value is reached. * * @param event the event * @throws ApiException */ @Handler public void onBallonChange(BalloonChangeEvent event) throws ApiException { - if (vmCrApi == null) { + if (vmStub == null) { return; } - var vmCr = vmCrApi.get(namespace, vmName).throwsApiException() - .getObject(); - vmCrApi.updateStatus(vmCr, from -> { - JsonObject status = currentStatus(from); - status.addProperty("ram", - new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) + Instant now = Instant.now(); + if (lastRamChange == null + || lastRamChange.isBefore(now.minusSeconds(15)) + || event.size().equals(targetRamValue)) { + if (balloonTimer != null) { + balloonTimer.cancel(); + balloonTimer = null; + } + lastRamChange = now; + lastRamValue = event.size(); + updateRam(); + return; + } + + // Save for later processing and maybe start timer + lastRamChange = now; + lastRamValue = event.size(); + if (balloonTimer != null) { + return; + } + final var pipeline = activeEventPipeline(); + balloonTimer = Components.schedule(t -> { + pipeline.submit("Update RAM size", () -> { + try { + updateRam(); + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Failed to update ram size: " + e.getMessage()); + } + balloonTimer = null; + }); + }, now.plusSeconds(15)); + } + + private void updateRam() throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.RAM, + new Quantity(new BigDecimal(lastRamValue), Format.BINARY_SI) .toSuffixedString()); return status; - }).throwsApiException(); + }); } /** @@ -293,15 +334,116 @@ public class StatusUpdater extends Component { */ @Handler public void onCpuChange(HotpluggableCpuStatus event) throws ApiException { - if (vmCrApi == null) { + if (vmStub == null) { return; } - var vmCr = vmCrApi.get(namespace, vmName).throwsApiException() - .getObject(); - vmCrApi.updateStatus(vmCr, from -> { - JsonObject status = currentStatus(from); - status.addProperty("cpus", event.usedCpus().size()); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.CPUS, event.usedCpus().size()); return status; - }).throwsApiException(); + }); + } + + /** + * On ballon change. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onDisplayPasswordChanged(DisplayPasswordChanged event) + throws ApiException { + if (vmStub == null) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, + status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1); + return status; + }); + } + + /** + * On shutdown. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onShutdown(ShutdownEvent event) throws ApiException { + shutdownByGuest = event.byGuest(); + } + + /** + * On osinfo. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onOsinfo(OsinfoEvent event) throws ApiException { + if (vmStub == null) { + return; + } + var asGson = gson.toJsonTree( + objectMapper.convertValue(event.osinfo(), Object.class)); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.add(Status.OSINFO, asGson); + return status; + }); + + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings("PMD.AssignmentInOperand") + public void onVmopAgentConnected(VmopAgentConnected event) + throws ApiException { + VmDefinition vmDef; + if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { + return; + } + vmStub.updateStatus(from -> { + var status = updateCondition(vmDef, "VmopAgentConnected", + true, "VmopAgentStarted", "The VM operator agent is running"); + updateUserLoggedIn(from); + return status; + }, vmDef); + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) + throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.LOGGED_IN_USER, + event.triggering().user()); + updateUserLoggedIn(from); + return status; + }); + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + public void onVmopAgentLoggedOut(VmopAgentLoggedOut event) + throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.LOGGED_IN_USER); + updateUserLoggedIn(from); + return status; + }); } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java new file mode 100644 index 0000000..406a0bc --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java @@ -0,0 +1,167 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sGenericStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.InitialConfiguration; + +/** + * Updates the CR status. + */ +public class VmDefUpdater extends Component { + + protected String namespace; + protected String vmName; + protected K8sClient apiClient; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + * @throws IOException + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public VmDefUpdater(Channel componentChannel) { + super(componentChannel); + if (apiClient == null) { + try { + apiClient = new K8sClient(); + io.kubernetes.client.openapi.Configuration + .setDefaultApiClient(apiClient); + } catch (IOException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access events API, terminating."); + fire(new Exit(1)); + } + } + } + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + @SuppressWarnings("unchecked") + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured("/Runner").ifPresent(c -> { + if (event instanceof InitialConfiguration) { + namespace = (String) c.get("namespace"); + updateNamespace(); + vmName = Optional.ofNullable((Map) c.get("vm")) + .map(vm -> vm.get("name")).orElse(null); + } + }); + } + + private void updateNamespace() { + if (namespace == null) { + var path = Path + .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); + if (Files.isReadable(path)) { + try { + namespace = Files.lines(path).findFirst().orElse(null); + } catch (IOException e) { + logger.log(Level.WARNING, e, + () -> "Cannot read namespace."); + } + } + } + if (namespace == null) { + logger.warning(() -> "Namespace is unknown, some functions" + + " won't be available."); + } + } + + /** + * Update condition. The `from` VM definition is used to determine the + * observed generation and the current status. This method is intended + * to be called in the function passed to + * {@link K8sGenericStub#updateStatus}. + * + * @param from the VM definition + * @param type the condition type + * @param state the new state + * @param reason the reason for the change + * @param message the message + * @return the updated status + */ + protected JsonObject updateCondition(VmDefinition from, String type, + boolean state, String reason, String message) { + JsonObject status = from.statusJson(); + // Avoid redundant updates, as this may be called several times + var current = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .filter(cond -> type.equals(cond.get("type").getAsString())) + .findFirst(); + var stateUnchanged = current.map(c -> c.get("status").getAsString()) + .map("True"::equals).map(s -> s == state).orElse(false); + if (stateUnchanged + && current.map(c -> c.get("reason").getAsString()) + .map(reason::equals).orElse(false) + && current.map(c -> c.get("observedGeneration").getAsLong()) + .map(from.getMetadata().getGeneration()::equals) + .orElse(false)) { + return status; + } + + // Do update + final var condition = new HashMap<>(Map.of("type", type, + "status", state ? "True" : "False", + "observedGeneration", from.getMetadata().getGeneration(), + "reason", reason, + "lastTransitionTime", stateUnchanged + ? current.get().get("lastTransitionTime").getAsString() + : Instant.now().toString())); + if (message != null) { + condition.put("message", message); + } + List toReplace = new ArrayList<>(List.of(condition)); + List newConds + = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .map(cond -> type.equals(cond.get("type").getAsString()) + ? toReplace.remove(0) + : cond) + .collect(Collectors.toCollection(() -> new ArrayList<>())); + newConds.addAll(toReplace); + status.add("conditions", + apiClient.getJSON().getGson().toJsonTree(newConds)); + return status; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java new file mode 100644 index 0000000..a940d73 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java @@ -0,0 +1,142 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.util.Deque; +import java.util.concurrent.ConcurrentLinkedDeque; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; + +/** + * A component that handles the communication over the vmop agent + * socket. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public class VmopAgentClient extends AgentConnector { + + private final Deque> executing = new ConcurrentLinkedDeque<>(); + + /** + * Instantiates a new VM operator agent client. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public VmopAgentClient(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * On vmop agent login. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmopAgentLogIn(VmopAgentLogIn event) throws IOException { + if (writer().isPresent()) { + logger.fine(() -> "Vmop agent handles:" + event); + executing.add(event); + logger.finer(() -> "vmop agent(out): login " + event.user()); + sendCommand("login " + event.user()); + } else { + logger + .warning(() -> "No vmop agent connection for sending " + event); + } + } + + /** + * On vmop agent logout. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmopAgentLogout(VmopAgentLogOut event) throws IOException { + if (writer().isPresent()) { + logger.fine(() -> "Vmop agent handles:" + event); + executing.add(event); + logger.finer(() -> "vmop agent(out): logout"); + sendCommand("logout"); + } + } + + @Override + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) + protected void processInput(String line) throws IOException { + logger.finer(() -> "vmop agent(in): " + line); + + // Check validity + if (line.isEmpty() || !Character.isDigit(line.charAt(0))) { + logger.warning(() -> "Illegal vmop agent response: " + line); + return; + } + + // Check positive responses + if (line.startsWith("220 ")) { + var evt = new VmopAgentConnected(); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); + return; + } + if (line.startsWith("201 ")) { + Event cmd = executing.pop(); + if (cmd instanceof VmopAgentLogIn login) { + var evt = new VmopAgentLoggedIn(login); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); + } else { + logger.severe(() -> "Response " + line + + " does not match executing command " + cmd); + } + return; + } + if (line.startsWith("202 ")) { + Event cmd = executing.pop(); + if (cmd instanceof VmopAgentLogOut logout) { + var evt = new VmopAgentLoggedOut(logout); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); + } else { + logger.severe(() -> "Response " + line + + "does not match executing command " + cmd); + } + return; + } + + // Ignore unhandled continuations + if (line.charAt(0) == '1') { + return; + } + + // Error + logger.warning(() -> "Error response from vmop agent: " + line); + executing.pop(); + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java index e77a984..86d92f6 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java @@ -47,7 +47,7 @@ public class QmpAddCpu extends QmpCommand { cmd.put("execute", "device_add"); ObjectNode args = mapper.createObjectNode(); cmd.set("arguments", args); - args.setAll((ObjectNode) (unused.get("props"))); + args.setAll((ObjectNode) unused.get("props")); args.set("driver", unused.get("type")); args.put("id", cpuId); return cmd; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java index ffd6ca6..918b7d5 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpCapabilities extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"qmp_capabilities\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java index 158a318..b60b619 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpChangeMedium extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-change-medium\",\"arguments\": {" + "\"id\": \"\",\"filename\": \"\",\"format\": \"raw\"," diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java index 8a03ab0..0db58e2 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.runner.qemu.commands; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; @@ -29,8 +30,7 @@ import java.util.logging.Logger; */ public abstract class QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) protected static final ObjectMapper mapper = new ObjectMapper(); /** @@ -55,4 +55,30 @@ public abstract class QmpCommand { * @return the json node */ public abstract JsonNode toJson(); + + /** + * Returns the string representation. + * + * @return the string + * @throws JsonProcessingException the JSON processing exception + */ + public String asText() throws JsonProcessingException { + return mapper.writeValueAsString(toJson()); + } + + /** + * Calls {@link #asText()} but suppresses the + * {@link JsonProcessingException}. + * + * @return the string + */ + @Override + public String toString() { + try { + return asText(); + } catch (JsonProcessingException e) { + return "(no string representation)"; + } + } + } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java index 7b1abbd..0e06e34 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpCont extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"cont\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java index 46fba32..a97e6c6 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpDelCpu extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"device_del\", " + "\"arguments\": " + "{ \"id\": 0 } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java new file mode 100644 index 0000000..cf4ba72 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that pings the guest agent. + */ +public class QmpGuestGetOsinfo extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-get-osinfo"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestGetOsinfo()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java new file mode 100644 index 0000000..75fdf73 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that requests the guest info. + */ +public class QmpGuestInfo extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-info"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestInfo()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java new file mode 100644 index 0000000..257c838 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that pings the guest agent. + */ +public class QmpGuestPing extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-ping"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestPing()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java new file mode 100644 index 0000000..04110a5 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that powers down the guest. + */ +public class QmpGuestPowerdown extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-shutdown"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestPowerdown()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java index 2f9ad55..88a392c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpOpenTray extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-open-tray\",\"arguments\": {" + "\"id\": \"\" } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java index 108a355..dfb7d96 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpPowerdown extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"system_powerdown\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java index 6f87d10..d4fb5cc 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpQueryHotpluggableCpus extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson( "{\"execute\":\"query-hotpluggable-cpus\",\"arguments\":{}}"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java index cc74555..71360cf 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpRemoveMedium extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-remove-medium\",\"arguments\": {" + "\"id\": \"\" } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java new file mode 100644 index 0000000..5364811 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java @@ -0,0 +1,42 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * A {@link QmpCommand} that send a system_reset to the VM. + */ +public class QmpReset extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"system_reset\" }"); + + @Override + public JsonNode toJson() { + return jsonTemplate.deepCopy(); + } + + @Override + public String toString() { + return "QmpReset()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java index c7f6bed..f9d4c5d 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java @@ -28,8 +28,7 @@ import java.math.BigInteger; */ public class QmpSetBalloon extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"balloon\", " + "\"arguments\": " + "{ \"value\": 0 } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java new file mode 100644 index 0000000..0048b9a --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java @@ -0,0 +1,68 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; + +/** + * A {@link QmpCommand} that sets the display password. + */ +public class QmpSetDisplayPassword extends QmpCommand { + + private final String password; + private final String protocol; + + /** + * Instantiates a new command. + * + * @param protocol the protocol + * @param password the password + */ + public QmpSetDisplayPassword(String protocol, String password) { + this.protocol = protocol; + this.password = password; + } + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "set_password"); + ObjectNode args = mapper.createObjectNode(); + cmd.set("arguments", args); + args.set("protocol", new TextNode(protocol)); + args.set("password", new TextNode(password)); + return cmd; + } + + @Override + public String toString() { + try { + var json = toJson(); + ((ObjectNode) json.get("arguments")).set("password", + new TextNode("********")); + return mapper.writeValueAsString(json); + } catch (JsonProcessingException e) { + return "(no string representation)"; + } + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetPasswordExpiry.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetPasswordExpiry.java new file mode 100644 index 0000000..672767d --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetPasswordExpiry.java @@ -0,0 +1,66 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; + +/** + * A {@link QmpCommand} that sets the password expiry. + */ +public class QmpSetPasswordExpiry extends QmpCommand { + + private final String protocol; + private final String expiry; + + /** + * Instantiates a new command. + * + * @param protocol the protocol + * @param expiry the expiry time + */ + public QmpSetPasswordExpiry(String protocol, String expiry) { + this.protocol = protocol; + this.expiry = expiry; + } + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "expire_password"); + ObjectNode args = mapper.createObjectNode(); + cmd.set("arguments", args); + args.set("protocol", new TextNode(protocol)); + args.set("time", new TextNode(expiry)); + return cmd; + } + + @Override + public String toString() { + try { + var json = toJson(); + return mapper.writeValueAsString(json); + } catch (JsonProcessingException e) { + return "(no string representation)"; + } + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java similarity index 90% rename from org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java rename to org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java index bdb0c73..7afa738 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.runner.qemu.events; import org.jdrupes.vmoperator.runner.qemu.Configuration; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; @@ -31,17 +31,17 @@ import org.jgrapes.core.Event; * on the event and only {@link Event#resumeHandling() resume handling} * when the adaption has completed. */ -public class RunnerConfigurationUpdate extends Event { +public class ConfigureQemu extends Event { private final Configuration configuration; - private final State state; + private final RunState state; /** * Instantiates a new configuration event. * * @param channels the channels */ - public RunnerConfigurationUpdate(Configuration configuration, State state, + public ConfigureQemu(Configuration configuration, RunState state, Channel... channels) { super(channels); this.state = state; @@ -62,7 +62,7 @@ public class RunnerConfigurationUpdate extends Event { * * @return the state */ - public State state() { + public RunState runState() { return state; } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/DisplayPasswordChanged.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/DisplayPasswordChanged.java new file mode 100644 index 0000000..0814f50 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/DisplayPasswordChanged.java @@ -0,0 +1,39 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; + +/** + * A {@link MonitorResult} that indicates that the display password has changed. + */ +public class DisplayPasswordChanged extends MonitorResult { + + /** + * Instantiates a new display password changed. + * + * @param command the command + * @param response the response + */ + public DisplayPasswordChanged(QmpCommand command, JsonNode response) { + super(command, response); + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/Exit.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/Exit.java new file mode 100644 index 0000000..bb608f6 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/Exit.java @@ -0,0 +1,43 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.events.Stop; + +/** + * Like {@link Stop}, but sets an exit status. + */ +@SuppressWarnings("PMD.ShortClassName") +public class Exit extends Stop { + + private final int exitStatus; + + /** + * Instantiates a new exit. + * + * @param exitStatus the exit status + */ + public Exit(int exitStatus) { + this.exitStatus = exitStatus; + } + + public int exitStatus() { + return exitStatus; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java new file mode 100644 index 0000000..a1b585d --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java @@ -0,0 +1,63 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * An {@link Event} that causes some component to send a QMP + * command to the guest agent process. + */ +public class GuestAgentCommand extends Event { + + private final QmpCommand command; + + /** + * Instantiates a new exec qmp command. + * + * @param command the command + */ + public GuestAgentCommand(QmpCommand command) { + this.command = command; + } + + /** + * Gets the command. + * + * @return the command + */ + public QmpCommand command() { + return command; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)) + .append(" [").append(command); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java index 68641c9..2ab2c5a 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java @@ -30,7 +30,9 @@ import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; */ public class HotpluggableCpuStatus extends MonitorResult { + @SuppressWarnings("PMD.ImmutableField") private List usedCpus = new ArrayList<>(); + @SuppressWarnings("PMD.ImmutableField") private List unusedCpus = new ArrayList<>(); /** diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java index 36d5b40..d2b5e8c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java @@ -55,8 +55,7 @@ public class MonitorCommand extends Event { builder.append(Components.objectName(this)) .append(" [").append(command); if (channels() != null) { - builder.append(", channels="); - builder.append(Channel.toString(channels())); + builder.append(", channels=").append(Channel.toString(channels())); } builder.append(']'); return builder.toString(); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java index 72647a1..93e7785 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java @@ -20,6 +20,8 @@ package org.jdrupes.vmoperator.runner.qemu.events; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; import org.jgrapes.core.Event; /** @@ -28,11 +30,14 @@ import org.jgrapes.core.Event; */ public class MonitorEvent extends Event { + private static final String EVENT_DATA = "data"; + /** * The kind of monitor event. */ public enum Kind { - READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE + READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, + SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED, VSERPORT_CHANGE } private final Kind kind; @@ -44,23 +49,36 @@ public class MonitorEvent extends Event { * @param response the response * @return the optional */ - @SuppressWarnings("PMD.TooFewBranchesForASwitchStatement") public static Optional from(JsonNode response) { try { - var kind - = MonitorEvent.Kind.valueOf(response.get("event").asText()); + var kind = Kind.valueOf(response.get("event").asText()); switch (kind) { case POWERDOWN: return Optional.of(new PowerdownEvent(kind, null)); case DEVICE_TRAY_MOVED: return Optional - .of(new TrayMovedEvent(kind, response.get("data"))); + .of(new TrayMovedEvent(kind, response.get(EVENT_DATA))); case BALLOON_CHANGE: + return Optional.of( + new BalloonChangeEvent(kind, response.get(EVENT_DATA))); + case SHUTDOWN: return Optional - .of(new BalloonChangeEvent(kind, response.get("data"))); + .of(new ShutdownEvent(kind, response.get(EVENT_DATA))); + case SPICE_CONNECTED: + return Optional.of(new SpiceConnectedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_INITIALIZED: + return Optional.of(new SpiceInitializedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_DISCONNECTED: + return Optional.of(new SpiceDisconnectedEvent(kind, + response.get(EVENT_DATA))); + case VSERPORT_CHANGE: + return Optional.of(new VserportChangeEvent(kind, + response.get(EVENT_DATA))); default: return Optional - .of(new MonitorEvent(kind, response.get("data"))); + .of(new MonitorEvent(kind, response.get(EVENT_DATA))); } } catch (IllegalArgumentException e) { return Optional.empty(); @@ -95,4 +113,20 @@ public class MonitorEvent extends Event { public JsonNode data() { return data; } + + /* + * (non-Javadoc) + * + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [").append(data); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java index c0f55fe..6d7278c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java @@ -25,6 +25,7 @@ import org.jdrupes.vmoperator.runner.qemu.commands.QmpCapabilities; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpDelCpu; import org.jdrupes.vmoperator.runner.qemu.commands.QmpQueryHotpluggableCpus; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -57,6 +58,9 @@ public class MonitorResult extends Event { if (command instanceof QmpDelCpu) { return new CpuDeleted(command, response); } + if (command instanceof QmpSetDisplayPassword) { + return new DisplayPasswordChanged(command, response); + } return new MonitorResult(command, response); } @@ -148,8 +152,7 @@ public class MonitorResult extends Event { builder.append(Components.objectName(this)) .append(" [").append(executed).append(", ").append(successful()); if (channels() != null) { - builder.append(", channels="); - builder.append(Channel.toString(channels())); + builder.append(", channels=").append(Channel.toString(channels())); } builder.append(']'); return builder.toString(); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java new file mode 100644 index 0000000..0e90019 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java @@ -0,0 +1,62 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Signals information about the guest OS. + */ +public class OsinfoEvent extends Event { + + private final JsonNode osinfo; + + /** + * Instantiates a new osinfo event. + * + * @param data the data + */ + public OsinfoEvent(JsonNode data) { + osinfo = data; + } + + public JsonNode osinfo() { + return osinfo; + } + + /* + * (non-Javadoc) + * + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(osinfo); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java index 46fa1f8..261eebf 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.runner.qemu.events; +import java.util.EnumSet; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -28,26 +29,63 @@ import org.jgrapes.core.Event; public class RunnerStateChange extends Event { /** - * The state. + * The states. */ - public enum State { - INITIALIZING, STARTING, RUNNING, TERMINATING, STOPPED + public enum RunState { + INITIALIZING, STARTING, BOOTING, BOOTED, TERMINATING, STOPPED; + + /** + * Checks if the state is one of the states in which the VM is running. + * + * @return true, if is running + */ + public boolean vmRunning() { + return EnumSet.of(BOOTING, BOOTED, TERMINATING).contains(this); + } + + /** + * Checks if the state is one of the states in which the VM is active. + * + * @return true, if is active + */ + public boolean vmActive() { + return EnumSet.of(BOOTING, BOOTED).contains(this); + } } - private final State state; + private final RunState state; private final String reason; private final String message; + private final boolean failed; /** * Instantiates a new runner state change. * + * @param state the state + * @param reason the reason + * @param message the message * @param channels the channels */ - public RunnerStateChange(State state, String reason, String message, + public RunnerStateChange(RunState state, String reason, String message, Channel... channels) { + this(state, reason, message, false, channels); + } + + /** + * Instantiates a new runner state change. + * + * @param state the state + * @param reason the reason + * @param message the message + * @param failed the failed + * @param channels the channels + */ + public RunnerStateChange(RunState state, String reason, String message, + boolean failed, Channel... channels) { super(channels); this.state = state; this.reason = reason; + this.failed = failed; this.message = message; } @@ -56,7 +94,7 @@ public class RunnerStateChange extends Event { * * @return the state */ - public State state() { + public RunState runState() { return state; } @@ -78,14 +116,25 @@ public class RunnerStateChange extends Event { return message; } + /** + * Checks if is failed. + * + * @return the failed + */ + public boolean failed() { + return failed; + } + @Override public String toString() { - StringBuilder builder = new StringBuilder(); + StringBuilder builder = new StringBuilder(50); builder.append(Components.objectName(this)) .append(" [").append(state).append(": ").append(reason); + if (failed) { + builder.append(" (failed)"); + } if (channels() != null) { - builder.append(", channels="); - builder.append(Channel.toString(channels())); + builder.append(", channels=").append(Channel.toString(channels())); } builder.append(']'); return builder.toString(); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ShutdownEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ShutdownEvent.java new file mode 100644 index 0000000..1804232 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ShutdownEvent.java @@ -0,0 +1,47 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals the reception of a SHUTDOWN event. + */ +public class ShutdownEvent extends MonitorEvent { + + /** + * Instantiates a new shutdown event. + * + * @param kind the kind + * @param data the data + */ + public ShutdownEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * returns if this is initiated by the guest. + * + * @return the value + */ + public boolean byGuest() { + return data().get("guest").asBoolean(); + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java new file mode 100644 index 0000000..c133307 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java @@ -0,0 +1,37 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceConnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceConnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java new file mode 100644 index 0000000..cfcb489 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java @@ -0,0 +1,37 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceDisconnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice disconnected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceDisconnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java new file mode 100644 index 0000000..4ce27e2 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java @@ -0,0 +1,55 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceEvent extends MonitorEvent { + + /** + * Instantiates a new tray moved. + * + * @param kind the kind + * @param data the data + */ + public SpiceEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the client's host. + * + * @return the client's host address + */ + public String clientHost() { + return data().get("client").get("host").asText(); + } + + /** + * Returns the client's port. + * + * @return the client's port number + */ + public long clientPort() { + return data().get("client").get("port").asLong(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java new file mode 100644 index 0000000..7bb84b7 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java @@ -0,0 +1,46 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceInitializedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceInitializedEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the channel type. + * + * @return the channel type + */ + public int channelType() { + return data().get("client").get("channel-type").asInt(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java index f5ef725..e2d2286 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java @@ -50,7 +50,7 @@ public class TrayMovedEvent extends MonitorEvent { * * @return the tray state */ - public TrayState state() { + public TrayState trayState() { return data().get("tray-open").asBoolean() ? TrayState.OPEN : TrayState.CLOSED; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java new file mode 100644 index 0000000..dc13569 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java @@ -0,0 +1,27 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals information about the guest OS. + */ +public class VmopAgentConnected extends Event { +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java new file mode 100644 index 0000000..96db884 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java @@ -0,0 +1,45 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Sends the login command to the VM operator agent. + */ +public class VmopAgentLogIn extends Event { + + private final String user; + + /** + * Instantiates a new vmop agent logout. + */ + public VmopAgentLogIn(String user) { + this.user = user; + } + + /** + * Returns the user. + * + * @return the user + */ + public String user() { + return user; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java new file mode 100644 index 0000000..1502200 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java @@ -0,0 +1,27 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Sends the logout command to the VM operator agent. + */ +public class VmopAgentLogOut extends Event { +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java new file mode 100644 index 0000000..f59ed71 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals that the logout command has been processes by the + * VM operator agent. + */ +public class VmopAgentLoggedIn extends Event { + + private final VmopAgentLogIn triggering; + + /** + * Instantiates a new vmop agent logged in. + * + * @param triggeringEvent the triggering event + */ + public VmopAgentLoggedIn(VmopAgentLogIn triggeringEvent) { + this.triggering = triggeringEvent; + } + + /** + * Gets the triggering event. + * + * @return the triggering + */ + public VmopAgentLogIn triggering() { + return triggering; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java new file mode 100644 index 0000000..5f60e00 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals that the logout command has been processes by the + * VM operator agent. + */ +public class VmopAgentLoggedOut extends Event { + + private final VmopAgentLogOut triggering; + + /** + * Instantiates a new vmop agent logged out. + * + * @param triggeringEvent the triggering event + */ + public VmopAgentLoggedOut(VmopAgentLogOut triggeringEvent) { + this.triggering = triggeringEvent; + } + + /** + * Gets the triggering event. + * + * @return the triggering + */ + public VmopAgentLogOut triggering() { + return triggering; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java new file mode 100644 index 0000000..b590cd3 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java @@ -0,0 +1,56 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a virtual serial port's open state change. + */ +public class VserportChangeEvent extends MonitorEvent { + + /** + * Initializes a new instance. + * + * @param kind the kind + * @param data the data + */ + public VserportChangeEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Return the channel's id. + * + * @return the string + */ + @SuppressWarnings("PMD.ShortMethodName") + public String id() { + return data().get("id").asText(); + } + + /** + * Returns the open state of the port. + * + * @return true, if is open + */ + public boolean isOpen() { + return Boolean.parseBoolean(data().get("open").asText()); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index b21db8f..c5c0252 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -11,6 +11,22 @@ - [ "--ctrl", "type=unixio,path=${ runtimeDir }/swtpm-sock,mode=0600" ] - "--terminate" +"cloudInitImg": + # Candidate paths for the executable + "executable": [ "/bin/sh", "/usr/bin/sh" ] + + # Arguments may be specified as nested lists for better readability. + # The arguments are flattened before being passed to the process. + "arguments": + - "-c" + - >- + mformat -C -f 1440 -v CIDATA -i ${ runtimeDir }/cloud-init.img + && mcopy -i ${ runtimeDir }/cloud-init.img + ${ dataDir }/cloud-init/meta-data ${ dataDir }/cloud-init/user-data :: + && if [ -r ${ dataDir }/cloud-init/network-config ]; then + mcopy -i ${ runtimeDir }/cloud-init.img + ${ dataDir }/cloud-init/network-config :: ; fi + "qemu": # Candidate paths for the executable "executable": [ "/usr/bin/qemu-system-x86_64" ] @@ -106,11 +122,16 @@ # Best explanation found: # https://fedoraproject.org/wiki/Features/VirtioSerial - [ "-device", "virtio-serial-pci,id=virtio-serial0" ] - # - Guest agent serial connection + # - Guest agent serial connection. - [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\ chardev=guest-agent-socket" ] - [ "-chardev","socket,id=guest-agent-socket,\ path=${ runtimeDir }/org.qemu.guest_agent.0,server=on,wait=off" ] + # - VM operator agent serial connection. + - [ "-device", "virtserialport,id=channel1,name=org.jdrupes.vmop_agent.0,\ + chardev=vmop-agent-socket" ] + - [ "-chardev","socket,id=vmop-agent-socket,\ + path=${ runtimeDir }/org.jdrupes.vmop_agent.0,server=on,wait=off" ] # * USB Hub and devices (more in SPICE configuration below) # https://qemu-project.gitlab.io/qemu/system/devices/usb.html # https://github.com/qemu/qemu/blob/master/hw/usb/hcd-xhci.c @@ -121,7 +142,8 @@ - [ "-device", "virtio-rng-pci,rng=objrng0,id=rng0" ] # * Graphics and Audio Card # This is the only video "card" without a flickering cursor. - - [ "-device", "virtio-vga,id=video0,max_outputs=1" ] + - [ "-device", "virtio-vga,id=video0,max_outputs=${ vm.display.outputs },\ + max_hostmem=${ (vm.display.outputs * 256 * 1024 * 1024)?c }" ] - [ "-device", "ich9-intel-hda,id=sound0" ] # Network <#assign nwCounter = 0/> @@ -183,18 +205,24 @@ <#break> + # Cloud-init image + <#if cloudInit??> + - [ "-blockdev", "node-name=drive-${ drvCounter }-host-resource,\ + driver=file,filename=${ runtimeDir }/cloud-init.img" ] + # - how to use the file (as sequence of literal blocks) + - [ "-blockdev", "node-name=drive-${ drvCounter }-backend,driver=raw,\ + file=drive-${ drvCounter }-host-resource" ] + # - the driver (what the guest sees) + - [ "-device", "virtio-blk-pci,drive=drive-${ drvCounter }-backend" ] + <#if vm.display??> <#if vm.display.spice??> <#assign spice = vm.display.spice/> # SPICE (display, channels ...) # https://www.linux-kvm.org/page/SPICE - <#if ticketPath??> - - [ "-object", "secret,id=spiceTicket,file=${ ticketPath }" ] - - [ "-spice", "port=${ spice.port?c }\ - <#if spice.ticket??>,password-secret=spiceTicket\ - <#else>,disable-ticketing=on\ + ,disable-ticketing=<#if hasDisplayPassword!false>off<#else>on\ <#if spice.streamingVideo??>,streaming-video=${ spice.streamingVideo }\ ,seamless-migration=on" ] - [ "-chardev", "spicevmc,id=vdagentdev,name=vdagent" ] diff --git a/org.jdrupes.vmoperator.util/.eclipse-pmd b/org.jdrupes.vmoperator.util/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.util/.eclipse-pmd +++ b/org.jdrupes.vmoperator.util/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java new file mode 100644 index 0000000..e83cf27 --- /dev/null +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java @@ -0,0 +1,176 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.util; + +import java.lang.reflect.Array; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * Utility class that supports navigation through arbitrary data structures. + */ +public final class DataPath { + + private static final Logger logger + = Logger.getLogger(DataPath.class.getName()); + + private DataPath() { + } + + /** + * Apply the given selectors on the given object and return the + * value reached. + * + * Selectors can be if type {@link String} or {@link Number}. The + * former are used to access a property of an object, the latter to + * access an element in an array or a {@link List}. + * + * Depending on the object currently visited, a {@link String} can + * be the key of a {@link Map}, the property part of a getter method + * or the name of a method that has an empty parameter list. + * + * @param the generic type + * @param from the from + * @param selectors the selectors + * @return the result + */ + public static Optional get(Object from, Object... selectors) { + Object cur = from; + for (var selector : selectors) { + if (cur == null) { + return Optional.empty(); + } + if (selector instanceof String && cur instanceof Map map) { + cur = map.get(selector); + continue; + } + if (selector instanceof Number index && cur instanceof List list) { + cur = list.get(index.intValue()); + continue; + } + if (selector instanceof String property) { + var retrieved = tryAccess(cur, property); + if (retrieved.isEmpty()) { + return Optional.empty(); + } + cur = retrieved.get(); + } + } + @SuppressWarnings("unchecked") + var result = Optional.ofNullable((T) cur); + return result; + } + + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + private static Optional tryAccess(Object obj, String property) { + Method acc = null; + try { + // Try getter + acc = obj.getClass().getMethod("get" + property.substring(0, 1) + .toUpperCase() + property.substring(1)); + } catch (SecurityException e) { + return Optional.empty(); + } catch (NoSuchMethodException e) { // NOPMD + // Can happen... + } + if (acc == null) { + try { + // Try method + acc = obj.getClass().getMethod(property); + } catch (SecurityException | NoSuchMethodException e) { + return Optional.empty(); + } + } + if (acc != null) { + try { + return Optional.ofNullable(acc.invoke(obj)); + } catch (IllegalAccessException + | InvocationTargetException e) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + /** + * Attempts to make a as-deep-as-possible copy of the given + * container. New containers will be created for Maps, Lists and + * Arrays. The method is invoked recursively for the entries/items. + * + * If invoked with an object that is neither a map, list or array, + * the methods checks if the object implements {@link Cloneable} + * and if it does, invokes its {@link Object#clone()} method. + * Else the method return the object. + * + * @param the generic type + * @param object the container + * @return the t + */ + @SuppressWarnings({ "PMD.CognitiveComplexity", "unchecked" }) + public static T deepCopy(T object) { + if (object instanceof Map map) { + Map copy; + try { + copy = (Map) object.getClass().getConstructor() + .newInstance(); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + logger.severe( + () -> "Cannot create new instance of " + object.getClass()); + return null; + } + for (var entry : ((Map) map).entrySet()) { + copy.put(entry.getKey(), + deepCopy(entry.getValue())); + } + return (T) copy; + } + if (object instanceof List list) { + List copy = new ArrayList<>(); + for (var item : list) { + copy.add(deepCopy(item)); + } + return (T) copy; + } + if (object.getClass().isArray()) { + var copy = Array.newInstance(object.getClass().getComponentType(), + Array.getLength(object)); + for (int i = 0; i < Array.getLength(object); i++) { + Array.set(copy, i, deepCopy(Array.get(object, i))); + } + return (T) copy; + } + if (object instanceof Cloneable) { + try { + return (T) object.getClass().getMethod("clone") + .invoke(object); + } catch (IllegalAccessException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return object; + } + } + return object; + } +} diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java index e3d9fcd..c6fb101 100644 --- a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java @@ -23,6 +23,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import java.math.BigInteger; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -31,8 +32,7 @@ import java.util.function.Supplier; /** * Utility class for pointing to elements on a Gson (Json) tree. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal", "PMD.GodClass" }) +@SuppressWarnings({ "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal" }) public class GsonPtr { private final JsonElement position; @@ -62,7 +62,8 @@ public class GsonPtr { * @param selectors the selectors * @return the Gson pointer */ - @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) + @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace", + "PMD.AvoidDuplicateLiterals" }) public GsonPtr to(Object... selectors) { JsonElement element = position; for (Object sel : selectors) { @@ -91,6 +92,42 @@ public class GsonPtr { return new GsonPtr(element); } + /** + * Create a new instance pointing to the {@link JsonElement} + * selected by the given selectors. If a selector of type + * {@link String} denotes a non-existant member of a + * {@link JsonObject} the result is empty. + * + * @param selectors the selectors + * @return the Gson pointer + */ + @SuppressWarnings({ "PMD.PreserveStackTrace" }) + public Optional get(Object... selectors) { + JsonElement element = position; + for (Object sel : selectors) { + if (element instanceof JsonObject obj + && sel instanceof String member) { + element = obj.get(member); + if (element == null) { + return Optional.empty(); + } + continue; + } + if (element instanceof JsonArray arr + && sel instanceof Integer index) { + try { + element = arr.get(index); + } catch (IndexOutOfBoundsException e) { + throw new IllegalStateException("Selected array index" + + " may not be empty."); + } + continue; + } + throw new IllegalStateException("Invalid selection"); + } + return Optional.of(new GsonPtr(element)); + } + /** * Returns {@link JsonElement} that the pointer points to. * @@ -108,8 +145,7 @@ public class GsonPtr { * @param cls the cls * @return the result */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) - public T get(Class cls) { + public T getAs(Class cls) { if (cls.isAssignableFrom(position.getClass())) { return cls.cast(position); } @@ -128,7 +164,7 @@ public class GsonPtr { */ @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) public Optional - get(Class cls, Object... selectors) { + getAs(Class cls, Object... selectors) { JsonElement element = position; for (Object sel : selectors) { if (element instanceof JsonObject obj @@ -163,7 +199,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsString(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsString); } @@ -174,7 +210,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsInt(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsInt); } @@ -185,7 +221,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsBigInteger(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsBigInteger); } @@ -196,7 +232,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsLong(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsLong); } @@ -207,7 +243,7 @@ public class GsonPtr { * @return the boolean */ public Optional getAsBoolean(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsBoolean); } @@ -222,7 +258,7 @@ public class GsonPtr { @SuppressWarnings("unchecked") public List getAsListOf(Class cls, Object... selectors) { - return get(JsonArray.class, selectors).map(a -> (List) a.asList()) + return getAs(JsonArray.class, selectors).map(a -> (List) a.asList()) .orElse(Collections.emptyList()); } @@ -265,6 +301,18 @@ public class GsonPtr { return set(selector, new JsonPrimitive(value)); } + /** + * Short for `set(selector, new JsonPrimitive(value))`. + * + * @param selector the selector + * @param value the value + * @return the gson ptr + * @see #set(Object, JsonElement) + */ + public GsonPtr set(Object selector, Long value) { + return set(selector, new JsonPrimitive(value)); + } + /** * Short for `set(selector, new JsonPrimitive(value))`. * @@ -277,6 +325,18 @@ public class GsonPtr { return set(selector, new JsonPrimitive(value)); } + /** + * Short for `set(selector, new JsonPrimitive(value))`. + * + * @param selector the selector + * @param value the value + * @return the gson ptr + * @see #set(Object, JsonElement) + */ + public GsonPtr set(Object selector, Boolean value) { + return set(selector, new JsonPrimitive(value)); + } + /** * Same as {@link #set(Object, JsonElement)}, but sets the value * only if it doesn't exist yet, else returns the existing value. @@ -324,4 +384,22 @@ public class GsonPtr { return this; } + /** + * Removes all properties except the specified ones. + * + * @param properties the properties + */ + public void removeExcept(String... properties) { + if (!position.isJsonObject()) { + return; + } + for (var itr = ((JsonObject) position).entrySet().iterator(); + itr.hasNext();) { + var entry = itr.next(); + if (Arrays.asList(properties).contains(entry.getKey())) { + continue; + } + itr.remove(); + } + } } diff --git a/org.jdrupes.vmoperator.util/test/org/jdrupes/vmoperator/util/DataPathTests.java b/org.jdrupes.vmoperator.util/test/org/jdrupes/vmoperator/util/DataPathTests.java new file mode 100644 index 0000000..9c7855f --- /dev/null +++ b/org.jdrupes.vmoperator.util/test/org/jdrupes/vmoperator/util/DataPathTests.java @@ -0,0 +1,17 @@ +package org.jdrupes.vmoperator.util; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class DataPathTests { + + @Test + void testArray() { + int[] orig + = { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3) }; + var copy = DataPath.deepCopy(orig); + for (int i = 0; i < orig.length; i++) { + assertEquals(orig[i], copy[i]); + } + } +} diff --git a/org.jdrupes.vmoperator.vmconlet/.checkstyle b/org.jdrupes.vmoperator.vmaccess/.checkstyle similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.checkstyle rename to org.jdrupes.vmoperator.vmaccess/.checkstyle diff --git a/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd b/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd new file mode 100644 index 0000000..60d7780 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmconlet/.eslintignore b/org.jdrupes.vmoperator.vmaccess/.eslintignore similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.eslintignore rename to org.jdrupes.vmoperator.vmaccess/.eslintignore diff --git a/org.jdrupes.vmoperator.vmconlet/.eslintrc.json b/org.jdrupes.vmoperator.vmaccess/.eslintrc.json similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.eslintrc.json rename to org.jdrupes.vmoperator.vmaccess/.eslintrc.json diff --git a/org.jdrupes.vmoperator.vmconlet/.gitignore b/org.jdrupes.vmoperator.vmaccess/.gitignore similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.gitignore rename to org.jdrupes.vmoperator.vmaccess/.gitignore diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.buildship.core.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.buildship.core.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.buildship.core.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.resources.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.resources.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.resources.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.runtime.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.jdt.ui.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.jdt.ui.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.jdt.ui.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.jdt.ui.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/build.gradle b/org.jdrupes.vmoperator.vmaccess/build.gradle similarity index 95% rename from org.jdrupes.vmoperator.vmconlet/build.gradle rename to org.jdrupes.vmoperator.vmaccess/build.gradle index ab667f5..606c6cd 100644 --- a/org.jdrupes.vmoperator.vmconlet/build.gradle +++ b/org.jdrupes.vmoperator.vmaccess/build.gradle @@ -5,7 +5,7 @@ plugins { dependencies { implementation project(':org.jdrupes.vmoperator.manager.events') - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.3.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' diff --git a/org.jdrupes.vmoperator.vmconlet/package.json b/org.jdrupes.vmoperator.vmaccess/package.json similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/package.json rename to org.jdrupes.vmoperator.vmaccess/package.json diff --git a/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory new file mode 100644 index 0000000..ec5cf30 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory @@ -0,0 +1 @@ +org.jdrupes.vmoperator.vmaccess.VmAccessFactory diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html new file mode 100644 index 0000000..d7b9405 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html @@ -0,0 +1,13 @@ +
+

${_("confirmResetMsg")}

+

+ + + + + + +

+
\ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html new file mode 100644 index 0000000..a34f725 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-edit.ftl.html @@ -0,0 +1,39 @@ +
+
+
+
+ {{ localize("Select VM or pool") }} +
    +
  • + +
  • +
  • + +
  • +
+
+
+
+
diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-l10nBundles.ftl.js b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-l10nBundles.ftl.js new file mode 100644 index 0000000..96928ef --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-l10nBundles.ftl.js @@ -0,0 +1,31 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +"use strict"; + +const l10nBundles = new Map(); +let entries = null; +// <#list supportedLanguages() as l> +entries = new Map(); +l10nBundles.set("${l.locale.toLanguageTag()}", entries); +// <#list l.l10nBundle.keys as key> +entries.set("${key}", "${l.l10nBundle.getString(key)}"); +// +// + +export default l10nBundles; diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html new file mode 100644 index 0000000..57693ea --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html @@ -0,0 +1,7 @@ +
+
diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-in-use.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-in-use.svg new file mode 100644 index 0000000..00e4cc0 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-in-use.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-off.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-off.svg new file mode 100644 index 0000000..27c11ae --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-off.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer.svg new file mode 100644 index 0000000..f7a6b94 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties new file mode 100644 index 0000000..6ec24aa --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n.properties @@ -0,0 +1,9 @@ +conletName = VM Access + +okayLabel = Apply and Close + +confirmResetTitle = Confirm reset +confirmResetMsg = Resetting the VM may cause loss of data. \ + Please confirm to continue. +consoleInaccessibleNotification = Console is not ready or in use. +poolEmptyNotification = No VM available. Please consult your administrator. diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties new file mode 100644 index 0000000..28c01f0 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_de.properties @@ -0,0 +1,17 @@ +conletName = VM-Zugriff + +okayLabel = Anwenden und Schließen +Select\ VM\ or\ pool = VM oder Pool auswählen + +Start\ VM = VM starten +Stop\ VM = VM anhalten +Reset\ VM = VM zurücksetzen +Open\ console = Konsole anzeigen + +confirmResetTitle = Zurücksetzen bestätigen +confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \ + Bitte bestätigen um fortzufahren. +consoleInaccessibleNotification = Die Konsole ist nicht bereit oder belegt. +poolEmptyNotification = Keine VM verfügbar. Wenden Sie sich bitte an den \ + Systemadministrator. + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_en.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_en.properties similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_en.properties rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_en.properties diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt new file mode 100644 index 0000000..ac24b16 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt @@ -0,0 +1,20 @@ +almalinux.svg: + Source: https://commons.wikimedia.org/wiki/File:AlmaLinux_Icon_Logo.svg + License: https://github.com/AlmaLinux/wiki/blob/master/LICENSE + +archlinux.svg: + Source: https://commons.wikimedia.org/wiki/File:Arch_Linux_%22Crystal%22_icon.svghttps://commons.wikimedia.org/wiki/File:Arch_Linux_%22Crystal%22_icon.svg + License: GPL v2 or later + +debian.svg: + Source: https://commons.wikimedia.org/wiki/File:Openlogo-debianV2.svg + License : LGPL + +fedora.svg: + Source: https://commons.wikimedia.org/wiki/File:Fedora_icon_(2021).svg + License: Public Domain + +tux.svg: + Source: https://commons.wikimedia.org/wiki/File:Tux.svghttps://commons.wikimedia.org/wiki/File:Tux.svg + License: Creative Commons CC0 1.0 Universal Public Domain Dedication. Creative Commons CC0 1.0 Universal Public Domain Dedication. + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg new file mode 100644 index 0000000..b2e050a --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg new file mode 100644 index 0000000..ca8204c --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg new file mode 100644 index 0000000..685f632 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg new file mode 100644 index 0000000..e227311 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg @@ -0,0 +1,16 @@ + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/tux.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/tux.svg new file mode 100644 index 0000000..6b558e7 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/tux.svg @@ -0,0 +1,438 @@ + + + Tux + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg new file mode 100644 index 0000000..f217bc8 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg new file mode 100644 index 0000000..51f3016 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + OS + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg new file mode 100644 index 0000000..2c7392e --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/reset-icon.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/reset-icon.svg new file mode 100644 index 0000000..d47e33d --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/reset-icon.svg @@ -0,0 +1,42 @@ + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs b/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs new file mode 100644 index 0000000..ab1aae9 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs @@ -0,0 +1,35 @@ +import typescript from 'rollup-plugin-typescript2'; +import postcss from 'rollup-plugin-postcss'; + +let packagePath = "org/jdrupes/vmoperator/vmaccess"; +let baseName = "VmAccess" +let module = "build/generated/resources/" + packagePath + + "/" + baseName + "-functions.js"; + +let pathsMap = { + "aash-plugin": "../../page-resource/aash-vue-components/lib/aash-vue-components.js", + "jgconsole": "../../console-base-resource/jgconsole.js", + "jgwc": "../../page-resource/jgwc-vue-components/jgwc-components.js", + "l10nBundles": "./" + baseName + "-l10nBundles.ftl.js", + "vue": "../../page-resource/vue/vue.esm-browser.js" +} + +export default { + external: ['aash-plugin', 'jgconsole', 'jgwc', 'l10nBundles', 'vue', 'chartjs'], + input: "src/" + packagePath + "/browser/" + baseName + "-functions.ts", + output: [ + { + format: "esm", + file: module, + sourcemap: true, + sourcemapPathTransform: (relativeSourcePath, _sourcemapPath) => { + return relativeSourcePath.replace(/^([^/]*\/){12}/, "./"); + }, + paths: pathsMap + } + ], + plugins: [ + typescript(), + postcss() + ] +}; diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java new file mode 100644 index 0000000..f30b771 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -0,0 +1,991 @@ +/* + * VM-Operator + * Copyright (C) 2023,2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.vmaccess; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.JsonSyntaxException; +import freemarker.core.ParseException; +import freemarker.template.MalformedTemplateNameException; +import freemarker.template.Template; +import freemarker.template.TemplateNotFoundException; +import io.kubernetes.client.util.Strings; +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.time.Duration; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ResourceBundle; +import java.util.Set; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.bouncycastle.util.Objects; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.AssignVm; +import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.GetVms; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; +import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.ResetVm; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.Manager; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.http.Session; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.KeyValueStoreQuery; +import org.jgrapes.util.events.KeyValueStoreUpdate; +import org.jgrapes.webconsole.base.Conlet.RenderMode; +import org.jgrapes.webconsole.base.ConletBaseModel; +import org.jgrapes.webconsole.base.ConsoleConnection; +import org.jgrapes.webconsole.base.ConsoleRole; +import org.jgrapes.webconsole.base.ConsoleUser; +import org.jgrapes.webconsole.base.WebConsoleUtils; +import org.jgrapes.webconsole.base.events.AddConletRequest; +import org.jgrapes.webconsole.base.events.AddConletType; +import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; +import org.jgrapes.webconsole.base.events.ConletDeleted; +import org.jgrapes.webconsole.base.events.ConsoleConfigured; +import org.jgrapes.webconsole.base.events.ConsolePrepared; +import org.jgrapes.webconsole.base.events.ConsoleReady; +import org.jgrapes.webconsole.base.events.DeleteConlet; +import org.jgrapes.webconsole.base.events.DisplayNotification; +import org.jgrapes.webconsole.base.events.NotifyConletModel; +import org.jgrapes.webconsole.base.events.NotifyConletView; +import org.jgrapes.webconsole.base.events.OpenModalDialog; +import org.jgrapes.webconsole.base.events.RenderConlet; +import org.jgrapes.webconsole.base.events.RenderConletRequestBase; +import org.jgrapes.webconsole.base.events.SetLocale; +import org.jgrapes.webconsole.base.events.UpdateConletType; +import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; + +/** + * The Class {@link VmAccess}. The component supports the following + * configuration properties: + * + * * `displayResource`: a map with the following entries: + * - `preferredIpVersion`: `ipv4` or `ipv6` (default: `ipv4`). + * Determines the IP addresses uses in the generated + * connection file. + * * `deleteConnectionFile`: `true` or `false` (default: `true`). + * If `true`, the downloaded connection file will be deleted by + * the remote viewer when opened. + * * `syncPreviewsFor`: a list objects with either property `user` or + * `role` and the associated name (default: `[]`). + * The remote viewer will synchronize the previews for the specified + * users and roles. + * + */ +@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.CouplingBetweenObjects", + "PMD.GodClass", "PMD.TooManyMethods", "PMD.CyclomaticComplexity" }) +public class VmAccess extends FreeMarkerConlet { + + private static final String VM_NAME_PROPERTY = "vmName"; + private static final String POOL_NAME_PROPERTY = "poolName"; + private static final String RENDERED + = VmAccess.class.getName() + ".rendered"; + private static final String PENDING + = VmAccess.class.getName() + ".pending"; + private static final Set MODES = RenderMode.asSet( + RenderMode.Preview, RenderMode.Edit); + private static final Set MODES_FOR_GENERATED = RenderMode.asSet( + RenderMode.Preview, RenderMode.StickyPreview); + private EventPipeline appPipeline; + private static ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); + + private Class preferredIpVersion = Inet4Address.class; + private Set syncUsers = Collections.emptySet(); + private Set syncRoles = Collections.emptySet(); + private boolean deleteConnectionFile = true; + + /** + * The periodically generated update event. + */ + public static class Update extends Event { + } + + /** + * Creates a new component with its channel set to the given channel. + * + * @param componentChannel the channel that the component's handlers listen + * on by default and that {@link Manager#fire(Event, Channel...)} + * sends the event to + */ + public VmAccess(Channel componentChannel) { + super(componentChannel); + } + + /** + * On start. + * + * @param event the event + */ + @Handler + public void onStart(Start event) { + appPipeline = event.processedBy().get(); + } + + /** + * Configure the component. + * + * @param event the event + */ + @SuppressWarnings({ "unchecked" }) + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()) + .or(() -> { + var oldConfig = event.structured("/Manager/GuiHttpServer" + + "/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer"); + if (oldConfig.isPresent()) { + logger.warning(() -> "Using configuration with old " + + "component name \"VmViewer\", please update to " + + "\"VmAccess\""); + } + return oldConfig; + }) + .ifPresent(c -> { + try { + var dispRes = (Map) c + .getOrDefault("displayResource", + Collections.emptyMap()); + switch ((String) dispRes.getOrDefault("preferredIpVersion", + "")) { + case "ipv6": + preferredIpVersion = Inet6Address.class; + break; + case "ipv4": + default: + preferredIpVersion = Inet4Address.class; + break; + } + + // Delete connection file + deleteConnectionFile + = Optional.ofNullable(c.get("deleteConnectionFile")) + .map(Object::toString).map(Boolean::parseBoolean) + .orElse(true); + + // Users or roles for which previews should be synchronized + syncUsers = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("user")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for users: " + + syncUsers.toString()); + syncRoles = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("role")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for roles: " + + syncRoles.toString()); + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } + + private boolean syncPreviews(Session session) { + return WebConsoleUtils.userFromSession(session) + .filter(u -> syncUsers.contains(u.getName())).isPresent() + || WebConsoleUtils.rolesFromSession(session).stream() + .filter(cr -> syncRoles.contains(cr.getName())).findAny() + .isPresent(); + } + + /** + * On {@link ConsoleReady}, fire the {@link AddConletType}. + * + * @param event the event + * @param channel the channel + * @throws TemplateNotFoundException the template not found exception + * @throws MalformedTemplateNameException the malformed template name + * exception + * @throws ParseException the parse exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException { + // Add conlet resources to page + channel.respond(new AddConletType(type()) + .setDisplayNames( + localizations(channel.supportedLocales(), "conletName")) + .addRenderMode(RenderMode.Preview) + .addScript(new ScriptResource().setScriptType("module") + .setScriptUri(event.renderSupport().conletResource( + type(), "VmAccess-functions.js")))); + channel.session().put(RENDERED, new HashSet<>()); + } + + /** + * On console configured. + * + * @param event the event + * @param connection the console connection + * @throws InterruptedException the interrupted exception + */ + @Handler + public void onConsoleConfigured(ConsoleConfigured event, + ConsoleConnection connection) throws InterruptedException, + IOException { + @SuppressWarnings({ "unchecked" }) + final var rendered + = (Set) connection.session().get(RENDERED); + connection.session().remove(RENDERED); + if (!syncPreviews(connection.session())) { + return; + } + addMissingConlets(event, connection, rendered); + } + + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) + private void addMissingConlets(ConsoleConfigured event, + ConsoleConnection connection, final Set rendered) + throws InterruptedException { + var session = connection.session(); + + // Evaluate missing VMs + var missingVms = appPipeline.fire(new GetVms().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(d -> d.definition().name()) + .collect(Collectors.toCollection(HashSet::new)); + missingVms.removeAll(rendered.stream() + .filter(r -> r.mode() == ResourceModel.Mode.VM) + .map(ResourceModel::name).toList()); + + // Evaluate missing pools + var missingPools = appPipeline.fire(new GetPools().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(VmPool::name) + .collect(Collectors.toCollection(HashSet::new)); + missingPools.removeAll(rendered.stream() + .filter(r -> r.mode() == ResourceModel.Mode.POOL) + .map(ResourceModel::name).toList()); + + // Nothing to do + if (missingVms.isEmpty() && missingPools.isEmpty()) { + return; + } + + // Suspending to allow rendering of conlets to be noticed + var failSafe = Components.schedule(t -> event.resumeHandling(), + Duration.ofSeconds(1)); + event.suspendHandling(failSafe::cancel); + connection.setAssociated(PENDING, event); + + // Create conlets for VMs and pools that haven't been rendered + for (var vmName : missingVms) { + fire(new AddConletRequest(event.event().event().renderSupport(), + VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview)) + .addProperty(VM_NAME_PROPERTY, vmName), + connection); + } + for (var poolName : missingPools) { + fire(new AddConletRequest(event.event().event().renderSupport(), + VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview)) + .addProperty(POOL_NAME_PROPERTY, poolName), + connection); + } + } + + /** + * On console prepared. + * + * @param event the event + * @param connection the connection + */ + @Handler + public void onConsolePrepared(ConsolePrepared event, + ConsoleConnection connection) { + if (syncPreviews(connection.session())) { + connection.respond(new UpdateConletType(type())); + } + } + + private String storagePath(Session session, String conletId) { + return "/" + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse("") + + "/" + VmAccess.class.getName() + "/" + conletId; + } + + @Override + protected Optional createNewState(AddConletRequest event, + ConsoleConnection connection, String conletId) throws Exception { + var model = new ResourceModel(conletId); + var poolName = (String) event.properties().get(POOL_NAME_PROPERTY); + if (poolName != null) { + model.setMode(ResourceModel.Mode.POOL); + model.setName(poolName); + } else { + model.setMode(ResourceModel.Mode.VM); + model.setName((String) event.properties().get(VM_NAME_PROPERTY)); + } + String jsonState = objectMapper.writeValueAsString(model); + connection.respond(new KeyValueStoreUpdate().update( + storagePath(connection.session(), model.getConletId()), jsonState)); + return Optional.of(model); + } + + @Override + protected Optional createStateRepresentation(Event event, + ConsoleConnection connection, String conletId) throws Exception { + var model = new ResourceModel(conletId); + String jsonState = objectMapper.writeValueAsString(model); + connection.respond(new KeyValueStoreUpdate().update( + storagePath(connection.session(), model.getConletId()), jsonState)); + return Optional.of(model); + } + + @Override + @SuppressWarnings("PMD.EmptyCatchBlock") + protected Optional recreateState(Event event, + ConsoleConnection channel, String conletId) throws Exception { + KeyValueStoreQuery query = new KeyValueStoreQuery( + storagePath(channel.session(), conletId), channel); + newEventPipeline().fire(query, channel); + try { + if (!query.results().isEmpty()) { + var json = query.results().get(0).values().stream().findFirst() + .get(); + ResourceModel model + = objectMapper.readValue(json, ResourceModel.class); + return Optional.of(model); + } + } catch (InterruptedException e) { + // Means we have no result. + } + + // Fall back to creating default state. + return createStateRepresentation(event, channel, conletId); + } + + @Override + protected Set doRenderConlet(RenderConletRequestBase event, + ConsoleConnection channel, String conletId, ResourceModel model) + throws Exception { + if (event.renderAs().contains(RenderMode.Preview)) { + return renderPreview(event, channel, conletId, model); + } + + // Render edit + ResourceBundle resourceBundle = resourceBundle(channel.locale()); + Set renderedAs = EnumSet.noneOf(RenderMode.class); + if (event.renderAs().contains(RenderMode.Edit)) { + var session = channel.session(); + var vmNames = appPipeline.fire(new GetVms().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(d -> d.definition().name()).sorted() + .toList(); + var poolNames = appPipeline.fire(new GetPools().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(VmPool::name).sorted().toList(); + Template tpl + = freemarkerConfig().getTemplate("VmAccess-edit.ftl.html"); + var fmModel = fmModel(event, channel, conletId, model); + fmModel.put("vmNames", vmNames); + fmModel.put("poolNames", poolNames); + channel.respond(new OpenModalDialog(type(), conletId, + processTemplate(event, tpl, fmModel)) + .addOption("cancelable", true) + .addOption("okayLabel", + resourceBundle.getString("okayLabel"))); + } + return renderedAs; + } + + @SuppressWarnings("unchecked") + private Set renderPreview(RenderConletRequestBase event, + ConsoleConnection channel, String conletId, ResourceModel model) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException, InterruptedException { + channel.associated(PENDING, Event.class) + .ifPresent(e -> { + e.resumeHandling(); + channel.setAssociated(PENDING, null); + }); + + VmDefinition vmDef = null; + if (model.mode() == ResourceModel.Mode.VM && model.name() != null) { + // Remove conlet if VM definition has been removed + // or user has not at least one permission + vmDef = getVmData(model, channel).map(VmData::definition) + .orElse(null); + if (vmDef == null) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + } + + if (model.mode() == ResourceModel.Mode.POOL && model.name() != null) { + // Remove conlet if pool definition has been removed + // or user has not at least one permission + VmPool pool = appPipeline + .fire(new GetPools().withName(model.name())).get() + .stream().findFirst().orElse(null); + if (pool == null + || permissions(pool, channel.session()).isEmpty()) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + vmDef = getVmData(model, channel).map(VmData::definition) + .orElse(null); + } + + // Render + Template tpl + = freemarkerConfig().getTemplate("VmAccess-preview.ftl.html"); + channel.respond(new RenderConlet(type(), conletId, + processTemplate(event, tpl, + fmModel(event, channel, conletId, model))) + .setRenderAs( + RenderMode.Preview.addModifiers(event.renderAs())) + .setSupportedModes(syncPreviews(channel.session()) + ? MODES_FOR_GENERATED + : MODES)); + if (!Strings.isNullOrEmpty(model.name())) { + Optional.ofNullable(channel.session().get(RENDERED)) + .ifPresent(s -> ((Set) s).add(model)); + updatePreview(channel, model, vmDef); + } + return EnumSet.of(RenderMode.Preview); + } + + private Optional getVmData(ResourceModel model, + ConsoleConnection channel) throws InterruptedException { + if (model.mode() == ResourceModel.Mode.VM) { + // Get the VM data by name. + var session = channel.session(); + return appPipeline.fire(new GetVms().withName(model.name()) + .accessibleFor(WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().findFirst(); + } + + // Look for an (already) assigned VM + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(null); + return appPipeline.fire(new GetVms().assignedFrom(model.name()) + .assignedTo(user)).get().stream().findFirst(); + } + + /** + * Returns the permissions from the VM definition. + * + * @param vmDef the VM definition + * @param session the session + * @return the sets the + */ + private Set permissions(VmDefinition vmDef, Session session) { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + return vmDef.permissionsFor(user, roles); + } + + /** + * Returns the permissions from the pool. + * + * @param pool the pool + * @param session the session + * @return the sets the + */ + private Set permissions(VmPool pool, Session session) { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + return pool.permissionsFor(user, roles); + } + + /** + * Returns the permissions from the VM definition or the pool depending + * on the state of the model. + * + * @param session the session + * @param model the model + * @param vmDef the vm def + * @return the sets the + * @throws InterruptedException the interrupted exception + */ + private Set permissions(Session session, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + if (model.mode() == ResourceModel.Mode.POOL) { + // Use permissions from pool + var pool = appPipeline.fire(new GetPools().withName(model.name())) + .get().stream().findFirst().orElse(null); + if (pool == null) { + return Collections.emptySet(); + } + return pool.permissionsFor(user, roles); + } + + // Use permissions from VM + if (vmDef == null) { + vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name()) + .assignedTo(user)).get().stream().map(VmData::definition) + .findFirst().orElse(null); + } + if (vmDef == null) { + return Collections.emptySet(); + } + return vmDef.permissionsFor(user, roles); + } + + private void updatePreview(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + updateConfig(channel, model, vmDef); + updateVmDef(channel, model, vmDef); + } + + private void updateConfig(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + channel.respond(new NotifyConletView(type(), + model.getConletId(), "updateConfig", model.mode(), model.name(), + permissions(channel.session(), model, vmDef).stream() + .map(VmDefinition.Permission::toString).toList())); + } + + private void updateVmDef(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + Map data = null; + if (vmDef == null) { + model.setAssignedVm(null); + } else { + model.setAssignedVm(vmDef.name()); + var session = channel.session(); + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var perms = permissions(session, model, vmDef); + try { + data = Map.of( + "metadata", Map.of("namespace", vmDef.namespace(), + "name", vmDef.name()), + "spec", vmDef.spec(), + "status", vmDef.status(), + "consoleAccessible", vmDef.consoleAccessible(user, perms)); + } catch (JsonSyntaxException e) { + logger.log(Level.SEVERE, e, + () -> "Failed to serialize VM definition"); + return; + } + } + channel.respond(new NotifyConletView(type(), + model.getConletId(), "updateVmDefinition", data)); + } + + @Override + protected void doConletDeleted(ConletDeleted event, + ConsoleConnection channel, String conletId, + ResourceModel conletState) + throws Exception { + if (event.renderModes().isEmpty()) { + channel.respond(new KeyValueStoreUpdate().delete( + storagePath(channel.session(), conletId))); + } + } + + /** + * Track the VM definitions and update conlets. + * + * @param event the event + * @param channel the channel + * @throws IOException + * @throws InterruptedException + */ + @Handler(namedChannels = "manager") + @SuppressWarnings({ "PMD.CognitiveComplexity", + "PMD.AvoidInstantiatingObjectsInLoops" }) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) + throws IOException, InterruptedException { + var vmDef = event.vmDefinition(); + + // Update known conlets + for (var entry : conletIdsByConsoleConnection().entrySet()) { + var connection = entry.getKey(); + var user = WebConsoleUtils.userFromSession(connection.session()) + .map(ConsoleUser::getName).orElse(null); + for (var conletId : entry.getValue()) { + var model = stateFromSession(connection.session(), conletId); + if (model.isEmpty() + || Strings.isNullOrEmpty(model.get().name())) { + continue; + } + if (model.get().mode() == ResourceModel.Mode.VM) { + // Check if this VM is used by conlet + if (!Objects.areEqual(model.get().name(), vmDef.name())) { + continue; + } + if (event.type() == K8sObserver.ResponseType.DELETED + || permissions(vmDef, connection.session()).isEmpty()) { + connection.respond( + new DeleteConlet(conletId, Collections.emptySet())); + continue; + } + } else { + // Check if VM is used by pool conlet or to be assigned to + // it + var toBeUsedByConlet = vmDef.assignment() + .map(Assignment::pool) + .map(p -> p.equals(model.get().name())).orElse(false) + && vmDef.assignment().map(Assignment::user) + .map(u -> u.equals(user)).orElse(false); + if (!Objects.areEqual(model.get().assignedVm(), + vmDef.name()) && !toBeUsedByConlet) { + continue; + } + + // Now unassigned if VM is deleted or no longer to be used + if (event.type() == K8sObserver.ResponseType.DELETED + || !toBeUsedByConlet) { + updateVmDef(connection, model.get(), null); + continue; + } + } + + // Full update because permissions may have changed + updatePreview(connection, model.get(), vmDef); + } + } + } + + /** + * On vm pool changed. + * + * @param event the event + * @throws InterruptedException the interrupted exception + */ + @Handler(namedChannels = "manager") + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onVmPoolChanged(VmPoolChanged event) + throws InterruptedException { + var poolName = event.vmPool().name(); + // Update known conlets + for (var entry : conletIdsByConsoleConnection().entrySet()) { + var connection = entry.getKey(); + for (var conletId : entry.getValue()) { + var model = stateFromSession(connection.session(), conletId); + if (model.isEmpty() + || model.get().mode() != ResourceModel.Mode.POOL + || !Objects.areEqual(model.get().name(), poolName)) { + continue; + } + if (event.deleted() + || permissions(event.vmPool(), connection.session()) + .isEmpty()) { + connection.respond( + new DeleteConlet(conletId, Collections.emptySet())); + continue; + } + updateConfig(connection, model.get(), null); + } + } + } + + @SuppressWarnings({ "PMD.NcssCount", "PMD.CognitiveComplexity", + "PMD.AvoidLiteralsInIfCondition" }) + @Override + protected void doUpdateConletState(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model) throws Exception { + event.stop(); + if ("selectedResource".equals(event.method())) { + selectResource(event, channel, model); + return; + } + + Optional vmData = getVmData(model, channel); + if (vmData.isEmpty()) { + if (model.mode() == ResourceModel.Mode.VM) { + return; + } + if ("start".equals(event.method())) { + // Assign a VM. + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(null); + vmData = Optional.ofNullable(appPipeline + .fire(new AssignVm(model.name(), user)).get()); + if (vmData.isEmpty()) { + ResourceBundle resourceBundle + = resourceBundle(channel.locale()); + channel.respond(new DisplayNotification( + resourceBundle.getString("poolEmptyNotification"), + Map.of("autoClose", 10_000, "type", "Error"))); + return; + } + } + } + + // Handle command for selected VM + var vmChannel = vmData.get().channel(); + var vmDef = vmData.get().definition(); + var vmName = vmDef.metadata().getName(); + var perms = permissions(channel.session(), model, vmDef); + var resourceBundle = resourceBundle(channel.locale()); + switch (event.method()) { + case "start": + if (perms.contains(VmDefinition.Permission.START)) { + vmChannel.fire(new ModifyVm(vmName, "state", "Running")); + } + break; + case "stop": + if (perms.contains(VmDefinition.Permission.STOP)) { + vmChannel.fire(new ModifyVm(vmName, "state", "Stopped")); + } + break; + case "reset": + if (perms.contains(VmDefinition.Permission.RESET)) { + confirmReset(event, channel, model, resourceBundle); + } + break; + case "resetConfirmed": + if (perms.contains(VmDefinition.Permission.RESET)) { + vmChannel.fire(new ResetVm(vmName)); + } + break; + case "openConsole": + openConsole(channel, model, vmChannel, vmDef, perms); + break; + default:// ignore + break; + } + } + + private void confirmReset(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model, + ResourceBundle resourceBundle) throws TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException { + Template tpl = freemarkerConfig() + .getTemplate("VmAccess-confirmReset.ftl.html"); + channel.respond(new OpenModalDialog(type(), model.getConletId(), + processTemplate(event, tpl, + fmModel(event, channel, model.getConletId(), model))) + .addOption("cancelable", true).addOption("closeLabel", "") + .addOption("title", + resourceBundle.getString("confirmResetTitle"))); + } + + private void openConsole(ConsoleConnection channel, ResourceModel model, + VmChannel vmChannel, VmDefinition vmDef, Set perms) { + var resourceBundle = resourceBundle(channel.locale()); + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + if (!vmDef.consoleAccessible(user, perms)) { + channel.respond(new DisplayNotification( + resourceBundle.getString("consoleInaccessibleNotification"), + Map.of("autoClose", 5_000, "type", "Warning"))); + return; + } + var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user), + e -> gotPassword(channel, model, vmDef, e)); + vmChannel.fire(pwQuery); + } + + private void gotPassword(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef, GetDisplaySecret event) { + if (!event.secretAvailable()) { + return; + } + vmDef.extra().connectionFile(event.secret(), + preferredIpVersion, deleteConnectionFile) + .ifPresent(cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + + @SuppressWarnings({ "PMD.UseLocaleWithCaseConversions" }) + private void selectResource(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model) + throws JsonProcessingException, InterruptedException { + try { + model.setMode(ResourceModel.Mode + .valueOf(event. param(0).toUpperCase())); + model.setName(event.param(1)); + String jsonState = objectMapper.writeValueAsString(model); + channel.respond(new KeyValueStoreUpdate().update(storagePath( + channel.session(), model.getConletId()), jsonState)); + updatePreview(channel, model, + getVmData(model, channel).map(VmData::definition).orElse(null)); + } catch (IllegalArgumentException e) { + logger.warning(() -> "Invalid resource type: " + e.getMessage()); + } + } + + @Override + protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, + String conletId) throws Exception { + return true; + } + + /** + * The Class AccessModel. + */ + public static class ResourceModel extends ConletBaseModel { + + /** + * The Enum ResourceType. + */ + @SuppressWarnings("PMD.ShortVariable") + public enum Mode { + VM, POOL + } + + private Mode mode; + private String name; + private String assignedVm; + + /** + * Instantiates a new resource model. + * + * @param conletId the conlet id + */ + public ResourceModel(@JsonProperty("conletId") String conletId) { + super(conletId); + } + + /** + * Returns the mode. + * + * @return the resourceType + */ + @JsonGetter("mode") + public Mode mode() { + return mode; + } + + /** + * Sets the mode. + * + * @param mode the resource mode to set + */ + public void setMode(Mode mode) { + this.mode = mode; + } + + /** + * Gets the resource name. + * + * @return the string + */ + @JsonGetter("name") + public String name() { + return name; + } + + /** + * Sets the name. + * + * @param name the resource name to set + */ + public void setName(String name) { + this.name = name; + } + + /** + * Gets the assigned vm. + * + * @return the string + */ + @JsonGetter("assignedVm") + public String assignedVm() { + return assignedVm; + } + + /** + * Sets the assigned vm. + * + * @param name the assigned vm + */ + public void setAssignedVm(String name) { + this.assignedVm = name; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = super.hashCode(); + result = prime * result + java.util.Objects.hash(mode, name); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!super.equals(obj)) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ResourceModel other = (ResourceModel) obj; + return mode == other.mode + && java.util.Objects.equals(name, other.name); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(50); + builder.append("AccessModel [mode=").append(mode) + .append(", name=").append(name).append(']'); + return builder.toString(); + } + } +} diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConletFactory.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java similarity index 85% rename from org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConletFactory.java rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java index d77ceb6..5140056 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConletFactory.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.vmconlet; +package org.jdrupes.vmoperator.vmaccess; import java.util.Map; import java.util.Optional; @@ -25,9 +25,9 @@ import org.jgrapes.core.ComponentType; import org.jgrapes.webconsole.base.ConletComponentFactory; /** - * The factory service for {@link VmConlet}s. + * The factory service for {@link VmAccess}s. */ -public class VmConletFactory implements ConletComponentFactory { +public class VmAccessFactory implements ConletComponentFactory { /* * (non-Javadoc) @@ -36,7 +36,7 @@ public class VmConletFactory implements ConletComponentFactory { */ @Override public Class componentType() { - return VmConlet.class; + return VmAccess.class; } /* @@ -48,7 +48,7 @@ public class VmConletFactory implements ConletComponentFactory { @Override public Optional create(Channel componentChannel, Map properties) { - return Optional.of(new VmConlet(componentChannel)); + return Optional.of(new VmAccess(componentChannel)); } } diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts new file mode 100644 index 0000000..47e6e11 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-functions.ts @@ -0,0 +1,306 @@ +/* + * VM-Operator + * Copyright (C) 2024,2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + reactive, ref, createApp, computed, watch +} from "vue"; +import JGConsole from "jgconsole"; +import JgwcPlugin, { JGWC } from "jgwc"; +import { provideApi, getApi } from "aash-plugin"; +import l10nBundles from "l10nBundles"; + +import "./VmAccess-style.scss"; + +// For global access +declare global { + interface Window { + orgJDrupesVmOperatorVmAccess: { + initPreview?: (previewDom: HTMLElement, isUpdate: boolean) => void, + initEdit?: (viewDom: HTMLElement, isUpdate: boolean) => void, + applyEdit?: (viewDom: HTMLElement, apply: boolean) => void, + confirmReset?: (conletType: string, conletId: string) => void + } + } +} + +window.orgJDrupesVmOperatorVmAccess = {}; + +interface Api { + /* eslint-disable @typescript-eslint/no-explicit-any */ + vmName: string; + vmDefinition: any; + poolName: string | null; + permissions: string[]; +} + +const localize = (key: string) => { + return JGConsole.localize( + l10nBundles, JGWC.lang(), key); +}; + +window.orgJDrupesVmOperatorVmAccess.initPreview = (previewDom: HTMLElement, + _isUpdate: boolean) => { + const app = createApp({ + setup(_props: object) { + const conletId = (previewDom.closest( + "[data-conlet-id]")!).dataset["conletId"]!; + const resourceBase = (previewDom.closest( + "*[data-conlet-resource-base]")!).dataset.conletResourceBase; + + const previewApi: Api = reactive({ + vmName: "", + vmDefinition: {}, + poolName: null, + permissions: [] + }); + const poolName = computed(() => previewApi.poolName); + const vmName = computed(() => previewApi.vmDefinition.name); + const configured = computed(() => previewApi.vmDefinition.spec); + const accessible = computed(() => previewApi.vmDefinition.consoleAccessible); + const busy = computed(() => previewApi.vmDefinition.spec + && (previewApi.vmDefinition.spec.vm.state === 'Running' + && (!previewApi.vmDefinition.consoleAccessible) + || previewApi.vmDefinition.spec.vm.state === 'Stopped' + && previewApi.vmDefinition.running)); + const startable = computed(() => previewApi.vmDefinition.spec + && previewApi.vmDefinition.spec.vm.state !== 'Running' + && !previewApi.vmDefinition.running + && previewApi.permissions.includes('start') + || previewApi.poolName !== null && !previewApi.vmDefinition.name); + const stoppable = computed(() => previewApi.vmDefinition.spec && + previewApi.vmDefinition.spec.vm.state !== 'Stopped' + && previewApi.vmDefinition.running); + const running = computed(() => previewApi.vmDefinition.running); + const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); + const permissions = computed(() => previewApi.permissions); + const osicon = computed(() => { + if (!previewApi.vmDefinition.status?.osinfo?.id) { + return null; + } + switch(previewApi.vmDefinition.status.osinfo.id) { + case "almalinux": return "almalinux.svg"; + case "arch": return "arch.svg"; + case "debian": return "debian.svg"; + case "fedora": return "fedora.svg"; + case "mswindows": return "windows.svg"; + case "ubuntu": return "ubuntu.svg"; + default: { + if ((previewApi.vmDefinition.status.osinfo.name || "") + .toLowerCase().includes("linux")) { + return "tux.svg"; + } + return "unknown.svg"; + } + } + }); + + watch(previewApi, (api: Api) => { + JGConsole.instance.updateConletTitle(conletId, + api.poolName || api.vmDefinition.name || ""); + }); + + provideApi(previewDom, previewApi); + + const vmAction = (action: string) => { + JGConsole.notifyConletModel(conletId, action); + }; + + return { localize, resourceBase, vmAction, poolName, vmName, + configured, accessible, busy, startable, stoppable, running, + inUse, permissions, osicon }; + }, + template: ` + + + + + + + + + + + +
{{ vmName }}
+ + + + + + + + +
` + }); + app.use(JgwcPlugin, []); + app.config.globalProperties.window = window; + app.mount(previewDom); +}; + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", + "updateConfig", + function(conletId: string, type: string, resource: string, + permissions: []) { + const conlet = JGConsole.findConletPreview(conletId); + if (!conlet) { + return; + } + const api = getApi(conlet.element().querySelector( + ":scope .jdrupes-vmoperator-vmaccess-preview"))!; + if (type === "VM") { + api.vmName = resource; + api.poolName = ""; + } else { + api.poolName = resource; + api.vmName = ""; + } + api.permissions = permissions; + }); + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", + "updateVmDefinition", function(conletId: string, vmDefinition: any | null) { + const conlet = JGConsole.findConletPreview(conletId); + if (!conlet) { + return; + } + const api = getApi(conlet.element().querySelector( + ":scope .jdrupes-vmoperator-vmaccess-preview"))!; + if (vmDefinition) { + // Add some short-cuts for rendering + vmDefinition.name = vmDefinition.metadata.name; + vmDefinition.currentCpus = vmDefinition.status.cpus; + vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; + // safety fallbacks + vmDefinition.status.conditions.forEach((condition: any) => { + if (condition.type === "Running") { + vmDefinition.running = condition.status === "True"; + vmDefinition.runningConditionSince + = new Date(condition.lastTransitionTime); + } + }) + } else { + vmDefinition = {}; + } + api.vmDefinition = vmDefinition; + }); + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmaccess.VmAccess", + "openConsole", function(_conletId: string, data: string) { + let target = document.getElementById( + "org.jdrupes.vmoperator.vmaccess.VmAccess.target"); + if (!target) { + target = document.createElement("iframe"); + target.id = "org.jdrupes.vmoperator.vmaccess.VmAccess.target"; + target.setAttribute("name", target.id); + target.setAttribute("style", "display: none;"); + document.querySelector("body")!.append(target); + } + const url = "data:application/x-virt-viewer;base64," + + window.btoa(data); + window.open(url, target.id); + }); + +window.orgJDrupesVmOperatorVmAccess.initEdit = (dialogDom: HTMLElement, + isUpdate: boolean) => { + if (isUpdate) { + return; + } + const app = createApp({ + setup() { + const formId = (dialogDom + .closest("*[data-conlet-id]")!).id + "-form"; + + const localize = (key: string) => { + return JGConsole.localize( + l10nBundles, JGWC.lang()!, key); + }; + + const resource = ref("vm"); + const vmNameInput = ref(""); + const poolNameInput = ref(""); + + watch(resource, (resource: string) => { + if (resource === "vm") { + poolNameInput.value = ""; + } + if (resource === "pool") + vmNameInput.value = ""; + }); + + const conletId = (dialogDom.closest( + "[data-conlet-id]")!).dataset["conletId"]!; + const conlet = JGConsole.findConletPreview(conletId); + if (conlet) { + const api = getApi(conlet.element().querySelector( + ":scope .jdrupes-vmoperator-vmaccess-preview"))!; + if (api.poolName) { + resource.value = "pool"; + } + vmNameInput.value = api.vmName; + poolNameInput.value = api.poolName; + } + + provideApi(dialogDom, { resource: () => resource.value, + name: () => resource.value === "vm" + ? vmNameInput.value : poolNameInput.value }); + + return { formId, localize, resource, vmNameInput, poolNameInput }; + } + }); + app.use(JgwcPlugin); + app.mount(dialogDom); +} + +window.orgJDrupesVmOperatorVmAccess.applyEdit = + (dialogDom: HTMLElement, apply: boolean) => { + if (!apply) { + return; + } + const conletId = (dialogDom.closest("[data-conlet-id]")!) + .dataset["conletId"]!; + const editApi = getApi>(dialogDom!)!; + JGConsole.notifyConletModel(conletId, "selectedResource", editApi.resource(), + editApi.name()); +} + +window.orgJDrupesVmOperatorVmAccess.confirmReset = + (conletType: string, conletId: string) => { + JGConsole.instance.closeModalDialog(conletType, conletId); + JGConsole.notifyConletModel(conletId, "resetConfirmed"); +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss new file mode 100644 index 0000000..3a291dd --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/VmAccess-style.scss @@ -0,0 +1,124 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* + * Conlet specific styles. + */ +.jdrupes-vmoperator-vmaccess { + + span[role="button"].svg-icon { + display: inline-block; + line-height: 1; + + /* Align with forkawesome */ + font-size: 14px; + fill: var(--primary); + + &[aria-disabled="true"], &[aria-disabled=""] { + fill: var(--disabled); + } + + svg { + height: 2ex; + width: 1em; + } + } + + [role=button] { + padding: 0.25rem; + + &:not([aria-disabled]):hover, &[aria-disabled='false']:hover { + box-shadow: var(--darkening); + } + } +} + +.jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-preview { + + table { + border-spacing: 0; + } + + img { + display: block; + height: 3em; + padding: 0.25rem; + + &[aria-disabled=''], &[aria-disabled='true'] { + opacity: 0.4; + } + } + + .jdrupes-vmoperator-vmaccess-preview-action-list { + white-space: nowrap; + } + + span.busy::before { + font: normal normal normal 14px/1 ForkAwesome; + font-size: 1.125em; + content: "\f1ce"; + left: 1.45em; + top: 0.7em; + color: var(--info); + position: absolute; + animation: spin 2s linear infinite; + z-index: 100; + pointer-events: none; + } + + span.osicon { + width: 4.25em; + height: 3em; + padding: 0.25rem; + pointer-events: none; + + img { + display: block; + height: 1.75em; + margin: 0.2em auto 0; + pointer-events: none; + } + } +} + +.jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-edit { + + fieldset ul li { + margin-top: 0.5em; + } + + select { + width: 15em; + } +} + +.jdrupes-vmoperator-vmaccess.jdrupes-vmoperator-vmaccess-confirm-reset { + p { + text-align: center; + } + + span[role="button"].svg-icon { + fill: var(--danger); + + svg { + width: 2.5em; + height: 2.5em; + } + } + +} diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/l10nBundles-stub.d.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub.d.ts similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/l10nBundles-stub.d.ts rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub.d.ts diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java new file mode 100644 index 0000000..745ded7 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java @@ -0,0 +1,19 @@ +/* + * VM-Operator + * Copyright (C) 2023, 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.vmaccess; diff --git a/org.jdrupes.vmoperator.vmaccess/tsconfig.json b/org.jdrupes.vmoperator.vmaccess/tsconfig.json new file mode 100644 index 0000000..d9dbb3f --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2015", + "module": "es2015", + "sourceMap": true, + "inlineSources": true, + "declaration": true, + "importHelpers": true, + "strict": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "lib": ["DOM", "ES2020"], + "paths": { + "aash-plugin": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/aash-vue-components/lib/AashPlugin"], + "jgconsole": ["./build/unpacked/org/jgrapes/webconsole/base/JGConsole"], + "jgwc": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/jgwc-vue-components/jgwc-components"], + "l10nBundles": ["./src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub"], + "vue": ["./build/unpacked/org/jgrapes/webconsole/provider/vue/vue/vue"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "l10nBundles-stub.ts"] +} diff --git a/org.jdrupes.vmoperator.vmconlet/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.vmconlet/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory deleted file mode 100644 index 5a22dc7..0000000 --- a/org.jdrupes.vmoperator.vmconlet/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory +++ /dev/null @@ -1 +0,0 @@ -org.jdrupes.vmoperator.vmconlet.VmConletFactory diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties deleted file mode 100644 index 9a0efcf..0000000 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties +++ /dev/null @@ -1,15 +0,0 @@ -conletName = VM Viewer - -VMsSummary = VMs (running/total) - -since = Since -currentCpus = Current CPUs -currentRam = Current RAM -maximumCpus = Maximum CPUs -maximumRam = Maximum RAM -nodeName = Node -requestedCpus = Requested CPUs -requestedRam = Requested RAM -running = Running -vmActions = Actions -vmname = Name diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties b/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties deleted file mode 100644 index d17bab6..0000000 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties +++ /dev/null @@ -1,29 +0,0 @@ -conletName = VM Anzeige - -VMsSummary = VMs (gestartet/gesamt) - -Used\ CPUs = Verwendete CPUs -Used\ RAM = Verwendetes RAM -Period = Zeitraum -Last\ hour = Letzte Stunde -Last\ day = Letzter Tag - -running = Gestartet -since = Seit -currentCpus = Aktuelle CPUs -currentRam = Akuelles RAM -maximumCpus = Maximale CPUs -maximumRam = Maximales RAM -nodeName = Knoten -requestedCpus = Angeforderte CPUs -requestedRam = Angefordertes RAM -vmActions = Aktionen -vmname = Name -Value\ is\ above\ maximum = Wert ist zu groß -Illegal\ format = Ungültiges Format - -Start\ VM = VM Starten -Stop\ VM = VM Anhalten - -Yes = Ja -No = Nein diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java b/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java deleted file mode 100644 index f096485..0000000 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java +++ /dev/null @@ -1,395 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2023 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.vmconlet; - -import com.google.gson.JsonObject; -import freemarker.core.ParseException; -import freemarker.template.MalformedTemplateNameException; -import freemarker.template.Template; -import freemarker.template.TemplateNotFoundException; -import io.kubernetes.client.custom.Quantity; -import io.kubernetes.client.custom.Quantity.Format; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; -import java.io.IOException; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.time.Duration; -import java.time.Instant; -import java.util.HashSet; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import org.jdrupes.json.JsonBeanDecoder; -import org.jdrupes.json.JsonDecodeException; -import org.jdrupes.vmoperator.manager.events.ModifyVm; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type; -import org.jdrupes.vmoperator.util.GsonPtr; -import org.jgrapes.core.Channel; -import org.jgrapes.core.Event; -import org.jgrapes.core.Manager; -import org.jgrapes.core.NamedChannel; -import org.jgrapes.core.annotation.Handler; -import org.jgrapes.webconsole.base.Conlet.RenderMode; -import org.jgrapes.webconsole.base.ConletBaseModel; -import org.jgrapes.webconsole.base.ConsoleConnection; -import org.jgrapes.webconsole.base.events.AddConletRequest; -import org.jgrapes.webconsole.base.events.AddConletType; -import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; -import org.jgrapes.webconsole.base.events.ConsoleReady; -import org.jgrapes.webconsole.base.events.NotifyConletModel; -import org.jgrapes.webconsole.base.events.NotifyConletView; -import org.jgrapes.webconsole.base.events.RenderConlet; -import org.jgrapes.webconsole.base.events.RenderConletRequestBase; -import org.jgrapes.webconsole.base.events.SetLocale; -import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; - -/** - * The Class VmConlet. - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class VmConlet extends FreeMarkerConlet { - - private static final Set MODES = RenderMode.asSet( - RenderMode.Preview, RenderMode.View); - private final Map vmInfos - = new ConcurrentHashMap<>(); - private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1)); - private Summary cachedSummary; - - /** - * The periodically generated update event. - */ - public static class Update extends Event { - } - - /** - * Creates a new component with its channel set to the given channel. - * - * @param componentChannel the channel that the component's handlers listen - * on by default and that {@link Manager#fire(Event, Channel...)} - * sends the event to - */ - public VmConlet(Channel componentChannel) { - super(componentChannel); - setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update()); - } - - /** - * On {@link ConsoleReady}, fire the {@link AddConletType}. - * - * @param event the event - * @param channel the channel - * @throws TemplateNotFoundException the template not found exception - * @throws MalformedTemplateNameException the malformed template name - * exception - * @throws ParseException the parse exception - * @throws IOException Signals that an I/O exception has occurred. - */ - @Handler - public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) - throws TemplateNotFoundException, MalformedTemplateNameException, - ParseException, IOException { - // Add conlet resources to page - channel.respond(new AddConletType(type()) - .setDisplayNames( - localizations(channel.supportedLocales(), "conletName")) - .addRenderMode(RenderMode.Preview) - .addScript(new ScriptResource().setScriptType("module") - .setScriptUri(event.renderSupport().conletResource( - type(), "VmConlet-functions.js")))); - } - - @Override - protected Optional createNewState(AddConletRequest event, - ConsoleConnection connection, String conletId) throws Exception { - return Optional.of(new VmsModel(conletId)); - } - - @Override - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - protected Set doRenderConlet(RenderConletRequestBase event, - ConsoleConnection channel, String conletId, VmsModel conletState) - throws Exception { - Set renderedAs = new HashSet<>(); - boolean sendVmInfos = false; - if (event.renderAs().contains(RenderMode.Preview)) { - Template tpl - = freemarkerConfig().getTemplate("VmConlet-preview.ftl.html"); - channel.respond(new RenderConlet(type(), conletId, - processTemplate(event, tpl, - fmModel(event, channel, conletId, conletState))) - .setRenderAs( - RenderMode.Preview.addModifiers(event.renderAs())) - .setSupportedModes(MODES)); - renderedAs.add(RenderMode.View); - channel.respond(new NotifyConletView(type(), - conletId, "summarySeries", summarySeries.entries())); - var summary = evaluateSummary(false); - channel.respond(new NotifyConletView(type(), - conletId, "updateSummary", summary)); - sendVmInfos = true; - } - if (event.renderAs().contains(RenderMode.View)) { - Template tpl - = freemarkerConfig().getTemplate("VmConlet-view.ftl.html"); - channel.respond(new RenderConlet(type(), conletId, - processTemplate(event, tpl, - fmModel(event, channel, conletId, conletState))) - .setRenderAs( - RenderMode.View.addModifiers(event.renderAs())) - .setSupportedModes(MODES)); - renderedAs.add(RenderMode.View); - sendVmInfos = true; - } - if (sendVmInfos) { - for (var vmInfo : vmInfos.values()) { - var def = JsonBeanDecoder.create(vmInfo.getRaw().toString()) - .readObject(); - channel.respond(new NotifyConletView(type(), - conletId, "updateVm", def)); - } - } - - return renderedAs; - } - - /** - * Track the VM definitions. - * - * @param event the event - * @param channel the channel - * @throws JsonDecodeException the json decode exception - * @throws IOException - */ - @Handler(namedChannels = "manager") - @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals" }) - public void onVmDefChanged(VmDefChanged event, VmChannel channel) - throws JsonDecodeException, IOException { - if (event.type() == Type.DELETED) { - var vmName = event.vmDefinition().getMetadata().getName(); - vmInfos.remove(vmName); - for (var entry : conletIdsByConsoleConnection().entrySet()) { - for (String conletId : entry.getValue()) { - entry.getKey().respond(new NotifyConletView(type(), - conletId, "removeVm", vmName)); - } - } - } else { - var vmDef = convertQuantities(event); - var def = JsonBeanDecoder.create(vmDef.getRaw().toString()) - .readObject(); - for (var entry : conletIdsByConsoleConnection().entrySet()) { - for (String conletId : entry.getValue()) { - entry.getKey().respond(new NotifyConletView(type(), - conletId, "updateVm", def)); - } - } - } - - var summary = evaluateSummary(true); - summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam); - for (var entry : conletIdsByConsoleConnection().entrySet()) { - for (String conletId : entry.getValue()) { - entry.getKey().respond(new NotifyConletView(type(), - conletId, "updateSummary", summary)); - } - } - } - - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private DynamicKubernetesObject convertQuantities(VmDefChanged event) { - // Clone and remove managed fields - var vmDef = new DynamicKubernetesObject( - event.vmDefinition().getRaw().deepCopy()); - GsonPtr.to(vmDef.getRaw()).to("metadata").get(JsonObject.class) - .remove("managedFields"); - - // Convert RAM sizes to unitless numbers - var vmSpec = GsonPtr.to(vmDef.getRaw()).to("spec", "vm"); - vmSpec.set("maximumRam", Quantity.fromString( - vmSpec.getAsString("maximumRam").orElse("0")).getNumber() - .toBigInteger()); - vmSpec.set("currentRam", Quantity.fromString( - vmSpec.getAsString("currentRam").orElse("0")).getNumber() - .toBigInteger()); - var status = GsonPtr.to(vmDef.getRaw()).to("status"); - status.set("ram", Quantity.fromString( - status.getAsString("ram").orElse("0")).getNumber() - .toBigInteger()); - String vmName = event.vmDefinition().getMetadata().getName(); - vmInfos.put(vmName, vmDef); - return vmDef; - } - - /** - * Handle the periodic update event by sending {@link NotifyConletView} - * events. - * - * @param event the event - * @param connection the console connection - */ - @Handler - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - public void onUpdate(Update event, ConsoleConnection connection) { - var summary = evaluateSummary(false); - summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam); - for (String conletId : conletIds(connection)) { - connection.respond(new NotifyConletView(type(), - conletId, "updateSummary", summary)); - } - } - - /** - * The Class Summary. - */ - @SuppressWarnings("PMD.DataClass") - public static class Summary { - - /** The total vms. */ - public int totalVms; - - /** The running vms. */ - public int runningVms; - - /** The used cpus. */ - public int usedCpus; - - /** The used ram. */ - public BigInteger usedRam = BigInteger.ZERO; - - /** - * Gets the total vms. - * - * @return the totalVms - */ - public int getTotalVms() { - return totalVms; - } - - /** - * Gets the running vms. - * - * @return the runningVms - */ - public int getRunningVms() { - return runningVms; - } - - /** - * Gets the used cpus. - * - * @return the usedCpus - */ - public int getUsedCpus() { - return usedCpus; - } - - /** - * Gets the used ram. Returned as String for Json rendering. - * - * @return the usedRam - */ - public String getUsedRam() { - return usedRam.toString(); - } - - } - - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - private Summary evaluateSummary(boolean force) { - if (!force && cachedSummary != null) { - return cachedSummary; - } - Summary summary = new Summary(); - for (var vmDef : vmInfos.values()) { - summary.totalVms += 1; - var status = GsonPtr.to(vmDef.getRaw()).to("status"); - summary.usedCpus += status.getAsInt("cpus").orElse(0); - summary.usedRam = summary.usedRam.add(status.getAsString("ram") - .map(BigInteger::new).orElse(BigInteger.ZERO)); - for (var c : status.getAsListOf(JsonObject.class, "conditions")) { - if ("Running".equals(GsonPtr.to(c).getAsString("type") - .orElse(null)) - && "True".equals(GsonPtr.to(c).getAsString("status") - .orElse(null))) { - summary.runningVms += 1; - } - } - } - cachedSummary = summary; - return summary; - } - - @Override - @SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor") - protected void doUpdateConletState(NotifyConletModel event, - ConsoleConnection channel, VmsModel conletState) - throws Exception { - event.stop(); - switch (event.method()) { - case "start": - fire(new ModifyVm(event.params().asString(0), "state", "Running", - new NamedChannel("manager"))); - break; - case "stop": - fire(new ModifyVm(event.params().asString(0), "state", "Stopped", - new NamedChannel("manager"))); - break; - case "cpus": - fire(new ModifyVm(event.params().asString(0), "currentCpus", - new BigDecimal(event.params().asDouble(1)).toBigInteger(), - new NamedChannel("manager"))); - break; - case "ram": - fire(new ModifyVm(event.params().asString(0), "currentRam", - new Quantity(new BigDecimal(event.params().asDouble(1)), - Format.BINARY_SI).toSuffixedString(), - new NamedChannel("manager"))); - break; - default:// ignore - break; - } - } - - @Override - protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, - String conletId) throws Exception { - return true; - } - - /** - * The Class VmsModel. - */ - public class VmsModel extends ConletBaseModel { - - /** - * Instantiates a new vms model. - * - * @param conletId the conlet id - */ - public VmsModel(String conletId) { - super(conletId); - } - - } -} diff --git a/org.jdrupes.vmoperator.vmmgmt/.checkstyle b/org.jdrupes.vmoperator.vmmgmt/.checkstyle new file mode 100644 index 0000000..7f2c604 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.checkstyle @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmconlet/.eclipse-pmd b/org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd similarity index 76% rename from org.jdrupes.vmoperator.vmconlet/.eclipse-pmd rename to org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.vmconlet/.eclipse-pmd +++ b/org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.vmmgmt/.eslintignore b/org.jdrupes.vmoperator.vmmgmt/.eslintignore new file mode 100644 index 0000000..139d3ee --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.eslintignore @@ -0,0 +1 @@ +rollup.config.mjs diff --git a/org.jdrupes.vmoperator.vmmgmt/.eslintrc.json b/org.jdrupes.vmoperator.vmmgmt/.eslintrc.json new file mode 100644 index 0000000..e4f80f1 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { "project": ["./tsconfig.json"] }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "constructor-super": "off" + } +} + diff --git a/org.jdrupes.vmoperator.vmmgmt/.gitignore b/org.jdrupes.vmoperator.vmmgmt/.gitignore new file mode 100644 index 0000000..a53e74c --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.gitignore @@ -0,0 +1,4 @@ +/bin/ +/bin_test/ +/generated/ +/build/ diff --git a/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..641c156 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,10 @@ +build.commands=org.eclipse.jdt.core.javabuilder +connection.arguments= +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) +connection.java.home=null +connection.jvm.arguments= +connection.project.dir=.. +derived.resources=.gradle,generated +eclipse.preferences.version=1 +natures=org.eclipse.jdt.groovy.core.groovyNature,org.eclipse.jdt.core.javanature +project.path=\:org.jgrapes.osgi.conlets.services diff --git a/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.runtime.prefs new file mode 100644 index 0000000..5a0ad22 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.runtime.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +line.separator=\n diff --git a/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.jdt.ui.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.jdt.ui.prefs new file mode 100644 index 0000000..784d01f --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.jdt.ui.prefs @@ -0,0 +1,63 @@ +eclipse.preferences.version=1 +editor_save_participant_org.eclipse.jdt.ui.postsavelistener.cleanup=true +formatter_profile=_JGrapes +formatter_settings_version=13 +sp_cleanup.add_default_serial_version_id=true +sp_cleanup.add_generated_serial_version_id=false +sp_cleanup.add_missing_annotations=true +sp_cleanup.add_missing_deprecated_annotations=true +sp_cleanup.add_missing_methods=false +sp_cleanup.add_missing_nls_tags=false +sp_cleanup.add_missing_override_annotations=true +sp_cleanup.add_missing_override_annotations_interface_methods=true +sp_cleanup.add_serial_version_id=false +sp_cleanup.always_use_blocks=true +sp_cleanup.always_use_parentheses_in_expressions=false +sp_cleanup.always_use_this_for_non_static_field_access=false +sp_cleanup.always_use_this_for_non_static_method_access=false +sp_cleanup.convert_functional_interfaces=false +sp_cleanup.convert_to_enhanced_for_loop=false +sp_cleanup.correct_indentation=false +sp_cleanup.format_source_code=true +sp_cleanup.format_source_code_changes_only=false +sp_cleanup.insert_inferred_type_arguments=false +sp_cleanup.make_local_variable_final=true +sp_cleanup.make_parameters_final=false +sp_cleanup.make_private_fields_final=true +sp_cleanup.make_type_abstract_if_missing_method=false +sp_cleanup.make_variable_declarations_final=false +sp_cleanup.never_use_blocks=false +sp_cleanup.never_use_parentheses_in_expressions=true +sp_cleanup.on_save_use_additional_actions=false +sp_cleanup.organize_imports=false +sp_cleanup.qualify_static_field_accesses_with_declaring_class=false +sp_cleanup.qualify_static_member_accesses_through_instances_with_declaring_class=true +sp_cleanup.qualify_static_member_accesses_through_subtypes_with_declaring_class=true +sp_cleanup.qualify_static_member_accesses_with_declaring_class=false +sp_cleanup.qualify_static_method_accesses_with_declaring_class=false +sp_cleanup.remove_private_constructors=true +sp_cleanup.remove_redundant_type_arguments=false +sp_cleanup.remove_trailing_whitespaces=false +sp_cleanup.remove_trailing_whitespaces_all=true +sp_cleanup.remove_trailing_whitespaces_ignore_empty=false +sp_cleanup.remove_unnecessary_casts=true +sp_cleanup.remove_unnecessary_nls_tags=false +sp_cleanup.remove_unused_imports=false +sp_cleanup.remove_unused_local_variables=false +sp_cleanup.remove_unused_private_fields=true +sp_cleanup.remove_unused_private_members=false +sp_cleanup.remove_unused_private_methods=true +sp_cleanup.remove_unused_private_types=true +sp_cleanup.sort_members=false +sp_cleanup.sort_members_all=false +sp_cleanup.use_anonymous_class_creation=false +sp_cleanup.use_blocks=false +sp_cleanup.use_blocks_only_for_return_and_throw=false +sp_cleanup.use_lambda=true +sp_cleanup.use_parentheses_in_expressions=false +sp_cleanup.use_this_for_non_static_field_access=false +sp_cleanup.use_this_for_non_static_field_access_only_if_necessary=true +sp_cleanup.use_this_for_non_static_method_access=false +sp_cleanup.use_this_for_non_static_method_access_only_if_necessary=true +sp_jautodoc.cleanup.add_header=false +sp_jautodoc.cleanup.replace_header=false diff --git a/org.jdrupes.vmoperator.vmmgmt/build.gradle b/org.jdrupes.vmoperator.vmmgmt/build.gradle new file mode 100644 index 0000000..606c6cd --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'org.jdrupes.vmoperator.java-library-conventions' +} + +dependencies { + implementation project(':org.jdrupes.vmoperator.manager.events') + + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' + +} + +apply plugin: 'com.github.node-gradle.node' + +node { + download = true +} + +task extractDependencies(type: Copy) { + from configurations.compileClasspath + .findAll{ it.name.contains('.provider.') + || it.name.contains('org.jgrapes.webconsole.base') + } + .collect{ zipTree (it) } + exclude '*.class' + into 'build/unpacked' + duplicatesStrategy 'include' + } + +task compileTs(type: NodeTask) { + dependsOn ':npmInstall' + dependsOn extractDependencies + inputs.dir project.file('src') + inputs.file project.file('tsconfig.json') + inputs.file project.file('rollup.config.mjs') + outputs.dir project.file('build/generated/resources') + script = file("${rootProject.rootDir}/node_modules/rollup/dist/bin/rollup") + args = ["-c"] +} + +sourceSets { + main { + resources { + srcDir project.file('build/generated/resources') + } + } +} + +processResources { + dependsOn compileTs +} + +eclipse { + autoBuildTasks compileTs +} diff --git a/org.jdrupes.vmoperator.vmmgmt/package.json b/org.jdrupes.vmoperator.vmmgmt/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/package.json @@ -0,0 +1 @@ +{} diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.vmmgmt/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory new file mode 100644 index 0000000..d7d7c8d --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory @@ -0,0 +1 @@ +org.jdrupes.vmoperator.vmmgmt.VmMgmtFactory diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html new file mode 100644 index 0000000..d174707 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-confirmReset.ftl.html @@ -0,0 +1,13 @@ +
+

${_("confirmResetMsg")}

+

+ + + + + + +

+
\ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-l10nBundles.ftl.js b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-l10nBundles.ftl.js rename to org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-preview.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html similarity index 81% rename from org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-preview.ftl.html rename to org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html index 11cd882..8c9970a 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-preview.ftl.html +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html @@ -1,6 +1,6 @@ -
@@ -30,11 +30,11 @@ {{ vmSummary.runningVms }} / {{ vmSummary.totalVms }} - {{ localize("Used CPUs") }}: + {{ localize("currentCpus") }}: {{ vmSummary.usedCpus }} - {{ localize("Used RAM") }}: + {{ localize("currentRam") }}: {{ formatMemory(Number(vmSummary.usedRam)) }} diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html similarity index 55% rename from org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html rename to org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html index 913f45d..3197440 100644 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-view.ftl.html +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html @@ -1,7 +1,8 @@ -
-