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/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 70654cc..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,28 +0,0 @@ -default: - # Template project: https://gitlab.com/pages/jekyll - # Docs: https://docs.gitlab.com/ee/pages/ - image: ruby:3.2 - before_script: - - git fetch origin gh-pages - - git checkout gh-pages - - gem install bundler - - bundle install -variables: - JEKYLL_ENV: production - LC_ALL: C.UTF-8 -test: - stage: test - script: - - bundle exec jekyll build -d test - artifacts: - paths: - - test - -pages: - stage: deploy - script: - - bundle exec jekyll build -d public - artifacts: - paths: - - public - environment: production 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/.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 e09814c..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,9 +18,6 @@ plugins { // Access to git information id 'org.ajoberstar.grgit' - - // Apply the common versioning conventions. - id 'org.jdrupes.vmoperator.versioning-conventions' } repositories { @@ -53,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/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 f441cbc..c2a7a66 100644 --- a/deploy/crds/vms-crd.yaml +++ b/deploy/crds/vms-crd.yaml @@ -994,6 +994,10 @@ spec: 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: >- @@ -1012,8 +1016,26 @@ spec: type: array items: type: string - enum: ["start", "stop", "accessConsole", "*"] + 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. @@ -1405,6 +1427,15 @@ 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: @@ -1439,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. @@ -1449,12 +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 @@ -1465,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 0b0e94a..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,9 +35,12 @@ rules: - apiGroups: - "" resources: + - persistentvolumeclaims - pods verbs: + - watch - list - get + - create - delete - patch diff --git a/dev-example/.gitignore b/dev-example/.gitignore index 925478d..1e31cc5 100644 --- a/dev-example/.gitignore +++ b/dev-example/.gitignore @@ -1 +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 579103d..2a72bc8 100644 --- a/dev-example/config.yaml +++ b/dev-example/config.yaml @@ -7,8 +7,28 @@ "/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": @@ -17,18 +37,33 @@ "/WebConsole": "/LoginConlet": users: - - name: admin - fullName: Administrator - password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." - - name: 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 - test: + operator: + - operator + test1: + - user + test2: + - user + test3: - user # All users have role other "*": @@ -39,13 +74,16 @@ # Admins can use all conlets admin: - "*" + operator: + - org.jdrupes.vmoperator.vmmgmt.VmMgmt + - org.jdrupes.vmoperator.vmaccess.VmAccess user: - - org.jdrupes.vmoperator.vmviewer.VmViewer + - org.jdrupes.vmoperator.vmaccess.VmAccess # Others cannot use any conlet (except login conlet to log out) other: - org.jgrapes.webconlet.oidclogin.LoginConlet "/ComponentCollector": - "/VmViewer": + "/VmAccess": displayResource: preferredIpVersion: ipv4 syncPreviewsFor: 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 19b6295..975d95f 100644 --- a/dev-example/kustomization.yaml +++ b/dev-example/kustomization.yaml @@ -35,6 +35,14 @@ patches: "/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": @@ -43,18 +51,28 @@ 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 - test: + test1: + - user + test2: + - user + test3: - user # All users have role other "*": @@ -71,7 +89,7 @@ patches: other: - org.jgrapes.webconlet.locallogin.LoginConlet "/ComponentCollector": - "/VmViewer": + "/VmAccess": displayResource: preferredIpVersion: ipv4 syncPreviewsFor: 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-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 e874ef8..aa75bc3 100644 --- a/dev-example/test-vm.yaml +++ b/dev-example/test-vm.yaml @@ -5,18 +5,13 @@ metadata: name: test-vm spec: image: - repository: docker-registry.lan.mnl.de - path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine - version: latest + source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing pullPolicy: Always permissions: - - user: admin - may: - - "*" - - user: test - may: - - "accessConsole" + - user: admin + may: + - "*" resources: requests: @@ -37,8 +32,9 @@ spec: currentCpus: 4 networks: - - tap: - mac: "02:16:3e:33:58:10" + # No bridge on test cluster + - user: {} + disks: - volumeClaimTemplate: metadata: @@ -62,3 +58,5 @@ spec: 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/build.gradle b/org.jdrupes.vmoperator.common/build.gradle index 42c05ae..e72cb14 100644 --- a/org.jdrupes.vmoperator.common/build.gradle +++ b/org.jdrupes.vmoperator.common/build.gradle @@ -10,6 +10,8 @@ plugins { dependencies { api project(':org.jdrupes.vmoperator.util') + 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 150bb3b..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 @@ -27,15 +27,101 @@ public class Constants { /** The Constant APP_NAME. */ public static final String APP_NAME = "vm-runner"; - /** The Constant COMP_DISPLAY_SECRETS. */ - public static final String COMP_DISPLAY_SECRET = "display-secret"; - /** 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/K8s.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java index 481f724..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 @@ -47,8 +47,7 @@ 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 { /** @@ -113,7 +112,6 @@ public class K8s { public static JsonObject yamlToJson(ApiClient client, Reader yaml) { // Avoid Yaml.load due to // https://github.com/kubernetes-client/java/issues/2741 - @SuppressWarnings("PMD.UseConcurrentHashMap") Map yamlData = new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml); @@ -157,27 +155,6 @@ public class K8s { return Optional.of(apiRes); } - /** - * Get an object from its metadata. - * - * @param the generic type - * @param the generic type - * @param api the api - * @param meta the meta - * @return the object - */ - @Deprecated - @SuppressWarnings("PMD.GenericsNaming") - public static - Optional - get(GenericKubernetesApi api, V1ObjectMeta meta) { - var response = api.get(meta.getNamespace(), meta.getName()); - if (response.isSuccess()) { - return Optional.of(response.getObject()); - } - return Optional.empty(); - } - /** * Apply the given patch data. * 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 index 37b0b97..272da2b 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java @@ -48,8 +48,7 @@ import okhttp3.Response; * A client with some additional properties. */ @SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods", - "PMD.LinguisticNaming", "checkstyle:LineLength", - "PMD.CouplingBetweenObjects", "PMD.GodClass" }) + "checkstyle:LineLength", "PMD.CouplingBetweenObjects", "PMD.GodClass" }) public class K8sClient extends ApiClient { private ApiClient apiClient; @@ -231,7 +230,6 @@ public class K8sClient extends ApiClient { * @return the api client * @see ApiClient#setKeyManagers(javax.net.ssl.KeyManager[]) */ - @SuppressWarnings("PMD.UseVarargs") @Override public ApiClient setKeyManagers(KeyManager[] managers) { return apiClient().setKeyManagers(managers); @@ -638,7 +636,6 @@ public class K8sClient extends ApiClient { * @return the string * @see ApiClient#selectHeaderAccept(java.lang.String[]) */ - @SuppressWarnings("PMD.UseVarargs") @Override public String selectHeaderAccept(String[] accepts) { return apiClient().selectHeaderAccept(accepts); @@ -651,7 +648,6 @@ public class K8sClient extends ApiClient { * @return the string * @see ApiClient#selectHeaderContentType(java.lang.String[]) */ - @SuppressWarnings("PMD.UseVarargs") @Override public String selectHeaderContentType(String[] contentTypes) { return apiClient().selectHeaderContentType(contentTypes); @@ -818,7 +814,7 @@ public class K8sClient extends ApiClient { * @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", "PMD.ExcessiveParameterList" }) + @SuppressWarnings({ "rawtypes" }) @Override public Call buildCall(String path, String method, List queryParams, List collectionQueryParams, Object body, @@ -847,7 +843,7 @@ public class K8sClient extends ApiClient { * @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", "PMD.ExcessiveParameterList" }) + @SuppressWarnings({ "rawtypes" }) @Override public Request buildRequest(String path, String method, List queryParams, List collectionQueryParams, 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 index 81a4eab..59b4d12 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java @@ -45,7 +45,7 @@ import java.util.function.Function; * @param the generic type * @param the generic type */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) public class K8sClusterGenericStub { protected final K8sClient client; @@ -239,6 +239,7 @@ public class K8sClusterGenericStub the object list type * @param the result type */ + @FunctionalInterface public interface GenericSupplier> { @@ -253,7 +254,6 @@ public class K8sClusterGenericStub objectClass, Class objectListClass, K8sClient client, APIResource context, String name); } @@ -282,7 +282,6 @@ public class K8sClusterGenericStub> R get(Class objectClass, Class objectListClass, @@ -313,8 +312,6 @@ public class K8sClusterGenericStub> R get(Class objectClass, Class objectListClass, @@ -339,8 +336,6 @@ public class K8sClusterGenericStub> R create(Class objectClass, Class objectListClass, @@ -373,7 +368,7 @@ public class K8sClusterGenericStub> Collection list(Class objectClass, Class objectListClass, - K8sClient client, APIResource context, + K8sClient client, APIResource context, ListOptions options, GenericSupplier provider) throws ApiException { var result = new ArrayList(); 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 index 6a4410f..2392d3e 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java @@ -29,7 +29,6 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta; * notably the metadata, is made available through the methods * defined by {@link KubernetesObject}. */ -@SuppressWarnings("PMD.DataClass") public class K8sDynamicModel implements KubernetesObject { private final V1ObjectMeta metadata; @@ -102,7 +101,7 @@ public class K8sDynamicModel implements KubernetesObject { * * @return the JSON object describing the status */ - public JsonObject status() { + public JsonObject statusJson() { return data.getAsJsonObject("status"); } 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 index afed802..c0303c2 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java @@ -31,7 +31,6 @@ import java.util.Collection; * state and can therefore be used for any kind of object, especially * custom objects. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sDynamicStub extends K8sDynamicStubBase { @@ -64,8 +63,6 @@ public class K8sDynamicStub * @return the stub if the object exists * @throws ApiException the api exception */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static K8sDynamicStub get(K8sClient client, GroupVersionKind gvk, String namespace, String name) throws ApiException { @@ -83,8 +80,6 @@ public class K8sDynamicStub * @return the stub if the object exists * @throws ApiException the api exception */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static K8sDynamicStub get(K8sClient client, APIResource context, String namespace, String name) { return new K8sDynamicStub(client, context, namespace, name); 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 index 44f419c..ae3f012 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java @@ -26,7 +26,6 @@ import io.kubernetes.client.Discovery.APIResource; * state and can therefore be used for any kind of object, especially * custom objects. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public abstract class K8sDynamicStubBase> extends K8sGenericStub { @@ -40,7 +39,6 @@ public abstract class K8sDynamicStubBase objectClass, Class objectListClass, DynamicTypeAdapterFactory taf, K8sClient client, APIResource context, String namespace, 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 index f118a17..9ba376f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java @@ -27,6 +27,7 @@ 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; @@ -47,7 +48,7 @@ import java.util.function.Function; * @param the generic type * @param the generic type */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.TooManyMethods" }) public class K8sGenericStub { protected final K8sClient client; @@ -192,30 +193,92 @@ public class K8sGenericStub updateStatus(O object, - Function status) throws ApiException { - return K8s.optional(api.updateStatus(object, status)); + public Optional updateStatus(O object, Function updater) + throws ApiException { + return K8s.optional(api.updateStatus(object, updater)); } /** - * Updates the status. + * 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 status the status + * @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 status) + public Optional updateStatus(Function updater, O current) throws ApiException { - return updateStatus( - api.get(namespace, name).throwsApiException().getObject(), status); + 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); } /** @@ -224,7 +287,7 @@ public class K8sGenericStub patch(String patchType, V1Patch patch, @@ -239,7 +302,7 @@ public class K8sGenericStub @@ -248,6 +311,21 @@ public class K8sGenericStub 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. * @@ -279,6 +357,7 @@ public class K8sGenericStub the object list type * @param the result type */ + @FunctionalInterface public interface GenericSupplier> { @@ -290,7 +369,6 @@ public class K8sGenericStub> R create(Class objectClass, Class objectListClass, 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 index 2545a30..9e22382 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java @@ -27,9 +27,11 @@ 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 @@ -48,7 +50,6 @@ public class K8sObserver objectClass, Class objectListClass, K8sClient client, APIResource context, String namespace, ListOptions options) { @@ -85,39 +85,47 @@ public class K8sObserver(objectClass, objectListClass, context.getGroup(), context.getPreferredVersion(), context.getResourcePlural(), client); - thread = new Thread(() -> { - try { - logger.config(() -> "Watching " + context.getResourcePlural() - + " (" + context.getPreferredVersion() + ")" - + " in " + namespace); + 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 { - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var changed = api.watch(namespace, options).iterator(); - while (changed.hasNext()) { - handler.accept(client, changed.next()); + // 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); } - } catch (ApiException e) { - logger.log(Level.FINE, e, () -> "Problem watching" - + " (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); } } - 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); - } - } - }); - thread.setDaemon(true); + }); } @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") @@ -222,7 +230,6 @@ public class K8sObserver { 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 index 16e5c82..9075a84 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java @@ -29,7 +29,6 @@ import java.util.Optional; /** * A stub for pods (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1DeploymentStub extends K8sGenericStub { 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 index 050c593..ea1237d 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for nodes (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1NodeStub extends K8sClusterGenericStub { public static final APIResource CONTEXT = new APIResource("", List.of("v1"), @@ -74,8 +73,7 @@ public class K8sV1NodeStub extends K8sClusterGenericStub { /** * Provide {@link GenericSupplier}. */ - @SuppressWarnings({ "PMD.UnusedFormalParameter", - "PMD.UnusedPrivateMethod" }) + @SuppressWarnings({ "PMD.UnusedFormalParameter" }) private static K8sV1NodeStub getGeneric(Class objectClass, Class objectListClass, K8sClient client, APIResource context, String name) { 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 index 21ac931..f21bb47 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for pods (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1PodStub extends K8sGenericStub { /** The pods' context. */ 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 index a847d36..9c1c086 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for secrets (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1SecretStub extends K8sGenericStub { public static final APIResource CONTEXT = new APIResource("", List.of("v1"), 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 index 2157a1d..863f86f 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java @@ -29,7 +29,6 @@ import java.util.List; /** * A stub for secrets (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1ServiceStub extends K8sGenericStub { public static final APIResource CONTEXT = new APIResource("", List.of("v1"), 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 index b918725..be30b00 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java @@ -26,7 +26,6 @@ import java.util.List; /** * A stub for stateful sets (v1). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class K8sV1StatefulSetStub extends K8sGenericStub { 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/VmDefinitionModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java deleted file mode 100644 index fa59c82..0000000 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModel.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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 com.google.gson.JsonPrimitive; -import java.util.Collection; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; -import org.jdrupes.vmoperator.util.GsonPtr; - -/** - * Represents a VM definition. - */ -@SuppressWarnings("PMD.DataClass") -public class VmDefinitionModel extends K8sDynamicModel { - - /** - * Permissions for accessing and manipulating the VM. - */ - public enum Permission { - START("start"), STOP("stop"), ACCESS_CONSOLE("accessConsole"); - - @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)); - } - - @Override - public String toString() { - return repr; - } - } - - /** - * Instantiates a new model from the JSON representation. - * - * @param delegate the gson instance to use for extracting structured data - * @param json the JSON - */ - public VmDefinitionModel(Gson delegate, JsonObject json) { - super(delegate, json); - } - - /** - * 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 GsonPtr.to(data()) - .getAsListOf(JsonObject.class, "spec", "permissions") - .stream().filter(p -> GsonPtr.to(p).getAsString("user") - .map(u -> u.equals(user)).orElse(false) - || GsonPtr.to(p).getAsString("role").map(roles::contains) - .orElse(false)) - .map(p -> GsonPtr.to(p).getAsListOf(JsonPrimitive.class, "may") - .stream()) - .flatMap(Function.identity()).map(p -> p.getAsString()) - .map(Permission::parse).map(Set::stream) - .flatMap(Function.identity()).collect(Collectors.toSet()); - } - - /** - * Get the display password serial. - * - * @return the optional - */ - public Optional displayPasswordSerial() { - return GsonPtr.to(status()) - .get(JsonPrimitive.class, "displayPasswordSerial") - .map(JsonPrimitive::getAsLong); - } -} 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 index 49da3e0..377220a 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java @@ -31,12 +31,11 @@ import java.util.Collection; * state and can therefore be used for any kind of object, especially * custom objects. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class VmDefinitionStub - extends K8sDynamicStubBase { + extends K8sDynamicStubBase { - private static DynamicTypeAdapterFactory taf = new VmDefintionModelTypeAdapterFactory(); + private static DynamicTypeAdapterFactory taf = new VmDefintionModelTypeAdapterFactory(); /** * Instantiates a new stub for VM defintions. @@ -48,7 +47,7 @@ public class VmDefinitionStub */ public VmDefinitionStub(K8sClient client, APIResource context, String namespace, String name) { - super(VmDefinitionModel.class, VmDefinitionModels.class, taf, client, + super(VmDefinition.class, VmDefinitions.class, taf, client, context, namespace, name); } @@ -64,8 +63,6 @@ public class VmDefinitionStub * @return the stub if the object exists * @throws ApiException the api exception */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static VmDefinitionStub get(K8sClient client, GroupVersionKind gvk, String namespace, String name) throws ApiException { @@ -83,8 +80,6 @@ public class VmDefinitionStub * @return the stub if the object exists * @throws ApiException the api exception */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" }) public static VmDefinitionStub get(K8sClient client, APIResource context, String namespace, String name) { return new VmDefinitionStub(client, context, namespace, name); @@ -101,10 +96,10 @@ public class VmDefinitionStub */ public static VmDefinitionStub createFromYaml(K8sClient client, APIResource context, Reader yaml) throws ApiException { - var model = new VmDefinitionModel(client.getJSON().getGson(), + var model = new VmDefinition(client.getJSON().getGson(), K8s.yamlToJson(client, yaml)); - return K8sGenericStub.create(VmDefinitionModel.class, - VmDefinitionModels.class, client, context, model, + return K8sGenericStub.create(VmDefinition.class, + VmDefinitions.class, client, context, model, (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); } @@ -121,8 +116,8 @@ public class VmDefinitionStub public static Collection list(K8sClient client, APIResource context, String namespace, ListOptions options) throws ApiException { - return K8sGenericStub.list(VmDefinitionModel.class, - VmDefinitionModels.class, client, context, namespace, options, + return K8sGenericStub.list(VmDefinition.class, + VmDefinitions.class, client, context, namespace, options, (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); } @@ -144,13 +139,13 @@ public class VmDefinitionStub * A factory for creating VmDefinitionModel(s) objects. */ public static class VmDefintionModelTypeAdapterFactory extends - DynamicTypeAdapterFactory { + DynamicTypeAdapterFactory { /** * Instantiates a new dynamic model type adapter factory. */ public VmDefintionModelTypeAdapterFactory() { - super(VmDefinitionModel.class, VmDefinitionModels.class); + super(VmDefinition.class, VmDefinitions.class); } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java similarity index 79% rename from org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java rename to org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java index 5ac412f..c79654e 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionModels.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java @@ -22,10 +22,10 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; /** - * Represents a list of {@link VmDefinitionModel}s. + * Represents a list of {@link VmDefinition}s. */ -public class VmDefinitionModels - extends K8sDynamicModelsBase { +public class VmDefinitions + extends K8sDynamicModelsBase { /** * Initialize the object list using the given JSON data. @@ -33,7 +33,7 @@ public class VmDefinitionModels * @param delegate the gson instance to use for extracting structured data * @param data the data */ - public VmDefinitionModels(Gson delegate, JsonObject data) { - super(VmDefinitionModel.class, delegate, 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/build.gradle b/org.jdrupes.vmoperator.manager.events/build.gradle index 566e200..bb4b8d8 100644 --- a/org.jdrupes.vmoperator.manager.events/build.gradle +++ b/org.jdrupes.vmoperator.manager.events/build.gradle @@ -9,7 +9,5 @@ plugins { } dependencies { - api 'org.jgrapes:org.jgrapes.core:[1.19.0,2)' api project(':org.jdrupes.vmoperator.common') - api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]' } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java similarity index 50% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java index 37eddec..7252c6a 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplayPassword.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java @@ -18,45 +18,43 @@ package org.jdrupes.vmoperator.manager.events; -import java.util.Optional; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; import org.jgrapes.core.Event; /** - * Gets the current display secret and optionally updates it. + * Assign a VM from a pool to a user. */ -@SuppressWarnings("PMD.DataClass") -public class GetDisplayPassword extends Event { +public class AssignVm extends Event { - private final VmDefinitionModel vmDef; + private final String fromPool; + private final String toUser; /** - * Instantiates a new returns the display secret. + * Instantiates a new event. * - * @param vmDef the vm name + * @param fromPool the from pool + * @param toUser the to user */ - public GetDisplayPassword(VmDefinitionModel vmDef) { - this.vmDef = vmDef; + public AssignVm(String fromPool, String toUser) { + this.fromPool = fromPool; + this.toUser = toUser; } /** - * Gets the vm definition. + * Gets the pool to assign from. * - * @return the vm definition + * @return the pool */ - public VmDefinitionModel vmDefinition() { - return vmDef; + public String fromPool() { + return fromPool; } /** - * Return the password. May only be called when the event is completed. + * Gets the user to assign to. * - * @return the optional + * @return the to user */ - public Optional password() { - if (!isDone()) { - throw new IllegalStateException("Event is not done."); - } - return currentResults().stream().findFirst(); + 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 index eb27ea0..da36123 100644 --- 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 @@ -27,53 +27,24 @@ import java.util.function.Function; import org.jgrapes.core.Channel; /** - * A channel manager that maintains mappings from a key to a channel. - * As a convenience, it is possible to additionally associate arbitrary - * data with the entry (and thus with the channel). + * Provides an actively managed implementation of the {@link ChannelDictionary}. * - * The manager should be used by a component that defines channels for - * housekeeping. It can be shared between this component and another - * component, preferably using the {@link #fixed()} view for the - * second component. Alternatively, the second component can use a - * {@link ChannelCache} to track the mappings using events. + * 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 { +public class ChannelManager + implements ChannelDictionary { - private final Map> channels = new ConcurrentHashMap<>(); + private final Map> entries = new ConcurrentHashMap<>(); private final Function supplier; - private ChannelManager readOnly; - - /** - * Combines the channel and the associated data. - * - * @param the generic type - * @param the generic type - */ - @SuppressWarnings("PMD.ShortClassName") - public static class Both { - - /** The channel. */ - public C channel; - - /** The associated. */ - public A associated; - - /** - * Instantiates a new both. - * - * @param channel the channel - * @param associated the associated - */ - public Both(C channel, A associated) { - super(); - this.channel = channel; - this.associated = associated; - } - } /** * Instantiates a new channel manager. @@ -91,6 +62,26 @@ public class 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. @@ -98,10 +89,8 @@ public class ChannelManager { * @param key the key * @return the result */ - public Optional> both(K key) { - synchronized (channels) { - return Optional.ofNullable(channels.get(key)); - } + public Optional> value(K key) { + return Optional.ofNullable(entries.get(key)); } /** @@ -113,7 +102,7 @@ public class ChannelManager { * @return the channel manager */ public ChannelManager put(K key, C channel, A associated) { - channels.put(key, new Both<>(channel, associated)); + entries.put(key, new Value<>(channel, associated)); return this; } @@ -130,14 +119,15 @@ public class ChannelManager { } /** - * Returns the channel registered for the key or an empty optional - * if no mapping exists. + * 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 optional + * @return the c */ - public Optional channel(K key) { - return both(key).map(b -> b.channel); + public C createChannel(K key) { + return supplier.apply(key); } /** @@ -147,8 +137,8 @@ public class ChannelManager { * @param key the key * @return the channel */ - public Optional getChannel(K key) { - return getChannel(key, supplier); + public C channelGet(K key) { + return computeIfAbsent(key, supplier); } /** @@ -159,19 +149,9 @@ public class ChannelManager { * @param supplier the supplier * @return the channel */ - @SuppressWarnings({ "PMD.AssignmentInOperand", - "PMD.DataflowAnomalyAnalysis" }) - public Optional getChannel(K key, Function supplier) { - synchronized (channels) { - return Optional - .of(Optional.ofNullable(channels.get(key)) - .map(v -> v.channel) - .orElseGet(() -> { - var channel = supplier.apply(key); - channels.put(key, new Both<>(channel, null)); - return channel; - })); - } + public C computeIfAbsent(K key, Function supplier) { + return entries.computeIfAbsent(key, + k -> new Value<>(supplier.apply(k), null)).channel(); } /** @@ -183,121 +163,17 @@ public class ChannelManager { * @return the channel manager */ public ChannelManager associate(K key, A data) { - synchronized (channels) { - Optional.ofNullable(channels.get(key)) - .ifPresent(v -> v.associated = data); - } + Optional.ofNullable(entries.computeIfPresent(key, + (k, existing) -> new Value<>(existing.channel(), data))); return this; } - /** - * Return the data associated with the entry for the channel. - * - * @param key the key - * @return the data - */ - public Optional associated(K key) { - return both(key).map(b -> b.associated); - } - - /** - * Returns all associated data. - * - * @return the collection - */ - public Collection associated() { - synchronized (channels) { - return channels.values().stream() - .filter(v -> v.associated != null) - .map(v -> v.associated).toList(); - } - } - /** * Removes the channel with the given name. * * @param name the name */ public void remove(String name) { - synchronized (channels) { - channels.remove(name); - } - } - - /** - * Returns all known keys. - * - * @return the sets the - */ - public Set keys() { - return channels.keySet(); - } - - /** - * Returns a read only view of this channel manager. The methods - * that usually create a new entry refrain from doing so. The - * methods that change the value of channel and {@link #remove(String)} - * do nothing. The associated data, however, can still be changed. - * - * @return the channel manager - */ - public ChannelManager fixed() { - if (readOnly == null) { - readOnly = new ChannelManager<>(supplier) { - - @Override - public Optional> both(K key) { - return ChannelManager.this.both(key); - } - - @Override - public ChannelManager put(K key, C channel, - A associated) { - return associate(key, associated); - } - - @Override - public Optional getChannel(K key) { - return ChannelManager.this.channel(key); - } - - @Override - public Optional getChannel(K key, Function supplier) { - return ChannelManager.this.channel(key); - } - - @Override - public ChannelManager associate(K key, A data) { - return ChannelManager.this.associate(key, data); - } - - @Override - public Optional associated(K key) { - return ChannelManager.this.associated(key); - } - - @Override - public Collection associated() { - return ChannelManager.this.associated(); - } - - @Override - public void remove(String name) { - // Do nothing - } - - @Override - public Set keys() { - return ChannelManager.this.keys(); - } - - @Override - public ChannelManager fixed() { - return ChannelManager.this.fixed(); - } - - }; - } - return readOnly; + entries.remove(name); } } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelCache.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java similarity index 52% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelCache.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java index 1e6d031..8a41908 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelCache.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java @@ -19,6 +19,7 @@ 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; @@ -27,20 +28,30 @@ import java.util.concurrent.ConcurrentHashMap; import org.jgrapes.core.Channel; /** - * A channel manager that tracks mappings from a key to a channel using - * "add/remove" (or "open/close") events and the channels on which they - * are delivered. + * 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 ChannelCache { +public class ChannelTracker + implements ChannelDictionary { - private final Map> channels = new ConcurrentHashMap<>(); + private final Map> entries = new ConcurrentHashMap<>(); /** - * Helper + * Combines the channel and associated data. + * + * @param the generic type + * @param the generic type */ @SuppressWarnings("PMD.ShortClassName") private static class Data { @@ -57,32 +68,24 @@ public class ChannelCache { } } - /** - * Combines the channel and the associated data. - * - * @param the generic type - * @param the generic type - */ - @SuppressWarnings("PMD.ShortClassName") - public static class Both { + @Override + public Set keys() { + return entries.keySet(); + } - /** The channel. */ - public C channel; - - /** The associated. */ - public A associated; - - /** - * Instantiates a new both. - * - * @param channel the channel - * @param associated the associated - */ - public Both(C channel, A associated) { - super(); - this.channel = channel; - this.associated = associated; + @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; } /** @@ -92,20 +95,18 @@ public class ChannelCache { * @param key the key * @return the result */ - public Optional> both(K key) { - synchronized (channels) { - var value = channels.get(key); - if (value == null) { - return Optional.empty(); - } - var channel = value.channel.get(); - if (channel == null) { - // Cleanup old reference - channels.remove(key); - return Optional.empty(); - } - return Optional.of(new Both<>(channel, value.associated)); + 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)); } /** @@ -116,10 +117,10 @@ public class ChannelCache { * @param associated the associated * @return the channel manager */ - public ChannelCache put(K key, C channel, A associated) { + public ChannelTracker put(K key, C channel, A associated) { Data data = new Data<>(channel); data.associated = associated; - channels.put(key, data); + entries.put(key, data); return this; } @@ -130,22 +131,11 @@ public class ChannelCache { * @param channel the channel * @return the channel manager */ - public ChannelCache put(K key, C channel) { + public ChannelTracker put(K key, C channel) { put(key, channel, null); return this; } - /** - * Returns the channel registered for the key or an empty optional - * if no mapping exists. - * - * @param key the key - * @return the optional - */ - public Optional channel(K key) { - return both(key).map(b -> b.channel); - } - /** * Associate the entry for the channel with the given data. The entry * for the channel must already exist. @@ -154,54 +144,18 @@ public class ChannelCache { * @param data the data * @return the channel manager */ - public ChannelCache associate(K key, A data) { - synchronized (channels) { - Optional.ofNullable(channels.get(key)) - .ifPresent(v -> v.associated = data); - } + public ChannelTracker associate(K key, A data) { + Optional.ofNullable(entries.get(key)) + .ifPresent(v -> v.associated = data); return this; } - /** - * Return the data associated with the entry for the channel. - * - * @param key the key - * @return the data - */ - public Optional associated(K key) { - return both(key).map(b -> b.associated); - } - - /** - * Returns all associated data. - * - * @return the collection - */ - public Collection associated() { - synchronized (channels) { - return channels.values().stream() - .filter(v -> v.channel.get() != null && v.associated != null) - .map(v -> v.associated).toList(); - } - } - /** * Removes the channel with the given name. * * @param name the name */ public void remove(String name) { - synchronized (channels) { - channels.remove(name); - } - } - - /** - * Returns all known keys. - * - * @return the sets the - */ - public Set keys() { - return channels.keySet(); + entries.remove(name); } } 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/ServiceChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java similarity index 65% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ServiceChanged.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java index a8008e0..8bbcfe8 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ServiceChanged.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2024 Michael N. Lipp + * 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 @@ -18,30 +18,38 @@ package org.jdrupes.vmoperator.manager.events; -import io.kubernetes.client.openapi.models.V1Service; -import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +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 that a service has changed. + * Indicates a change in a pod that runs a VM. */ -@SuppressWarnings("PMD.DataClass") -public class ServiceChanged extends Event { +public class PodChanged extends Event { - private final ResponseType type; - private final V1Service service; + private final V1Pod pod; + private final K8sObserver.ResponseType type; /** - * Initializes a new service changed event. + * Instantiates a new VM changed event. * + * @param pod the pod * @param type the type - * @param service the service */ - public ServiceChanged(ResponseType type, V1Service service) { + public PodChanged(V1Pod pod, K8sObserver.ResponseType type) { + this.pod = pod; this.type = type; - this.service = service; + } + + /** + * Gets the pod. + * + * @return the pod + */ + public V1Pod pod() { + return pod; } /** @@ -49,24 +57,15 @@ public class ServiceChanged extends Event { * * @return the type */ - public ResponseType type() { + public K8sObserver.ResponseType type() { return type; } - /** - * Gets the service. - * - * @return the service - */ - public V1Service service() { - return service; - } - @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append(Components.objectName(this)).append(" [") - .append(service.getMetadata().getName()).append(' ').append(type); + .append(pod.getMetadata().getName()).append(' ').append(type); if (channels() != null) { builder.append(", channels=").append(Channel.toString(channels())); } 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 46861ce..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 @@ -19,20 +19,20 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +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 K8sClient client; - private VmDefinitionModel vmDefinition; + private VmDefinition definition; private long generation = -1; /** @@ -55,19 +55,18 @@ public class VmChannel extends DefaultSubchannel { * @param definition the definition * @return the watch channel */ - @SuppressWarnings("PMD.LinguisticNaming") - public VmChannel setVmDefinition(VmDefinitionModel 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 VmDefinitionModel 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,6 +102,19 @@ 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. * 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 64% 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 a2bafb7..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 @@ -19,37 +19,41 @@ package org.jdrupes.vmoperator.manager.events; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +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 { private final K8sObserver.ResponseType type; + private final VmDefinition vmDefinition; private final boolean specChanged; - private final VmDefinitionModel vmDef; + private final boolean podChanged; /** * Instantiates a new VM changed event. * * @param type the type - * @param specChanged the spec part changed * @param vmDefinition the VM definition + * @param specChanged the spec part changed */ - public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged, - VmDefinitionModel vmDefinition) { + public VmResourceChanged(K8sObserver.ResponseType type, + VmDefinition vmDefinition, boolean specChanged, + boolean podChanged) { this.type = type; + this.vmDefinition = vmDefinition; this.specChanged = specChanged; - this.vmDef = vmDefinition; + this.podChanged = podChanged; } /** @@ -61,6 +65,15 @@ public class VmDefChanged extends Event { return type; } + /** + * Return the VM definition. + * + * @return the VM definition + */ + public VmDefinition vmDefinition() { + return vmDefinition; + } + /** * Indicates if the "spec" part changed. */ @@ -69,19 +82,17 @@ public class VmDefChanged extends Event { } /** - * Returns the object. - * - * @return the object. + * Indicates if the pod status changed. */ - public VmDefinitionModel 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=").append(Channel.toString(channels())); } 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/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index a8b67a0..4ce4ed0 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -13,15 +13,14 @@ 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.34.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.5.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.5.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.3.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.3.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.sysinfo:[1.4.0,2)' @@ -32,8 +31,8 @@ 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') - runtimeOnly project(':org.jdrupes.vmoperator.vmviewer') + runtimeOnly project(':org.jdrupes.vmoperator.vmmgmt') + runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') } application { @@ -45,75 +44,69 @@ 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', '--pull', - '-t', "${project.name}:${project.version}",\ + '-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") \ - || project.rootProject.properties['docker.testRegistry'] \ - && project.rootProject.properties['docker.registry'] \ - == project.rootProject.properties['docker.testRegistry'] - - 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}" +} + +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', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}:latest" + 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 index 50a3024..b6b9efa 100644 --- 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 @@ -1,5 +1,5 @@ -You can use the "puzzle piece" icon on the top right corner of the +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. \ No newline at end of file +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 index e5e4d68..bec5f3e 100644 --- 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 @@ -1,4 +1,4 @@ -Verwenden Sie das "Puzzle"-Icon auf der rechten oberen Ecke +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 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/logging.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties index 9e6d0f5..2a16af6 100644 --- a/org.jdrupes.vmoperator.manager/resources/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 7679a68..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,135 +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: ${ cr.spec.guestShutdownStops!false?c } + 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 cr.spec.cloudInit??> + <#if spec.cloudInit??> cloudInit: - <#if cr.spec.cloudInit.metaData??> - metaData: ${ cr.spec.cloudInit.metaData.toString() } - <#else> - metaData: {} - - <#if cr.spec.cloudInit.userData??> - userData: ${ cr.spec.cloudInit.userData.toString() } + metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData!{}, cr.metadata())) } + <#if spec.cloudInit.userData??> + userData: ${ toJson(spec.cloudInit.userData) } <#else> userData: {} - <#if cr.spec.cloudInit.networkConfig??> - networkConfig: ${ cr.spec.cloudInit.networkConfig.toString() } + <#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" @@ -141,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/> @@ -169,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> @@ -181,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??> - streaming-video: "${ 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 3d4a316..0000000 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml +++ /dev/null @@ -1,194 +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) } - updateStrategy: - type: OnDelete - 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 - projected: - sources: - - configMap: - name: ${ cr.metadata.name.asString } - <#if displaySecret??> - - secret: - name: ${ displaySecret } - - - 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 index e78a5e0..c10752e 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java @@ -27,14 +27,11 @@ 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.Optional; 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.common.K8sObserver.ResponseType; -import org.jdrupes.vmoperator.manager.events.ChannelManager; import org.jdrupes.vmoperator.manager.events.Exit; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -45,12 +42,15 @@ import org.jgrapes.core.events.Stop; import org.jgrapes.util.events.ConfigurationUpdate; /** - * A base class for monitoring VM related resources. + * 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 */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis" }) public abstract class AbstractMonitor extends Component { @@ -61,16 +61,17 @@ public abstract class AbstractMonitor channelManager; - private boolean channelManagerMaster; /** * 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) { + protected AbstractMonitor(Channel componentChannel, + Class objectClass, Class objectListClass) { super(componentChannel); this.objectClass = objectClass; this.objectListClass = objectListClass; @@ -156,27 +157,6 @@ public abstract class AbstractMonitor channelManager() { - return channelManager; - } - - /** - * Sets the channel manager. - * - * @param channelManager the channel manager - * @return the abstract monitor - */ - public AbstractMonitor - channelManager(ChannelManager channelManager) { - this.channelManager = channelManager; - return this; - } - /** * Looks for a key "namespace" in the configuration and, if found, * sets the namespace to its value. @@ -194,13 +174,12 @@ public abstract class AbstractMonitor "Observing " + K8s.toString(context) - + " objects in " + namespace); // Monitor all versions for (var version : context.getVersions()) { @@ -238,13 +215,7 @@ public abstract class AbstractMonitor(objectClass, objectListClass, client, K8s.preferred(context, version), namespace, options) - .handler((c, r) -> { - handleChange(c, r); - if (ResponseType.valueOf(r.type) == ResponseType.DELETED - && channelManagerMaster) { - channelManager.remove(r.object.getMetadata().getName()); - } - }).onTerminated((o, t) -> { + .handler(this::handleChange).onTerminated((o, t) -> { if (observerCounter.decrementAndGet() == 0) { unregisterAsGenerator(); } @@ -257,7 +228,8 @@ public abstract class AbstractMonitor change); - - /** - * Returns the {@link Channel} for the given name. - * - * @param name the name - * @return the channel used for events related to the specified object - */ - protected Optional channel(String name) { - return channelManager.getChannel(name); - } } 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 a882a79..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) { } /** @@ -135,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/Constants.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java index 7de839b..2ef4199 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java @@ -21,18 +21,8 @@ package org.jdrupes.vmoperator.manager; /** * Some constants. */ -@SuppressWarnings("PMD.DataClass") public class Constants extends org.jdrupes.vmoperator.common.Constants { - /** The Constant COMP_DISPLAY_SECRET. */ - public static final String COMP_DISPLAY_SECRET = "display-secret"; - - /** The Constant DATA_DISPLAY_PASSWORD. */ - public static final String DATA_DISPLAY_PASSWORD = "display-password"; - - /** The Constant DATA_PASSWORD_EXPIRY. */ - public static final String DATA_PASSWORD_EXPIRY = "password-expiry"; - /** The Constant STATE_RUNNING. */ public static final String STATE_RUNNING = "Running"; 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 66c11a7..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,25 +18,39 @@ 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.openapi.Configuration; 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.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; import org.jdrupes.vmoperator.common.K8sClient; -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.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.PodChanged; +import org.jdrupes.vmoperator.manager.events.UpdateAssignment; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +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; @@ -48,7 +62,7 @@ import org.jgrapes.util.events.ConfigurationUpdate; * * The implementation splits the controller in two components. The * {@link VmMonitor} and the {@link Reconciler}. The former watches - * the VM definitions (CRs) and generates {@link VmDefChanged} events + * the VM definitions (CRs) and generates {@link VmResourceChanged} events * when they change. The latter handles the changes and reconciles the * resources in the cluster. * @@ -81,6 +95,7 @@ import org.jgrapes.util.events.ConfigurationUpdate; public class Controller extends Component { private String namespace; + private final ChannelManager chanMgr; /** * Creates a new instance. @@ -89,24 +104,24 @@ public class Controller extends Component { public Controller(Channel componentChannel) { super(componentChannel); // Prepare component tree - ChannelManager 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()).channelManager(chanMgr)); - attach(new DisplaySecretMonitor(channel()) - .channelManager(chanMgr.fixed())); + 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)); } /** @@ -168,41 +183,146 @@ public class Controller extends Component { 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 onModifyVm(ModifyVm event, VmChannel channel) - throws ApiException, IOException { - patchVmSpec(channel.client(), 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(K8sClient client, String name, String path, - Object value) - throws ApiException, IOException { - var vmStub = K8sDynamicStub.get(client, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, - name); + 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; + } + }; - // Patch running - String valueAsText = value instanceof String - ? "\"" + value + "\"" - : value.toString(); - var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, - new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/" - + path + "\", \"value\": " + valueAsText + "}]"), - client.defaultPatchOptions()); - if (!res.isPresent()) { - logger.warning( - () -> "Cannot patch definition for Vm " + vmStub.name()); + /** + * 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; + } + 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 index 8bc1db0..b094b79 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2024 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 Affero General Public License as @@ -26,79 +26,44 @@ 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.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.Scanner; 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 static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; -import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; +import org.jdrupes.vmoperator.manager.events.ChannelDictionary; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; import org.jgrapes.core.Channel; -import org.jgrapes.core.CompletionLock; -import org.jgrapes.core.Event; -import org.jgrapes.core.annotation.Handler; -import org.jgrapes.util.events.ConfigurationUpdate; -import org.jose4j.base64url.Base64; /** - * Watches for changes of display secrets. + * 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. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" }) public class DisplaySecretMonitor extends AbstractMonitor { - private int passwordValidity = 10; - private final List pendingGets - = Collections.synchronizedList(new LinkedList<>()); + private final ChannelDictionary channelDictionary; /** * Instantiates a new display secrets monitor. * * @param componentChannel the component channel + * @param channelDictionary the channel dictionary */ - public DisplaySecretMonitor(Channel componentChannel) { + 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=" + COMP_DISPLAY_SECRET); + + "app.kubernetes.io/component=" + DisplaySecret.NAME); options(options); } - /** - * On configuration update. - * - * @param event the event - */ - @Handler - @Override - public void onConfigurationUpdate(ConfigurationUpdate event) { - super.onConfigurationUpdate(event); - event.structured(componentPath()).ifPresent(c -> { - try { - if (c.containsKey("passwordValidity")) { - passwordValidity = Integer - .parseInt((String) c.get("passwordValidity")); - } - } catch (ClassCastException e) { - logger.config("Malformed configuration: " + e.getMessage()); - } - }); - } - @Override protected void prepareMonitoring() throws IOException, ApiException { client(new K8sClient()); @@ -111,7 +76,7 @@ public class DisplaySecretMonitor if (vmName == null) { return; } - var channel = channel(vmName).orElse(null); + var channel = channelDictionary.channel(vmName).orElse(null); if (channel == null || channel.vmDefinition() == null) { return; } @@ -153,133 +118,4 @@ public class DisplaySecretMonitor + "\"}]"), patchOpts); } - - /** - * On get display secrets. - * - * @param event the event - * @param channel the channel - * @throws ApiException the api exception - */ - @Handler - @SuppressWarnings("PMD.StringInstantiation") - public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel) - throws ApiException { - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" - + event.vmDefinition().metadata().getName()); - var stubs = K8sV1SecretStub.list(client(), - event.vmDefinition().metadata().getNamespace(), options); - if (stubs.isEmpty()) { - return; - } - var stub = stubs.iterator().next(); - - // Check validity - var model = stub.model().get(); - @SuppressWarnings("PMD.StringInstantiation") - var expiry = new String(model.getData().get(DATA_PASSWORD_EXPIRY)); - if (model.getData().get(DATA_DISPLAY_PASSWORD) != null - && stillValid(expiry)) { - event.setResult( - new String(model.getData().get(DATA_DISPLAY_PASSWORD))); - return; - } - updatePassword(stub, event); - } - - @SuppressWarnings("PMD.StringInstantiation") - private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event) - throws ApiException { - 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); - var model = stub.model().get(); - model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, - Long.toString(Instant.now().getEpochSecond() + passwordValidity))); - event.setResult(password); - - // Prepare wait for confirmation (by VM status change) - var pending = new PendingGet(event, - event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, - new CompletionLock(event, 1500)); - pendingGets.add(pending); - Event.onCompletion(event, e -> { - pendingGets.remove(pending); - }); - - // Update, will (eventually) trigger confirmation - stub.update(model).getObject(); - } - - 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 - public void onVmDefChanged(VmDefChanged event, Channel channel) { - synchronized (pendingGets) { - String vmName = event.vmDefinition().metadata().getName(); - for (var pending : pendingGets) { - if (pending.event.vmDefinition().metadata().getName() - .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. - */ - @SuppressWarnings("PMD.DataClass") - private static class PendingGet { - public final GetDisplayPassword event; - public final long expectedSerial; - public final CompletionLock lock; - - /** - * Instantiates a new pending get. - * - * @param event the event - * @param expectedSerial the expected serial - */ - public PendingGet(GetDisplayPassword 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/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java index 14b8890..1e3eb0f 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -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 Affero General Public License as @@ -18,8 +18,9 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonPrimitive; +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; @@ -27,66 +28,143 @@ 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 static org.jdrupes.vmoperator.manager.Constants.APP_NAME; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; -import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD; -import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY; +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.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; +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; /** - * Delegee for reconciling the display secret + * 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. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -/* default */ class DisplaySecretReconciler { +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<>()); /** - * Reconcile. + * 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(VmDefChanged event, - Map model, VmChannel channel) + 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 = GsonPtr.to(event.vmDefinition().data()).to("spec", "vm", - "display"); - if (!display.get(JsonPrimitive.class, "spice", "generateSecret") - .map(JsonPrimitive::getAsBoolean).orElse(false)) { + var display = vmDef.fromVm("display").get(); + if (!DataPath. get(display, "spice", "generateSecret") + .orElse(true)) { return; } // Check if exists - var metadata = event.vmDefinition().getMetadata(); ListOptions options = new ListOptions(); options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + metadata.getName()); - var stubs = K8sV1SecretStub.list(channel.client(), - metadata.getNamespace(), options); + + "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(metadata.getNamespace()) - .name(metadata.getName() + "-" + COMP_DISPLAY_SECRET) + secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) + .name(secretName) .putLabelsItem("app.kubernetes.io/name", APP_NAME) - .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET) - .putLabelsItem("app.kubernetes.io/instance", metadata.getName())); + .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) + .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); secret.setType("Opaque"); SecureRandom random = null; try { @@ -98,9 +176,167 @@ import org.jose4j.base64url.Base64; byte[] bytes = new byte[16]; random.nextBytes(bytes); var password = Base64.encode(bytes); - secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password, - DATA_PASSWORD_EXPIRY, "now")); + 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 85158c7..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.K8sDynamicModel; +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,18 +68,24 @@ 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 { + // Nothing to do unless spec changed + if (!specChanged) { + return; + } + // Check if to be generated - @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" }) + @SuppressWarnings({ "unchecked" }) var lbsDef = Optional.of(model) .map(m -> (Map) m.get("reconciler")) .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE); @@ -92,14 +98,17 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; if (lbsDef instanceof Boolean isOn && !isOn) { return; } - JsonObject cfgMeta = new JsonObject(); - if (lbsDef instanceof Map) { - var json = channel.client().getJSON(); - cfgMeta - = json.deserialize(json.serialize(lbsDef), JsonObject.class); + + // 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); @@ -107,53 +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, cfgMeta, event.vmDefinition()); + @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()); + var svcStub = K8sV1ServiceStub + .get(client, vmDef.namespace(), vmDef.name()); + if (svcStub.apply(svcDef).isEmpty()) { + logger.warning( + () -> "Could not patch service for " + svcStub.name()); + } } - private void mergeMetadata(DynamicKubernetesObject svcDef, - JsonObject cfgMeta, K8sDynamicModel vmDefinition) { - // Get metadata from VM definition - var vmMeta = GsonPtr.to(vmDefinition.data()).to("spec") - .get(JsonObject.class, LOAD_BALANCER_SERVICE) - .map(JsonObject::deepCopy).orElseGet(() -> new JsonObject()); + 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 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)); + // 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 void mergeReplace(JsonObject dest, JsonObject src) { + 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 9d291cf..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 @@ -81,7 +81,7 @@ import org.jgrapes.webconsole.vuejs.VueJsConsoleWeblet; /** * The application class. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) +@SuppressWarnings({ "PMD.ExcessiveImports" }) public class Manager extends Component { private static String version; @@ -97,8 +97,8 @@ public class Manager extends Component { * @throws IOException Signals that an I/O exception has occurred. * @throws URISyntaxException */ - @SuppressWarnings({ "PMD.TooFewBranchesForASwitchStatement", - "PMD.NcssCount", "PMD.ConstructorCallsOverridableMethod" }) + @SuppressWarnings({ "PMD.NcssCount", + "PMD.ConstructorCallsOverridableMethod" }) public Manager(CommandLine cmdLine) throws IOException, URISyntaxException { super(new NamedChannel("manager")); // Prepare component tree @@ -217,7 +217,6 @@ public class Manager extends Component { * @param event the event */ @Handler - @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { if (c.containsKey("clusterName")) { @@ -264,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 { @@ -291,7 +290,6 @@ 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. 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 5bbfe38..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,43 +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 io.kubernetes.client.util.generic.options.ListOptions; 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 static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import java.util.logging.Level; import org.jdrupes.vmoperator.common.Convertions; -import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicModel; import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.K8sV1SecretStub; -import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET; +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.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; @@ -71,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 @@ -127,16 +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 DisplaySecretReconciler dsReconciler; - private final StatefulSetReconciler stsReconciler; + private final PvcReconciler pvcReconciler; + private final PodReconciler podReconciler; private final LoadBalancerReconciler lbReconciler; @SuppressWarnings("PMD.UseConcurrentHashMap") private final Map config = new HashMap<>(); @@ -146,6 +158,7 @@ public class Reconciler extends Component { * * @param componentChannel the component channel */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public Reconciler(Channel componentChannel) { super(componentChannel); @@ -160,8 +173,9 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); - dsReconciler = new DisplaySecretReconciler(); - stsReconciler = new StatefulSetReconciler(fmConfig); + dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); + pvcReconciler = new PvcReconciler(fmConfig); + podReconciler = new PodReconciler(fmConfig); lbReconciler = new LoadBalancerReconciler(fmConfig); } @@ -183,135 +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() == K8sObserver.ResponseType.DELETED) { - logger.fine(() -> "VM \"" + defMeta.getName() + "\" deleted"); return; } - // Reconcile, use "augmented" vm definition for model + // Create model for processing templates + var vmDef = event.vmDefinition(); + Map model = prepareModel(vmDef); + cmReconciler.reconcile(model, channel, event.specChanged()); + + // The remaining reconcilers depend only on changes of the spec part + // or the pod state. + if (!event.specChanged() && !event.podChanged()) { + return; + } + 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()); + } + + /** + * 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.client(), patchCr(event.vmDefinition())); - var configMap = cmReconciler.reconcile(event, model, channel); - model.put("cm", configMap.getRaw()); - dsReconciler.reconcile(event, model, channel); - stsReconciler.reconcile(event, model, channel); - lbReconciler.reconcile(event, model, channel); + = prepareModel(channel.vmDefinition()); + cmReconciler.reconcile(model, channel, true); } - private DynamicKubernetesObject patchCr(K8sDynamicModel vmDef) { - var json = vmDef.data().deepCopy(); - // Adjust cdromImage path - adjustCdRomPaths(json); - - // Adjust cloud-init data - adjustCloudInitData(json); - - return new DynamicKubernetesObject(json); - } - - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - private void adjustCdRomPaths(JsonObject json) { - 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); - } - } - } - - private void adjustCloudInitData(JsonObject json) { - var spec = GsonPtr.to(json).to("spec").get(JsonObject.class); - if (!spec.has("cloudInit")) { - return; - } - var metaData = GsonPtr.to(spec).to("cloudInit", "metaData"); - if (metaData.getAsString("instance-id").isEmpty()) { - metaData.set("instance-id", - GsonPtr.to(json).getAsString("metadata", "resourceVersion") - .map(s -> "v" + s).orElse("v1")); - } - if (metaData.getAsString("local-hostname").isEmpty()) { - metaData.set("local-hostname", - GsonPtr.to(json).getAsString("metadata", "name").get()); - } - } - - @SuppressWarnings("PMD.CognitiveComplexity") - private Map prepareModel(K8sClient client, - DynamicKubernetesObject vmDef) + 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); - - // Check if we have a display secret - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," - + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + "," - + "app.kubernetes.io/instance=" + vmDef.getMetadata().getName()); - var dsStub = K8sV1SecretStub - .list(client, vmDef.getMetadata().getNamespace(), options).stream() - .findFirst(); - if (dsStub.isPresent()) { - dsStub.get().model().ifPresent(m -> { - model.put("displaySecret", m.getMetadata().getName()); - }); - } + 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(); @@ -320,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); @@ -348,7 +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/ServiceMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ServiceMonitor.java deleted file mode 100644 index bd5635e..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ServiceMonitor.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.openapi.ApiException; -import io.kubernetes.client.openapi.models.V1Service; -import io.kubernetes.client.openapi.models.V1ServiceList; -import io.kubernetes.client.util.Watch.Response; -import io.kubernetes.client.util.generic.options.ListOptions; -import java.io.IOException; -import static org.jdrupes.vmoperator.common.Constants.APP_NAME; -import org.jdrupes.vmoperator.common.K8sClient; -import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; -import org.jdrupes.vmoperator.common.K8sV1ServiceStub; -import org.jdrupes.vmoperator.manager.events.ServiceChanged; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jgrapes.core.Channel; - -/** - * Watches for changes of services. - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class ServiceMonitor - extends AbstractMonitor { - - /** - * Instantiates a new display secrets monitor. - * - * @param componentChannel the component channel - */ - public ServiceMonitor(Channel componentChannel) { - super(componentChannel, V1Service.class, V1ServiceList.class); - context(K8sV1ServiceStub.CONTEXT); - ListOptions options = new ListOptions(); - options.setLabelSelector("app.kubernetes.io/name=" + APP_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 = channel(vmName).orElse(null); - if (channel == null || channel.vmDefinition() == null) { - return; - } - channel.pipeline().fire(new ServiceChanged( - ResponseType.valueOf(change.type), change.object), channel); - } -} 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 baf833c..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.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.K8sV1StatefulSetStub; -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 { - 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 stsStub = K8sV1StatefulSetStub.get(channel.client(), - metadata.getNamespace(), metadata.getName()); - var stsModel = stsStub.model().orElse(null); - if (stsModel != null) { - var current = stsModel.getSpec().getReplicas(); - 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"); - if (stsStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, - new V1Patch(channel.client().getJSON().serialize(stsDef)), opts) - .isEmpty()) { - logger.warning( - () -> "Could not patch stateful set for " + stsStub.name()); - } - } - -} 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 index 41f08ce..22f083c 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023,2024 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,51 +18,76 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonArray; 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.openapi.models.V1ObjectMeta; 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.logging.Level; import java.util.stream.Collectors; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; +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.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; -import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; -import org.jdrupes.vmoperator.common.VmDefinitionModel; -import org.jdrupes.vmoperator.common.VmDefinitionModels; +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_KIND_VM; 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.VmDefChanged; +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. + * 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. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" }) public class VmMonitor extends - AbstractMonitor { + 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) { - super(componentChannel, VmDefinitionModel.class, - VmDefinitionModels.class); + public VmMonitor(Channel componentChannel, + ChannelManager channelManager) { + super(componentChannel, VmDefinition.class, + VmDefinitions.class); + this.channelManager = channelManager; } @Override @@ -70,7 +95,7 @@ public class VmMonitor extends client(new K8sClient()); // Get all our API versions - var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM); + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); if (ctx.isEmpty()) { logger.severe(() -> "Cannot get CRD context."); return; @@ -81,7 +106,6 @@ public class VmMonitor extends purge(); } - @SuppressWarnings("PMD.CognitiveComplexity") private void purge() throws ApiException { // Get existing CRs (VMs) var known = K8sDynamicStub.list(client(), context(), namespace()) @@ -105,13 +129,19 @@ public class VmMonitor extends @Override protected void handleChange(K8sClient client, - Watch.Response response) { - V1ObjectMeta metadata = response.object.getMetadata(); - VmChannel channel = channel(metadata.getName()).orElse(null); - if (channel == null) { - return; - } + 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) { @@ -119,30 +149,39 @@ public class VmMonitor extends // 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 - addDynamicData(channel.client(), vmDef); + addExtraData(vmDef, channel.vmDefinition()); channel.setVmDefinition(vmDef); } else { - // Reuse cached + // Reuse cached (e.g. if deleted) vmDef = channel.vmDefinition(); } if (vmDef == null) { - logger.warning( - () -> "Cannot get model for " + response.object.getMetadata()); + logger.warning(() -> "Cannot get defintion for " + + response.object.getMetadata()); return; } + channelManager.put(name, channel, preparing); - // Create and fire event - channel.pipeline() - .fire(new VmDefChanged(ResponseType.valueOf(response.type), - channel.setGeneration( - response.object.getMetadata().getGeneration()), - vmDef), channel); + // 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 VmDefinitionModel getModel(K8sClient client, - VmDefinitionModel vmDef) { + private VmDefinition getModel(K8sClient client, VmDefinition vmDef) { try { return VmDefinitionStub.get(client, context(), namespace(), vmDef.metadata().getName()).model().orElse(null); @@ -151,47 +190,137 @@ public class VmMonitor extends } } - private void addDynamicData(K8sClient client, K8sDynamicModel vmState) { - var rootNode = GsonPtr.to(vmState.data()).get(JsonObject.class); - rootNode.addProperty("nodeName", ""); - rootNode.addProperty("nodeAddress", ""); + private void addExtraData(VmDefinition vmDef, VmDefinition prevState) { + var extra = new VmExtraData(vmDef); + var prevExtra = Optional.ofNullable(prevState).map(VmDefinition::extra); - // 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) { + // 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; } - var podSearch = new ListOptions(); - podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME - + ",app.kubernetes.io/component=" + APP_NAME - + ",app.kubernetes.io/instance=" + vmState.getMetadata().getName()); - try { - var podList - = K8sV1PodStub.list(client, namespace(), podSearch); - for (var podStub : podList) { - var nodeName = podStub.model().get().getSpec().getNodeName(); - rootNode.addProperty("nodeName", nodeName); - logger.fine(() -> "Added node name " + nodeName - + " to VM info for " + vmState.getMetadata().getName()); - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var addrs = new JsonArray(); - podStub.model().get().getStatus().getPodIPs().stream() - .map(ip -> ip.getIp()).forEach(addrs::add); - rootNode.add("nodeAddresses", addrs); - logger.fine(() -> "Added node addresses " + addrs - + " to VM info for " + vmState.getMetadata().getName()); - } - } catch (ApiException e) { - logger.log(Level.WARNING, e, - () -> "Cannot access node information: " + e.getMessage()); + + // 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/package-info.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java index 54d4efe..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] @@ -147,21 +160,35 @@ * () "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 13a93e1..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,18 +1,32 @@ package org.jdrupes.vmoperator.manager; 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.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; -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.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.K8sV1StatefulSetStub; +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; @@ -26,6 +40,9 @@ class BasicTests { 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 { @@ -35,23 +52,40 @@ class BasicTests { // Get client client = new K8sClient(); + // Update manager pod by scaling deployment + 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, VM_OP_GROUP, null, VM_OP_KIND_VM); + 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-dev", "unittest-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(); - // Update manager pod by scaling deployment - mgrDeployment - = K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator"); - mgrDeployment.scale(0); - mgrDeployment.scale(1); + // 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 (mgrDeployment.model().get().getStatus().getConditions() .stream().filter(c -> "Available".equals(c.getType())).findAny() @@ -63,60 +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 mgrDeployment.scale(0); } @Test - void test() throws IOException, InterruptedException, ApiException { - // Load from Yaml - var rdr = new FileReader("test-resources/unittest-vm.yaml"); - var vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr); - assertTrue(vmStub.model().isPresent()); - - // Wait for created resources - assertTrue(waitForConfigMap(client)); - assertTrue(waitForStatefulSet(client)); - + 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 = K8sV1ConfigMapStub.get(client, "vmop-dev", "unittest-vm") - .model().get(); - var yaml = new Yaml(new SafeConstructor(new LoaderOptions())) + 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); + + 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")); - @SuppressWarnings("unchecked") - var maximumRam = ((Map>>) yaml) - .get("/Runner").get("vm").get("maximumRam"); - assertEquals("4 GiB", maximumRam); - - // Cleanup - K8sDynamicStub.get(client, vmsContext, "vmop-dev", "unittest-vm") - .delete(); + checkProps(cm, toCheck); } - private boolean waitForConfigMap(K8sClient client) - throws InterruptedException, ApiException { - var stub = K8sV1ConfigMapStub.get(client, "vmop-dev", "unittest-vm"); + @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 (stub.model().isPresent()) { - 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(K8sClient client) - throws InterruptedException, ApiException { - var stub = K8sV1StatefulSetStub.get(client, "vmop-dev", "unittest-vm"); + @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 (stub.model().isPresent()) { - return true; + 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/build.gradle b/org.jdrupes.vmoperator.runner.qemu/build.gradle index 82525c6..695c815 100644 --- a/org.jdrupes.vmoperator.runner.qemu/build.gradle +++ b/org.jdrupes.vmoperator.runner.qemu/build.gradle @@ -9,10 +9,10 @@ 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' @@ -31,116 +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', '--pull', - '-t', "${project.name}-arch:${project.version}",\ + '-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', 'push', \ + "${project.name}-arch:${project.gitBranch}",\ + "${registry}/${project.name}-arch:${project.version}" +} + +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', 'tag', "${project.name}-arch:${project.version}",\ - "${project.name}-arch:latest" + commandLine 'podman', 'push', \ + "${project.name}-arch:${project.gitBranch}",\ + "${registry}/${project.name}-arch:latest" } -task buildLatestArchImage { - dependsOn buildArchImage - dependsOn tagLatestArchImage -} - -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") \ - || project.rootProject.properties['docker.testRegistry'] \ - && project.rootProject.properties['docker.registry'] \ - == project.rootProject.properties['docker.testRegistry'] - - 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', '--pull', - '-t', "${project.name}-alpine:${project.version}",\ + '-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', 'push', \ + "${project.name}-alpine:${project.gitBranch}",\ + "${registry}/${project.name}-alpine:${project.version}" +} + +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', 'tag', "${project.name}-alpine:${project.version}",\ - "${project.name}-alpine:latest" + commandLine 'podman', 'push', \ + "${project.name}-alpine:${project.gitBranch}",\ + "${registry}/${project.name}-alpine:latest" } -task buildLatestAlpineImage { - dependsOn buildAlpineImage - dependsOn tagLatestAlpineImage +task publishImage { + dependsOn pushImageArch + dependsOn tagWithVersionArch + dependsOn tagAsLatestArch + dependsOn pushImageAlpine + dependsOn tagWithVersionAlpine + dependsOn tagAsLatestAlpine } - -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 pushAlpineLatestImage(type: Exec) { - dependsOn buildLatestAlpineImage - - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - || project.rootProject.properties['docker.testRegistry'] \ - && project.rootProject.properties['docker.registry'] \ - == project.rootProject.properties['docker.testRegistry'] - - 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 c365a12..e23a2ec 100644 --- a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml @@ -45,6 +45,12 @@ # 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": diff --git a/org.jdrupes.vmoperator.runner.qemu/password-expiry b/org.jdrupes.vmoperator.runner.qemu/password-expiry index c42fe6e..8a606d5 100644 --- a/org.jdrupes.vmoperator.runner.qemu/password-expiry +++ b/org.jdrupes.vmoperator.runner.qemu/password-expiry @@ -1 +1 @@ -+30 \ No newline at end of file ++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 53922f2..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 @@ -27,7 +27,7 @@ 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.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" }) + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) public void onConfigureQemu(ConfigureQemu event) { - if (event.state() == State.TERMINATING) { + 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 9057606..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 @@ -69,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 d6d5219..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 @@ -39,11 +39,9 @@ import org.jdrupes.vmoperator.util.FsdUtils; /** * The configuration information from the configuration file. */ -@SuppressWarnings("PMD.ExcessivePublicCount") public class Configuration implements Dto { private static final String CI_INSTANCE_ID = "instance-id"; - @SuppressWarnings("PMD.FieldNamingConventions") protected final Logger logger = Logger.getLogger(getClass().getName()); /** Configuration timestamp. */ @@ -82,6 +80,9 @@ public class Configuration implements Dto { /** 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; @@ -92,15 +93,12 @@ public class Configuration implements Dto { public static class CloudInit implements Dto { /** The meta data. */ - @SuppressWarnings("PMD.UseConcurrentHashMap") public Map metaData; /** The user data. */ - @SuppressWarnings("PMD.UseConcurrentHashMap") public Map userData; /** The network config. */ - @SuppressWarnings("PMD.UseConcurrentHashMap") public Map networkConfig; } @@ -242,6 +240,12 @@ public class Configuration implements Dto { */ 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; } @@ -290,7 +294,6 @@ public class Configuration implements Dto { return true; } - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") private void checkDrives() { for (Drive drive : vm.drives) { if (drive.file != null || drive.device != null @@ -310,7 +313,6 @@ public class Configuration implements Dto { } } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkRuntimeDir() { // Runtime directory (sockets etc.) if (runtimeDir == null) { @@ -346,7 +348,6 @@ public class Configuration implements Dto { return true; } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkDataDir() { // Data directory if (dataDir == null) { 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 b87049e..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 mtools +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 2ccb2f9..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,11 +1,11 @@ -FROM archlinux/archlinux:latest +FROM docker.io/archlinux/archlinux:latest RUN systemd-firstboot 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 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 857b14e..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 @@ -33,7 +33,7 @@ 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.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; @@ -41,7 +41,6 @@ import org.jgrapes.core.annotation.Handler; /** * The Class CpuController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class CpuController extends Component { private Integer currentCpus; @@ -64,7 +63,7 @@ public class CpuController extends Component { */ @Handler public void onConfigureQemu(ConfigureQemu event) { - if (event.state() == State.TERMINATING) { + if (event.runState() == RunState.TERMINATING) { return; } Optional.ofNullable(event.configuration().vm.currentCpus) 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 index 304ea04..c3bec93 100644 --- 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 @@ -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 @@ -22,14 +22,20 @@ 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.State; +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; @@ -37,14 +43,14 @@ import org.jgrapes.util.events.WatchFile; /** * The Class DisplayController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class DisplayController extends Component { - public static final String DISPLAY_PASSWORD_FILE = "display-password"; - public static final String PASSWORD_EXPIRY_FILE = "password-expiry"; private String currentPassword; private String protocol; private final Path configDir; + private boolean canBeUpdated; + private boolean vmopAgentConnected; + private String loggedInUser; /** * Instantiates a new Display controller. @@ -52,12 +58,11 @@ public class DisplayController extends Component { * @param componentChannel the component channel * @param configDir */ - @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic", - "PMD.ConstructorCallsOverridableMethod" }) + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) public DisplayController(Channel componentChannel, Path configDir) { super(componentChannel); this.configDir = configDir; - fire(new WatchFile(configDir.resolve(DISPLAY_PASSWORD_FILE))); + fire(new WatchFile(configDir.resolve(DisplaySecret.PASSWORD))); } /** @@ -67,12 +72,38 @@ public class DisplayController extends Component { */ @Handler public void onConfigureQemu(ConfigureQemu event) { - if (event.state() == State.TERMINATING) { + if (event.runState() == RunState.TERMINATING) { return; } protocol = event.configuration().vm.display.spice != null ? "spice" : null; - updatePassword(); + 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); } /** @@ -81,15 +112,16 @@ public class DisplayController extends Component { * @param event the event */ @Handler - @SuppressWarnings("PMD.EmptyCatchBlock") public void onFileChanged(FileChanged event) { - if (event.path().equals(configDir.resolve(DISPLAY_PASSWORD_FILE))) { - updatePassword(); + if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) { + logger.fine(() -> "Display password updated"); + if (canBeUpdated) { + configurePassword(); + } } } - @SuppressWarnings("PMD.DataflowAnomalyAnalysis") - private void updatePassword() { + private void configurePassword() { if (protocol == null) { return; } @@ -99,46 +131,41 @@ public class DisplayController extends Component { } private boolean setDisplayPassword() { - String password; - Path dpPath = configDir.resolve(DISPLAY_PASSWORD_FILE); - if (dpPath.toFile().canRead()) { - logger.finer(() -> "Found display password"); - try { - password = Files.readString(dpPath); - } catch (IOException e) { - logger.log(Level.WARNING, e, () -> "Cannot read display" - + " password: " + e.getMessage()); - return false; + return readFromFile(DisplaySecret.PASSWORD).map(password -> { + if (Objects.equals(this.currentPassword, password)) { + return true; } - } else { - logger.finer(() -> "No display password"); - return false; - } - - if (Objects.equals(this.currentPassword, password)) { - return false; - } - logger.fine(() -> "Updating display password"); - fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, 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() { - Path pePath = configDir.resolve(PASSWORD_EXPIRY_FILE); - if (!pePath.toFile().canRead()) { - return; - } - logger.finer(() -> "Found expiry time"); - String expiry; - try { - expiry = Files.readString(pePath); - } catch (IOException e) { - logger.log(Level.WARNING, e, () -> "Cannot read expiry" - + " time: " + e.getMessage()); - return; - } - logger.fine(() -> "Updating expiry time"); - fire(new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); + 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 f59375c..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,19 +19,15 @@ 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; @@ -42,24 +38,14 @@ 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.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,30 +54,23 @@ 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", - "PMD.ConstructorCallsOverridableMethod" }) public QemuMonitor(Channel componentChannel, Path configDir) throws IOException { super(componentChannel); @@ -111,121 +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 - */ - @SuppressWarnings("resource") - @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); @@ -239,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; }); } @@ -257,30 +153,37 @@ public class QemuMonitor extends Component { * On monitor command. * * @param event the event + * @throws IOException */ @Handler - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - 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(() -> "monitor(out): " + command.toString()); + logger.fine(() -> "QMP handles: " + event.toString()); String asText; try { asText = command.asText(); + logger.finer(() -> "monitor(out): " + asText); } catch (JsonProcessingException e) { logger.log(Level.SEVERE, e, () -> "Cannot serialize Json: " + e.getMessage()); return; } 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); + } } } @@ -290,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 @@ -329,26 +246,53 @@ 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 + @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onConfigureQemu(ConfigureQemu event) { int newTimeout = event.configuration().vm.powerdownTimeout; if (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 9cdc2b5..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 @@ -39,7 +39,6 @@ public class RamController extends Component { * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") public RamController(Channel componentChannel) { super(componentChannel); } 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 e0baa4f..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,2024 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 @@ -41,26 +41,32 @@ 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.HashSet; 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.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; @@ -151,6 +157,15 @@ import org.jgrapes.util.events.WatchFile; * * 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 <> @@ -186,13 +201,9 @@ import org.jgrapes.util.events.WatchFile; * */ @SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace", - "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods", - "PMD.CouplingBetweenObjects" }) + "PMD.TooManyMethods", "PMD.CouplingBetweenObjects" }) public class Runner extends Component { - private static final String QEMU = "qemu"; - private static final String SWTPM = "swtpm"; - private static final String CLOUD_INIT_IMG = "cloudInitImg"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE @@ -201,21 +212,25 @@ public class Runner extends Component { private static final String FW_VARS = "fw-vars.fd"; private static int exitStatus; - private EventPipeline rep; + 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 final File configFile; private final Path configDir; - private Configuration config = new Configuration(); + 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") @@ -225,7 +240,7 @@ public class Runner extends Component { CloudInit } - private final Set qemuLatch = new HashSet<>(); + private final Set qemuLatch = EnumSet.noneOf(QemuPreps.class); /** * Instantiates a new runner. @@ -233,8 +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", - "PMD.ConstructorCallsOverridableMethod" }) + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) public Runner(CommandLine cmdLine) throws IOException { yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -271,6 +285,8 @@ public class Runner extends Component { attach(new ProcessManager(channel())); attach(new SocketConnector(channel())); attach(qemuMonitor = new QemuMonitor(channel(), configDir)); + attach(guestAgentClient = new GuestAgentClient(channel())); + attach(vmopAgentClient = new VmopAgentClient(channel())); attach(new StatusUpdater(channel())); attach(new YamlConfigurationStore(channel(), configFile, false)); fire(new WatchFile(configFile.toPath())); @@ -291,67 +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 -> { + logger.fine(() -> "Runner configuratation updated"); var newConf = yamlMapper.convertValue(c, Configuration.class); // Add some values from other sources to configuration newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); - Path dsPath - = configDir.resolve(DisplayController.DISPLAY_PASSWORD_FILE); + Path dsPath = configDir.resolve(DisplaySecret.PASSWORD); newConf.hasDisplayPassword = dsPath.toFile().canRead(); // Special actions for initial configuration (startup) if (event instanceof InitialConfiguration) { processInitialConfiguration(newConf); - return; } - logger.fine(() -> "Updating configuration"); - rep.fire(new ConfigureQemu(newConf, state)); + + // Check if to be sent immediately or later + if (qmpConfigured) { + rep.fire(new ConfigureQemu(newConf, state)); + } else { + pendingConfig = newConf; + } }); } + @SuppressWarnings("PMD.LambdaCanBeMethodReference") private void processInitialConfiguration(Configuration newConfig) { try { - config = newConfig; - 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); - cloudInitImgDefinition - = Optional.ofNullable(tplData.get(CLOUD_INIT_IMG)) - .map(d -> new CommandDefinition(CLOUD_INIT_IMG, d)) + 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"); @@ -362,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)) { @@ -375,7 +414,7 @@ public class Runner extends Component { } } - private JsonNode dataFromTemplate() + private JsonNode dataFromTemplate(Configuration config) throws IOException, TemplateNotFoundException, MalformedTemplateNameException, ParseException, TemplateException, JsonProcessingException, JsonMappingException { @@ -403,15 +442,32 @@ public class Runner extends Component { model.put("hasDisplayPassword", config.hasDisplayPassword); model.put("cloudInit", config.cloudInit); model.put("vm", config.vm); + 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. * @@ -419,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()); @@ -430,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) { @@ -465,22 +520,23 @@ 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(es) qemuLatch.add(QemuPreps.Config); - if (config.vm.useTpm && swtpmDefinition != null) { + if (initialConfig.vm.useTpm && swtpmDefinition != null) { startProcess(swtpmDefinition); qemuLatch.add(QemuPreps.Tpm); } - if (config.cloudInit != null) { - generateCloudInitImg(); + 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()) { @@ -493,7 +549,7 @@ public class Runner extends Component { } } - private void generateCloudInitImg() { + private void generateCloudInitImg(Configuration config) { try { var cloudInitDir = config.dataDir.resolve("cloud-init"); cloudInitDir.toFile().mkdir(); @@ -530,7 +586,7 @@ public class Runner extends Component { 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; } @@ -544,7 +600,7 @@ public class Runner extends Component { @Handler public void onFileChanged(FileChanged event) { if (event.change() == Kind.CREATED - && event.path().equals(config.swtpmSocket)) { + && event.path().equals(initialConfig.swtpmSocket)) { // swtpm running, maybe start qemu mayBeStartQemu(QemuPreps.Tpm); } @@ -559,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); @@ -600,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 ConfigureQemu(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(ConfigureQemu 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. * @@ -639,15 +716,18 @@ public class Runner extends Component { mayBeStartQemu(QemuPreps.CloudInit); return; } + // No other process(es) may exit during startup - if (state == State.STARTING) { + 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) { + + // 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 @@ -674,7 +754,7 @@ 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", exitStatus != 0)); } @@ -686,14 +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")); } - @SuppressWarnings("PMD.ConfusingArgumentToVarargsMethod") private void shutdown() { - if (!Set.of(State.TERMINATING, State.STOPPED).contains(state)) { + if (!Set.of(RunState.TERMINATING, RunState.STOPPED).contains(state)) { fire(new Stop()); } try { @@ -702,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()) @@ -726,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(); } 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 bbcba5e..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,2024 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,81 +18,80 @@ 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.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.APP_NAME; -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.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.K8sClient; -import org.jdrupes.vmoperator.common.K8sDynamicModel; -import org.jdrupes.vmoperator.common.VmDefinitionModel; +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.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. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -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 K8sClient apiClient; - 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. * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public StatusUpdater(Channel componentChannel) { super(componentChannel); - 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)); - } + attach(new ConsoleTracker(componentChannel)); } /** @@ -109,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. * @@ -160,10 +122,19 @@ public class StatusUpdater extends Component { } try { vmStub = VmDefinitionStub.get(apiClient, - new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace, vmName); - vmStub.model().ifPresent(model -> { - observedGeneration = model.getMetadata().getGeneration(); + 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, @@ -180,38 +151,28 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler - @SuppressWarnings("PMD.AvoidDuplicateLiterals") public void onConfigureQemu(ConfigureQemu event) throws ApiException { 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 update only if we have - // a new version of the CR. There's one exception: the display - // password is configured by a file, not by the CR. - var vmDef = vmStub.model(); - if (vmDef.isPresent() - && vmDef.get().metadata().getGeneration() == observedGeneration - && (event.configuration().hasDisplayPassword - || vmDef.get().status().getAsJsonPrimitive( - "displayPasswordSerial").getAsInt() == -1)) { - return; - } - vmStub.updateStatus(vmDef.get(), from -> { - JsonObject status = from.status(); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); if (!event.configuration().hasDisplayPassword) { - status.addProperty("displayPasswordSerial", -1); + 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; }); } @@ -223,36 +184,49 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler - @SuppressWarnings({ "PMD.AssignmentInOperand", - "PMD.AvoidLiteralsInIfCondition" }) + @SuppressWarnings({ "PMD.AssignmentInOperand" }) public void onRunnerStateChanged(RunnerStateChange event) throws ApiException { - VmDefinitionModel vmDef; + VmDefinition vmDef; if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } - vmStub.updateStatus(vmDef, from -> { - JsonObject status = from.status(); - 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.data()) + 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; - }); + }, vmDef); // Maybe stop VM - if (event.state() == State.TERMINATING && !event.failed() + 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, @@ -267,36 +241,38 @@ public class StatusUpdater extends Component { // Log event var evt = new EventsV1Event() - .reportingController(VM_OP_GROUP + "/" + APP_NAME) + .reportingController(Crd.GROUP + "/" + APP_NAME) .action("StatusUpdate").reason(event.reason()) .note(event.message()); K8s.createEvent(apiClient, vmDef, evt); } - private void updateRunningCondition(RunnerStateChange event, - K8sDynamicModel 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 @@ -306,10 +282,45 @@ public class StatusUpdater extends Component { if (vmStub == null) { return; } + 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.status(); - status.addProperty("ram", - new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) + JsonObject status = from.statusJson(); + status.addProperty(Status.RAM, + new Quantity(new BigDecimal(lastRamValue), Format.BINARY_SI) .toSuffixedString()); return status; }); @@ -327,8 +338,8 @@ public class StatusUpdater extends Component { return; } vmStub.updateStatus(from -> { - JsonObject status = from.status(); - status.addProperty("cpus", event.usedCpus().size()); + JsonObject status = from.statusJson(); + status.addProperty(Status.CPUS, event.usedCpus().size()); return status; }); } @@ -346,9 +357,9 @@ public class StatusUpdater extends Component { return; } vmStub.updateStatus(from -> { - JsonObject status = from.status(); - status.addProperty("displayPasswordSerial", - status.get("displayPasswordSerial").getAsLong() + 1); + JsonObject status = from.statusJson(); + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, + status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1); return status; }); } @@ -363,4 +374,76 @@ public class StatusUpdater extends Component { 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/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 f91d702..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 @@ -30,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(); /** 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/events/ConfigureQemu.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java index a8b1e45..7afa738 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.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; @@ -34,14 +34,14 @@ import org.jgrapes.core.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 ConfigureQemu(Configuration configuration, State state, + public ConfigureQemu(Configuration configuration, RunState state, Channel... channels) { super(channels); this.state = state; @@ -62,7 +62,7 @@ public class ConfigureQemu 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/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/MonitorEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java index ba04a26..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; /** @@ -34,7 +36,8 @@ public class MonitorEvent extends Event { * The kind of monitor event. */ public enum Kind { - READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN + READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, + SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED, VSERPORT_CHANGE } private final Kind kind; @@ -46,11 +49,9 @@ 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)); @@ -63,6 +64,18 @@ public class MonitorEvent extends Event { case SHUTDOWN: return Optional .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(EVENT_DATA))); @@ -100,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/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 4a7ef08..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; @@ -25,17 +26,34 @@ import org.jgrapes.core.Event; /** * The Class RunnerStateChange. */ -@SuppressWarnings("PMD.DataClass") 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; @@ -48,7 +66,7 @@ public class RunnerStateChange extends Event { * @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); } @@ -62,7 +80,7 @@ public class RunnerStateChange extends Event { * @param failed the failed * @param channels the channels */ - public RunnerStateChange(State state, String reason, String message, + public RunnerStateChange(RunState state, String reason, String message, boolean failed, Channel... channels) { super(channels); this.state = state; @@ -76,7 +94,7 @@ public class RunnerStateChange extends Event { * * @return the state */ - public State state() { + public RunState runState() { return state; } diff --git a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-style.scss b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java similarity index 63% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-style.scss rename to org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java index 83e92a5..c133307 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/browser/VmViewer-style.scss +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java @@ -16,30 +16,22 @@ * along with this program. If not, see . */ -/* - * Conlet specific styles. +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. */ -.jdrupes-vmoperator-vmviewer-preview { +public class SpiceConnectedEvent extends SpiceEvent { - [role=button] { - padding: 0.25rem; - - &:not([aria-disabled]):hover, &[aria-disabled='false']:hover { - box-shadow: var(--darkening); + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceConnectedEvent(Kind kind, JsonNode data) { + super(kind, data); } - } - - img { - height: 3em; - padding: 0.25rem; - - &[aria-disabled=''], &[aria-disabled='true'] { - opacity: 0.4; - } - } } - -.jdrupes-vmoperator-vmviewer-preview-action-list { - white-space: nowrap; -} - 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 d100554..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 @@ -122,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 @@ -137,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/> 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.vmviewer/.eclipse-pmd b/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd similarity index 96% rename from org.jdrupes.vmoperator.vmviewer/.eclipse-pmd rename to org.jdrupes.vmoperator.vmaccess/.eclipse-pmd index 5d69caa..60d7780 100644 --- a/org.jdrupes.vmoperator.vmviewer/.eclipse-pmd +++ b/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd @@ -4,4 +4,4 @@ - + \ 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.vmviewer/build.gradle b/org.jdrupes.vmoperator.vmaccess/build.gradle similarity index 95% rename from org.jdrupes.vmoperator.vmviewer/build.gradle rename to org.jdrupes.vmoperator.vmaccess/build.gradle index ab667f5..606c6cd 100644 --- a/org.jdrupes.vmoperator.vmviewer/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.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-l10nBundles.ftl.js b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-l10nBundles.ftl.js similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-l10nBundles.ftl.js rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-l10nBundles.ftl.js diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-preview.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html similarity index 54% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-preview.ftl.html rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html index 1cd0392..57693ea 100644 --- a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/VmViewer-preview.ftl.html +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-preview.ftl.html @@ -1,6 +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.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-off.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-off.svg similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer-off.svg rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer-off.svg diff --git a/org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer.svg similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/resources/org/jdrupes/vmoperator/vmviewer/computer.svg rename to org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/computer.svg 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.vmviewer/rollup.config.mjs b/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs similarity index 92% rename from org.jdrupes.vmoperator.vmviewer/rollup.config.mjs rename to org.jdrupes.vmoperator.vmaccess/rollup.config.mjs index f00a51f..ab1aae9 100644 --- a/org.jdrupes.vmoperator.vmviewer/rollup.config.mjs +++ b/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs @@ -1,8 +1,8 @@ import typescript from 'rollup-plugin-typescript2'; import postcss from 'rollup-plugin-postcss'; -let packagePath = "org/jdrupes/vmoperator/vmviewer"; -let baseName = "VmViewer" +let packagePath = "org/jdrupes/vmoperator/vmaccess"; +let baseName = "VmAccess" let module = "build/generated/resources/" + packagePath + "/" + baseName + "-functions.js"; 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.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewerFactory.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java similarity index 85% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewerFactory.java rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java index 6748f47..5140056 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/VmViewerFactory.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.vmviewer; +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 VmViewer}s. + * The factory service for {@link VmAccess}s. */ -public class VmViewerFactory implements ConletComponentFactory { +public class VmAccessFactory implements ConletComponentFactory { /* * (non-Javadoc) @@ -36,7 +36,7 @@ public class VmViewerFactory implements ConletComponentFactory { */ @Override public Class componentType() { - return VmViewer.class; + return VmAccess.class; } /* @@ -48,7 +48,7 @@ public class VmViewerFactory implements ConletComponentFactory { @Override public Optional create(Channel componentChannel, Map properties) { - return Optional.of(new VmViewer(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.vmviewer/src/org/jdrupes/vmoperator/vmviewer/package-info.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java similarity index 89% rename from org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/package-info.java rename to org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java index 9a4045a..745ded7 100644 --- a/org.jdrupes.vmoperator.vmviewer/src/org/jdrupes/vmoperator/vmviewer/package-info.java +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.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 @@ -16,4 +16,4 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.vmviewer; \ No newline at end of file +package org.jdrupes.vmoperator.vmaccess; diff --git a/org.jdrupes.vmoperator.vmviewer/tsconfig.json b/org.jdrupes.vmoperator.vmaccess/tsconfig.json similarity index 92% rename from org.jdrupes.vmoperator.vmviewer/tsconfig.json rename to org.jdrupes.vmoperator.vmaccess/tsconfig.json index 6418f59..d9dbb3f 100644 --- a/org.jdrupes.vmoperator.vmviewer/tsconfig.json +++ b/org.jdrupes.vmoperator.vmaccess/tsconfig.json @@ -14,7 +14,7 @@ "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/vmviewer/browser/l10nBundles-stub"], + "l10nBundles": ["./src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub"], "vue": ["./build/unpacked/org/jgrapes/webconsole/provider/vue/vue/vue"] } }, 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 880369b..0000000 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n.properties +++ /dev/null @@ -1,15 +0,0 @@ -conletName = VM Infos - -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 7e1d95e..0000000 --- a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/l10n_de.properties +++ /dev/null @@ -1,27 +0,0 @@ -conletName = VM-Informationen - -VMsSummary = VMs (gestartet/gesamt) - -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 a8bb1ae..0000000 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/VmConlet.java +++ /dev/null @@ -1,398 +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 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.Optional; -import java.util.Set; -import org.jdrupes.json.JsonBeanDecoder; -import org.jdrupes.json.JsonDecodeException; -import org.jdrupes.vmoperator.common.K8sObserver; -import org.jdrupes.vmoperator.common.VmDefinitionModel; -import org.jdrupes.vmoperator.manager.events.ChannelCache; -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.util.GsonPtr; -import org.jgrapes.core.Channel; -import org.jgrapes.core.Event; -import org.jgrapes.core.Manager; -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 ChannelCache channelManager = new ChannelCache<>(); - 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 - */ - @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") - 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.Preview); - 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 vmDef : channelManager.associated()) { - var def - = JsonBeanDecoder.create(vmDef.data().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", - "PMD.ConfusingArgumentToVarargsMethod" }) - public void onVmDefChanged(VmDefChanged event, VmChannel channel) - throws JsonDecodeException, IOException { - var vmName = event.vmDefinition().getMetadata().getName(); - if (event.type() == K8sObserver.ResponseType.DELETED) { - channelManager.remove(vmName); - for (var entry : conletIdsByConsoleConnection().entrySet()) { - for (String conletId : entry.getValue()) { - entry.getKey().respond(new NotifyConletView(type(), - conletId, "removeVm", vmName)); - } - } - } else { - var vmDef = new VmDefinitionModel(channel.client().getJSON() - .getGson(), cleanup(event.vmDefinition().data())); - channelManager.put(vmName, channel, vmDef); - var def = JsonBeanDecoder.create(vmDef.data().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 JsonObject cleanup(JsonObject vmDef) { - // Clone and remove managed fields - var json = vmDef.deepCopy(); - GsonPtr.to(json).to("metadata").get(JsonObject.class) - .remove("managedFields"); - - // Convert RAM sizes to unitless numbers - var vmSpec = GsonPtr.to(json).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(json).to("status"); - status.set("ram", Quantity.fromString( - status.getAsString("ram").orElse("0")).getNumber() - .toBigInteger()); - return json; - } - - /** - * 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 : channelManager.associated()) { - summary.totalVms += 1; - var status = GsonPtr.to(vmDef.data()).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(); - var vmName = event.params().asString(0); - var vmChannel = channelManager.channel(vmName).orElse(null); - if (vmChannel == null) { - return; - } - switch (event.method()) { - case "start": - fire(new ModifyVm(vmName, "state", "Running", vmChannel)); - break; - case "stop": - fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); - break; - case "cpus": - fire(new ModifyVm(vmName, "currentCpus", - new BigDecimal(event.params().asDouble(1)).toBigInteger(), - vmChannel)); - break; - case "ram": - fire(new ModifyVm(vmName, "currentRam", - new Quantity(new BigDecimal(event.params().asDouble(1)), - Format.BINARY_SI).toSuffixedString(), - vmChannel)); - 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.vmviewer/.checkstyle b/org.jdrupes.vmoperator.vmmgmt/.checkstyle similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.checkstyle rename to org.jdrupes.vmoperator.vmmgmt/.checkstyle diff --git a/org.jdrupes.vmoperator.vmconlet/.eclipse-pmd b/org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.eclipse-pmd rename to org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd diff --git a/org.jdrupes.vmoperator.vmviewer/.eslintignore b/org.jdrupes.vmoperator.vmmgmt/.eslintignore similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.eslintignore rename to org.jdrupes.vmoperator.vmmgmt/.eslintignore diff --git a/org.jdrupes.vmoperator.vmviewer/.eslintrc.json b/org.jdrupes.vmoperator.vmmgmt/.eslintrc.json similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.eslintrc.json rename to org.jdrupes.vmoperator.vmmgmt/.eslintrc.json diff --git a/org.jdrupes.vmoperator.vmviewer/.gitignore b/org.jdrupes.vmoperator.vmmgmt/.gitignore similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.gitignore rename to org.jdrupes.vmoperator.vmmgmt/.gitignore diff --git a/org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.buildship.core.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.buildship.core.prefs rename to org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.buildship.core.prefs diff --git a/org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.resources.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.core.resources.prefs rename to org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.resources.prefs diff --git a/org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.runtime.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.core.runtime.prefs rename to org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.core.runtime.prefs diff --git a/org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.jdt.ui.prefs b/org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.jdt.ui.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/.settings/org.eclipse.jdt.ui.prefs rename to org.jdrupes.vmoperator.vmmgmt/.settings/org.eclipse.jdt.ui.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/build.gradle b/org.jdrupes.vmoperator.vmmgmt/build.gradle similarity index 95% rename from org.jdrupes.vmoperator.vmconlet/build.gradle rename to org.jdrupes.vmoperator.vmmgmt/build.gradle index ab667f5..606c6cd 100644 --- a/org.jdrupes.vmoperator.vmconlet/build.gradle +++ b/org.jdrupes.vmoperator.vmmgmt/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.vmviewer/package.json b/org.jdrupes.vmoperator.vmmgmt/package.json similarity index 100% rename from org.jdrupes.vmoperator.vmviewer/package.json rename to org.jdrupes.vmoperator.vmmgmt/package.json 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 88% 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 0c6aa37..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 @@ -
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 @@ -
-