diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index 547c1a4..f47366a 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -22,10 +22,10 @@ jobs:
fetch-depth: 0
- name: Install graphviz
run: sudo apt-get install graphviz
- - name: Set up JDK 17
+ - name: Set up JDK 21
uses: actions/setup-java@v3
with:
- java-version: '17'
+ java-version: '21'
distribution: 'temurin'
- name: Build with Gradle
- run: ./gradlew -Prepo.access.token=${{ secrets.REPO_ACCESS_TOKEN }} stage
+ run: ./gradlew -Pwebsite.push.token=${{ secrets.WEBSITE_PUSH_TOKEN }} stage
diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml
new file mode 100644
index 0000000..d0e4ec9
--- /dev/null
+++ b/.github/workflows/jekyll.yml
@@ -0,0 +1,89 @@
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+
+# Sample workflow for building and deploying a Jekyll site to GitHub Pages
+name: Deploy Jekyll site to Pages
+
+on:
+ # Runs on pushes targeting the default branch
+ push:
+ branches: ["main"]
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between
+# the run in-progress and latest queued. However, do NOT cancel
+# in-progress runs as we want to allow these production deployments
+# to complete.
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ # Build job
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.3' # Not needed with a .ruby-version file
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
+ cache-version: 0 # Increment this number if you need to re-download cached gems
+ working-directory: webpages
+ - name: Setup Pages
+ id: pages
+ uses: actions/configure-pages@v5
+ - name: Build with Jekyll
+ # Outputs to the './_site' directory by default
+ run: cd webpages && bundle exec jekyll build
+ env:
+ JEKYLL_ENV: production
+ - name: Install graphviz
+ run: sudo apt-get install graphviz
+ - name: Set up JDK 21
+ uses: actions/setup-java@v3
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ - name: Build apidocs
+ run: ./gradlew apidocs
+ - name: Copy javadoc
+ run: cp -a build/javadoc webpages/_site/
+ - name: Generate the sitemap
+ uses: cicirello/generate-sitemap@v1
+ with:
+ path-to-root: webpages/_site
+ base-url-path: https://vm-operator.jdrupes.org
+ - name: Index pagefind
+ run: cd webpages && npx pagefind --source "_site"
+ - name: Upload artifact
+ # Automatically uploads an artifact from the './_site' directory by default
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: './webpages/_site'
+
+ # Deployment job
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3e6b3c9..beab0c4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,10 +18,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
fetch-depth: 0
- ref: main
- name: Install graphviz
run: sudo apt-get install graphviz
- name: Install podman
@@ -32,10 +31,10 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Set up JDK 17
+ - name: Set up JDK 21
uses: actions/setup-java@v3
with:
- java-version: '17'
+ java-version: '21'
distribution: 'temurin'
- name: Push with Gradle
- run: ./gradlew -Prepo.access.token=${{ secrets.REPO_ACCESS_TOKEN }} -Pdocker.registry=ghcr.io/${{ github.actor }} stage pushImages
+ run: ./gradlew -Pwebsite.push.token=${{ secrets.WEBSITE_PUSH_TOKEN }} -Pdocker.registry=ghcr.io/${{ github.actor }} stage publishImage
diff --git a/.markdownlint.yaml b/.markdownlint.yaml
new file mode 100644
index 0000000..6ed5002
--- /dev/null
+++ b/.markdownlint.yaml
@@ -0,0 +1,30 @@
+# See [rules](https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml)
+
+# Default state for all rules
+default: true
+
+# MD007/ul-indent : Unordered list indentation :
+# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md007.md
+MD007:
+ # Spaces for indent
+ indent: 2
+ # Whether to indent the first level of the list
+ start_indented: true
+ # Spaces for first level indent (when start_indented is set)
+ start_indent: 2
+
+# MD025/single-title/single-h1 : Multiple top-level headings in the same document :
+# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md025.md
+MD025:
+ # Heading level
+ level: 1
+ # RegExp for matching title in front matter (disable)
+ front_matter_title: ""
+
+# MD036/no-emphasis-as-heading : Emphasis used instead of a heading :
+# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md036.md
+MD036: false
+
+# MD043/required-headings : Required heading structure :
+# https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md043.md
+MD043: false
diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs
index 44c5061..72733d9 100644
--- a/.settings/org.eclipse.buildship.core.prefs
+++ b/.settings/org.eclipse.buildship.core.prefs
@@ -5,7 +5,7 @@ connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
-java.home=/usr/lib/jvm/java-17-openjdk-17.0.8.0.7-1.fc37.x86_64
+java.home=
jvm.arguments=
offline.mode=false
override.workspace.settings=true
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 @@
-[](https://github.com/mnlipp/VM-Operator/actions/workflows/gradle.yml)
+[](https://github.com/mnlipp/VM-Operator/actions/workflows/gradle.yml)
[](https://app.codacy.com/gh/mnlipp/VM-Operator/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)


-# 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.
+
-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/checkstyle.xml b/checkstyle.xml
index 015ef09..088e543 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -30,8 +30,11 @@
+
+
+
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 1863afe..c2a7a66 100644
--- a/deploy/crds/vms-crd.yaml
+++ b/deploy/crds/vms-crd.yaml
@@ -990,6 +990,52 @@ spec:
description: Copied to cloud-init's network-config file.
type: object
x-kubernetes-preserve-unknown-fields: true
+ permissions:
+ type: array
+ description: >-
+ Defines permissions for accessing and manipulating the VM.
+ The meaning of most permissions should be obvious. The
+ difference between "accessConsole" and "takeConsole" is
+ that "takeConsole" allows the user to take control of
+ the console even if it is already in use by another user.
+ items:
+ type: object
+ description: >-
+ Permissions can be granted to a user or to a role.
+ oneOf:
+ - required:
+ - user
+ - required:
+ - role
+ properties:
+ user:
+ type: string
+ role:
+ type: string
+ may:
+ type: array
+ items:
+ type: string
+ enum:
+ - start
+ - stop
+ - reset
+ - accessConsole
+ - takeConsole
+ - "*"
+ default: []
+ pools:
+ type: array
+ description: >-
+ List of pools this VM belongs to.
+ items:
+ type: string
+ default: []
+ loggingProperties:
+ type: string
+ description: >-
+ Override the default logging properties for
+ the runner for this VM.
vm:
type: object
description: Defines the VM.
@@ -1381,13 +1427,36 @@ spec:
display:
type: object
properties:
+ outputs:
+ type: integer
+ default: 1
+ loggedInUser:
+ description: >-
+ The name of a user that should be automatically
+ logged in on the display. Note that this requires
+ support from an agent in the guest OS.
+ type: string
spice:
type: object
properties:
port:
+ description: >-
+ Port number used for the Spice server.
type: integer
default: 5900
- ticket:
+ server:
+ description: >-
+ Server (address) to use for connecting
+ to the spice server. Defaults to the address
+ of the node that the VM is running on.
+ type: string
+ generateSecret:
+ type: boolean
+ default: true
+ proxyUrl:
+ description: >-
+ If specified, is copied to the generated
+ viewer configuration files.
type: string
streamingVideo:
type: string
@@ -1401,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.
@@ -1411,6 +1484,50 @@ spec:
Amount of memory in use.
type: string
default: "0"
+ consoleClient:
+ description: >-
+ The hostname of the currently connected client.
+ type: string
+ default: ""
+ consoleUser:
+ description: >-
+ The id of the user who has last requested a console
+ connection.
+ type: string
+ default: ""
+ loggedInUser:
+ description: >-
+ The name of a user that is currently logged in by the
+ VM operator agent.
+ type: string
+ displayPasswordSerial:
+ description: >-
+ Counts changes of the display password. Set to -1
+ by the runner if password protection is not enabled.
+ type: integer
+ default: 0
+ osinfo:
+ description: Copy of the OS info provided by the guest agent.
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ assignment:
+ description: >-
+ The assignment of this VM to a a particular user.
+ type: object
+ properties:
+ pool:
+ description: >-
+ The pool this VM is taken from.
+ type: string
+ user:
+ description: >-
+ The user this VM is assigned to.
+ type: string
+ lastUsed:
+ description: >-
+ The last time this VM was used by the user.
+ type: string
+ default: {}
conditions:
description: >-
List of component conditions observed
@@ -1421,6 +1538,30 @@ spec:
lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation
message: "Creation of CR"
+ - type: Booted
+ status: "False"
+ observedGeneration: 1
+ lastTransitionTime: "1970-01-01T00:00:00Z"
+ reason: Creation
+ message: "Creation of CR"
+ - type: VmopAgentConnected
+ status: "False"
+ observedGeneration: 1
+ lastTransitionTime: "1970-01-01T00:00:00Z"
+ reason: Creation
+ message: "Creation of CR"
+ - type: UserLoggedIn
+ status: "False"
+ observedGeneration: 1
+ lastTransitionTime: "1970-01-01T00:00:00Z"
+ reason: Creation
+ message: "Creation of CR"
+ - type: ConsoleConnected
+ status: "False"
+ observedGeneration: 1
+ lastTransitionTime: "1970-01-01T00:00:00Z"
+ reason: Creation
+ message: "Creation of CR"
type: array
items:
type: object
diff --git a/deploy/vmop-deployment.yaml b/deploy/vmop-deployment.yaml
index 648cc39..08316f6 100644
--- a/deploy/vmop-deployment.yaml
+++ b/deploy/vmop-deployment.yaml
@@ -21,22 +21,31 @@ spec:
- name: vm-operator
image: >-
ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest
+ imagePullPolicy: Always
+ env:
+ - name: JAVA_OPTS
+ # The VM operator needs about 25 MB of memory, plus 1 MB for
+ # each VM. The reason is that for the sake of effeciency, we
+ # have to keep a parsed representation of the CRD in memory,
+ # which requires about 512 KB per VM. While handling updates,
+ # we temporarily have the old and the new version of the CRD
+ # in memory, so we need another 512 KB per VM.
+ value: "-Xmx128m"
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
volumeMounts:
- name: config
mountPath: /etc/opt/vmoperator
- name: vmop-image-repository
mountPath: /var/local/vmop-image-repository
- imagePullPolicy: Always
securityContext:
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
- resources:
- requests:
- cpu: 100m
- memory: 128Mi
volumes:
- name: config
configMap:
diff --git a/deploy/vmop-role.yaml b/deploy/vmop-role.yaml
index e1ea85b..e1ae7bc 100644
--- a/deploy/vmop-role.yaml
+++ b/deploy/vmop-role.yaml
@@ -9,8 +9,15 @@ rules:
- vmoperator.jdrupes.org
resources:
- vms
+ - vmpools
verbs:
- '*'
+- apiGroups:
+ - vmoperator.jdrupes.org
+ resources:
+ - vms/status
+ verbs:
+ - patch
- apiGroups:
- apps
resources:
@@ -28,8 +35,12 @@ rules:
- apiGroups:
- ""
resources:
+ - persistentvolumeclaims
- pods
verbs:
+ - watch
- list
+ - get
+ - create
- delete
- patch
diff --git a/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 cf43692..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,17 +37,34 @@
"/WebConsole":
"/LoginConlet":
users:
- admin:
- fullName: Administrator
- password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
- test:
- fullName: Test Account
- password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
+ - name: admin
+ fullName: Administrator
+ password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
+ - name: operator
+ fullName: Operator
+ password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
+ - name: test1
+ fullName: Test Account 1
+ password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
+ - name: test2
+ fullName: Test Account 2
+ password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
+ - name: test3
+ fullName: Test Account 3
+ password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
"/RoleConfigurator":
rolesByUser:
# User admin has role admin
admin:
- admin
+ operator:
+ - operator
+ test1:
+ - user
+ test2:
+ - user
+ test3:
+ - user
# All users have role other
"*":
- other
@@ -37,6 +74,17 @@
# Admins can use all conlets
admin:
- "*"
+ operator:
+ - org.jdrupes.vmoperator.vmmgmt.VmMgmt
+ - org.jdrupes.vmoperator.vmaccess.VmAccess
+ user:
+ - org.jdrupes.vmoperator.vmaccess.VmAccess
# Others cannot use any conlet (except login conlet to log out)
other:
- - org.jgrapes.webconlet.locallogin.LoginConlet
+ - org.jgrapes.webconlet.oidclogin.LoginConlet
+ "/ComponentCollector":
+ "/VmAccess":
+ displayResource:
+ preferredIpVersion: ipv4
+ syncPreviewsFor:
+ - role: user
diff --git a/dev-example/gen-pool-vm-crds b/dev-example/gen-pool-vm-crds
new file mode 100755
index 0000000..f9cf692
--- /dev/null
+++ b/dev-example/gen-pool-vm-crds
@@ -0,0 +1,47 @@
+#!/bin/bash
+
+function usage() {
+ cat >&2 <&2 "Unknown option: $1"; exit 1;;
+ *) template="$1";;
+ esac
+ shift
+done
+
+if [ -z "$template" ]; then
+ usage
+fi
+
+if [ "$count" = "0" ]; then
+ exit 0
+fi
+for number in $(seq 1 $count); do
+ if [ -z "$prefix" ]; then
+ prefix=$(basename $template .tpl.yaml)
+ fi
+ name="$prefix$(printf %03d $number)"
+ index=$(($number - 1))
+ esh -o $destination/$name.yaml $template number=$number index=$index
+done
diff --git a/dev-example/kustomization.yaml b/dev-example/kustomization.yaml
index 70c6ae6..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,17 +51,29 @@ patches:
"/WebConsole":
"/LoginConlet":
users:
- admin:
- fullName: Administrator
- password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
- test:
- fullName: Test Account
- password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
+ - name: admin
+ fullName: Administrator
+ password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
+ - name: test1
+ fullName: Test Account
+ password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
+ - name: test2
+ fullName: Test Account
+ password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
+ - name: test3
+ fullName: Test Account
+ password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
"/RoleConfigurator":
rolesByUser:
# User admin has role admin
admin:
- admin
+ test1:
+ - user
+ test2:
+ - user
+ test3:
+ - user
# All users have role other
"*":
- other
@@ -63,10 +83,17 @@ patches:
# Admins can use all conlets
admin:
- "*"
+ user:
+ - org.jdrupes.vmoperator.vmviewer.VmViewer
# Others cannot use any conlet (except login conlet to log out)
other:
- org.jgrapes.webconlet.locallogin.LoginConlet
-
+ "/ComponentCollector":
+ "/VmAccess":
+ displayResource:
+ preferredIpVersion: ipv4
+ syncPreviewsFor:
+ - role: user
- target:
group: apps
version: v1
diff --git a/dev-example/pool-action b/dev-example/pool-action
new file mode 100755
index 0000000..bc8fbce
--- /dev/null
+++ b/dev-example/pool-action
@@ -0,0 +1,66 @@
+#!/bin/bash
+
+function usage() {
+ cat >&2 <&2 "Unknown option: $1"; exit 1;;
+ *) if [ ! -v pool ]; then
+ pool="$1"
+ elif [ ! -v action ]; then
+ action="$1"
+ else
+ usage
+ fi;;
+ esac
+ shift
+done
+
+if [ ! -v pool -o ! -v "action" -o ! -v context ]; then
+ echo >&2 "Missing arguments or context not set."
+ echo >&2
+ usage
+fi
+case "$action" in
+ "start"|"stop"|"delete"|"delete-disks") ;;
+ *) usage;;
+esac
+
+kubectl --context="$context" -n "$namespace" get vms -o json \
+ | jq -r '.items[] | select(.spec.pools | contains(["'${pool}'"])) | .metadata.name' \
+| while read vmName; do
+ case "$action" in
+ start) kubectl --context="$context" -n "$namespace" patch vms "$vmName" \
+ --type='merge' -p '{"spec":{"vm":{"state":"Running"}}}';;
+ stop) kubectl --context="$context" -n "$namespace" patch vms "$vmName" \
+ --type='merge' -p '{"spec":{"vm":{"state":"Stopped"}}}';;
+ delete) kubectl --context="$context" -n "$namespace" delete vm/"$vmName";;
+ delete-disks) kubectl --context="$context" -n "$namespace" delete \
+ pvc -l app.kubernetes.io/instance="$vmName" ;;
+ esac
+done
diff --git a/dev-example/test-pool.yaml b/dev-example/test-pool.yaml
new file mode 100644
index 0000000..497aaf7
--- /dev/null
+++ b/dev-example/test-pool.yaml
@@ -0,0 +1,17 @@
+apiVersion: "vmoperator.jdrupes.org/v1"
+kind: VmPool
+metadata:
+ namespace: vmop-dev
+ name: test-vms
+spec:
+ retention: "PT1m"
+ loginOnAssignment: true
+ permissions:
+ - user: admin
+ may:
+ - accessConsole
+ - start
+ - role: user
+ may:
+ - accessConsole
+ - start
diff --git a/dev-example/test-vm-display-secret.yaml b/dev-example/test-vm-display-secret.yaml
new file mode 100644
index 0000000..a6f0fe6
--- /dev/null
+++ b/dev-example/test-vm-display-secret.yaml
@@ -0,0 +1,13 @@
+kind: Secret
+apiVersion: v1
+metadata:
+ name: test-vm-display-secret
+ namespace: vmop-dev
+ labels:
+ app.kubernetes.io/name: vm-runner
+ app.kubernetes.io/instance: test-vm
+ app.kubernetes.io/component: display-secret
+type: Opaque
+data:
+ display-password: dGVzdC12bQ==
+ password-expiry: KzMw
diff --git a/dev-example/test-vm-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 0a8a098..aa75bc3 100644
--- a/dev-example/test-vm.yaml
+++ b/dev-example/test-vm.yaml
@@ -5,19 +5,23 @@ metadata:
name: test-vm
spec:
image:
- repository: docker-registry.lan.mnl.de
- path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine
+ source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing
pullPolicy: Always
+ permissions:
+ - user: admin
+ may:
+ - "*"
+
resources:
requests:
cpu: 1
memory: 2Gi
-
+
guestShutdownStops: true
cloudInit: {}
-
+
vm:
# state: Running
bootMenu: yes
@@ -28,8 +32,9 @@ spec:
currentCpus: 4
networks:
- - tap:
- mac: "02:16:3e:33:58:10"
+ # No bridge on test cluster
+ - user: {}
+
disks:
- volumeClaimTemplate:
metadata:
@@ -52,3 +57,6 @@ spec:
display:
spice:
port: 5810
+ generateSecret: true
+
+ loadBalancerService: {}
diff --git a/dev-example/vmop-agent/99-vmop-agent.rules b/dev-example/vmop-agent/99-vmop-agent.rules
new file mode 100644
index 0000000..4a18472
--- /dev/null
+++ b/dev-example/vmop-agent/99-vmop-agent.rules
@@ -0,0 +1,2 @@
+SUBSYSTEM=="virtio-ports", ATTR{name}=="org.jdrupes.vmop_agent.0", \
+ TAG+="systemd" ENV{SYSTEMD_WANTS}="vmop-agent.service"
diff --git a/dev-example/vmop-agent/gdm/PostLogin/Default b/dev-example/vmop-agent/gdm/PostLogin/Default
new file mode 100755
index 0000000..8a70890
--- /dev/null
+++ b/dev-example/vmop-agent/gdm/PostLogin/Default
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf
diff --git a/dev-example/vmop-agent/vmop-agent b/dev-example/vmop-agent/vmop-agent
new file mode 100755
index 0000000..9f4d9e7
--- /dev/null
+++ b/dev-example/vmop-agent/vmop-agent
@@ -0,0 +1,146 @@
+#!/usr/bin/bash
+
+# Note that this script requires "jq" to be installed and a version
+# of loginctl that accepts the "-j" option.
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --path) shift; ttyPath="$1";;
+ --path=*) IFS='=' read -r option value <<< "$1"; ttyPath="$value";;
+ esac
+ shift
+done
+
+ttyPath="${ttyPath:-/dev/virtio-ports/org.jdrupes.vmop_agent.0}"
+
+if [ ! -w "$ttyPath" ]; then
+ echo >&2 "Device $ttyPath not writable"
+ exit 1
+fi
+
+# Create fd for the tty in variable con
+if ! exec {con}<>"$ttyPath"; then
+ echo >&2 "Cannot open device $ttyPath"
+ exit 1
+fi
+
+# Temporary file for logging error messages, clear tty and signal ready
+temperr=$(mktemp)
+clear >/dev/tty1
+echo >&${con} "220 Hello"
+
+# This script uses the (shared) home directory as "dictonary" for
+# synchronizing the username and the uid between hosts.
+#
+# Every user has a directory with his username. The directory is
+# owned by root to prevent changes of access rights by the user.
+# The uid and gid of the directory are equal. Thus the name of the
+# directory and the id from the group ownership also provide the
+# association between the username and the uid.
+
+# Add the user with name $1 to the host's "user database". This
+# may not be invoked concurrently.
+createUser() {
+ local missing=$1
+ local uid
+ local userHome="/home/$missing"
+ local createOpts=""
+
+ # Retrieve or create the uid for the username
+ if [ -d "$userHome" ]; then
+ # If a home directory exists, use the id from the group ownership as uid
+ uid=$(ls -ldn "$userHome" | head -n 1 | awk '{print $4}')
+ createOpts="--no-create-home"
+ else
+ # Else get the maximum of all ids from the group ownership +1
+ uid=$(ls -ln "/home" | tail -n +2 | awk '{print $4}' | sort | tail -1)
+ uid=$(( $uid + 1 ))
+ if [ $uid -lt 1100 ]; then
+ uid=1100
+ fi
+ createOpts="--create-home"
+ fi
+ groupadd -g $uid $missing
+ useradd $missing -u $uid -g $uid $createOpts
+}
+
+# Login the user, i.e. create a desktopn for the user.
+doLogin() {
+ user=$1
+ if [ "$user" = "root" ]; then
+ echo >&${con} "504 Won't log in root"
+ return
+ fi
+
+ # Check if this user is already logged in on tty2
+ curUser=$(loginctl -j | jq -r '.[] | select(.tty=="tty2") | .user')
+ if [ "$curUser" = "$user" ]; then
+ echo >&${con} "201 User already logged in"
+ return
+ fi
+
+ # Terminate a running desktop (fail safe)
+ attemptLogout
+
+ # Check if username is known on this host. If not, create user
+ uid=$(id -u ${user} 2>/dev/null)
+ if [ $? != 0 ]; then
+ ( flock 200
+ createUser ${user}
+ ) 200>/home/.gen-uid-lock
+
+ # This should now work, else something went wrong
+ uid=$(id -u ${user} 2>/dev/null)
+ if [ $? != 0 ]; then
+ echo >&${con} "451 Cannot determine uid"
+ return
+ fi
+ fi
+
+ # Configure user as auto login user
+ sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf
+ sed -i '/\[daemon\]/a AutomaticLoginEnable=true\nAutomaticLogin='$user \
+ /etc/gdm/custom.conf
+
+ # Activate user
+ systemctl restart gdm
+ if [ $? -eq 0 ]; then
+ echo >&${con} "201 User logged in successfully"
+ else
+ echo >&${con} "451 $(tr '\n' ' ' <${temperr})"
+ fi
+}
+
+# Attempt to log out a user currently using tty1. This is an intermediate
+# operation that can be invoked from other operations
+attemptLogout() {
+ sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf
+ systemctl stop gdm
+ echo >&${con} "102 Desktop stopped"
+}
+
+# Log out any user currently using tty1. This is invoked when executing
+# the logout command and therefore sends back a 2xx return code.
+# Also try to restart gdm, if it is not running.
+doLogout() {
+ attemptLogout
+ systemctl restart gdm
+ echo >&${con} "202 User logged out"
+}
+
+while read line <&${con}; do
+ case $line in
+ "login "*) IFS=' ' read -ra args <<< "$line"; doLogin ${args[1]};;
+ "logout") doLogout;;
+ esac
+done
+
+onExit() {
+ doLogout
+ if [ -n "$temperr" ]; then
+ rm -f $temperr
+ fi
+ echo >&${con} "240 Quit"
+}
+
+trap onExit EXIT
diff --git a/dev-example/vmop-agent/vmop-agent.service b/dev-example/vmop-agent/vmop-agent.service
new file mode 100644
index 0000000..11c64f2
--- /dev/null
+++ b/dev-example/vmop-agent/vmop-agent.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=VM-Operator (Guest) Agent
+BindsTo=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device
+After=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device multi-user.target
+IgnoreOnIsolate=True
+
+[Service]
+UMask=0077
+#EnvironmentFile=/etc/sysconfig/vmop-agent
+ExecStart=/usr/local/libexec/vmop-agent
+Restart=always
+RestartSec=0
+
+[Install]
+WantedBy=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device
diff --git a/example/local-path/Readme.md b/example/local-path/Readme.md
index 7afb948..bdba8cc 100644
--- a/example/local-path/Readme.md
+++ b/example/local-path/Readme.md
@@ -1,17 +1,17 @@
# Example setup
-The CRD must be deployed independently.
+The CRD must be deployed independently.
```sh
kubectl apply -f https://github.com/mnlipp/VM-Operator/raw/main/deploy/crds/vms-crd.yaml
```
-Apart from that, the `kustomize.yaml` defines a namespace for the manager
+Apart from that, the `kustomize.yaml` defines a namespace for the manager
(and the VMs managed by it) and patches the repository PVC to create
a small volume using local-path.
-A second patch provides a new configuration file for the manager
-that makes it use the local-path storage class when creating the
+A second patch provides a new configuration file for the manager
+that makes it use the local-path storage class when creating the
small volume for a runner's data.
The `kustomize.yaml` does not include the test VM. Before creating
diff --git a/example/rook-ceph/Readme.md b/example/rook-ceph/Readme.md
index 1d2cfc6..3756e93 100644
--- a/example/rook-ceph/Readme.md
+++ b/example/rook-ceph/Readme.md
@@ -1,12 +1,12 @@
# Example setup
-The CRD must be deployed independently.
+The CRD must be deployed independently.
```sh
kubectl apply -f https://github.com/mnlipp/VM-Operator/raw/main/deploy/crds/vms-crd.yaml
```
-Apart from that, the `kustomize.yaml` defines a namespace for the manager
+Apart from that, the `kustomize.yaml` defines a namespace for the manager
(and the VMs managed by it) and applies patches to use `rook-cephfs` as
storage class (instead of the default storage class).
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f97ebb7
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1 @@
+org.gradle.parallel=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index ccebba7..e644113 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 8707e8b..a441313 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 79a61d4..b740cf1 100755
--- a/gradlew
+++ b/gradlew
@@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -83,10 +83,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -133,10 +131,13 @@ location of your Java installation."
fi
else
JAVACMD=java
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
+ fi
fi
# Increase the maximum file descriptors if we can.
@@ -144,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC3045
+ # shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -152,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC3045
+ # shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -197,11 +198,15 @@ if "$cygwin" || "$msys" ; then
done
fi
-# Collect all arguments for the java command;
-# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
-# shell script including quotes and variable substitutions, so put them in
-# double quotes to make sure that they get re-expanded; and
-# * put everything else in single quotes, so that it's not re-expanded.
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
diff --git a/gradlew.bat b/gradlew.bat
index 93e3f59..25da30d 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -43,11 +43,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
@@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
diff --git a/misc/DejaVuSans-Bold.woff2 b/misc/DejaVuSans-Bold.woff2
deleted file mode 100644
index 373095f..0000000
Binary files a/misc/DejaVuSans-Bold.woff2 and /dev/null differ
diff --git a/misc/DejaVuSans.woff2 b/misc/DejaVuSans.woff2
deleted file mode 100644
index 8437d4e..0000000
Binary files a/misc/DejaVuSans.woff2 and /dev/null differ
diff --git a/misc/DejaVuSansMono-Bold.woff2 b/misc/DejaVuSansMono-Bold.woff2
deleted file mode 100644
index f2b469a..0000000
Binary files a/misc/DejaVuSansMono-Bold.woff2 and /dev/null differ
diff --git a/misc/DejaVuSansMono.woff2 b/misc/DejaVuSansMono.woff2
deleted file mode 100644
index cf200e1..0000000
Binary files a/misc/DejaVuSansMono.woff2 and /dev/null differ
diff --git a/misc/DejaVuSerif-Bold.woff2 b/misc/DejaVuSerif-Bold.woff2
deleted file mode 100644
index 655ac56..0000000
Binary files a/misc/DejaVuSerif-Bold.woff2 and /dev/null differ
diff --git a/misc/DejaVuSerif.woff2 b/misc/DejaVuSerif.woff2
deleted file mode 100644
index 238566d..0000000
Binary files a/misc/DejaVuSerif.woff2 and /dev/null differ
diff --git a/misc/javadoc-overwrites.css b/misc/javadoc-overwrites.css
new file mode 100644
index 0000000..7eed81f
--- /dev/null
+++ b/misc/javadoc-overwrites.css
@@ -0,0 +1,2 @@
+:root { --body-font-size: 16px;}
+:root { --code-font-size: 16px;}
diff --git a/misc/javadoc.bottom.txt b/misc/javadoc.bottom.txt
index bf7dd56..d5589ac 100644
--- a/misc/javadoc.bottom.txt
+++ b/misc/javadoc.bottom.txt
@@ -4,26 +4,33 @@
Terms
— Privacy
+
+
+
+
+
\ No newline at end of file
diff --git a/misc/stylesheet.css b/misc/stylesheet.css
deleted file mode 100644
index e21b9b2..0000000
--- a/misc/stylesheet.css
+++ /dev/null
@@ -1,912 +0,0 @@
-/*
- * Javadoc style sheet
- */
-
-@font-face {
- font-family: 'DejaVu Serif';
- src: local('DejaVu Serif'), url('DejaVuSerif.woff2');
-}
-
-@font-face {
- font-family: 'DejaVu Serif';
- font-weight: bold;
- src: local('DejaVu Serif Bold'), url('DejaVuSerif-Bold.woff2');
-}
-
-@font-face {
- font-family: 'DejaVu Sans';
- src: local('DejaVu Sans'), url('DejaVuSans.woff2');
-}
-
-@font-face {
- font-family: 'DejaVu Sans';
- font-weight: bold;
- src: local('DejaVu Sans Bold'), url('DejaVuSans-Bold.woff2');
-}
-
-@font-face {
- font-family: 'DejaVu Sans Mono';
- src: local('DejaVu Sans Mono'), url('DejaVuSansMono.woff2');
-}
-
-@font-face {
- font-family: 'DejaVu Sans Mono';
- font-weight: bold;
- src: local('DejaVu Sans Mono Bold'), url('DejaVuSansMono-Bold.woff2');
-}
-
-/*
- * Styles for individual HTML elements.
- *
- * These are styles that are specific to individual HTML elements. Changing them affects the style of a particular
- * HTML element throughout the page.
- */
-
-body {
- background-color:#ffffff;
- color:#353833;
- font: normal 16px/1.5 "DejaVu Serif", serif;
- margin:0;
- padding:0;
- height:100%;
- width:100%;
-}
-iframe {
- margin:0;
- padding:0;
- height:100%;
- width:100%;
- overflow-y:scroll;
- border:none;
-}
-a:link, a:visited {
- text-decoration:none;
- color:#4A6782;
-}
-a[href]:hover, a[href]:focus {
- text-decoration:none;
- color:#bb7a2a;
-}
-a[name] {
- color:#353833;
-}
-pre {
- font-family: "DejaVu Sans Mono", monospace;
-}
-h1 {
- font-family: "DejaVu Sans", sans;
- font-size:20px;
-}
-h2 {
- font-family: "DejaVu Sans", sans;
- font-size:18px;
-}
-h3 {
- font-family: "DejaVu Sans", sans;
- font-size:16px;
-}
-h4 {
- font-family: "DejaVu Sans", sans;
- font-size:15px;
-}
-h5 {
- font-family: "DejaVu Sans", sans;
- font-size:14px;
-}
-h6 {
- font-family: "DejaVu Sans", sans;
- font-size:13px;
-}
-ul {
- list-style-type:disc;
-}
-code, tt {
- font-family: "DejaVu Sans Mono", monospace;
-}
-:not(h1, h2, h3, h4, h5, h6) > code,
-:not(h1, h2, h3, h4, h5, h6) > tt {
- /* font-size:14px; */
- padding-top:4px;
- margin-top:8px;
- line-height:1.4em;
-}
-dt code {
- font-family: "DejaVu Sans Mono", monospace;
- font-size:14px;
- padding-top:4px;
-}
-.summary-table dt code {
- font-family: "DejaVu Sans Mono", monospace;
- font-size:14px;
- vertical-align:top;
- padding-top:4px;
-}
-sup {
- font-size:8px;
-}
-
-/*
- * Styles for HTML generated by javadoc.
- *
- * These are style classes that are used by the standard doclet to generate HTML documentation.
- */
-
-/*
- * Styles for document title and copyright.
- */
-.clear {
- clear:both;
- height:0;
- overflow:hidden;
-}
-.about-language {
- float:right;
- padding:0 21px 8px 8px;
- font-size:11px;
- margin-top:-9px;
- height:2.9em;
-}
-.legal-copy {
- margin-left:.5em;
-}
-.tab {
- background-color:#0066FF;
- color:#ffffff;
- padding:8px;
- width:5em;
- font-weight:bold;
-}
-/*
- * Styles for navigation bar.
- */
-@media screen {
- .flex-box {
- position:fixed;
- display:flex;
- flex-direction:column;
- height: 100%;
- width: 100%;
- }
- .flex-header {
- flex: 0 0 auto;
- }
- .flex-content {
- flex: 1 1 auto;
- overflow-y: auto;
- }
-}
-.top-nav {
- background-color:#4D7A97;
- color:#FFFFFF;
- float:left;
- padding:0;
- width:100%;
- clear:right;
- min-height:2.8em;
- padding-top:10px;
- overflow:hidden;
- font-family: "DejaVu Sans", sans;
- font-size:80%;
-}
-.sub-nav {
- background-color:#dee3e9;
- float:left;
- width:100%;
- overflow:hidden;
- font-family: "DejaVu Sans", sans;
- font-size:80%;
-}
-.sub-nav div {
- clear:left;
- float:left;
- padding:0 0 5px 6px;
- text-transform:uppercase;
-}
-.sub-nav .nav-list {
- padding-top:5px;
-}
-ul.nav-list {
- display:block;
- margin:0 25px 0 0;
- padding:0;
-}
-ul.sub-nav-list {
- float:left;
- margin:0 25px 0 0;
- padding:0;
-}
-ul.nav-list li {
- list-style:none;
- float:left;
- padding: 5px 6px;
- text-transform:uppercase;
-}
-.sub-nav .nav-list-search {
- float:right;
- margin:0 0 0 0;
- padding:5px 6px;
- clear:none;
-}
-.nav-list-search label {
- position:relative;
- right:-16px;
-}
-ul.sub-nav-list li {
- list-style:none;
- float:left;
- padding-top:10px;
-}
-.top-nav a:link, .top-nav a:active, .top-nav a:visited {
- color:#FFFFFF;
- text-decoration:none;
- text-transform:uppercase;
-}
-.top-nav a:hover {
- text-decoration:none;
- color:#bb7a2a;
- text-transform:uppercase;
-}
-.nav-bar-cell1-rev {
- background-color:#F8981D;
- color:#253441;
- margin: auto 5px;
-}
-.skip-nav {
- position:absolute;
- top:auto;
- left:-9999px;
- overflow:hidden;
-}
-/*
- * Hide navigation links and search box in print layout
- */
-@media print {
- ul.nav-list, div.sub-nav {
- display:none;
- }
-}
-/*
- * Styles for page header and footer.
- */
-.title {
- color:#2c4557;
- margin:10px 0;
-}
-.sub-title {
- margin:5px 0 0 0;
-}
-.header ul {
- margin:0 0 15px 0;
- padding:0;
-}
-.header ul li, .footer ul li {
- list-style:none;
- font-size:80%;
-}
-/*
- * Styles for headings.
- */
-body.class-declaration-page .summary h2,
-body.class-declaration-page .details h2,
-body.class-use-page h2,
-body.module-declaration-page .block-list h2 {
- font-style: italic;
- padding:0;
- margin:15px 0;
-}
-body.class-declaration-page .summary h3,
-body.class-declaration-page .details h3,
-body.class-declaration-page .summary .inherited-list h2 {
- background-color:#dee3e9;
- border:1px solid #d0d9e0;
- margin:0 0 6px -8px;
- padding:7px 5px;
-}
-/*
- * Styles for page layout containers.
- */
-main {
- clear:both;
- padding:10px 20px;
- position:relative;
-}
-dl.notes > dt {
- font-family: "DejaVu Sans", sans;
- font-weight:bold;
- margin:10px 0 0 0;
- color:#4E4E4E;
-}
-dl.notes > dd {
- margin:5px 10px 10px 0;
-}
-dl.name-value > dt {
- margin-left:1px;
- /* font-size:1.1em; */
- display:inline;
- font-weight:bold;
-}
-dl.name-value > dd {
- margin:0 0 0 1px;
- /* font-size:1.1em; */
- display:inline;
-}
-/*
- * Styles for lists.
- */
-li.circle {
- list-style:circle;
-}
-ul.horizontal li {
- display:inline;
- /* font-size:0.9em; */
-}
-div.inheritance {
- margin:0;
- padding:0;
-}
-div.inheritance div.inheritance {
- margin-left:2em;
-}
-ul.block-list,
-ul.details-list,
-ul.member-list,
-ul.summary-list {
- margin:10px 0 10px 0;
- padding:0;
-}
-ul.block-list > li,
-ul.details-list > li,
-ul.member-list > li,
-ul.summary-list > li {
- list-style:none;
- margin-bottom:15px;
- line-height:1.4;
-}
-.summary-table dl, .summary-table dl dt, .summary-table dl dd {
- margin-top:0;
- margin-bottom:1px;
-}
-ul.see-list, ul.see-list-long {
- padding-left: 0;
- list-style: none;
-}
-ul.see-list li {
- display: inline;
-}
-ul.see-list li:not(:last-child):after,
-ul.see-list-long li:not(:last-child):after {
- content: ", ";
- white-space: pre-wrap;
-}
-/*
- * Styles for tables.
- */
-.summary-table, .details-table {
- width:100%;
- border-spacing:0;
- border-left:1px solid #EEE;
- border-right:1px solid #EEE;
- border-bottom:1px solid #EEE;
- padding:0;
-}
-.caption {
- position:relative;
- text-align:left;
- background-repeat:no-repeat;
- color:#253441;
- font-weight:bold;
- clear:none;
- overflow:hidden;
- padding:0;
- padding-top:10px;
- padding-left:1px;
- margin:0;
- white-space:pre;
- font-family: 'DejaVu Sans';
-}
-.caption a:link, .caption a:visited {
- color:#1f389c;
-}
-.caption a:hover,
-.caption a:active {
- color:#FFFFFF;
-}
-.caption span {
- white-space:nowrap;
- padding-top:5px;
- padding-left:12px;
- padding-right:12px;
- padding-bottom:7px;
- display:inline-block;
- float:left;
- background-color:#F8981D;
- border: none;
- height:16px;
-}
-div.table-tabs {
- padding:10px 0 0 1px;
- margin:0;
-}
-div.table-tabs > button {
- border: none;
- cursor: pointer;
- padding: 5px 12px 7px 12px;
- font-weight: bold;
- margin-right: 3px;
-}
-div.table-tabs > button.active-table-tab {
- background: #F8981D;
- color: #253441;
-}
-div.table-tabs > button.table-tab {
- background: #4D7A97;
- color: #FFFFFF;
-}
-.two-column-summary {
- display: grid;
- grid-template-columns: minmax(15%, max-content) minmax(15%, auto);
-}
-.three-column-summary {
- display: grid;
- grid-template-columns: minmax(10%, max-content) minmax(15%, max-content) minmax(15%, auto);
-}
-#method-summary-table .three-column-summary {
- grid-template-columns: minmax(10%, 20%) minmax(15%, max-content) minmax(15%, auto);
-}
-.four-column-summary {
- display: grid;
- grid-template-columns: minmax(10%, max-content) minmax(10%, max-content) minmax(10%, max-content) minmax(10%, auto);
-}
-@media screen and (max-width: 600px) {
- .two-column-summary {
- display: grid;
- grid-template-columns: 1fr;
- }
-}
-@media screen and (max-width: 800px) {
- .three-column-summary {
- display: grid;
- grid-template-columns: minmax(10%, max-content) minmax(25%, auto);
- }
- .three-column-summary .col-last {
- grid-column-end: span 2;
- }
-}
-@media screen and (max-width: 1000px) {
- .four-column-summary {
- display: grid;
- grid-template-columns: minmax(15%, max-content) minmax(15%, auto);
- }
-}
-.summary-table > div, .details-table > div {
- text-align:left;
- padding: 8px 3px 3px 7px;
-}
-.col-first, .col-second, .col-last, .col-constructor-name, .col-summary-item-name {
- vertical-align:top;
- padding-right:0;
- padding-top:8px;
- padding-bottom:3px;
-}
-.table-header {
- background:#dee3e9;
- font-family: 'DejaVu Sans';
- font-weight: bold;
-}
-/*
-.col-first, .col-first {
- font-size:13px;
-}
-.col-second, .col-second, .col-last, .col-constructor-name, .col-summary-item-name, .col-last {
- font-size:13px;
-}
-*/
-.col-first, .col-second, .col-constructor-name {
- vertical-align:top;
- overflow: auto;
-}
-.col-last {
- white-space:normal;
-}
-/*
-.col-first a:link, .col-first a:visited,
-.col-second a:link, .col-second a:visited,
-.col-first a:link, .col-first a:visited,
-.col-second a:link, .col-second a:visited,
-.col-constructor-name a:link, .col-constructor-name a:visited,
-.col-summary-item-name a:link, .col-summary-item-name a:visited,
-.constant-values-container a:link, .constant-values-container a:visited,
-.all-classes-container a:link, .all-classes-container a:visited,
-.all-packages-container a:link, .all-packages-container a:visited {
- font-weight:bold;
-}
-*/
-.table-sub-heading-color {
- background-color:#EEEEFF;
-}
-.even-row-color, .even-row-color .table-header {
- background-color:#FFFFFF;
-}
-.odd-row-color, .odd-row-color .table-header {
- background-color:#EEEEEF;
-}
-/*
- * Styles for contents.
- */
-.deprecated-content {
- margin:0;
- padding:10px 0;
-}
-/*
-div.block {
- font-size:14px;
- font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif;
-}
-*/
-.col-last div {
- padding-top:0;
-}
-.col-last a {
- padding-bottom:3px;
-}
-.module-signature,
-.package-signature,
-.type-signature,
-.member-signature {
- font-family: "DejaVu Sans Mono", monospace;
- /* font-size:14px; */
- margin:14px 0;
- white-space: pre-wrap;
-}
-.module-signature,
-.package-signature,
-.type-signature {
- margin-top: 0;
-}
-.member-signature .type-parameters-long,
-.member-signature .parameters,
-.member-signature .exceptions {
- display: inline-block;
- vertical-align: top;
- white-space: pre;
-}
-.member-signature .type-parameters {
- white-space: normal;
-}
-/*
- * Styles for formatting effect.
- */
-.source-line-no {
- color:green;
- padding:0 30px 0 0;
-}
-h1.hidden {
- visibility:hidden;
- overflow:hidden;
- /* font-size:10px; */
-}
-.block {
- display:block;
- margin:0 10px 5px 0;
- color:#474747;
-}
-.deprecated-label, .descfrm-type-label, .implementation-label, .member-name-label, .member-name-link,
-.module-label-in-package, .module-label-in-type, .override-specify-label, .package-label-in-type,
-.package-hierarchy-label, .type-name-label, .type-name-link, .search-tag-link, .preview-label {
- font-family: "DejaVu Sans", sans;
- font-weight:bold;
-}
-.sub-title, .inheritance, .all-packages-table-tab1.col-first,
- .summary-table .col-first {
- font-family: "DejaVu Sans", sans;
-}
-.deprecation-comment, .help-footnote, .preview-comment {
- font-style:italic;
-}
-.deprecation-block {
- /* font-size:14px; */
- font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif;
- border-style:solid;
- border-width:thin;
- border-radius:10px;
- padding:10px;
- margin-bottom:10px;
- margin-right:10px;
- display:inline-block;
-}
-.preview-block {
- /* font-size:14px; */
- font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif;
- border-style:solid;
- border-width:thin;
- border-radius:10px;
- padding:10px;
- margin-bottom:10px;
- margin-right:10px;
- display:inline-block;
-}
-div.block div.deprecation-comment {
- font-style:normal;
-}
-/*
- * Styles specific to HTML5 elements.
- */
-main, nav, header, footer, section {
- display:block;
-}
-/*
- * Styles for javadoc search.
- */
-.ui-autocomplete-category {
- font-weight:bold;
- /* font-size:15px; */
- padding:7px 0 7px 3px;
- background-color:#4D7A97;
- color:#FFFFFF;
-}
-.result-item {
- /* font-size:13px; */
-}
-.ui-autocomplete {
- max-height:85%;
- max-width:65%;
- overflow-y:scroll;
- overflow-x:scroll;
- white-space:nowrap;
- box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
-}
-ul.ui-autocomplete {
- position:fixed;
- z-index:999999;
-}
-ul.ui-autocomplete li {
- float:left;
- clear:both;
- width:100%;
-}
-.result-highlight {
- font-weight:bold;
-}
-#search-input {
- background-image:url('resources/glass.png');
- background-size:13px;
- background-repeat:no-repeat;
- background-position:2px 3px;
- padding-left:20px;
- position:relative;
- right:-18px;
- width:400px;
-}
-#reset-button {
- background-color: rgb(255,255,255);
- background-image:url('resources/x.png');
- background-position:center;
- background-repeat:no-repeat;
- background-size:12px;
- border:0 none;
- width:16px;
- height:16px;
- position:relative;
- left:-4px;
- top:-4px;
- font-size:0px;
-}
-.watermark {
- color:#545454;
-}
-.search-tag-desc-result {
- font-style:italic;
- /* font-size:11px; */
-}
-.search-tag-holder-result {
- font-style:italic;
- /* font-size:12px; */
-}
-.search-tag-result:target {
- background-color:yellow;
-}
-.module-graph span {
- display:none;
- position:absolute;
-}
-.module-graph:hover span {
- display:block;
- margin: -100px 0 0 100px;
- z-index: 1;
-}
-.inherited-list {
- margin: 10px 0 10px 0;
-}
-section.class-description {
- line-height: 1.4;
-}
-.summary section[class$="-summary"], .details section[class$="-details"],
-.class-uses .detail, .serialized-class-details {
- padding: 0px 20px 5px 10px;
- border: 1px solid #ededed;
- background-color: #f8f8f8;
-}
-.inherited-list, section[class$="-details"] .detail {
- padding:0 0 5px 8px;
- background-color:#ffffff;
- border:none;
-}
-.vertical-separator {
- padding: 0 5px;
-}
-ul.help-section-list {
- margin: 0;
-}
-ul.help-subtoc > li {
- display: inline-block;
- padding-right: 5px;
- /* font-size: smaller; */
-}
-ul.help-subtoc > li::before {
- content: "\2022" ;
- padding-right:2px;
-}
-span.help-note {
- font-style: italic;
-}
-/*
- * Indicator icon for external links.
- */
-main a[href*="://"]::after {
- content:"";
- display:inline-block;
- background-image:url('data:image/svg+xml; utf8, \
- ');
- background-size:100% 100%;
- width:7px;
- height:7px;
- margin-left:2px;
- margin-bottom:4px;
-}
-main a[href*="://"]:hover::after,
-main a[href*="://"]:focus::after {
- background-image:url('data:image/svg+xml; utf8, \
- ');
-}
-
-/*
- * Styles for user-provided tables.
- *
- * borderless:
- * No borders, vertical margins, styled caption.
- * This style is provided for use with existing doc comments.
- * In general, borderless tables should not be used for layout purposes.
- *
- * plain:
- * Plain borders around table and cells, vertical margins, styled caption.
- * Best for small tables or for complex tables for tables with cells that span
- * rows and columns, when the "striped" style does not work well.
- *
- * striped:
- * Borders around the table and vertical borders between cells, striped rows,
- * vertical margins, styled caption.
- * Best for tables that have a header row, and a body containing a series of simple rows.
- */
-
-table.borderless,
-table.plain,
-table.striped {
- margin-top: 10px;
- margin-bottom: 10px;
-}
-table.borderless > caption,
-table.plain > caption,
-table.striped > caption {
- font-weight: bold;
- /* font-size: smaller; */
-}
-table.borderless th, table.borderless td,
-table.plain th, table.plain td,
-table.striped th, table.striped td {
- padding: 2px 5px;
-}
-table.borderless,
-table.borderless > thead > tr > th, table.borderless > tbody > tr > th, table.borderless > tr > th,
-table.borderless > thead > tr > td, table.borderless > tbody > tr > td, table.borderless > tr > td {
- border: none;
-}
-table.borderless > thead > tr, table.borderless > tbody > tr, table.borderless > tr {
- background-color: transparent;
-}
-table.plain {
- border-collapse: collapse;
- border: 1px solid black;
-}
-table.plain > thead > tr, table.plain > tbody tr, table.plain > tr {
- background-color: transparent;
-}
-table.plain > thead > tr > th, table.plain > tbody > tr > th, table.plain > tr > th,
-table.plain > thead > tr > td, table.plain > tbody > tr > td, table.plain > tr > td {
- border: 1px solid black;
-}
-table.striped {
- border-collapse: collapse;
- border: 1px solid black;
-}
-table.striped > thead {
- background-color: #E3E3E3;
-}
-table.striped > thead > tr > th, table.striped > thead > tr > td {
- border: 1px solid black;
-}
-table.striped > tbody > tr:nth-child(even) {
- background-color: #EEE
-}
-table.striped > tbody > tr:nth-child(odd) {
- background-color: #FFF
-}
-table.striped > tbody > tr > th, table.striped > tbody > tr > td {
- border-left: 1px solid black;
- border-right: 1px solid black;
-}
-table.striped > tbody > tr > th {
- font-weight: normal;
-}
-/**
- * Tweak font sizes and paddings for small screens.
- */
-@media screen and (max-width: 1050px) {
- #search-input {
- width: 300px;
- }
-}
-@media screen and (max-width: 800px) {
- #search-input {
- width: 200px;
- }
- .top-nav,
- .bottom-nav {
- font-size: 80%;
- padding-top: 6px;
- }
- .sub-nav {
- font-size: 80%;
- }
- .about-language {
- padding-right: 16px;
- }
- ul.nav-list li,
- .sub-nav .nav-list-search {
- padding: 6px;
- }
- ul.sub-nav-list li {
- padding-top: 5px;
- }
- main {
- padding: 10px;
- }
- .summary section[class$="-summary"], .details section[class$="-details"],
- .class-uses .detail, .serialized-class-details {
- padding: 0 8px 5px 8px;
- }
- body {
- -webkit-text-size-adjust: none;
- }
-}
-@media screen and (max-width: 500px) {
- #search-input {
- width: 150px;
- }
- .top-nav,
- .bottom-nav {
- font-size: 80%;
- }
- .sub-nav {
- font-size: 80%;
- }
- .about-language {
- font-size: 80%;
- padding-right: 12px;
- }
-}
diff --git a/org.jdrupes.vmoperator.common/.eclipse-pmd b/org.jdrupes.vmoperator.common/.eclipse-pmd
index 8b394f8..5d69caa 100644
--- a/org.jdrupes.vmoperator.common/.eclipse-pmd
+++ b/org.jdrupes.vmoperator.common/.eclipse-pmd
@@ -2,6 +2,6 @@
-
+
diff --git a/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs b/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs
new file mode 100644
index 0000000..8b8b906
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs
@@ -0,0 +1,7 @@
+add_header=true
+eclipse.preferences.version=1
+header_text=/*\n * VM-Operator\n * Copyright (C) 2024 Michael N. Lipp\n * \n * This program is free software\: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see .\n */
+project_specific_settings=true
+visibility_package=false
+visibility_private=false
+visibility_protected=false
diff --git a/org.jdrupes.vmoperator.common/build.gradle b/org.jdrupes.vmoperator.common/build.gradle
index ed082a1..e72cb14 100644
--- a/org.jdrupes.vmoperator.common/build.gradle
+++ b/org.jdrupes.vmoperator.common/build.gradle
@@ -10,5 +10,8 @@ plugins {
dependencies {
api project(':org.jdrupes.vmoperator.util')
- api 'io.kubernetes:client-java:[18.0.0,19)'
+ api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)'
+ api 'io.kubernetes:client-java:[19.0.0,20.0.0)'
+ api 'org.yaml:snakeyaml'
+ api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]'
}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java
index 3ebe29d..b9de69f 100644
--- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java
@@ -21,6 +21,7 @@ package org.jdrupes.vmoperator.common;
/**
* Some constants.
*/
+@SuppressWarnings("PMD.DataClass")
public class Constants {
/** The Constant APP_NAME. */
@@ -29,9 +30,98 @@ public class Constants {
/** The Constant VM_OP_NAME. */
public static final String VM_OP_NAME = "vm-operator";
- /** The Constant VM_OP_GROUP. */
- public static final String VM_OP_GROUP = "vmoperator.jdrupes.org";
+ /**
+ * Constants related to the CRD.
+ */
+ @SuppressWarnings("PMD.ShortClassName")
+ public static class Crd {
+ /** The Constant GROUP. */
+ public static final String GROUP = "vmoperator.jdrupes.org";
- /** The Constant VM_OP_KIND_VM. */
- public static final String VM_OP_KIND_VM = "VirtualMachine";
+ /** The Constant KIND_VM. */
+ public static final String KIND_VM = "VirtualMachine";
+
+ /** The Constant KIND_VM_POOL. */
+ public static final String KIND_VM_POOL = "VmPool";
+ }
+
+ /**
+ * Status related constants.
+ */
+ public static class Status {
+ /** The Constant RUNNER_VERSION. */
+ public static final String RUNNER_VERSION = "runnerVersion";
+
+ /** The Constant CPUS. */
+ public static final String CPUS = "cpus";
+
+ /** The Constant RAM. */
+ public static final String RAM = "ram";
+
+ /** The Constant OSINFO. */
+ public static final String OSINFO = "osinfo";
+
+ /** The Constant DISPLAY_PASSWORD_SERIAL. */
+ public static final String DISPLAY_PASSWORD_SERIAL
+ = "displayPasswordSerial";
+
+ /** The Constant LOGGED_IN_USER. */
+ public static final String LOGGED_IN_USER = "loggedInUser";
+
+ /** The Constant CONSOLE_CLIENT. */
+ public static final String CONSOLE_CLIENT = "consoleClient";
+
+ /** The Constant CONSOLE_USER. */
+ public static final String CONSOLE_USER = "consoleUser";
+
+ /** The Constant ASSIGNMENT. */
+ public static final String ASSIGNMENT = "assignment";
+
+ /**
+ * Conditions used in Status.
+ */
+ public static class Condition {
+ /** The Constant COND_RUNNING. */
+ public static final String RUNNING = "Running";
+
+ /** The Constant COND_BOOTED. */
+ public static final String BOOTED = "Booted";
+
+ /** The Constant COND_VMOP_AGENT. */
+ public static final String VMOP_AGENT = "VmopAgentConnected";
+
+ /** The Constant COND_USER_LOGGED_IN. */
+ public static final String USER_LOGGED_IN = "UserLoggedIn";
+
+ /** The Constant COND_CONSOLE. */
+ public static final String CONSOLE_CONNECTED = "ConsoleConnected";
+
+ /**
+ * Reasons used in conditions.
+ */
+ public static class Reason {
+ /** The Constant NOT_REQUESTED. */
+ public static final String NOT_REQUESTED = "NotRequested";
+
+ /** The Constant USER_LOGGED_IN. */
+ public static final String LOGGED_IN = "LoggedIn";
+ }
+ }
+ }
+
+ /**
+ * DisplaySecret related constants.
+ */
+ public static class DisplaySecret {
+
+ /** The Constant NAME. */
+ public static final String NAME = "display-secret";
+
+ /** The Constant PASSWORD. */
+ public static final String PASSWORD = "display-password";
+
+ /** The Constant EXPIRY. */
+ public static final String EXPIRY = "password-expiry";
+
+ }
}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java
index 47b7208..68f52eb 100644
--- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java
@@ -32,13 +32,11 @@ import java.util.regex.Pattern;
public class Convertions {
@SuppressWarnings({ "PMD.UseConcurrentHashMap",
- "PMD.FieldNamingConventions", "PMD.VariableNamingConventions" })
+ "PMD.FieldNamingConventions" })
private static final Map unitMap = new HashMap<>();
- @SuppressWarnings({ "PMD.FieldNamingConventions",
- "PMD.VariableNamingConventions" })
+ @SuppressWarnings({ "PMD.FieldNamingConventions" })
private static final List> unitMappings;
- @SuppressWarnings({ "PMD.FieldNamingConventions",
- "PMD.VariableNamingConventions" })
+ @SuppressWarnings({ "PMD.FieldNamingConventions" })
private static final Pattern memorySize
= Pattern.compile("^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*");
@@ -69,7 +67,6 @@ public class Convertions {
* @param amount the amount
* @return the big integer
*/
- @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public static BigInteger parseMemory(Object amount) {
if (amount == null) {
return (BigInteger) amount;
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java
new file mode 100644
index 0000000..d21eed4
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java
@@ -0,0 +1,197 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import com.google.gson.Gson;
+import com.google.gson.InstanceCreator;
+import com.google.gson.JsonObject;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+import io.kubernetes.client.openapi.ApiClient;
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Type;
+
+/**
+ * A factory for creating objects.
+ *
+ * @param the generic type
+ * @param the generic type
+ */
+public class DynamicTypeAdapterFactory> implements TypeAdapterFactory {
+
+ private final Class objectClass;
+ private final Class objectListClass;
+
+ /**
+ * Make sure that this adapter is registered.
+ *
+ * @param client the client
+ */
+ public void register(ApiClient client) {
+ if (!ModelCreator.class
+ .equals(client.getJSON().getGson().getAdapter(objectClass)
+ .getClass())
+ || !ModelsCreator.class.equals(client.getJSON().getGson()
+ .getAdapter(objectListClass).getClass())) {
+ Gson gson = client.getJSON().getGson();
+ client.getJSON().setGson(gson.newBuilder()
+ .registerTypeAdapterFactory(this).create());
+ }
+ }
+
+ /**
+ * Instantiates a new generic type adapter factory.
+ *
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ */
+ public DynamicTypeAdapterFactory(Class objectClass,
+ Class objectListClass) {
+ this.objectClass = objectClass;
+ this.objectListClass = objectListClass;
+ }
+
+ /**
+ * Creates a type adapter for the given type.
+ *
+ * @param the generic type
+ * @param gson the gson
+ * @param typeToken the type token
+ * @return the type adapter or null if the type is not handles by
+ * this factory
+ */
+ @SuppressWarnings("unchecked")
+ @Override
+ public TypeAdapter create(Gson gson, TypeToken typeToken) {
+ if (TypeToken.get(objectClass).equals(typeToken)) {
+ return (TypeAdapter) new ModelCreator(gson);
+ }
+ if (TypeToken.get(objectListClass).equals(typeToken)) {
+ return (TypeAdapter) new ModelsCreator(gson);
+ }
+ return null;
+ }
+
+ /**
+ * The Class ModelCreator.
+ */
+ private class ModelCreator extends TypeAdapter
+ implements InstanceCreator {
+ private final Gson delegate;
+
+ /**
+ * Instantiates a new object state creator.
+ *
+ * @param delegate the delegate
+ */
+ public ModelCreator(Gson delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public O createInstance(Type type) {
+ try {
+ return objectClass.getConstructor(Gson.class, JsonObject.class)
+ .newInstance(delegate, null);
+ } catch (InstantiationException | IllegalAccessException
+ | IllegalArgumentException | InvocationTargetException
+ | NoSuchMethodException | SecurityException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public void write(JsonWriter jsonWriter, O state)
+ throws IOException {
+ jsonWriter.jsonValue(delegate.toJson(state.data()));
+ }
+
+ @Override
+ public O read(JsonReader jsonReader)
+ throws IOException {
+ try {
+ return objectClass.getConstructor(Gson.class, JsonObject.class)
+ .newInstance(delegate,
+ delegate.fromJson(jsonReader, JsonObject.class));
+ } catch (InstantiationException | IllegalAccessException
+ | IllegalArgumentException | InvocationTargetException
+ | NoSuchMethodException | SecurityException e) {
+ return null;
+ }
+ }
+ }
+
+ /**
+ * The Class ModelsCreator.
+ */
+ private class ModelsCreator extends TypeAdapter
+ implements InstanceCreator {
+
+ private final Gson delegate;
+
+ /**
+ * Instantiates a new object states creator.
+ *
+ * @param delegate the delegate
+ */
+ public ModelsCreator(Gson delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public L createInstance(Type type) {
+ try {
+ return objectListClass
+ .getConstructor(Gson.class, JsonObject.class)
+ .newInstance(delegate, null);
+ } catch (InstantiationException | IllegalAccessException
+ | IllegalArgumentException | InvocationTargetException
+ | NoSuchMethodException | SecurityException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public void write(JsonWriter jsonWriter, L states)
+ throws IOException {
+ jsonWriter.jsonValue(delegate.toJson(states.data()));
+ }
+
+ @Override
+ public L read(JsonReader jsonReader)
+ throws IOException {
+ try {
+ return objectListClass
+ .getConstructor(Gson.class, JsonObject.class)
+ .newInstance(delegate,
+ delegate.fromJson(jsonReader, JsonObject.class));
+ } catch (InstantiationException | IllegalAccessException
+ | IllegalArgumentException | InvocationTargetException
+ | NoSuchMethodException | SecurityException e) {
+ return null;
+ }
+ }
+ }
+
+}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java
index f61b431..3870337 100644
--- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java
@@ -1,6 +1,6 @@
/*
* VM-Operator
- * Copyright (C) 2023 Michael N. Lipp
+ * Copyright (C) 2023,2024 Michael N. Lipp
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -18,170 +18,141 @@
package org.jdrupes.vmoperator.common;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.Discovery;
+import io.kubernetes.client.Discovery.APIResource;
import io.kubernetes.client.common.KubernetesListObject;
import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.common.KubernetesType;
import io.kubernetes.client.custom.V1Patch;
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.openapi.ApiException;
-import io.kubernetes.client.openapi.apis.ApisApi;
-import io.kubernetes.client.openapi.apis.CustomObjectsApi;
-import io.kubernetes.client.openapi.models.V1APIGroup;
-import io.kubernetes.client.openapi.models.V1ConfigMap;
-import io.kubernetes.client.openapi.models.V1ConfigMapList;
-import io.kubernetes.client.openapi.models.V1GroupVersionForDiscovery;
+import io.kubernetes.client.openapi.apis.EventsV1Api;
+import io.kubernetes.client.openapi.models.EventsV1Event;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1ObjectReference;
-import io.kubernetes.client.openapi.models.V1PersistentVolumeClaim;
-import io.kubernetes.client.openapi.models.V1PersistentVolumeClaimList;
-import io.kubernetes.client.openapi.models.V1Pod;
-import io.kubernetes.client.openapi.models.V1PodList;
+import io.kubernetes.client.util.Strings;
import io.kubernetes.client.util.generic.GenericKubernetesApi;
-import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi;
-import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
-import io.kubernetes.client.util.generic.options.DeleteOptions;
+import io.kubernetes.client.util.generic.KubernetesApiResponse;
import io.kubernetes.client.util.generic.options.PatchOptions;
+import java.io.Reader;
+import java.net.HttpURLConnection;
+import java.time.OffsetDateTime;
+import java.util.Map;
import java.util.Optional;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* Helpers for K8s API.
*/
-@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass",
- "PMD.DataflowAnomalyAnalysis" })
+@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass" })
public class K8s {
/**
- * Given a groupVersion, returns only the version.
- *
- * @param groupVersion the group version
- * @return the string
- */
- public static String version(String groupVersion) {
- return groupVersion.substring(groupVersion.lastIndexOf('/') + 1);
- }
-
- /**
- * Get PVC API.
- *
- * @param client the client
- * @return the generic kubernetes api
- */
- public static GenericKubernetesApi pvcApi(ApiClient client) {
- return new GenericKubernetesApi<>(V1PersistentVolumeClaim.class,
- V1PersistentVolumeClaimList.class, "", "v1",
- "persistentvolumeclaims", client);
- }
-
- /**
- * Get config map API.
- *
- * @param client the client
- * @return the generic kubernetes api
- */
- public static GenericKubernetesApi cmApi(ApiClient client) {
- return new GenericKubernetesApi<>(V1ConfigMap.class,
- V1ConfigMapList.class, "", "v1", "configmaps", client);
- }
-
- /**
- * Get pod API.
- *
- * @param client the client
- * @return the generic kubernetes api
- */
- public static GenericKubernetesApi
- podApi(ApiClient client) {
- return new GenericKubernetesApi<>(V1Pod.class, V1PodList.class, "",
- "v1", "pods", client);
- }
-
- /**
- * Get the API for a custom resource.
- *
- * @param client the client
- * @param group the group
- * @param kind the kind
- * @param namespace the namespace
- * @param name the name
- * @return the dynamic kubernetes api
- * @throws ApiException the api exception
- */
- @SuppressWarnings("PMD.UseObjectForClearerAPI")
- public static Optional crApi(ApiClient client,
- String group, String kind, String namespace, String name)
- throws ApiException {
- var apis = new ApisApi(client).getAPIVersions();
- var crdVersions = apis.getGroups().stream()
- .filter(g -> g.getName().equals(group)).findFirst()
- .map(V1APIGroup::getVersions).stream().flatMap(l -> l.stream())
- .map(V1GroupVersionForDiscovery::getVersion).toList();
- var coa = new CustomObjectsApi(client);
- for (var crdVersion : crdVersions) {
- var crdApiRes = coa.getAPIResources(group, crdVersion)
- .getResources().stream().filter(r -> kind.equals(r.getKind()))
- .findFirst();
- if (crdApiRes.isEmpty()) {
- continue;
- }
- @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
- var crApi = new DynamicKubernetesApi(group,
- crdVersion, crdApiRes.get().getName(), client);
- var customResource = crApi.get(namespace, name);
- if (customResource.isSuccess()) {
- return Optional.of(crApi);
- }
- }
- return Optional.empty();
- }
-
- /**
- * Get an object from its metadata.
+ * Returns the result from an API call as {@link Optional} if the
+ * call was successful. Returns an empty `Optional` if the status
+ * code is 404 (not found). Else throws an exception.
*
* @param the generic type
- * @param the generic type
- * @param api the api
- * @param meta the meta
- * @return the object
+ * @param response the response
+ * @return the optional
+ * @throws ApiException the API exception
*/
- public static
- Optional
- get(GenericKubernetesApi api, V1ObjectMeta meta) {
- var response = api.get(meta.getNamespace(), meta.getName());
+ public static Optional
+ optional(KubernetesApiResponse response) throws ApiException {
if (response.isSuccess()) {
return Optional.of(response.getObject());
}
+ if (response.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
+ return Optional.empty();
+ }
+ response.throwsApiException();
+ // Never reached
return Optional.empty();
}
/**
- * Delete an object.
+ * Returns a new context with the given version as preferred version.
*
- * @param the generic type
- * @param the generic type
- * @param api the api
- * @param object the object
+ * @param context the context
+ * @param version the version
+ * @return the API resource
*/
- public static
- void delete(GenericKubernetesApi api, T object)
- throws ApiException {
- api.delete(object.getMetadata().getNamespace(),
- object.getMetadata().getName()).throwsApiException();
+ public static APIResource preferred(APIResource context, String version) {
+ assert context.getVersions().contains(version);
+ return new APIResource(context.getGroup(),
+ context.getVersions(), version, context.getKind(),
+ context.getNamespaced(), context.getResourcePlural(),
+ context.getResourceSingular());
}
/**
- * Delete an object.
+ * Return a string representation of the context (API resource).
*
- * @param the generic type
- * @param the generic type
- * @param api the api
- * @param object the object
+ * @param context the context
+ * @return the string
*/
- public static
- void delete(GenericKubernetesApi api, T object,
- DeleteOptions options) throws ApiException {
- api.delete(object.getMetadata().getNamespace(),
- object.getMetadata().getName(), options).throwsApiException();
+ @SuppressWarnings("PMD.UseLocaleWithCaseConversions")
+ public static String toString(APIResource context) {
+ return (Strings.isNullOrEmpty(context.getGroup()) ? ""
+ : context.getGroup() + "/")
+ + context.getPreferredVersion().toUpperCase()
+ + context.getKind();
+ }
+
+ /**
+ * Convert Yaml to Json.
+ *
+ * @param client the client
+ * @param yaml the yaml
+ * @return the json element
+ */
+ public static JsonObject yamlToJson(ApiClient client, Reader yaml) {
+ // Avoid Yaml.load due to
+ // https://github.com/kubernetes-client/java/issues/2741
+ Map yamlData
+ = new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml);
+
+ // There's no short-cut from Java (collections) to Gson
+ var gson = client.getJSON().getGson();
+ var jsonText = gson.toJson(yamlData);
+ return gson.fromJson(jsonText, JsonObject.class);
+ }
+
+ /**
+ * Lookup the specified API resource. If the version is `null` or
+ * empty, the preferred version in the result is the default
+ * returned from the server.
+ *
+ * @param client the client
+ * @param group the group
+ * @param version the version
+ * @param kind the kind
+ * @return the optional
+ * @throws ApiException the api exception
+ */
+ public static Optional context(ApiClient client,
+ String group, String version, String kind) throws ApiException {
+ var apiMatch = new Discovery(client).findAll().stream()
+ .filter(r -> r.getGroup().equals(group) && r.getKind().equals(kind)
+ && (Strings.isNullOrEmpty(version)
+ || r.getVersions().contains(version)))
+ .findFirst();
+ if (apiMatch.isEmpty()) {
+ return Optional.empty();
+ }
+ var apiRes = apiMatch.get();
+ if (!Strings.isNullOrEmpty(version)) {
+ if (!apiRes.getVersions().contains(version)) {
+ return Optional.empty();
+ }
+ apiRes = new APIResource(apiRes.getGroup(), apiRes.getVersions(),
+ version, apiRes.getKind(), apiRes.getNamespaced(),
+ apiRes.getResourcePlural(), apiRes.getResourceSingular());
+ }
+ return Optional.of(apiRes);
}
/**
@@ -192,8 +163,10 @@ public class K8s {
* @param api the api
* @param existing the existing
* @param update the update
+ * @return the t
* @throws ApiException the api exception
*/
+ @SuppressWarnings("PMD.GenericsNaming")
public static
T apply(GenericKubernetesApi api, T existing, String update)
throws ApiException {
@@ -213,7 +186,7 @@ public class K8s {
* @return the v 1 object reference
*/
public static V1ObjectReference
- objectReference(DynamicKubernetesObject object) {
+ objectReference(KubernetesObject object) {
return new V1ObjectReference().apiVersion(object.getApiVersion())
.kind(object.getKind())
.namespace(object.getMetadata().getNamespace())
@@ -221,4 +194,56 @@ public class K8s {
.resourceVersion(object.getMetadata().getResourceVersion())
.uid(object.getMetadata().getUid());
}
+
+ /**
+ * Creates an event related to the object, adding reasonable defaults.
+ *
+ * * If `kind` is not set, it is set to "Event".
+ * * If `metadata.namespace` is not set, it is set
+ * to the object's namespace.
+ * * If neither `metadata.name` nor `matadata.generateName` are set,
+ * set `generateName` to the object's name with a dash appended.
+ * * If `reportingInstance` is not set, set it to the object's name.
+ * * If `eventTime` is not set, set it to now.
+ * * If `type` is not set, set it to "Normal"
+ * * If `regarding` is not set, set it to the given object.
+ *
+ * @param client the client
+ * @param object the object
+ * @param event the event
+ * @throws ApiException the api exception
+ */
+ @SuppressWarnings("PMD.NPathComplexity")
+ public static void createEvent(ApiClient client,
+ KubernetesObject object, EventsV1Event event)
+ throws ApiException {
+ if (Strings.isNullOrEmpty(event.getKind())) {
+ event.kind("Event");
+ }
+ if (event.getMetadata() == null) {
+ event.metadata(new V1ObjectMeta());
+ }
+ if (Strings.isNullOrEmpty(event.getMetadata().getNamespace())) {
+ event.getMetadata().namespace(object.getMetadata().getNamespace());
+ }
+ if (Strings.isNullOrEmpty(event.getMetadata().getName())
+ && Strings.isNullOrEmpty(event.getMetadata().getGenerateName())) {
+ event.getMetadata()
+ .generateName(object.getMetadata().getName() + "-");
+ }
+ if (Strings.isNullOrEmpty(event.getReportingInstance())) {
+ event.reportingInstance(object.getMetadata().getName());
+ }
+ if (event.getEventTime() == null) {
+ event.eventTime(OffsetDateTime.now());
+ }
+ if (Strings.isNullOrEmpty(event.getType())) {
+ event.type("Normal");
+ }
+ if (event.getRegarding() == null) {
+ event.regarding(objectReference(object));
+ }
+ new EventsV1Api(client).createNamespacedEvent(
+ object.getMetadata().getNamespace(), event, null, null, null, null);
+ }
}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java
new file mode 100644
index 0000000..272da2b
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java
@@ -0,0 +1,954 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.openapi.ApiCallback;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.ApiResponse;
+import io.kubernetes.client.openapi.JSON;
+import io.kubernetes.client.openapi.Pair;
+import io.kubernetes.client.openapi.auth.Authentication;
+import io.kubernetes.client.util.ClientBuilder;
+import io.kubernetes.client.util.generic.options.PatchOptions;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Type;
+import java.text.DateFormat;
+import java.time.format.DateTimeFormatter;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import javax.net.ssl.KeyManager;
+import okhttp3.Call;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Request.Builder;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+/**
+ * A client with some additional properties.
+ */
+@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods",
+ "checkstyle:LineLength", "PMD.CouplingBetweenObjects", "PMD.GodClass" })
+public class K8sClient extends ApiClient {
+
+ private ApiClient apiClient;
+ private PatchOptions defaultPatchOptions;
+
+ /**
+ * Instantiates a new client.
+ *
+ * @throws IOException Signals that an I/O exception has occurred.
+ */
+ public K8sClient() throws IOException {
+ defaultPatchOptions = new PatchOptions();
+ defaultPatchOptions.setFieldManager("kubernetes-java-kubectl-apply");
+ }
+
+ private ApiClient apiClient() {
+ if (apiClient == null) {
+ try {
+ apiClient = ClientBuilder.standard().build();
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ return apiClient;
+ }
+
+ /**
+ * Gets the default patch options.
+ *
+ * @return the defaultPatchOptions
+ */
+ public PatchOptions defaultPatchOptions() {
+ return defaultPatchOptions;
+ }
+
+ /**
+ * Changes the default patch options.
+ *
+ * @param patchOptions the patch options
+ * @return the client
+ */
+ public K8sClient with(PatchOptions patchOptions) {
+ defaultPatchOptions = patchOptions;
+ return this;
+ }
+
+ /**
+ * Gets the base path.
+ *
+ * @return the base path
+ * @see ApiClient#getBasePath()
+ */
+ @Override
+ public String getBasePath() {
+ return apiClient().getBasePath();
+ }
+
+ /**
+ * Sets the base path.
+ *
+ * @param basePath the base path
+ * @return the api client
+ * @see ApiClient#setBasePath(java.lang.String)
+ */
+ @Override
+ public ApiClient setBasePath(String basePath) {
+ return apiClient().setBasePath(basePath);
+ }
+
+ /**
+ * Gets the http client.
+ *
+ * @return the http client
+ * @see ApiClient#getHttpClient()
+ */
+ @Override
+ public OkHttpClient getHttpClient() {
+ return apiClient().getHttpClient();
+ }
+
+ /**
+ * Sets the http client.
+ *
+ * @param newHttpClient the new http client
+ * @return the api client
+ * @see ApiClient#setHttpClient(okhttp3.OkHttpClient)
+ */
+ @Override
+ public ApiClient setHttpClient(OkHttpClient newHttpClient) {
+ return apiClient().setHttpClient(newHttpClient);
+ }
+
+ /**
+ * Gets the json.
+ *
+ * @return the json
+ * @see ApiClient#getJSON()
+ */
+ @SuppressWarnings("abbreviationAsWordInName")
+ @Override
+ public JSON getJSON() {
+ return apiClient().getJSON();
+ }
+
+ /**
+ * Sets the JSON.
+ *
+ * @param json the json
+ * @return the api client
+ * @see ApiClient#setJSON(io.kubernetes.client.openapi.JSON)
+ */
+ @SuppressWarnings("abbreviationAsWordInName")
+ @Override
+ public ApiClient setJSON(JSON json) {
+ return apiClient().setJSON(json);
+ }
+
+ /**
+ * Checks if is verifying ssl.
+ *
+ * @return true, if is verifying ssl
+ * @see ApiClient#isVerifyingSsl()
+ */
+ @Override
+ public boolean isVerifyingSsl() {
+ return apiClient().isVerifyingSsl();
+ }
+
+ /**
+ * Sets the verifying ssl.
+ *
+ * @param verifyingSsl the verifying ssl
+ * @return the api client
+ * @see ApiClient#setVerifyingSsl(boolean)
+ */
+ @Override
+ public ApiClient setVerifyingSsl(boolean verifyingSsl) {
+ return apiClient().setVerifyingSsl(verifyingSsl);
+ }
+
+ /**
+ * Gets the ssl ca cert.
+ *
+ * @return the ssl ca cert
+ * @see ApiClient#getSslCaCert()
+ */
+ @Override
+ public InputStream getSslCaCert() {
+ return apiClient().getSslCaCert();
+ }
+
+ /**
+ * Sets the ssl ca cert.
+ *
+ * @param sslCaCert the ssl ca cert
+ * @return the api client
+ * @see ApiClient#setSslCaCert(java.io.InputStream)
+ */
+ @Override
+ public ApiClient setSslCaCert(InputStream sslCaCert) {
+ return apiClient().setSslCaCert(sslCaCert);
+ }
+
+ /**
+ * Gets the key managers.
+ *
+ * @return the key managers
+ * @see ApiClient#getKeyManagers()
+ */
+ @Override
+ public KeyManager[] getKeyManagers() {
+ return apiClient().getKeyManagers();
+ }
+
+ /**
+ * Sets the key managers.
+ *
+ * @param managers the managers
+ * @return the api client
+ * @see ApiClient#setKeyManagers(javax.net.ssl.KeyManager[])
+ */
+ @Override
+ public ApiClient setKeyManagers(KeyManager[] managers) {
+ return apiClient().setKeyManagers(managers);
+ }
+
+ /**
+ * Gets the date format.
+ *
+ * @return the date format
+ * @see ApiClient#getDateFormat()
+ */
+ @Override
+ public DateFormat getDateFormat() {
+ return apiClient().getDateFormat();
+ }
+
+ /**
+ * Sets the date format.
+ *
+ * @param dateFormat the date format
+ * @return the api client
+ * @see ApiClient#setDateFormat(java.text.DateFormat)
+ */
+ @Override
+ public ApiClient setDateFormat(DateFormat dateFormat) {
+ return apiClient().setDateFormat(dateFormat);
+ }
+
+ /**
+ * Sets the sql date format.
+ *
+ * @param dateFormat the date format
+ * @return the api client
+ * @see ApiClient#setSqlDateFormat(java.text.DateFormat)
+ */
+ @Override
+ public ApiClient setSqlDateFormat(DateFormat dateFormat) {
+ return apiClient().setSqlDateFormat(dateFormat);
+ }
+
+ /**
+ * Sets the offset date time format.
+ *
+ * @param dateFormat the date format
+ * @return the api client
+ * @see ApiClient#setOffsetDateTimeFormat(java.time.format.DateTimeFormatter)
+ */
+ @Override
+ public ApiClient setOffsetDateTimeFormat(DateTimeFormatter dateFormat) {
+ return apiClient().setOffsetDateTimeFormat(dateFormat);
+ }
+
+ /**
+ * Sets the local date format.
+ *
+ * @param dateFormat the date format
+ * @return the api client
+ * @see ApiClient#setLocalDateFormat(java.time.format.DateTimeFormatter)
+ */
+ @Override
+ public ApiClient setLocalDateFormat(DateTimeFormatter dateFormat) {
+ return apiClient().setLocalDateFormat(dateFormat);
+ }
+
+ /**
+ * Sets the lenient on json.
+ *
+ * @param lenientOnJson the lenient on json
+ * @return the api client
+ * @see ApiClient#setLenientOnJson(boolean)
+ */
+ @Override
+ public ApiClient setLenientOnJson(boolean lenientOnJson) {
+ return apiClient().setLenientOnJson(lenientOnJson);
+ }
+
+ /**
+ * Gets the authentications.
+ *
+ * @return the authentications
+ * @see ApiClient#getAuthentications()
+ */
+ @Override
+ public Map getAuthentications() {
+ return apiClient().getAuthentications();
+ }
+
+ /**
+ * Gets the authentication.
+ *
+ * @param authName the auth name
+ * @return the authentication
+ * @see ApiClient#getAuthentication(java.lang.String)
+ */
+ @Override
+ public Authentication getAuthentication(String authName) {
+ return apiClient().getAuthentication(authName);
+ }
+
+ /**
+ * Sets the username.
+ *
+ * @param username the new username
+ * @see ApiClient#setUsername(java.lang.String)
+ */
+ @Override
+ public void setUsername(String username) {
+ apiClient().setUsername(username);
+ }
+
+ /**
+ * Sets the password.
+ *
+ * @param password the new password
+ * @see ApiClient#setPassword(java.lang.String)
+ */
+ @Override
+ public void setPassword(String password) {
+ apiClient().setPassword(password);
+ }
+
+ /**
+ * Sets the api key.
+ *
+ * @param apiKey the new api key
+ * @see ApiClient#setApiKey(java.lang.String)
+ */
+ @Override
+ public void setApiKey(String apiKey) {
+ apiClient().setApiKey(apiKey);
+ }
+
+ /**
+ * Sets the api key prefix.
+ *
+ * @param apiKeyPrefix the new api key prefix
+ * @see ApiClient#setApiKeyPrefix(java.lang.String)
+ */
+ @Override
+ public void setApiKeyPrefix(String apiKeyPrefix) {
+ apiClient().setApiKeyPrefix(apiKeyPrefix);
+ }
+
+ /**
+ * Sets the access token.
+ *
+ * @param accessToken the new access token
+ * @see ApiClient#setAccessToken(java.lang.String)
+ */
+ @Override
+ public void setAccessToken(String accessToken) {
+ apiClient().setAccessToken(accessToken);
+ }
+
+ /**
+ * Sets the user agent.
+ *
+ * @param userAgent the user agent
+ * @return the api client
+ * @see ApiClient#setUserAgent(java.lang.String)
+ */
+ @Override
+ public ApiClient setUserAgent(String userAgent) {
+ return apiClient().setUserAgent(userAgent);
+ }
+
+ /**
+ * To string.
+ *
+ * @return the string
+ * @see java.lang.Object#toString()
+ */
+ @Override
+ public String toString() {
+ return apiClient().toString();
+ }
+
+ /**
+ * Adds the default header.
+ *
+ * @param key the key
+ * @param value the value
+ * @return the api client
+ * @see ApiClient#addDefaultHeader(java.lang.String, java.lang.String)
+ */
+ @Override
+ public ApiClient addDefaultHeader(String key, String value) {
+ return apiClient().addDefaultHeader(key, value);
+ }
+
+ /**
+ * Adds the default cookie.
+ *
+ * @param key the key
+ * @param value the value
+ * @return the api client
+ * @see ApiClient#addDefaultCookie(java.lang.String, java.lang.String)
+ */
+ @Override
+ public ApiClient addDefaultCookie(String key, String value) {
+ return apiClient().addDefaultCookie(key, value);
+ }
+
+ /**
+ * Checks if is debugging.
+ *
+ * @return true, if is debugging
+ * @see ApiClient#isDebugging()
+ */
+ @Override
+ public boolean isDebugging() {
+ return apiClient().isDebugging();
+ }
+
+ /**
+ * Sets the debugging.
+ *
+ * @param debugging the debugging
+ * @return the api client
+ * @see ApiClient#setDebugging(boolean)
+ */
+ @Override
+ public ApiClient setDebugging(boolean debugging) {
+ return apiClient().setDebugging(debugging);
+ }
+
+ /**
+ * Gets the temp folder path.
+ *
+ * @return the temp folder path
+ * @see ApiClient#getTempFolderPath()
+ */
+ @Override
+ public String getTempFolderPath() {
+ return apiClient().getTempFolderPath();
+ }
+
+ /**
+ * Sets the temp folder path.
+ *
+ * @param tempFolderPath the temp folder path
+ * @return the api client
+ * @see ApiClient#setTempFolderPath(java.lang.String)
+ */
+ @Override
+ public ApiClient setTempFolderPath(String tempFolderPath) {
+ return apiClient().setTempFolderPath(tempFolderPath);
+ }
+
+ /**
+ * Gets the connect timeout.
+ *
+ * @return the connect timeout
+ * @see ApiClient#getConnectTimeout()
+ */
+ @Override
+ public int getConnectTimeout() {
+ return apiClient().getConnectTimeout();
+ }
+
+ /**
+ * Sets the connect timeout.
+ *
+ * @param connectionTimeout the connection timeout
+ * @return the api client
+ * @see ApiClient#setConnectTimeout(int)
+ */
+ @Override
+ public ApiClient setConnectTimeout(int connectionTimeout) {
+ return apiClient().setConnectTimeout(connectionTimeout);
+ }
+
+ /**
+ * Gets the read timeout.
+ *
+ * @return the read timeout
+ * @see ApiClient#getReadTimeout()
+ */
+ @Override
+ public int getReadTimeout() {
+ return apiClient().getReadTimeout();
+ }
+
+ /**
+ * Sets the read timeout.
+ *
+ * @param readTimeout the read timeout
+ * @return the api client
+ * @see ApiClient#setReadTimeout(int)
+ */
+ @Override
+ public ApiClient setReadTimeout(int readTimeout) {
+ return apiClient().setReadTimeout(readTimeout);
+ }
+
+ /**
+ * Gets the write timeout.
+ *
+ * @return the write timeout
+ * @see ApiClient#getWriteTimeout()
+ */
+ @Override
+ public int getWriteTimeout() {
+ return apiClient().getWriteTimeout();
+ }
+
+ /**
+ * Sets the write timeout.
+ *
+ * @param writeTimeout the write timeout
+ * @return the api client
+ * @see ApiClient#setWriteTimeout(int)
+ */
+ @Override
+ public ApiClient setWriteTimeout(int writeTimeout) {
+ return apiClient().setWriteTimeout(writeTimeout);
+ }
+
+ /**
+ * Parameter to string.
+ *
+ * @param param the param
+ * @return the string
+ * @see ApiClient#parameterToString(java.lang.Object)
+ */
+ @Override
+ public String parameterToString(Object param) {
+ return apiClient().parameterToString(param);
+ }
+
+ /**
+ * Parameter to pair.
+ *
+ * @param name the name
+ * @param value the value
+ * @return the list
+ * @see ApiClient#parameterToPair(java.lang.String, java.lang.Object)
+ */
+ @Override
+ public List parameterToPair(String name, Object value) {
+ return apiClient().parameterToPair(name, value);
+ }
+
+ /**
+ * Parameter to pairs.
+ *
+ * @param collectionFormat the collection format
+ * @param name the name
+ * @param value the value
+ * @return the list
+ * @see ApiClient#parameterToPairs(java.lang.String, java.lang.String, java.util.Collection)
+ */
+ @SuppressWarnings({ "rawtypes", "PMD.AvoidDuplicateLiterals" })
+ @Override
+ public List parameterToPairs(String collectionFormat, String name,
+ Collection value) {
+ return apiClient().parameterToPairs(collectionFormat, name, value);
+ }
+
+ /**
+ * Collection path parameter to string.
+ *
+ * @param collectionFormat the collection format
+ * @param value the value
+ * @return the string
+ * @see ApiClient#collectionPathParameterToString(java.lang.String, java.util.Collection)
+ */
+ @SuppressWarnings("rawtypes")
+ @Override
+ public String collectionPathParameterToString(String collectionFormat,
+ Collection value) {
+ return apiClient().collectionPathParameterToString(collectionFormat,
+ value);
+ }
+
+ /**
+ * Sanitize filename.
+ *
+ * @param filename the filename
+ * @return the string
+ * @see ApiClient#sanitizeFilename(java.lang.String)
+ */
+ @Override
+ public String sanitizeFilename(String filename) {
+ return apiClient().sanitizeFilename(filename);
+ }
+
+ /**
+ * Checks if is json mime.
+ *
+ * @param mime the mime
+ * @return true, if is json mime
+ * @see ApiClient#isJsonMime(java.lang.String)
+ */
+ @Override
+ public boolean isJsonMime(String mime) {
+ return apiClient().isJsonMime(mime);
+ }
+
+ /**
+ * Select header accept.
+ *
+ * @param accepts the accepts
+ * @return the string
+ * @see ApiClient#selectHeaderAccept(java.lang.String[])
+ */
+ @Override
+ public String selectHeaderAccept(String[] accepts) {
+ return apiClient().selectHeaderAccept(accepts);
+ }
+
+ /**
+ * Select header content type.
+ *
+ * @param contentTypes the content types
+ * @return the string
+ * @see ApiClient#selectHeaderContentType(java.lang.String[])
+ */
+ @Override
+ public String selectHeaderContentType(String[] contentTypes) {
+ return apiClient().selectHeaderContentType(contentTypes);
+ }
+
+ /**
+ * Escape string.
+ *
+ * @param str the str
+ * @return the string
+ * @see ApiClient#escapeString(java.lang.String)
+ */
+ @Override
+ public String escapeString(String str) {
+ return apiClient().escapeString(str);
+ }
+
+ /**
+ * Deserialize.
+ *
+ * @param the generic type
+ * @param response the response
+ * @param returnType the return type
+ * @return the t
+ * @throws ApiException the api exception
+ * @see ApiClient#deserialize(okhttp3.Response, java.lang.reflect.Type)
+ */
+ @Override
+ public T deserialize(Response response, Type returnType)
+ throws ApiException {
+ return apiClient().deserialize(response, returnType);
+ }
+
+ /**
+ * Serialize.
+ *
+ * @param obj the obj
+ * @param contentType the content type
+ * @return the request body
+ * @throws ApiException the api exception
+ * @see ApiClient#serialize(java.lang.Object, java.lang.String)
+ */
+ @Override
+ public RequestBody serialize(Object obj, String contentType)
+ throws ApiException {
+ return apiClient().serialize(obj, contentType);
+ }
+
+ /**
+ * Download file from response.
+ *
+ * @param response the response
+ * @return the file
+ * @throws ApiException the api exception
+ * @see ApiClient#downloadFileFromResponse(okhttp3.Response)
+ */
+ @Override
+ public File downloadFileFromResponse(Response response)
+ throws ApiException {
+ return apiClient().downloadFileFromResponse(response);
+ }
+
+ /**
+ * Prepare download file.
+ *
+ * @param response the response
+ * @return the file
+ * @throws IOException Signals that an I/O exception has occurred.
+ * @see ApiClient#prepareDownloadFile(okhttp3.Response)
+ */
+ @Override
+ public File prepareDownloadFile(Response response) throws IOException {
+ return apiClient().prepareDownloadFile(response);
+ }
+
+ /**
+ * Execute.
+ *
+ * @param the generic type
+ * @param call the call
+ * @return the api response
+ * @throws ApiException the api exception
+ * @see ApiClient#execute(okhttp3.Call)
+ */
+ @Override
+ public ApiResponse execute(Call call) throws ApiException {
+ return apiClient().execute(call);
+ }
+
+ /**
+ * Execute.
+ *
+ * @param the generic type
+ * @param call the call
+ * @param returnType the return type
+ * @return the api response
+ * @throws ApiException the api exception
+ * @see ApiClient#execute(okhttp3.Call, java.lang.reflect.Type)
+ */
+ @Override
+ public ApiResponse execute(Call call, Type returnType)
+ throws ApiException {
+ return apiClient().execute(call, returnType);
+ }
+
+ /**
+ * Execute async.
+ *
+ * @param the generic type
+ * @param call the call
+ * @param callback the callback
+ * @see ApiClient#executeAsync(okhttp3.Call, io.kubernetes.client.openapi.ApiCallback)
+ */
+ @Override
+ public void executeAsync(Call call, ApiCallback callback) {
+ apiClient().executeAsync(call, callback);
+ }
+
+ /**
+ * Execute async.
+ *
+ * @param the generic type
+ * @param call the call
+ * @param returnType the return type
+ * @param callback the callback
+ * @see ApiClient#executeAsync(okhttp3.Call, java.lang.reflect.Type, io.kubernetes.client.openapi.ApiCallback)
+ */
+ @Override
+ public void executeAsync(Call call, Type returnType,
+ ApiCallback callback) {
+ apiClient().executeAsync(call, returnType, callback);
+ }
+
+ /**
+ * Handle response.
+ *
+ * @param the generic type
+ * @param response the response
+ * @param returnType the return type
+ * @return the t
+ * @throws ApiException the api exception
+ * @see ApiClient#handleResponse(okhttp3.Response, java.lang.reflect.Type)
+ */
+ @Override
+ public T handleResponse(Response response, Type returnType)
+ throws ApiException {
+ return apiClient().handleResponse(response, returnType);
+ }
+
+ /**
+ * Builds the call.
+ *
+ * @param path the path
+ * @param method the method
+ * @param queryParams the query params
+ * @param collectionQueryParams the collection query params
+ * @param body the body
+ * @param headerParams the header params
+ * @param cookieParams the cookie params
+ * @param formParams the form params
+ * @param authNames the auth names
+ * @param callback the callback
+ * @return the call
+ * @throws ApiException the api exception
+ * @see ApiClient#buildCall(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback)
+ */
+ @SuppressWarnings({ "rawtypes" })
+ @Override
+ public Call buildCall(String path, String method, List queryParams,
+ List collectionQueryParams, Object body,
+ Map headerParams, Map cookieParams,
+ Map formParams, String[] authNames,
+ ApiCallback callback) throws ApiException {
+ return apiClient().buildCall(path, method, queryParams,
+ collectionQueryParams, body, headerParams, cookieParams, formParams,
+ authNames, callback);
+ }
+
+ /**
+ * Builds the request.
+ *
+ * @param path the path
+ * @param method the method
+ * @param queryParams the query params
+ * @param collectionQueryParams the collection query params
+ * @param body the body
+ * @param headerParams the header params
+ * @param cookieParams the cookie params
+ * @param formParams the form params
+ * @param authNames the auth names
+ * @param callback the callback
+ * @return the request
+ * @throws ApiException the api exception
+ * @see ApiClient#buildRequest(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback)
+ */
+ @SuppressWarnings({ "rawtypes" })
+ @Override
+ public Request buildRequest(String path, String method,
+ List queryParams, List collectionQueryParams,
+ Object body, Map headerParams,
+ Map cookieParams, Map formParams,
+ String[] authNames, ApiCallback callback) throws ApiException {
+ return apiClient().buildRequest(path, method, queryParams,
+ collectionQueryParams, body, headerParams, cookieParams, formParams,
+ authNames, callback);
+ }
+
+ /**
+ * Builds the url.
+ *
+ * @param path the path
+ * @param queryParams the query params
+ * @param collectionQueryParams the collection query params
+ * @return the string
+ * @see ApiClient#buildUrl(java.lang.String, java.util.List, java.util.List)
+ */
+ @Override
+ public String buildUrl(String path, List queryParams,
+ List collectionQueryParams) {
+ return apiClient().buildUrl(path, queryParams, collectionQueryParams);
+ }
+
+ /**
+ * Process header params.
+ *
+ * @param headerParams the header params
+ * @param reqBuilder the req builder
+ * @see ApiClient#processHeaderParams(java.util.Map, okhttp3.Request.Builder)
+ */
+ @Override
+ public void processHeaderParams(Map headerParams,
+ Builder reqBuilder) {
+ apiClient().processHeaderParams(headerParams, reqBuilder);
+ }
+
+ /**
+ * Process cookie params.
+ *
+ * @param cookieParams the cookie params
+ * @param reqBuilder the req builder
+ * @see ApiClient#processCookieParams(java.util.Map, okhttp3.Request.Builder)
+ */
+ @Override
+ public void processCookieParams(Map cookieParams,
+ Builder reqBuilder) {
+ apiClient().processCookieParams(cookieParams, reqBuilder);
+ }
+
+ /**
+ * Update params for auth.
+ *
+ * @param authNames the auth names
+ * @param queryParams the query params
+ * @param headerParams the header params
+ * @param cookieParams the cookie params
+ * @see ApiClient#updateParamsForAuth(java.lang.String[], java.util.List, java.util.Map, java.util.Map)
+ */
+ @Override
+ public void updateParamsForAuth(String[] authNames, List queryParams,
+ Map headerParams,
+ Map cookieParams) {
+ apiClient().updateParamsForAuth(authNames, queryParams, headerParams,
+ cookieParams);
+ }
+
+ /**
+ * Builds the request body form encoding.
+ *
+ * @param formParams the form params
+ * @return the request body
+ * @see ApiClient#buildRequestBodyFormEncoding(java.util.Map)
+ */
+ @Override
+ public RequestBody
+ buildRequestBodyFormEncoding(Map formParams) {
+ return apiClient().buildRequestBodyFormEncoding(formParams);
+ }
+
+ /**
+ * Builds the request body multipart.
+ *
+ * @param formParams the form params
+ * @return the request body
+ * @see ApiClient#buildRequestBodyMultipart(java.util.Map)
+ */
+ @Override
+ public RequestBody
+ buildRequestBodyMultipart(Map formParams) {
+ return apiClient().buildRequestBodyMultipart(formParams);
+ }
+
+ /**
+ * Guess content type from file.
+ *
+ * @param file the file
+ * @return the string
+ * @see ApiClient#guessContentTypeFromFile(java.io.File)
+ */
+ @Override
+ public String guessContentTypeFromFile(File file) {
+ return apiClient().guessContentTypeFromFile(file);
+ }
+
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java
new file mode 100644
index 0000000..59b4d12
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java
@@ -0,0 +1,396 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.apimachinery.GroupVersionKind;
+import io.kubernetes.client.common.KubernetesListObject;
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.custom.V1Patch;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.Strings;
+import io.kubernetes.client.util.generic.GenericKubernetesApi;
+import io.kubernetes.client.util.generic.options.GetOptions;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import io.kubernetes.client.util.generic.options.PatchOptions;
+import java.net.HttpURLConnection;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
+/**
+ * A stub for cluster scoped objects. This stub provides the
+ * functions common to all Kubernetes objects, but uses variables
+ * for all types. This class should be used as base class only.
+ *
+ * @param the generic type
+ * @param the generic type
+ */
+@SuppressWarnings({ "PMD.CouplingBetweenObjects" })
+public class K8sClusterGenericStub {
+ protected final K8sClient client;
+ private final GenericKubernetesApi api;
+ protected final APIResource context;
+ protected final String name;
+
+ /**
+ * Instantiates a new stub for the object specified. If the object
+ * exists in the context specified, the version (see
+ * {@link #version()} is bound to the existing object's version.
+ * Else the stub is dangling with the version set to the context's
+ * preferred version.
+ *
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param name the name
+ */
+ @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
+ protected K8sClusterGenericStub(Class objectClass,
+ Class objectListClass, K8sClient client, APIResource context,
+ String name) {
+ this.client = client;
+ this.name = name;
+
+ // Bind version
+ var foundVersion = context.getPreferredVersion();
+ GenericKubernetesApi testApi = null;
+ GetOptions mdOpts
+ = new GetOptions().isPartialObjectMetadataRequest(true);
+ for (var version : candidateVersions(context)) {
+ testApi = new GenericKubernetesApi<>(objectClass, objectListClass,
+ context.getGroup(), version, context.getResourcePlural(),
+ client);
+ if (testApi.get(name, mdOpts).isSuccess()) {
+ foundVersion = version;
+ break;
+ }
+ }
+ if (foundVersion.equals(context.getPreferredVersion())) {
+ this.context = context;
+ } else {
+ this.context = K8s.preferred(context, foundVersion);
+ }
+
+ api = Optional.ofNullable(testApi)
+ .orElseGet(() -> new GenericKubernetesApi<>(objectClass,
+ objectListClass, group(), version(), plural(), client));
+ }
+
+ /**
+ * Gets the context.
+ *
+ * @return the context
+ */
+ public APIResource context() {
+ return context;
+ }
+
+ /**
+ * Gets the group.
+ *
+ * @return the group
+ */
+ public String group() {
+ return context.getGroup();
+ }
+
+ /**
+ * Gets the version.
+ *
+ * @return the version
+ */
+ public String version() {
+ return context.getPreferredVersion();
+ }
+
+ /**
+ * Gets the kind.
+ *
+ * @return the kind
+ */
+ public String kind() {
+ return context.getKind();
+ }
+
+ /**
+ * Gets the plural.
+ *
+ * @return the plural
+ */
+ public String plural() {
+ return context.getResourcePlural();
+ }
+
+ /**
+ * Gets the name.
+ *
+ * @return the name
+ */
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Delete the Kubernetes object.
+ *
+ * @throws ApiException the API exception
+ */
+ public void delete() throws ApiException {
+ var result = api.delete(name);
+ if (result.isSuccess()
+ || result.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
+ return;
+ }
+ result.throwsApiException();
+ }
+
+ /**
+ * Retrieves and returns the current state of the object.
+ *
+ * @return the object's state
+ * @throws ApiException the api exception
+ */
+ public Optional model() throws ApiException {
+ return K8s.optional(api.get(name));
+ }
+
+ /**
+ * Updates the object's status.
+ *
+ * @param object the current state of the object (passed to `status`)
+ * @param status function that returns the new status
+ * @return the updated model or empty if not successful
+ * @throws ApiException the api exception
+ */
+ public Optional updateStatus(O object,
+ Function status) throws ApiException {
+ return K8s.optional(api.updateStatus(object, status));
+ }
+
+ /**
+ * Updates the status.
+ *
+ * @param status the status
+ * @return the kubernetes api response
+ * the updated model or empty if not successful
+ * @throws ApiException the api exception
+ */
+ public Optional updateStatus(Function status)
+ throws ApiException {
+ return updateStatus(api.get(name).throwsApiException().getObject(),
+ status);
+ }
+
+ /**
+ * Patch the object.
+ *
+ * @param patchType the patch type
+ * @param patch the patch
+ * @param options the options
+ * @return the kubernetes api response
+ * @throws ApiException the api exception
+ */
+ public Optional patch(String patchType, V1Patch patch,
+ PatchOptions options) throws ApiException {
+ return K8s
+ .optional(api.patch(name, patchType, patch, options));
+ }
+
+ /**
+ * Patch the object using default options.
+ *
+ * @param patchType the patch type
+ * @param patch the patch
+ * @return the kubernetes api response
+ * @throws ApiException the api exception
+ */
+ public Optional
+ patch(String patchType, V1Patch patch) throws ApiException {
+ PatchOptions opts = new PatchOptions();
+ return patch(patchType, patch, opts);
+ }
+
+ /**
+ * A supplier for generic stubs.
+ *
+ * @param the object type
+ * @param the object list type
+ * @param the result type
+ */
+ @FunctionalInterface
+ public interface GenericSupplier> {
+
+ /**
+ * Gets a new stub.
+ *
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the API resource
+ * @param name the name
+ * @return the result
+ */
+ R get(Class objectClass, Class objectListClass, K8sClient client,
+ APIResource context, String name);
+ }
+
+ @Override
+ @SuppressWarnings("PMD.UseLocaleWithCaseConversions")
+ public String toString() {
+ return (Strings.isNullOrEmpty(group()) ? "" : group() + "/")
+ + version().toUpperCase() + kind() + " " + name;
+ }
+
+ /**
+ * Get an object stub. If the version in parameter
+ * `gvk` is an empty string, the stub refers to the first object
+ * found with matching group and kind.
+ *
+ * @param the object type
+ * @param the object list type
+ * @param the stub type
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param gvk the group, version and kind
+ * @param name the name
+ * @param provider the provider
+ * @return the stub if the object exists
+ * @throws ApiException the api exception
+ */
+ public static >
+ R get(Class objectClass, Class objectListClass,
+ K8sClient client, GroupVersionKind gvk, String name,
+ GenericSupplier provider) throws ApiException {
+ var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(),
+ gvk.getKind());
+ if (context.isEmpty()) {
+ throw new ApiException("No known API for " + gvk.getGroup()
+ + "/" + gvk.getVersion() + " " + gvk.getKind());
+ }
+ return provider.get(objectClass, objectListClass, client, context.get(),
+ name);
+ }
+
+ /**
+ * Get an object stub.
+ *
+ * @param the object type
+ * @param the object list type
+ * @param the stub type
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param name the name
+ * @param provider the provider
+ * @return the stub if the object exists
+ * @throws ApiException the api exception
+ */
+ public static >
+ R get(Class objectClass, Class objectListClass,
+ K8sClient client, APIResource context, String name,
+ GenericSupplier provider) {
+ return provider.get(objectClass, objectListClass, client, context,
+ name);
+ }
+
+ /**
+ * Get an object stub for a newly created object.
+ *
+ * @param the object type
+ * @param the object list type
+ * @param the stub type
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param model the model
+ * @param provider the provider
+ * @return the stub if the object exists
+ * @throws ApiException the api exception
+ */
+ public static >
+ R create(Class objectClass, Class objectListClass,
+ K8sClient client, APIResource context, O model,
+ GenericSupplier provider) throws ApiException {
+ var api = new GenericKubernetesApi<>(objectClass, objectListClass,
+ context.getGroup(), context.getPreferredVersion(),
+ context.getResourcePlural(), client);
+ api.create(model).throwsApiException();
+ return provider.get(objectClass, objectListClass, client,
+ context, model.getMetadata().getName());
+ }
+
+ /**
+ * Get the stubs for the objects that match
+ * the criteria from the given options.
+ *
+ * @param the object type
+ * @param the object list type
+ * @param the stub type
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param options the options
+ * @param provider the provider
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static >
+ Collection list(Class objectClass, Class objectListClass,
+ K8sClient client, APIResource context,
+ ListOptions options, GenericSupplier provider)
+ throws ApiException {
+ var result = new ArrayList();
+ for (var version : candidateVersions(context)) {
+ @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
+ var api = new GenericKubernetesApi<>(objectClass, objectListClass,
+ context.getGroup(), version, context.getResourcePlural(),
+ client);
+ var objs = api.list(options).throwsApiException();
+ for (var item : objs.getObject().getItems()) {
+ result.add(provider.get(objectClass, objectListClass, client,
+ context, item.getMetadata().getName()));
+ }
+ }
+ return result;
+ }
+
+ private static List candidateVersions(APIResource context) {
+ var result = new LinkedList<>(context.getVersions());
+ result.remove(context.getPreferredVersion());
+ result.add(0, context.getPreferredVersion());
+ return result;
+ }
+
+}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java
new file mode 100644
index 0000000..2392d3e
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java
@@ -0,0 +1,113 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+
+/**
+ * Represents a Kubernetes object using a JSON data structure.
+ * Some information that is common to all Kubernetes objects,
+ * notably the metadata, is made available through the methods
+ * defined by {@link KubernetesObject}.
+ */
+public class K8sDynamicModel implements KubernetesObject {
+
+ private final V1ObjectMeta metadata;
+ private final JsonObject data;
+
+ /**
+ * Instantiates a new model from the JSON representation.
+ *
+ * @param delegate the gson instance to use for extracting structured data
+ * @param json the JSON
+ */
+ public K8sDynamicModel(Gson delegate, JsonObject json) {
+ this.data = json;
+ metadata = delegate.fromJson(data.get("metadata"), V1ObjectMeta.class);
+ }
+
+ @Override
+ public String getApiVersion() {
+ return apiVersion();
+ }
+
+ /**
+ * Gets the API version. (Abbreviated method name for convenience.)
+ *
+ * @return the API version
+ */
+ public String apiVersion() {
+ return data.get("apiVersion").getAsString();
+ }
+
+ @Override
+ public String getKind() {
+ return kind();
+ }
+
+ /**
+ * Gets the kind. (Abbreviated method name for convenience.)
+ *
+ * @return the kind
+ */
+ public String kind() {
+ return data.get("kind").getAsString();
+ }
+
+ @Override
+ public V1ObjectMeta getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * Gets the metadata. (Abbreviated method name for convenience.)
+ *
+ * @return the metadata
+ */
+ public V1ObjectMeta metadata() {
+ return metadata;
+ }
+
+ /**
+ * Gets the data.
+ *
+ * @return the data
+ */
+ public JsonObject data() {
+ return data;
+ }
+
+ /**
+ * Convenience method for getting the status.
+ *
+ * @return the JSON object describing the status
+ */
+ public JsonObject statusJson() {
+ return data.getAsJsonObject("status");
+ }
+
+ @Override
+ public String toString() {
+ return data.toString();
+ }
+
+}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java
new file mode 100644
index 0000000..d165c10
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java
@@ -0,0 +1,44 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.common.KubernetesListObject;
+
+/**
+ * Represents a list of Kubernetes objects each of which is
+ * represented using a JSON data structure.
+ * Some information that is common to all Kubernetes objects,
+ * notably the metadata, is made available through the methods
+ * defined by {@link KubernetesListObject}.
+ */
+public class K8sDynamicModels extends K8sDynamicModelsBase {
+
+ /**
+ * Initialize the object list using the given JSON data.
+ *
+ * @param delegate the gson instance to use for extracting structured data
+ * @param data the data
+ */
+ public K8sDynamicModels(Gson delegate, JsonObject data) {
+ super(K8sDynamicModel.class, delegate, data);
+ }
+
+}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java
new file mode 100644
index 0000000..4e21c0e
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java
@@ -0,0 +1,174 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.common.KubernetesListObject;
+import io.kubernetes.client.openapi.Configuration;
+import io.kubernetes.client.openapi.models.V1ListMeta;
+import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a list of Kubernetes objects each of which is
+ * represented using a JSON data structure.
+ * Some information that is common to all Kubernetes objects,
+ * notably the metadata, is made available through the methods
+ * defined by {@link KubernetesListObject}.
+ */
+public class K8sDynamicModelsBase
+ implements KubernetesListObject {
+
+ private final JsonObject data;
+ private final V1ListMeta metadata;
+ private final List items;
+
+ /**
+ * Initialize the object list using the given JSON data.
+ *
+ * @param itemClass the item class
+ * @param delegate the gson instance to use for extracting structured data
+ * @param data the data
+ */
+ public K8sDynamicModelsBase(Class itemClass, Gson delegate,
+ JsonObject data) {
+ this.data = data;
+ metadata = delegate.fromJson(data.get("metadata"), V1ListMeta.class);
+ items = new ArrayList<>();
+ for (JsonElement e : data.get("items").getAsJsonArray()) {
+ try {
+ items.add(itemClass.getConstructor(Gson.class, JsonObject.class)
+ .newInstance(delegate, e.getAsJsonObject()));
+ } catch (InstantiationException | IllegalAccessException
+ | IllegalArgumentException | InvocationTargetException
+ | NoSuchMethodException | SecurityException exc) {
+ throw new IllegalArgumentException(exc);
+ }
+ }
+ }
+
+ @Override
+ public String getApiVersion() {
+ return apiVersion();
+ }
+
+ /**
+ * Gets the API version. (Abbreviated method name for convenience.)
+ *
+ * @return the API version
+ */
+ public String apiVersion() {
+ return data.get("apiVersion").getAsString();
+ }
+
+ @Override
+ public String getKind() {
+ return kind();
+ }
+
+ /**
+ * Gets the kind. (Abbreviated method name for convenience.)
+ *
+ * @return the kind
+ */
+ public String kind() {
+ return data.get("kind").getAsString();
+ }
+
+ @Override
+ public V1ListMeta getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * Gets the metadata. (Abbreviated method name for convenience.)
+ *
+ * @return the metadata
+ */
+ public V1ListMeta metadata() {
+ return metadata;
+ }
+
+ /**
+ * Returns the JSON representation of this object.
+ *
+ * @return the JOSN representation
+ */
+ public JsonObject data() {
+ return data;
+ }
+
+ @Override
+ public List getItems() {
+ return items;
+ }
+
+ /**
+ * Sets the api version.
+ *
+ * @param apiVersion the new api version
+ */
+ public void setApiVersion(String apiVersion) {
+ data.addProperty("apiVersion", apiVersion);
+ }
+
+ /**
+ * Sets the kind.
+ *
+ * @param kind the new kind
+ */
+ public void setKind(String kind) {
+ data.addProperty("kind", kind);
+ }
+
+ /**
+ * Sets the metadata.
+ *
+ * @param objectMeta the new metadata
+ */
+ public void setMetadata(V1ListMeta objectMeta) {
+ data.add("metadata",
+ Configuration.getDefaultApiClient().getJSON().getGson()
+ .toJsonTree(objectMeta));
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(data);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ K8sDynamicModelsBase> other = (K8sDynamicModelsBase>) obj;
+ return Objects.equals(data, other.data);
+ }
+}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java
new file mode 100644
index 0000000..c0303c2
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java
@@ -0,0 +1,152 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.apimachinery.GroupVersionKind;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import java.io.Reader;
+import java.util.Collection;
+
+/**
+ * A stub for namespaced custom objects. It uses a dynamic model
+ * (see {@link K8sDynamicModel}) for representing the object's
+ * state and can therefore be used for any kind of object, especially
+ * custom objects.
+ */
+public class K8sDynamicStub
+ extends K8sDynamicStubBase {
+
+ private static DynamicTypeAdapterFactory taf = new K8sDynamicModelTypeAdapterFactory();
+
+ /**
+ * Instantiates a new dynamic stub.
+ *
+ * @param client the client
+ * @param context the context
+ * @param namespace the namespace
+ * @param name the name
+ */
+ public K8sDynamicStub(K8sClient client,
+ APIResource context, String namespace, String name) {
+ super(K8sDynamicModel.class, K8sDynamicModels.class, taf, client,
+ context, namespace, name);
+ }
+
+ /**
+ * Get a dynamic object stub. If the version in parameter
+ * `gvk` is an empty string, the stub refers to the first object with
+ * matching group and kind.
+ *
+ * @param client the client
+ * @param gvk the group, version and kind
+ * @param namespace the namespace
+ * @param name the name
+ * @return the stub if the object exists
+ * @throws ApiException the api exception
+ */
+ public static K8sDynamicStub get(K8sClient client,
+ GroupVersionKind gvk, String namespace, String name)
+ throws ApiException {
+ return new K8sDynamicStub(client, apiResource(client, gvk), namespace,
+ name);
+ }
+
+ /**
+ * Get a dynamic object stub.
+ *
+ * @param client the client
+ * @param context the context
+ * @param namespace the namespace
+ * @param name the name
+ * @return the stub if the object exists
+ * @throws ApiException the api exception
+ */
+ public static K8sDynamicStub get(K8sClient client,
+ APIResource context, String namespace, String name) {
+ return new K8sDynamicStub(client, context, namespace, name);
+ }
+
+ /**
+ * Creates a stub from yaml.
+ *
+ * @param client the client
+ * @param context the context
+ * @param yaml the yaml
+ * @return the k 8 s dynamic stub
+ * @throws ApiException the api exception
+ */
+ public static K8sDynamicStub createFromYaml(K8sClient client,
+ APIResource context, Reader yaml) throws ApiException {
+ var model = new K8sDynamicModel(client.getJSON().getGson(),
+ K8s.yamlToJson(client, yaml));
+ return K8sGenericStub.create(K8sDynamicModel.class,
+ K8sDynamicModels.class, client, context, model,
+ (c, ns, n) -> new K8sDynamicStub(c, context, ns, n));
+ }
+
+ /**
+ * Get the stubs for the objects in the given namespace that match
+ * the criteria from the given options.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param options the options
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static Collection list(K8sClient client,
+ APIResource context, String namespace, ListOptions options)
+ throws ApiException {
+ return K8sGenericStub.list(K8sDynamicModel.class,
+ K8sDynamicModels.class, client, context, namespace, options,
+ (c, ns, n) -> new K8sDynamicStub(c, context, ns, n));
+ }
+
+ /**
+ * Get the stubs for the objects in the given namespace.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static Collection list(K8sClient client,
+ APIResource context, String namespace)
+ throws ApiException {
+ return list(client, context, namespace, new ListOptions());
+ }
+
+ /**
+ * A factory for creating K8sDynamicModel(s) objects.
+ */
+ public static class K8sDynamicModelTypeAdapterFactory extends
+ DynamicTypeAdapterFactory {
+
+ /**
+ * Instantiates a new dynamic model type adapter factory.
+ */
+ public K8sDynamicModelTypeAdapterFactory() {
+ super(K8sDynamicModel.class, K8sDynamicModels.class);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java
new file mode 100644
index 0000000..ae3f012
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java
@@ -0,0 +1,49 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+
+/**
+ * A stub for namespaced custom objects. It uses a dynamic model
+ * (see {@link K8sDynamicModel}) for representing the object's
+ * state and can therefore be used for any kind of object, especially
+ * custom objects.
+ */
+public abstract class K8sDynamicStubBase> extends K8sGenericStub {
+
+ /**
+ * Instantiates a new dynamic stub.
+ *
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param namespace the namespace
+ * @param name the name
+ */
+ public K8sDynamicStubBase(Class objectClass,
+ Class objectListClass, DynamicTypeAdapterFactory taf,
+ K8sClient client, APIResource context, String namespace,
+ String name) {
+ super(objectClass, objectListClass, client, context, namespace, name);
+ taf.register(client);
+ }
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java
new file mode 100644
index 0000000..9ba376f
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java
@@ -0,0 +1,474 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.apimachinery.GroupVersionKind;
+import io.kubernetes.client.common.KubernetesListObject;
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.custom.V1Patch;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.Strings;
+import io.kubernetes.client.util.generic.GenericKubernetesApi;
+import io.kubernetes.client.util.generic.KubernetesApiResponse;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import io.kubernetes.client.util.generic.options.GetOptions;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import io.kubernetes.client.util.generic.options.PatchOptions;
+import io.kubernetes.client.util.generic.options.UpdateOptions;
+import java.net.HttpURLConnection;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
+/**
+ * A stub for namespaced custom objects. This stub provides the
+ * functions common to all Kubernetes objects, but uses variables
+ * for all types. This class should be used as base class only.
+ *
+ * @param the generic type
+ * @param the generic type
+ */
+@SuppressWarnings({ "PMD.TooManyMethods" })
+public class K8sGenericStub {
+ protected final K8sClient client;
+ private final GenericKubernetesApi api;
+ protected final APIResource context;
+ protected final String namespace;
+ protected final String name;
+
+ /**
+ * Instantiates a new stub for the object specified. If the object
+ * exists in the context specified, the version (see
+ * {@link #version()} is bound to the existing object's version.
+ * Else the stub is dangling with the version set to the context's
+ * preferred version.
+ *
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param namespace the namespace
+ * @param name the name
+ */
+ @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
+ protected K8sGenericStub(Class objectClass, Class objectListClass,
+ K8sClient client, APIResource context, String namespace,
+ String name) {
+ this.client = client;
+ this.namespace = namespace;
+ this.name = name;
+
+ // Bind version
+ var foundVersion = context.getPreferredVersion();
+ GenericKubernetesApi testApi = null;
+ GetOptions mdOpts
+ = new GetOptions().isPartialObjectMetadataRequest(true);
+ for (var version : candidateVersions(context)) {
+ testApi = new GenericKubernetesApi<>(objectClass, objectListClass,
+ context.getGroup(), version, context.getResourcePlural(),
+ client);
+ if (testApi.get(namespace, name, mdOpts)
+ .isSuccess()) {
+ foundVersion = version;
+ break;
+ }
+ }
+ if (foundVersion.equals(context.getPreferredVersion())) {
+ this.context = context;
+ } else {
+ this.context = K8s.preferred(context, foundVersion);
+ }
+
+ api = Optional.ofNullable(testApi)
+ .orElseGet(() -> new GenericKubernetesApi<>(objectClass,
+ objectListClass, group(), version(), plural(), client));
+ }
+
+ /**
+ * Gets the context.
+ *
+ * @return the context
+ */
+ public APIResource context() {
+ return context;
+ }
+
+ /**
+ * Gets the group.
+ *
+ * @return the group
+ */
+ public String group() {
+ return context.getGroup();
+ }
+
+ /**
+ * Gets the version.
+ *
+ * @return the version
+ */
+ public String version() {
+ return context.getPreferredVersion();
+ }
+
+ /**
+ * Gets the kind.
+ *
+ * @return the kind
+ */
+ public String kind() {
+ return context.getKind();
+ }
+
+ /**
+ * Gets the plural.
+ *
+ * @return the plural
+ */
+ public String plural() {
+ return context.getResourcePlural();
+ }
+
+ /**
+ * Gets the namespace.
+ *
+ * @return the namespace
+ */
+ public String namespace() {
+ return namespace;
+ }
+
+ /**
+ * Gets the name.
+ *
+ * @return the name
+ */
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Delete the Kubernetes object.
+ *
+ * @throws ApiException the API exception
+ */
+ public void delete() throws ApiException {
+ var result = api.delete(namespace, name);
+ if (result.isSuccess()
+ || result.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
+ return;
+ }
+ result.throwsApiException();
+ }
+
+ /**
+ * Retrieves and returns the current state of the object.
+ *
+ * @return the object's state
+ * @throws ApiException the api exception
+ */
+ public Optional model() throws ApiException {
+ return K8s.optional(api.get(namespace, name));
+ }
+
+ /**
+ * Updates the object's status. Does not retry in case of conflict.
+ *
+ * @param object the current state of the object (passed to `status`)
+ * @param updater function that returns the new status
+ * @return the updated model or empty if the object was not found
+ * @throws ApiException the api exception
+ */
+ public Optional updateStatus(O object, Function updater)
+ throws ApiException {
+ return K8s.optional(api.updateStatus(object, updater));
+ }
+
+ /**
+ * Updates the status of the given object. In case of conflict,
+ * get the current version of the object and tries again. Retries
+ * up to `retries` times.
+ *
+ * @param updater the function updating the status
+ * @param current the current state of the object, used for the first
+ * attempt to update
+ * @param retries the retries in case of conflict
+ * @return the updated model or empty if the object was not found
+ * @throws ApiException the api exception
+ */
+ @SuppressWarnings({ "PMD.AssignmentInOperand" })
+ public Optional updateStatus(Function updater, O current,
+ int retries) throws ApiException {
+ while (true) {
+ try {
+ if (current == null) {
+ current = api.get(namespace, name)
+ .throwsApiException().getObject();
+ }
+ return updateStatus(current, updater);
+ } catch (ApiException e) {
+ if (HttpURLConnection.HTTP_CONFLICT != e.getCode()
+ || retries-- <= 0) {
+ throw e;
+ }
+ // Get current version for new attempt
+ current = null;
+ }
+ }
+ }
+
+ /**
+ * Gets the object and updates the status. In case of conflict, retries
+ * up to `retries` times.
+ *
+ * @param updater the function updating the status
+ * @param retries the retries in case of conflict
+ * @return the updated model or empty if the object was not found
+ * @throws ApiException the api exception
+ */
+ public Optional updateStatus(Function updater, int retries)
+ throws ApiException {
+ return updateStatus(updater, null, retries);
+ }
+
+ /**
+ * Updates the status of the given object. In case of conflict,
+ * get the current version of the object and tries again. Retries
+ * up to `retries` times.
+ *
+ * @param updater the function updating the status
+ * @param current the current
+ * @return the kubernetes api response
+ * the updated model or empty if not successful
+ * @throws ApiException the api exception
+ */
+ public Optional updateStatus(Function updater, O current)
+ throws ApiException {
+ return updateStatus(updater, current, 16);
+ }
+
+ /**
+ * Updates the status. In case of conflict, retries up to 16 times.
+ *
+ * @param updater the function updating the status
+ * @return the kubernetes api response
+ * the updated model or empty if not successful
+ * @throws ApiException the api exception
+ */
+ public Optional updateStatus(Function updater)
+ throws ApiException {
+ return updateStatus(updater, null);
+ }
+
+ /**
+ * Patch the object.
+ *
+ * @param patchType the patch type
+ * @param patch the patch
+ * @param options the options
+ * @return the kubernetes api response if successful
+ * @throws ApiException the api exception
+ */
+ public Optional patch(String patchType, V1Patch patch,
+ PatchOptions options) throws ApiException {
+ return K8s
+ .optional(api.patch(namespace, name, patchType, patch, options)
+ .throwsApiException());
+ }
+
+ /**
+ * Patch the object using default options.
+ *
+ * @param patchType the patch type
+ * @param patch the patch
+ * @return the kubernetes api response if successful
+ * @throws ApiException the api exception
+ */
+ public Optional
+ patch(String patchType, V1Patch patch) throws ApiException {
+ PatchOptions opts = new PatchOptions();
+ return patch(patchType, patch, opts);
+ }
+
+ /**
+ * Apply the given definition.
+ *
+ * @param def the def
+ * @return the kubernetes api response if successful
+ * @throws ApiException the api exception
+ */
+ public Optional apply(DynamicKubernetesObject def) throws ApiException {
+ PatchOptions opts = new PatchOptions();
+ opts.setForce(true);
+ opts.setFieldManager("kubernetes-java-kubectl-apply");
+ return patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
+ new V1Patch(client.getJSON().serialize(def)), opts);
+ }
+
+ /**
+ * Update the object.
+ *
+ * @param object the object
+ * @return the kubernetes api response
+ * @throws ApiException the api exception
+ */
+ public KubernetesApiResponse update(O object) throws ApiException {
+ return api.update(object).throwsApiException();
+ }
+
+ /**
+ * Update the object.
+ *
+ * @param object the object
+ * @param options the options
+ * @return the kubernetes api response
+ * @throws ApiException the api exception
+ */
+ public KubernetesApiResponse update(O object, UpdateOptions options)
+ throws ApiException {
+ return api.update(object, options).throwsApiException();
+ }
+
+ /**
+ * A supplier for generic stubs.
+ *
+ * @param the object type
+ * @param the object list type
+ * @param the result type
+ */
+ @FunctionalInterface
+ public interface GenericSupplier> {
+
+ /**
+ * Gets a new stub.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ * @return the result
+ */
+ R get(K8sClient client, String namespace, String name);
+ }
+
+ @Override
+ @SuppressWarnings("PMD.UseLocaleWithCaseConversions")
+ public String toString() {
+ return (Strings.isNullOrEmpty(group()) ? "" : group() + "/")
+ + version().toUpperCase() + kind() + " " + namespace + ":" + name;
+ }
+
+ /**
+ * Get a namespaced object stub for a newly created object.
+ *
+ * @param the object type
+ * @param the object list type
+ * @param the stub type
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param model the model
+ * @param provider the provider
+ * @return the stub if the object exists
+ * @throws ApiException the api exception
+ */
+ public static >
+ R create(Class objectClass, Class objectListClass,
+ K8sClient client, APIResource context, O model,
+ GenericSupplier provider) throws ApiException {
+ var api = new GenericKubernetesApi<>(objectClass, objectListClass,
+ context.getGroup(), context.getPreferredVersion(),
+ context.getResourcePlural(), client);
+ api.create(model).throwsApiException();
+ return provider.get(client, model.getMetadata().getNamespace(),
+ model.getMetadata().getName());
+ }
+
+ /**
+ * Get the stubs for the objects in the given namespace that match
+ * the criteria from the given options.
+ *
+ * @param the object type
+ * @param the object list type
+ * @param the stub type
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param namespace the namespace
+ * @param options the options
+ * @param provider the provider
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static >
+ Collection list(Class objectClass, Class objectListClass,
+ K8sClient client, APIResource context, String namespace,
+ ListOptions options, GenericSupplier provider)
+ throws ApiException {
+ var result = new ArrayList();
+ for (var version : candidateVersions(context)) {
+ @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
+ var api = new GenericKubernetesApi<>(objectClass, objectListClass,
+ context.getGroup(), version, context.getResourcePlural(),
+ client);
+ var objs = api.list(namespace, options).throwsApiException();
+ for (var item : objs.getObject().getItems()) {
+ result.add(provider.get(client, namespace,
+ item.getMetadata().getName()));
+ }
+ }
+ return result;
+ }
+
+ private static List candidateVersions(APIResource context) {
+ var result = new LinkedList<>(context.getVersions());
+ result.remove(context.getPreferredVersion());
+ result.add(0, context.getPreferredVersion());
+ return result;
+ }
+
+ /**
+ * Api resource.
+ *
+ * @param client the client
+ * @param gvk the gvk
+ * @return the API resource
+ * @throws ApiException the api exception
+ */
+ public static APIResource apiResource(K8sClient client,
+ GroupVersionKind gvk) throws ApiException {
+ var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(),
+ gvk.getKind());
+ if (context.isEmpty()) {
+ throw new ApiException("No known API for " + gvk.getGroup()
+ + "/" + gvk.getVersion() + " " + gvk.getKind());
+ }
+ return context.get();
+ }
+
+}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java
new file mode 100644
index 0000000..9e22382
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java
@@ -0,0 +1,237 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.common.KubernetesListObject;
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.Watch.Response;
+import io.kubernetes.client.util.generic.GenericKubernetesApi;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.function.BiConsumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.jgrapes.core.Components;
+
+/**
+ * An observer that watches namespaced resources in a given context and
+ * invokes a handler on changes.
+ *
+ * @param the object type for the context
+ * @param the object list type for the context
+ */
+public class K8sObserver {
+
+ /**
+ * The type of change reported by {@link Response} as enum.
+ */
+ public enum ResponseType {
+ ADDED, MODIFIED, DELETED
+ }
+
+ protected final Logger logger = Logger.getLogger(getClass().getName());
+
+ protected final K8sClient client;
+ protected final GenericKubernetesApi api;
+ protected final APIResource context;
+ protected final String namespace;
+ protected final ListOptions options;
+ protected final Thread thread;
+ protected BiConsumer> handler;
+ protected BiConsumer, Throwable> onTerminated;
+
+ /**
+ * Create and start a new observer for objects in the given context
+ * (using preferred version) and namespace with the given options.
+ *
+ * @param objectClass the object class
+ * @param objectListClass the object list class
+ * @param client the client
+ * @param context the context
+ * @param namespace the namespace
+ * @param options the options
+ */
+ @SuppressWarnings({ "PMD.AvoidCatchingThrowable",
+ "PMD.CognitiveComplexity", "PMD.AvoidCatchingGenericException" })
+ public K8sObserver(Class objectClass, Class objectListClass,
+ K8sClient client, APIResource context, String namespace,
+ ListOptions options) {
+ this.client = client;
+ this.context = context;
+ this.namespace = namespace;
+ this.options = options;
+
+ api = new GenericKubernetesApi<>(objectClass, objectListClass,
+ context.getGroup(), context.getPreferredVersion(),
+ context.getResourcePlural(), client);
+ thread = (Components.useVirtualThreads() ? Thread.ofVirtual()
+ : Thread.ofPlatform()).unstarted(() -> {
+ try {
+ logger.fine(() -> "Observing " + context.getResourcePlural()
+ + " (" + context.getPreferredVersion() + ")"
+ + Optional.ofNullable(options.getLabelSelector())
+ .map(ls -> " with labels " + ls).orElse("")
+ + " in " + namespace);
+
+ // Watch sometimes terminates without apparent reason.
+ while (!Thread.currentThread().isInterrupted()) {
+ Instant startedAt = Instant.now();
+ try {
+ var changed
+ = api.watch(namespace, options).iterator();
+ while (changed.hasNext()) {
+ var response = changed.next();
+ logger.fine(() -> "Resource "
+ + context.getKind() + "/"
+ + response.object.getMetadata().getName()
+ + " " + response.type);
+ handler.accept(client, response);
+ }
+ } catch (ApiException | RuntimeException e) {
+ logger.log(Level.FINE, e, () -> "Problem watching"
+ + " resource " + context.getKind()
+ + " (will retry): " + e.getMessage());
+ delayRestart(startedAt);
+ }
+ }
+ if (onTerminated != null) {
+ onTerminated.accept(this, null);
+ }
+ } catch (Throwable e) {
+ logger.log(Level.SEVERE, e, () -> "Probem watching: "
+ + e.getMessage());
+ if (onTerminated != null) {
+ onTerminated.accept(this, e);
+ }
+ }
+ });
+ }
+
+ @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
+ private void delayRestart(Instant started) {
+ var runningFor = Duration
+ .between(started, Instant.now()).toMillis();
+ if (runningFor < 5000) {
+ logger.log(Level.FINE, () -> "Waiting... ");
+ try {
+ Thread.sleep(5000 - runningFor);
+ } catch (InterruptedException e1) { // NOPMD
+ // Retry
+ }
+ logger.log(Level.FINE, () -> "Retrying");
+ }
+ }
+
+ /**
+ * Sets the handler.
+ *
+ * @param handler the handler
+ * @return the observer
+ */
+ public K8sObserver
+ handler(BiConsumer> handler) {
+ this.handler = handler;
+ return this;
+ }
+
+ /**
+ * Sets a function to invoke if the observer terminates. First argument
+ * is this observer, the second is the throwable that caused the
+ * abnormal termination or `null` if the observer was terminated
+ * by {@link #stop()}.
+ *
+ * @param onTerminated the on terminated
+ * @return the observer
+ */
+ public K8sObserver onTerminated(
+ BiConsumer, Throwable> onTerminated) {
+ this.onTerminated = onTerminated;
+ return this;
+ }
+
+ /**
+ * Start the observer.
+ *
+ * @return the observer
+ */
+ public K8sObserver start() {
+ if (handler == null) {
+ throw new IllegalStateException("No handler defined");
+ }
+ thread.start();
+ return this;
+ }
+
+ /**
+ * Stops the observer.
+ *
+ * @return the observer
+ */
+ public K8sObserver stop() {
+ thread.interrupt();
+ return this;
+ }
+
+ /**
+ * Returns the client.
+ *
+ * @return the client
+ */
+ public K8sClient client() {
+ return client;
+ }
+
+ /**
+ * Returns the context.
+ *
+ * @return the context
+ */
+ public APIResource context() {
+ return context;
+ }
+
+ /**
+ * Returns the observed namespace.
+ *
+ * @return the namespace
+ */
+ public String getNamespace() {
+ return namespace;
+ }
+
+ /**
+ * Returns the options for object selection.
+ *
+ * @return the list options
+ */
+ public ListOptions options() {
+ return options;
+ }
+
+ @Override
+ public String toString() {
+ return "Observer for " + K8s.toString(context) + " " + namespace;
+ }
+
+}
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java
new file mode 100644
index 0000000..07c59b2
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java
@@ -0,0 +1,60 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.openapi.models.V1ConfigMap;
+import io.kubernetes.client.openapi.models.V1ConfigMapList;
+import java.util.List;
+
+/**
+ * A stub for config maps (v1).
+ */
+public class K8sV1ConfigMapStub
+ extends K8sGenericStub {
+
+ public static final APIResource CONTEXT = new APIResource("", List.of("v1"),
+ "v1", "ConfigMap", true, "configmaps", "configmap");
+
+ /**
+ * Instantiates a new stub.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ */
+ protected K8sV1ConfigMapStub(K8sClient client, String namespace,
+ String name) {
+ super(V1ConfigMap.class, V1ConfigMapList.class, client,
+ CONTEXT, namespace, name);
+ }
+
+ /**
+ * Gets the stub for the given namespace and name.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ * @return the config map stub
+ */
+ public static K8sV1ConfigMapStub get(K8sClient client, String namespace,
+ String name) {
+ return new K8sV1ConfigMapStub(client, namespace, name);
+ }
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java
new file mode 100644
index 0000000..9075a84
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java
@@ -0,0 +1,78 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.custom.V1Patch;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1Deployment;
+import io.kubernetes.client.openapi.models.V1DeploymentList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * A stub for pods (v1).
+ */
+public class K8sV1DeploymentStub
+ extends K8sGenericStub {
+
+ /** The deployment's context. */
+ public static final APIResource CONTEXT = new APIResource("apps",
+ List.of("v1"), "v1", "Pod", true, "deployments", "deployment");
+
+ /**
+ * Instantiates a new stub.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ */
+ protected K8sV1DeploymentStub(K8sClient client, String namespace,
+ String name) {
+ super(V1Deployment.class, V1DeploymentList.class, client,
+ CONTEXT, namespace, name);
+ }
+
+ /**
+ * Scales the deployment.
+ *
+ * @param replicas the replicas
+ * @return the new model or empty if not successful
+ * @throws ApiException the API exception
+ */
+ public Optional scale(int replicas) throws ApiException {
+ return patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
+ new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/replicas"
+ + "\", \"value\": " + replicas + "}]"),
+ client.defaultPatchOptions());
+ }
+
+ /**
+ * Gets the stub for the given namespace and name.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ * @return the deployment stub
+ */
+ public static K8sV1DeploymentStub get(K8sClient client, String namespace,
+ String name) {
+ return new K8sV1DeploymentStub(client, namespace, name);
+ }
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java
new file mode 100644
index 0000000..ea1237d
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java
@@ -0,0 +1,83 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1Node;
+import io.kubernetes.client.openapi.models.V1NodeList;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A stub for nodes (v1).
+ */
+public class K8sV1NodeStub extends K8sClusterGenericStub {
+
+ public static final APIResource CONTEXT = new APIResource("", List.of("v1"),
+ "v1", "Node", true, "nodes", "node");
+
+ /**
+ * Instantiates a new stub.
+ *
+ * @param client the client
+ * @param name the name
+ */
+ protected K8sV1NodeStub(K8sClient client, String name) {
+ super(V1Node.class, V1NodeList.class, client, CONTEXT, name);
+ }
+
+ /**
+ * Gets the stub for the given name.
+ *
+ * @param client the client
+ * @param name the name
+ * @return the config map stub
+ */
+ public static K8sV1NodeStub get(K8sClient client, String name) {
+ return new K8sV1NodeStub(client, name);
+ }
+
+ /**
+ * Get the stubs for the objects that match
+ * the criteria from the given options.
+ *
+ * @param client the client
+ * @param options the options
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static Collection list(K8sClient client,
+ ListOptions options) throws ApiException {
+ return K8sClusterGenericStub.list(V1Node.class, V1NodeList.class,
+ client, CONTEXT, options, K8sV1NodeStub::getGeneric);
+ }
+
+ /**
+ * Provide {@link GenericSupplier}.
+ */
+ @SuppressWarnings({ "PMD.UnusedFormalParameter" })
+ private static K8sV1NodeStub getGeneric(Class objectClass,
+ Class objectListClass, K8sClient client,
+ APIResource context, String name) {
+ return new K8sV1NodeStub(client, name);
+ }
+
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java
new file mode 100644
index 0000000..f21bb47
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java
@@ -0,0 +1,78 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1Pod;
+import io.kubernetes.client.openapi.models.V1PodList;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A stub for pods (v1).
+ */
+public class K8sV1PodStub extends K8sGenericStub {
+
+ /** The pods' context. */
+ public static final APIResource CONTEXT
+ = new APIResource("", List.of("v1"), "v1", "Pod", true, "pods", "pod");
+
+ /**
+ * Instantiates a new stub.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ */
+ protected K8sV1PodStub(K8sClient client, String namespace, String name) {
+ super(V1Pod.class, V1PodList.class, client, CONTEXT, namespace, name);
+ }
+
+ /**
+ * Gets the stub for the given namespace and name.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ * @return the kpod stub
+ */
+ public static K8sV1PodStub get(K8sClient client, String namespace,
+ String name) {
+ return new K8sV1PodStub(client, namespace, name);
+ }
+
+ /**
+ * Get the stubs for the objects in the given namespace that match
+ * the criteria from the given options.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param options the options
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static Collection list(K8sClient client,
+ String namespace, ListOptions options) throws ApiException {
+ return K8sGenericStub.list(V1Pod.class, V1PodList.class, client,
+ CONTEXT, namespace, options, (clnt, nscp,
+ name) -> new K8sV1PodStub(clnt, nscp, name));
+ }
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java
new file mode 100644
index 0000000..c46a60f
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java
@@ -0,0 +1,81 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1PersistentVolumeClaim;
+import io.kubernetes.client.openapi.models.V1PersistentVolumeClaimList;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A stub for pods (v1).
+ */
+public class K8sV1PvcStub extends
+ K8sGenericStub {
+
+ /** The pods' context. */
+ public static final APIResource CONTEXT
+ = new APIResource("", List.of("v1"), "v1", "PersistentVolumeClaim",
+ true, "persistentvolumeclaims", "persistentvolumeclaim");
+
+ /**
+ * Instantiates a new stub.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ */
+ protected K8sV1PvcStub(K8sClient client, String namespace, String name) {
+ super(V1PersistentVolumeClaim.class, V1PersistentVolumeClaimList.class,
+ client, CONTEXT, namespace, name);
+ }
+
+ /**
+ * Gets the stub for the given namespace and name.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ * @return the kpod stub
+ */
+ public static K8sV1PvcStub get(K8sClient client, String namespace,
+ String name) {
+ return new K8sV1PvcStub(client, namespace, name);
+ }
+
+ /**
+ * Get the stubs for the objects in the given namespace that match
+ * the criteria from the given options.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param options the options
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static Collection list(K8sClient client,
+ String namespace, ListOptions options) throws ApiException {
+ return K8sGenericStub.list(V1PersistentVolumeClaim.class,
+ V1PersistentVolumeClaimList.class, client, CONTEXT, namespace,
+ options, (clnt, nscp, name) -> new K8sV1PvcStub(clnt, nscp, name));
+ }
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java
new file mode 100644
index 0000000..9c1c086
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java
@@ -0,0 +1,92 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1Secret;
+import io.kubernetes.client.openapi.models.V1SecretList;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A stub for secrets (v1).
+ */
+public class K8sV1SecretStub extends K8sGenericStub {
+
+ public static final APIResource CONTEXT = new APIResource("", List.of("v1"),
+ "v1", "Secret", true, "secrets", "secret");
+
+ /**
+ * Instantiates a new stub.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ */
+ protected K8sV1SecretStub(K8sClient client, String namespace,
+ String name) {
+ super(V1Secret.class, V1SecretList.class, client,
+ CONTEXT, namespace, name);
+ }
+
+ /**
+ * Gets the stub for the given namespace and name.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ * @return the config map stub
+ */
+ public static K8sV1SecretStub get(K8sClient client, String namespace,
+ String name) {
+ return new K8sV1SecretStub(client, namespace, name);
+ }
+
+ /**
+ * Creates an object stub from a model.
+ *
+ * @param client the client
+ * @param model the model
+ * @return the k 8 s dynamic stub
+ * @throws ApiException the api exception
+ */
+ public static K8sV1SecretStub create(K8sClient client, V1Secret model)
+ throws ApiException {
+ return K8sGenericStub.create(V1Secret.class,
+ V1SecretList.class, client, CONTEXT, model, K8sV1SecretStub::new);
+ }
+
+ /**
+ * Get the stubs for the objects in the given namespace that match
+ * the criteria from the given options.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param options the options
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static Collection list(K8sClient client,
+ String namespace, ListOptions options) throws ApiException {
+ return K8sGenericStub.list(V1Secret.class, V1SecretList.class, client,
+ CONTEXT, namespace, options, K8sV1SecretStub::new);
+ }
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java
new file mode 100644
index 0000000..863f86f
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java
@@ -0,0 +1,79 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServiceList;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A stub for secrets (v1).
+ */
+public class K8sV1ServiceStub extends K8sGenericStub {
+
+ public static final APIResource CONTEXT = new APIResource("", List.of("v1"),
+ "v1", "Service", true, "services", "service");
+
+ /**
+ * Instantiates a new stub.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ */
+ protected K8sV1ServiceStub(K8sClient client, String namespace,
+ String name) {
+ super(V1Service.class, V1ServiceList.class, client, CONTEXT, namespace,
+ name);
+ }
+
+ /**
+ * Gets the stub for the given namespace and name.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ * @return the config map stub
+ */
+ public static K8sV1ServiceStub get(K8sClient client, String namespace,
+ String name) {
+ return new K8sV1ServiceStub(client, namespace, name);
+ }
+
+ /**
+ * Get the stubs for the objects in the given namespace that match
+ * the criteria from the given options.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param options the options
+ * @return the collection
+ * @throws ApiException the api exception
+ */
+ public static Collection list(K8sClient client,
+ String namespace, ListOptions options) throws ApiException {
+ return K8sGenericStub.list(V1Service.class, V1ServiceList.class, client,
+ CONTEXT, namespace, options,
+ (clnt, nscp, name) -> new K8sV1ServiceStub(clnt, nscp, name));
+ }
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java
new file mode 100644
index 0000000..be30b00
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java
@@ -0,0 +1,62 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2024 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import io.kubernetes.client.Discovery.APIResource;
+import io.kubernetes.client.openapi.models.V1StatefulSet;
+import io.kubernetes.client.openapi.models.V1StatefulSetList;
+import java.util.List;
+
+/**
+ * A stub for stateful sets (v1).
+ */
+public class K8sV1StatefulSetStub
+ extends K8sGenericStub {
+
+ /** The stateful sets' context */
+ public static final APIResource CONTEXT
+ = new APIResource("apps", List.of("v1"), "v1", "StatefulSet", true,
+ "statefulsets", "statefulset");
+
+ /**
+ * Instantiates a new stub.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ */
+ protected K8sV1StatefulSetStub(K8sClient client, String namespace,
+ String name) {
+ super(V1StatefulSet.class, V1StatefulSetList.class, client, CONTEXT,
+ namespace, name);
+ }
+
+ /**
+ * Gets the stub for the given namespace and name.
+ *
+ * @param client the client
+ * @param namespace the namespace
+ * @param name the name
+ * @return the stateful set stub
+ */
+ public static K8sV1StatefulSetStub get(K8sClient client, String namespace,
+ String name) {
+ return new K8sV1StatefulSetStub(client, namespace, name);
+ }
+}
\ No newline at end of file
diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java
new file mode 100644
index 0000000..a0b66bf
--- /dev/null
+++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java
@@ -0,0 +1,499 @@
+/*
+ * VM-Operator
+ * Copyright (C) 2025 Michael N. Lipp
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jdrupes.vmoperator.common;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.openapi.JSON;
+import io.kubernetes.client.openapi.models.V1Condition;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import org.jdrupes.vmoperator.common.Constants.Status;
+import org.jdrupes.vmoperator.common.Constants.Status.Condition;
+import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason;
+import org.jdrupes.vmoperator.util.DataPath;
+
+/**
+ * Represents a VM definition.
+ */
+@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" })
+public class VmDefinition extends K8sDynamicModel {
+
+ @SuppressWarnings({ "unused" })
+ private static final Logger logger
+ = Logger.getLogger(VmDefinition.class.getName());
+ @SuppressWarnings("PMD.FieldNamingConventions")
+ private static final Gson gson = new JSON().getGson();
+ @SuppressWarnings("PMD.FieldNamingConventions")
+ private static final ObjectMapper objectMapper
+ = new ObjectMapper().registerModule(new JavaTimeModule());
+
+ private final Model model;
+ private VmExtraData extraData;
+
+ /**
+ * The VM state from the VM definition.
+ */
+ public enum RequestedVmState {
+ STOPPED, RUNNING
+ }
+
+ /**
+ * Permissions for accessing and manipulating the VM.
+ */
+ public enum Permission {
+ START("start"), STOP("stop"), RESET("reset"),
+ ACCESS_CONSOLE("accessConsole"), TAKE_CONSOLE("takeConsole");
+
+ @SuppressWarnings("PMD.UseConcurrentHashMap")
+ private static Map reprs = new HashMap<>();
+
+ static {
+ for (var value : EnumSet.allOf(Permission.class)) {
+ reprs.put(value.repr, value);
+ }
+ }
+
+ private final String repr;
+
+ Permission(String repr) {
+ this.repr = repr;
+ }
+
+ /**
+ * Create permission from representation in CRD.
+ *
+ * @param value the value
+ * @return the permission
+ */
+ @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
+ public static Set parse(String value) {
+ if ("*".equals(value)) {
+ return EnumSet.allOf(Permission.class);
+ }
+ return Set.of(reprs.get(value));
+ }
+
+ /**
+ * To string.
+ *
+ * @return the string
+ */
+ @Override
+ public String toString() {
+ return repr;
+ }
+ }
+
+ /**
+ * Permissions granted to a user or role.
+ *
+ * @param user the user
+ * @param role the role
+ * @param may the may
+ */
+ public record Grant(String user, String role, Set may) {
+
+ /**
+ * To string.
+ *
+ * @return the string
+ */
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ if (user != null) {
+ builder.append("User ").append(user);
+ } else {
+ builder.append("Role ").append(role);
+ }
+ builder.append(" may=").append(may).append(']');
+ return builder.toString();
+ }
+ }
+
+ /**
+ * The assignment information.
+ *
+ * @param pool the pool
+ * @param user the user
+ * @param lastUsed the last used
+ */
+ public record Assignment(String pool, String user, Instant lastUsed) {
+ }
+
+ /**
+ * Instantiates a new vm definition.
+ *
+ * @param delegate the delegate
+ * @param json the json
+ */
+ public VmDefinition(Gson delegate, JsonObject json) {
+ super(delegate, json);
+ model = gson.fromJson(json, Model.class);
+ }
+
+ /**
+ * Gets the spec.
+ *
+ * @return the spec
+ */
+ public Map spec() {
+ return model.getSpec();
+ }
+
+ /**
+ * Get a value from the spec using {@link DataPath#get}.
+ *
+ * @param the generic type
+ * @param selectors the selectors
+ * @return the value, if found
+ */
+ public Optional fromSpec(Object... selectors) {
+ return DataPath.get(spec(), selectors);
+ }
+
+ /**
+ * The pools that this VM belongs to.
+ *
+ * @return the list
+ */
+ public List pools() {
+ return this.> fromSpec("pools")
+ .orElse(Collections.emptyList());
+ }
+
+ /**
+ * Get a value from the `spec().get("vm")` using {@link DataPath#get}.
+ *
+ * @param the generic type
+ * @param selectors the selectors
+ * @return the value, if found
+ */
+ public Optional fromVm(Object... selectors) {
+ return DataPath.get(spec(), "vm")
+ .flatMap(vm -> DataPath.get(vm, selectors));
+ }
+
+ /**
+ * Gets the status.
+ *
+ * @return the status
+ */
+ public Map status() {
+ return model.getStatus();
+ }
+
+ /**
+ * Get a value from the status using {@link DataPath#get}.
+ *
+ * @param the generic type
+ * @param selectors the selectors
+ * @return the value, if found
+ */
+ public Optional fromStatus(Object... selectors) {
+ return DataPath.get(status(), selectors);
+ }
+
+ /**
+ * The assignment information.
+ *
+ * @return the optional
+ */
+ public Optional assignment() {
+ return this.