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

+ + + + + \ No newline at end of file diff --git a/misc/stylesheet.css b/misc/stylesheet.css deleted file mode 100644 index e21b9b2..0000000 --- a/misc/stylesheet.css +++ /dev/null @@ -1,912 +0,0 @@ -/* - * Javadoc style sheet - */ - -@font-face { - font-family: 'DejaVu Serif'; - src: local('DejaVu Serif'), url('DejaVuSerif.woff2'); -} - -@font-face { - font-family: 'DejaVu Serif'; - font-weight: bold; - src: local('DejaVu Serif Bold'), url('DejaVuSerif-Bold.woff2'); -} - -@font-face { - font-family: 'DejaVu Sans'; - src: local('DejaVu Sans'), url('DejaVuSans.woff2'); -} - -@font-face { - font-family: 'DejaVu Sans'; - font-weight: bold; - src: local('DejaVu Sans Bold'), url('DejaVuSans-Bold.woff2'); -} - -@font-face { - font-family: 'DejaVu Sans Mono'; - src: local('DejaVu Sans Mono'), url('DejaVuSansMono.woff2'); -} - -@font-face { - font-family: 'DejaVu Sans Mono'; - font-weight: bold; - src: local('DejaVu Sans Mono Bold'), url('DejaVuSansMono-Bold.woff2'); -} - -/* - * Styles for individual HTML elements. - * - * These are styles that are specific to individual HTML elements. Changing them affects the style of a particular - * HTML element throughout the page. - */ - -body { - background-color:#ffffff; - color:#353833; - font: normal 16px/1.5 "DejaVu Serif", serif; - margin:0; - padding:0; - height:100%; - width:100%; -} -iframe { - margin:0; - padding:0; - height:100%; - width:100%; - overflow-y:scroll; - border:none; -} -a:link, a:visited { - text-decoration:none; - color:#4A6782; -} -a[href]:hover, a[href]:focus { - text-decoration:none; - color:#bb7a2a; -} -a[name] { - color:#353833; -} -pre { - font-family: "DejaVu Sans Mono", monospace; -} -h1 { - font-family: "DejaVu Sans", sans; - font-size:20px; -} -h2 { - font-family: "DejaVu Sans", sans; - font-size:18px; -} -h3 { - font-family: "DejaVu Sans", sans; - font-size:16px; -} -h4 { - font-family: "DejaVu Sans", sans; - font-size:15px; -} -h5 { - font-family: "DejaVu Sans", sans; - font-size:14px; -} -h6 { - font-family: "DejaVu Sans", sans; - font-size:13px; -} -ul { - list-style-type:disc; -} -code, tt { - font-family: "DejaVu Sans Mono", monospace; -} -:not(h1, h2, h3, h4, h5, h6) > code, -:not(h1, h2, h3, h4, h5, h6) > tt { - /* font-size:14px; */ - padding-top:4px; - margin-top:8px; - line-height:1.4em; -} -dt code { - font-family: "DejaVu Sans Mono", monospace; - font-size:14px; - padding-top:4px; -} -.summary-table dt code { - font-family: "DejaVu Sans Mono", monospace; - font-size:14px; - vertical-align:top; - padding-top:4px; -} -sup { - font-size:8px; -} - -/* - * Styles for HTML generated by javadoc. - * - * These are style classes that are used by the standard doclet to generate HTML documentation. - */ - -/* - * Styles for document title and copyright. - */ -.clear { - clear:both; - height:0; - overflow:hidden; -} -.about-language { - float:right; - padding:0 21px 8px 8px; - font-size:11px; - margin-top:-9px; - height:2.9em; -} -.legal-copy { - margin-left:.5em; -} -.tab { - background-color:#0066FF; - color:#ffffff; - padding:8px; - width:5em; - font-weight:bold; -} -/* - * Styles for navigation bar. - */ -@media screen { - .flex-box { - position:fixed; - display:flex; - flex-direction:column; - height: 100%; - width: 100%; - } - .flex-header { - flex: 0 0 auto; - } - .flex-content { - flex: 1 1 auto; - overflow-y: auto; - } -} -.top-nav { - background-color:#4D7A97; - color:#FFFFFF; - float:left; - padding:0; - width:100%; - clear:right; - min-height:2.8em; - padding-top:10px; - overflow:hidden; - font-family: "DejaVu Sans", sans; - font-size:80%; -} -.sub-nav { - background-color:#dee3e9; - float:left; - width:100%; - overflow:hidden; - font-family: "DejaVu Sans", sans; - font-size:80%; -} -.sub-nav div { - clear:left; - float:left; - padding:0 0 5px 6px; - text-transform:uppercase; -} -.sub-nav .nav-list { - padding-top:5px; -} -ul.nav-list { - display:block; - margin:0 25px 0 0; - padding:0; -} -ul.sub-nav-list { - float:left; - margin:0 25px 0 0; - padding:0; -} -ul.nav-list li { - list-style:none; - float:left; - padding: 5px 6px; - text-transform:uppercase; -} -.sub-nav .nav-list-search { - float:right; - margin:0 0 0 0; - padding:5px 6px; - clear:none; -} -.nav-list-search label { - position:relative; - right:-16px; -} -ul.sub-nav-list li { - list-style:none; - float:left; - padding-top:10px; -} -.top-nav a:link, .top-nav a:active, .top-nav a:visited { - color:#FFFFFF; - text-decoration:none; - text-transform:uppercase; -} -.top-nav a:hover { - text-decoration:none; - color:#bb7a2a; - text-transform:uppercase; -} -.nav-bar-cell1-rev { - background-color:#F8981D; - color:#253441; - margin: auto 5px; -} -.skip-nav { - position:absolute; - top:auto; - left:-9999px; - overflow:hidden; -} -/* - * Hide navigation links and search box in print layout - */ -@media print { - ul.nav-list, div.sub-nav { - display:none; - } -} -/* - * Styles for page header and footer. - */ -.title { - color:#2c4557; - margin:10px 0; -} -.sub-title { - margin:5px 0 0 0; -} -.header ul { - margin:0 0 15px 0; - padding:0; -} -.header ul li, .footer ul li { - list-style:none; - font-size:80%; -} -/* - * Styles for headings. - */ -body.class-declaration-page .summary h2, -body.class-declaration-page .details h2, -body.class-use-page h2, -body.module-declaration-page .block-list h2 { - font-style: italic; - padding:0; - margin:15px 0; -} -body.class-declaration-page .summary h3, -body.class-declaration-page .details h3, -body.class-declaration-page .summary .inherited-list h2 { - background-color:#dee3e9; - border:1px solid #d0d9e0; - margin:0 0 6px -8px; - padding:7px 5px; -} -/* - * Styles for page layout containers. - */ -main { - clear:both; - padding:10px 20px; - position:relative; -} -dl.notes > dt { - font-family: "DejaVu Sans", sans; - font-weight:bold; - margin:10px 0 0 0; - color:#4E4E4E; -} -dl.notes > dd { - margin:5px 10px 10px 0; -} -dl.name-value > dt { - margin-left:1px; - /* font-size:1.1em; */ - display:inline; - font-weight:bold; -} -dl.name-value > dd { - margin:0 0 0 1px; - /* font-size:1.1em; */ - display:inline; -} -/* - * Styles for lists. - */ -li.circle { - list-style:circle; -} -ul.horizontal li { - display:inline; - /* font-size:0.9em; */ -} -div.inheritance { - margin:0; - padding:0; -} -div.inheritance div.inheritance { - margin-left:2em; -} -ul.block-list, -ul.details-list, -ul.member-list, -ul.summary-list { - margin:10px 0 10px 0; - padding:0; -} -ul.block-list > li, -ul.details-list > li, -ul.member-list > li, -ul.summary-list > li { - list-style:none; - margin-bottom:15px; - line-height:1.4; -} -.summary-table dl, .summary-table dl dt, .summary-table dl dd { - margin-top:0; - margin-bottom:1px; -} -ul.see-list, ul.see-list-long { - padding-left: 0; - list-style: none; -} -ul.see-list li { - display: inline; -} -ul.see-list li:not(:last-child):after, -ul.see-list-long li:not(:last-child):after { - content: ", "; - white-space: pre-wrap; -} -/* - * Styles for tables. - */ -.summary-table, .details-table { - width:100%; - border-spacing:0; - border-left:1px solid #EEE; - border-right:1px solid #EEE; - border-bottom:1px solid #EEE; - padding:0; -} -.caption { - position:relative; - text-align:left; - background-repeat:no-repeat; - color:#253441; - font-weight:bold; - clear:none; - overflow:hidden; - padding:0; - padding-top:10px; - padding-left:1px; - margin:0; - white-space:pre; - font-family: 'DejaVu Sans'; -} -.caption a:link, .caption a:visited { - color:#1f389c; -} -.caption a:hover, -.caption a:active { - color:#FFFFFF; -} -.caption span { - white-space:nowrap; - padding-top:5px; - padding-left:12px; - padding-right:12px; - padding-bottom:7px; - display:inline-block; - float:left; - background-color:#F8981D; - border: none; - height:16px; -} -div.table-tabs { - padding:10px 0 0 1px; - margin:0; -} -div.table-tabs > button { - border: none; - cursor: pointer; - padding: 5px 12px 7px 12px; - font-weight: bold; - margin-right: 3px; -} -div.table-tabs > button.active-table-tab { - background: #F8981D; - color: #253441; -} -div.table-tabs > button.table-tab { - background: #4D7A97; - color: #FFFFFF; -} -.two-column-summary { - display: grid; - grid-template-columns: minmax(15%, max-content) minmax(15%, auto); -} -.three-column-summary { - display: grid; - grid-template-columns: minmax(10%, max-content) minmax(15%, max-content) minmax(15%, auto); -} -#method-summary-table .three-column-summary { - grid-template-columns: minmax(10%, 20%) minmax(15%, max-content) minmax(15%, auto); -} -.four-column-summary { - display: grid; - grid-template-columns: minmax(10%, max-content) minmax(10%, max-content) minmax(10%, max-content) minmax(10%, auto); -} -@media screen and (max-width: 600px) { - .two-column-summary { - display: grid; - grid-template-columns: 1fr; - } -} -@media screen and (max-width: 800px) { - .three-column-summary { - display: grid; - grid-template-columns: minmax(10%, max-content) minmax(25%, auto); - } - .three-column-summary .col-last { - grid-column-end: span 2; - } -} -@media screen and (max-width: 1000px) { - .four-column-summary { - display: grid; - grid-template-columns: minmax(15%, max-content) minmax(15%, auto); - } -} -.summary-table > div, .details-table > div { - text-align:left; - padding: 8px 3px 3px 7px; -} -.col-first, .col-second, .col-last, .col-constructor-name, .col-summary-item-name { - vertical-align:top; - padding-right:0; - padding-top:8px; - padding-bottom:3px; -} -.table-header { - background:#dee3e9; - font-family: 'DejaVu Sans'; - font-weight: bold; -} -/* -.col-first, .col-first { - font-size:13px; -} -.col-second, .col-second, .col-last, .col-constructor-name, .col-summary-item-name, .col-last { - font-size:13px; -} -*/ -.col-first, .col-second, .col-constructor-name { - vertical-align:top; - overflow: auto; -} -.col-last { - white-space:normal; -} -/* -.col-first a:link, .col-first a:visited, -.col-second a:link, .col-second a:visited, -.col-first a:link, .col-first a:visited, -.col-second a:link, .col-second a:visited, -.col-constructor-name a:link, .col-constructor-name a:visited, -.col-summary-item-name a:link, .col-summary-item-name a:visited, -.constant-values-container a:link, .constant-values-container a:visited, -.all-classes-container a:link, .all-classes-container a:visited, -.all-packages-container a:link, .all-packages-container a:visited { - font-weight:bold; -} -*/ -.table-sub-heading-color { - background-color:#EEEEFF; -} -.even-row-color, .even-row-color .table-header { - background-color:#FFFFFF; -} -.odd-row-color, .odd-row-color .table-header { - background-color:#EEEEEF; -} -/* - * Styles for contents. - */ -.deprecated-content { - margin:0; - padding:10px 0; -} -/* -div.block { - font-size:14px; - font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif; -} -*/ -.col-last div { - padding-top:0; -} -.col-last a { - padding-bottom:3px; -} -.module-signature, -.package-signature, -.type-signature, -.member-signature { - font-family: "DejaVu Sans Mono", monospace; - /* font-size:14px; */ - margin:14px 0; - white-space: pre-wrap; -} -.module-signature, -.package-signature, -.type-signature { - margin-top: 0; -} -.member-signature .type-parameters-long, -.member-signature .parameters, -.member-signature .exceptions { - display: inline-block; - vertical-align: top; - white-space: pre; -} -.member-signature .type-parameters { - white-space: normal; -} -/* - * Styles for formatting effect. - */ -.source-line-no { - color:green; - padding:0 30px 0 0; -} -h1.hidden { - visibility:hidden; - overflow:hidden; - /* font-size:10px; */ -} -.block { - display:block; - margin:0 10px 5px 0; - color:#474747; -} -.deprecated-label, .descfrm-type-label, .implementation-label, .member-name-label, .member-name-link, -.module-label-in-package, .module-label-in-type, .override-specify-label, .package-label-in-type, -.package-hierarchy-label, .type-name-label, .type-name-link, .search-tag-link, .preview-label { - font-family: "DejaVu Sans", sans; - font-weight:bold; -} -.sub-title, .inheritance, .all-packages-table-tab1.col-first, - .summary-table .col-first { - font-family: "DejaVu Sans", sans; -} -.deprecation-comment, .help-footnote, .preview-comment { - font-style:italic; -} -.deprecation-block { - /* font-size:14px; */ - font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif; - border-style:solid; - border-width:thin; - border-radius:10px; - padding:10px; - margin-bottom:10px; - margin-right:10px; - display:inline-block; -} -.preview-block { - /* font-size:14px; */ - font-family:'DejaVu Serif', Georgia, "Times New Roman", Times, serif; - border-style:solid; - border-width:thin; - border-radius:10px; - padding:10px; - margin-bottom:10px; - margin-right:10px; - display:inline-block; -} -div.block div.deprecation-comment { - font-style:normal; -} -/* - * Styles specific to HTML5 elements. - */ -main, nav, header, footer, section { - display:block; -} -/* - * Styles for javadoc search. - */ -.ui-autocomplete-category { - font-weight:bold; - /* font-size:15px; */ - padding:7px 0 7px 3px; - background-color:#4D7A97; - color:#FFFFFF; -} -.result-item { - /* font-size:13px; */ -} -.ui-autocomplete { - max-height:85%; - max-width:65%; - overflow-y:scroll; - overflow-x:scroll; - white-space:nowrap; - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); -} -ul.ui-autocomplete { - position:fixed; - z-index:999999; -} -ul.ui-autocomplete li { - float:left; - clear:both; - width:100%; -} -.result-highlight { - font-weight:bold; -} -#search-input { - background-image:url('resources/glass.png'); - background-size:13px; - background-repeat:no-repeat; - background-position:2px 3px; - padding-left:20px; - position:relative; - right:-18px; - width:400px; -} -#reset-button { - background-color: rgb(255,255,255); - background-image:url('resources/x.png'); - background-position:center; - background-repeat:no-repeat; - background-size:12px; - border:0 none; - width:16px; - height:16px; - position:relative; - left:-4px; - top:-4px; - font-size:0px; -} -.watermark { - color:#545454; -} -.search-tag-desc-result { - font-style:italic; - /* font-size:11px; */ -} -.search-tag-holder-result { - font-style:italic; - /* font-size:12px; */ -} -.search-tag-result:target { - background-color:yellow; -} -.module-graph span { - display:none; - position:absolute; -} -.module-graph:hover span { - display:block; - margin: -100px 0 0 100px; - z-index: 1; -} -.inherited-list { - margin: 10px 0 10px 0; -} -section.class-description { - line-height: 1.4; -} -.summary section[class$="-summary"], .details section[class$="-details"], -.class-uses .detail, .serialized-class-details { - padding: 0px 20px 5px 10px; - border: 1px solid #ededed; - background-color: #f8f8f8; -} -.inherited-list, section[class$="-details"] .detail { - padding:0 0 5px 8px; - background-color:#ffffff; - border:none; -} -.vertical-separator { - padding: 0 5px; -} -ul.help-section-list { - margin: 0; -} -ul.help-subtoc > li { - display: inline-block; - padding-right: 5px; - /* font-size: smaller; */ -} -ul.help-subtoc > li::before { - content: "\2022" ; - padding-right:2px; -} -span.help-note { - font-style: italic; -} -/* - * Indicator icon for external links. - */ -main a[href*="://"]::after { - content:""; - display:inline-block; - background-image:url('data:image/svg+xml; utf8, \ - \ - \ - '); - background-size:100% 100%; - width:7px; - height:7px; - margin-left:2px; - margin-bottom:4px; -} -main a[href*="://"]:hover::after, -main a[href*="://"]:focus::after { - background-image:url('data:image/svg+xml; utf8, \ - \ - \ - '); -} - -/* - * Styles for user-provided tables. - * - * borderless: - * No borders, vertical margins, styled caption. - * This style is provided for use with existing doc comments. - * In general, borderless tables should not be used for layout purposes. - * - * plain: - * Plain borders around table and cells, vertical margins, styled caption. - * Best for small tables or for complex tables for tables with cells that span - * rows and columns, when the "striped" style does not work well. - * - * striped: - * Borders around the table and vertical borders between cells, striped rows, - * vertical margins, styled caption. - * Best for tables that have a header row, and a body containing a series of simple rows. - */ - -table.borderless, -table.plain, -table.striped { - margin-top: 10px; - margin-bottom: 10px; -} -table.borderless > caption, -table.plain > caption, -table.striped > caption { - font-weight: bold; - /* font-size: smaller; */ -} -table.borderless th, table.borderless td, -table.plain th, table.plain td, -table.striped th, table.striped td { - padding: 2px 5px; -} -table.borderless, -table.borderless > thead > tr > th, table.borderless > tbody > tr > th, table.borderless > tr > th, -table.borderless > thead > tr > td, table.borderless > tbody > tr > td, table.borderless > tr > td { - border: none; -} -table.borderless > thead > tr, table.borderless > tbody > tr, table.borderless > tr { - background-color: transparent; -} -table.plain { - border-collapse: collapse; - border: 1px solid black; -} -table.plain > thead > tr, table.plain > tbody tr, table.plain > tr { - background-color: transparent; -} -table.plain > thead > tr > th, table.plain > tbody > tr > th, table.plain > tr > th, -table.plain > thead > tr > td, table.plain > tbody > tr > td, table.plain > tr > td { - border: 1px solid black; -} -table.striped { - border-collapse: collapse; - border: 1px solid black; -} -table.striped > thead { - background-color: #E3E3E3; -} -table.striped > thead > tr > th, table.striped > thead > tr > td { - border: 1px solid black; -} -table.striped > tbody > tr:nth-child(even) { - background-color: #EEE -} -table.striped > tbody > tr:nth-child(odd) { - background-color: #FFF -} -table.striped > tbody > tr > th, table.striped > tbody > tr > td { - border-left: 1px solid black; - border-right: 1px solid black; -} -table.striped > tbody > tr > th { - font-weight: normal; -} -/** - * Tweak font sizes and paddings for small screens. - */ -@media screen and (max-width: 1050px) { - #search-input { - width: 300px; - } -} -@media screen and (max-width: 800px) { - #search-input { - width: 200px; - } - .top-nav, - .bottom-nav { - font-size: 80%; - padding-top: 6px; - } - .sub-nav { - font-size: 80%; - } - .about-language { - padding-right: 16px; - } - ul.nav-list li, - .sub-nav .nav-list-search { - padding: 6px; - } - ul.sub-nav-list li { - padding-top: 5px; - } - main { - padding: 10px; - } - .summary section[class$="-summary"], .details section[class$="-details"], - .class-uses .detail, .serialized-class-details { - padding: 0 8px 5px 8px; - } - body { - -webkit-text-size-adjust: none; - } -} -@media screen and (max-width: 500px) { - #search-input { - width: 150px; - } - .top-nav, - .bottom-nav { - font-size: 80%; - } - .sub-nav { - font-size: 80%; - } - .about-language { - font-size: 80%; - padding-right: 12px; - } -} diff --git a/org.jdrupes.vmoperator.common/.eclipse-pmd b/org.jdrupes.vmoperator.common/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.common/.eclipse-pmd +++ b/org.jdrupes.vmoperator.common/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs b/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs new file mode 100644 index 0000000..8b8b906 --- /dev/null +++ b/org.jdrupes.vmoperator.common/.settings/net.sf.jautodoc.prefs @@ -0,0 +1,7 @@ +add_header=true +eclipse.preferences.version=1 +header_text=/*\n * VM-Operator\n * Copyright (C) 2024 Michael N. Lipp\n * \n * This program is free software\: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see .\n */ +project_specific_settings=true +visibility_package=false +visibility_private=false +visibility_protected=false diff --git a/org.jdrupes.vmoperator.common/build.gradle b/org.jdrupes.vmoperator.common/build.gradle index ed082a1..e72cb14 100644 --- a/org.jdrupes.vmoperator.common/build.gradle +++ b/org.jdrupes.vmoperator.common/build.gradle @@ -10,5 +10,8 @@ plugins { dependencies { api project(':org.jdrupes.vmoperator.util') - api 'io.kubernetes:client-java:[18.0.0,19)' + api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' + api 'io.kubernetes:client-java:[19.0.0,20.0.0)' + api 'org.yaml:snakeyaml' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]' } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java index 22decd3..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,14 +21,107 @@ package org.jdrupes.vmoperator.common; /** * Some constants. */ +@SuppressWarnings("PMD.DataClass") public class Constants { + /** The Constant APP_NAME. */ + public static final String APP_NAME = "vm-runner"; + /** The Constant VM_OP_NAME. */ public static final String VM_OP_NAME = "vm-operator"; - /** The Constant VM_OP_GROUP. */ - public static final String VM_OP_GROUP = "vmoperator.jdrupes.org"; + /** + * Constants related to the CRD. + */ + @SuppressWarnings("PMD.ShortClassName") + public static class Crd { + /** The Constant GROUP. */ + public static final String GROUP = "vmoperator.jdrupes.org"; - /** The Constant VM_OP_KIND_VM. */ - public static final String VM_OP_KIND_VM = "VirtualMachine"; + /** The Constant KIND_VM. */ + public static final String KIND_VM = "VirtualMachine"; + + /** The Constant KIND_VM_POOL. */ + public static final String KIND_VM_POOL = "VmPool"; + } + + /** + * Status related constants. + */ + public static class Status { + /** The Constant RUNNER_VERSION. */ + public static final String RUNNER_VERSION = "runnerVersion"; + + /** The Constant CPUS. */ + public static final String CPUS = "cpus"; + + /** The Constant RAM. */ + public static final String RAM = "ram"; + + /** The Constant OSINFO. */ + public static final String OSINFO = "osinfo"; + + /** The Constant DISPLAY_PASSWORD_SERIAL. */ + public static final String DISPLAY_PASSWORD_SERIAL + = "displayPasswordSerial"; + + /** The Constant LOGGED_IN_USER. */ + public static final String LOGGED_IN_USER = "loggedInUser"; + + /** The Constant CONSOLE_CLIENT. */ + public static final String CONSOLE_CLIENT = "consoleClient"; + + /** The Constant CONSOLE_USER. */ + public static final String CONSOLE_USER = "consoleUser"; + + /** The Constant ASSIGNMENT. */ + public static final String ASSIGNMENT = "assignment"; + + /** + * Conditions used in Status. + */ + public static class Condition { + /** The Constant COND_RUNNING. */ + public static final String RUNNING = "Running"; + + /** The Constant COND_BOOTED. */ + public static final String BOOTED = "Booted"; + + /** The Constant COND_VMOP_AGENT. */ + public static final String VMOP_AGENT = "VmopAgentConnected"; + + /** The Constant COND_USER_LOGGED_IN. */ + public static final String USER_LOGGED_IN = "UserLoggedIn"; + + /** The Constant COND_CONSOLE. */ + public static final String CONSOLE_CONNECTED = "ConsoleConnected"; + + /** + * Reasons used in conditions. + */ + public static class Reason { + /** The Constant NOT_REQUESTED. */ + public static final String NOT_REQUESTED = "NotRequested"; + + /** The Constant USER_LOGGED_IN. */ + public static final String LOGGED_IN = "LoggedIn"; + } + } + } + + /** + * DisplaySecret related constants. + */ + public static class DisplaySecret { + + /** The Constant NAME. */ + public static final String NAME = "display-secret"; + + /** The Constant PASSWORD. */ + public static final String PASSWORD = "display-password"; + + /** The Constant EXPIRY. */ + public static final String EXPIRY = "password-expiry"; + + } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java index 47b7208..68f52eb 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java @@ -32,13 +32,11 @@ import java.util.regex.Pattern; public class Convertions { @SuppressWarnings({ "PMD.UseConcurrentHashMap", - "PMD.FieldNamingConventions", "PMD.VariableNamingConventions" }) + "PMD.FieldNamingConventions" }) private static final Map unitMap = new HashMap<>(); - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final List> unitMappings; - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final Pattern memorySize = Pattern.compile("^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*"); @@ -69,7 +67,6 @@ public class Convertions { * @param amount the amount * @return the big integer */ - @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public static BigInteger parseMemory(Object amount) { if (amount == null) { return (BigInteger) amount; diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java new file mode 100644 index 0000000..d21eed4 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/DynamicTypeAdapterFactory.java @@ -0,0 +1,197 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.InstanceCreator; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import io.kubernetes.client.openapi.ApiClient; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Type; + +/** + * A factory for creating objects. + * + * @param the generic type + * @param the generic type + */ +public class DynamicTypeAdapterFactory> implements TypeAdapterFactory { + + private final Class objectClass; + private final Class objectListClass; + + /** + * Make sure that this adapter is registered. + * + * @param client the client + */ + public void register(ApiClient client) { + if (!ModelCreator.class + .equals(client.getJSON().getGson().getAdapter(objectClass) + .getClass()) + || !ModelsCreator.class.equals(client.getJSON().getGson() + .getAdapter(objectListClass).getClass())) { + Gson gson = client.getJSON().getGson(); + client.getJSON().setGson(gson.newBuilder() + .registerTypeAdapterFactory(this).create()); + } + } + + /** + * Instantiates a new generic type adapter factory. + * + * @param objectClass the object class + * @param objectListClass the object list class + */ + public DynamicTypeAdapterFactory(Class objectClass, + Class objectListClass) { + this.objectClass = objectClass; + this.objectListClass = objectListClass; + } + + /** + * Creates a type adapter for the given type. + * + * @param the generic type + * @param gson the gson + * @param typeToken the type token + * @return the type adapter or null if the type is not handles by + * this factory + */ + @SuppressWarnings("unchecked") + @Override + public TypeAdapter create(Gson gson, TypeToken typeToken) { + if (TypeToken.get(objectClass).equals(typeToken)) { + return (TypeAdapter) new ModelCreator(gson); + } + if (TypeToken.get(objectListClass).equals(typeToken)) { + return (TypeAdapter) new ModelsCreator(gson); + } + return null; + } + + /** + * The Class ModelCreator. + */ + private class ModelCreator extends TypeAdapter + implements InstanceCreator { + private final Gson delegate; + + /** + * Instantiates a new object state creator. + * + * @param delegate the delegate + */ + public ModelCreator(Gson delegate) { + this.delegate = delegate; + } + + @Override + public O createInstance(Type type) { + try { + return objectClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, null); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + + @Override + public void write(JsonWriter jsonWriter, O state) + throws IOException { + jsonWriter.jsonValue(delegate.toJson(state.data())); + } + + @Override + public O read(JsonReader jsonReader) + throws IOException { + try { + return objectClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, + delegate.fromJson(jsonReader, JsonObject.class)); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + } + + /** + * The Class ModelsCreator. + */ + private class ModelsCreator extends TypeAdapter + implements InstanceCreator { + + private final Gson delegate; + + /** + * Instantiates a new object states creator. + * + * @param delegate the delegate + */ + public ModelsCreator(Gson delegate) { + this.delegate = delegate; + } + + @Override + public L createInstance(Type type) { + try { + return objectListClass + .getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, null); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + + @Override + public void write(JsonWriter jsonWriter, L states) + throws IOException { + jsonWriter.jsonValue(delegate.toJson(states.data())); + } + + @Override + public L read(JsonReader jsonReader) + throws IOException { + try { + return objectListClass + .getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, + delegate.fromJson(jsonReader, JsonObject.class)); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return null; + } + } + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java index 5a87ecd..3870337 100644 --- a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2024 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,168 +18,141 @@ package org.jdrupes.vmoperator.common; +import com.google.gson.JsonObject; +import io.kubernetes.client.Discovery; +import io.kubernetes.client.Discovery.APIResource; import io.kubernetes.client.common.KubernetesListObject; import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.common.KubernetesType; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.apis.ApisApi; -import io.kubernetes.client.openapi.apis.CustomObjectsApi; -import io.kubernetes.client.openapi.models.V1APIGroup; -import io.kubernetes.client.openapi.models.V1ConfigMap; -import io.kubernetes.client.openapi.models.V1ConfigMapList; -import io.kubernetes.client.openapi.models.V1GroupVersionForDiscovery; +import io.kubernetes.client.openapi.apis.EventsV1Api; +import io.kubernetes.client.openapi.models.EventsV1Event; import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.openapi.models.V1PersistentVolumeClaim; -import io.kubernetes.client.openapi.models.V1PersistentVolumeClaimList; -import io.kubernetes.client.openapi.models.V1Pod; -import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.openapi.models.V1ObjectReference; +import io.kubernetes.client.util.Strings; import io.kubernetes.client.util.generic.GenericKubernetesApi; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.options.DeleteOptions; +import io.kubernetes.client.util.generic.KubernetesApiResponse; import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.time.OffsetDateTime; +import java.util.Map; import java.util.Optional; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Helpers for K8s API. */ -@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass", - "PMD.DataflowAnomalyAnalysis" }) +@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass" }) public class K8s { /** - * Given a groupVersion, returns only the version. - * - * @param groupVersion the group version - * @return the string - */ - public static String version(String groupVersion) { - return groupVersion.substring(groupVersion.lastIndexOf('/') + 1); - } - - /** - * Get PVC API. - * - * @param client the client - * @return the generic kubernetes api - */ - public static GenericKubernetesApi pvcApi(ApiClient client) { - return new GenericKubernetesApi<>(V1PersistentVolumeClaim.class, - V1PersistentVolumeClaimList.class, "", "v1", - "persistentvolumeclaims", client); - } - - /** - * Get config map API. - * - * @param client the client - * @return the generic kubernetes api - */ - public static GenericKubernetesApi cmApi(ApiClient client) { - return new GenericKubernetesApi<>(V1ConfigMap.class, - V1ConfigMapList.class, "", "v1", "configmaps", client); - } - - /** - * Get pod API. - * - * @param client the client - * @return the generic kubernetes api - */ - public static GenericKubernetesApi - podApi(ApiClient client) { - return new GenericKubernetesApi<>(V1Pod.class, V1PodList.class, "", - "v1", "pods", client); - } - - /** - * Get the API for a custom resource. - * - * @param client the client - * @param group the group - * @param kind the kind - * @param namespace the namespace - * @param name the name - * @return the dynamic kubernetes api - * @throws ApiException the api exception - */ - @SuppressWarnings("PMD.UseObjectForClearerAPI") - public static Optional crApi(ApiClient client, - String group, String kind, String namespace, String name) - throws ApiException { - var apis = new ApisApi(client).getAPIVersions(); - var crdVersions = apis.getGroups().stream() - .filter(g -> g.getName().equals(group)).findFirst() - .map(V1APIGroup::getVersions).stream().flatMap(l -> l.stream()) - .map(V1GroupVersionForDiscovery::getVersion).toList(); - var coa = new CustomObjectsApi(client); - for (var crdVersion : crdVersions) { - var crdApiRes = coa.getAPIResources(group, crdVersion) - .getResources().stream().filter(r -> kind.equals(r.getKind())) - .findFirst(); - if (crdApiRes.isEmpty()) { - continue; - } - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var crApi = new DynamicKubernetesApi(group, - crdVersion, crdApiRes.get().getName(), client); - var customResource = crApi.get(namespace, name); - if (customResource.isSuccess()) { - return Optional.of(crApi); - } - } - return Optional.empty(); - } - - /** - * Get an object from its metadata. + * Returns the result from an API call as {@link Optional} if the + * call was successful. Returns an empty `Optional` if the status + * code is 404 (not found). Else throws an exception. * * @param the generic type - * @param the generic type - * @param api the api - * @param meta the meta - * @return the object + * @param response the response + * @return the optional + * @throws ApiException the API exception */ - public static - Optional - get(GenericKubernetesApi api, V1ObjectMeta meta) { - var response = api.get(meta.getNamespace(), meta.getName()); + public static Optional + optional(KubernetesApiResponse response) throws ApiException { if (response.isSuccess()) { return Optional.of(response.getObject()); } + if (response.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { + return Optional.empty(); + } + response.throwsApiException(); + // Never reached return Optional.empty(); } /** - * Delete an object. + * Returns a new context with the given version as preferred version. * - * @param the generic type - * @param the generic type - * @param api the api - * @param object the object + * @param context the context + * @param version the version + * @return the API resource */ - public static - void delete(GenericKubernetesApi api, T object) - throws ApiException { - api.delete(object.getMetadata().getNamespace(), - object.getMetadata().getName()).throwsApiException(); + public static APIResource preferred(APIResource context, String version) { + assert context.getVersions().contains(version); + return new APIResource(context.getGroup(), + context.getVersions(), version, context.getKind(), + context.getNamespaced(), context.getResourcePlural(), + context.getResourceSingular()); } /** - * Delete an object. + * Return a string representation of the context (API resource). * - * @param the generic type - * @param the generic type - * @param api the api - * @param object the object + * @param context the context + * @return the string */ - public static - void delete(GenericKubernetesApi api, T object, - DeleteOptions options) throws ApiException { - api.delete(object.getMetadata().getNamespace(), - object.getMetadata().getName(), options).throwsApiException(); + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public static String toString(APIResource context) { + return (Strings.isNullOrEmpty(context.getGroup()) ? "" + : context.getGroup() + "/") + + context.getPreferredVersion().toUpperCase() + + context.getKind(); + } + + /** + * Convert Yaml to Json. + * + * @param client the client + * @param yaml the yaml + * @return the json element + */ + public static JsonObject yamlToJson(ApiClient client, Reader yaml) { + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + Map yamlData + = new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml); + + // There's no short-cut from Java (collections) to Gson + var gson = client.getJSON().getGson(); + var jsonText = gson.toJson(yamlData); + return gson.fromJson(jsonText, JsonObject.class); + } + + /** + * Lookup the specified API resource. If the version is `null` or + * empty, the preferred version in the result is the default + * returned from the server. + * + * @param client the client + * @param group the group + * @param version the version + * @param kind the kind + * @return the optional + * @throws ApiException the api exception + */ + public static Optional context(ApiClient client, + String group, String version, String kind) throws ApiException { + var apiMatch = new Discovery(client).findAll().stream() + .filter(r -> r.getGroup().equals(group) && r.getKind().equals(kind) + && (Strings.isNullOrEmpty(version) + || r.getVersions().contains(version))) + .findFirst(); + if (apiMatch.isEmpty()) { + return Optional.empty(); + } + var apiRes = apiMatch.get(); + if (!Strings.isNullOrEmpty(version)) { + if (!apiRes.getVersions().contains(version)) { + return Optional.empty(); + } + apiRes = new APIResource(apiRes.getGroup(), apiRes.getVersions(), + version, apiRes.getKind(), apiRes.getNamespaced(), + apiRes.getResourcePlural(), apiRes.getResourceSingular()); + } + return Optional.of(apiRes); } /** @@ -190,8 +163,10 @@ public class K8s { * @param api the api * @param existing the existing * @param update the update + * @return the t * @throws ApiException the api exception */ + @SuppressWarnings("PMD.GenericsNaming") public static T apply(GenericKubernetesApi api, T existing, String update) throws ApiException { @@ -204,4 +179,71 @@ public class K8s { return response.getObject(); } + /** + * Create an object reference. + * + * @param object the object + * @return the v 1 object reference + */ + public static V1ObjectReference + objectReference(KubernetesObject object) { + return new V1ObjectReference().apiVersion(object.getApiVersion()) + .kind(object.getKind()) + .namespace(object.getMetadata().getNamespace()) + .name(object.getMetadata().getName()) + .resourceVersion(object.getMetadata().getResourceVersion()) + .uid(object.getMetadata().getUid()); + } + + /** + * Creates an event related to the object, adding reasonable defaults. + * + * * If `kind` is not set, it is set to "Event". + * * If `metadata.namespace` is not set, it is set + * to the object's namespace. + * * If neither `metadata.name` nor `matadata.generateName` are set, + * set `generateName` to the object's name with a dash appended. + * * If `reportingInstance` is not set, set it to the object's name. + * * If `eventTime` is not set, set it to now. + * * If `type` is not set, set it to "Normal" + * * If `regarding` is not set, set it to the given object. + * + * @param client the client + * @param object the object + * @param event the event + * @throws ApiException the api exception + */ + @SuppressWarnings("PMD.NPathComplexity") + public static void createEvent(ApiClient client, + KubernetesObject object, EventsV1Event event) + throws ApiException { + if (Strings.isNullOrEmpty(event.getKind())) { + event.kind("Event"); + } + if (event.getMetadata() == null) { + event.metadata(new V1ObjectMeta()); + } + if (Strings.isNullOrEmpty(event.getMetadata().getNamespace())) { + event.getMetadata().namespace(object.getMetadata().getNamespace()); + } + if (Strings.isNullOrEmpty(event.getMetadata().getName()) + && Strings.isNullOrEmpty(event.getMetadata().getGenerateName())) { + event.getMetadata() + .generateName(object.getMetadata().getName() + "-"); + } + if (Strings.isNullOrEmpty(event.getReportingInstance())) { + event.reportingInstance(object.getMetadata().getName()); + } + if (event.getEventTime() == null) { + event.eventTime(OffsetDateTime.now()); + } + if (Strings.isNullOrEmpty(event.getType())) { + event.type("Normal"); + } + if (event.getRegarding() == null) { + event.regarding(objectReference(object)); + } + new EventsV1Api(client).createNamespacedEvent( + object.getMetadata().getNamespace(), event, null, null, null, null); + } } diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java new file mode 100644 index 0000000..272da2b --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClient.java @@ -0,0 +1,954 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.openapi.ApiCallback; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.ApiResponse; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.Pair; +import io.kubernetes.client.openapi.auth.Authentication; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.text.DateFormat; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import javax.net.ssl.KeyManager; +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * A client with some additional properties. + */ +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods", + "checkstyle:LineLength", "PMD.CouplingBetweenObjects", "PMD.GodClass" }) +public class K8sClient extends ApiClient { + + private ApiClient apiClient; + private PatchOptions defaultPatchOptions; + + /** + * Instantiates a new client. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + public K8sClient() throws IOException { + defaultPatchOptions = new PatchOptions(); + defaultPatchOptions.setFieldManager("kubernetes-java-kubectl-apply"); + } + + private ApiClient apiClient() { + if (apiClient == null) { + try { + apiClient = ClientBuilder.standard().build(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + return apiClient; + } + + /** + * Gets the default patch options. + * + * @return the defaultPatchOptions + */ + public PatchOptions defaultPatchOptions() { + return defaultPatchOptions; + } + + /** + * Changes the default patch options. + * + * @param patchOptions the patch options + * @return the client + */ + public K8sClient with(PatchOptions patchOptions) { + defaultPatchOptions = patchOptions; + return this; + } + + /** + * Gets the base path. + * + * @return the base path + * @see ApiClient#getBasePath() + */ + @Override + public String getBasePath() { + return apiClient().getBasePath(); + } + + /** + * Sets the base path. + * + * @param basePath the base path + * @return the api client + * @see ApiClient#setBasePath(java.lang.String) + */ + @Override + public ApiClient setBasePath(String basePath) { + return apiClient().setBasePath(basePath); + } + + /** + * Gets the http client. + * + * @return the http client + * @see ApiClient#getHttpClient() + */ + @Override + public OkHttpClient getHttpClient() { + return apiClient().getHttpClient(); + } + + /** + * Sets the http client. + * + * @param newHttpClient the new http client + * @return the api client + * @see ApiClient#setHttpClient(okhttp3.OkHttpClient) + */ + @Override + public ApiClient setHttpClient(OkHttpClient newHttpClient) { + return apiClient().setHttpClient(newHttpClient); + } + + /** + * Gets the json. + * + * @return the json + * @see ApiClient#getJSON() + */ + @SuppressWarnings("abbreviationAsWordInName") + @Override + public JSON getJSON() { + return apiClient().getJSON(); + } + + /** + * Sets the JSON. + * + * @param json the json + * @return the api client + * @see ApiClient#setJSON(io.kubernetes.client.openapi.JSON) + */ + @SuppressWarnings("abbreviationAsWordInName") + @Override + public ApiClient setJSON(JSON json) { + return apiClient().setJSON(json); + } + + /** + * Checks if is verifying ssl. + * + * @return true, if is verifying ssl + * @see ApiClient#isVerifyingSsl() + */ + @Override + public boolean isVerifyingSsl() { + return apiClient().isVerifyingSsl(); + } + + /** + * Sets the verifying ssl. + * + * @param verifyingSsl the verifying ssl + * @return the api client + * @see ApiClient#setVerifyingSsl(boolean) + */ + @Override + public ApiClient setVerifyingSsl(boolean verifyingSsl) { + return apiClient().setVerifyingSsl(verifyingSsl); + } + + /** + * Gets the ssl ca cert. + * + * @return the ssl ca cert + * @see ApiClient#getSslCaCert() + */ + @Override + public InputStream getSslCaCert() { + return apiClient().getSslCaCert(); + } + + /** + * Sets the ssl ca cert. + * + * @param sslCaCert the ssl ca cert + * @return the api client + * @see ApiClient#setSslCaCert(java.io.InputStream) + */ + @Override + public ApiClient setSslCaCert(InputStream sslCaCert) { + return apiClient().setSslCaCert(sslCaCert); + } + + /** + * Gets the key managers. + * + * @return the key managers + * @see ApiClient#getKeyManagers() + */ + @Override + public KeyManager[] getKeyManagers() { + return apiClient().getKeyManagers(); + } + + /** + * Sets the key managers. + * + * @param managers the managers + * @return the api client + * @see ApiClient#setKeyManagers(javax.net.ssl.KeyManager[]) + */ + @Override + public ApiClient setKeyManagers(KeyManager[] managers) { + return apiClient().setKeyManagers(managers); + } + + /** + * Gets the date format. + * + * @return the date format + * @see ApiClient#getDateFormat() + */ + @Override + public DateFormat getDateFormat() { + return apiClient().getDateFormat(); + } + + /** + * Sets the date format. + * + * @param dateFormat the date format + * @return the api client + * @see ApiClient#setDateFormat(java.text.DateFormat) + */ + @Override + public ApiClient setDateFormat(DateFormat dateFormat) { + return apiClient().setDateFormat(dateFormat); + } + + /** + * Sets the sql date format. + * + * @param dateFormat the date format + * @return the api client + * @see ApiClient#setSqlDateFormat(java.text.DateFormat) + */ + @Override + public ApiClient setSqlDateFormat(DateFormat dateFormat) { + return apiClient().setSqlDateFormat(dateFormat); + } + + /** + * Sets the offset date time format. + * + * @param dateFormat the date format + * @return the api client + * @see ApiClient#setOffsetDateTimeFormat(java.time.format.DateTimeFormatter) + */ + @Override + public ApiClient setOffsetDateTimeFormat(DateTimeFormatter dateFormat) { + return apiClient().setOffsetDateTimeFormat(dateFormat); + } + + /** + * Sets the local date format. + * + * @param dateFormat the date format + * @return the api client + * @see ApiClient#setLocalDateFormat(java.time.format.DateTimeFormatter) + */ + @Override + public ApiClient setLocalDateFormat(DateTimeFormatter dateFormat) { + return apiClient().setLocalDateFormat(dateFormat); + } + + /** + * Sets the lenient on json. + * + * @param lenientOnJson the lenient on json + * @return the api client + * @see ApiClient#setLenientOnJson(boolean) + */ + @Override + public ApiClient setLenientOnJson(boolean lenientOnJson) { + return apiClient().setLenientOnJson(lenientOnJson); + } + + /** + * Gets the authentications. + * + * @return the authentications + * @see ApiClient#getAuthentications() + */ + @Override + public Map getAuthentications() { + return apiClient().getAuthentications(); + } + + /** + * Gets the authentication. + * + * @param authName the auth name + * @return the authentication + * @see ApiClient#getAuthentication(java.lang.String) + */ + @Override + public Authentication getAuthentication(String authName) { + return apiClient().getAuthentication(authName); + } + + /** + * Sets the username. + * + * @param username the new username + * @see ApiClient#setUsername(java.lang.String) + */ + @Override + public void setUsername(String username) { + apiClient().setUsername(username); + } + + /** + * Sets the password. + * + * @param password the new password + * @see ApiClient#setPassword(java.lang.String) + */ + @Override + public void setPassword(String password) { + apiClient().setPassword(password); + } + + /** + * Sets the api key. + * + * @param apiKey the new api key + * @see ApiClient#setApiKey(java.lang.String) + */ + @Override + public void setApiKey(String apiKey) { + apiClient().setApiKey(apiKey); + } + + /** + * Sets the api key prefix. + * + * @param apiKeyPrefix the new api key prefix + * @see ApiClient#setApiKeyPrefix(java.lang.String) + */ + @Override + public void setApiKeyPrefix(String apiKeyPrefix) { + apiClient().setApiKeyPrefix(apiKeyPrefix); + } + + /** + * Sets the access token. + * + * @param accessToken the new access token + * @see ApiClient#setAccessToken(java.lang.String) + */ + @Override + public void setAccessToken(String accessToken) { + apiClient().setAccessToken(accessToken); + } + + /** + * Sets the user agent. + * + * @param userAgent the user agent + * @return the api client + * @see ApiClient#setUserAgent(java.lang.String) + */ + @Override + public ApiClient setUserAgent(String userAgent) { + return apiClient().setUserAgent(userAgent); + } + + /** + * To string. + * + * @return the string + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return apiClient().toString(); + } + + /** + * Adds the default header. + * + * @param key the key + * @param value the value + * @return the api client + * @see ApiClient#addDefaultHeader(java.lang.String, java.lang.String) + */ + @Override + public ApiClient addDefaultHeader(String key, String value) { + return apiClient().addDefaultHeader(key, value); + } + + /** + * Adds the default cookie. + * + * @param key the key + * @param value the value + * @return the api client + * @see ApiClient#addDefaultCookie(java.lang.String, java.lang.String) + */ + @Override + public ApiClient addDefaultCookie(String key, String value) { + return apiClient().addDefaultCookie(key, value); + } + + /** + * Checks if is debugging. + * + * @return true, if is debugging + * @see ApiClient#isDebugging() + */ + @Override + public boolean isDebugging() { + return apiClient().isDebugging(); + } + + /** + * Sets the debugging. + * + * @param debugging the debugging + * @return the api client + * @see ApiClient#setDebugging(boolean) + */ + @Override + public ApiClient setDebugging(boolean debugging) { + return apiClient().setDebugging(debugging); + } + + /** + * Gets the temp folder path. + * + * @return the temp folder path + * @see ApiClient#getTempFolderPath() + */ + @Override + public String getTempFolderPath() { + return apiClient().getTempFolderPath(); + } + + /** + * Sets the temp folder path. + * + * @param tempFolderPath the temp folder path + * @return the api client + * @see ApiClient#setTempFolderPath(java.lang.String) + */ + @Override + public ApiClient setTempFolderPath(String tempFolderPath) { + return apiClient().setTempFolderPath(tempFolderPath); + } + + /** + * Gets the connect timeout. + * + * @return the connect timeout + * @see ApiClient#getConnectTimeout() + */ + @Override + public int getConnectTimeout() { + return apiClient().getConnectTimeout(); + } + + /** + * Sets the connect timeout. + * + * @param connectionTimeout the connection timeout + * @return the api client + * @see ApiClient#setConnectTimeout(int) + */ + @Override + public ApiClient setConnectTimeout(int connectionTimeout) { + return apiClient().setConnectTimeout(connectionTimeout); + } + + /** + * Gets the read timeout. + * + * @return the read timeout + * @see ApiClient#getReadTimeout() + */ + @Override + public int getReadTimeout() { + return apiClient().getReadTimeout(); + } + + /** + * Sets the read timeout. + * + * @param readTimeout the read timeout + * @return the api client + * @see ApiClient#setReadTimeout(int) + */ + @Override + public ApiClient setReadTimeout(int readTimeout) { + return apiClient().setReadTimeout(readTimeout); + } + + /** + * Gets the write timeout. + * + * @return the write timeout + * @see ApiClient#getWriteTimeout() + */ + @Override + public int getWriteTimeout() { + return apiClient().getWriteTimeout(); + } + + /** + * Sets the write timeout. + * + * @param writeTimeout the write timeout + * @return the api client + * @see ApiClient#setWriteTimeout(int) + */ + @Override + public ApiClient setWriteTimeout(int writeTimeout) { + return apiClient().setWriteTimeout(writeTimeout); + } + + /** + * Parameter to string. + * + * @param param the param + * @return the string + * @see ApiClient#parameterToString(java.lang.Object) + */ + @Override + public String parameterToString(Object param) { + return apiClient().parameterToString(param); + } + + /** + * Parameter to pair. + * + * @param name the name + * @param value the value + * @return the list + * @see ApiClient#parameterToPair(java.lang.String, java.lang.Object) + */ + @Override + public List parameterToPair(String name, Object value) { + return apiClient().parameterToPair(name, value); + } + + /** + * Parameter to pairs. + * + * @param collectionFormat the collection format + * @param name the name + * @param value the value + * @return the list + * @see ApiClient#parameterToPairs(java.lang.String, java.lang.String, java.util.Collection) + */ + @SuppressWarnings({ "rawtypes", "PMD.AvoidDuplicateLiterals" }) + @Override + public List parameterToPairs(String collectionFormat, String name, + Collection value) { + return apiClient().parameterToPairs(collectionFormat, name, value); + } + + /** + * Collection path parameter to string. + * + * @param collectionFormat the collection format + * @param value the value + * @return the string + * @see ApiClient#collectionPathParameterToString(java.lang.String, java.util.Collection) + */ + @SuppressWarnings("rawtypes") + @Override + public String collectionPathParameterToString(String collectionFormat, + Collection value) { + return apiClient().collectionPathParameterToString(collectionFormat, + value); + } + + /** + * Sanitize filename. + * + * @param filename the filename + * @return the string + * @see ApiClient#sanitizeFilename(java.lang.String) + */ + @Override + public String sanitizeFilename(String filename) { + return apiClient().sanitizeFilename(filename); + } + + /** + * Checks if is json mime. + * + * @param mime the mime + * @return true, if is json mime + * @see ApiClient#isJsonMime(java.lang.String) + */ + @Override + public boolean isJsonMime(String mime) { + return apiClient().isJsonMime(mime); + } + + /** + * Select header accept. + * + * @param accepts the accepts + * @return the string + * @see ApiClient#selectHeaderAccept(java.lang.String[]) + */ + @Override + public String selectHeaderAccept(String[] accepts) { + return apiClient().selectHeaderAccept(accepts); + } + + /** + * Select header content type. + * + * @param contentTypes the content types + * @return the string + * @see ApiClient#selectHeaderContentType(java.lang.String[]) + */ + @Override + public String selectHeaderContentType(String[] contentTypes) { + return apiClient().selectHeaderContentType(contentTypes); + } + + /** + * Escape string. + * + * @param str the str + * @return the string + * @see ApiClient#escapeString(java.lang.String) + */ + @Override + public String escapeString(String str) { + return apiClient().escapeString(str); + } + + /** + * Deserialize. + * + * @param the generic type + * @param response the response + * @param returnType the return type + * @return the t + * @throws ApiException the api exception + * @see ApiClient#deserialize(okhttp3.Response, java.lang.reflect.Type) + */ + @Override + public T deserialize(Response response, Type returnType) + throws ApiException { + return apiClient().deserialize(response, returnType); + } + + /** + * Serialize. + * + * @param obj the obj + * @param contentType the content type + * @return the request body + * @throws ApiException the api exception + * @see ApiClient#serialize(java.lang.Object, java.lang.String) + */ + @Override + public RequestBody serialize(Object obj, String contentType) + throws ApiException { + return apiClient().serialize(obj, contentType); + } + + /** + * Download file from response. + * + * @param response the response + * @return the file + * @throws ApiException the api exception + * @see ApiClient#downloadFileFromResponse(okhttp3.Response) + */ + @Override + public File downloadFileFromResponse(Response response) + throws ApiException { + return apiClient().downloadFileFromResponse(response); + } + + /** + * Prepare download file. + * + * @param response the response + * @return the file + * @throws IOException Signals that an I/O exception has occurred. + * @see ApiClient#prepareDownloadFile(okhttp3.Response) + */ + @Override + public File prepareDownloadFile(Response response) throws IOException { + return apiClient().prepareDownloadFile(response); + } + + /** + * Execute. + * + * @param the generic type + * @param call the call + * @return the api response + * @throws ApiException the api exception + * @see ApiClient#execute(okhttp3.Call) + */ + @Override + public ApiResponse execute(Call call) throws ApiException { + return apiClient().execute(call); + } + + /** + * Execute. + * + * @param the generic type + * @param call the call + * @param returnType the return type + * @return the api response + * @throws ApiException the api exception + * @see ApiClient#execute(okhttp3.Call, java.lang.reflect.Type) + */ + @Override + public ApiResponse execute(Call call, Type returnType) + throws ApiException { + return apiClient().execute(call, returnType); + } + + /** + * Execute async. + * + * @param the generic type + * @param call the call + * @param callback the callback + * @see ApiClient#executeAsync(okhttp3.Call, io.kubernetes.client.openapi.ApiCallback) + */ + @Override + public void executeAsync(Call call, ApiCallback callback) { + apiClient().executeAsync(call, callback); + } + + /** + * Execute async. + * + * @param the generic type + * @param call the call + * @param returnType the return type + * @param callback the callback + * @see ApiClient#executeAsync(okhttp3.Call, java.lang.reflect.Type, io.kubernetes.client.openapi.ApiCallback) + */ + @Override + public void executeAsync(Call call, Type returnType, + ApiCallback callback) { + apiClient().executeAsync(call, returnType, callback); + } + + /** + * Handle response. + * + * @param the generic type + * @param response the response + * @param returnType the return type + * @return the t + * @throws ApiException the api exception + * @see ApiClient#handleResponse(okhttp3.Response, java.lang.reflect.Type) + */ + @Override + public T handleResponse(Response response, Type returnType) + throws ApiException { + return apiClient().handleResponse(response, returnType); + } + + /** + * Builds the call. + * + * @param path the path + * @param method the method + * @param queryParams the query params + * @param collectionQueryParams the collection query params + * @param body the body + * @param headerParams the header params + * @param cookieParams the cookie params + * @param formParams the form params + * @param authNames the auth names + * @param callback the callback + * @return the call + * @throws ApiException the api exception + * @see ApiClient#buildCall(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback) + */ + @SuppressWarnings({ "rawtypes" }) + @Override + public Call buildCall(String path, String method, List queryParams, + List collectionQueryParams, Object body, + Map headerParams, Map cookieParams, + Map formParams, String[] authNames, + ApiCallback callback) throws ApiException { + return apiClient().buildCall(path, method, queryParams, + collectionQueryParams, body, headerParams, cookieParams, formParams, + authNames, callback); + } + + /** + * Builds the request. + * + * @param path the path + * @param method the method + * @param queryParams the query params + * @param collectionQueryParams the collection query params + * @param body the body + * @param headerParams the header params + * @param cookieParams the cookie params + * @param formParams the form params + * @param authNames the auth names + * @param callback the callback + * @return the request + * @throws ApiException the api exception + * @see ApiClient#buildRequest(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback) + */ + @SuppressWarnings({ "rawtypes" }) + @Override + public Request buildRequest(String path, String method, + List queryParams, List collectionQueryParams, + Object body, Map headerParams, + Map cookieParams, Map formParams, + String[] authNames, ApiCallback callback) throws ApiException { + return apiClient().buildRequest(path, method, queryParams, + collectionQueryParams, body, headerParams, cookieParams, formParams, + authNames, callback); + } + + /** + * Builds the url. + * + * @param path the path + * @param queryParams the query params + * @param collectionQueryParams the collection query params + * @return the string + * @see ApiClient#buildUrl(java.lang.String, java.util.List, java.util.List) + */ + @Override + public String buildUrl(String path, List queryParams, + List collectionQueryParams) { + return apiClient().buildUrl(path, queryParams, collectionQueryParams); + } + + /** + * Process header params. + * + * @param headerParams the header params + * @param reqBuilder the req builder + * @see ApiClient#processHeaderParams(java.util.Map, okhttp3.Request.Builder) + */ + @Override + public void processHeaderParams(Map headerParams, + Builder reqBuilder) { + apiClient().processHeaderParams(headerParams, reqBuilder); + } + + /** + * Process cookie params. + * + * @param cookieParams the cookie params + * @param reqBuilder the req builder + * @see ApiClient#processCookieParams(java.util.Map, okhttp3.Request.Builder) + */ + @Override + public void processCookieParams(Map cookieParams, + Builder reqBuilder) { + apiClient().processCookieParams(cookieParams, reqBuilder); + } + + /** + * Update params for auth. + * + * @param authNames the auth names + * @param queryParams the query params + * @param headerParams the header params + * @param cookieParams the cookie params + * @see ApiClient#updateParamsForAuth(java.lang.String[], java.util.List, java.util.Map, java.util.Map) + */ + @Override + public void updateParamsForAuth(String[] authNames, List queryParams, + Map headerParams, + Map cookieParams) { + apiClient().updateParamsForAuth(authNames, queryParams, headerParams, + cookieParams); + } + + /** + * Builds the request body form encoding. + * + * @param formParams the form params + * @return the request body + * @see ApiClient#buildRequestBodyFormEncoding(java.util.Map) + */ + @Override + public RequestBody + buildRequestBodyFormEncoding(Map formParams) { + return apiClient().buildRequestBodyFormEncoding(formParams); + } + + /** + * Builds the request body multipart. + * + * @param formParams the form params + * @return the request body + * @see ApiClient#buildRequestBodyMultipart(java.util.Map) + */ + @Override + public RequestBody + buildRequestBodyMultipart(Map formParams) { + return apiClient().buildRequestBodyMultipart(formParams); + } + + /** + * Guess content type from file. + * + * @param file the file + * @return the string + * @see ApiClient#guessContentTypeFromFile(java.io.File) + */ + @Override + public String guessContentTypeFromFile(File file) { + return apiClient().guessContentTypeFromFile(file); + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java new file mode 100644 index 0000000..59b4d12 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sClusterGenericStub.java @@ -0,0 +1,396 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Strings; +import io.kubernetes.client.util.generic.GenericKubernetesApi; +import io.kubernetes.client.util.generic.options.GetOptions; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * A stub for cluster scoped objects. This stub provides the + * functions common to all Kubernetes objects, but uses variables + * for all types. This class should be used as base class only. + * + * @param the generic type + * @param the generic type + */ +@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) +public class K8sClusterGenericStub { + protected final K8sClient client; + private final GenericKubernetesApi api; + protected final APIResource context; + protected final String name; + + /** + * Instantiates a new stub for the object specified. If the object + * exists in the context specified, the version (see + * {@link #version()} is bound to the existing object's version. + * Else the stub is dangling with the version set to the context's + * preferred version. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param name the name + */ + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + protected K8sClusterGenericStub(Class objectClass, + Class objectListClass, K8sClient client, APIResource context, + String name) { + this.client = client; + this.name = name; + + // Bind version + var foundVersion = context.getPreferredVersion(); + GenericKubernetesApi testApi = null; + GetOptions mdOpts + = new GetOptions().isPartialObjectMetadataRequest(true); + for (var version : candidateVersions(context)) { + testApi = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + if (testApi.get(name, mdOpts).isSuccess()) { + foundVersion = version; + break; + } + } + if (foundVersion.equals(context.getPreferredVersion())) { + this.context = context; + } else { + this.context = K8s.preferred(context, foundVersion); + } + + api = Optional.ofNullable(testApi) + .orElseGet(() -> new GenericKubernetesApi<>(objectClass, + objectListClass, group(), version(), plural(), client)); + } + + /** + * Gets the context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Gets the group. + * + * @return the group + */ + public String group() { + return context.getGroup(); + } + + /** + * Gets the version. + * + * @return the version + */ + public String version() { + return context.getPreferredVersion(); + } + + /** + * Gets the kind. + * + * @return the kind + */ + public String kind() { + return context.getKind(); + } + + /** + * Gets the plural. + * + * @return the plural + */ + public String plural() { + return context.getResourcePlural(); + } + + /** + * Gets the name. + * + * @return the name + */ + public String name() { + return name; + } + + /** + * Delete the Kubernetes object. + * + * @throws ApiException the API exception + */ + public void delete() throws ApiException { + var result = api.delete(name); + if (result.isSuccess() + || result.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { + return; + } + result.throwsApiException(); + } + + /** + * Retrieves and returns the current state of the object. + * + * @return the object's state + * @throws ApiException the api exception + */ + public Optional model() throws ApiException { + return K8s.optional(api.get(name)); + } + + /** + * Updates the object's status. + * + * @param object the current state of the object (passed to `status`) + * @param status function that returns the new status + * @return the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(O object, + Function status) throws ApiException { + return K8s.optional(api.updateStatus(object, status)); + } + + /** + * Updates the status. + * + * @param status the status + * @return the kubernetes api response + * the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(Function status) + throws ApiException { + return updateStatus(api.get(name).throwsApiException().getObject(), + status); + } + + /** + * Patch the object. + * + * @param patchType the patch type + * @param patch the patch + * @param options the options + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public Optional patch(String patchType, V1Patch patch, + PatchOptions options) throws ApiException { + return K8s + .optional(api.patch(name, patchType, patch, options)); + } + + /** + * Patch the object using default options. + * + * @param patchType the patch type + * @param patch the patch + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public Optional + patch(String patchType, V1Patch patch) throws ApiException { + PatchOptions opts = new PatchOptions(); + return patch(patchType, patch, opts); + } + + /** + * A supplier for generic stubs. + * + * @param the object type + * @param the object list type + * @param the result type + */ + @FunctionalInterface + public interface GenericSupplier> { + + /** + * Gets a new stub. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the API resource + * @param name the name + * @return the result + */ + R get(Class objectClass, Class objectListClass, K8sClient client, + APIResource context, String name); + } + + @Override + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public String toString() { + return (Strings.isNullOrEmpty(group()) ? "" : group() + "/") + + version().toUpperCase() + kind() + " " + name; + } + + /** + * Get an object stub. If the version in parameter + * `gvk` is an empty string, the stub refers to the first object + * found with matching group and kind. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param gvk the group, version and kind + * @param name the name + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static > + R get(Class objectClass, Class objectListClass, + K8sClient client, GroupVersionKind gvk, String name, + GenericSupplier provider) throws ApiException { + var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(), + gvk.getKind()); + if (context.isEmpty()) { + throw new ApiException("No known API for " + gvk.getGroup() + + "/" + gvk.getVersion() + " " + gvk.getKind()); + } + return provider.get(objectClass, objectListClass, client, context.get(), + name); + } + + /** + * Get an object stub. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param name the name + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static > + R get(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String name, + GenericSupplier provider) { + return provider.get(objectClass, objectListClass, client, context, + name); + } + + /** + * Get an object stub for a newly created object. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param model the model + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static > + R create(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, O model, + GenericSupplier provider) throws ApiException { + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), context.getPreferredVersion(), + context.getResourcePlural(), client); + api.create(model).throwsApiException(); + return provider.get(objectClass, objectListClass, client, + context, model.getMetadata().getName()); + } + + /** + * Get the stubs for the objects that match + * the criteria from the given options. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param options the options + * @param provider the provider + * @return the collection + * @throws ApiException the api exception + */ + public static > + Collection list(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, + ListOptions options, GenericSupplier provider) + throws ApiException { + var result = new ArrayList(); + for (var version : candidateVersions(context)) { + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + var objs = api.list(options).throwsApiException(); + for (var item : objs.getObject().getItems()) { + result.add(provider.get(objectClass, objectListClass, client, + context, item.getMetadata().getName())); + } + } + return result; + } + + private static List candidateVersions(APIResource context) { + var result = new LinkedList<>(context.getVersions()); + result.remove(context.getPreferredVersion()); + result.add(0, context.getPreferredVersion()); + return result; + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java new file mode 100644 index 0000000..2392d3e --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModel.java @@ -0,0 +1,113 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.models.V1ObjectMeta; + +/** + * Represents a Kubernetes object using a JSON data structure. + * Some information that is common to all Kubernetes objects, + * notably the metadata, is made available through the methods + * defined by {@link KubernetesObject}. + */ +public class K8sDynamicModel implements KubernetesObject { + + private final V1ObjectMeta metadata; + private final JsonObject data; + + /** + * Instantiates a new model from the JSON representation. + * + * @param delegate the gson instance to use for extracting structured data + * @param json the JSON + */ + public K8sDynamicModel(Gson delegate, JsonObject json) { + this.data = json; + metadata = delegate.fromJson(data.get("metadata"), V1ObjectMeta.class); + } + + @Override + public String getApiVersion() { + return apiVersion(); + } + + /** + * Gets the API version. (Abbreviated method name for convenience.) + * + * @return the API version + */ + public String apiVersion() { + return data.get("apiVersion").getAsString(); + } + + @Override + public String getKind() { + return kind(); + } + + /** + * Gets the kind. (Abbreviated method name for convenience.) + * + * @return the kind + */ + public String kind() { + return data.get("kind").getAsString(); + } + + @Override + public V1ObjectMeta getMetadata() { + return metadata; + } + + /** + * Gets the metadata. (Abbreviated method name for convenience.) + * + * @return the metadata + */ + public V1ObjectMeta metadata() { + return metadata; + } + + /** + * Gets the data. + * + * @return the data + */ + public JsonObject data() { + return data; + } + + /** + * Convenience method for getting the status. + * + * @return the JSON object describing the status + */ + public JsonObject statusJson() { + return data.getAsJsonObject("status"); + } + + @Override + public String toString() { + return data.toString(); + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java new file mode 100644 index 0000000..d165c10 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModels.java @@ -0,0 +1,44 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.kubernetes.client.common.KubernetesListObject; + +/** + * Represents a list of Kubernetes objects each of which is + * represented using a JSON data structure. + * Some information that is common to all Kubernetes objects, + * notably the metadata, is made available through the methods + * defined by {@link KubernetesListObject}. + */ +public class K8sDynamicModels extends K8sDynamicModelsBase { + + /** + * Initialize the object list using the given JSON data. + * + * @param delegate the gson instance to use for extracting structured data + * @param data the data + */ + public K8sDynamicModels(Gson delegate, JsonObject data) { + super(K8sDynamicModel.class, delegate, data); + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java new file mode 100644 index 0000000..4e21c0e --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicModelsBase.java @@ -0,0 +1,174 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.models.V1ListMeta; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Represents a list of Kubernetes objects each of which is + * represented using a JSON data structure. + * Some information that is common to all Kubernetes objects, + * notably the metadata, is made available through the methods + * defined by {@link KubernetesListObject}. + */ +public class K8sDynamicModelsBase + implements KubernetesListObject { + + private final JsonObject data; + private final V1ListMeta metadata; + private final List items; + + /** + * Initialize the object list using the given JSON data. + * + * @param itemClass the item class + * @param delegate the gson instance to use for extracting structured data + * @param data the data + */ + public K8sDynamicModelsBase(Class itemClass, Gson delegate, + JsonObject data) { + this.data = data; + metadata = delegate.fromJson(data.get("metadata"), V1ListMeta.class); + items = new ArrayList<>(); + for (JsonElement e : data.get("items").getAsJsonArray()) { + try { + items.add(itemClass.getConstructor(Gson.class, JsonObject.class) + .newInstance(delegate, e.getAsJsonObject())); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException exc) { + throw new IllegalArgumentException(exc); + } + } + } + + @Override + public String getApiVersion() { + return apiVersion(); + } + + /** + * Gets the API version. (Abbreviated method name for convenience.) + * + * @return the API version + */ + public String apiVersion() { + return data.get("apiVersion").getAsString(); + } + + @Override + public String getKind() { + return kind(); + } + + /** + * Gets the kind. (Abbreviated method name for convenience.) + * + * @return the kind + */ + public String kind() { + return data.get("kind").getAsString(); + } + + @Override + public V1ListMeta getMetadata() { + return metadata; + } + + /** + * Gets the metadata. (Abbreviated method name for convenience.) + * + * @return the metadata + */ + public V1ListMeta metadata() { + return metadata; + } + + /** + * Returns the JSON representation of this object. + * + * @return the JOSN representation + */ + public JsonObject data() { + return data; + } + + @Override + public List getItems() { + return items; + } + + /** + * Sets the api version. + * + * @param apiVersion the new api version + */ + public void setApiVersion(String apiVersion) { + data.addProperty("apiVersion", apiVersion); + } + + /** + * Sets the kind. + * + * @param kind the new kind + */ + public void setKind(String kind) { + data.addProperty("kind", kind); + } + + /** + * Sets the metadata. + * + * @param objectMeta the new metadata + */ + public void setMetadata(V1ListMeta objectMeta) { + data.add("metadata", + Configuration.getDefaultApiClient().getJSON().getGson() + .toJsonTree(objectMeta)); + } + + @Override + public int hashCode() { + return Objects.hash(data); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + K8sDynamicModelsBase other = (K8sDynamicModelsBase) obj; + return Objects.equals(data, other.data); + } +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java new file mode 100644 index 0000000..c0303c2 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStub.java @@ -0,0 +1,152 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.Reader; +import java.util.Collection; + +/** + * A stub for namespaced custom objects. It uses a dynamic model + * (see {@link K8sDynamicModel}) for representing the object's + * state and can therefore be used for any kind of object, especially + * custom objects. + */ +public class K8sDynamicStub + extends K8sDynamicStubBase { + + private static DynamicTypeAdapterFactory taf = new K8sDynamicModelTypeAdapterFactory(); + + /** + * Instantiates a new dynamic stub. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + public K8sDynamicStub(K8sClient client, + APIResource context, String namespace, String name) { + super(K8sDynamicModel.class, K8sDynamicModels.class, taf, client, + context, namespace, name); + } + + /** + * Get a dynamic object stub. If the version in parameter + * `gvk` is an empty string, the stub refers to the first object with + * matching group and kind. + * + * @param client the client + * @param gvk the group, version and kind + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static K8sDynamicStub get(K8sClient client, + GroupVersionKind gvk, String namespace, String name) + throws ApiException { + return new K8sDynamicStub(client, apiResource(client, gvk), namespace, + name); + } + + /** + * Get a dynamic object stub. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static K8sDynamicStub get(K8sClient client, + APIResource context, String namespace, String name) { + return new K8sDynamicStub(client, context, namespace, name); + } + + /** + * Creates a stub from yaml. + * + * @param client the client + * @param context the context + * @param yaml the yaml + * @return the k 8 s dynamic stub + * @throws ApiException the api exception + */ + public static K8sDynamicStub createFromYaml(K8sClient client, + APIResource context, Reader yaml) throws ApiException { + var model = new K8sDynamicModel(client.getJSON().getGson(), + K8s.yamlToJson(client, yaml)); + return K8sGenericStub.create(K8sDynamicModel.class, + K8sDynamicModels.class, client, context, model, + (c, ns, n) -> new K8sDynamicStub(c, context, ns, n)); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace, ListOptions options) + throws ApiException { + return K8sGenericStub.list(K8sDynamicModel.class, + K8sDynamicModels.class, client, context, namespace, options, + (c, ns, n) -> new K8sDynamicStub(c, context, ns, n)); + } + + /** + * Get the stubs for the objects in the given namespace. + * + * @param client the client + * @param namespace the namespace + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace) + throws ApiException { + return list(client, context, namespace, new ListOptions()); + } + + /** + * A factory for creating K8sDynamicModel(s) objects. + */ + public static class K8sDynamicModelTypeAdapterFactory extends + DynamicTypeAdapterFactory { + + /** + * Instantiates a new dynamic model type adapter factory. + */ + public K8sDynamicModelTypeAdapterFactory() { + super(K8sDynamicModel.class, K8sDynamicModels.class); + } + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java new file mode 100644 index 0000000..ae3f012 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sDynamicStubBase.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; + +/** + * A stub for namespaced custom objects. It uses a dynamic model + * (see {@link K8sDynamicModel}) for representing the object's + * state and can therefore be used for any kind of object, especially + * custom objects. + */ +public abstract class K8sDynamicStubBase> extends K8sGenericStub { + + /** + * Instantiates a new dynamic stub. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + public K8sDynamicStubBase(Class objectClass, + Class objectListClass, DynamicTypeAdapterFactory taf, + K8sClient client, APIResource context, String namespace, + String name) { + super(objectClass, objectListClass, client, context, namespace, name); + taf.register(client); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java new file mode 100644 index 0000000..9ba376f --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sGenericStub.java @@ -0,0 +1,474 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Strings; +import io.kubernetes.client.util.generic.GenericKubernetesApi; +import io.kubernetes.client.util.generic.KubernetesApiResponse; +import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import io.kubernetes.client.util.generic.options.GetOptions; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import io.kubernetes.client.util.generic.options.UpdateOptions; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * A stub for namespaced custom objects. This stub provides the + * functions common to all Kubernetes objects, but uses variables + * for all types. This class should be used as base class only. + * + * @param the generic type + * @param the generic type + */ +@SuppressWarnings({ "PMD.TooManyMethods" }) +public class K8sGenericStub { + protected final K8sClient client; + private final GenericKubernetesApi api; + protected final APIResource context; + protected final String namespace; + protected final String name; + + /** + * Instantiates a new stub for the object specified. If the object + * exists in the context specified, the version (see + * {@link #version()} is bound to the existing object's version. + * Else the stub is dangling with the version set to the context's + * preferred version. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + protected K8sGenericStub(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + String name) { + this.client = client; + this.namespace = namespace; + this.name = name; + + // Bind version + var foundVersion = context.getPreferredVersion(); + GenericKubernetesApi testApi = null; + GetOptions mdOpts + = new GetOptions().isPartialObjectMetadataRequest(true); + for (var version : candidateVersions(context)) { + testApi = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + if (testApi.get(namespace, name, mdOpts) + .isSuccess()) { + foundVersion = version; + break; + } + } + if (foundVersion.equals(context.getPreferredVersion())) { + this.context = context; + } else { + this.context = K8s.preferred(context, foundVersion); + } + + api = Optional.ofNullable(testApi) + .orElseGet(() -> new GenericKubernetesApi<>(objectClass, + objectListClass, group(), version(), plural(), client)); + } + + /** + * Gets the context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Gets the group. + * + * @return the group + */ + public String group() { + return context.getGroup(); + } + + /** + * Gets the version. + * + * @return the version + */ + public String version() { + return context.getPreferredVersion(); + } + + /** + * Gets the kind. + * + * @return the kind + */ + public String kind() { + return context.getKind(); + } + + /** + * Gets the plural. + * + * @return the plural + */ + public String plural() { + return context.getResourcePlural(); + } + + /** + * Gets the namespace. + * + * @return the namespace + */ + public String namespace() { + return namespace; + } + + /** + * Gets the name. + * + * @return the name + */ + public String name() { + return name; + } + + /** + * Delete the Kubernetes object. + * + * @throws ApiException the API exception + */ + public void delete() throws ApiException { + var result = api.delete(namespace, name); + if (result.isSuccess() + || result.getHttpStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) { + return; + } + result.throwsApiException(); + } + + /** + * Retrieves and returns the current state of the object. + * + * @return the object's state + * @throws ApiException the api exception + */ + public Optional model() throws ApiException { + return K8s.optional(api.get(namespace, name)); + } + + /** + * Updates the object's status. Does not retry in case of conflict. + * + * @param object the current state of the object (passed to `status`) + * @param updater function that returns the new status + * @return the updated model or empty if the object was not found + * @throws ApiException the api exception + */ + public Optional updateStatus(O object, Function updater) + throws ApiException { + return K8s.optional(api.updateStatus(object, updater)); + } + + /** + * Updates the status of the given object. In case of conflict, + * get the current version of the object and tries again. Retries + * up to `retries` times. + * + * @param updater the function updating the status + * @param current the current state of the object, used for the first + * attempt to update + * @param retries the retries in case of conflict + * @return the updated model or empty if the object was not found + * @throws ApiException the api exception + */ + @SuppressWarnings({ "PMD.AssignmentInOperand" }) + public Optional updateStatus(Function updater, O current, + int retries) throws ApiException { + while (true) { + try { + if (current == null) { + current = api.get(namespace, name) + .throwsApiException().getObject(); + } + return updateStatus(current, updater); + } catch (ApiException e) { + if (HttpURLConnection.HTTP_CONFLICT != e.getCode() + || retries-- <= 0) { + throw e; + } + // Get current version for new attempt + current = null; + } + } + } + + /** + * Gets the object and updates the status. In case of conflict, retries + * up to `retries` times. + * + * @param updater the function updating the status + * @param retries the retries in case of conflict + * @return the updated model or empty if the object was not found + * @throws ApiException the api exception + */ + public Optional updateStatus(Function updater, int retries) + throws ApiException { + return updateStatus(updater, null, retries); + } + + /** + * Updates the status of the given object. In case of conflict, + * get the current version of the object and tries again. Retries + * up to `retries` times. + * + * @param updater the function updating the status + * @param current the current + * @return the kubernetes api response + * the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(Function updater, O current) + throws ApiException { + return updateStatus(updater, current, 16); + } + + /** + * Updates the status. In case of conflict, retries up to 16 times. + * + * @param updater the function updating the status + * @return the kubernetes api response + * the updated model or empty if not successful + * @throws ApiException the api exception + */ + public Optional updateStatus(Function updater) + throws ApiException { + return updateStatus(updater, null); + } + + /** + * Patch the object. + * + * @param patchType the patch type + * @param patch the patch + * @param options the options + * @return the kubernetes api response if successful + * @throws ApiException the api exception + */ + public Optional patch(String patchType, V1Patch patch, + PatchOptions options) throws ApiException { + return K8s + .optional(api.patch(namespace, name, patchType, patch, options) + .throwsApiException()); + } + + /** + * Patch the object using default options. + * + * @param patchType the patch type + * @param patch the patch + * @return the kubernetes api response if successful + * @throws ApiException the api exception + */ + public Optional + patch(String patchType, V1Patch patch) throws ApiException { + PatchOptions opts = new PatchOptions(); + return patch(patchType, patch, opts); + } + + /** + * Apply the given definition. + * + * @param def the def + * @return the kubernetes api response if successful + * @throws ApiException the api exception + */ + public Optional apply(DynamicKubernetesObject def) throws ApiException { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + return patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(client.getJSON().serialize(def)), opts); + } + + /** + * Update the object. + * + * @param object the object + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public KubernetesApiResponse update(O object) throws ApiException { + return api.update(object).throwsApiException(); + } + + /** + * Update the object. + * + * @param object the object + * @param options the options + * @return the kubernetes api response + * @throws ApiException the api exception + */ + public KubernetesApiResponse update(O object, UpdateOptions options) + throws ApiException { + return api.update(object, options).throwsApiException(); + } + + /** + * A supplier for generic stubs. + * + * @param the object type + * @param the object list type + * @param the result type + */ + @FunctionalInterface + public interface GenericSupplier> { + + /** + * Gets a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the result + */ + R get(K8sClient client, String namespace, String name); + } + + @Override + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + public String toString() { + return (Strings.isNullOrEmpty(group()) ? "" : group() + "/") + + version().toUpperCase() + kind() + " " + namespace + ":" + name; + } + + /** + * Get a namespaced object stub for a newly created object. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param model the model + * @param provider the provider + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static > + R create(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, O model, + GenericSupplier provider) throws ApiException { + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), context.getPreferredVersion(), + context.getResourcePlural(), client); + api.create(model).throwsApiException(); + return provider.get(client, model.getMetadata().getNamespace(), + model.getMetadata().getName()); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param the object type + * @param the object list type + * @param the stub type + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param options the options + * @param provider the provider + * @return the collection + * @throws ApiException the api exception + */ + public static > + Collection list(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + ListOptions options, GenericSupplier provider) + throws ApiException { + var result = new ArrayList(); + for (var version : candidateVersions(context)) { + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + var api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), version, context.getResourcePlural(), + client); + var objs = api.list(namespace, options).throwsApiException(); + for (var item : objs.getObject().getItems()) { + result.add(provider.get(client, namespace, + item.getMetadata().getName())); + } + } + return result; + } + + private static List candidateVersions(APIResource context) { + var result = new LinkedList<>(context.getVersions()); + result.remove(context.getPreferredVersion()); + result.add(0, context.getPreferredVersion()); + return result; + } + + /** + * Api resource. + * + * @param client the client + * @param gvk the gvk + * @return the API resource + * @throws ApiException the api exception + */ + public static APIResource apiResource(K8sClient client, + GroupVersionKind gvk) throws ApiException { + var context = K8s.context(client, gvk.getGroup(), gvk.getVersion(), + gvk.getKind()); + if (context.isEmpty()) { + throw new ApiException("No known API for " + gvk.getGroup() + + "/" + gvk.getVersion() + " " + gvk.getKind()); + } + return context.get(); + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java new file mode 100644 index 0000000..9e22382 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sObserver.java @@ -0,0 +1,237 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.GenericKubernetesApi; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jgrapes.core.Components; + +/** + * An observer that watches namespaced resources in a given context and + * invokes a handler on changes. + * + * @param the object type for the context + * @param the object list type for the context + */ +public class K8sObserver { + + /** + * The type of change reported by {@link Response} as enum. + */ + public enum ResponseType { + ADDED, MODIFIED, DELETED + } + + protected final Logger logger = Logger.getLogger(getClass().getName()); + + protected final K8sClient client; + protected final GenericKubernetesApi api; + protected final APIResource context; + protected final String namespace; + protected final ListOptions options; + protected final Thread thread; + protected BiConsumer> handler; + protected BiConsumer, Throwable> onTerminated; + + /** + * Create and start a new observer for objects in the given context + * (using preferred version) and namespace with the given options. + * + * @param objectClass the object class + * @param objectListClass the object list class + * @param client the client + * @param context the context + * @param namespace the namespace + * @param options the options + */ + @SuppressWarnings({ "PMD.AvoidCatchingThrowable", + "PMD.CognitiveComplexity", "PMD.AvoidCatchingGenericException" }) + public K8sObserver(Class objectClass, Class objectListClass, + K8sClient client, APIResource context, String namespace, + ListOptions options) { + this.client = client; + this.context = context; + this.namespace = namespace; + this.options = options; + + api = new GenericKubernetesApi<>(objectClass, objectListClass, + context.getGroup(), context.getPreferredVersion(), + context.getResourcePlural(), client); + thread = (Components.useVirtualThreads() ? Thread.ofVirtual() + : Thread.ofPlatform()).unstarted(() -> { + try { + logger.fine(() -> "Observing " + context.getResourcePlural() + + " (" + context.getPreferredVersion() + ")" + + Optional.ofNullable(options.getLabelSelector()) + .map(ls -> " with labels " + ls).orElse("") + + " in " + namespace); + + // Watch sometimes terminates without apparent reason. + while (!Thread.currentThread().isInterrupted()) { + Instant startedAt = Instant.now(); + try { + var changed + = api.watch(namespace, options).iterator(); + while (changed.hasNext()) { + var response = changed.next(); + logger.fine(() -> "Resource " + + context.getKind() + "/" + + response.object.getMetadata().getName() + + " " + response.type); + handler.accept(client, response); + } + } catch (ApiException | RuntimeException e) { + logger.log(Level.FINE, e, () -> "Problem watching" + + " resource " + context.getKind() + + " (will retry): " + e.getMessage()); + delayRestart(startedAt); + } + } + if (onTerminated != null) { + onTerminated.accept(this, null); + } + } catch (Throwable e) { + logger.log(Level.SEVERE, e, () -> "Probem watching: " + + e.getMessage()); + if (onTerminated != null) { + onTerminated.accept(this, e); + } + } + }); + } + + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + private void delayRestart(Instant started) { + var runningFor = Duration + .between(started, Instant.now()).toMillis(); + if (runningFor < 5000) { + logger.log(Level.FINE, () -> "Waiting... "); + try { + Thread.sleep(5000 - runningFor); + } catch (InterruptedException e1) { // NOPMD + // Retry + } + logger.log(Level.FINE, () -> "Retrying"); + } + } + + /** + * Sets the handler. + * + * @param handler the handler + * @return the observer + */ + public K8sObserver + handler(BiConsumer> handler) { + this.handler = handler; + return this; + } + + /** + * Sets a function to invoke if the observer terminates. First argument + * is this observer, the second is the throwable that caused the + * abnormal termination or `null` if the observer was terminated + * by {@link #stop()}. + * + * @param onTerminated the on terminated + * @return the observer + */ + public K8sObserver onTerminated( + BiConsumer, Throwable> onTerminated) { + this.onTerminated = onTerminated; + return this; + } + + /** + * Start the observer. + * + * @return the observer + */ + public K8sObserver start() { + if (handler == null) { + throw new IllegalStateException("No handler defined"); + } + thread.start(); + return this; + } + + /** + * Stops the observer. + * + * @return the observer + */ + public K8sObserver stop() { + thread.interrupt(); + return this; + } + + /** + * Returns the client. + * + * @return the client + */ + public K8sClient client() { + return client; + } + + /** + * Returns the context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Returns the observed namespace. + * + * @return the namespace + */ + public String getNamespace() { + return namespace; + } + + /** + * Returns the options for object selection. + * + * @return the list options + */ + public ListOptions options() { + return options; + } + + @Override + public String toString() { + return "Observer for " + K8s.toString(context) + " " + namespace; + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java new file mode 100644 index 0000000..07c59b2 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ConfigMapStub.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import java.util.List; + +/** + * A stub for config maps (v1). + */ +public class K8sV1ConfigMapStub + extends K8sGenericStub { + + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "ConfigMap", true, "configmaps", "configmap"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1ConfigMapStub(K8sClient client, String namespace, + String name) { + super(V1ConfigMap.class, V1ConfigMapList.class, client, + CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the config map stub + */ + public static K8sV1ConfigMapStub get(K8sClient client, String namespace, + String name) { + return new K8sV1ConfigMapStub(client, namespace, name); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java new file mode 100644 index 0000000..9075a84 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1DeploymentStub.java @@ -0,0 +1,78 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Deployment; +import io.kubernetes.client.openapi.models.V1DeploymentList; +import java.util.List; +import java.util.Optional; + +/** + * A stub for pods (v1). + */ +public class K8sV1DeploymentStub + extends K8sGenericStub { + + /** The deployment's context. */ + public static final APIResource CONTEXT = new APIResource("apps", + List.of("v1"), "v1", "Pod", true, "deployments", "deployment"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1DeploymentStub(K8sClient client, String namespace, + String name) { + super(V1Deployment.class, V1DeploymentList.class, client, + CONTEXT, namespace, name); + } + + /** + * Scales the deployment. + * + * @param replicas the replicas + * @return the new model or empty if not successful + * @throws ApiException the API exception + */ + public Optional scale(int replicas) throws ApiException { + return patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/replicas" + + "\", \"value\": " + replicas + "}]"), + client.defaultPatchOptions()); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the deployment stub + */ + public static K8sV1DeploymentStub get(K8sClient client, String namespace, + String name) { + return new K8sV1DeploymentStub(client, namespace, name); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java new file mode 100644 index 0000000..ea1237d --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1NodeStub.java @@ -0,0 +1,83 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Node; +import io.kubernetes.client.openapi.models.V1NodeList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for nodes (v1). + */ +public class K8sV1NodeStub extends K8sClusterGenericStub { + + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "Node", true, "nodes", "node"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param name the name + */ + protected K8sV1NodeStub(K8sClient client, String name) { + super(V1Node.class, V1NodeList.class, client, CONTEXT, name); + } + + /** + * Gets the stub for the given name. + * + * @param client the client + * @param name the name + * @return the config map stub + */ + public static K8sV1NodeStub get(K8sClient client, String name) { + return new K8sV1NodeStub(client, name); + } + + /** + * Get the stubs for the objects that match + * the criteria from the given options. + * + * @param client the client + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + ListOptions options) throws ApiException { + return K8sClusterGenericStub.list(V1Node.class, V1NodeList.class, + client, CONTEXT, options, K8sV1NodeStub::getGeneric); + } + + /** + * Provide {@link GenericSupplier}. + */ + @SuppressWarnings({ "PMD.UnusedFormalParameter" }) + private static K8sV1NodeStub getGeneric(Class objectClass, + Class objectListClass, K8sClient client, + APIResource context, String name) { + return new K8sV1NodeStub(client, name); + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java new file mode 100644 index 0000000..f21bb47 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PodStub.java @@ -0,0 +1,78 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for pods (v1). + */ +public class K8sV1PodStub extends K8sGenericStub { + + /** The pods' context. */ + public static final APIResource CONTEXT + = new APIResource("", List.of("v1"), "v1", "Pod", true, "pods", "pod"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1PodStub(K8sClient client, String namespace, String name) { + super(V1Pod.class, V1PodList.class, client, CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the kpod stub + */ + public static K8sV1PodStub get(K8sClient client, String namespace, + String name) { + return new K8sV1PodStub(client, namespace, name); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + String namespace, ListOptions options) throws ApiException { + return K8sGenericStub.list(V1Pod.class, V1PodList.class, client, + CONTEXT, namespace, options, (clnt, nscp, + name) -> new K8sV1PodStub(clnt, nscp, name)); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java new file mode 100644 index 0000000..c46a60f --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1PvcStub.java @@ -0,0 +1,81 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1PersistentVolumeClaim; +import io.kubernetes.client.openapi.models.V1PersistentVolumeClaimList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for pods (v1). + */ +public class K8sV1PvcStub extends + K8sGenericStub { + + /** The pods' context. */ + public static final APIResource CONTEXT + = new APIResource("", List.of("v1"), "v1", "PersistentVolumeClaim", + true, "persistentvolumeclaims", "persistentvolumeclaim"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1PvcStub(K8sClient client, String namespace, String name) { + super(V1PersistentVolumeClaim.class, V1PersistentVolumeClaimList.class, + client, CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the kpod stub + */ + public static K8sV1PvcStub get(K8sClient client, String namespace, + String name) { + return new K8sV1PvcStub(client, namespace, name); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + String namespace, ListOptions options) throws ApiException { + return K8sGenericStub.list(V1PersistentVolumeClaim.class, + V1PersistentVolumeClaimList.class, client, CONTEXT, namespace, + options, (clnt, nscp, name) -> new K8sV1PvcStub(clnt, nscp, name)); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java new file mode 100644 index 0000000..9c1c086 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1SecretStub.java @@ -0,0 +1,92 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1SecretList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for secrets (v1). + */ +public class K8sV1SecretStub extends K8sGenericStub { + + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "Secret", true, "secrets", "secret"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1SecretStub(K8sClient client, String namespace, + String name) { + super(V1Secret.class, V1SecretList.class, client, + CONTEXT, namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the config map stub + */ + public static K8sV1SecretStub get(K8sClient client, String namespace, + String name) { + return new K8sV1SecretStub(client, namespace, name); + } + + /** + * Creates an object stub from a model. + * + * @param client the client + * @param model the model + * @return the k 8 s dynamic stub + * @throws ApiException the api exception + */ + public static K8sV1SecretStub create(K8sClient client, V1Secret model) + throws ApiException { + return K8sGenericStub.create(V1Secret.class, + V1SecretList.class, client, CONTEXT, model, K8sV1SecretStub::new); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + String namespace, ListOptions options) throws ApiException { + return K8sGenericStub.list(V1Secret.class, V1SecretList.class, client, + CONTEXT, namespace, options, K8sV1SecretStub::new); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java new file mode 100644 index 0000000..863f86f --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1ServiceStub.java @@ -0,0 +1,79 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Service; +import io.kubernetes.client.openapi.models.V1ServiceList; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.util.Collection; +import java.util.List; + +/** + * A stub for secrets (v1). + */ +public class K8sV1ServiceStub extends K8sGenericStub { + + public static final APIResource CONTEXT = new APIResource("", List.of("v1"), + "v1", "Service", true, "services", "service"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1ServiceStub(K8sClient client, String namespace, + String name) { + super(V1Service.class, V1ServiceList.class, client, CONTEXT, namespace, + name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the config map stub + */ + public static K8sV1ServiceStub get(K8sClient client, String namespace, + String name) { + return new K8sV1ServiceStub(client, namespace, name); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + String namespace, ListOptions options) throws ApiException { + return K8sGenericStub.list(V1Service.class, V1ServiceList.class, client, + CONTEXT, namespace, options, + (clnt, nscp, name) -> new K8sV1ServiceStub(clnt, nscp, name)); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java new file mode 100644 index 0000000..be30b00 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8sV1StatefulSetStub.java @@ -0,0 +1,62 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.openapi.models.V1StatefulSet; +import io.kubernetes.client.openapi.models.V1StatefulSetList; +import java.util.List; + +/** + * A stub for stateful sets (v1). + */ +public class K8sV1StatefulSetStub + extends K8sGenericStub { + + /** The stateful sets' context */ + public static final APIResource CONTEXT + = new APIResource("apps", List.of("v1"), "v1", "StatefulSet", true, + "statefulsets", "statefulset"); + + /** + * Instantiates a new stub. + * + * @param client the client + * @param namespace the namespace + * @param name the name + */ + protected K8sV1StatefulSetStub(K8sClient client, String namespace, + String name) { + super(V1StatefulSet.class, V1StatefulSetList.class, client, CONTEXT, + namespace, name); + } + + /** + * Gets the stub for the given namespace and name. + * + * @param client the client + * @param namespace the namespace + * @param name the name + * @return the stateful set stub + */ + public static K8sV1StatefulSetStub get(K8sClient client, String namespace, + String name) { + return new K8sV1StatefulSetStub(client, namespace, name); + } +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java new file mode 100644 index 0000000..a0b66bf --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinition.java @@ -0,0 +1,499 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.models.V1Condition; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.Constants.Status.Condition; +import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason; +import org.jdrupes.vmoperator.util.DataPath; + +/** + * Represents a VM definition. + */ +@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) +public class VmDefinition extends K8sDynamicModel { + + @SuppressWarnings({ "unused" }) + private static final Logger logger + = Logger.getLogger(VmDefinition.class.getName()); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final Gson gson = new JSON().getGson(); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); + + private final Model model; + private VmExtraData extraData; + + /** + * The VM state from the VM definition. + */ + public enum RequestedVmState { + STOPPED, RUNNING + } + + /** + * Permissions for accessing and manipulating the VM. + */ + public enum Permission { + START("start"), STOP("stop"), RESET("reset"), + ACCESS_CONSOLE("accessConsole"), TAKE_CONSOLE("takeConsole"); + + @SuppressWarnings("PMD.UseConcurrentHashMap") + private static Map reprs = new HashMap<>(); + + static { + for (var value : EnumSet.allOf(Permission.class)) { + reprs.put(value.repr, value); + } + } + + private final String repr; + + Permission(String repr) { + this.repr = repr; + } + + /** + * Create permission from representation in CRD. + * + * @param value the value + * @return the permission + */ + @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") + public static Set parse(String value) { + if ("*".equals(value)) { + return EnumSet.allOf(Permission.class); + } + return Set.of(reprs.get(value)); + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return repr; + } + } + + /** + * Permissions granted to a user or role. + * + * @param user the user + * @param role the role + * @param may the may + */ + public record Grant(String user, String role, Set may) { + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (user != null) { + builder.append("User ").append(user); + } else { + builder.append("Role ").append(role); + } + builder.append(" may=").append(may).append(']'); + return builder.toString(); + } + } + + /** + * The assignment information. + * + * @param pool the pool + * @param user the user + * @param lastUsed the last used + */ + public record Assignment(String pool, String user, Instant lastUsed) { + } + + /** + * Instantiates a new vm definition. + * + * @param delegate the delegate + * @param json the json + */ + public VmDefinition(Gson delegate, JsonObject json) { + super(delegate, json); + model = gson.fromJson(json, Model.class); + } + + /** + * Gets the spec. + * + * @return the spec + */ + public Map spec() { + return model.getSpec(); + } + + /** + * Get a value from the spec using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromSpec(Object... selectors) { + return DataPath.get(spec(), selectors); + } + + /** + * The pools that this VM belongs to. + * + * @return the list + */ + public List pools() { + return this.> fromSpec("pools") + .orElse(Collections.emptyList()); + } + + /** + * Get a value from the `spec().get("vm")` using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromVm(Object... selectors) { + return DataPath.get(spec(), "vm") + .flatMap(vm -> DataPath.get(vm, selectors)); + } + + /** + * Gets the status. + * + * @return the status + */ + public Map status() { + return model.getStatus(); + } + + /** + * Get a value from the status using {@link DataPath#get}. + * + * @param the generic type + * @param selectors the selectors + * @return the value, if found + */ + public Optional fromStatus(Object... selectors) { + return DataPath.get(status(), selectors); + } + + /** + * The assignment information. + * + * @return the optional + */ + public Optional assignment() { + return this.> fromStatus(Status.ASSIGNMENT) + .filter(m -> !m.isEmpty()).map(a -> new Assignment( + a.get("pool").toString(), a.get("user").toString(), + Instant.parse(a.get("lastUsed").toString()))); + } + + /** + * Return a condition from the status. + * + * @param name the condition's name + * @return the status, if the condition is defined + */ + public Optional condition(String name) { + return this.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(name::equals).orElse(false)) + .findFirst() + .map(cond -> objectMapper.convertValue(cond, V1Condition.class)); + } + + /** + * Return a condition's status. + * + * @param name the condition's name + * @return the status, if the condition is defined + */ + public Optional conditionStatus(String name) { + return this.>> fromStatus("conditions") + .orElse(Collections.emptyList()).stream() + .filter(cond -> DataPath.get(cond, "type") + .map(name::equals).orElse(false)) + .findFirst().map(cond -> DataPath.get(cond, "status") + .map("True"::equals).orElse(false)); + } + + /** + * Return true if the console is in use. + * + * @return true, if successful + */ + public boolean consoleConnected() { + return conditionStatus("ConsoleConnected").orElse(false); + } + + /** + * Return the last known console user. + * + * @return the optional + */ + public Optional consoleUser() { + return this. fromStatus(Status.CONSOLE_USER); + } + + /** + * Set extra data (unknown to kubernetes). + * @return the VM definition + */ + /* default */ VmDefinition extra(VmExtraData extraData) { + this.extraData = extraData; + return this; + } + + /** + * Return the extra data. + * + * @return the data + */ + public VmExtraData extra() { + return extraData; + } + + /** + * Returns the definition's name. + * + * @return the string + */ + public String name() { + return metadata().getName(); + } + + /** + * Returns the definition's namespace. + * + * @return the string + */ + public String namespace() { + return metadata().getNamespace(); + } + + /** + * Return the requested VM state. + * + * @return the string + */ + public RequestedVmState vmState() { + return fromVm("state") + .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING + : RequestedVmState.STOPPED) + .orElse(RequestedVmState.STOPPED); + } + + /** + * Collect all permissions for the given user with the given roles. + * If permission "takeConsole" is granted, the result will also + * contain "accessConsole" to simplify checks. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + var result = this.>> fromSpec("permissions") + .orElse(Collections.emptyList()).stream() + .filter(p -> DataPath.get(p, "user").map(u -> u.equals(user)) + .orElse(false) + || DataPath.get(p, "role").map(roles::contains).orElse(false)) + .map(p -> DataPath.> get(p, "may") + .orElse(Collections.emptyList()).stream()) + .flatMap(Function.identity()) + .map(Permission::parse).map(Set::stream) + .flatMap(Function.identity()) + .collect(Collectors.toCollection(HashSet::new)); + + // Take console implies access console, simplify checks + if (result.contains(Permission.TAKE_CONSOLE)) { + result.add(Permission.ACCESS_CONSOLE); + } + return result; + } + + /** + * Check if the console is accessible. Always returns `true` if + * the VM is running and the permissions allow taking over the + * console. Else, returns `true` if + * + * * the permissions allow access to the console and + * + * * the VM is running and + * + * * the console is currently unused or used by the given user and + * + * * if user login is requested, the given user is logged in. + * + * @param user the user + * @param permissions the permissions + * @return true, if successful + */ + @SuppressWarnings("PMD.SimplifyBooleanReturns") + public boolean consoleAccessible(String user, Set permissions) { + // Basic checks + if (!conditionStatus(Condition.RUNNING).orElse(false)) { + return false; + } + if (permissions.contains(Permission.TAKE_CONSOLE)) { + return true; + } + if (!permissions.contains(Permission.ACCESS_CONSOLE)) { + return false; + } + + // If the console is in use by another user, deny access + if (conditionStatus(Condition.CONSOLE_CONNECTED).orElse(false) + && !consoleUser().map(cu -> cu.equals(user)).orElse(false)) { + return false; + } + + // If no login is requested, allow access, else check if user matches + if (condition(Condition.USER_LOGGED_IN).map(V1Condition::getReason) + .map(r -> Reason.NOT_REQUESTED.equals(r)).orElse(false)) { + return true; + } + return user.equals(status().get(Status.LOGGED_IN_USER)); + } + + /** + * Get the display password serial. + * + * @return the optional + */ + public Optional displayPasswordSerial() { + return this. fromStatus(Status.DISPLAY_PASSWORD_SERIAL) + .map(Number::longValue); + } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + return Objects.hash(metadata().getNamespace(), metadata().getName()); + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + VmDefinition other = (VmDefinition) obj; + return Objects.equals(metadata().getNamespace(), + other.metadata().getNamespace()) + && Objects.equals(metadata().getName(), other.metadata().getName()); + } + + /** + * The Class Model. + */ + public static class Model { + + private Map spec; + private Map status; + + /** + * Gets the spec. + * + * @return the spec + */ + public Map getSpec() { + return spec; + } + + /** + * Sets the spec. + * + * @param spec the spec to set + */ + public void setSpec(Map spec) { + this.spec = spec; + } + + /** + * Gets the status. + * + * @return the status + */ + public Map getStatus() { + return status; + } + + /** + * Sets the status. + * + * @param status the status to set + */ + public void setStatus(Map status) { + this.status = status; + } + + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java new file mode 100644 index 0000000..377220a --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitionStub.java @@ -0,0 +1,152 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.Reader; +import java.util.Collection; + +/** + * A stub for namespaced custom objects. It uses a dynamic model + * (see {@link K8sDynamicModel}) for representing the object's + * state and can therefore be used for any kind of object, especially + * custom objects. + */ +public class VmDefinitionStub + extends K8sDynamicStubBase { + + private static DynamicTypeAdapterFactory taf = new VmDefintionModelTypeAdapterFactory(); + + /** + * Instantiates a new stub for VM defintions. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + */ + public VmDefinitionStub(K8sClient client, APIResource context, + String namespace, String name) { + super(VmDefinition.class, VmDefinitions.class, taf, client, + context, namespace, name); + } + + /** + * Get a dynamic object stub. If the version in parameter + * `gvk` is an empty string, the stub refers to the first object with + * matching group and kind. + * + * @param client the client + * @param gvk the group, version and kind + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static VmDefinitionStub get(K8sClient client, + GroupVersionKind gvk, String namespace, String name) + throws ApiException { + return new VmDefinitionStub(client, apiResource(client, gvk), namespace, + name); + } + + /** + * Get a dynamic object stub. + * + * @param client the client + * @param context the context + * @param namespace the namespace + * @param name the name + * @return the stub if the object exists + * @throws ApiException the api exception + */ + public static VmDefinitionStub get(K8sClient client, + APIResource context, String namespace, String name) { + return new VmDefinitionStub(client, context, namespace, name); + } + + /** + * Creates a stub from yaml. + * + * @param client the client + * @param context the context + * @param yaml the yaml + * @return the k 8 s dynamic stub + * @throws ApiException the api exception + */ + public static VmDefinitionStub createFromYaml(K8sClient client, + APIResource context, Reader yaml) throws ApiException { + var model = new VmDefinition(client.getJSON().getGson(), + K8s.yamlToJson(client, yaml)); + return K8sGenericStub.create(VmDefinition.class, + VmDefinitions.class, client, context, model, + (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); + } + + /** + * Get the stubs for the objects in the given namespace that match + * the criteria from the given options. + * + * @param client the client + * @param namespace the namespace + * @param options the options + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace, ListOptions options) + throws ApiException { + return K8sGenericStub.list(VmDefinition.class, + VmDefinitions.class, client, context, namespace, options, + (c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); + } + + /** + * Get the stubs for the objects in the given namespace. + * + * @param client the client + * @param namespace the namespace + * @return the collection + * @throws ApiException the api exception + */ + public static Collection list(K8sClient client, + APIResource context, String namespace) + throws ApiException { + return list(client, context, namespace, new ListOptions()); + } + + /** + * A factory for creating VmDefinitionModel(s) objects. + */ + public static class VmDefintionModelTypeAdapterFactory extends + DynamicTypeAdapterFactory { + + /** + * Instantiates a new dynamic model type adapter factory. + */ + public VmDefintionModelTypeAdapterFactory() { + super(VmDefinition.class, VmDefinitions.class); + } + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java new file mode 100644 index 0000000..c79654e --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmDefinitions.java @@ -0,0 +1,39 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * Represents a list of {@link VmDefinition}s. + */ +public class VmDefinitions + extends K8sDynamicModelsBase { + + /** + * Initialize the object list using the given JSON data. + * + * @param delegate the gson instance to use for extracting structured data + * @param data the data + */ + public VmDefinitions(Gson delegate, JsonObject data) { + super(VmDefinition.class, delegate, data); + } +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java new file mode 100644 index 0000000..e1565c5 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmExtraData.java @@ -0,0 +1,179 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import io.kubernetes.client.util.Strings; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Represents internally used dynamic data associated with a + * {@link VmDefinition}. + */ +public class VmExtraData { + + private static final Logger logger + = Logger.getLogger(VmExtraData.class.getName()); + + private final VmDefinition vmDef; + private String nodeName = ""; + private List nodeAddresses = Collections.emptyList(); + private long resetCount; + + /** + * Initializes a new instance. + * + * @param vmDef the VM definition + */ + public VmExtraData(VmDefinition vmDef) { + this.vmDef = vmDef; + vmDef.extra(this); + } + + /** + * Sets the node info. + * + * @param name the name + * @param addresses the addresses + * @return the VM extra data + */ + public VmExtraData nodeInfo(String name, List addresses) { + nodeName = name; + nodeAddresses = addresses; + return this; + } + + /** + * Return the node name. + * + * @return the string + */ + public String nodeName() { + return nodeName; + } + + /** + * Gets the node addresses. + * + * @return the nodeAddresses + */ + public List nodeAddresses() { + return nodeAddresses; + } + + /** + * Sets the reset count. + * + * @param resetCount the reset count + * @return the vm extra data + */ + public VmExtraData resetCount(long resetCount) { + this.resetCount = resetCount; + return this; + } + + /** + * Returns the reset count. + * + * @return the long + */ + public long resetCount() { + return resetCount; + } + + /** + * Create a connection file. + * + * @param password the password + * @param preferredIpVersion the preferred IP version + * @param deleteConnectionFile the delete connection file + * @return the string + */ + public Optional connectionFile(String password, + Class preferredIpVersion, boolean deleteConnectionFile) { + var addr = displayIp(preferredIpVersion); + if (addr.isEmpty()) { + logger + .severe(() -> "Failed to find display IP for " + vmDef.name()); + return Optional.empty(); + } + var port = vmDef. fromVm("display", "spice", "port") + .map(Number::longValue); + if (port.isEmpty()) { + logger + .severe(() -> "No port defined for display of " + vmDef.name()); + return Optional.empty(); + } + StringBuffer data = new StringBuffer(100) + .append("[virt-viewer]\ntype=spice\nhost=") + .append(addr.get().getHostAddress()).append("\nport=") + .append(port.get().toString()) + .append('\n'); + if (password != null) { + data.append("password=").append(password).append('\n'); + } + vmDef. fromVm("display", "spice", "proxyUrl") + .ifPresent(u -> { + if (!Strings.isNullOrEmpty(u)) { + data.append("proxy=").append(u).append('\n'); + } + }); + if (deleteConnectionFile) { + data.append("delete-this-file=1\n"); + } + return Optional.of(data.toString()); + } + + private Optional displayIp(Class preferredIpVersion) { + Optional server = vmDef.fromVm("display", "spice", "server"); + if (server.isPresent()) { + var srv = server.get(); + try { + var addr = InetAddress.getByName(srv); + logger.fine(() -> "Using IP address from CRD for " + + vmDef.metadata().getName() + ": " + addr); + return Optional.of(addr); + } catch (UnknownHostException e) { + logger.log(Level.SEVERE, e, () -> "Invalid server address " + + srv + ": " + e.getMessage()); + return Optional.empty(); + } + } + var addrs = nodeAddresses.stream().map(a -> { + try { + return InetAddress.getByName(a); + } catch (UnknownHostException e) { + logger.warning(() -> "Invalid IP address: " + a); + return null; + } + }).filter(Objects::nonNull).toList(); + logger.fine( + () -> "Known IP addresses for " + vmDef.name() + ": " + addrs); + return addrs.stream() + .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) + .findFirst().or(() -> addrs.stream().findFirst()); + } + +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java new file mode 100644 index 0000000..f7aaa67 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/VmPool.java @@ -0,0 +1,226 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.common; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmDefinition.Grant; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; +import org.jdrupes.vmoperator.util.DataPath; + +/** + * Represents a VM pool. + */ +public class VmPool { + + private final String name; + private String retention; + private boolean loginOnAssignment; + private boolean defined; + private List permissions = Collections.emptyList(); + private final Set vms + = Collections.synchronizedSet(new HashSet<>()); + + /** + * Instantiates a new vm pool. + * + * @param name the name + */ + public VmPool(String name) { + this.name = name; + } + + /** + * Fill the properties of a provisionally created pool from + * the definition. + * + * @param definition the definition + */ + public void defineFrom(VmPool definition) { + retention = definition.retention(); + permissions = definition.permissions(); + loginOnAssignment = definition.loginOnAssignment(); + defined = true; + } + + /** + * Returns the name. + * + * @return the name + */ + public String name() { + return name; + } + + /** + * Checks if is login on assignment. + * + * @return the loginOnAssignment + */ + public boolean loginOnAssignment() { + return loginOnAssignment; + } + + /** + * Checks if is defined. + * + * @return the result + */ + public boolean isDefined() { + return defined; + } + + /** + * Marks the pool as undefined. + */ + public void setUndefined() { + defined = false; + } + + /** + * Gets the retention. + * + * @return the retention + */ + public String retention() { + return retention; + } + + /** + * Permissions granted for a VM from the pool. + * + * @return the permissions + */ + public List permissions() { + return permissions; + } + + /** + * Returns the VM names. + * + * @return the vms + */ + public Set vms() { + return vms; + } + + /** + * Collect all permissions for the given user with the given roles. + * + * @param user the user + * @param roles the roles + * @return the sets the + */ + public Set permissionsFor(String user, + Collection roles) { + return permissions.stream() + .filter(g -> DataPath.get(g, "user").map(u -> u.equals(user)) + .orElse(false) + || DataPath.get(g, "role").map(roles::contains).orElse(false)) + .map(g -> DataPath.> get(g, "may") + .orElse(Collections.emptySet()).stream()) + .flatMap(Function.identity()).collect(Collectors.toSet()); + } + + /** + * Checks if the given VM belongs to the pool and is not in use. + * + * @param vmDef the vm def + * @return true, if is assignable + */ + @SuppressWarnings("PMD.SimplifyBooleanReturns") + public boolean isAssignable(VmDefinition vmDef) { + // Check if the VM is in the pool + if (!vmDef.pools().contains(name)) { + return false; + } + + // Check if the VM is not in use + if (vmDef.consoleConnected()) { + return false; + } + + // If not assigned, it's usable + if (vmDef.assignment().isEmpty()) { + return true; + } + + // Check if it is to be retained + if (vmDef.assignment().map(Assignment::lastUsed).map(this::retainUntil) + .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { + return false; + } + + // Additional check in case lastUsed has not been updated + // by PoolMonitor#onVmResourceChanged() yet ("race condition") + if (vmDef.condition("ConsoleConnected") + .map(cc -> cc.getLastTransitionTime().toInstant()) + .map(this::retainUntil) + .map(ru -> Instant.now().isBefore(ru)).orElse(false)) { + return false; + } + return true; + } + + /** + * Return the instant until which an assignment should be retained. + * + * @param lastUsed the last used + * @return the instant + */ + public Instant retainUntil(Instant lastUsed) { + if (retention.startsWith("P")) { + return lastUsed.plus(Duration.parse(retention)); + } + return Instant.parse(retention); + } + + /** + * To string. + * + * @return the string + */ + @Override + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AvoidSynchronizedStatement" }) + public String toString() { + StringBuilder builder = new StringBuilder(50); + builder.append("VmPool [name=").append(name).append(", permissions=") + .append(permissions).append(", vms="); + if (vms.size() <= 3) { + builder.append(vms); + } else { + synchronized (vms) { + builder.append('[').append(vms.stream().limit(3) + .map(s -> s + ",").collect(Collectors.joining())) + .append("...]"); + } + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/.eclipse-pmd b/org.jdrupes.vmoperator.manager.events/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.manager.events/.eclipse-pmd +++ b/org.jdrupes.vmoperator.manager.events/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.manager.events/build.gradle b/org.jdrupes.vmoperator.manager.events/build.gradle index 56c364f..bb4b8d8 100644 --- a/org.jdrupes.vmoperator.manager.events/build.gradle +++ b/org.jdrupes.vmoperator.manager.events/build.gradle @@ -9,6 +9,5 @@ plugins { } dependencies { - api 'org.jgrapes:org.jgrapes.core:[1.19.0,2)' api project(':org.jdrupes.vmoperator.common') } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java new file mode 100644 index 0000000..7252c6a --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; +import org.jgrapes.core.Event; + +/** + * Assign a VM from a pool to a user. + */ +public class AssignVm extends Event { + + private final String fromPool; + private final String toUser; + + /** + * Instantiates a new event. + * + * @param fromPool the from pool + * @param toUser the to user + */ + public AssignVm(String fromPool, String toUser) { + this.fromPool = fromPool; + this.toUser = toUser; + } + + /** + * Gets the pool to assign from. + * + * @return the pool + */ + public String fromPool() { + return fromPool; + } + + /** + * Gets the user to assign to. + * + * @return the to user + */ + public String toUser() { + return toUser; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java new file mode 100644 index 0000000..2b23532 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java @@ -0,0 +1,112 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import org.jgrapes.core.Channel; + +/** + * Supports the lookup of a channel by a name (an id). As a convenience, + * it is possible to additionally associate arbitrary data with the entry + * (and thus with the channel). Note that this interface defines a + * read-only view of the dictionary. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public interface ChannelDictionary { + + /** + * Combines the channel and the associated data. + * + * @param the channel type + * @param the type of the associated data + * @param channel the channel + * @param associated the associated + */ + public record Value(C channel, A associated) { + } + + /** + * Returns all known keys. + * + * @return the keys + */ + Set keys(); + + /** + * Return all known values. + * + * @return the collection + */ + Collection> values(); + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no entry exists. + * + * @param key the key + * @return the result + */ + Optional> value(K key); + + /** + * Return all known channels. + * + * @return the collection + */ + default Collection channels() { + return values().stream().map(v -> v.channel).toList(); + } + + /** + * Returns the channel registered for the key or an empty optional + * if no mapping exists. + * + * @param key the key + * @return the optional + */ + default Optional channel(K key) { + return value(key).map(b -> b.channel); + } + + /** + * Returns all known associated data. + * + * @return the collection + */ + default Collection associated() { + return values().stream() + .filter(v -> v.associated() != null) + .map(v -> v.associated).toList(); + } + + /** + * Return the data associated with the entry for the channel. + * + * @param key the key + * @return the data + */ + default Optional associated(K key) { + return value(key).map(b -> b.associated); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java new file mode 100644 index 0000000..da36123 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java @@ -0,0 +1,179 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.jgrapes.core.Channel; + +/** + * Provides an actively managed implementation of the {@link ChannelDictionary}. + * + * The {@link ChannelManager} can be used for housekeeping by any component + * that creates channels. It can be shared between this component and + * some other component, preferably passing it as {@link ChannelDictionary} + * (the read-only view) to the second component. Alternatively, the other + * component can use a {@link ChannelTracker} to track the mappings using + * events. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public class ChannelManager + implements ChannelDictionary { + + private final Map> entries = new ConcurrentHashMap<>(); + private final Function supplier; + + /** + * Instantiates a new channel manager. + * + * @param supplier the supplier that creates new channels + */ + public ChannelManager(Function supplier) { + this.supplier = supplier; + } + + /** + * Instantiates a new channel manager without a default supplier. + */ + public ChannelManager() { + this(k -> null); + } + + /** + * Return all keys. + * + * @return the keys. + */ + @Override + public Set keys() { + return entries.keySet(); + } + + /** + * Return all known values. + * + * @return the collection + */ + @Override + public Collection> values() { + return entries.values(); + } + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no mapping exists. + * + * @param key the key + * @return the result + */ + public Optional> value(K key) { + return Optional.ofNullable(entries.get(key)); + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @param associated the associated + * @return the channel manager + */ + public ChannelManager put(K key, C channel, A associated) { + entries.put(key, new Value<>(channel, associated)); + return this; + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @return the channel manager + */ + public ChannelManager put(K key, C channel) { + put(key, channel, null); + return this; + } + + /** + * Creates a new channel without adding it to the channel manager. + * After fully initializing the channel, it should be added to the + * manager using {@link #put(K, C)}. + * + * @param key the key + * @return the c + */ + public C createChannel(K key) { + return supplier.apply(key); + } + + /** + * Returns the {@link Channel} for the given name, creating it using + * the supplier passed to the constructor if it doesn't exist yet. + * + * @param key the key + * @return the channel + */ + public C channelGet(K key) { + return computeIfAbsent(key, supplier); + } + + /** + * Returns the {@link Channel} for the given name, creating it using + * the given supplier if it doesn't exist yet. + * + * @param key the key + * @param supplier the supplier + * @return the channel + */ + public C computeIfAbsent(K key, Function supplier) { + return entries.computeIfAbsent(key, + k -> new Value<>(supplier.apply(k), null)).channel(); + } + + /** + * Associate the entry for the channel with the given data. The entry + * for the channel must already exist. + * + * @param key the key + * @param data the data + * @return the channel manager + */ + public ChannelManager associate(K key, A data) { + Optional.ofNullable(entries.computeIfPresent(key, + (k, existing) -> new Value<>(existing.channel(), data))); + return this; + } + + /** + * Removes the channel with the given name. + * + * @param name the name + */ + public void remove(String name) { + entries.remove(name); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java new file mode 100644 index 0000000..8a41908 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java @@ -0,0 +1,161 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.jgrapes.core.Channel; + +/** + * Used to track mapping from a key to a channel. Entries must + * be maintained by handlers for "add/remove" (or "open/close") + * events delivered on the channels that are to be + * made available by the tracker. + * + * The channels are stored in the dictionary using {@link WeakReference}s. + * Removing entries is therefore best practice but not an absolute necessity + * as entries for cleared references are removed when one of the methods + * {@link #values()}, {@link #channels()} or {@link #associated()} is called. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public class ChannelTracker + implements ChannelDictionary { + + private final Map> entries = new ConcurrentHashMap<>(); + + /** + * Combines the channel and associated data. + * + * @param the generic type + * @param the generic type + */ + @SuppressWarnings("PMD.ShortClassName") + private static class Data { + public final WeakReference channel; + public A associated; + + /** + * Instantiates a new value. + * + * @param channel the channel + */ + public Data(C channel) { + this.channel = new WeakReference<>(channel); + } + } + + @Override + public Set keys() { + return entries.keySet(); + } + + @Override + public Collection> values() { + var result = new ArrayList>(); + for (var itr = entries.entrySet().iterator(); itr.hasNext();) { + var value = itr.next().getValue(); + var channel = value.channel.get(); + if (channel == null) { + itr.remove(); + continue; + } + result.add(new Value<>(channel, value.associated)); + } + return result; + } + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no mapping exists. + * + * @param key the key + * @return the result + */ + public Optional> value(K key) { + var value = entries.get(key); + if (value == null) { + return Optional.empty(); + } + var channel = value.channel.get(); + if (channel == null) { + // Cleanup old reference + entries.remove(key); + return Optional.empty(); + } + return Optional.of(new Value<>(channel, value.associated)); + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @param associated the associated + * @return the channel manager + */ + public ChannelTracker put(K key, C channel, A associated) { + Data data = new Data<>(channel); + data.associated = associated; + entries.put(key, data); + return this; + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @return the channel manager + */ + public ChannelTracker put(K key, C channel) { + put(key, channel, null); + return this; + } + + /** + * Associate the entry for the channel with the given data. The entry + * for the channel must already exist. + * + * @param key the key + * @param data the data + * @return the channel manager + */ + public ChannelTracker associate(K key, A data) { + Optional.ofNullable(entries.get(key)) + .ifPresent(v -> v.associated = data); + return this; + } + + /** + * Removes the channel with the given name. + * + * @param name the name + */ + public void remove(String name) { + entries.remove(name); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java new file mode 100644 index 0000000..1c11a4e --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java @@ -0,0 +1,43 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jgrapes.core.events.Stop; + +/** + * Like {@link Stop}, but sets an exit status. + */ +@SuppressWarnings("PMD.ShortClassName") +public class Exit extends Stop { + + private final int exitStatus; + + /** + * Instantiates a new exit. + * + * @param exitStatus the exit status + */ + public Exit(int exitStatus) { + this.exitStatus = exitStatus; + } + + public int exitStatus() { + return exitStatus; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java new file mode 100644 index 0000000..dc47b4a --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java @@ -0,0 +1,92 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the current display secret and optionally updates it. + */ +public class GetDisplaySecret extends Event { + + private final VmDefinition vmDef; + private final String user; + + /** + * Instantiates a new request for the display secret. + * After handling the event, a result of `null` means that + * no secret is needed. No result means that the console + * is not accessible. + * + * @param vmDef the vm name + * @param user the requesting user + */ + public GetDisplaySecret(VmDefinition vmDef, String user) { + this.vmDef = vmDef; + this.user = user; + } + + /** + * Gets the VM definition. + * + * @return the VM definition + */ + public VmDefinition vmDefinition() { + return vmDef; + } + + /** + * Return the id of the user who has requested the password. + * + * @return the string + */ + public String user() { + return user; + } + + /** + * Returns `true` if a password is available. May only be called + * when the event is completed. Note that the password returned + * by {@link #secret()} may be `null`, indicating that no password + * is needed. + * + * @return true, if successful + */ + public boolean secretAvailable() { + if (!isDone()) { + throw new IllegalStateException("Event is not done."); + } + return !currentResults().isEmpty(); + } + + /** + * Return the secret. May only be called when the event has been + * completed with a valid result (see {@link #secretAvailable()}). + * + * @return the password. A value of `null` means that no password + * is required. + */ + public String secret() { + if (!isDone() || currentResults().isEmpty()) { + throw new IllegalStateException("Event is not done."); + } + return currentResults().get(0); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java new file mode 100644 index 0000000..b563c9c --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java @@ -0,0 +1,87 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Event; + +/** + * Gets the known pools' definitions. + */ +public class GetPools extends Event> { + + private String name; + private String user; + private List roles = Collections.emptyList(); + + /** + * Return only the pool with the given name. + * + * @param name the name + * @return the returns the vms + */ + public GetPools withName(String name) { + this.name = name; + return this; + } + + /** + * Return only {@link VmPool}s that are accessible by + * the given user or roles. + * + * @param user the user + * @param roles the roles + * @return the event + */ + public GetPools accessibleFor(String user, List roles) { + this.user = user; + this.roles = roles; + return this; + } + + /** + * Returns the name filter criterion, if set. + * + * @return the optional + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional forUser() { + return Optional.ofNullable(user); + } + + /** + * Returns the roles criterion. + * + * @return the list + */ + public List forRoles() { + return roles; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java new file mode 100644 index 0000000..0e24013 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java @@ -0,0 +1,138 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the known VMs' definitions and channels. + */ +public class GetVms extends Event> { + + private String name; + private String user; + private List roles = Collections.emptyList(); + private String fromPool; + private String toUser; + + /** + * Return only the VMs with the given name. + * + * @param name the name + * @return the returns the vms + */ + public GetVms withName(String name) { + this.name = name; + return this; + } + + /** + * Return only {@link VmDefinition}s that are accessible by + * the given user or roles. + * + * @param user the user + * @param roles the roles + * @return the event + */ + public GetVms accessibleFor(String user, List roles) { + this.user = user; + this.roles = roles; + return this; + } + + /** + * Return only {@link VmDefinition}s that are assigned from the given pool. + * + * @param pool the pool + * @return the returns the vms + */ + public GetVms assignedFrom(String pool) { + this.fromPool = pool; + return this; + } + + /** + * Return only {@link VmDefinition}s that are assigned to the given user. + * + * @param user the user + * @return the returns the vms + */ + public GetVms assignedTo(String user) { + this.toUser = user; + return this; + } + + /** + * Returns the name filter criterion, if set. + * + * @return the optional + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional user() { + return Optional.ofNullable(user); + } + + /** + * Returns the roles criterion. + * + * @return the list + */ + public List roles() { + return roles; + } + + /** + * Returns the pool filter criterion, if set. + * + * @return the optional + */ + public Optional fromPool() { + return Optional.ofNullable(fromPool); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional toUser() { + return Optional.ofNullable(toUser); + } + + /** + * Return tuple. + * + * @param definition the definition + * @param channel the channel + */ + public record VmData(VmDefinition definition, VmChannel channel) { + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/StartVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java similarity index 67% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/StartVm.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java index fb28f0a..9e19255 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/StartVm.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java @@ -22,22 +22,26 @@ import org.jgrapes.core.Channel; import org.jgrapes.core.Event; /** - * Starts a VM. + * Modifies a VM. */ -@SuppressWarnings("PMD.DataClass") -public class StartVm extends Event { +public class ModifyVm extends Event { private final String name; + private final String path; + private final Object value; /** - * Instantiates a new start vm event. + * Instantiates a new modify vm event. * * @param channels the channels * @param name the name */ - public StartVm(String name, Channel... channels) { + public ModifyVm(String name, String path, Object value, + Channel... channels) { super(channels); this.name = name; + this.path = path; + this.value = value; } /** @@ -49,4 +53,22 @@ public class StartVm extends Event { return name; } + /** + * Gets the path. + * + * @return the path + */ + public String path() { + return path; + } + + /** + * Gets the value. + * + * @return the value + */ + public Object value() { + return value; + } + } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java new file mode 100644 index 0000000..8bbcfe8 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/PodChanged.java @@ -0,0 +1,75 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import io.kubernetes.client.openapi.models.V1Pod; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Indicates a change in a pod that runs a VM. + */ +public class PodChanged extends Event { + + private final V1Pod pod; + private final K8sObserver.ResponseType type; + + /** + * Instantiates a new VM changed event. + * + * @param pod the pod + * @param type the type + */ + public PodChanged(V1Pod pod, K8sObserver.ResponseType type) { + this.pod = pod; + this.type = type; + } + + /** + * Gets the pod. + * + * @return the pod + */ + public V1Pod pod() { + return pod; + } + + /** + * Returns the type. + * + * @return the type + */ + public K8sObserver.ResponseType type() { + return type; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(pod.getMetadata().getName()).append(' ').append(type); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/StopVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java similarity index 64% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/StopVm.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java index a6d6281..778820e 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/StopVm.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * 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 @@ -18,35 +18,30 @@ package org.jdrupes.vmoperator.manager.events; -import org.jgrapes.core.Channel; import org.jgrapes.core.Event; /** - * Stops a VM. + * Triggers a reset of the VM. */ -@SuppressWarnings("PMD.DataClass") -public class StopVm extends Event { +public class ResetVm extends Event { - private final String name; + private final String vmName; /** - * Instantiates a new start vm event. + * Instantiates a new event. * - * @param channels the channels - * @param name the name + * @param vmName the vm name */ - public StopVm(String name, Channel... channels) { - super(channels); - this.name = name; + public ResetVm(String vmName) { + this.vmName = vmName; } /** - * Gets the name. + * Gets the vm name. * - * @return the name + * @return the vm name */ - public String name() { - return name; + public String vmName() { + return vmName; } - } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java new file mode 100644 index 0000000..b4fcf56 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Event; + +/** + * Note the assignment to a user in the VM status. + */ +public class UpdateAssignment extends Event { + + private final VmPool fromPool; + private final String toUser; + + /** + * Instantiates a new event. + * + * @param fromPool the pool from which the VM was assigned + * @param toUser the to user + */ + public UpdateAssignment(VmPool fromPool, String toUser) { + this.fromPool = fromPool; + this.toUser = toUser; + } + + /** + * Gets the pool from which the VM was assigned. + * + * @return the pool + */ + public VmPool fromPool() { + return fromPool; + } + + /** + * Gets the user to whom the VM was assigned. + * + * @return the to user + */ + public String toUser() { + return toUser; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java index bc06e68..73507ae 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java @@ -18,21 +18,21 @@ package org.jdrupes.vmoperator.manager.events; -import io.kubernetes.client.openapi.ApiClient; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; import org.jgrapes.core.EventPipeline; import org.jgrapes.core.Subchannel.DefaultSubchannel; /** * A subchannel used to send the events related to a specific VM. */ -@SuppressWarnings("PMD.DataClass") public class VmChannel extends DefaultSubchannel { private final EventPipeline pipeline; - private final ApiClient client; - private DynamicKubernetesObject vmDefinition; + private final K8sClient client; + private VmDefinition definition; private long generation = -1; /** @@ -43,7 +43,7 @@ public class VmChannel extends DefaultSubchannel { * @param client the client */ public VmChannel(Channel mainChannel, EventPipeline pipeline, - ApiClient client) { + K8sClient client) { super(mainChannel); this.pipeline = pipeline; this.client = client; @@ -55,19 +55,18 @@ public class VmChannel extends DefaultSubchannel { * @param definition the definition * @return the watch channel */ - @SuppressWarnings("PMD.LinguisticNaming") - public VmChannel setVmDefinition(DynamicKubernetesObject definition) { - this.vmDefinition = definition; + public VmChannel setVmDefinition(VmDefinition definition) { + this.definition = definition; return this; } /** * Returns the last known definition of the VM. * - * @return the json object + * @return the defintion */ - public DynamicKubernetesObject vmDefinition() { - return vmDefinition; + public VmDefinition vmDefinition() { + return definition; } /** @@ -86,7 +85,6 @@ public class VmChannel extends DefaultSubchannel { * @param generation the generation to set * @return true if value has changed */ - @SuppressWarnings("PMD.LinguisticNaming") public boolean setGeneration(long generation) { if (this.generation == generation) { return false; @@ -104,12 +102,25 @@ public class VmChannel extends DefaultSubchannel { return pipeline; } + /** + * Fire the given event on this channel, using the associated + * {@link #pipeline()}. + * + * @param the generic type + * @param event the event + * @return the t + */ + public > T fire(T event) { + pipeline.fire(event, this); + return event; + } + /** * Returns the API client. * * @return the API client */ - public ApiClient client() { + public K8sClient client() { return client; } } diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java new file mode 100644 index 0000000..0c3f3a1 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmPoolChanged.java @@ -0,0 +1,87 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Indicates a change in a pool configuration. + */ +public class VmPoolChanged extends Event { + + private final VmPool vmPool; + private final boolean deleted; + + /** + * Instantiates a new VM changed event. + * + * @param pool the pool + * @param deleted true, if the pool was deleted + */ + public VmPoolChanged(VmPool pool, boolean deleted) { + vmPool = pool; + this.deleted = deleted; + } + + /** + * Instantiates a new VM changed event for an existing pool. + * + * @param pool the pool + */ + public VmPoolChanged(VmPool pool) { + this(pool, false); + } + + /** + * Returns the VM pool. + * + * @return the vm pool + */ + public VmPool vmPool() { + return vmPool; + } + + /** + * Pool has been deleted. + * + * @return true, if successful + */ + public boolean deleted() { + return deleted; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(30); + builder.append(Components.objectName(this)) + .append(" ["); + if (deleted) { + builder.append("Deleted: "); + } + builder.append(vmPool); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java similarity index 56% rename from org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java rename to org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java index fd5d43c..eac30fb 100644 --- a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmDefChanged.java +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java @@ -18,48 +18,42 @@ package org.jdrupes.vmoperator.manager.events; -import io.kubernetes.client.openapi.models.V1APIResource; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; /** - * Indicates a change in a VM definition. Note that the definition - * consists of the metadata (mostly immutable), the "spec" and the - * "status" parts. Consumers that are only interested in "spec" - * changes should check {@link #specChanged()} before processing - * the event any further. + * Indicates a change in a VM "resource". Note that the resource + * combines the VM CR's metadata (mostly immutable), the VM CR's + * "spec" part, the VM CR's "status" subresource and state information + * from the pod. Consumers that are only interested in "spec" changes + * should check {@link #specChanged()} before processing the event any + * further. */ @SuppressWarnings("PMD.DataClass") -public class VmDefChanged extends Event { +public class VmResourceChanged extends Event { - /** - * The type of change. - */ - public enum Type { - ADDED, MODIFIED, DELETED - } - - private final Type type; + private final K8sObserver.ResponseType type; + private final VmDefinition vmDefinition; private final boolean specChanged; - private final V1APIResource crd; - private final DynamicKubernetesObject vmDef; + private final boolean podChanged; /** * Instantiates a new VM changed event. * * @param type the type - * @param specChanged the spec part changed - * @param crd the crd * @param vmDefinition the VM definition + * @param specChanged the spec part changed */ - public VmDefChanged(Type type, boolean specChanged, V1APIResource crd, - DynamicKubernetesObject vmDefinition) { + public VmResourceChanged(K8sObserver.ResponseType type, + VmDefinition vmDefinition, boolean specChanged, + boolean podChanged) { this.type = type; + this.vmDefinition = vmDefinition; this.specChanged = specChanged; - this.crd = crd; - this.vmDef = vmDefinition; + this.podChanged = podChanged; } /** @@ -67,10 +61,19 @@ public class VmDefChanged extends Event { * * @return the type */ - public Type type() { + public K8sObserver.ResponseType type() { return type; } + /** + * Return the VM definition. + * + * @return the VM definition + */ + public VmDefinition vmDefinition() { + return vmDefinition; + } + /** * Indicates if the "spec" part changed. */ @@ -79,31 +82,19 @@ public class VmDefChanged extends Event { } /** - * Returns the Crd. - * - * @return the v 1 API resource + * Indicates if the pod status changed. */ - public V1APIResource crd() { - return crd; - } - - /** - * Returns the object. - * - * @return the object. - */ - public DynamicKubernetesObject vmDefinition() { - return vmDef; + public boolean podChanged() { + return podChanged; } @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append(Components.objectName(this)).append(" [") - .append(vmDef.getMetadata().getName()).append(' ').append(type); + .append(vmDefinition.name()).append(' ').append(type); if (channels() != null) { - builder.append(", channels="); - builder.append(Channel.toString(channels())); + builder.append(", channels=").append(Channel.toString(channels())); } builder.append(']'); return builder.toString(); diff --git a/org.jdrupes.vmoperator.manager/.eclipse-pmd b/org.jdrupes.vmoperator.manager/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.manager/.eclipse-pmd +++ b/org.jdrupes.vmoperator.manager/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.manager/.gitignore b/org.jdrupes.vmoperator.manager/.gitignore new file mode 100644 index 0000000..50a6b62 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/.gitignore @@ -0,0 +1 @@ +/logging.properties diff --git a/org.jdrupes.vmoperator.manager/.settings/net.sf.jautodoc.prefs b/org.jdrupes.vmoperator.manager/.settings/net.sf.jautodoc.prefs index 6f3b6d4..03e8200 100644 --- a/org.jdrupes.vmoperator.manager/.settings/net.sf.jautodoc.prefs +++ b/org.jdrupes.vmoperator.manager/.settings/net.sf.jautodoc.prefs @@ -1,6 +1,6 @@ add_header=true eclipse.preferences.version=1 -header_text=/*\n * VM-Operator\n * Copyright (C) 2023 Michael N. Lipp\n * \n * This program is free software\: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see .\n */ +header_text=/*\n * VM-Operator\n * Copyright (C) 2024 Michael N. Lipp\n * \n * This program is free software\: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see .\n */ project_specific_settings=true replacements=\n\n\nReturns the\nSets the\nAdds the\nEdits the\nRemoves the\nInits the\nParses the\nCreates the\nBuilds the\nChecks if is\nPrints the\nChecks for\n\n\n visibility_package=false diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index c112737..4ce4ed0 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -13,101 +13,100 @@ dependencies { implementation 'commons-cli:commons-cli:1.5.0' - implementation 'org.jgrapes:org.jgrapes.core:[1.19.0,2)' - implementation 'org.jgrapes:org.jgrapes.io:[2.7.0,3)' - implementation 'org.jgrapes:org.jgrapes.http:[3.1.0,4)' - implementation 'org.jgrapes:org.jgrapes.util:[1.31.0,2)' + implementation 'org.jgrapes:org.jgrapes.util:[1.38.1,2)' + implementation 'org.jgrapes:org.jgrapes.io:[2.12.1,3)' + implementation 'org.jgrapes:org.jgrapes.http:[3.5.0,4)' - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.2.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.3.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.0.0,2)' - implementation 'org.jgrapes:org.jgrapes.webconlet.locallogin:[1.0.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.3.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.8.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.4.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconlet.markdowndisplay:[1.2.0,2)' - runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.locallogin:[0.1.0,2)' - runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.sysinfo:[1.2.0,2)' - runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.logviewer:[0.1.0-SNAPSHOT,2)' + runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.sysinfo:[1.4.0,2)' + runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.logviewer:[0.2.0,2)' runtimeOnly 'com.electronwill.night-config:yaml:[3.6.7,3.7)' runtimeOnly 'org.eclipse.angus:angus-activation:[1.0.0,2.0.0)' runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)' runtimeOnly 'org.apache.logging.log4j:log4j-to-jul:2.20.0' - runtimeOnly project(':org.jdrupes.vmoperator.vmconlet') - - testImplementation 'io.fabric8:kubernetes-client:[6.8.1,6.9)' + runtimeOnly project(':org.jdrupes.vmoperator.vmmgmt') + runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') } application { applicationName = 'vm-manager' - applicationDefaultJvmArgs = ['-Xmx50m', '-XX:+UseParallelGC', + applicationDefaultJvmArgs = ['-Xmx128m', '-XX:+UseParallelGC', '-Djava.util.logging.manager=org.jdrupes.vmoperator.util.LongLoggingManager' ] // Define the main class for the application. mainClass = 'org.jdrupes.vmoperator.manager.Manager' } +project.ext.gitBranch = grgit.branch.current.name.replace('/', '-') +def registry = "${project.rootProject.properties['docker.registry']}" +def rootVersion = rootProject.version + task buildImage(type: Exec) { dependsOn installDist inputs.files 'src/org/jdrupes/vmoperator/manager/Containerfile' - commandLine 'podman', 'build', '-t', "${project.name}:${project.version}",\ + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}:${project.gitBranch}",\ '-f', 'src/org/jdrupes/vmoperator/manager/Containerfile', '.' } -task tagLatestImage(type: Exec) { - dependsOn buildImage - - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'tag', "${project.name}:${project.version}",\ - "${project.name}:latest" -} - -task buildLatestImage { - dependsOn buildImage - dependsOn tagLatestImage -} - task pushImage(type: Exec) { dependsOn buildImage + // Don't push without testing first + dependsOn test commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}:${project.version}" + "${project.name}:${project.gitBranch}", \ + "${registry}/${project.name}:${project.gitBranch}" } -task pushLatestImage(type: Exec) { - dependsOn buildLatestImage +task tagWithVersion(type: Exec) { + dependsOn pushImage - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ + enabled = !rootVersion.contains("SNAPSHOT") + + commandLine 'podman', 'push', \ + "${project.name}:${project.gitBranch}",\ + "${registry}/${project.name}:${project.version}" +} - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}:latest" +task tagAsLatest(type: Exec) { + dependsOn tagWithVersion + + enabled = !rootVersion.contains("SNAPSHOT") + && !rootVersion.contains("alpha") \ + && !rootVersion.contains("beta") \ + || project.rootProject.properties['docker.testRegistry'] \ + && project.rootProject.properties['docker.registry'] \ + == project.rootProject.properties['docker.testRegistry'] + + commandLine 'podman', 'push', \ + "${project.name}:${project.gitBranch}",\ + "${registry}/${project.name}:latest" +} + +task publishImage { + dependsOn pushImage + dependsOn tagWithVersion + dependsOn tagAsLatest } task pushForTest(type: Exec) { dependsOn buildImage commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ + "${project.name}:${project.gitBranch}", \ + "${project.rootProject.properties['docker.testRegistry']}" \ + "/${project.name}:test" } -task pushImages { - // Don't push without testing first - dependsOn test - dependsOn pushImage - dependsOn pushLatestImage -} - test { enabled = project.hasProperty("k8s.testCluster") diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md new file mode 100644 index 0000000..b6b9efa --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview.md @@ -0,0 +1,5 @@ +You can use the "puzzle piece" icon on the top right corner of the +page to add display widgets (conlets) to the overview tab. + +Use the "full screen" icon on the top right corner of any +conlet (if available) to get a detailed view. diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md new file mode 100644 index 0000000..bec5f3e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/ManagerIntro-Preview_de.md @@ -0,0 +1,6 @@ +Verwenden Sie das "Puzzle"-Icon auf der rechten oberen Ecke +der Seite, um Anzeige-Widgets (Conlets) hinzuzufügen. + +Wenn sich in der rechten oberen Ecke eines Conlets ein Vollbild-Icon +befindet, können Sie es verwenden, um eine Detailansicht in einem neuen +Register anzufordern. diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html index a81ee0a..9c9de88 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html @@ -1,2 +1,5 @@ ${_("consoleTitle")} \ No newline at end of file + src="${renderSupport.consoleResource('VM-Operator.svg')}" + >${_("consoleTitle")}  + (<#if clusterName()??>${clusterName() + "/"}${ namespace() }) \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html new file mode 100644 index 0000000..72596d5 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-footer.ftl.html @@ -0,0 +1,3 @@ +
+Copyright © Michael N. Lipp 2023, 2025 +
diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties index ec22a06..6bcc3a2 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties @@ -17,3 +17,4 @@ # consoleTitle = VM-Operator +introTitle = Usage \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties new file mode 100644 index 0000000..dcbba93 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n_de.properties @@ -0,0 +1,19 @@ +# +# VM-Operator +# Copyright (C) 2024 Michael N. Lipp +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +# for more details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program; if not, see . +# + +introTitle = Benutzung diff --git a/org.jdrupes.vmoperator.manager/logging.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties similarity index 87% rename from org.jdrupes.vmoperator.manager/logging.properties rename to org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties index 9e6d0f5..2a16af6 100644 --- a/org.jdrupes.vmoperator.manager/logging.properties +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties @@ -1,6 +1,6 @@ # # VM-Operator -# Copyright (C) 2023 Michael N. Lipp +# Copyright (C) 2025 Michael N. Lipp # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by @@ -19,10 +19,7 @@ handlers=java.util.logging.ConsoleHandler, \ org.jgrapes.webconlet.logviewer.LogViewerHandler -org.jgrapes.level=FINE -org.jgrapes.core.handlerTracking.level=FINER - -org.jdrupes.vmoperator.manager.level=FINE +org.jdrupes.vmoperator.level=FINE java.util.logging.ConsoleHandler.level=ALL java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml index d49e705..0200021 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml @@ -1,114 +1,138 @@ apiVersion: v1 kind: ConfigMap metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } - kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + - apiVersion: ${ cr.apiVersion() } + kind: ${ constants.Crd.KIND_VM } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } controller: false - + data: config.yaml: | "/Runner": # The directory used to store data files. Defaults to (depending on # values available): - # * $XDG_DATA_HOME/vmrunner/${ cr.metadata.name.asString } - # * $HOME/.local/share/vmrunner/${ cr.metadata.name.asString } - # * ./${ cr.metadata.name.asString } + # * $XDG_DATA_HOME/vmrunner/${ cr.name() } + # * $HOME/.local/share/vmrunner/${ cr.name() } + # * ./${ cr.name() } dataDir: /var/local/vm-data # The directory used to store runtime files. Defaults to (depending on # values available): - # * $XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString } - # * /tmp/$USER/vmrunner/${ cr.metadata.name.asString } - # * /tmp/vmrunner/${ cr.metadata.name.asString } - # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString }" + # * $XDG_RUNTIME_DIR/vmrunner/${ cr.name() } + # * /tmp/$USER/vmrunner/${ cr.name() } + # * /tmp/vmrunner/${ cr.name() } + # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.name() }" + <#assign spec = cr.spec() /> # The template to use. Resolved relative to /usr/share/vmrunner/templates. # template: "Standard-VM-latest.ftl.yaml" - <#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.source?? > - template: ${ cr.spec.runnerTemplate.source.asString } + <#if spec.runnerTemplate?? && spec.runnerTemplate.source?? > + template: ${ spec.runnerTemplate.source } # The template is copied to the data diretory when the VM starts for # the first time. Subsequent starts use the copy unless this option is set. - <#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.update?? > - updateTemplate: ${ cr.spec.runnerTemplate.update.asBoolean?c } + <#if spec.runnerTemplate?? && spec.runnerTemplate.update?? > + updateTemplate: ${ spec.runnerTemplate.update?c } - + + # Whether a shutdown initiated by the guest stops the pod deployment + guestShutdownStops: ${ (spec.guestShutdownStops!false)?c } + + # When incremented, the VM is reset. The value has no default value, + # i.e. if you start the VM without a value for this property, and + # decide to trigger a reset later, you have to first set the value + # and then inrement it. + resetCounter: ${ cr.extra().resetCount()?c } + + # Forward the cloud-init data if provided + <#if spec.cloudInit??> + cloudInit: + metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData!{}, cr.metadata())) } + <#if spec.cloudInit.userData??> + userData: ${ toJson(spec.cloudInit.userData) } + <#else> + userData: {} + + <#if spec.cloudInit.networkConfig??> + networkConfig: ${ toJson(spec.cloudInit.networkConfig) } + + + # Define the VM (required) vm: # The VM's name (required) - name: ${ cr.metadata.name.asString } + name: ${ cr.name() } # The machine's uuid. If none is specified, a uuid is generated # and stored in the data directory. If the uuid is important # (e.g. because licenses depend on it) it is recommaned to specify # it here explicitly or to carefully backup the data directory. # uuid: "generated uuid" - <#if cr.spec.vm.machineUuid??> - uuid: "${ cr.spec.vm.machineUuid.asString }" + <#if spec.vm.machineUuid??> + uuid: "${ spec.vm.machineUuid }" # Whether to provide a software TPM (defaults to false) # useTpm: false - useTpm: ${ cr.spec.vm.useTpm.asBoolean?c } + useTpm: ${ spec.vm.useTpm?c } # How to boot (see https://github.com/mnlipp/VM-Operator/blob/main/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml): # * bios # * uefi[-4m] # * secure[-4m] - firmware: ${ cr.spec.vm.firmware.asString } + firmware: ${ spec.vm.firmware } # Whether to show a boot menu. # bootMenu: false - bootMenu: ${ cr.spec.vm.bootMenu.asBoolean?c } + bootMenu: ${ spec.vm.bootMenu?c } # When terminating, a graceful powerdown is attempted. If it # doesn't succeed within the given timeout (seconds) SIGTERM # is sent to Qemu. # powerdownTimeout: 900 - powerdownTimeout: ${ cr.spec.vm.powerdownTimeout.asLong?c } + powerdownTimeout: ${ spec.vm.powerdownTimeout?c } # CPU settings - cpuModel: ${ cr.spec.vm.cpuModel.asString } + cpuModel: ${ spec.vm.cpuModel } # Setting maximumCpus to 1 omits the "-smp" options. The defaults (0) # cause the corresponding property to be omitted from the "-smp" option. # If currentCpus is greater than maximumCpus, the latter is adjusted. - <#if cr.spec.vm.maximumCpus?? > - maximumCpus: ${ parseQuantity(cr.spec.vm.maximumCpus.asString)?c } + <#if spec.vm.maximumCpus?? > + maximumCpus: ${ parseQuantity(spec.vm.maximumCpus)?c } - <#if cr.spec.vm.cpuTopology?? > - sockets: ${ cr.spec.vm.cpuTopology.sockets.asInt?c } - diesPerSocket: ${ cr.spec.vm.cpuTopology.diesPerSocket.asInt?c } - coresPerDie: ${ cr.spec.vm.cpuTopology.coresPerDie.asInt?c } - threadsPerCore: ${ cr.spec.vm.cpuTopology.threadsPerCore.asInt?c } + <#if spec.vm.cpuTopology?? > + sockets: ${ spec.vm.cpuTopology.sockets?c } + diesPerSocket: ${ spec.vm.cpuTopology.diesPerSocket?c } + coresPerDie: ${ spec.vm.cpuTopology.coresPerDie?c } + threadsPerCore: ${ spec.vm.cpuTopology.threadsPerCore?c } - <#if cr.spec.vm.currentCpus?? > - currentCpus: ${ parseQuantity(cr.spec.vm.currentCpus.asString)?c } + <#if spec.vm.currentCpus?? > + currentCpus: ${ parseQuantity(spec.vm.currentCpus)?c } # RAM settings # Maximum defaults to 1G - maximumRam: "${ formatMemory(parseQuantity(cr.spec.vm.maximumRam.asString)) }" - <#if cr.spec.vm.currentRam?? > - currentRam: "${ formatMemory(parseQuantity(cr.spec.vm.currentRam.asString)) }" + maximumRam: "${ formatMemory(parseQuantity(spec.vm.maximumRam)) }" + <#if spec.vm.currentRam?? > + currentRam: "${ formatMemory(parseQuantity(spec.vm.currentRam)) }" # RTC settings. # rtcBase: utc # rtcClock: rt - rtcBase: ${ cr.spec.vm.rtcBase.asString } - rtcClock: ${ cr.spec.vm.rtcClock.asString } + rtcBase: ${ spec.vm.rtcBase } + rtcClock: ${ spec.vm.rtcClock } # Network settings # Supported types are "tap" and "user" (for debugging). Type "user" @@ -120,19 +144,19 @@ data: # mac: (undefined) network: <#assign nwCounter = 0/> - <#list cr.spec.vm.networks.asList() as itf> + <#list spec.vm.networks as itf> <#if itf.tap??> - type: tap - device: ${ itf.tap.device.asString } - bridge: ${ itf.tap.bridge.asString } + device: ${ itf.tap.device } + bridge: ${ itf.tap.bridge } <#if itf.tap.mac??> - mac: "${ itf.tap.mac.asString }" + mac: "${ itf.tap.mac }" <#elseif itf.user??> - type: user - device: ${ itf.user.device.asString } + device: ${ itf.user.device } <#if itf.user.net??> - net: "${ itf.user.net.asString }" + net: "${ itf.user.net }" <#assign nwCounter += 1/> @@ -148,11 +172,11 @@ data: # file: (undefined) drives: <#assign drvCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> + <#list spec.vm.disks as disk> <#if disk.volumeClaimTemplate?? && disk.volumeClaimTemplate.metadata?? && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> + <#assign diskName = disk.volumeClaimTemplate.metadata.name + "-disk"> <#else> <#assign diskName = "disk-" + drvCounter> @@ -160,30 +184,36 @@ data: - type: raw resource: /dev/${ diskName } <#if disk.bootindex??> - bootindex: ${ disk.bootindex.asInt?c } + bootindex: ${ disk.bootindex?c } <#assign drvCounter = drvCounter + 1/> <#if disk.cdrom??> - type: ide-cd - file: "${ disk.cdrom.image.asString }" + file: "${ imageLocation(disk.cdrom.image) }" <#if disk.bootindex??> - bootindex: ${ disk.bootindex.asInt?c } + bootindex: ${ disk.bootindex?c } display: - <#if cr.spec.vm.display.spice??> + <#if spec.vm.display.outputs?? > + outputs: ${ spec.vm.display.outputs?c } + + <#if loginRequestedFor?? > + loggedInUser: "${ loginRequestedFor }" + + <#if spec.vm.display.spice??> spice: - port: ${ cr.spec.vm.display.spice.port.asInt?c } - <#if cr.spec.vm.display.spice.ticket??> - ticket: "${ cr.spec.vm.display.spice.ticket.asString }" + port: ${ spec.vm.display.spice.port?c } + <#if spec.vm.display.spice.ticket??> + ticket: "${ spec.vm.display.spice.ticket }" - <#if cr.spec.vm.display.spice.streamingVideo??> - ticket: "${ cr.spec.vm.display.spice.streamingVideo.asString }" + <#if spec.vm.display.spice.streamingVideo??> + streaming-video: "${ spec.vm.display.spice.streamingVideo }" - usbRedirects: ${ cr.spec.vm.display.spice.usbRedirects.asInt?c } + usbRedirects: ${ spec.vm.display.spice.usbRedirects?c } logging.properties: | diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml new file mode 100644 index 0000000..ddb638c --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDataPvc.ftl.yaml @@ -0,0 +1,18 @@ +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + namespace: ${ cr.namespace() } + name: ${ runnerDataPvcName } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + app.kubernetes.io/instance: ${ cr.name() } + app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } +spec: + accessModes: + - ReadWriteOnce + <#if reconciler.runnerDataPvc?? && reconciler.runnerDataPvc.storageClassName??> + storageClassName: ${ reconciler.runnerDataPvc.storageClassName } + + resources: + requests: + storage: 1Mi diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml new file mode 100644 index 0000000..8258d55 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerDiskPvc.ftl.yaml @@ -0,0 +1,16 @@ +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + namespace: ${ cr.namespace() } + name: ${ disk.generatedPvcName } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + app.kubernetes.io/instance: ${ cr.name() } + app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } + <#if disk.volumeClaimTemplate.metadata?? + && disk.volumeClaimTemplate.metadata.annotations??> + annotations: + ${ toJson(disk.volumeClaimTemplate.metadata.annotations) } + +spec: + ${ toJson(disk.volumeClaimTemplate.spec) } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml index 2c32aa6..b7215a5 100644 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml @@ -1,26 +1,26 @@ apiVersion: v1 kind: Service metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } + namespace: ${ cr.namespace() } + name: ${ cr.name() } labels: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } annotations: vmoperator.jdrupes.org/version: ${ managerVersion } ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } - kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } + - apiVersion: ${ cr.apiVersion() } + kind: ${ constants.Crd.KIND_VM } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } controller: false spec: type: LoadBalancer ports: - name: spice - port: ${ cr.spec.vm.display.spice.port.asInt?c } + port: ${ cr.spec().vm.display.spice.port?c } selector: app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } + app.kubernetes.io/instance: ${ cr.name() } diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml new file mode 100644 index 0000000..7518ad3 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerPod.ftl.yaml @@ -0,0 +1,135 @@ +kind: Pod +apiVersion: v1 +metadata: + namespace: ${ cr.namespace() } + name: ${ cr.name() } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + app.kubernetes.io/instance: ${ cr.name() } + app.kubernetes.io/component: ${ constants.APP_NAME } + app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } + annotations: + # Triggers update of config map mounted in pod + # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ + vmrunner.jdrupes.org/cmVersion: "${ configMapResourceVersion }" + vmoperator.jdrupes.org/version: ${ managerVersion } + ownerReferences: + - apiVersion: ${ cr.apiVersion() } + kind: ${ constants.Crd.KIND_VM } + name: ${ cr.name() } + uid: ${ cr.metadata().getUid() } + blockOwnerDeletion: true + controller: false +<#assign spec = cr.spec() /> +spec: + containers: + - name: ${ cr.name() } + <#assign image = spec.image> + <#if image.source??> + image: ${ image.source } + <#else> + image: ${ image.repository }/${ image.path }<#if image.version??>:${ image.version } + + <#if image.pullPolicy??> + imagePullPolicy: ${ image.pullPolicy } + + <#if spec.vm.display.spice??> + ports: + <#if spec.vm.display.spice??> + - name: spice + containerPort: ${ spec.vm.display.spice.port?c } + protocol: TCP + + + volumeMounts: + # Not needed because pod is priviledged: + # - mountPath: /dev/kvm + # name: dev-kvm + # - mountPath: /dev/net/tun + # name: dev-tun + # - mountPath: /sys/fs/cgroup + # name: cgroup + - name: config + mountPath: /etc/opt/vmrunner + - name: runner-data + mountPath: /var/local/vm-data + - name: vmop-image-repository + mountPath: ${ constants.IMAGE_REPO_PATH } + volumeDevices: + <#list spec.vm.disks as disk> + <#if disk.volumeClaimTemplate??> + - name: ${ disk.generatedDiskName } + devicePath: /dev/${ disk.generatedDiskName } + + + securityContext: + privileged: true + <#if spec.resources??> + resources: ${ toJson(spec.resources) } + <#else> + <#if spec.vm.currentCpus?? || spec.vm.currentRam?? > + resources: + requests: + <#if spec.vm.currentCpus?? > + <#assign factor = 2.0 /> + <#if reconciler.cpuOvercommit??> + <#assign factor = reconciler.cpuOvercommit * 1.0 /> + + cpu: ${ (parseQuantity(spec.vm.currentCpus) / factor)?c } + + <#if spec.vm.currentRam?? > + <#assign factor = 1.25 /> + <#if reconciler.ramOvercommit??> + <#assign factor = reconciler.ramOvercommit * 1.0 /> + + memory: ${ (parseQuantity(spec.vm.currentRam) / factor)?floor?c } + + + + volumes: + # Not needed because pod is priviledged: + # - name: dev-kvm + # hostPath: + # path: /dev/kvm + # type: CharDevice + # - hostPath: + # path: /dev/net/tun + # type: CharDevice + # name: dev-tun + # - name: cgroup + # hostPath: + # path: /sys/fs/cgroup + - name: config + projected: + sources: + - configMap: + name: ${ cr.name() } + <#if displaySecret??> + - secret: + name: ${ displaySecret } + + - name: vmop-image-repository + persistentVolumeClaim: + claimName: vmop-image-repository + - name: runner-data + persistentVolumeClaim: + claimName: ${ runnerDataPvcName } + <#list spec.vm.disks as disk> + <#if disk.volumeClaimTemplate??> + - name: ${ disk.generatedDiskName } + persistentVolumeClaim: + claimName: ${ disk.generatedPvcName } + + + hostNetwork: true + terminationGracePeriodSeconds: ${ (spec.vm.powerdownTimeout + 5)?c } + <#if spec.nodeName??> + nodeName: ${ spec.nodeName } + + <#if spec.nodeSelector??> + nodeSelector: ${ toJson(spec.nodeSelector) } + + <#if spec.affinity??> + affinity: ${ toJson(spec.affinity) } + + serviceAccountName: vm-runner diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml deleted file mode 100644 index ac1178a..0000000 --- a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerSts.ftl.yaml +++ /dev/null @@ -1,186 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - annotations: - vmoperator.jdrupes.org/version: ${ managerVersion } - ownerReferences: - - apiVersion: ${ cr.apiVersion.asString } - kind: ${ constants.VM_OP_KIND_VM } - name: ${ cr.metadata.name.asString } - uid: ${ cr.metadata.uid.asString } - blockOwnerDeletion: true - controller: false - -spec: - selector: - matchLabels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - replicas: ${ (cr.spec.vm.state.asString == "Running")?then(1, 0) } - template: - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ cr.metadata.name.asString } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/component: ${ constants.APP_NAME } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - annotations: - # Triggers update of config map mounted in pod - # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ - vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion.asString }" - vmoperator.jdrupes.org/version: ${ managerVersion } - spec: - containers: - - name: ${ cr.metadata.name.asString } - <#assign image = cr.spec.image> - <#if image.source??> - image: ${ image.source.asString } - <#else> - image: ${ image.repository.asString }/${ image.path.asString }<#if image.version??>:${ image.version.asString } - - <#if image.pullPolicy??> - imagePullPolicy: ${ image.pullPolicy.asString } - - <#if cr.spec.vm.display.spice??> - ports: - <#if cr.spec.vm.display.spice??> - - name: spice - containerPort: ${ cr.spec.vm.display.spice.port.asInt?c } - protocol: TCP - - - volumeMounts: - # Not needed because pod is priviledged: - # - mountPath: /dev/kvm - # name: dev-kvm - # - mountPath: /dev/net/tun - # name: dev-tun - # - mountPath: /sys/fs/cgroup - # name: cgroup - - name: config - mountPath: /etc/opt/vmrunner - - name: runner-data - mountPath: /var/local/vm-data - - name: vmop-image-repository - mountPath: ${ constants.IMAGE_REPO_PATH } - volumeDevices: - <#assign diskCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> - <#if disk.volumeClaimTemplate??> - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> - <#else> - <#assign diskName = "disk-" + diskCounter> - - - name: ${ diskName } - devicePath: /dev/${ diskName } - <#assign diskCounter = diskCounter + 1/> - - - securityContext: - privileged: true - <#if cr.spec.resources??> - resources: ${ cr.spec.resources.toString() } - <#else> - <#if cr.spec.vm.currentCpus?? || cr.spec.vm.currentRam?? > - resources: - requests: - <#if cr.spec.vm.currentCpus?? > - <#assign factor = 2.0 /> - <#if reconciler.cpuOvercommit??> - <#assign factor = reconciler.cpuOvercommit * 1.0 /> - - cpu: ${ (parseQuantity(cr.spec.vm.currentCpus.asString) / factor)?c } - - <#if cr.spec.vm.currentRam?? > - <#assign factor = 1.25 /> - <#if reconciler.ramOvercommit??> - <#assign factor = reconciler.ramOvercommit * 1.0 /> - - memory: ${ (parseQuantity(cr.spec.vm.currentRam.asString) / factor)?floor?c } - - - - volumes: - # Not needed because pod is priviledged: - # - name: dev-kvm - # hostPath: - # path: /dev/kvm - # type: CharDevice - # - hostPath: - # path: /dev/net/tun - # type: CharDevice - # name: dev-tun - # - name: cgroup - # hostPath: - # path: /sys/fs/cgroup - - name: config - configMap: - name: ${ cr.metadata.name.asString } - - name: vmop-image-repository - persistentVolumeClaim: - claimName: vmop-image-repository - hostNetwork: true - terminationGracePeriodSeconds: ${ (cr.spec.vm.powerdownTimeout.asInt + 5)?c } - <#if cr.spec.nodeName??> - nodeName: ${ cr.spec.nodeName.asString } - - <#if cr.spec.nodeSelector??> - nodeSelector: ${ cr.spec.nodeSelector.toString() } - - <#if cr.spec.affinity??> - affinity: ${ cr.spec.affinity.toString() } - - serviceAccountName: vm-runner - volumeClaimTemplates: - - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: runner-data - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - spec: - accessModes: - - ReadWriteOnce - <#if reconciler.runnerDataPvc?? && reconciler.runnerDataPvc.storageClassName??> - storageClassName: ${ reconciler.runnerDataPvc.storageClassName } - - resources: - requests: - storage: 1Mi - <#assign diskCounter = 0/> - <#list cr.spec.vm.disks.asList() as disk> - <#if disk.volumeClaimTemplate??> - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.name??> - <#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk"> - <#else> - <#assign diskName = "disk-" + diskCounter> - - - metadata: - namespace: ${ cr.metadata.namespace.asString } - name: ${ diskName } - labels: - app.kubernetes.io/name: ${ constants.APP_NAME } - app.kubernetes.io/instance: ${ cr.metadata.name.asString } - app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } - <#if disk.volumeClaimTemplate.metadata?? - && disk.volumeClaimTemplate.metadata.annotations??> - annotations: - ${ disk.volumeClaimTemplate.metadata.annotations.toString() } - - spec: - ${ disk.volumeClaimTemplate.spec.toString() } - <#assign diskCounter = diskCounter + 1/> - - diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java new file mode 100644 index 0000000..c10752e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AbstractMonitor.java @@ -0,0 +1,251 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.common.KubernetesListObject; +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.manager.events.Exit; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.Components; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.util.events.ConfigurationUpdate; + +/** + * A base class for monitoring VM related resources. When started, + * it creates observers for all versions of the the {@link APIResource} + * configured by {@link #context(APIResource)}. The APIResource is not + * passed to the constructor because in some cases it has to be + * evaluated lazily. + * + * @param the object type for the context + * @param the object list type for the context + */ +public abstract class AbstractMonitor extends Component { + + private final Class objectClass; + private final Class objectListClass; + private K8sClient client; + private APIResource context; + private String namespace; + private ListOptions options = new ListOptions(); + private final AtomicInteger observerCounter = new AtomicInteger(0); + + /** + * Initializes the instance. + * + * @param componentChannel the component channel + * @param objectClass the class of the Kubernetes object to watch + * @param objectListClass the class of the list of Kubernetes objects + * to watch + */ + protected AbstractMonitor(Channel componentChannel, + Class objectClass, Class objectListClass) { + super(componentChannel); + this.objectClass = objectClass; + this.objectListClass = objectListClass; + } + + /** + * Return the client. + * + * @return the client + */ + public K8sClient client() { + return client; + } + + /** + * Sets the client to be used. + * + * @param client the client + * @return the abstract monitor + */ + public AbstractMonitor client(K8sClient client) { + this.client = client; + return this; + } + + /** + * Return the observed namespace. + * + * @return the namespace + */ + public String namespace() { + return namespace; + } + + /** + * Sets the namespace to be observed. + * + * @param namespace the namespaceToWatch to set + * @return the abstract monitor + */ + public AbstractMonitor namespace(String namespace) { + this.namespace = namespace; + return this; + } + + /** + * Returns the options for selecting the objects to observe. + * + * @return the options + */ + public ListOptions options() { + return options; + } + + /** + * Sets the options for selecting the objects to observe. + * + * @param options the options to set + * @return the abstract monitor + */ + public AbstractMonitor options(ListOptions options) { + this.options = options; + return this; + } + + /** + * Returns the observed context. + * + * @return the context + */ + public APIResource context() { + return context; + } + + /** + * Sets the context to observe. + * + * @param context the context + * @return the abstract monitor + */ + public AbstractMonitor context(APIResource context) { + this.context = context; + return this; + } + + /** + * Looks for a key "namespace" in the configuration and, if found, + * sets the namespace to its value. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(Components.manager(parent()).componentPath()) + .ifPresent(c -> { + if (c.containsKey("namespace")) { + namespace = (String) c.get("namespace"); + } + }); + } + + /** + * Handle the start event. Configures the namespace, invokes + * {@link #prepareMonitoring()} and starts the observers. + * + * @param event the event + */ + @Handler(priority = 10) + public void onStart(Start event) { + try { + // Get namespace + if (namespace == null) { + var path = Path + .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); + if (Files.isReadable(path)) { + namespace + = Files.lines(path).findFirst().orElse(null); + } + } + + // Additional preparations by derived class + prepareMonitoring(); + assert client != null; + assert context != null; + assert namespace != null; + + // Monitor all versions + for (var version : context.getVersions()) { + createObserver(version); + } + registerAsGenerator(); + } catch (IOException | ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot watch VMs, terminating."); + event.cancel(true); + fire(new Exit(1)); + } + } + + private void createObserver(String version) { + observerCounter.incrementAndGet(); + new K8sObserver<>(objectClass, objectListClass, client, + K8s.preferred(context, version), namespace, options) + .handler(this::handleChange).onTerminated((o, t) -> { + if (observerCounter.decrementAndGet() == 0) { + unregisterAsGenerator(); + } + // Exception has been logged already + if (t != null) { + fire(new Stop()); + } + }).start(); + } + + /** + * Invoked by {@link #onStart(Start)} after the namespace has + * been configured and before starting the observer. This is + * the last opportunity to invoke {@link #context(APIResource)}. + * + * @throws IOException Signals that an I/O exception has occurred. + * @throws ApiException the api exception + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void prepareMonitoring() throws IOException, ApiException { + // To be overridden by derived class. + } + + /** + * Handle an observed change. The method is invoked by the observer + * thread(s). It is the responsibility of the implementing class to + * fire derived events on the appropriate event pipeline. + * + * @param client the client + * @param change the change + */ + protected abstract void handleChange(K8sClient client, Response change); +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java new file mode 100644 index 0000000..912b623 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/AvoidEmptyPolicy.java @@ -0,0 +1,127 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Collections; +import java.util.ResourceBundle; +import java.util.stream.Collectors; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.webconlet.markdowndisplay.MarkdownDisplayConlet; +import org.jgrapes.webconsole.base.Conlet.RenderMode; +import org.jgrapes.webconsole.base.ConsoleConnection; +import org.jgrapes.webconsole.base.events.AddConletRequest; +import org.jgrapes.webconsole.base.events.ConsoleConfigured; +import org.jgrapes.webconsole.base.events.ConsoleReady; +import org.jgrapes.webconsole.base.events.RenderConlet; + +/** + * + */ +public class AvoidEmptyPolicy extends Component { + + private final String renderedFlagName = getClass().getName() + ".rendered"; + + /** + * Creates a new component with its channel set to the given channel. + * + * @param componentChannel + */ + public AvoidEmptyPolicy(Channel componentChannel) { + super(componentChannel); + } + + /** + * On console ready. + * + * @param event the event + * @param connection the connection + */ + @Handler + public void onConsoleReady(ConsoleReady event, + ConsoleConnection connection) { + connection.session().put(renderedFlagName, false); + } + + /** + * On render conlet. + * + * @param event the event + * @param connection the connection + */ + @Handler(priority = 100) + public void onRenderConlet(RenderConlet event, + ConsoleConnection connection) { + if (event.renderAs().contains(RenderMode.Preview) + || event.renderAs().contains(RenderMode.View)) { + connection.session().put(renderedFlagName, true); + } + } + + /** + * On console configured. + * + * @param event the event + * @param connection the console connection + * @throws InterruptedException the interrupted exception + */ + @Handler(priority = -100) + public void onConsoleConfigured(ConsoleConfigured event, + ConsoleConnection connection) throws InterruptedException, + IOException { + if ((Boolean) connection.session().getOrDefault(renderedFlagName, + false)) { + return; + } + var resourceBundle = ResourceBundle.getBundle( + getClass().getPackage().getName() + ".l10n", connection.locale(), + getClass().getClassLoader(), + ResourceBundle.Control.getNoFallbackControl( + ResourceBundle.Control.FORMAT_DEFAULT)); + var locale = resourceBundle.getLocale().toString(); + String shortDesc; + try (BufferedReader shortDescReader + = new BufferedReader(new InputStreamReader( + AvoidEmptyPolicy.class.getResourceAsStream( + "ManagerIntro-Preview" + (locale.isEmpty() ? "" + : "_" + locale) + ".md"), + "utf-8"))) { + shortDesc + = shortDescReader.lines().collect(Collectors.joining("\n")); + } + fire(new AddConletRequest(event.event().event().renderSupport(), + MarkdownDisplayConlet.class.getName(), + RenderMode.asSet(RenderMode.Preview)) + .addProperty(MarkdownDisplayConlet.CONLET_ID, + getClass().getName()) + .addProperty(MarkdownDisplayConlet.TITLE, + resourceBundle.getString("consoleTitle")) + .addProperty(MarkdownDisplayConlet.PREVIEW_SOURCE, + shortDesc) + .addProperty(MarkdownDisplayConlet.DELETABLE, true) + .addProperty(MarkdownDisplayConlet.EDITABLE_BY, + Collections.EMPTY_SET), + connection); + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java index 0fbb3a7..0ca6312 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java @@ -18,11 +18,18 @@ package org.jdrupes.vmoperator.manager; +import com.google.gson.JsonObject; +import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; import freemarker.template.TemplateException; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.utility.DeepUnwrap; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; @@ -30,13 +37,18 @@ import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.io.StringWriter; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.logging.Logger; import org.jdrupes.vmoperator.common.K8s; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.util.DataPath; +import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -44,7 +56,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Delegee for reconciling the config map */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") /* default */ class ConfigMapReconciler { protected final Logger logger = Logger.getLogger(getClass().getName()); @@ -62,34 +73,72 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile. * - * @param event the event * @param model the model * @param channel the channel - * @return the dynamic kubernetes object + * @param modelChanged the model has changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception - * @throws ApiException the api exception + * @throws ApiException the API exception */ - public DynamicKubernetesObject reconcile(VmDefChanged event, - Map model, VmChannel channel) + public void reconcile(Map model, VmChannel channel, + boolean modelChanged) throws IOException, TemplateException, ApiException { - // Get API - DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", - "configmaps", channel.client()); + // Check if an update is needed + var prevData = channel.associated(PrevData.class) + .orElseGet(() -> new PrevData(null, new HashMap<>())); + Object newInputs = model.get("loginRequestedFor"); + if (!modelChanged && Objects.equals(prevData.inputs, newInputs)) { + // Make added data available in new model + model.putAll(prevData.added); + return; + } + prevData = new PrevData(newInputs, prevData.added); + channel.setAssociated(PrevData.class, prevData); // Combine template and data and parse result + logger.fine(() -> "Create/update configmap " + + DataPath. get(model, "cr", "name").orElse("unknown")); + model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); + prevData.added.put("adjustCloudInitMeta", adjustCloudInitMetaModel); var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); // Avoid Yaml.load due to // https://github.com/kubernetes-client/java/issues/2741 - var mapDef = Dynamics.newFromYaml( + var newCm = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + // Maybe override logging.properties from reconciler configuration. + DataPath. get(model, "reconciler", "loggingProperties") + .ifPresent(props -> { + GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") + .get().addProperty("logging.properties", props); + }); + + // Maybe override logging.properties from VM definition. + DataPath. get(model, "cr", "spec", "loggingProperties") + .ifPresent(props -> { + GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") + .get().addProperty("logging.properties", props); + }); + + // Get API and update + DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", + "configmaps", channel.client()); + // Apply and maybe force pod update - var newState = K8s.apply(cmApi, mapDef, out.toString()); - maybeForceUpdate(channel.client(), newState); - return newState; + var updatedCm = K8s.apply(cmApi, newCm, newCm.getRaw().toString()); + maybeForceUpdate(channel.client(), updatedCm); + model.put("configMapResourceVersion", + updatedCm.getMetadata().getResourceVersion()); + prevData.added.put("configMapResourceVersion", + updatedCm.getMetadata().getResourceVersion()); + } + + /** + * Key for association. + */ + private record PrevData(Object inputs, Map added) { } /** @@ -104,14 +153,16 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector( "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," - + "app.kubernetes.io/name=" + APP_NAME); + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + newCm.getMetadata() + .getLabels().get("app.kubernetes.io/instance")); // Get pod, selected by label var podApi = new DynamicKubernetesApi("", "v1", "pods", client); var pods = podApi .list(newCm.getMetadata().getNamespace(), listOpts).getObject(); // If the VM is being created, the pod may not exist yet. - if (pods == null || pods.getItems().size() == 0) { + if (pods == null || pods.getItems().isEmpty()) { return; } var pod = pods.getItems().get(0); @@ -133,4 +184,27 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; } } + private final TemplateMethodModelEx adjustCloudInitMetaModel + = new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + @SuppressWarnings("unchecked") + var res = new HashMap<>((Map) DeepUnwrap + .unwrap((TemplateModel) arguments.get(0))); + var metadata + = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) + .getAdaptedObject(Object.class); + if (!res.containsKey("instance-id")) { + res.put("instance-id", + Optional.ofNullable(metadata.getGeneration()) + .map(s -> "v" + s).orElse("v1")); + } + if (!res.containsKey("local-hostname")) { + res.put("local-hostname", metadata.getName()); + } + return res; + } + }; + } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java index bf51a59..2ef4199 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java @@ -23,9 +23,6 @@ package org.jdrupes.vmoperator.manager; */ public class Constants extends org.jdrupes.vmoperator.common.Constants { - /** The Constant APP_NAME. */ - public static final String APP_NAME = "vm-runner"; - /** The Constant STATE_RUNNING. */ public static final String STATE_RUNNING = "Running"; diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile index c212945..08c4bff 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile @@ -1,4 +1,4 @@ -FROM docker.io/eclipse-temurin:17-jre-alpine +FROM docker.io/eclipse-temurin:21-jre-alpine COPY build/install/vm-manager /opt/vmmanager diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java index 10bb1b2..ce14488 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023, 2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,27 +18,42 @@ package org.jdrupes.vmoperator.manager; -import io.kubernetes.client.custom.V1Patch; +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.Configuration; -import io.kubernetes.client.util.Config; -import io.kubernetes.client.util.generic.options.PatchOptions; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; +import java.util.Comparator; +import java.util.Optional; import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; -import org.jdrupes.vmoperator.common.K8s; -import org.jdrupes.vmoperator.manager.events.StartVm; -import org.jdrupes.vmoperator.manager.events.StopVm; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.AssignVm; +import org.jdrupes.vmoperator.manager.events.ChannelManager; +import org.jdrupes.vmoperator.manager.events.Exit; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.GetVms; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; +import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PodChanged; +import org.jdrupes.vmoperator.manager.events.UpdateAssignment; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; +import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; -import org.jgrapes.core.events.Stop; import org.jgrapes.util.events.ConfigurationUpdate; /** @@ -46,8 +61,8 @@ import org.jgrapes.util.events.ConfigurationUpdate; * [Operator Whitepaper](https://github.com/cncf/tag-app-delivery/blob/eece8f7307f2970f46f100f51932db106db46968/operator-wg/whitepaper/Operator-WhitePaper_v1-0.md#operator-components-in-kubernetes). * * The implementation splits the controller in two components. The - * {@link VmWatcher} and the {@link Reconciler}. The former watches - * the VM definitions (CRs) and generates {@link VmDefChanged} events + * {@link VmMonitor} and the {@link Reconciler}. The former watches + * the VM definitions (CRs) and generates {@link VmResourceChanged} events * when they change. The latter handles the changes and reconciles the * resources in the cluster. * @@ -80,15 +95,33 @@ import org.jgrapes.util.events.ConfigurationUpdate; public class Controller extends Component { private String namespace; + private final ChannelManager chanMgr; /** * Creates a new instance. */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public Controller(Channel componentChannel) { super(componentChannel); // Prepare component tree - attach(new VmWatcher(channel())); + chanMgr = new ChannelManager<>(name -> { + try { + return new VmChannel(channel(), newEventPipeline(), + new K8sClient()); + } catch (IOException e) { + logger.log(Level.SEVERE, e, () -> "Failed to create client" + + " for handling changes: " + e.getMessage()); + return null; + } + }); + attach(new VmMonitor(channel(), chanMgr)); + attach(new DisplaySecretMonitor(channel(), chanMgr)); + // Currently, we don't use the IP assigned by the load balancer + // to access the VM's console. Might change in the future. + // attach(new ServiceMonitor(channel()).channelManager(chanMgr)); attach(new Reconciler(channel())); + attach(new PoolMonitor(channel())); + attach(new PodMonitor(channel(), chanMgr)); } /** @@ -139,66 +172,157 @@ public class Controller extends Component { .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); if (Files.isReadable(path)) { namespace = Files.lines(path).findFirst().orElse(null); + fire(new ConfigurationUpdate().add(componentPath(), "namespace", + namespace)); } } if (namespace == null) { logger.severe(() -> "Namespace to control not configured and" + " no file in kubernetes directory."); event.cancel(true); - fire(new Stop()); + fire(new Exit(2)); return; } - logger.fine(() -> "Controlling namespace \"" + namespace + "\"."); + logger.config(() -> "Controlling namespace \"" + namespace + "\"."); } /** - * On start vm. + * Returns the VM data. * * @param event the event - * @throws ApiException the api exception - * @throws IOException Signals that an I/O exception has occurred. */ @Handler - public void onStartVm(StartVm event) throws ApiException, IOException { - patchRunning(event.name(), true); + public void onGetVms(GetVms event) { + event.setResult(chanMgr.channels().stream() + .filter(c -> event.name().isEmpty() + || c.vmDefinition().name().equals(event.name().get())) + .filter(c -> event.user().isEmpty() && event.roles().isEmpty() + || !c.vmDefinition().permissionsFor(event.user().orElse(null), + event.roles()).isEmpty()) + .filter(c -> event.fromPool().isEmpty() + || c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool().get())).orElse(false)) + .filter(c -> event.toUser().isEmpty() + || c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser().get())).orElse(false)) + .map(c -> new VmData(c.vmDefinition(), c)) + .toList()); } /** - * On stop vm. + * Assign a VM if not already assigned. * * @param event the event * @throws ApiException the api exception - * @throws IOException Signals that an I/O exception has occurred. + * @throws InterruptedException */ @Handler - public void onStopVm(StopVm event) throws ApiException, IOException { - patchRunning(event.name(), false); + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onAssignVm(AssignVm event) + throws ApiException, InterruptedException { + while (true) { + // Search for existing assignment. + var vmQuery = chanMgr.channels().stream() + .filter(c -> c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool())).orElse(false)) + .filter(c -> c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser())).orElse(false)) + .findFirst(); + if (vmQuery.isPresent()) { + var vmDef = vmQuery.get().vmDefinition(); + event.setResult(new VmData(vmDef, vmQuery.get())); + return; + } + + // Get the pool definition for checking possible assignment + VmPool vmPool = newEventPipeline().fire(new GetPools() + .withName(event.fromPool())).get().stream().findFirst() + .orElse(null); + if (vmPool == null) { + return; + } + + // Find available VM. + vmQuery = chanMgr.channels().stream() + .filter(c -> vmPool.isAssignable(c.vmDefinition())) + .sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition() + .assignment().map(Assignment::lastUsed) + .orElse(Instant.ofEpochSecond(0))) + .thenComparing(preferRunning)) + .findFirst(); + + // None found + if (vmQuery.isEmpty()) { + return; + } + + // Assign to user + var chosenVm = vmQuery.get(); + if (Optional.ofNullable(chosenVm.fire(new UpdateAssignment( + vmPool, event.toUser())).get()).orElse(false)) { + var vmDef = chosenVm.vmDefinition(); + event.setResult(new VmData(vmDef, chosenVm)); + + // Make sure that a newly assigned VM is running. + chosenVm.fire(new ModifyVm(vmDef.name(), "state", "Running")); + return; + } + } } - private void patchRunning(String name, boolean running) - throws ApiException, IOException { - var crApi = K8s.crApi(Config.defaultClient(), VM_OP_GROUP, - VM_OP_KIND_VM, namespace, name); - if (crApi.isEmpty()) { - logger.warning(() -> "Trying to patch " + namespace + "/" + name - + " which does not exist."); + private static Comparator preferRunning + = new Comparator<>() { + @Override + public int compare(VmChannel ch1, VmChannel ch2) { + if (ch1.vmDefinition().conditionStatus("Running").orElse(false) + && !ch2.vmDefinition().conditionStatus("Running") + .orElse(false)) { + return -1; + } + return 0; + } + }; + + /** + * When s pool is deleted, remove all related assignments. + * + * @param event the event + * @throws InterruptedException + */ + @Handler + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onPoolChanged(VmPoolChanged event) throws InterruptedException { + if (!event.deleted()) { return; } - - // Patch running - PatchOptions patchOpts = new PatchOptions(); - patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); - var res = crApi.get().patch(namespace, name, - V1Patch.PATCH_FORMAT_JSON_PATCH, - new V1Patch("[{\"op\": \"replace\", \"path\": " - + "\"/spec/vm/state\", " - + "\"value\": \"" + (running ? "Running" : "Stopped") - + "\"}]"), - patchOpts); - if (!res.isSuccess()) { - logger.warning( - () -> "Cannot patch pod annotations: " + res.getStatus()); + var vms = newEventPipeline() + .fire(new GetVms().assignedFrom(event.vmPool().name())).get(); + for (var vm : vms) { + vm.channel().fire(new UpdateAssignment(event.vmPool(), null)); } + } + /** + * Remove runner version from status when pod is deleted + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onPodChange(PodChanged event, VmChannel channel) + throws ApiException { + if (event.type() == ResponseType.DELETED) { + // Remove runner info from status + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.RUNNER_VERSION); + return status; + }); + } } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java new file mode 100644 index 0000000..b094b79 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretMonitor.java @@ -0,0 +1,121 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1SecretList; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.IOException; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.manager.events.ChannelDictionary; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jgrapes.core.Channel; + +/** + * Watches for changes of display secrets. Updates an artifical attribute + * of the pod running the VM in response to force an update of the files + * in the pod that reflect the information from the secret. + */ +public class DisplaySecretMonitor + extends AbstractMonitor { + + private final ChannelDictionary channelDictionary; + + /** + * Instantiates a new display secrets monitor. + * + * @param componentChannel the component channel + * @param channelDictionary the channel dictionary + */ + public DisplaySecretMonitor(Channel componentChannel, + ChannelDictionary channelDictionary) { + super(componentChannel, V1Secret.class, V1SecretList.class); + this.channelDictionary = channelDictionary; + context(K8sV1SecretStub.CONTEXT); + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME); + options(options); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + } + + @Override + protected void handleChange(K8sClient client, Response change) { + String vmName = change.object.getMetadata().getLabels() + .get("app.kubernetes.io/instance"); + if (vmName == null) { + return; + } + var channel = channelDictionary.channel(vmName).orElse(null); + if (channel == null || channel.vmDefinition() == null) { + return; + } + + try { + patchPod(client, change); + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Cannot patch pod annotations: " + e.getMessage()); + } + } + + private void patchPod(K8sClient client, Response change) + throws ApiException { + // Force update for pod + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + change.object.getMetadata() + .getLabels().get("app.kubernetes.io/instance")); + // Get pod, selected by label + var pods = K8sV1PodStub.list(client, namespace(), listOpts); + + // If the VM is being created, the pod may not exist yet. + if (pods.isEmpty()) { + return; + } + var pod = pods.iterator().next(); + + // Patch pod annotation + PatchOptions patchOpts = new PatchOptions(); + patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); + pod.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": " + + "\"/metadata/annotations/vmrunner.jdrupes.org~1dpVersion\", " + + "\"value\": \"" + + change.object.getMetadata().getResourceVersion() + + "\"}]"), + patchOpts); + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java new file mode 100644 index 0000000..1e3eb0f --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/DisplaySecretReconciler.java @@ -0,0 +1,342 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import com.google.gson.JsonObject; +import freemarker.template.TemplateException; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Scanner; +import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jdrupes.vmoperator.util.DataPath; +import org.jgrapes.core.Channel; +import org.jgrapes.core.CompletionLock; +import org.jgrapes.core.Component; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jose4j.base64url.Base64; + +/** + * The properties of the display secret do not only depend on the + * VM definition, but also on events that occur during runtime. + * The reconciler for the display secret is therefore a separate + * component. + * + * The reconciler supports the following configuration properties: + * + * * `passwordValidity`: the validity of the random password in seconds. + * Used to calculate the password expiry time in the generated secret. + */ +public class DisplaySecretReconciler extends Component { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + private int passwordValidity = 10; + private final List pendingPrepares + = Collections.synchronizedList(new LinkedList<>()); + + /** + * Instantiates a new display secret reconciler. + * + * @param componentChannel the component channel + */ + public DisplaySecretReconciler(Channel componentChannel) { + super(componentChannel); + } + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()) + // for backward compatibility + .or(() -> { + var oldConfig = event + .structured("/Manager/Controller/DisplaySecretMonitor"); + if (oldConfig.isPresent()) { + logger.warning(() -> "Using configuration with old " + + "path '/Manager/Controller/DisplaySecretMonitor' " + + "for `passwordValidity`, please update " + + "the configuration."); + } + return oldConfig; + }).ifPresent(c -> { + try { + Optional.ofNullable(c.get("passwordValidity")) + .map(p -> p instanceof Integer ? (Integer) p + : Integer.valueOf((String) p)) + .ifPresent(p -> { + passwordValidity = p; + }); + } catch (NumberFormatException e) { + logger.warning( + () -> "Malformed configuration: " + e.getMessage()); + } + }); + } + + /** + * Reconcile. If the configuration prevents generating a secret + * or the secret already exists, do nothing. Else generate a new + * secret with a random password and immediate expiration, thus + * preventing access to the display. + * + * @param vmDef the VM definition + * @param model the model + * @param channel the channel + * @param specChanged the spec changed + * @throws IOException Signals that an I/O exception has occurred. + * @throws TemplateException the template exception + * @throws ApiException the api exception + */ + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) + throws IOException, TemplateException, ApiException { + // Nothing to do unless spec changed + if (!specChanged) { + return; + } + + // Secret needed at all? + var display = vmDef.fromVm("display").get(); + if (!DataPath. get(display, "spice", "generateSecret") + .orElse(true)) { + return; + } + + // Check if exists + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); + if (!stubs.isEmpty()) { + return; + } + + // Create secret + var secretName = vmDef.name() + "-" + DisplaySecret.NAME; + logger.fine(() -> "Create/update secret " + secretName); + var secret = new V1Secret(); + secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) + .name(secretName) + .putLabelsItem("app.kubernetes.io/name", APP_NAME) + .putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) + .putLabelsItem("app.kubernetes.io/instance", vmDef.name())); + secret.setType("Opaque"); + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, + DisplaySecret.EXPIRY, "now")); + K8sV1SecretStub.create(channel.client(), secret); + } + + /** + * Prepares access to the console for the user from the event. + * Generates a new password and sends it to the runner. + * Requests the VM (via the runner) to login the user if specified + * in the event. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onGetDisplaySecret(GetDisplaySecret event, VmChannel channel) + throws ApiException { + // Get VM definition and check if running + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + event.vmDefinition().namespace(), event.vmDefinition().name()); + var vmDef = vmStub.model().orElse(null); + if (vmDef == null || !vmDef.conditionStatus("Running").orElse(false)) { + return; + } + + // Update console user in status + vmDef = vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.CONSOLE_USER, event.user()); + return status; + }).get(); + + // Get secret and update password in secret + var stub = getSecretStub(event, channel, vmDef); + if (stub == null) { + return; + } + var secret = stub.model().get(); + if (!updatePassword(secret, event)) { + return; + } + + // Register wait for confirmation (by VM status change, + // after secret update) + var pending = new PendingRequest(event, + event.vmDefinition().displayPasswordSerial().orElse(0L) + 1, + new CompletionLock(event, 1500)); + pendingPrepares.add(pending); + Event.onCompletion(event, e -> { + pendingPrepares.remove(pending); + }); + + // Update, will (eventually) trigger confirmation + stub.update(secret).getObject(); + } + + private K8sV1SecretStub getSecretStub(GetDisplaySecret event, + VmChannel channel, VmDefinition vmDef) throws ApiException { + // Look for secret + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), + options); + if (stubs.isEmpty()) { + // No secret means no password for this VM wanted + event.setResult(null); + return null; + } + return stubs.iterator().next(); + } + + private boolean updatePassword(V1Secret secret, GetDisplaySecret event) { + var expiry = Optional.ofNullable(secret.getData() + .get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null); + if (secret.getData().get(DisplaySecret.PASSWORD) != null + && stillValid(expiry)) { + // Fixed secret, don't touch + event.setResult( + new String(secret.getData().get(DisplaySecret.PASSWORD))); + return false; + } + + // Generate password and set expiry + SecureRandom random = null; + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { // NOPMD + // "Every implementation of the Java platform is required + // to support at least one strong SecureRandom implementation." + } + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + var password = Base64.encode(bytes); + secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, + DisplaySecret.EXPIRY, + Long.toString(Instant.now().getEpochSecond() + passwordValidity))); + event.setResult(password); + return true; + } + + private boolean stillValid(String expiry) { + if (expiry == null || "never".equals(expiry)) { + return true; + } + @SuppressWarnings({ "PMD.CloseResource", "resource" }) + var scanner = new Scanner(expiry); + if (!scanner.hasNextLong()) { + return false; + } + long expTime = scanner.nextLong(); + return expTime > Instant.now().getEpochSecond() + passwordValidity; + } + + /** + * On vm def changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onVmResourceChanged(VmResourceChanged event, Channel channel) { + synchronized (pendingPrepares) { + String vmName = event.vmDefinition().name(); + for (var pending : pendingPrepares) { + if (pending.event.vmDefinition().name().equals(vmName) + && event.vmDefinition().displayPasswordSerial() + .map(s -> s >= pending.expectedSerial).orElse(false)) { + pending.lock.remove(); + // pending will be removed from pendingGest by + // waiting thread, see updatePassword + continue; + } + } + } + } + + /** + * The Class PendingGet. + */ + private static class PendingRequest { + public final GetDisplaySecret event; + public final long expectedSerial; + public final CompletionLock lock; + + /** + * Instantiates a new pending get. + * + * @param event the event + * @param expectedSerial the expected serial + */ + public PendingRequest(GetDisplaySecret event, long expectedSerial, + CompletionLock lock) { + super(); + this.event = event; + this.expectedSerial = expectedSerial; + this.lock = lock; + } + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java index efa95f4..a66b432 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java @@ -18,24 +18,25 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonObject; +import com.google.gson.Gson; import freemarker.template.Configuration; import freemarker.template.TemplateException; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.models.V1APIService; import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.Dynamics; import java.io.IOException; import java.io.StringWriter; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.logging.Logger; -import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sV1ServiceStub; +import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.GsonPtr; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; @@ -44,7 +45,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Delegee for reconciling the service */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") /* default */ class LoadBalancerReconciler { private static final String LOAD_BALANCER_SERVICE = "loadBalancerService"; @@ -68,32 +68,47 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Reconcile. * - * @param event the event + * @param vmDef the VM definition * @param model the model * @param channel the channel + * @param specChanged the spec changed * @throws IOException Signals that an I/O exception has occurred. * @throws TemplateException the template exception * @throws ApiException the api exception */ - public void reconcile(VmDefChanged event, - Map model, VmChannel channel) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) throws IOException, TemplateException, ApiException { - // Check if to be generated - @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) - var lbs = Optional.of(model) - .map(m -> (Map) m.get("reconciler")) - .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE); - if (lbs instanceof Boolean isOn && !isOn) { + // Nothing to do unless spec changed + if (!specChanged) { return; } - if (!(lbs instanceof Map)) { + + // Check if to be generated + @SuppressWarnings({ "unchecked" }) + var lbsDef = Optional.of(model) + .map(m -> (Map) m.get("reconciler")) + .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE); + if (!(lbsDef instanceof Map) && !(lbsDef instanceof Boolean)) { logger.warning(() -> "\"" + LOAD_BALANCER_SERVICE + "\" in configuration must be boolean or mapping but is " - + lbs.getClass() + "."); + + lbsDef.getClass() + "."); + return; + } + if (lbsDef instanceof Boolean isOn && !isOn) { + return; + } + + // Load balancer can also be turned off for VM + if (vmDef + .>> fromSpec(LOAD_BALANCER_SERVICE) + .map(m -> m.isEmpty()).orElse(false)) { return; } // Combine template and data and parse result + logger.fine(() -> "Create/update load balancer service for " + + DataPath. get(model, "cr", "name").orElse("unknown")); var fmTemplate = fmConfig.getTemplate("runnerLoadBalancer.ftl.yaml"); StringWriter out = new StringWriter(); fmTemplate.process(model, out); @@ -101,63 +116,78 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; // https://github.com/kubernetes-client/java/issues/2741 var svcDef = Dynamics.newFromYaml( new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - mergeMetadata(svcDef, lbs, channel); + @SuppressWarnings("unchecked") + var defaults = lbsDef instanceof Map + ? (Map>) lbsDef + : null; + var client = channel.client(); + mergeMetadata(client.getJSON().getGson(), svcDef, defaults, vmDef); // Apply - DynamicKubernetesApi svcApi = new DynamicKubernetesApi("", "v1", - "services", channel.client()); - K8s.apply(svcApi, svcDef, svcDef.getRaw().toString()); - } - - @SuppressWarnings("unchecked") - private void mergeMetadata(DynamicKubernetesObject svcDef, - Object lbsConfig, VmChannel channel) { - // Get metadata from config - Map asmData = Collections.emptyMap(); - if (lbsConfig instanceof Map config) { - asmData = (Map) config; + var svcStub = K8sV1ServiceStub + .get(client, vmDef.namespace(), vmDef.name()); + if (svcStub.apply(svcDef).isEmpty()) { + logger.warning( + () -> "Could not patch service for " + svcStub.name()); } - var json = channel.client().getJSON(); - JsonObject cfgMeta - = json.deserialize(json.serialize(asmData), JsonObject.class); - - // Get metadata from VM definition - var vmMeta = GsonPtr.to(channel.vmDefinition().getRaw()).to("spec") - .get(JsonObject.class, LOAD_BALANCER_SERVICE) - .map(JsonObject::deepCopy).orElseGet(() -> new JsonObject()); - - // Merge Data from VM definition into config data - mergeReplace(GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class), - GsonPtr.to(vmMeta).to(LABELS).get(JsonObject.class)); - mergeReplace( - GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class), - GsonPtr.to(vmMeta).to(ANNOTATIONS).get(JsonObject.class)); - - // Merge additional data into service definition - var svcMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA); - mergeIfAbsent(svcMeta.to(LABELS).get(JsonObject.class), - GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class)); - mergeIfAbsent(svcMeta.to(ANNOTATIONS).get(JsonObject.class), - GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class)); } - private void mergeReplace(JsonObject dest, JsonObject src) { + private void mergeMetadata(Gson gson, DynamicKubernetesObject svcDef, + Map> defaults, + VmDefinition vmDefinition) { + // Get specific load balancer metadata from VM definition + var vmLbMeta = vmDefinition + .>> fromSpec(LOAD_BALANCER_SERVICE) + .orElse(Collections.emptyMap()); + + // Merge + var svcMeta = svcDef.getMetadata(); + var svcJsonMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA); + Optional.ofNullable(mergeIfAbsent(svcMeta.getLabels(), + mergeReplace(defaults.get(LABELS), vmLbMeta.get(LABELS)))) + .ifPresent(lbls -> svcJsonMeta.set(LABELS, gson.toJsonTree(lbls))); + Optional.ofNullable(mergeIfAbsent(svcMeta.getAnnotations(), + mergeReplace(defaults.get(ANNOTATIONS), vmLbMeta.get(ANNOTATIONS)))) + .ifPresent(as -> svcJsonMeta.set(ANNOTATIONS, gson.toJsonTree(as))); + } + + private Map mergeReplace(Map dest, + Map src) { + if (src == null) { + return dest; + } + if (dest == null) { + dest = new LinkedHashMap<>(); + } else { + dest = new LinkedHashMap<>(dest); + } for (var e : src.entrySet()) { - if (e.getValue().isJsonNull()) { + if (e.getValue() == null) { dest.remove(e.getKey()); continue; } - dest.add(e.getKey(), e.getValue()); + dest.put(e.getKey(), e.getValue()); } + return dest; } - private void mergeIfAbsent(JsonObject dest, JsonObject src) { + private Map mergeIfAbsent(Map dest, + Map src) { + if (src == null) { + return dest; + } + if (dest == null) { + dest = new LinkedHashMap<>(); + } else { + dest = new LinkedHashMap<>(dest); + } for (var e : src.entrySet()) { - if (dest.has(e.getKey())) { + if (dest.containsKey(e.getKey())) { continue; } - dest.add(e.getKey(), e.getValue()); + dest.put(e.getKey(), e.getValue()); } + return dest; } } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java index f668c21..f431c9d 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Manager.java @@ -18,6 +18,8 @@ package org.jdrupes.vmoperator.manager; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModelException; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -27,6 +29,9 @@ import java.net.URISyntaxException; import java.nio.file.Files; import java.util.Arrays; import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; @@ -36,6 +41,7 @@ import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.manager.events.Exit; import org.jdrupes.vmoperator.util.FsdUtils; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -44,18 +50,23 @@ import org.jgrapes.core.NamedChannel; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Stop; +import org.jgrapes.http.HttpConnector; import org.jgrapes.http.HttpServer; import org.jgrapes.http.InMemorySessionManager; import org.jgrapes.http.LanguageSelector; import org.jgrapes.http.events.Request; import org.jgrapes.io.NioDispatcher; import org.jgrapes.io.util.PermitsPool; +import org.jgrapes.net.SocketConnector; import org.jgrapes.net.SocketServer; +import org.jgrapes.net.SslCodec; import org.jgrapes.util.ComponentCollector; import org.jgrapes.util.FileSystemWatcher; import org.jgrapes.util.YamlConfigurationStore; +import org.jgrapes.util.events.ConfigurationUpdate; import org.jgrapes.util.events.WatchFile; -import org.jgrapes.webconlet.locallogin.LoginConlet; +import org.jgrapes.webconlet.oidclogin.LoginConlet; +import org.jgrapes.webconlet.oidclogin.OidcClient; import org.jgrapes.webconsole.base.BrowserLocalBackedKVStore; import org.jgrapes.webconsole.base.ConletComponentFactory; import org.jgrapes.webconsole.base.ConsoleWeblet; @@ -64,41 +75,48 @@ import org.jgrapes.webconsole.base.PageResourceProviderFactory; import org.jgrapes.webconsole.base.WebConsole; import org.jgrapes.webconsole.rbac.RoleConfigurator; import org.jgrapes.webconsole.rbac.RoleConletFilter; +import org.jgrapes.webconsole.rbac.UserLogger; import org.jgrapes.webconsole.vuejs.VueJsConsoleWeblet; /** * The application class. */ +@SuppressWarnings({ "PMD.ExcessiveImports" }) public class Manager extends Component { + private static String version; private static Manager app; + private String clusterName; + private String namespace = "unknown"; + private static int exitStatus; /** * Instantiates a new manager. * @param cmdLine * * @throws IOException Signals that an I/O exception has occurred. + * @throws URISyntaxException */ - @SuppressWarnings("PMD.TooFewBranchesForASwitchStatement") - public Manager(CommandLine cmdLine) throws IOException { + @SuppressWarnings({ "PMD.NcssCount", + "PMD.ConstructorCallsOverridableMethod" }) + public Manager(CommandLine cmdLine) throws IOException, URISyntaxException { + super(new NamedChannel("manager")); // Prepare component tree attach(new NioDispatcher()); - Channel mgrChannel = new NamedChannel("manager"); - attach(new FileSystemWatcher(mgrChannel)); - attach(new Controller(mgrChannel)); + attach(new FileSystemWatcher(channel())); + attach(new Controller(channel())); // Configuration store with file in /etc/opt (default) File cfgFile = new File(cmdLine.getOptionValue('c', - "/etc/opt/" + VM_OP_NAME.replace("-", "") + "/config.yaml")) - .getCanonicalFile(); + "/etc/opt/" + VM_OP_NAME.replace("-", "") + "/config.yaml")); logger.config(() -> "Using configuration from: " + cfgFile.getPath()); // Don't rely on night config to produce a good exception // for this simple case if (!Files.isReadable(cfgFile.toPath())) { throw new IOException("Cannot read configuration file " + cfgFile); } - attach(new YamlConfigurationStore(mgrChannel, cfgFile, false)); - fire(new WatchFile(cfgFile.toPath())); + attach(new YamlConfigurationStore(channel(), cfgFile, false)); + fire(new WatchFile(cfgFile.toPath()), channel()); // Prepare GUI Channel httpTransport = new NamedChannel("guiTransport"); @@ -108,9 +126,19 @@ public class Manager extends Component { .setServerAddress(new InetSocketAddress(8080)) .setName("GuiSocketServer")); + // Channel for HTTP application layer + Channel httpChannel = new NamedChannel("guiHttp"); + + // Create network channels for client requests. + Channel requestChannel = attach(new SocketConnector(SELF)); + Channel secReqChannel + = attach(new SslCodec(SELF, requestChannel, true)); + // Support for making HTTP requests + attach(new HttpConnector(httpChannel, requestChannel, + secReqChannel)); + // Create an HTTP server as converter between transport and application // layer. - Channel httpChannel = new NamedChannel("guiHttp"); HttpServer guiHttpServer = attach(new HttpServer(httpChannel, httpTransport, Request.In.Get.class, Request.In.Post.class)); guiHttpServer.setName("GuiHttpServer"); @@ -126,7 +154,12 @@ public class Manager extends Component { return; } ConsoleWeblet consoleWeblet = guiHttpServer - .attach(new VueJsConsoleWeblet(httpChannel, Channel.SELF, rootUri)) + .attach(new VueJsConsoleWeblet(httpChannel, SELF, rootUri) { + @Override + protected Map createConsoleBaseModel() { + return augmentBaseModel(super.createConsoleBaseModel()); + } + }) .prependClassTemplateLoader(getClass()) .prependResourceBundleProvider(getClass()) .prependConsoleResourceProvider(getClass()); @@ -135,12 +168,18 @@ public class Manager extends Component { console.attach(new BrowserLocalBackedKVStore( console.channel(), consoleWeblet.prefix().getPath())); console.attach(new KVStoreBasedConsolePolicy(console.channel())); + console.attach(new AvoidEmptyPolicy(console.channel())); console.attach(new RoleConfigurator(console.channel())); console.attach(new RoleConletFilter(console.channel())); console.attach(new LoginConlet(console.channel())); + console.attach(new OidcClient(console.channel(), httpChannel, + httpChannel, new URI("/oauth/callback"), 1500)); + console.attach(new UserLogger(console.channel())); + // Add all available page resource providers console.attach(new ComponentCollector<>( PageResourceProviderFactory.class, console.channel())); + // Add all available conlets console.attach(new ComponentCollector<>( ConletComponentFactory.class, console.channel(), type -> { @@ -153,6 +192,46 @@ public class Manager extends Component { })); } + private Map augmentBaseModel(Map base) { + base.put("version", version); + base.put("clusterName", new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + return clusterName; + } + }); + base.put("namespace", new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + return namespace; + } + }); + return base; + } + + /** + * Configure the component. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()).ifPresent(c -> { + if (c.containsKey("clusterName")) { + clusterName = (String) c.get("clusterName"); + } else { + clusterName = null; + } + }); + event.structured(componentPath() + "/Controller").ifPresent(c -> { + if (c.containsKey("namespace")) { + namespace = (String) c.get("namespace"); + } + }); + } + /** * Log the exception when a handling error is reported. * @@ -167,6 +246,16 @@ public class Manager extends Component { event.stop(); } + /** + * On exit. + * + * @param event the event + */ + @Handler + public void onExit(Exit event) { + exitStatus = event.exitStatus(); + } + /** * On stop. * @@ -174,7 +263,7 @@ public class Manager extends Component { */ @Handler(priority = -1000) public void onStop(Stop event) { - logger.fine(() -> "Application stopped."); + logger.info(() -> "Application stopped."); } static { @@ -201,13 +290,14 @@ public class Manager extends Component { * @param args the arguments * @throws Exception the exception */ - @SuppressWarnings("PMD.SignatureDeclareThrowsException") public static void main(String[] args) { try { // Instance logger is not available yet. var logger = Logger.getLogger(Manager.class.getName()); - logger.config(() -> "Version: " - + Manager.class.getPackage().getImplementationVersion()); + version = Optional.ofNullable( + Manager.class.getPackage().getImplementationVersion()) + .orElse("unknown"); + logger.config(() -> "Version: " + version); logger.config(() -> "running on " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.version") + ")" @@ -228,14 +318,18 @@ public class Manager extends Component { try { app.fire(new Stop()); Components.awaitExhaustion(); - } catch (InterruptedException e) { + } catch (InterruptedException e) { // NOPMD // Cannot do anything about this. } })); // Start the application Components.start(app); - } catch (IOException | InterruptedException + + // Wait for (regular) termination + Components.awaitExhaustion(); + System.exit(exitStatus); + } catch (IOException | InterruptedException | URISyntaxException | org.apache.commons.cli.ParseException e) { Logger.getLogger(Manager.class.getName()).log(Level.SEVERE, e, () -> "Failed to start manager: " + e.getMessage()); diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java new file mode 100644 index 0000000..cfb49e5 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodMonitor.java @@ -0,0 +1,139 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.util.Watch.Response; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.manager.events.ChannelDictionary; +import org.jdrupes.vmoperator.manager.events.PodChanged; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; + +/** + * Watches for changes of pods that run VMs. + */ +public class PodMonitor extends AbstractMonitor { + + private final ChannelDictionary channelDictionary; + + private final Map pendingChanges + = new ConcurrentHashMap<>(); + + /** + * Instantiates a new pod monitor. + * + * @param componentChannel the component channel + * @param channelDictionary the channel dictionary + */ + public PodMonitor(Channel componentChannel, + ChannelDictionary channelDictionary) { + super(componentChannel, V1Pod.class, V1PodList.class); + this.channelDictionary = channelDictionary; + context(K8sV1PodStub.CONTEXT); + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + APP_NAME + "," + + "app.kubernetes.io/managed-by=" + VM_OP_NAME); + options(options); + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + } + + @Override + protected void handleChange(K8sClient client, Response change) { + String vmName = change.object.getMetadata().getLabels() + .get("app.kubernetes.io/instance"); + if (vmName == null) { + return; + } + var channel = channelDictionary.channel(vmName).orElse(null); + var responseType = ResponseType.valueOf(change.type); + if (channel != null && channel.vmDefinition() != null) { + pendingChanges.remove(vmName); + channel.fire(new PodChanged(change.object, responseType)); + return; + } + + // VM definition not available yet, may happen during startup + if (responseType == ResponseType.DELETED) { + return; + } + purgePendingChanges(); + logger.finer(() -> "Add pending pod change for " + vmName); + pendingChanges.put(vmName, new PendingChange(Instant.now(), change)); + } + + private void purgePendingChanges() { + Instant tooOld = Instant.now().minus(Duration.ofMinutes(15)); + for (var itr = pendingChanges.entrySet().iterator(); itr.hasNext();) { + var change = itr.next(); + if (change.getValue().from().isBefore(tooOld)) { + itr.remove(); + logger.finer( + () -> "Cleaned pending pod change for " + change.getKey()); + } + } + } + + /** + * Check for pending changes. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onVmResourceChanged(VmResourceChanged event, + VmChannel channel) { + Optional.ofNullable(pendingChanges.remove(event.vmDefinition().name())) + .map(PendingChange::change).ifPresent(change -> { + logger.finer(() -> "Firing pending pod change for " + + event.vmDefinition().name()); + channel.fire(new PodChanged(change.object, + ResponseType.valueOf(change.type))); + if (logger.isLoggable(Level.FINER) + && pendingChanges.isEmpty()) { + logger.finer("No pending pod changes left."); + } + }); + } + + private record PendingChange(Instant from, Response change) { + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java new file mode 100644 index 0000000..4733e73 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PodReconciler.java @@ -0,0 +1,128 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import freemarker.template.Configuration; +import freemarker.template.TemplateException; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.dynamic.Dynamics; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.IOException; +import java.io.StringWriter; +import java.util.Map; +import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Delegee for reconciling the pod. + */ +/* default */ class PodReconciler { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + private final Configuration fmConfig; + + /** + * Instantiates a new pod reconciler. + * + * @param fmConfig the fm config + */ + public PodReconciler(Configuration fmConfig) { + this.fmConfig = fmConfig; + } + + /** + * Reconcile the pod. + * + * @param vmDef the vm def + * @param model the model + * @param channel the channel + * @param specChanged the spec changed + * @throws IOException Signals that an I/O exception has occurred. + * @throws TemplateException the template exception + * @throws ApiException the api exception + */ + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) + throws IOException, TemplateException, ApiException { + // Get pod stub. + var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(), + vmDef.name()); + + // Nothing to do if exists and should be running + if (vmDef.vmState() == RequestedVmState.RUNNING + && podStub.model().isPresent()) { + return; + } + + // Delete if running but should be stopped + if (vmDef.vmState() == RequestedVmState.STOPPED) { + if (podStub.model().isPresent()) { + podStub.delete(); + } + return; + } + + // Create pod. First combine template and data and parse result + logger.fine(() -> "Create/update pod " + podStub.name()); + addDisplaySecret(channel.client(), model, vmDef); + var fmTemplate = fmConfig.getTemplate("runnerPod.ftl.yaml"); + StringWriter out = new StringWriter(); + fmTemplate.process(model, out); + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + var podDef = Dynamics.newFromYaml( + new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + + // Do apply changes + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (podStub.apply(podDef).isEmpty()) { + logger.warning( + () -> "Could not patch pod for " + podStub.name()); + } + } + + private void addDisplaySecret(K8sClient client, Map model, + VmDefinition vmDef) throws ApiException { + ListOptions options = new ListOptions(); + options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + var dsStub = K8sV1SecretStub + .list(client, vmDef.namespace(), options).stream().findFirst(); + if (dsStub.isPresent()) { + dsStub.get().model().ifPresent(m -> { + model.put("displaySecret", m.getMetadata().getName()); + }); + } + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java new file mode 100644 index 0000000..e554d5a --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PoolMonitor.java @@ -0,0 +1,210 @@ +/* + * VM-Operator + * Copyright (C) 2023,2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sDynamicModel; +import org.jdrupes.vmoperator.common.K8sDynamicModels; +import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.jgrapes.core.Channel; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Attached; + +/** + * Watches for changes of VM pools. Reports the changes using + * {@link VmPoolChanged} events fired on a special pipeline to + * avoid concurrent change informations. + */ +public class PoolMonitor extends + AbstractMonitor { + + private final Map pools = new ConcurrentHashMap<>(); + private EventPipeline poolPipeline; + + /** + * Instantiates a new VM pool manager. + * + * @param componentChannel the component channel + */ + public PoolMonitor(Channel componentChannel) { + super(componentChannel, K8sDynamicModel.class, + K8sDynamicModels.class); + } + + /** + * On attached. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public void onAttached(Attached event) { + if (event.node() == this) { + poolPipeline = newEventPipeline(); + } + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + + // Get all our API versions + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM_POOL); + if (ctx.isEmpty()) { + logger.severe(() -> "Cannot get CRD context."); + return; + } + context(ctx.get()); + } + + @Override + protected void handleChange(K8sClient client, + Watch.Response response) { + + var type = ResponseType.valueOf(response.type); + var poolName = response.object.metadata().getName(); + + // When pool is deleted, save VMs in pending + if (type == ResponseType.DELETED) { + Optional.ofNullable(pools.get(poolName)).ifPresent(pool -> { + pool.setUndefined(); + if (pool.vms().isEmpty()) { + pools.remove(poolName); + } + poolPipeline.fire(new VmPoolChanged(pool, true)); + }); + return; + } + + // Get full definition + var poolModel = response.object; + if (poolModel.data() == null) { + // ADDED event does not provide data, see + // https://github.com/kubernetes-client/java/issues/3215 + try { + poolModel = K8sDynamicStub.get(client(), context(), namespace(), + poolModel.metadata().getName()).model().orElse(null); + } catch (ApiException e) { + return; + } + } + + // Get pool and merge changes + var vmPool = pools.computeIfAbsent(poolName, k -> new VmPool(poolName)); + vmPool.defineFrom(client().getJSON().getGson().fromJson( + GsonPtr.to(poolModel.data()).to("spec").get(), VmPool.class)); + poolPipeline.fire(new VmPoolChanged(vmPool)); + } + + /** + * Track VM definition changes. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onVmResourceChanged(VmResourceChanged event) + throws ApiException { + final var vmDef = event.vmDefinition(); + final String vmName = vmDef.name(); + switch (event.type()) { + case ADDED: + vmDef.> fromSpec("pools") + .orElse(Collections.emptyList()).stream().forEach(p -> { + pools.computeIfAbsent(p, k -> new VmPool(p)) + .vms().add(vmName); + poolPipeline.fire(new VmPoolChanged(pools.get(p))); + }); + break; + case DELETED: + pools.values().stream().forEach(p -> { + if (p.vms().remove(vmName)) { + poolPipeline.fire(new VmPoolChanged(p)); + } + }); + return; + default: + break; + } + + // Sync last usage to console state change if user matches + if (vmDef.assignment().map(Assignment::user) + .map(at -> at.equals(vmDef.consoleUser().orElse(null))) + .orElse(true)) { + return; + } + + var ccChange = vmDef.condition("ConsoleConnected") + .map(cc -> cc.getLastTransitionTime().toInstant()); + if (ccChange + .map(tt -> vmDef.assignment().map(Assignment::lastUsed) + .map(alu -> alu.isAfter(tt)).orElse(true)) + .orElse(true)) { + return; + } + var vmStub = VmDefinitionStub.get(client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + vmStub.updateStatus(from -> { + // TODO + JsonObject status = from.statusJson(); + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); + assignment.set("lastUsed", ccChange.get().toString()); + return status; + }); + } + + /** + * Return the requested pools. + * + * @param event the event + */ + @Handler + public void onGetPools(GetPools event) { + event.setResult(pools.values().stream().filter(VmPool::isDefined) + .filter(p -> event.name().isEmpty() + || p.name().equals(event.name().get())) + .filter(p -> event.forUser().isEmpty() && event.forRoles().isEmpty() + || !p.permissionsFor(event.forUser().orElse(null), + event.forRoles()).isEmpty()) + .toList()); + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java new file mode 100644 index 0000000..515bfc9 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/PvcReconciler.java @@ -0,0 +1,226 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import freemarker.core.ParseException; +import freemarker.template.Configuration; +import freemarker.template.MalformedTemplateNameException; +import freemarker.template.TemplateException; +import freemarker.template.TemplateNotFoundException; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.dynamic.Dynamics; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.IOException; +import java.io.StringWriter; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8sV1PvcStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.util.DataPath; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Delegee for reconciling the stateful set (effectively the pod). + */ +/* default */ class PvcReconciler { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + private final Configuration fmConfig; + + /** + * Instantiates a new pvc reconciler. + * + * @param fmConfig the fm config + */ + public PvcReconciler(Configuration fmConfig) { + this.fmConfig = fmConfig; + } + + /** + * Reconcile the PVCs. + * + * @param vmDef the VM definition + * @param model the model + * @param channel the channel + * @param specChanged the spec changed + * @throws IOException Signals that an I/O exception has occurred. + * @throws TemplateException the template exception + * @throws ApiException the api exception + */ + @SuppressWarnings({ "unchecked" }) + public void reconcile(VmDefinition vmDef, Map model, + VmChannel channel, boolean specChanged) + throws IOException, TemplateException, ApiException { + Set knownPvcs; + if (!specChanged && channel.associated(this, Set.class).isPresent()) { + knownPvcs = (Set) channel.associated(this, Set.class).get(); + } else { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + vmDef.name()); + knownPvcs = K8sV1PvcStub.list(channel.client(), + vmDef.namespace(), listOpts).stream().map(K8sV1PvcStub::name) + .collect(Collectors.toSet()); + channel.setAssociated(this, knownPvcs); + } + + // Reconcile runner data pvc + reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs, specChanged); + + // Reconcile pvcs for defined disks + var diskDefs = vmDef.>> fromVm("disks") + .orElse(List.of()); + var diskCounter = 0; + for (var diskDef : diskDefs) { + if (!diskDef.containsKey("volumeClaimTemplate")) { + continue; + } + var diskName = DataPath.get(diskDef, "volumeClaimTemplate", + "metadata", "name").map(name -> name + "-disk") + .orElse("disk-" + diskCounter); + diskCounter += 1; + diskDef.put("generatedDiskName", diskName); + + // Don't do anything if pvc with old (sts generated) name exists. + var stsDiskPvcName = diskName + "-" + vmDef.name() + "-0"; + if (knownPvcs.contains(stsDiskPvcName)) { + diskDef.put("generatedPvcName", stsDiskPvcName); + continue; + } + + // Update PVC + reconcileRunnerDiskPvc(vmDef, model, channel, specChanged, diskDef); + } + } + + private void reconcileRunnerDataPvc(VmDefinition vmDef, + Map model, VmChannel channel, + Set knownPvcs, boolean specChanged) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException, TemplateException, ApiException { + + // Look for old (sts generated) name. + var stsRunnerDataPvcName + = "runner-data" + "-" + vmDef.name() + "-0"; + if (knownPvcs.contains(stsRunnerDataPvcName)) { + model.put("runnerDataPvcName", stsRunnerDataPvcName); + return; + } + + // Generate PVC + var runnerDataPvcName = vmDef.name() + "-runner-data"; + logger.fine(() -> "Create/update pvc " + runnerDataPvcName); + model.put("runnerDataPvcName", runnerDataPvcName); + if (!specChanged) { + // Augmenting the model is all we have to do + return; + } + var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml"); + StringWriter out = new StringWriter(); + fmTemplate.process(model, out); + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + var pvcDef = Dynamics.newFromYaml( + new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + + // Do apply changes + var pvcStub = K8sV1PvcStub.get(channel.client(), + vmDef.namespace(), (String) model.get("runnerDataPvcName")); + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) + .isEmpty()) { + logger.warning( + () -> "Could not patch pvc for " + pvcStub.name()); + } + } + + private void reconcileRunnerDiskPvc(VmDefinition vmDef, + Map model, VmChannel channel, boolean specChanged, + Map diskDef) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException, TemplateException, ApiException { + // Generate PVC + var pvcName = vmDef.name() + "-" + diskDef.get("generatedDiskName"); + diskDef.put("generatedPvcName", pvcName); + if (!specChanged) { + // Augmenting the model is all we have to do + return; + } + + // Generate PVC + logger.fine(() -> "Create/update pvc " + pvcName); + model.put("disk", diskDef); + var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml"); + StringWriter out = new StringWriter(); + fmTemplate.process(model, out); + model.remove("disk"); + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + var pvcDef = Dynamics.newFromYaml( + new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + + // Apply changes + var pvcStub + = K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName); + var pvc = pvcStub.model(); + if (pvc.isEmpty() + || !"Bound".equals(pvc.get().getStatus().getPhase())) { + // Does not exist or isn't bound, use apply + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) + .isEmpty()) { + logger.warning( + () -> "Could not patch pvc for " + pvcStub.name()); + } + return; + } + + // If bound, use json merge, omitting immutable fields + var spec = GsonPtr.to(pvcDef.getRaw()).to("spec"); + spec.removeExcept("volumeAttributesClassName", "resources"); + spec.get("resources").ifPresent(p -> p.removeExcept("requests")); + PatchOptions opts = new PatchOptions(); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + if (pvcStub.patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, + new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) + .isEmpty()) { + logger.warning( + () -> "Could not patch pvc for " + pvcStub.name()); + } + } +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index c3fee7d..e580c48 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -18,37 +18,40 @@ package org.jdrupes.vmoperator.manager; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import freemarker.core.ParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import freemarker.template.AdapterTemplateModel; import freemarker.template.Configuration; -import freemarker.template.DefaultObjectWrapperBuilder; -import freemarker.template.MalformedTemplateNameException; import freemarker.template.SimpleNumber; +import freemarker.template.SimpleScalar; import freemarker.template.TemplateException; import freemarker.template.TemplateExceptionHandler; -import freemarker.template.TemplateHashModel; import freemarker.template.TemplateMethodModelEx; import freemarker.template.TemplateModelException; -import freemarker.template.TemplateNotFoundException; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import java.io.IOException; +import java.lang.reflect.Modifier; import java.math.BigDecimal; import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.logging.Level; import org.jdrupes.vmoperator.common.Convertions; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.ResetVm; import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; -import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -65,20 +68,25 @@ import org.jgrapes.util.events.ConfigurationUpdate; * * * A [`ConfigMap`](https://kubernetes.io/docs/concepts/configuration/configmap/) * that defines the configuration file for the runner. - * - * * A [`StatefulSet`](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) - * that creates - * * the [`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/) - * with the Runner instance, - * * a PVC for 1 MiB of persistent storage used by the Runner - * (referred to as the "runnerDataPvc") and - * * the PVCs for the VM's disks. - * + * + * * A [`PVC`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) + * for 1 MiB of persistent storage used by the Runner (referred to as the + * "runnerDataPvc") + * + * * The PVCs for the VM's disks. + * + * * A [`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/) with the + * runner instance[^oldSts]. + * * * (Optional) A load balancer * [`Service`](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/) * that allows the user to access a VM's console without knowing which * node it runs on. * + * [^oldSts]: Before version 3.4, the operator created a + * [`StatefulSet`](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) + * that created the pod. + * * The reconciler is part of the {@link Controller} component. It's * configuration properties are therefore defined in * ```yaml @@ -121,15 +129,26 @@ import org.jgrapes.util.events.ConfigurationUpdate; * ``` * This makes all VM consoles available at IP address 192.168.168.1 * with the port numbers from the VM definitions. + * + * * `loggingProperties`: If defined, specifies the default logging + * properties to be used by the runners managed by the controller. + * This property is a string that holds the content of + * a logging.properties file. + * + * @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.AvoidDuplicateLiterals" }) +@SuppressWarnings({ "PMD.AvoidDuplicateLiterals" }) public class Reconciler extends Component { - @SuppressWarnings("PMD.SingularField") + /** The Constant mapper. */ + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + private final Configuration fmConfig; private final ConfigMapReconciler cmReconciler; - private final StatefulSetReconciler stsReconciler; + private final DisplaySecretReconciler dsReconciler; + private final PvcReconciler pvcReconciler; + private final PodReconciler podReconciler; private final LoadBalancerReconciler lbReconciler; @SuppressWarnings("PMD.UseConcurrentHashMap") private final Map config = new HashMap<>(); @@ -139,6 +158,7 @@ public class Reconciler extends Component { * * @param componentChannel the component channel */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") public Reconciler(Channel componentChannel) { super(componentChannel); @@ -153,7 +173,9 @@ public class Reconciler extends Component { fmConfig.setClassForTemplateLoading(Reconciler.class, ""); cmReconciler = new ConfigMapReconciler(fmConfig); - stsReconciler = new StatefulSetReconciler(fmConfig); + dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); + pvcReconciler = new PvcReconciler(fmConfig); + podReconciler = new PodReconciler(fmConfig); lbReconciler = new LoadBalancerReconciler(fmConfig); } @@ -175,92 +197,131 @@ public class Reconciler extends Component { * @param event the event * @param channel the channel * @throws ApiException the api exception - * @throws IOException - * @throws ParseException - * @throws MalformedTemplateNameException - * @throws TemplateNotFoundException - * @throws TemplateException - * @throws KubectlException + * @throws TemplateException the template exception + * @throws IOException Signals that an I/O exception has occurred. */ @Handler - @SuppressWarnings("PMD.ConfusingTernary") - public void onVmDefChanged(VmDefChanged event, VmChannel channel) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) throws ApiException, TemplateException, IOException { - // We're only interested in "spec" changes. - if (!event.specChanged()) { - return; - } - // Ownership relationships takes care of deletions - var defMeta = event.vmDefinition().getMetadata(); - if (event.type() == Type.DELETED) { - logger.fine(() -> "VM \"" + defMeta.getName() + "\" deleted"); + if (event.type() == K8sObserver.ResponseType.DELETED) { return; } - // Reconcile, use "augmented" vm definition for model - Map model = prepareModel(patchCr(event.vmDefinition())); - var configMap = cmReconciler.reconcile(event, model, channel); - model.put("cm", configMap.getRaw()); - stsReconciler.reconcile(event, model, channel); - lbReconciler.reconcile(event, model, channel); - } + // Create model for processing templates + var vmDef = event.vmDefinition(); + Map model = prepareModel(vmDef); + cmReconciler.reconcile(model, channel, event.specChanged()); - private DynamicKubernetesObject patchCr(DynamicKubernetesObject vmDef) { - var json = vmDef.getRaw().deepCopy(); - // Adjust cdromImage path - var disks - = GsonPtr.to(json).to("spec", "vm", "disks").get(JsonArray.class); - for (var disk : disks) { - var cdrom = (JsonObject) ((JsonObject) disk).get("cdrom"); - if (cdrom == null) { - continue; - } - String image = cdrom.get("image").getAsString(); - if (image.isEmpty()) { - continue; - } - try { - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH - + "/").resolve(image); - if ("file".equals(imageUri.getScheme())) { - cdrom.addProperty("image", imageUri.getPath()); - } else { - cdrom.addProperty("image", imageUri.toString()); - } - } catch (URISyntaxException e) { - logger.warning(() -> "Invalid CDROM image: " + image); - } + // The remaining reconcilers depend only on changes of the spec part + // or the pod state. + if (!event.specChanged() && !event.podChanged()) { + return; } - return new DynamicKubernetesObject(json); + dsReconciler.reconcile(vmDef, model, channel, event.specChanged()); + pvcReconciler.reconcile(vmDef, model, channel, event.specChanged()); + podReconciler.reconcile(vmDef, model, channel, event.specChanged()); + lbReconciler.reconcile(vmDef, model, channel, event.specChanged()); } - @SuppressWarnings("PMD.CognitiveComplexity") - private Map prepareModel(DynamicKubernetesObject vmDef) - throws TemplateModelException { + /** + * Reset the VM by incrementing the reset count and doing a + * partial reconcile (configmap only). + * + * @param event the event + * @param channel the channel + * @throws IOException + * @throws ApiException + * @throws TemplateException + */ + @Handler + public void onResetVm(ResetVm event, VmChannel channel) + throws ApiException, IOException, TemplateException { + var vmDef = channel.vmDefinition(); + var extra = vmDef.extra(); + extra.resetCount(extra.resetCount() + 1); + Map model + = prepareModel(channel.vmDefinition()); + cmReconciler.reconcile(model, channel, true); + } + + private Map prepareModel(VmDefinition vmDef) + throws TemplateModelException, ApiException { @SuppressWarnings("PMD.UseConcurrentHashMap") Map model = new HashMap<>(); model.put("managerVersion", Optional.ofNullable(Reconciler.class.getPackage() .getImplementationVersion()).orElse("(Unknown)")); - model.put("cr", vmDef.getRaw()); - model.put("constants", - (TemplateHashModel) new DefaultObjectWrapperBuilder( - Configuration.VERSION_2_3_32) - .build().getStaticModels() - .get(Constants.class.getName())); + model.put("cr", vmDef); model.put("reconciler", config); + model.put("constants", constantsMap(Constants.class)); + addLoginRequestedFor(model, vmDef); // Methods - model.put("parseQuantity", new TemplateMethodModelEx() { + model.put("parseQuantity", parseQuantityModel); + model.put("formatMemory", formatMemoryModel); + model.put("imageLocation", imgageLocationModel); + model.put("toJson", toJsonModel); + return model; + } + + /** + * Creates a map with constants. Needed because freemarker doesn't support + * nested classes with its static models. + * + * @param clazz the clazz + * @return the map + */ + @SuppressWarnings("PMD.EmptyCatchBlock") + private Map constantsMap(Class clazz) { + @SuppressWarnings("PMD.UseConcurrentHashMap") + Map result = new HashMap<>(); + Arrays.stream(clazz.getFields()).filter(f -> { + var modifiers = f.getModifiers(); + return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers) + && f.getType() == String.class; + }).forEach(f -> { + try { + result.put(f.getName(), f.get(null)); + } catch (IllegalArgumentException | IllegalAccessException e) { + // Should not happen, ignore + } + }); + Arrays.stream(clazz.getClasses()).filter(c -> { + var modifiers = c.getModifiers(); + return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers); + }).forEach(c -> { + result.put(c.getSimpleName(), constantsMap(c)); + }); + return result; + } + + private void addLoginRequestedFor(Map model, + VmDefinition vmDef) { + vmDef.assignment().filter(a -> { + try { + return newEventPipeline() + .fire(new GetPools().withName(a.pool())).get() + .stream().findFirst().map(VmPool::loginOnAssignment) + .orElse(false); + } catch (InterruptedException e) { + logger.log(Level.WARNING, e, e::getMessage); + } + return false; + }).map(Assignment::user) + .or(() -> vmDef.fromSpec("vm", "display", "loggedInUser")) + .ifPresent(u -> model.put("loginRequestedFor", u)); + } + + private final TemplateMethodModelEx parseQuantityModel + = new TemplateMethodModelEx() { @Override @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { var arg = arguments.get(0); - if (arg instanceof Number number) { - return number; + if (arg instanceof SimpleNumber number) { + return number.getAsNumber(); } try { return Quantity.fromString(arg.toString()).getNumber(); @@ -269,10 +330,11 @@ public class Reconciler extends Component { + "specified as \"" + arg + "\": " + e.getMessage()); } } - }); - model.put("formatMemory", new TemplateMethodModelEx() { + }; + + private final TemplateMethodModelEx formatMemoryModel + = new TemplateMethodModelEx() { @Override - @SuppressWarnings("PMD.PreserveStackTrace") public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { var arg = arguments.get(0); @@ -297,8 +359,45 @@ public class Reconciler extends Component { } return Convertions.formatMemory(bigInt); } - }); - return model; - } + }; + private final TemplateMethodModelEx imgageLocationModel + = new TemplateMethodModelEx() { + @Override + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + var image = ((SimpleScalar) arguments.get(0)).getAsString(); + if (image.isEmpty()) { + return ""; + } + try { + var imageUri + = new URI("file://" + Constants.IMAGE_REPO_PATH + "/") + .resolve(image); + if ("file".equals(imageUri.getScheme())) { + return imageUri.getPath(); + } + return imageUri.toString(); + } catch (URISyntaxException e) { + logger.warning(() -> "Invalid CDROM image: " + image); + } + return image; + } + }; + + private final TemplateMethodModelEx toJsonModel + = new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + try { + return mapper.writeValueAsString( + ((AdapterTemplateModel) arguments.get(0)) + .getAdaptedObject(Object.class)); + } catch (JsonProcessingException e) { + return "{}"; + } + } + }; } diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java deleted file mode 100644 index 3cd6a39..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/StatefulSetReconciler.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2023 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager; - -import freemarker.template.Configuration; -import freemarker.template.TemplateException; -import io.kubernetes.client.custom.V1Patch; -import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.dynamic.Dynamics; -import io.kubernetes.client.util.generic.options.PatchOptions; -import java.io.IOException; -import java.io.StringWriter; -import java.util.Map; -import java.util.logging.Logger; -import org.jdrupes.vmoperator.common.K8s; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.util.GsonPtr; -import org.yaml.snakeyaml.LoaderOptions; -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.constructor.SafeConstructor; - -/** - * Delegee for reconciling the stateful set (effectively the pod). - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -/* default */ class StatefulSetReconciler { - - protected final Logger logger = Logger.getLogger(getClass().getName()); - private final Configuration fmConfig; - - /** - * Instantiates a new config map reconciler. - * - * @param fmConfig the fm config - */ - public StatefulSetReconciler(Configuration fmConfig) { - this.fmConfig = fmConfig; - } - - /** - * Reconcile stateful set. - * - * @param event the event - * @param model the model - * @param channel the channel - * @throws IOException Signals that an I/O exception has occurred. - * @throws TemplateException the template exception - * @throws ApiException the api exception - */ - public void reconcile(VmDefChanged event, Map model, - VmChannel channel) - throws IOException, TemplateException, ApiException { - DynamicKubernetesApi stsApi = new DynamicKubernetesApi("apps", "v1", - "statefulsets", channel.client()); - var metadata = event.vmDefinition().getMetadata(); - - // Combine template and data and parse result - var fmTemplate = fmConfig.getTemplate("runnerSts.ftl.yaml"); - StringWriter out = new StringWriter(); - fmTemplate.process(model, out); - // Avoid Yaml.load due to - // https://github.com/kubernetes-client/java/issues/2741 - var stsDef = Dynamics.newFromYaml( - new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); - - // If exists apply changes only when transitioning state - // or not running. - var existing = K8s.get(stsApi, metadata); - if (existing.isPresent()) { - var current = GsonPtr.to(existing.get().getRaw()) - .to("spec").getAsInt("replicas").orElse(1); - var desired = GsonPtr.to(stsDef.getRaw()) - .to("spec").getAsInt("replicas").orElse(1); - if (current == 1 && desired == 1) { - return; - } - } - - // Do apply changes - PatchOptions opts = new PatchOptions(); - opts.setForce(true); - opts.setFieldManager("kubernetes-java-kubectl-apply"); - stsApi.patch(stsDef.getMetadata().getNamespace(), - stsDef.getMetadata().getName(), V1Patch.PATCH_FORMAT_APPLY_YAML, - new V1Patch(channel.client().getJSON().serialize(stsDef)), - opts).throwsApiException(); - } - -} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java new file mode 100644 index 0000000..22f083c --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmMonitor.java @@ -0,0 +1,326 @@ +/* + * VM-Operator + * Copyright (C) 2023,2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.Watch; +import io.kubernetes.client.util.generic.options.ListOptions; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; +import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; +import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.common.VmDefinitions; +import org.jdrupes.vmoperator.common.VmExtraData; +import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; +import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.manager.events.ChannelManager; +import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.PodChanged; +import org.jdrupes.vmoperator.manager.events.UpdateAssignment; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jdrupes.vmoperator.util.GsonPtr; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.annotation.Handler; + +/** + * Watches for changes of VM definitions. When a VM definition (CR) + * becomes known, is is registered with a {@link ChannelManager} and thus + * gets an associated {@link VmChannel} and an associated + * {@link EventPipeline}. + * + * The {@link EventPipeline} is used for submitting an action that processes + * the change data from kubernetes, eventually transforming it to a + * {@link VmResourceChanged} event that is handled by another + * {@link EventPipeline} associated with the {@link VmChannel}. This + * event pipeline should be used for all events related to changes of + * a particular VM. + */ +public class VmMonitor extends + AbstractMonitor { + + private final ChannelManager channelManager; + + /** + * Instantiates a new VM definition watcher. + * + * @param componentChannel the component channel + * @param channelManager the channel manager + */ + public VmMonitor(Channel componentChannel, + ChannelManager channelManager) { + super(componentChannel, VmDefinition.class, + VmDefinitions.class); + this.channelManager = channelManager; + } + + @Override + protected void prepareMonitoring() throws IOException, ApiException { + client(new K8sClient()); + + // Get all our API versions + var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); + if (ctx.isEmpty()) { + logger.severe(() -> "Cannot get CRD context."); + return; + } + context(ctx.get()); + + // Remove left over resources + purge(); + } + + private void purge() throws ApiException { + // Get existing CRs (VMs) + var known = K8sDynamicStub.list(client(), context(), namespace()) + .stream().map(stub -> stub.name()).collect(Collectors.toSet()); + ListOptions opts = new ListOptions(); + opts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME); + for (var context : Set.of(K8sV1StatefulSetStub.CONTEXT, + K8sV1ConfigMapStub.CONTEXT)) { + for (var resStub : K8sDynamicStub.list(client(), context, + namespace(), opts)) { + String instance = resStub.model() + .map(m -> m.metadata().getName()).orElse("(unknown)"); + if (!known.contains(instance)) { + resStub.delete(); + } + } + } + } + + @Override + protected void handleChange(K8sClient client, + Watch.Response response) { + var name = response.object.getMetadata().getName(); + + // Process the response data on a VM specific pipeline to + // increase concurrency when e.g. starting many VMs. + var preparing = channelManager.associated(name) + .orElseGet(() -> newEventPipeline()); + preparing.submit("VmChange[" + name + "]", + () -> processChange(client, response, preparing)); + } + + private void processChange(K8sClient client, + Watch.Response response, EventPipeline preparing) { + // Get full definition and associate with channel as backup + var vmDef = response.object; + if (vmDef.data() == null) { + // ADDED event does not provide data, see + // https://github.com/kubernetes-client/java/issues/3215 + vmDef = getModel(client, vmDef); + } + var name = response.object.getMetadata().getName(); + var channel = channelManager.channel(name) + .orElseGet(() -> channelManager.createChannel(name)); + if (vmDef.data() != null) { + // New data, augment and save + addExtraData(vmDef, channel.vmDefinition()); + channel.setVmDefinition(vmDef); + } else { + // Reuse cached (e.g. if deleted) + vmDef = channel.vmDefinition(); + } + if (vmDef == null) { + logger.warning(() -> "Cannot get defintion for " + + response.object.getMetadata()); + return; + } + channelManager.put(name, channel, preparing); + + // Create and fire changed event. Remove channel from channel + // manager on completion. + VmResourceChanged chgEvt + = new VmResourceChanged(ResponseType.valueOf(response.type), vmDef, + channel.setGeneration(response.object.getMetadata() + .getGeneration()), + false); + if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { + chgEvt = Event.onCompletion(chgEvt, + e -> channelManager.remove(e.vmDefinition().name())); + } + channel.fire(chgEvt); + } + + private VmDefinition getModel(K8sClient client, VmDefinition vmDef) { + try { + return VmDefinitionStub.get(client, context(), namespace(), + vmDef.metadata().getName()).model().orElse(null); + } catch (ApiException e) { + return null; + } + } + + private void addExtraData(VmDefinition vmDef, VmDefinition prevState) { + var extra = new VmExtraData(vmDef); + var prevExtra = Optional.ofNullable(prevState).map(VmDefinition::extra); + + // Maintain (or initialize) the resetCount + extra.resetCount(prevExtra.map(VmExtraData::resetCount).orElse(0L)); + + // Maintain node info + prevExtra + .ifPresent(e -> extra.nodeInfo(e.nodeName(), e.nodeAddresses())); + } + + /** + * On pod changed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onPodChanged(PodChanged event, VmChannel channel) { + var vmDef = channel.vmDefinition(); + + // Make sure that this is properly sync'd with VM CR changes. + channelManager.associated(vmDef.name()) + .orElseGet(() -> activeEventPipeline()) + .submit("NodeInfo[" + vmDef.name() + "]", + () -> { + updateNodeInfo(event, vmDef); + channel.fire(new VmResourceChanged(ResponseType.MODIFIED, + vmDef, false, true)); + }); + } + + private void updateNodeInfo(PodChanged event, VmDefinition vmDef) { + var extra = vmDef.extra(); + if (event.type() == ResponseType.DELETED) { + // The status of a deleted pod is the status before deletion, + // i.e. the node info is still cached and must be removed. + extra.nodeInfo("", Collections.emptyList()); + return; + } + + // Get current node info from pod + var pod = event.pod(); + var nodeName = Optional + .ofNullable(pod.getSpec().getNodeName()).orElse(""); + logger.finer(() -> "Adding node name " + nodeName + + " to VM info for " + vmDef.name()); + var addrs = new ArrayList(); + Optional.ofNullable(pod.getStatus().getPodIPs()) + .orElse(Collections.emptyList()).stream() + .map(ip -> ip.getIp()).forEach(addrs::add); + logger.finer(() -> "Adding node addresses " + addrs + + " to VM info for " + vmDef.name()); + extra.nodeInfo(nodeName, addrs); + } + + /** + * On modify vm. + * + * @param event the event + * @throws ApiException the api exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onModifyVm(ModifyVm event, VmChannel channel) + throws ApiException, IOException { + patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), + event.value()); + } + + private void patchVmDef(K8sClient client, String name, String path, + Object value) throws ApiException, IOException { + var vmStub = K8sDynamicStub.get(client, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace(), + name); + + // Patch running + String valueAsText = value instanceof String + ? "\"" + value + "\"" + : value.toString(); + var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/" + + path + "\", \"value\": " + valueAsText + "}]"), + client.defaultPatchOptions()); + if (!res.isPresent()) { + logger.warning( + () -> "Cannot patch definition for Vm " + vmStub.name()); + } + } + + /** + * Attempt to Update the assignment information in the status of the + * VM CR. Returns true if successful. The handler does not attempt + * retries, because in case of failure it will be necessary to + * re-evaluate the chosen VM. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + */ + @Handler + public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) + throws ApiException { + try { + var vmDef = channel.vmDefinition(); + var vmStub = VmDefinitionStub.get(channel.client(), + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + vmDef.namespace(), vmDef.name()); + if (vmStub.updateStatus(vmDef, from -> { + JsonObject status = from.statusJson(); + if (event.toUser() == null) { + ((JsonObject) GsonPtr.to(status).get()) + .remove(Status.ASSIGNMENT); + } else { + var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); + assignment.set("pool", event.fromPool().name()); + assignment.set("user", event.toUser()); + assignment.set("lastUsed", Instant.now().toString()); + } + return status; + }).isPresent()) { + event.setResult(true); + } + } catch (ApiException e) { + // Log exceptions except for conflict, which can be expected + if (HttpURLConnection.HTTP_CONFLICT != e.getCode()) { + throw e; + } + } + event.setResult(false); + } + +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java deleted file mode 100644 index 492ad40..0000000 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/VmWatcher.java +++ /dev/null @@ -1,300 +0,0 @@ -/* - * VM-Operator - * Copyright (C) 2023 Michael N. Lipp - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.jdrupes.vmoperator.manager; - -import com.google.gson.reflect.TypeToken; -import io.kubernetes.client.openapi.ApiClient; -import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.apis.ApisApi; -import io.kubernetes.client.openapi.apis.CustomObjectsApi; -import io.kubernetes.client.openapi.models.V1APIGroup; -import io.kubernetes.client.openapi.models.V1APIResource; -import io.kubernetes.client.openapi.models.V1GroupVersionForDiscovery; -import io.kubernetes.client.openapi.models.V1Namespace; -import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.util.Config; -import io.kubernetes.client.util.Watch; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.options.ListOptions; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.time.Instant; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import org.jdrupes.vmoperator.common.K8s; -import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM; -import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; -import org.jdrupes.vmoperator.manager.events.VmChannel; -import org.jdrupes.vmoperator.manager.events.VmDefChanged; -import org.jdrupes.vmoperator.manager.events.VmDefChanged.Type; -import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; -import org.jgrapes.core.Components; -import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; -import org.jgrapes.core.events.Stop; -import org.jgrapes.util.events.ConfigurationUpdate; - -/** - * Watches for changes of VM definitions. - */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class VmWatcher extends Component { - - private String namespaceToWatch; - private final Map channels = new ConcurrentHashMap<>(); - - /** - * Instantiates a new VM definition watcher. - * - * @param componentChannel the component channel - */ - public VmWatcher(Channel componentChannel) { - super(componentChannel); - } - - /** - * Configure the component. - * - * @param event the event - */ - @Handler - public void onConfigurationUpdate(ConfigurationUpdate event) { - event.structured(Components.manager(parent()).componentPath()) - .ifPresent(c -> { - if (c.containsKey("namespace")) { - namespaceToWatch = (String) c.get("namespace"); - } - }); - } - - /** - * Handle the start event. - * - * @param event the event - * @throws IOException - * @throws ApiException - */ - @Handler(priority = 10) - public void onStart(Start event) throws IOException, ApiException { - // Get namespace - if (namespaceToWatch == null) { - var path = Path - .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); - if (Files.isReadable(path)) { - namespaceToWatch = Files.lines(path).findFirst().orElse(null); - } - } - // Availability already checked by Controller.onStart - logger.fine(() -> "Watching namespace \"" + namespaceToWatch + "\"."); - - // Get all our API versions - var client = Config.defaultClient(); - var apis = new ApisApi(client).getAPIVersions(); - var vmOpApiVersions = apis.getGroups().stream() - .filter(g -> g.getName().equals(VM_OP_GROUP)).findFirst() - .map(V1APIGroup::getVersions).stream().flatMap(l -> l.stream()) - .map(V1GroupVersionForDiscovery::getVersion).toList(); - - // Remove left overs - var coa = new CustomObjectsApi(client); - purge(client, coa, vmOpApiVersions); - - // Start a watcher thread for each existing CRD version. - // The watcher will send us an "ADDED" for each existing VM. - for (var version : vmOpApiVersions) { - coa.getAPIResources(VM_OP_GROUP, version) - .getResources().stream() - .filter(r -> VM_OP_KIND_VM.equals(r.getKind())) - .findFirst() - .ifPresent(crd -> watchVmDefs(crd, version)); - } - } - - @SuppressWarnings("PMD.CognitiveComplexity") - private void purge(ApiClient client, CustomObjectsApi coa, - List vmOpApiVersions) throws ApiException { - // Get existing CRs (VMs) - Set known = new HashSet<>(); - for (var version : vmOpApiVersions) { - // Get all known CR instances. - coa.getAPIResources(VM_OP_GROUP, version) - .getResources().stream() - .filter(r -> VM_OP_KIND_VM.equals(r.getKind())) - .findFirst() - .ifPresent(crd -> known.addAll(getKnown(client, crd, version))); - } - - ListOptions opts = new ListOptions(); - opts.setLabelSelector( - "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," - + "app.kubernetes.io/name=" + APP_NAME); - for (String resource : List.of("apps/v1/statefulsets", - "v1/configmaps", "v1/secrets")) { - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", - "PMD.AvoidDuplicateLiterals" }) - var resParts = new LinkedList<>(List.of(resource.split("/"))); - var group = resParts.size() == 3 ? resParts.poll() : ""; - var version = resParts.poll(); - var plural = resParts.poll(); - // Get resources, selected by label - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - var api = new DynamicKubernetesApi(group, version, plural, client); - var listObj = api.list(namespaceToWatch, opts).getObject(); - if (listObj == null) { - continue; - } - for (var obj : listObj.getItems()) { - String instance = obj.getMetadata().getLabels() - .get("app.kubernetes.io/instance"); - if (!known.contains(instance)) { - var resName = obj.getMetadata().getName(); - var result = api.delete(namespaceToWatch, resName); - if (!result.isSuccess()) { - logger.warning(() -> "Cannot cleanup resource \"" - + resName + "\": " + result.toString()); - } - } - } - } - } - - private Set getKnown(ApiClient client, V1APIResource crd, - String version) { - Set result = new HashSet<>(); - var api = new DynamicKubernetesApi(VM_OP_GROUP, version, - crd.getName(), client); - for (var item : api.list(namespaceToWatch).getObject().getItems()) { - if (!VM_OP_KIND_VM.equals(item.getKind())) { - continue; - } - result.add(item.getMetadata().getName()); - } - return result; - } - - private void watchVmDefs(V1APIResource crd, String version) { - @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", - "PMD.AvoidCatchingThrowable", "PMD.AvoidCatchingGenericException" }) - var watcher = new Thread(() -> { - try { - // Watch sometimes terminates without apparent reason. - while (true) { - Instant startedAt = Instant.now(); - var client = Config.defaultClient(); - var coa = new CustomObjectsApi(client); - var call = coa.listNamespacedCustomObjectCall(VM_OP_GROUP, - version, namespaceToWatch, crd.getName(), null, false, - null, null, null, null, null, null, null, true, null); - try (Watch watch - = Watch.createWatch(client, call, - new TypeToken>() { - }.getType())) { - for (Watch.Response item : watch) { - handleVmDefinitionChange(crd, item); - } - } catch (IOException | ApiException | RuntimeException e) { - logger.log(Level.FINE, e, () -> "Problem watching \"" - + crd.getName() + "\" (will retry): " - + e.getMessage()); - delayRestart(startedAt); - } - } - } catch (Throwable e) { - logger.log(Level.SEVERE, e, () -> "Probem watching: " - + e.getMessage()); - } - fire(new Stop()); - }); - watcher.setDaemon(true); - watcher.start(); - } - - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") - private void delayRestart(Instant started) { - var runningFor = Duration - .between(started, Instant.now()).toMillis(); - if (runningFor < 5000) { - logger.log(Level.FINE, () -> "Waiting... "); - try { - Thread.sleep(5000 - runningFor); - } catch (InterruptedException e1) { // NOPMD - // Retry - } - logger.log(Level.FINE, () -> "Retrying"); - } - } - - private void handleVmDefinitionChange(V1APIResource vmsCrd, - Watch.Response item) { - V1ObjectMeta metadata = item.object.getMetadata(); - VmChannel channel = channels.computeIfAbsent(metadata.getName(), - k -> { - try { - return new VmChannel(channel(), newEventPipeline(), - Config.defaultClient()); - } catch (IOException e) { - logger.log(Level.SEVERE, e, () -> "Failed to create client" - + " for handling changes: " + e.getMessage()); - return null; - } - }); - if (channel == null) { - return; - } - - // if (event.type() == Type.DELETED) { - - // Get full definition and associate with channel as backup - var apiVersion = K8s.version(item.object.getApiVersion()); - DynamicKubernetesApi vmCrApi = new DynamicKubernetesApi(VM_OP_GROUP, - apiVersion, vmsCrd.getName(), channel.client()); - var vmDef = K8s.get(vmCrApi, metadata); - vmDef.ifPresent(def -> channel.setVmDefinition(def)); - - // Create and fire event - channel.pipeline().fire(new VmDefChanged(VmDefChanged.Type - .valueOf(item.type), - channel.setGeneration(item.object.getMetadata().getGeneration()), - vmsCrd, vmDef.orElse(channel.vmDefinition())), channel); - } - - /** - * Remove VM channel when VM is deleted. - * - * @param event the event - * @param channel the channel - */ - @Handler(priority = -10_000) - public void onVmDefChanged(VmDefChanged event, VmChannel channel) { - if (event.type() == Type.DELETED) { - channels.remove(event.vmDefinition().getMetadata().getName()); - } - } - -} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java index 48bc158..1d05ec9 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -57,7 +57,7 @@ * ``` * * Developers may also be interested in the usage of channels - * by the application's component: + * by the application's components: * * ![Main channels](app-channels.svg) * @@ -74,6 +74,8 @@ * * Component NioDispatcher as NioDispatcher <> * [Manager] *-up- [NioDispatcher] + * Component HttpConnector as HttpConnector <> + * [Manager] *-up- [HttpConnector] * Component FileSystemWatcher as FileSystemWatcher <> * [Manager] *-up- [FileSystemWatcher] * Component YamlConfigurationStore as YamlConfigurationStore <> @@ -81,8 +83,18 @@ * [YamlConfigurationStore] *-right[hidden]- [Controller] * * [Manager] *-- [Controller] - * [Controller] *-- [VmWatcher] - * [Controller] *-- [Reconciler] + * Component VmMonitor as VmMonitor <> + * [Controller] *-- [VmMonitor] + * [VmMonitor] -right[hidden]- [PoolMonitor] + * Component PoolMonitor as PoolMonitor <> + * [Controller] *-- [PoolMonitor] + * Component PodMonitor as PodMonitor <> + * [Controller] *-- [PodMonitor] + * [PodMonitor] -up[hidden]- VmMonitor + * Component DisplaySecretMonitor as DisplaySecretMonitor <> + * [Controller] *-- [DisplaySecretMonitor] + * [DisplaySecretMonitor] -up[hidden]- VmMonitor + * [Controller] *-left- [Reconciler] * [Controller] -right[hidden]- [GuiHttpServer] * * [Manager] *-down- [GuiSocketServer:8080] @@ -119,6 +131,7 @@ * [WebConsole] *-- [RoleConfigurator] * [WebConsole] *-- [RoleConletFilter] * [WebConsole] *-left- [LoginConlet] + * [WebConsole] *-right- [OidcClient] * * Component "ComponentCollector\nfor page resources" as cpr <> * [WebConsole] *-- [cpr] @@ -140,27 +153,42 @@ * mgr .left. [FileSystemWatcher] * mgr .right. [YamlConfigurationStore] * mgr .. [Controller] + * mgr .up. [Manager] * mgr .up. [VmWatcher] * mgr .. [Reconciler] * * () "guiTransport" as hT * hT .up. [GuiSocketServer:8080] * hT .down. [GuiHttpServer] + * hT .right[hidden]. [HttpConnector] * * [YamlConfigurationStore] -right[hidden]- hT * * () "guiHttp" as http * http .up. [GuiHttpServer] + * http .up. [HttpConnector] + * note top of [HttpConnector]: transport layer com-\nponents omitted * - * [PreferencesStore] .right. http + * [PreferencesStore] .. http + * [OidcClient] .up. http + * [LanguageSelector] .left. http * [InMemorySessionManager] .up. http - * [LanguageSelector] .up. http * * package "Conceptual WebConsole" { - * [ConsoleWeblet] .left. http + * [ConsoleWeblet] .right. http * [ConsoleWeblet] *-down- [WebConsole] * } * + * [Controller] .down[hidden]. [ConsoleWeblet] + * + * () "console" as console + * console .. WebConsole + * + * [OidcClient] .. console + * [LoginConlet] .right. console + * + * note right of console: More conlets\nconnect here + * * @enduml */ package org.jdrupes.vmoperator.manager; diff --git a/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml new file mode 100644 index 0000000..36054a2 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml @@ -0,0 +1,64 @@ +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VirtualMachine +metadata: + namespace: vmop-test + name: test-vm +spec: + image: + repository: docker-registry.lan.mnl.de + path: vmoperator/this.will.never.start + version: 0.0.0 + + cloudInit: + metaData: {} + + vm: + # state: Running + maximumRam: 4Gi + currentRam: 2Gi + maximumCpus: 4 + currentCpus: 2 + powerdownTimeout: 1 + + networks: + - user: {} + disks: + - cdrom: + image: https://test.com/test.iso + bootindex: 0 + - cdrom: + image: "image.iso" + - volumeClaimTemplate: + metadata: + name: system + annotations: + use_as: system-disk + spec: + storageClassName: local-path + resources: + requests: + storage: 1Gi + - volumeClaimTemplate: + spec: + storageClassName: local-path + resources: + requests: + storage: 1Gi + + display: + outputs: 2 + spice: + port: 5812 + usbRedirects: 2 + + resources: + requests: + cpu: 1 + memory: 2Gi + + loadBalancerService: + labels: + label2: replaced + label3: added + annotations: + anno1: added diff --git a/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml b/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml new file mode 100644 index 0000000..3a8451e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml @@ -0,0 +1,111 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../../deploy + +namespace: vmop-test + +patches: +- patch: |- + kind: PersistentVolumeClaim + apiVersion: v1 + metadata: + name: vmop-image-repository + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: local-path + +- patch: |- + kind: ConfigMap + apiVersion: v1 + metadata: + name: vm-operator + data: + # Keep in sync with config.yaml + config.yaml: | + "/Manager": + # clusterName: "test" + "/Controller": + "/Reconciler": + runnerData: + storageClassName: null + loadBalancerService: + labels: + label1: label1 + label2: toBeReplaced + annotations: + metallb.universe.tf/loadBalancerIPs: 192.168.168.1 + metallb.universe.tf/ip-allocated-from-pool: single-common + metallb.universe.tf/allow-shared-ip: single-common + "/GuiSocketServer": + port: 8888 + "/GuiHttpServer": + # This configures the GUI + "/ConsoleWeblet": + "/WebConsole": + "/LoginConlet": + users: + - name: admin + fullName: Administrator + password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." + - name: test1 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test2 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + - name: test3 + fullName: Test Account + password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" + "/RoleConfigurator": + rolesByUser: + # User admin has role admin + admin: + - admin + test1: + - user + test2: + - user + test3: + - user + # All users have role other + "*": + - other + replace: false + "/RoleConletFilter": + conletTypesByRole: + # Admins can use all conlets + admin: + - "*" + user: + - org.jdrupes.vmoperator.vmviewer.VmViewer + # Others cannot use any conlet (except login conlet to log out) + other: + - org.jgrapes.webconlet.locallogin.LoginConlet + "/ComponentCollector": + "/VmAccess": + displayResource: + preferredIpVersion: ipv4 + syncPreviewsFor: + - role: user +- target: + group: apps + version: v1 + kind: Deployment + name: vm-operator + patch: |- + - op: replace + path: /spec/template/spec/containers/0/image + value: docker-registry.lan.mnl.de/vmoperator/org.jdrupes.vmoperator.manager:test + - op: replace + path: /spec/template/spec/containers/0/imagePullPolicy + value: Always + - op: replace + path: /spec/replicas + value: 0 + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml b/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml deleted file mode 100644 index 0d395bd..0000000 --- a/org.jdrupes.vmoperator.manager/test-resources/unittest-vm.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: "vmoperator.jdrupes.org/v1" -kind: VirtualMachine -metadata: - namespace: vmop-dev - name: unittest-vm -spec: - resources: - requests: - cpu: 1 - memory: 2Gi - - loadBalancerService: - labels: - test2: null - test3: added - - vm: - # state: Running - maximumRam: 4Gi - currentRam: 2Gi - maximumCpus: 4 - currentCpus: 2 - powerdownTimeout: 1 - - networks: - - user: {} - disks: - - cdrom: - # image: "" - image: https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso - # image: "Fedora-Workstation-Live-x86_64-38-1.6.iso" - - display: - spice: - port: 5812 diff --git a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java index 26eb387..d600d3c 100644 --- a/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -1,13 +1,32 @@ package org.jdrupes.vmoperator.manager; -import io.fabric8.kubernetes.client.Config; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClientBuilder; -import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext; +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.custom.Quantity; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.FileReader; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import org.jdrupes.vmoperator.common.Constants; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sDynamicStub; +import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; +import org.jdrupes.vmoperator.common.K8sV1DeploymentStub; +import org.jdrupes.vmoperator.common.K8sV1PodStub; +import org.jdrupes.vmoperator.common.K8sV1PvcStub; +import org.jdrupes.vmoperator.common.K8sV1SecretStub; +import org.jdrupes.vmoperator.common.K8sV1ServiceStub; +import org.jdrupes.vmoperator.util.DataPath; import org.junit.jupiter.api.AfterAll; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.BeforeAll; @@ -18,8 +37,12 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; class BasicTests { - private static KubernetesClient client; - private static ResourceDefinitionContext vmsContext; + private static K8sClient client; + private static APIResource vmsContext; + private static K8sV1DeploymentStub mgrDeployment; + private static K8sDynamicStub vmStub; + private static final String VM_NAME = "test-vm"; + private static final Object EXISTS = new Object(); @BeforeAll static void setUpBeforeClass() throws Exception { @@ -27,29 +50,44 @@ class BasicTests { assertNotNull(testCluster); // Get client - client = new KubernetesClientBuilder() - .withConfig(Config.autoConfigure(testCluster)).build(); - - // Context for working with our CR - vmsContext = new ResourceDefinitionContext.Builder() - .withGroup("vmoperator.jdrupes.org").withKind("VirtualMachine") - .withPlural("vms").withNamespaced(true).withVersion("v1").build(); - - // Cleanup - var resourcesInNamespace = client.genericKubernetesResources(vmsContext) - .inNamespace("vmop-dev"); - resourcesInNamespace.withName("unittest-vm").delete(); + client = new K8sClient(); // Update manager pod by scaling deployment - client.apps().deployments().inNamespace("vmop-dev") - .withName("vm-operator").scale(0); - client.apps().deployments().inNamespace("vmop-dev") - .withName("vm-operator").scale(1); + mgrDeployment + = K8sV1DeploymentStub.get(client, "vmop-test", "vm-operator"); + mgrDeployment.scale(0); + mgrDeployment.scale(1); + waitForManager(); + // Context for working with our CR + var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM); + assertTrue(apiRes.isPresent()); + vmsContext = apiRes.get(); + + // Cleanup existing VM + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) + .delete(); + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME); + var secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); + for (var secret : secrets) { + secret.delete(); + } + deletePvcs(); + + // Load from Yaml + var rdr = new FileReader("test-resources/basic-vm.yaml"); + vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr); + assertTrue(vmStub.model().isPresent()); + } + + private static void waitForManager() + throws ApiException, InterruptedException { // Wait until available for (int i = 0; i < 10; i++) { - if (client.apps().deployments().inNamespace("vmop-dev") - .withName("vm-operator").get().getStatus().getConditions() + if (mgrDeployment.model().get().getStatus().getConditions() .stream().filter(c -> "Available".equals(c.getType())).findAny() .isPresent()) { return; @@ -59,63 +97,245 @@ class BasicTests { fail("vm-operator not deployed."); } + private static void deletePvcs() throws ApiException { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME); + var knownPvcs = K8sV1PvcStub.list(client, "vmop-test", listOpts); + for (var pvc : knownPvcs) { + pvc.delete(); + } + } + @AfterAll static void tearDownAfterClass() throws Exception { + // Cleanup + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) + .delete(); + deletePvcs(); + // Bring down manager - client.apps().deployments().inNamespace("vmop-dev") - .withName("vm-operator").scale(0); - client.close(); + mgrDeployment.scale(0); } @Test - void test() throws IOException, InterruptedException { - // Load from Yaml - var vm = client.genericKubernetesResources(vmsContext) - .load(Files - .newInputStream(Path.of("test-resources/unittest-vm.yaml"))); - // Create Custom Resource - vm.create(); - - // Wait for created resources - assertTrue(waitForConfigMap()); - assertTrue(waitForStatefulSet()); - + void testConfigMap() + throws IOException, InterruptedException, ApiException { + K8sV1ConfigMapStub stub + = K8sV1ConfigMapStub.get(client, "vmop-test", VM_NAME); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } // Check config map - var config = client.configMaps().inNamespace("vmop-dev") - .withName("unittest-vm").get(); - var yaml = new Yaml(new SafeConstructor(new LoaderOptions())) - .load((String) config.getData().get("config.yaml")); - @SuppressWarnings("unchecked") - var currentRam = ((Map>>) yaml) - .get("/Runner").get("vm").get("maximumRam"); - assertEquals("4 GiB", currentRam); + var config = stub.model().get(); + Map, Object> toCheck = Map.of( + List.of("namespace"), "vmop-test", + List.of("name"), VM_NAME, + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS); + checkProps(config.getMetadata(), toCheck); - // Cleanup - var resourcesInNamespace = client.genericKubernetesResources(vmsContext) - .inNamespace("vmop-dev"); - resourcesInNamespace.withName("unittest-vm").delete(); + toCheck = new LinkedHashMap<>(); + toCheck.put(List.of("/Runner", "guestShutdownStops"), false); + toCheck.put(List.of("/Runner", "cloudInit", "metaData", "instance-id"), + EXISTS); + toCheck.put( + List.of("/Runner", "cloudInit", "metaData", "local-hostname"), + VM_NAME); + toCheck.put(List.of("/Runner", "cloudInit", "userData"), Map.of()); + toCheck.put(List.of("/Runner", "vm", "maximumRam"), "4 GiB"); + toCheck.put(List.of("/Runner", "vm", "currentRam"), "2 GiB"); + toCheck.put(List.of("/Runner", "vm", "maximumCpus"), 4); + toCheck.put(List.of("/Runner", "vm", "currentCpus"), 2); + toCheck.put(List.of("/Runner", "vm", "powerdownTimeout"), 1); + toCheck.put(List.of("/Runner", "vm", "network", 0, "type"), "user"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "file"), + "https://test.com/test.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "bootindex"), 0); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "file"), + "/var/local/vmop-image-repository/image.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "resource"), + "/dev/system-disk"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "resource"), + "/dev/disk-1"); + toCheck.put(List.of("/Runner", "vm", "display", "outputs"), 2); + toCheck.put(List.of("/Runner", "vm", "display", "spice", "port"), 5812); + toCheck.put( + List.of("/Runner", "vm", "display", "spice", "usbRedirects"), 2); + var cm = new Yaml(new SafeConstructor(new LoaderOptions())) + .load(config.getData().get("config.yaml")); + checkProps(cm, toCheck); } - private boolean waitForConfigMap() throws InterruptedException { + @Test + void testDisplaySecret() throws ApiException, InterruptedException { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME + "," + + "app.kubernetes.io/component=" + DisplaySecret.NAME); + Collection secrets = null; for (int i = 0; i < 10; i++) { - if (client.configMaps().inNamespace("vmop-dev") - .withName("unittest-vm").get() != null) { - return true; + secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); + if (secrets.size() > 0) { + break; } Thread.sleep(1000); } - return false; + assertEquals(1, secrets.size()); + var secretData = secrets.iterator().next().model().get().getData(); + checkProps(secretData, Map.of( + List.of("display-password"), EXISTS)); + assertEquals("now", new String(secretData.get("password-expiry"))); } - private boolean waitForStatefulSet() throws InterruptedException { + @Test + void testRunnerPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-runner-data"); for (int i = 0; i < 10; i++) { - if (client.apps().statefulSets().inNamespace("vmop-dev") - .withName("unittest-vm").get() != null) { - return true; + if (stub.model().isPresent()) { + break; } Thread.sleep(1000); } - return false; + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Mi"))); + } + + @Test + void testSystemDiskPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-system-disk"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("annotations", "use_as"), "system-disk")); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Gi"))); + } + + @Test + void testDisk1Pvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-disk-1"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pvc = stub.model().get(); + checkProps(pvc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); + checkProps(pvc.getSpec(), Map.of( + List.of("resources", "requests", "storage"), + Quantity.fromString("1Gi"))); + } + + @Test + void testPod() throws ApiException, InterruptedException { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + assertTrue(vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" + + "\", \"value\": \"Running\"}]"), + client.defaultPatchOptions()).isPresent()); + var stub = K8sV1PodStub.get(client, "vmop-test", VM_NAME); + for (int i = 0; i < 20; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var pod = stub.model().get(); + checkProps(pod.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/component"), APP_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS)); + checkProps(pod.getSpec(), Map.of( + List.of("containers", 0, "image"), EXISTS, + List.of("containers", 0, "name"), VM_NAME, + List.of("containers", 0, "resources", "requests", "cpu"), + Quantity.fromString("1"))); + } + + @Test + public void testLoadBalancer() throws ApiException, InterruptedException { + var stub = K8sV1ServiceStub.get(client, "vmop-test", VM_NAME); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + break; + } + Thread.sleep(1000); + } + var svc = stub.model().get(); + checkProps(svc.getMetadata(), Map.of( + List.of("labels", "app.kubernetes.io/name"), APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("labels", "label1"), "label1", + List.of("labels", "label2"), "replaced", + List.of("labels", "label3"), "added", + List.of("annotations", "metallb.universe.tf/loadBalancerIPs"), + "192.168.168.1", + List.of("annotations", "anno1"), "added")); + } + + private void checkProps(Object obj, + Map, Object> toCheck) { + for (var entry : toCheck.entrySet()) { + var prop = DataPath.get(obj, entry.getKey().toArray()); + assertTrue(prop.isPresent(), () -> "Property " + entry.getKey() + + " not found in " + obj); + + // Check for existance only + if (entry.getValue() == EXISTS) { + continue; + } + assertEquals(entry.getValue(), prop.get()); + } } } diff --git a/org.jdrupes.vmoperator.runner.qemu/.eclipse-pmd b/org.jdrupes.vmoperator.runner.qemu/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.runner.qemu/.eclipse-pmd +++ b/org.jdrupes.vmoperator.runner.qemu/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.runner.qemu/build.gradle b/org.jdrupes.vmoperator.runner.qemu/build.gradle index ec7de7f..695c815 100644 --- a/org.jdrupes.vmoperator.runner.qemu/build.gradle +++ b/org.jdrupes.vmoperator.runner.qemu/build.gradle @@ -9,14 +9,14 @@ plugins { } dependencies { - implementation 'org.jgrapes:org.jgrapes.core:[1.19.0,2)' - implementation 'org.jgrapes:org.jgrapes.io:[2.7.0,3)' - implementation 'org.jgrapes:org.jgrapes.http:[3.1.0,4)' - implementation 'org.jgrapes:org.jgrapes.util:[1.31.0,2)' + implementation 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' + implementation 'org.jgrapes:org.jgrapes.util:[1.38.1,2)' + implementation 'org.jgrapes:org.jgrapes.io:[2.12.1,3)' + implementation 'org.jgrapes:org.jgrapes.http:[3.5.0,4)' implementation project(':org.jdrupes.vmoperator.common') implementation 'commons-cli:commons-cli:1.5.0' - implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:[2.15.1,3]' + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:[2.16.1]' runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)' } @@ -31,102 +31,99 @@ application { mainClass = 'org.jdrupes.vmoperator.runner.qemu.Runner' } -task buildArchImage(type: Exec) { +project.ext.gitBranch = grgit.branch.current.name.replace('/', '-') +def registry = "${project.rootProject.properties['docker.registry']}" +def rootVersion = rootProject.version + +task buildImageArch(type: Exec) { dependsOn installDist inputs.files 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch' - commandLine 'podman', 'build', '-t', "${project.name}-arch:${project.version}",\ + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}-arch:${project.gitBranch}",\ '-f', 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch', '.' } -task tagLatestArchImage(type: Exec) { - dependsOn buildArchImage +task pushImageArch(type: Exec) { + dependsOn buildImageArch + + commandLine 'podman', 'push', '--tls-verify=false', \ + "${project.name}-arch:${project.gitBranch}", \ + "${registry}/${project.name}-arch:${project.gitBranch}" +} + +task tagWithVersionArch(type: Exec) { + dependsOn pushImageArch + + enabled = !rootVersion.contains("SNAPSHOT") - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'tag', "${project.name}-arch:${project.version}",\ - "${project.name}-arch:latest" + commandLine 'podman', 'push', \ + "${project.name}-arch:${project.gitBranch}",\ + "${registry}/${project.name}-arch:${project.version}" } -task buildLatestArchImage { - dependsOn buildArchImage - dependsOn tagLatestArchImage +task tagAsLatestArch(type: Exec) { + dependsOn tagWithVersionArch + + enabled = !rootVersion.contains("SNAPSHOT") + && !rootVersion.contains("alpha") \ + && !rootVersion.contains("beta") \ + || project.rootProject.properties['docker.testRegistry'] \ + && project.rootProject.properties['docker.registry'] \ + == project.rootProject.properties['docker.testRegistry'] + + commandLine 'podman', 'push', \ + "${project.name}-arch:${project.gitBranch}",\ + "${registry}/${project.name}-arch:latest" } -task pushArchImage(type: Exec) { - dependsOn buildArchImage - - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}-arch:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-arch:${project.version}" -} - -task pushArchLatestImage(type: Exec) { - dependsOn buildLatestArchImage - - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}-arch:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-arch:latest" -} - -task buildAlpineImage(type: Exec) { +task buildImageAlpine(type: Exec) { dependsOn installDist inputs.files 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine' - commandLine 'podman', 'build', '-t', "${project.name}-alpine:${project.version}",\ + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}-alpine:${project.gitBranch}",\ '-f', 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine', '.' } -task tagLatestAlpineImage(type: Exec) { - dependsOn buildAlpineImage +task pushImageAlpine(type: Exec) { + dependsOn buildImageAlpine + + commandLine 'podman', 'push', '--tls-verify=false', \ + "localhost/${project.name}-alpine:${project.gitBranch}", \ + "${registry}/${project.name}-alpine:${project.gitBranch}" +} + +task tagWithVersionAlpine(type: Exec) { + dependsOn pushImageAlpine + + enabled = !rootVersion.contains("SNAPSHOT") - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'tag', "${project.name}-alpine:${project.version}",\ - "${project.name}-alpine:latest" + commandLine 'podman', 'push', \ + "${project.name}-alpine:${project.gitBranch}",\ + "${registry}/${project.name}-alpine:${project.version}" } -task buildLatestAlpineImage { - dependsOn buildAlpineImage - dependsOn tagLatestAlpineImage +task tagAsLatestAlpine(type: Exec) { + dependsOn tagWithVersionAlpine + + enabled = !rootVersion.contains("SNAPSHOT") + && !rootVersion.contains("alpha") \ + && !rootVersion.contains("beta") \ + || project.rootProject.properties['docker.testRegistry'] \ + && project.rootProject.properties['docker.registry'] \ + == project.rootProject.properties['docker.testRegistry'] + + commandLine 'podman', 'push', \ + "${project.name}-alpine:${project.gitBranch}",\ + "${registry}/${project.name}-alpine:latest" } -task pushAlpineImage(type: Exec) { - dependsOn buildAlpineImage - - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}-alpine:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-alpine:${project.version}" +task publishImage { + dependsOn pushImageArch + dependsOn tagWithVersionArch + dependsOn tagAsLatestArch + dependsOn pushImageAlpine + dependsOn tagWithVersionAlpine + dependsOn tagAsLatestAlpine } - -task pushAlpineLatestImage(type: Exec) { - dependsOn buildLatestAlpineImage - - enabled = !project.version.contains("SNAPSHOT") - && !project.version.contains("alpha") \ - && !project.version.contains("beta") \ - - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}-alpine:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-alpine:latest" -} - -task pushImages { - dependsOn pushArchImage - dependsOn pushArchLatestImage - dependsOn pushAlpineImage - dependsOn pushAlpineLatestImage -} - diff --git a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml index 461e79b..e23a2ec 100644 --- a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml @@ -27,6 +27,31 @@ # be set when starting the runner during development e.g. from the IDE. # "namespace": ... + # Defines data for generating a cloud-init ISO image that is + # attached to the VM. + # "cloudInit": + # "metaData": + # ... + # "userData": + # ... + # "networkConfig": + # ... + # + # If .metaData.instance-id is missing, an id is generated from the + # config file's modification timestamp. .userData and .networkConfig + # are optional. + + # Whether a guest initiated shutdown event patches the state + # property in the CRD. + # "guestShutdownStops": + # false + + # When incremented, the VM is reset. The value has no default value, + # i.e. if you start the VM without a value for this property, and + # decide to trigger a reset later, you have to first set the value + # and then inrement it. + # "resetCounter": 1 + # Define the VM (required) "vm": # The VM's name (required) diff --git a/org.jdrupes.vmoperator.runner.qemu/display-password b/org.jdrupes.vmoperator.runner.qemu/display-password new file mode 100644 index 0000000..97c1abb --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/display-password @@ -0,0 +1 @@ +test-vm \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/password-expiry b/org.jdrupes.vmoperator.runner.qemu/password-expiry new file mode 100644 index 0000000..8a606d5 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/password-expiry @@ -0,0 +1 @@ ++1800 \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml b/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml index 9aadbf6..600f0ad 100644 --- a/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml @@ -8,10 +8,16 @@ - "/usr/share/edk2/ovmf/OVMF_CODE.fd" - "/usr/share/edk2/x64/OVMF_CODE.fd" - "/usr/share/OVMF/OVMF_CODE.fd" + # Use 4M version as fallback (if smaller version not available) + - "/usr/share/edk2/ovmf-4m/OVMF_CODE.fd" + - "/usr/share/edk2/x64/OVMF_CODE.4m.fd" "vars": - "/usr/share/edk2/ovmf/OVMF_VARS.fd" - "/usr/share/edk2/x64/OVMF_VARS.fd" - "/usr/share/OVMF/OVMF_VARS.fd" + # Use 4M version as fallback (if smaller version not available) + - "/usr/share/edk2/ovmf-4m/OVMF_VARS.fd" + - "/usr/share/edk2/x64/OVMF_VARS.4m.fd" "uefi-4m": "rom": - "/usr/share/edk2/ovmf-4m/OVMF_CODE.fd" diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java new file mode 100644 index 0000000..6303794 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/AgentConnector.java @@ -0,0 +1,122 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import org.jdrupes.vmoperator.runner.qemu.events.VserportChangeEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; + +/** + * A component that handles the communication with an agent + * running in the VM. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public abstract class AgentConnector extends QemuConnector { + + protected String channelId; + + /** + * Instantiates a new agent connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public AgentConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * Extracts the channel id and the socket path from the QEMU + * command line. + * + * @param command the command + * @param chardev the chardev + */ + @SuppressWarnings("PMD.CognitiveComplexity") + protected void configureConnection(List command, String chardev) { + Path socketPath = null; + for (var arg : command) { + if (arg.startsWith("virtserialport,") + && arg.contains("chardev=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("id=")) { + channelId = prop.substring(3); + } + } + } + if (arg.startsWith("socket,") + && arg.contains("id=" + chardev)) { + for (var prop : arg.split(",")) { + if (prop.startsWith("path=")) { + socketPath = Path.of(prop.substring(5)); + } + } + } + } + if (channelId == null || socketPath == null) { + logger.warning(() -> "Definition of chardev " + chardev + + " missing in runner template."); + return; + } + logger.fine(() -> getClass().getSimpleName() + " configured with" + + " channelId=" + channelId); + super.configure(socketPath); + } + + /** + * When the virtual serial port with the configured channel id has + * been opened call {@link #agentConnected()}. + * + * @param event the event + */ + @Handler + public void onVserportChanged(VserportChangeEvent event) { + if (event.id().equals(channelId)) { + if (event.isOpen()) { + agentConnected(); + } else { + agentDisconnected(); + } + } + } + + /** + * Called when the agent in the VM opens the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentConnected() { + // Default is to do nothing. + } + + /** + * Called when the agent in the VM closes the connection. The + * default implementation does nothing. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void agentDisconnected() { + // Default is to do nothing. + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java index b7c960a..c4ac871 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java @@ -25,9 +25,9 @@ import java.util.concurrent.ConcurrentHashMap; import org.jdrupes.vmoperator.runner.qemu.commands.QmpChangeMedium; import org.jdrupes.vmoperator.runner.qemu.commands.QmpOpenTray; import org.jdrupes.vmoperator.runner.qemu.commands.QmpRemoveMedium; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.TrayMovedEvent; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; @@ -36,7 +36,6 @@ import org.jgrapes.core.annotation.Handler; /** * The Class CdMediaController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class CdMediaController extends Component { /** @@ -55,7 +54,6 @@ public class CdMediaController extends Component { * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") public CdMediaController(Channel componentChannel) { super(componentChannel); } @@ -66,10 +64,9 @@ public class CdMediaController extends Component { * @param event the event */ @Handler - @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", - "PMD.AvoidInstantiatingObjectsInLoops" }) - public void onConfigureQemu(RunnerConfigurationUpdate event) { - if (event.state() == State.TERMINATING) { + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) + public void onConfigureQemu(ConfigureQemu event) { + if (event.runState() == RunState.TERMINATING) { return; } @@ -82,7 +79,7 @@ public class CdMediaController extends Component { } var driveId = "cd" + cdCounter++; var newFile = Optional.ofNullable(drives[i].file).orElse(""); - if (event.state() == State.STARTING) { + if (event.runState() == RunState.STARTING) { current.put(driveId, newFile); continue; } @@ -116,8 +113,8 @@ public class CdMediaController extends Component { */ @Handler public void onTrayMovedEvent(TrayMovedEvent event) { - trayState.put(event.driveId(), event.state()); - if (event.state() == TrayState.OPEN + trayState.put(event.driveId(), event.trayState()); + if (event.trayState() == TrayState.OPEN && pending.containsKey(event.driveId())) { changeMedium(event.driveId()); } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java index fadc4a0..7aec209 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CommandDefinition.java @@ -42,6 +42,7 @@ class CommandDefinition { for (JsonNode path : jsonData.get("executable")) { if (Files.isExecutable(Path.of(path.asText()))) { command.add(path.asText()); + break; } } if (command.isEmpty()) { @@ -68,4 +69,9 @@ class CommandDefinition { public String name() { return name; } + + @Override + public String toString() { + return "Command " + name + ": " + command; + } } \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java index bbee56e..87e8c76 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Configuration.java @@ -24,10 +24,14 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.util.Dto; import org.jdrupes.vmoperator.util.FsdUtils; @@ -35,11 +39,14 @@ import org.jdrupes.vmoperator.util.FsdUtils; /** * The configuration information from the configuration file. */ -@SuppressWarnings("PMD.ExcessivePublicCount") public class Configuration implements Dto { - @SuppressWarnings("PMD.FieldNamingConventions") + private static final String CI_INSTANCE_ID = "instance-id"; + protected final Logger logger = Logger.getLogger(getClass().getName()); + /** Configuration timestamp. */ + public Instant asOf; + /** The data dir. */ public Path dataDir; @@ -64,15 +71,42 @@ public class Configuration implements Dto { /** The firmware vars. */ public Path firmwareVars; + /** The display password. */ + public boolean hasDisplayPassword; + + /** Optional cloud-init data. */ + public CloudInit cloudInit; + + /** If guest shutdown changes CRD .vm.state to "Stopped". */ + public boolean guestShutdownStops; + + /** Increments of the reset counter trigger a reset of the VM. */ + public Integer resetCounter; + /** The vm. */ @SuppressWarnings("PMD.ShortVariable") public Vm vm; + /** + * Subsection "cloud-init". + */ + public static class CloudInit implements Dto { + + /** The meta data. */ + public Map metaData; + + /** The user data. */ + public Map userData; + + /** The network config. */ + public Map networkConfig; + } + /** * Subsection "vm". */ @SuppressWarnings({ "PMD.ShortClassName", "PMD.TooManyFields", - "PMD.DataClass" }) + "PMD.DataClass", "PMD.AvoidDuplicateLiterals" }) public static class Vm implements Dto { /** The name. */ @@ -160,6 +194,7 @@ public class Configuration implements Dto { /** * Subsection "network". */ + @SuppressWarnings("PMD.DataClass") public static class Network implements Dto { /** The type. */ @@ -181,6 +216,7 @@ public class Configuration implements Dto { /** * Subsection "drive". */ + @SuppressWarnings("PMD.DataClass") public static class Drive implements Dto { /** The type. */ @@ -203,12 +239,21 @@ public class Configuration implements Dto { * The Class Display. */ public static class Display implements Dto { + + /** The number of outputs. */ + public int outputs = 1; + + /** The logged in user. */ + public String loggedInUser; + + /** The spice. */ public Spice spice; } /** * Subsection "spice". */ + @SuppressWarnings("PMD.DataClass") public static class Spice implements Dto { /** The port. */ @@ -244,11 +289,11 @@ public class Configuration implements Dto { } checkDrives(); + checkCloudInit(); return true; } - @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") private void checkDrives() { for (Drive drive : vm.drives) { if (drive.file != null || drive.device != null @@ -268,11 +313,10 @@ public class Configuration implements Dto { } } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkRuntimeDir() { // Runtime directory (sockets etc.) if (runtimeDir == null) { - var appDir = FsdUtils.runtimeDir(Runner.APP_NAME.replace("-", "")); + var appDir = FsdUtils.runtimeDir(APP_NAME.replace("-", "")); if (!Files.exists(appDir) && appDir.toFile().mkdirs()) { try { // When appDir is derived from XDG_RUNTIME_DIR @@ -288,7 +332,7 @@ public class Configuration implements Dto { runtimeDir)); } } - runtimeDir = FsdUtils.runtimeDir(Runner.APP_NAME.replace("-", "")) + runtimeDir = FsdUtils.runtimeDir(APP_NAME.replace("-", "")) .resolve(vm.name); runtimeDir.toFile().mkdir(); swtpmSocket = runtimeDir.resolve("swtpm-sock"); @@ -304,12 +348,11 @@ public class Configuration implements Dto { return true; } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkDataDir() { // Data directory if (dataDir == null) { - dataDir = FsdUtils.dataHome(Runner.APP_NAME.replace("-", "")) - .resolve(vm.name); + dataDir + = FsdUtils.dataHome(APP_NAME.replace("-", "")).resolve(vm.name); } if (!Files.exists(dataDir)) { dataDir.toFile().mkdirs(); @@ -357,4 +400,18 @@ public class Configuration implements Dto { return true; } + + private void checkCloudInit() { + if (cloudInit == null) { + return; + } + + // Provide default for instance-id + if (cloudInit.metaData == null) { + cloudInit.metaData = new HashMap<>(); + } + if (!cloudInit.metaData.containsKey(CI_INSTANCE_ID)) { + cloudInit.metaData.put(CI_INSTANCE_ID, "v" + asOf.getEpochSecond()); + } + } } \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java new file mode 100644 index 0000000..b50b481 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -0,0 +1,152 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.EventsV1Event; +import java.io.IOException; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceInitializedEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; + +/** + * A (sub)component that updates the console status in the CR status. + * Created as child of {@link StatusUpdater}. + */ +public class ConsoleTracker extends VmDefUpdater { + + private VmDefinitionStub vmStub; + private String mainChannelClientHost; + private long mainChannelClientPort; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + */ + public ConsoleTracker(Channel componentChannel) { + super(componentChannel); + apiClient = (K8sClient) io.kubernetes.client.openapi.Configuration + .getDefaultApiClient(); + } + + /** + * Handle the start event. + * + * @param event the event + * @throws IOException + * @throws ApiException + */ + @Handler + public void onStart(Start event) { + if (namespace == null) { + return; + } + try { + vmStub = VmDefinitionStub.get(apiClient, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + namespace, vmName); + } catch (ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access VM object, terminating."); + event.cancel(true); + fire(new Exit(1)); + } + } + + /** + * On spice connected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) + public void onSpiceInitialized(SpiceInitializedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process connections using main channel. + if (event.channelType() != 1) { + return; + } + mainChannelClientHost = event.clientHost(); + mainChannelClientPort = event.clientPort(); + vmStub.updateStatus(from -> { + JsonObject status = updateCondition(from, "ConsoleConnected", true, + "Connected", "Connection from " + event.clientHost()); + status.addProperty(Status.CONSOLE_CLIENT, event.clientHost()); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(Crd.GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Connection from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + /** + * On spice disconnected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onSpiceDisconnected(SpiceDisconnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process disconnects from main channel. + if (!event.clientHost().equals(mainChannelClientHost) + || event.clientPort() != mainChannelClientPort) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = updateCondition(from, "ConsoleConnected", false, + "Disconnected", event.clientHost() + " has disconnected"); + status.addProperty(Status.CONSOLE_CLIENT, ""); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(Crd.GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Disconnected from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } +} diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-style.scss b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java similarity index 54% rename from org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-style.scss rename to org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java index 6d3168b..eac05fa 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/browser/VmConlet-style.scss +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java @@ -16,36 +16,26 @@ * along with this program. If not, see . */ -/* - * Conlet specific styles. +package org.jdrupes.vmoperator.runner.qemu; + +/** + * Some constants. */ - -.jdrupes-vmoperator-vmconlet-view-search { - display: flex; - justify-content: flex-end -} +public class Constants extends org.jdrupes.vmoperator.common.Constants { -.jdrupes-vmoperator-vmconlet-view-search form { - white-space: nowrap; -} + /** + * Process names. + */ + public static class ProcessName { -.jdrupes-vmoperator-vmconlet-view-action-list { - white-space: nowrap; -} + /** The Constant QEMU. */ + public static final String QEMU = "qemu"; -.jdrupes-vmoperator-vmconlet-view-action-list [role=button]:not(:last-child) { - margin-right: 0.5em; -} + /** The Constant SWTPM. */ + public static final String SWTPM = "swtpm"; -.jdrupes-vmoperator-vmconlet-view td { - vertical-align: top; -} + /** The Constant CLOUD_INIT_IMG. */ + public static final String CLOUD_INIT_IMG = "cloudInitImg"; + } -.jdrupes-vmoperator-vmconlet-view td:not([colspan]):first-child { - white-space: nowrap; } - -.jdrupes-vmoperator-vmconlet-view table td.details { - padding-left: 1em; -} - diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine index def82ef..d0104f3 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine @@ -2,7 +2,7 @@ FROM docker.io/alpine RUN apk update -RUN apk add qemu-system-x86_64 qemu-modules ovmf swtpm openjdk17 +RUN apk add qemu-system-x86_64 qemu-modules ovmf swtpm openjdk21 mtools RUN mkdir -p /etc/qemu && echo "allow all" > /etc/qemu/bridge.conf diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch index 379537b..0c2fd86 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch @@ -1,10 +1,12 @@ -FROM archlinux/archlinux +FROM docker.io/archlinux/archlinux:latest RUN systemd-firstboot -RUN pacman -Suy --noconfirm \ +RUN pacman-key --init \ + && pacman -Sy --noconfirm archlinux-keyring && pacman -Su --noconfirm \ && pacman -S --noconfirm which qemu-full virtiofsd \ - edk2-ovmf swtpm iproute2 bridge-utils jre17-openjdk-headless \ + edk2-ovmf swtpm iproute2 bridge-utils jre21-openjdk-headless \ + mtools \ && pacman -Scc --noconfirm # Remove all targets. diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java index f0face4..440da91 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java @@ -22,17 +22,18 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import org.jdrupes.vmoperator.runner.qemu.commands.QmpAddCpu; import org.jdrupes.vmoperator.runner.qemu.commands.QmpDelCpu; import org.jdrupes.vmoperator.runner.qemu.commands.QmpQueryHotpluggableCpus; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.CpuAdded; import org.jdrupes.vmoperator.runner.qemu.events.CpuDeleted; import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -40,12 +41,11 @@ import org.jgrapes.core.annotation.Handler; /** * The Class CpuController. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") public class CpuController extends Component { private Integer currentCpus; private Integer desiredCpus; - private RunnerConfigurationUpdate suspendedConfigure; + private ConfigureQemu suspendedConfigure; /** * Instantiates a new CPU controller. @@ -62,8 +62,8 @@ public class CpuController extends Component { * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { - if (event.state() == State.TERMINATING) { + public void onConfigureQemu(ConfigureQemu event) { + if (event.runState() == RunState.TERMINATING) { return; } Optional.ofNullable(event.configuration().vm.currentCpus) @@ -170,7 +170,7 @@ public class CpuController extends Component { private void checkCpus() { if (suspendedConfigure != null && desiredCpus != null - && currentCpus == desiredCpus.intValue()) { + && Objects.equals(currentCpus, desiredCpus)) { suspendedConfigure.resumeHandling(); suspendedConfigure = null; } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java new file mode 100644 index 0000000..c3bec93 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/DisplayController.java @@ -0,0 +1,171 @@ +/* + * VM-Operator + * Copyright (C) 2023,2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Level; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.FileChanged; +import org.jgrapes.util.events.WatchFile; + +/** + * The Class DisplayController. + */ +public class DisplayController extends Component { + + private String currentPassword; + private String protocol; + private final Path configDir; + private boolean canBeUpdated; + private boolean vmopAgentConnected; + private String loggedInUser; + + /** + * Instantiates a new Display controller. + * + * @param componentChannel the component channel + * @param configDir + */ + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) + public DisplayController(Channel componentChannel, Path configDir) { + super(componentChannel); + this.configDir = configDir; + fire(new WatchFile(configDir.resolve(DisplaySecret.PASSWORD))); + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + public void onConfigureQemu(ConfigureQemu event) { + if (event.runState() == RunState.TERMINATING) { + return; + } + protocol + = event.configuration().vm.display.spice != null ? "spice" : null; + loggedInUser = event.configuration().vm.display.loggedInUser; + configureLogin(); + if (event.runState() == RunState.STARTING) { + configurePassword(); + } + canBeUpdated = true; + } + + /** + * On vmop agent connected. + * + * @param event the event + */ + @Handler + public void onVmopAgentConnected(VmopAgentConnected event) { + vmopAgentConnected = true; + configureLogin(); + } + + private void configureLogin() { + if (!vmopAgentConnected) { + return; + } + Event evt = loggedInUser != null + ? new VmopAgentLogIn(loggedInUser) + : new VmopAgentLogOut(); + fire(evt); + } + + /** + * Watch for changes of the password file. + * + * @param event the event + */ + @Handler + public void onFileChanged(FileChanged event) { + if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) { + logger.fine(() -> "Display password updated"); + if (canBeUpdated) { + configurePassword(); + } + } + } + + private void configurePassword() { + if (protocol == null) { + return; + } + if (setDisplayPassword()) { + setPasswordExpiry(); + } + } + + private boolean setDisplayPassword() { + return readFromFile(DisplaySecret.PASSWORD).map(password -> { + if (Objects.equals(this.currentPassword, password)) { + return true; + } + this.currentPassword = password; + logger.fine(() -> "Updating display password"); + fire(new MonitorCommand( + new QmpSetDisplayPassword(protocol, password))); + return true; + }).orElse(false); + } + + private void setPasswordExpiry() { + readFromFile(DisplaySecret.EXPIRY).ifPresent(expiry -> { + logger.fine(() -> "Updating expiry time to " + expiry); + fire( + new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); + }); + } + + private Optional readFromFile(String dataItem) { + Path path = configDir.resolve(dataItem); + String label = dataItem.replace('-', ' '); + if (path.toFile().canRead()) { + logger.finer(() -> "Found " + label); + try { + return Optional.ofNullable(Files.readString(path)); + } catch (IOException e) { + logger.log(Level.WARNING, e, () -> "Cannot read " + label + ": " + + e.getMessage()); + return Optional.empty(); + } + } else { + logger.finer(() -> "No " + label); + return Optional.empty(); + } + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java new file mode 100644 index 0000000..45d2487 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/GuestAgentClient.java @@ -0,0 +1,226 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.time.Instant; +import java.util.LinkedList; +import java.util.Queue; +import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestPowerdown; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Components.Timer; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.ProcessExited; + +/** + * A component that handles the communication with the guest agent. + * + * If the log level for this class is set to fine, the messages + * exchanged on the monitor socket are logged. + */ +public class GuestAgentClient extends AgentConnector { + + private boolean connected; + private Instant powerdownStartedAt; + private int powerdownTimeout; + private Timer powerdownTimer; + private final Queue executing = new LinkedList<>(); + private Stop suspendedStop; + + /** + * Instantiates a new guest agent client. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public GuestAgentClient(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * When the agent has connected, request the OS information. + */ + @Override + protected void agentConnected() { + logger.fine(() -> "Guest agent connected"); + connected = true; + rep().fire(new GuestAgentCommand(new QmpGuestGetOsinfo())); + } + + @Override + protected void agentDisconnected() { + logger.fine(() -> "Guest agent disconnected"); + connected = false; + } + + /** + * Process agent input. + * + * @param line the line + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + protected void processInput(String line) throws IOException { + logger.finer(() -> "guest agent(in): " + line); + try { + var response = mapper.readValue(line, ObjectNode.class); + if (response.has("return") || response.has("error")) { + QmpCommand executed = executing.poll(); + logger.finer(() -> String.format("(Previous \"guest agent(in)\"" + + " is result from executing %s)", executed)); + if (executed instanceof QmpGuestGetOsinfo) { + var osInfo = new OsinfoEvent(response.get("return")); + logger.fine(() -> "Guest agent triggers: " + osInfo); + rep().fire(osInfo); + } + } + } catch (JsonProcessingException e) { + throw new IOException(e); + } + } + + /** + * On guest agent command. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) + public void onGuestAgentCommand(GuestAgentCommand event) + throws IOException { + if (qemuChannel() == null) { + return; + } + var command = event.command(); + logger.fine(() -> "Guest handles: " + event); + String asText; + try { + asText = command.asText(); + logger.finer(() -> "guest agent(out): " + asText); + } catch (JsonProcessingException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot serialize Json: " + e.getMessage()); + return; + } + synchronized (executing) { + if (writer().isPresent()) { + executing.add(command); + sendCommand(asText); + } + } + } + + /** + * Shutdown the VM. + * + * @param event the event + */ + @Handler(priority = 200) + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onStop(Stop event) { + if (!connected) { + logger.fine(() -> "No guest agent connection," + + " cannot send shutdown command"); + return; + } + + // We have a connection to the guest agent attempt shutdown. + powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { + var now = Instant.now(); + event.setAssociated(Instant.class, now); + return now; + }); + var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); + if (waitUntil.isBefore(Instant.now())) { + return; + } + event.suspendHandling(); + suspendedStop = event; + logger.fine(() -> "Attempting shutdown through guest agent," + + " waiting for termination until " + waitUntil); + powerdownTimer = Components.schedule(t -> { + logger.fine(() -> "Powerdown timeout reached."); + synchronized (this) { + powerdownTimer = null; + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + }, waitUntil); + rep().fire(new GuestAgentCommand(new QmpGuestPowerdown())); + } + + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onConfigureQemu(ConfigureQemu event) { + int newTimeout = event.configuration().vm.powerdownTimeout; + if (powerdownTimeout != newTimeout) { + powerdownTimeout = newTimeout; + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer + .reschedule(powerdownStartedAt.plusSeconds(newTimeout)); + } + + } + } + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java new file mode 100644 index 0000000..777478e --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuConnector.java @@ -0,0 +1,249 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.Writer; +import java.lang.reflect.UndeclaredThrowableException; +import java.net.UnixDomainSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.core.events.Stop; +import org.jgrapes.io.events.Closed; +import org.jgrapes.io.events.ConnectError; +import org.jgrapes.io.events.Input; +import org.jgrapes.io.events.OpenSocketConnection; +import org.jgrapes.io.util.ByteBufferWriter; +import org.jgrapes.io.util.LineCollector; +import org.jgrapes.net.SocketIOChannel; +import org.jgrapes.net.events.ClientConnected; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.FileChanged; +import org.jgrapes.util.events.WatchFile; + +/** + * A component that handles the communication with QEMU over a socket. + * + * Derived classes should log the messages exchanged on the socket + * if the log level is set to fine. + */ +public abstract class QemuConnector extends Component { + + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + + private EventPipeline rep; + private Path socketPath; + private SocketIOChannel qemuChannel; + + /** + * Instantiates a new QEMU connector. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public QemuConnector(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * As the initial configuration of this component depends on the + * configuration of the {@link Runner}, it doesn't have a handler + * for the {@link ConfigurationUpdate} event. The values are + * forwarded from the {@link Runner} instead. + * + * @param socketPath the socket path + */ + /* default */ void configure(Path socketPath) { + this.socketPath = socketPath; + logger.fine(() -> getClass().getSimpleName() + + " configured with socketPath=" + socketPath); + } + + /** + * Note the runner's event processor and delete the socket. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onStart(Start event) throws IOException { + rep = event.associated(EventPipeline.class).get(); + if (socketPath == null) { + return; + } + Files.deleteIfExists(socketPath); + fire(new WatchFile(socketPath)); + } + + /** + * Return the runner's event pipeline. + * + * @return the event pipeline + */ + protected EventPipeline rep() { + return rep; + } + + /** + * Watch for the creation of the swtpm socket and start the + * qemu process if it has been created. + * + * @param event the event + */ + @Handler + public void onFileChanged(FileChanged event) { + if (event.change() == FileChanged.Kind.CREATED + && event.path().equals(socketPath)) { + // qemu running, open socket + fire(new OpenSocketConnection( + UnixDomainSocketAddress.of(socketPath)) + .setAssociated(this, this)); + } + } + + /** + * Check if this is from opening the agent socket and if true, + * save the socket in the context and associate the channel with + * the context. + * + * @param event the event + * @param channel the channel + */ + @SuppressWarnings("resource") + @Handler + public void onClientConnected(ClientConnected event, + SocketIOChannel channel) { + event.openEvent().associated(this, getClass()).ifPresent(qc -> { + qemuChannel = channel; + channel.setAssociated(this, this); + channel.setAssociated(Writer.class, new ByteBufferWriter( + channel).nativeCharset()); + channel.setAssociated(LineCollector.class, + new LineCollector() + .consumer(line -> { + try { + qc.processInput(line); + } catch (IOException e) { + throw new UndeclaredThrowableException(e); + } + })); + qc.socketConnected(); + }); + } + + /** + * Return the QEMU channel if the connection has been established. + * + * @return the socket IO channel + */ + protected Optional qemuChannel() { + return Optional.ofNullable(qemuChannel); + } + + /** + * Return the {@link Writer} for the connection if the connection + * has been established. + * + * @return the optional + */ + protected Optional writer() { + return qemuChannel().flatMap(c -> c.associated(Writer.class)); + } + + /** + * Send the given command to QEMU. A newline is appended to the + * command automatically. + * + * @param command the command + * @return true, if successful + * @throws IOException Signals that an I/O exception has occurred. + */ + protected boolean sendCommand(String command) throws IOException { + if (writer().isEmpty()) { + return false; + } + writer().get().append(command).append('\n').flush(); + return true; + } + + /** + * Called when the connector has been connected to the socket. + */ + @SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract") + protected void socketConnected() { + // Default is to do nothing. + } + + /** + * Called when a connection attempt fails. + * + * @param event the event + */ + @Handler + public void onConnectError(ConnectError event) { + event.event().associated(this, getClass()).ifPresent(qc -> { + rep.fire(new Stop()); + }); + } + + /** + * Handle data from the socket connection. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onInput(Input event, SocketIOChannel channel) { + if (channel.associated(this, getClass()).isEmpty()) { + return; + } + channel.associated(LineCollector.class).ifPresent(collector -> { + collector.feed(event); + }); + } + + /** + * Process agent input. + * + * @param line the line + * @throws IOException Signals that an I/O exception has occurred. + */ + protected abstract void processInput(String line) throws IOException; + + /** + * On closed. + * + * @param event the event + * @param channel the channel + */ + @Handler + public void onClosed(Closed event, SocketIOChannel channel) { + channel.associated(this, getClass()).ifPresent(qc -> { + qemuChannel = null; + }); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java index 3d22b26..feeb76a 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitor.java @@ -19,47 +19,33 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.UndeclaredThrowableException; -import java.net.UnixDomainSocketAddress; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.LinkedList; import java.util.Queue; import java.util.logging.Level; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCapabilities; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpPowerdown; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.MonitorEvent; import org.jdrupes.vmoperator.runner.qemu.events.MonitorReady; import org.jdrupes.vmoperator.runner.qemu.events.MonitorResult; import org.jdrupes.vmoperator.runner.qemu.events.PowerdownEvent; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; import org.jgrapes.core.Components; import org.jgrapes.core.Components.Timer; -import org.jgrapes.core.EventPipeline; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; import org.jgrapes.core.events.Stop; import org.jgrapes.io.events.Closed; -import org.jgrapes.io.events.ConnectError; -import org.jgrapes.io.events.Input; -import org.jgrapes.io.events.OpenSocketConnection; -import org.jgrapes.io.util.ByteBufferWriter; -import org.jgrapes.io.util.LineCollector; +import org.jgrapes.io.events.ProcessExited; import org.jgrapes.net.SocketIOChannel; -import org.jgrapes.net.events.ClientConnected; import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.FileChanged; -import org.jgrapes.util.events.WatchFile; /** * A component that handles the communication over the Qemu monitor @@ -68,32 +54,29 @@ import org.jgrapes.util.events.WatchFile; * If the log level for this class is set to fine, the messages * exchanged on the monitor socket are logged. */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class QemuMonitor extends Component { +public class QemuMonitor extends QemuConnector { - private static ObjectMapper mapper = new ObjectMapper(); - - private EventPipeline rep; - private Path socketPath; private int powerdownTimeout; - private SocketIOChannel monitorChannel; private final Queue executing = new LinkedList<>(); private Instant powerdownStartedAt; private Stop suspendedStop; private Timer powerdownTimer; private boolean powerdownConfirmed; + private boolean monitorReady; /** - * Instantiates a new qemu monitor. + * Instantiates a new QEMU monitor. * * @param componentChannel the component channel + * @param configDir the config dir * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") - public QemuMonitor(Channel componentChannel) throws IOException { + public QemuMonitor(Channel componentChannel, Path configDir) + throws IOException { super(componentChannel); attach(new RamController(channel())); attach(new CpuController(channel())); + attach(new DisplayController(channel(), configDir)); attach(new CdMediaController(channel())); } @@ -107,120 +90,45 @@ public class QemuMonitor extends Component { * @param powerdownTimeout */ /* default */ void configure(Path socketPath, int powerdownTimeout) { - this.socketPath = socketPath; + super.configure(socketPath); this.powerdownTimeout = powerdownTimeout; } /** - * Handle the start event. - * - * @param event the event - * @throws IOException Signals that an I/O exception has occurred. + * When the socket is connected, send the capabilities command. */ - @Handler - public void onStart(Start event) throws IOException { - rep = event.associated(EventPipeline.class).get(); - if (socketPath == null) { - return; - } - Files.deleteIfExists(socketPath); - fire(new WatchFile(socketPath)); + @Override + protected void socketConnected() { + rep().fire(new MonitorCommand(new QmpCapabilities())); } - /** - * Watch for the creation of the swtpm socket and start the - * qemu process if it has been created. - * - * @param event the event - */ - @Handler - public void onFileChanged(FileChanged event) { - if (event.change() == FileChanged.Kind.CREATED - && event.path().equals(socketPath)) { - // qemu running, open socket - fire(new OpenSocketConnection( - UnixDomainSocketAddress.of(socketPath)) - .setAssociated(QemuMonitor.class, this)); - } - } - - /** - * Check if this is from opening the monitor socket and if true, - * save the socket in the context and associate the channel with - * the context. Then send the initial message to the socket. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onClientConnected(ClientConnected event, - SocketIOChannel channel) { - event.openEvent().associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = channel; - channel.setAssociated(QemuMonitor.class, this); - channel.setAssociated(Writer.class, new ByteBufferWriter( - channel).nativeCharset()); - channel.setAssociated(LineCollector.class, - new LineCollector() - .consumer(line -> { - try { - processMonitorInput(line); - } catch (IOException e) { - throw new UndeclaredThrowableException(e); - } - })); - fire(new MonitorCommand(new QmpCapabilities())); - }); - } - - /** - * Called when a connection attempt fails. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onConnectError(ConnectError event, SocketIOChannel channel) { - event.event().associated(QemuMonitor.class).ifPresent(qm -> { - rep.fire(new Stop()); - }); - } - - /** - * Handle data from qemu monitor connection. - * - * @param event the event - * @param channel the channel - */ - @Handler - public void onInput(Input event, SocketIOChannel channel) { - if (channel.associated(QemuMonitor.class).isEmpty()) { - return; - } - channel.associated(LineCollector.class).ifPresent(collector -> { - collector.feed(event); - }); - } - - private void processMonitorInput(String line) + @Override + protected void processInput(String line) throws IOException { - logger.fine(() -> "monitor(in): " + line); + logger.finer(() -> "monitor(in): " + line); try { var response = mapper.readValue(line, ObjectNode.class); if (response.has("QMP")) { - rep.fire(new MonitorReady()); + monitorReady = true; + logger.fine(() -> "QMP connection ready"); + rep().fire(new MonitorReady()); return; } if (response.has("return") || response.has("error")) { QmpCommand executed = executing.poll(); - logger.fine( + logger.finer( () -> String.format("(Previous \"monitor(in)\" is result " + "from executing %s)", executed)); - rep.fire(MonitorResult.from(executed, response)); + var monRes = MonitorResult.from(executed, response); + logger.fine(() -> "QMP triggers: " + monRes); + rep().fire(monRes); return; } if (response.has("event")) { - MonitorEvent.from(response).ifPresent(rep::fire); + MonitorEvent.from(response).ifPresent(me -> { + logger.fine(() -> "QMP triggers: " + me); + rep().fire(me); + }); } } catch (JsonProcessingException e) { throw new IOException(e); @@ -234,17 +142,10 @@ public class QemuMonitor extends Component { */ @Handler public void onClosed(Closed event, SocketIOChannel channel) { - channel.associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = null; - synchronized (this) { - if (powerdownTimer != null) { - powerdownTimer.cancel(); - } - if (suspendedStop != null) { - suspendedStop.resumeHandling(); - suspendedStop = null; - } - } + channel.associated(this, getClass()).ifPresent(qm -> { + super.onClosed(event, channel); + logger.fine(() -> "QMP connection closed."); + monitorReady = false; }); } @@ -252,29 +153,37 @@ public class QemuMonitor extends Component { * On monitor command. * * @param event the event + * @throws IOException */ @Handler - public void onExecQmpCommand(MonitorCommand event) { + @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", + "PMD.AvoidDuplicateLiterals" }) + public void onMonitorCommand(MonitorCommand event) throws IOException { + // Check prerequisites + if (!monitorReady && !(event.command() instanceof QmpCapabilities)) { + logger.severe(() -> "Premature QMP command (not ready): " + + event.command()); + rep().fire(new Stop()); + return; + } + + // Send the command var command = event.command(); + logger.fine(() -> "QMP handles: " + event.toString()); String asText; try { - asText = mapper.writeValueAsString(command.toJson()); + asText = command.asText(); + logger.finer(() -> "monitor(out): " + asText); } catch (JsonProcessingException e) { logger.log(Level.SEVERE, e, () -> "Cannot serialize Json: " + e.getMessage()); return; } - logger.fine(() -> "monitor(out): " + asText); synchronized (executing) { - monitorChannel.associated(Writer.class).ifPresent(writer -> { - try { - executing.add(command); - writer.append(asText).append('\n').flush(); - } catch (IOException e) { - // Cannot happen, but... - logger.log(Level.WARNING, e, () -> e.getMessage()); - } - }); + if (writer().isPresent()) { + executing.add(command); + sendCommand(asText); + } } } @@ -284,37 +193,51 @@ public class QemuMonitor extends Component { * @param event the event */ @Handler(priority = 100) + @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onStop(Stop event) { - if (monitorChannel != null) { - // We have a connection to Qemu, attempt ACPI shutdown. - event.suspendHandling(); - suspendedStop = event; - - // Attempt powerdown command. If not confirmed, assume - // "hanging" qemu process. - powerdownTimer = Components.schedule(t -> { - // Powerdown not confirmed - logger.fine(() -> "QMP powerdown command has not effect."); - synchronized (this) { - powerdownTimer = null; - if (suspendedStop != null) { - suspendedStop.resumeHandling(); - suspendedStop = null; - } - } - }, Duration.ofSeconds(1)); - logger.fine(() -> "Attempting QMP powerdown."); - powerdownStartedAt = Instant.now(); - fire(new MonitorCommand(new QmpPowerdown())); + if (!monitorReady) { + logger.fine(() -> "Not sending QMP powerdown command" + + " because QMP connection is closed"); + return; } + + // We have a connection to Qemu, attempt ACPI shutdown if time left + powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> { + var now = Instant.now(); + event.setAssociated(Instant.class, now); + return now; + }); + if (powerdownStartedAt.plusSeconds(powerdownTimeout) + .isBefore(Instant.now())) { + return; + } + event.suspendHandling(); + suspendedStop = event; + + // Send command. If not confirmed, assume "hanging" qemu process. + powerdownTimer = Components.schedule(t -> { + logger.fine(() -> "QMP powerdown command not confirmed"); + synchronized (this) { + powerdownTimer = null; + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + }, Duration.ofSeconds(5)); + logger.fine(() -> "Attempting QMP (ACPI) powerdown."); + rep().fire(new MonitorCommand(new QmpPowerdown())); } /** - * On powerdown event. + * When the powerdown event is confirmed, wait for termination + * or timeout. Termination is detected by the qemu process exiting + * (see {@link #onProcessExited(ProcessExited)}). * * @param event the event */ @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") public void onPowerdownEvent(PowerdownEvent event) { synchronized (this) { // Cancel confirmation timeout @@ -323,27 +246,54 @@ public class QemuMonitor extends Component { } // (Re-)schedule timer as fallback - logger.fine(() -> "QMP powerdown confirmed, waiting..."); + var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout); + logger.fine(() -> "QMP powerdown confirmed, waiting for" + + " termination until " + waitUntil); powerdownTimer = Components.schedule(t -> { logger.fine(() -> "Powerdown timeout reached."); synchronized (this) { + powerdownTimer = null; if (suspendedStop != null) { suspendedStop.resumeHandling(); suspendedStop = null; } } - }, powerdownStartedAt.plusSeconds(powerdownTimeout)); + }, waitUntil); powerdownConfirmed = true; } } + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + /** * On configure qemu. * * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onConfigureQemu(ConfigureQemu event) { int newTimeout = event.configuration().vm.powerdownTimeout; if (powerdownTimeout != newTimeout) { powerdownTimeout = newTimeout; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java index 05fdde6..81a10f9 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java @@ -21,8 +21,8 @@ package org.jdrupes.vmoperator.runner.qemu; import java.math.BigInteger; import java.util.Optional; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetBalloon; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -39,7 +39,6 @@ public class RamController extends Component { * * @param componentChannel the component channel */ - @SuppressWarnings("PMD.AssignmentToNonFinalStatic") public RamController(Channel componentChannel) { super(componentChannel); } @@ -50,7 +49,7 @@ public class RamController extends Component { * @param event the event */ @Handler - public void onConfigureQemu(RunnerConfigurationUpdate event) { + public void onConfigureQemu(ConfigureQemu event) { Optional.ofNullable(event.configuration().vm.currentRam) .ifPresent(cr -> { if (currentRam != null && currentRam.equals(cr)) { diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java index 3ace2e5..4819dcd 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Runner.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import freemarker.core.ParseException; import freemarker.template.MalformedTemplateNameException; import freemarker.template.TemplateException; @@ -38,24 +39,34 @@ import java.lang.reflect.UndeclaredThrowableException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Instant; import java.util.Comparator; +import java.util.EnumSet; import java.util.HashMap; -import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.CommandLineParser; import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jdrupes.vmoperator.runner.qemu.events.QmpConfigured; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jdrupes.vmoperator.util.FsdUtils; import org.jgrapes.core.Channel; @@ -107,21 +118,27 @@ import org.jgrapes.util.events.WatchFile; * * state "Starting (Processes)" as StartingProcess { * - * state which <> - * state "Start swtpm" as swtpm * state "Start qemu" as qemu * state "Open monitor" as monitor * state "Configure QMP" as waitForConfigured * state "Configure QEMU" as configure * state success <> * state error <> - * - * which --> swtpm: [use swtpm] - * which --> qemu: [else] - * + * + * state prepFork <> + * state prepJoin <> + * state "Generate cloud-init image" as cloudInit + * prepFork --> cloudInit: [cloud-init data provided] + * swtpm --> prepJoin: FileChanged[swtpm socket created] + * state "Start swtpm" as swtpm + * prepFork --> swtpm: [use swtpm] * swtpm: entry/start swtpm - * swtpm -> qemu: FileChanged[swtpm socket created] - * + * cloudInit --> prepJoin: ProcessExited + * cloudInit: entry/generate cloud-init image + * prepFork --> prepJoin: [else] + * + * prepJoin --> qemu + * * qemu: entry/start qemu * qemu --> monitor : FileChanged[monitor socket created] * @@ -132,14 +149,23 @@ import org.jgrapes.util.events.WatchFile; * waitForConfigured: entry/fire QmpCapabilities * waitForConfigured --> configure: QmpConfigured * - * configure: entry/fire RunnerConfigurationUpdate - * configure --> success: RunnerConfigurationUpdate (last handler)/fire cont command + * configure: entry/fire ConfigureQemu + * configure --> success: ConfigureQemu (last handler)/fire cont command * } * - * Initializing --> which: Started + * Initializing --> prepFork: Started * * success --> Running * + * state Running { + * state Booting + * state Booted + * + * [*] -right-> Booting + * Booting -down-> Booting: VserportChanged[guest agent connected]/fire GetOsinfo + * Booting --> Booted: Osinfo + * } + * * state Terminating { * state terminate <> * state qemuRunning <> @@ -175,28 +201,46 @@ import org.jgrapes.util.events.WatchFile; * */ @SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace", - "PMD.DataflowAnomalyAnalysis" }) + "PMD.TooManyMethods", "PMD.CouplingBetweenObjects" }) public class Runner extends Component { - /** The Constant APP_NAME. */ - public static final String APP_NAME = "vm-runner"; private static final String TEMPLATE_DIR = "/opt/" + APP_NAME.replace("-", "") + "/templates"; private static final String DEFAULT_TEMPLATE = "Standard-VM-latest.ftl.yaml"; private static final String SAVED_TEMPLATE = "VM.ftl.yaml"; private static final String FW_VARS = "fw-vars.fd"; + private static int exitStatus; - private EventPipeline rep; - private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + private final EventPipeline rep = newEventPipeline(); + private final ObjectMapper yamlMapper = new ObjectMapper(YAMLFactory + .builder().disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build()); private final JsonNode defaults; - @SuppressWarnings("PMD.UseConcurrentHashMap") - private Configuration config = new Configuration(); + private final File configFile; + private final Path configDir; + private Configuration initialConfig; + private Configuration pendingConfig; private final freemarker.template.Configuration fmConfig; private CommandDefinition swtpmDefinition; + private CommandDefinition cloudInitImgDefinition; private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; - private State state = State.INITIALIZING; + private boolean qmpConfigured; + private final GuestAgentClient guestAgentClient; + private final VmopAgentClient vmopAgentClient; + private Integer resetCounter; + private RunState state = RunState.INITIALIZING; + + /** Preparatory actions for QEMU start */ + @SuppressWarnings("PMD.FieldNamingConventions") + private enum QemuPreps { + Config, + Tpm, + CloudInit + } + + private final Set qemuLatch = EnumSet.noneOf(QemuPreps.class); /** * Instantiates a new runner. @@ -204,7 +248,7 @@ public class Runner extends Component { * @param cmdLine the cmd line * @throws IOException Signals that an I/O exception has occurred. */ - @SuppressWarnings("PMD.SystemPrintln") + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) public Runner(CommandLine cmdLine) throws IOException { yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -213,6 +257,17 @@ public class Runner extends Component { defaults = yamlMapper.readValue( Runner.class.getResourceAsStream("defaults.yaml"), JsonNode.class); + // Get the config + configFile = new File(cmdLine.getOptionValue('c', + "/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml")); + // Don't rely on night config to produce a good exception + // for this simple case + if (!Files.isReadable(configFile.toPath())) { + throw new IOException( + "Cannot read configuration file " + configFile); + } + configDir = configFile.getParentFile().toPath().toRealPath(); + // Configure freemarker library fmConfig = new freemarker.template.Configuration( freemarker.template.Configuration.VERSION_2_3_32); @@ -229,19 +284,12 @@ public class Runner extends Component { attach(new FileSystemWatcher(channel())); attach(new ProcessManager(channel())); attach(new SocketConnector(channel())); - attach(qemuMonitor = new QemuMonitor(channel())); + attach(qemuMonitor = new QemuMonitor(channel(), configDir)); + attach(guestAgentClient = new GuestAgentClient(channel())); + attach(vmopAgentClient = new VmopAgentClient(channel())); attach(new StatusUpdater(channel())); - - // Configuration store with file in /etc/opt (default) - File config = new File(cmdLine.getOptionValue('c', - "/etc/opt/" + APP_NAME.replace("-", "") + "/config.yaml")); - // Don't rely on night config to produce a good exception - // for this simple case - if (!Files.isReadable(config.toPath())) { - throw new IOException("Cannot read configuration file " + config); - } - attach(new YamlConfigurationStore(channel(), config, false)); - fire(new WatchFile(config.toPath())); + attach(new YamlConfigurationStore(channel(), configFile, false)); + fire(new WatchFile(configFile.toPath())); } /** @@ -259,57 +307,84 @@ public class Runner extends Component { } /** - * On configuration update. + * Process the initial configuration. The initial configuration + * and any subsequent updates will be forwarded to other components + * only when the QMP connection is ready + * (see @link #onQmpConfigured(QmpConfigured)). * * @param event the event */ @Handler public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { - if (event instanceof InitialConfiguration) { - processInitialConfiguration(c); - return; - } - logger.fine(() -> "Updating configuration"); + logger.fine(() -> "Runner configuratation updated"); var newConf = yamlMapper.convertValue(c, Configuration.class); - rep.fire(new RunnerConfigurationUpdate(newConf, state)); + + // Add some values from other sources to configuration + newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); + Path dsPath = configDir.resolve(DisplaySecret.PASSWORD); + newConf.hasDisplayPassword = dsPath.toFile().canRead(); + + // Special actions for initial configuration (startup) + if (event instanceof InitialConfiguration) { + processInitialConfiguration(newConf); + } + + // Check if to be sent immediately or later + if (qmpConfigured) { + rep.fire(new ConfigureQemu(newConf, state)); + } else { + pendingConfig = newConf; + } }); } - private void processInitialConfiguration( - Map runnerConfiguration) { + @SuppressWarnings("PMD.LambdaCanBeMethodReference") + private void processInitialConfiguration(Configuration newConfig) { try { - config = yamlMapper.convertValue(runnerConfiguration, - Configuration.class); - if (!config.check()) { + if (!newConfig.check()) { // Invalid configuration, not used, problems already logged. - config = null; + return; } // Prepare firmware files and add to config - setFirmwarePaths(); + setFirmwarePaths(newConfig); // Obtain more context data from template - var tplData = dataFromTemplate(); - swtpmDefinition = Optional.ofNullable(tplData.get("swtpm")) - .map(d -> new CommandDefinition("swtpm", d)).orElse(null); - qemuDefinition = Optional.ofNullable(tplData.get("qemu")) - .map(d -> new CommandDefinition("qemu", d)).orElse(null); + var tplData = dataFromTemplate(newConfig); + initialConfig = newConfig; + + // Configure + swtpmDefinition + = Optional.ofNullable(tplData.get(ProcessName.SWTPM)) + .map(d -> new CommandDefinition(ProcessName.SWTPM, d)) + .orElse(null); + logger.finest(() -> swtpmDefinition.toString()); + qemuDefinition = Optional.ofNullable(tplData.get(ProcessName.QEMU)) + .map(d -> new CommandDefinition(ProcessName.QEMU, d)) + .orElse(null); + logger.finest(() -> qemuDefinition.toString()); + cloudInitImgDefinition + = Optional.ofNullable(tplData.get(ProcessName.CLOUD_INIT_IMG)) + .map(d -> new CommandDefinition(ProcessName.CLOUD_INIT_IMG, + d)) + .orElse(null); + logger.finest(() -> cloudInitImgDefinition.toString()); // Forward some values to child components - qemuMonitor.configure(config.monitorSocket, - config.vm.powerdownTimeout); + qemuMonitor.configure(initialConfig.monitorSocket, + initialConfig.vm.powerdownTimeout); + guestAgentClient.configureConnection(qemuDefinition.command, + "guest-agent-socket"); + vmopAgentClient.configureConnection(qemuDefinition.command, + "vmop-agent-socket"); } catch (IllegalArgumentException | IOException | TemplateException e) { logger.log(Level.SEVERE, e, () -> "Invalid configuration: " + e.getMessage()); - // Don't use default configuration - config = null; } } - @SuppressWarnings({ "PMD.CognitiveComplexity", - "PMD.DataflowAnomalyAnalysis" }) - private void setFirmwarePaths() throws IOException { + private void setFirmwarePaths(Configuration config) throws IOException { JsonNode firmware = defaults.path("firmware").path(config.vm.firmware); // Get file for firmware ROM JsonNode codePaths = firmware.path("rom"); @@ -320,6 +395,12 @@ public class Runner extends Component { break; } } + if (codePaths.iterator().hasNext() && config.firmwareRom == null) { + throw new IllegalArgumentException("No ROM found, candidates were: " + + StreamSupport.stream(codePaths.spliterator(), false) + .map(JsonNode::asText).collect(Collectors.joining(", "))); + } + // Get file for firmware vars, if necessary config.firmwareVars = config.dataDir.resolve(FW_VARS); if (!Files.exists(config.firmwareVars)) { @@ -333,7 +414,7 @@ public class Runner extends Component { } } - private JsonNode dataFromTemplate() + private JsonNode dataFromTemplate(Configuration config) throws IOException, TemplateNotFoundException, MalformedTemplateNameException, ParseException, TemplateException, JsonProcessingException, JsonMappingException { @@ -358,20 +439,35 @@ public class Runner extends Component { .map(Object::toString).orElse(null)); model.put("firmwareVars", Optional.ofNullable(config.firmwareVars) .map(Object::toString).orElse(null)); + model.put("hasDisplayPassword", config.hasDisplayPassword); + model.put("cloudInit", config.cloudInit); model.put("vm", config.vm); - if (Optional.ofNullable(config.vm.display) - .map(d -> d.spice).map(s -> s.ticket).isPresent()) { - model.put("ticketPath", config.runtimeDir.resolve("ticket.txt")); - } + logger.finest(() -> "Processing template with model: " + model); // Combine template and data and parse result // (tempting, but no need to use a pipe here) var fmTemplate = fmConfig.getTemplate(templatePath.toString()); StringWriter out = new StringWriter(); fmTemplate.process(model, out); + logger.finest(() -> "Result of processing template: " + out); return yamlMapper.readValue(out.toString(), JsonNode.class); } + /** + * Note ready state and send a {@link ConfigureQemu} event for + * any pending configuration (initial or change). + * + * @param event the event + */ + @Handler + public void onQmpConfigured(QmpConfigured event) { + qmpConfigured = true; + if (pendingConfig != null) { + rep.fire(new ConfigureQemu(pendingConfig, state)); + pendingConfig = null; + } + } + /** * Handle the start event. * @@ -379,7 +475,7 @@ public class Runner extends Component { */ @Handler(priority = 100) public void onStart(Start event) { - if (config == null) { + if (initialConfig == null) { // Missing configuration, fail event.cancel(true); fire(new Stop()); @@ -390,25 +486,24 @@ public class Runner extends Component { // https://github.com/kubernetes-client/java/issues/100 io.kubernetes.client.openapi.Configuration.setDefaultApiClient(null); - // Prepare specific event pipeline to avoid concurrency. - rep = newEventPipeline(); + // Provide specific event pipeline to avoid concurrency. event.setAssociated(EventPipeline.class, rep); try { // Store process id try (var pidFile = Files.newBufferedWriter( - config.runtimeDir.resolve("runner.pid"))) { + initialConfig.runtimeDir.resolve("runner.pid"))) { pidFile.write(ProcessHandle.current().pid() + "\n"); } // Files to watch for - Files.deleteIfExists(config.swtpmSocket); - fire(new WatchFile(config.swtpmSocket)); + Files.deleteIfExists(initialConfig.swtpmSocket); + fire(new WatchFile(initialConfig.swtpmSocket)); // Helper files - var ticket = Optional.ofNullable(config.vm.display) + var ticket = Optional.ofNullable(initialConfig.vm.display) .map(d -> d.spice).map(s -> s.ticket); if (ticket.isPresent()) { - Files.write(config.runtimeDir.resolve("ticket.txt"), + Files.write(initialConfig.runtimeDir.resolve("ticket.txt"), ticket.get().getBytes()); } } catch (IOException e) { @@ -425,21 +520,73 @@ public class Runner extends Component { */ @Handler public void onStarted(Started event) { - state = State.STARTING; + state = RunState.STARTING; rep.fire(new RunnerStateChange(state, "RunnerStarted", "Runner has been started")); - // Start first process - if (config.vm.useTpm && swtpmDefinition != null) { + // Start first process(es) + qemuLatch.add(QemuPreps.Config); + if (initialConfig.vm.useTpm && swtpmDefinition != null) { startProcess(swtpmDefinition); - return; + qemuLatch.add(QemuPreps.Tpm); + } + if (initialConfig.cloudInit != null) { + generateCloudInitImg(initialConfig); + qemuLatch.add(QemuPreps.CloudInit); + } + mayBeStartQemu(QemuPreps.Config); + } + + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + private void mayBeStartQemu(QemuPreps done) { + synchronized (qemuLatch) { + if (qemuLatch.isEmpty()) { + return; + } + qemuLatch.remove(done); + if (qemuLatch.isEmpty()) { + startProcess(qemuDefinition); + } + } + } + + private void generateCloudInitImg(Configuration config) { + try { + var cloudInitDir = config.dataDir.resolve("cloud-init"); + cloudInitDir.toFile().mkdir(); + try (var metaOut + = Files.newBufferedWriter(cloudInitDir.resolve("meta-data"))) { + if (config.cloudInit.metaData != null) { + yamlMapper.writer().writeValue(metaOut, + config.cloudInit.metaData); + } + } + try (var userOut + = Files.newBufferedWriter(cloudInitDir.resolve("user-data"))) { + userOut.write("#cloud-config\n"); + if (config.cloudInit.userData != null) { + yamlMapper.writer().writeValue(userOut, + config.cloudInit.userData); + } + } + if (config.cloudInit.networkConfig != null) { + try (var networkConfig = Files.newBufferedWriter( + cloudInitDir.resolve("network-config"))) { + yamlMapper.writer().writeValue(networkConfig, + config.cloudInit.networkConfig); + } + } + startProcess(cloudInitImgDefinition); + } catch (IOException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot start runner: " + e.getMessage()); + fire(new Stop()); } - startProcess(qemuDefinition); } private boolean startProcess(CommandDefinition toStart) { logger.info( () -> "Starting process: " + String.join(" ", toStart.command)); - fire(new StartProcess(toStart.command) + rep.fire(new StartProcess(toStart.command) .setAssociated(CommandDefinition.class, toStart)); return true; } @@ -453,10 +600,9 @@ public class Runner extends Component { @Handler public void onFileChanged(FileChanged event) { if (event.change() == Kind.CREATED - && event.path().equals(config.swtpmSocket)) { - // swtpm running, start qemu - startProcess(qemuDefinition); - return; + && event.path().equals(initialConfig.swtpmSocket)) { + // swtpm running, maybe start qemu + mayBeStartQemu(QemuPreps.Tpm); } } @@ -469,15 +615,13 @@ public class Runner extends Component { * @throws InterruptedException the interrupted exception */ @Handler - @SuppressWarnings({ "PMD.SwitchStmtsShouldHaveDefault", - "PMD.TooFewBranchesForASwitchStatement" }) public void onProcessStarted(ProcessStarted event, ProcessChannel channel) throws InterruptedException { event.startEvent().associated(CommandDefinition.class) .ifPresent(procDef -> { channel.setAssociated(CommandDefinition.class, procDef); try (var pidFile = Files.newBufferedWriter( - config.runtimeDir.resolve(procDef.name + ".pid"))) { + initialConfig.runtimeDir.resolve(procDef.name + ".pid"))) { pidFile.write(channel.process().toHandle().pid() + "\n"); } catch (IOException e) { throw new UndeclaredThrowableException(e); @@ -510,30 +654,53 @@ public class Runner extends Component { } /** - * On monitor ready. + * Whenever a new QEMU configuration is available, check if it + * is supposed to trigger a reset. * * @param event the event */ @Handler - public void onQmpConfigured(QmpConfigured event) { - rep.fire(new RunnerConfigurationUpdate(config, state)); + public void onConfigureQemu(ConfigureQemu event) { + if (state.vmActive()) { + if (resetCounter != null + && event.configuration().resetCounter != null + && event.configuration().resetCounter > resetCounter) { + fire(new MonitorCommand(new QmpReset())); + } + resetCounter = event.configuration().resetCounter; + } } /** - * On configure qemu. - * + * As last step when handling a new configuration, check if + * QEMU is suspended after startup and should be continued. + * * @param event the event */ @Handler(priority = -1000) - public void onConfigureQemu(RunnerConfigurationUpdate event) { - if (state == State.STARTING) { + public void onConfigureQemuFinal(ConfigureQemu event) { + if (state == RunState.STARTING) { + state = RunState.BOOTING; fire(new MonitorCommand(new QmpCont())); - state = State.RUNNING; rep.fire(new RunnerStateChange(state, "VmStarted", "Qemu has been configured and is continuing")); } } + /** + * Receiving the OSinfo means that the OS has been booted. + * + * @param event the event + */ + @Handler + public void onOsinfo(OsinfoEvent event) { + if (state == RunState.BOOTING) { + state = RunState.BOOTED; + rep.fire(new RunnerStateChange(state, "VmBooted", + "The VM has started the guest agent.")); + } + } + /** * On process exited. * @@ -543,22 +710,43 @@ public class Runner extends Component { @Handler public void onProcessExited(ProcessExited event, ProcessChannel channel) { channel.associated(CommandDefinition.class).ifPresent(procDef -> { - // No process(es) may exit during startup - if (state == State.STARTING) { + if (procDef.equals(cloudInitImgDefinition) + && event.exitValue() == 0) { + // Cloud-init ISO generation was successful. + mayBeStartQemu(QemuPreps.CloudInit); + return; + } + + // No other process(es) may exit during startup + if (state == RunState.STARTING) { logger.severe(() -> "Process " + procDef.name + " has exited with value " + event.exitValue() + " during startup."); rep.fire(new Stop()); return; } - if (procDef.equals(qemuDefinition) && state == State.RUNNING) { - rep.fire(new Stop()); + + // No processes may exit while the VM is running normally + if (procDef.equals(qemuDefinition) && state.vmActive()) { + rep.fire(new Exit(event.exitValue())); } logger.info(() -> "Process " + procDef.name + " has exited with value " + event.exitValue()); }); } + /** + * On exit. + * + * @param event the event + */ + @Handler(priority = 10_001) + public void onExit(Exit event) { + if (exitStatus == 0) { + exitStatus = event.exitStatus(); + } + } + /** * On stop. * @@ -566,9 +754,9 @@ public class Runner extends Component { */ @Handler(priority = 10_000) public void onStopFirst(Stop event) { - state = State.TERMINATING; + state = RunState.TERMINATING; rep.fire(new RunnerStateChange(state, "VmTerminating", - "The VM is being shut down")); + "The VM is being shut down", exitStatus != 0)); } /** @@ -578,13 +766,13 @@ public class Runner extends Component { */ @Handler(priority = -10_000) public void onStopLast(Stop event) { - state = State.STOPPED; + state = RunState.STOPPED; rep.fire(new RunnerStateChange(state, "VmStopped", "The VM has been shut down")); } private void shutdown() { - if (state != State.TERMINATING) { + if (!Set.of(RunState.TERMINATING, RunState.STOPPED).contains(state)) { fire(new Stop()); } try { @@ -593,7 +781,7 @@ public class Runner extends Component { logger.log(Level.WARNING, e, () -> "Proper shutdown failed."); } - Optional.ofNullable(config).map(c -> c.runtimeDir) + Optional.ofNullable(initialConfig).map(c -> c.runtimeDir) .ifPresent(runtimeDir -> { try { Files.walk(runtimeDir).sorted(Comparator.reverseOrder()) @@ -609,7 +797,7 @@ public class Runner extends Component { static { try { InputStream props; - var path = FsdUtils.findConfigFile(Runner.APP_NAME.replace("-", ""), + var path = FsdUtils.findConfigFile(APP_NAME.replace("-", ""), "logging.properties"); if (path.isPresent()) { props = Files.newInputStream(path.get()); @@ -617,6 +805,10 @@ public class Runner extends Component { props = Runner.class.getResourceAsStream("logging.properties"); } LogManager.getLogManager().readConfiguration(props); + Logger.getLogger(Runner.class.getName()).log(Level.CONFIG, + () -> path.isPresent() + ? "Using logging configuration from " + path.get() + : "Using default logging configuration"); } catch (IOException e) { e.printStackTrace(); } @@ -651,6 +843,11 @@ public class Runner extends Component { // Start the application Components.start(app); + + // Wait for (regular) termination + Components.awaitExhaustion(); + System.exit(exitStatus); + } catch (IOException | InterruptedException | org.apache.commons.cli.ParseException e) { Logger.getLogger(Runner.class.getName()).log(Level.SEVERE, e, diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java index 19e252f..127c070 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -1,6 +1,6 @@ /* * VM-Operator - * Copyright (C) 2023 Michael N. Lipp + * Copyright (C) 2023,2025 Michael N. Lipp * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,54 +18,71 @@ package org.jdrupes.vmoperator.runner.qemu; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.Gson; import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.custom.Quantity.Format; +import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; -import io.kubernetes.client.openapi.apis.ApisApi; -import io.kubernetes.client.openapi.apis.CustomObjectsApi; -import io.kubernetes.client.openapi.models.V1APIGroup; -import io.kubernetes.client.openapi.models.V1GroupVersionForDiscovery; -import io.kubernetes.client.util.Config; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; -import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.models.EventsV1Event; import java.io.IOException; import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; +import java.math.BigInteger; import java.time.Instant; -import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.logging.Level; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; -import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.Constants.Status.Condition; +import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.runner.qemu.events.BalloonChangeEvent; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.DisplayPasswordChanged; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; import org.jdrupes.vmoperator.runner.qemu.events.HotpluggableCpuStatus; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerConfigurationUpdate; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; +import org.jdrupes.vmoperator.runner.qemu.events.ShutdownEvent; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; import org.jdrupes.vmoperator.util.GsonPtr; import org.jgrapes.core.Channel; -import org.jgrapes.core.Component; +import org.jgrapes.core.Components; +import org.jgrapes.core.Components.Timer; import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; -import org.jgrapes.util.events.ConfigurationUpdate; -import org.jgrapes.util.events.InitialConfiguration; /** * Updates the CR status. */ -public class StatusUpdater extends Component { +@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) +public class StatusUpdater extends VmDefUpdater { - private static final Set RUNNING_STATES - = Set.of(State.RUNNING, State.TERMINATING); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final Gson gson = new JSON().getGson(); + @SuppressWarnings("PMD.FieldNamingConventions") + private static final ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); - private String namespace; - private String vmName; - private DynamicKubernetesApi vmCrApi; - private long observedGeneration; + private boolean guestShutdownStops; + private boolean shutdownByGuest; + private VmDefinitionStub vmStub; + private String loggedInUser; + private BigInteger lastRamValue; + private Instant lastRamChange; + private Timer balloonTimer; + private BigInteger targetRamValue; /** * Instantiates a new status updater. @@ -74,6 +91,7 @@ public class StatusUpdater extends Component { */ public StatusUpdater(Channel componentChannel) { super(componentChannel); + attach(new ConsoleTracker(componentChannel)); } /** @@ -90,43 +108,6 @@ public class StatusUpdater extends Component { } } - /** - * On configuration update. - * - * @param event the event - */ - @Handler - @SuppressWarnings("unchecked") - public void onConfigurationUpdate(ConfigurationUpdate event) { - event.structured("/Runner").ifPresent(c -> { - if (event instanceof InitialConfiguration) { - namespace = (String) c.get("namespace"); - updateNamespace(); - vmName = Optional.ofNullable((Map) c.get("vm")) - .map(vm -> vm.get("name")).orElse(null); - } - }); - } - - private void updateNamespace() { - if (namespace == null) { - var path = Path - .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); - if (Files.isReadable(path)) { - try { - namespace = Files.lines(path).findFirst().orElse(null); - } catch (IOException e) { - logger.log(Level.WARNING, e, - () -> "Cannot read namespace."); - } - } - } - if (namespace == null) { - logger.warning(() -> "Namespace is unknown, some functions" - + " won't be available."); - } - } - /** * Handle the start event. * @@ -135,45 +116,32 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler - @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals" }) - public void onStart(Start event) throws IOException, ApiException { + public void onStart(Start event) { if (namespace == null) { return; } - var client = Config.defaultClient(); - var apis = new ApisApi(client).getAPIVersions(); - var crdVersions = apis.getGroups().stream() - .filter(g -> g.getName().equals(VM_OP_GROUP)).findFirst() - .map(V1APIGroup::getVersions).stream().flatMap(l -> l.stream()) - .map(V1GroupVersionForDiscovery::getVersion).toList(); - var coa = new CustomObjectsApi(client); - for (var crdVersion : crdVersions) { - var crdApiRes = coa.getAPIResources(VM_OP_GROUP, - crdVersion).getResources().stream() - .filter(r -> VM_OP_KIND_VM.equals(r.getKind())).findFirst(); - if (crdApiRes.isEmpty()) { - continue; - } - var crApi = new DynamicKubernetesApi(VM_OP_GROUP, - crdVersion, crdApiRes.get().getName(), client); - var vmCr = crApi.get(namespace, vmName); - if (vmCr.isSuccess()) { - vmCrApi = crApi; - observedGeneration - = vmCr.getObject().getMetadata().getGeneration(); - break; + try { + vmStub = VmDefinitionStub.get(apiClient, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + namespace, vmName); + var vmDef = vmStub.model().orElse(null); + if (vmDef == null) { + return; } + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.RUNNER_VERSION, Optional.ofNullable( + Runner.class.getPackage().getImplementationVersion()) + .orElse("(unknown)")); + status.remove(Status.LOGGED_IN_USER); + return status; + }); + } catch (ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access VM object, terminating."); + event.cancel(true); + fire(new Exit(1)); } - if (vmCrApi == null) { - logger.warning(() -> "Cannot find VM's CR, status will not" - + " be updated."); - } - } - - @SuppressWarnings("PMD.AvoidDuplicateLiterals") - private JsonObject currentStatus(DynamicKubernetesObject vmCr) { - return vmCr.getRaw().getAsJsonObject("status").deepCopy(); } /** @@ -183,25 +151,28 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler - public void onRunnerConfigurationUpdate(RunnerConfigurationUpdate event) + public void onConfigureQemu(ConfigureQemu event) throws ApiException { - if (vmCrApi == null) { + guestShutdownStops = event.configuration().guestShutdownStops; + loggedInUser = event.configuration().vm.display.loggedInUser; + targetRamValue = event.configuration().vm.currentRam; + + // Remainder applies only if we have a connection to k8s. + if (vmStub == null) { return; } - // A change of the runner configuration is typically caused - // by a new version of the CR. So we observe the new CR. - var vmCr = vmCrApi.get(namespace, vmName).throwsApiException() - .getObject(); - if (vmCr.getMetadata().getGeneration() == observedGeneration) { - return; - } - vmCrApi.updateStatus(vmCr, from -> { - JsonObject status = currentStatus(from); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + if (!event.configuration().hasDisplayPassword) { + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, -1); + } status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond).filter(cond -> "Running" + .map(cond -> (JsonObject) cond) + .filter(cond -> Condition.RUNNING .equals(cond.get("type").getAsString())) .forEach(cond -> cond.addProperty("observedGeneration", from.getMetadata().getGeneration())); + updateUserLoggedIn(from); return status; }); } @@ -213,76 +184,146 @@ public class StatusUpdater extends Component { * @throws ApiException */ @Handler + @SuppressWarnings({ "PMD.AssignmentInOperand" }) public void onRunnerStateChanged(RunnerStateChange event) throws ApiException { - if (vmCrApi == null) { + VmDefinition vmDef; + if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { return; } - var vmCr = vmCrApi.get(namespace, vmName).throwsApiException() - .getObject(); - vmCrApi.updateStatus(vmCr, from -> { - JsonObject status = currentStatus(from); - status.getAsJsonArray("conditions").asList().stream() - .map(cond -> (JsonObject) cond) - .forEach(cond -> { - if ("Running".equals(cond.get("type").getAsString())) { - updateRunningCondition(event, from, cond); - } - }); - if (event.state() == State.STARTING) { - status.addProperty("ram", GsonPtr.to(from.getRaw()) + vmStub.updateStatus(from -> { + boolean running = event.runState().vmRunning(); + updateCondition(vmDef, Condition.RUNNING, running, event.reason(), + event.message()); + JsonObject status = updateCondition(vmDef, Condition.BOOTED, + event.runState() == RunState.BOOTED, event.reason(), + event.message()); + if (event.runState() == RunState.STARTING) { + status.addProperty(Status.RAM, GsonPtr.to(from.data()) .getAsString("spec", "vm", "maximumRam").orElse("0")); - status.addProperty("cpus", 1); - } else if (event.state() == State.STOPPED) { - status.addProperty("ram", "0"); - status.addProperty("cpus", 0); + status.addProperty(Status.CPUS, 1); + } else if (event.runState() == RunState.STOPPED) { + status.addProperty(Status.RAM, "0"); + status.addProperty(Status.CPUS, 0); + status.remove(Status.LOGGED_IN_USER); + } + + if (!running) { + // In case console connection was still present + status.addProperty(Status.CONSOLE_CLIENT, ""); + updateCondition(from, Condition.CONSOLE_CONNECTED, false, + "VmStopped", + "The VM is not running"); + + // In case we had an irregular shutdown + updateCondition(from, Condition.USER_LOGGED_IN, false, + "VmStopped", "The VM is not running"); + status.remove(Status.OSINFO); + updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped", + "The VM is not running"); } return status; - }).throwsApiException(); + }, vmDef); + + // Maybe stop VM + if (event.runState() == RunState.TERMINATING && !event.failed() + && guestShutdownStops && shutdownByGuest) { + logger.info(() -> "Stopping VM because of shutdown by guest."); + var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" + + "\", \"value\": \"Stopped\"}]"), + apiClient.defaultPatchOptions()); + if (!res.isPresent()) { + logger.warning( + () -> "Cannot patch pod annotations for: " + vmStub.name()); + } + } + + // Log event + var evt = new EventsV1Event() + .reportingController(Crd.GROUP + "/" + APP_NAME) + .action("StatusUpdate").reason(event.reason()) + .note(event.message()); + K8s.createEvent(apiClient, vmDef, evt); } - private void updateRunningCondition(RunnerStateChange event, - DynamicKubernetesObject from, JsonObject cond) { - boolean reportedRunning - = "True".equals(cond.get("status").getAsString()); - if (RUNNING_STATES.contains(event.state()) - && !reportedRunning) { - cond.addProperty("status", "True"); - cond.addProperty("lastTransitionTime", - Instant.now().toString()); + private void updateUserLoggedIn(VmDefinition from) { + if (loggedInUser == null) { + updateCondition(from, Condition.USER_LOGGED_IN, false, + Reason.NOT_REQUESTED, "No user to be logged in"); + return; } - if (!RUNNING_STATES.contains(event.state()) - && reportedRunning) { - cond.addProperty("status", "False"); - cond.addProperty("lastTransitionTime", - Instant.now().toString()); + if (!from.conditionStatus(Condition.VMOP_AGENT).orElse(false)) { + updateCondition(from, Condition.USER_LOGGED_IN, false, + "VmopAgentDisconnected", "Waiting for VMOP agent to connect"); + return; } - cond.addProperty("reason", event.reason()); - cond.addProperty("message", event.message()); - cond.addProperty("observedGeneration", - from.getMetadata().getGeneration()); + if (!from.fromStatus(Status.LOGGED_IN_USER).map(loggedInUser::equals) + .orElse(false)) { + updateCondition(from, Condition.USER_LOGGED_IN, false, + "Processing", "Waiting for user to be logged in"); + } + updateCondition(from, Condition.USER_LOGGED_IN, true, + Reason.LOGGED_IN, "User is logged in"); } /** - * On ballon change. + * Update the current RAM size in the status. Balloon changes happen + * more than once every second during changes. While this is nice + * to watch, this puts a heavy load on the system. Therefore we + * only update the status once every 15 seconds or when the target + * value is reached. * * @param event the event * @throws ApiException */ @Handler public void onBallonChange(BalloonChangeEvent event) throws ApiException { - if (vmCrApi == null) { + if (vmStub == null) { return; } - var vmCr = vmCrApi.get(namespace, vmName).throwsApiException() - .getObject(); - vmCrApi.updateStatus(vmCr, from -> { - JsonObject status = currentStatus(from); - status.addProperty("ram", - new Quantity(new BigDecimal(event.size()), Format.BINARY_SI) + Instant now = Instant.now(); + if (lastRamChange == null + || lastRamChange.isBefore(now.minusSeconds(15)) + || event.size().equals(targetRamValue)) { + if (balloonTimer != null) { + balloonTimer.cancel(); + balloonTimer = null; + } + lastRamChange = now; + lastRamValue = event.size(); + updateRam(); + return; + } + + // Save for later processing and maybe start timer + lastRamChange = now; + lastRamValue = event.size(); + if (balloonTimer != null) { + return; + } + final var pipeline = activeEventPipeline(); + balloonTimer = Components.schedule(t -> { + pipeline.submit("Update RAM size", () -> { + try { + updateRam(); + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Failed to update ram size: " + e.getMessage()); + } + balloonTimer = null; + }); + }, now.plusSeconds(15)); + } + + private void updateRam() throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.RAM, + new Quantity(new BigDecimal(lastRamValue), Format.BINARY_SI) .toSuffixedString()); return status; - }).throwsApiException(); + }); } /** @@ -293,15 +334,116 @@ public class StatusUpdater extends Component { */ @Handler public void onCpuChange(HotpluggableCpuStatus event) throws ApiException { - if (vmCrApi == null) { + if (vmStub == null) { return; } - var vmCr = vmCrApi.get(namespace, vmName).throwsApiException() - .getObject(); - vmCrApi.updateStatus(vmCr, from -> { - JsonObject status = currentStatus(from); - status.addProperty("cpus", event.usedCpus().size()); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.CPUS, event.usedCpus().size()); return status; - }).throwsApiException(); + }); + } + + /** + * On ballon change. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onDisplayPasswordChanged(DisplayPasswordChanged event) + throws ApiException { + if (vmStub == null) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.DISPLAY_PASSWORD_SERIAL, + status.get(Status.DISPLAY_PASSWORD_SERIAL).getAsLong() + 1); + return status; + }); + } + + /** + * On shutdown. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onShutdown(ShutdownEvent event) throws ApiException { + shutdownByGuest = event.byGuest(); + } + + /** + * On osinfo. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onOsinfo(OsinfoEvent event) throws ApiException { + if (vmStub == null) { + return; + } + var asGson = gson.toJsonTree( + objectMapper.convertValue(event.osinfo(), Object.class)); + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.add(Status.OSINFO, asGson); + return status; + }); + + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings("PMD.AssignmentInOperand") + public void onVmopAgentConnected(VmopAgentConnected event) + throws ApiException { + VmDefinition vmDef; + if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { + return; + } + vmStub.updateStatus(from -> { + var status = updateCondition(vmDef, "VmopAgentConnected", + true, "VmopAgentStarted", "The VM operator agent is running"); + updateUserLoggedIn(from); + return status; + }, vmDef); + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + public void onVmopAgentLoggedIn(VmopAgentLoggedIn event) + throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.LOGGED_IN_USER, + event.triggering().user()); + updateUserLoggedIn(from); + return status; + }); + } + + /** + * @param event the event + * @throws ApiException + */ + @Handler + public void onVmopAgentLoggedOut(VmopAgentLoggedOut event) + throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.remove(Status.LOGGED_IN_USER); + updateUserLoggedIn(from); + return status; + }); } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java new file mode 100644 index 0000000..406a0bc --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmDefUpdater.java @@ -0,0 +1,167 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.K8sGenericStub; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.InitialConfiguration; + +/** + * Updates the CR status. + */ +public class VmDefUpdater extends Component { + + protected String namespace; + protected String vmName; + protected K8sClient apiClient; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + * @throws IOException + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public VmDefUpdater(Channel componentChannel) { + super(componentChannel); + if (apiClient == null) { + try { + apiClient = new K8sClient(); + io.kubernetes.client.openapi.Configuration + .setDefaultApiClient(apiClient); + } catch (IOException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access events API, terminating."); + fire(new Exit(1)); + } + } + } + + /** + * On configuration update. + * + * @param event the event + */ + @Handler + @SuppressWarnings("unchecked") + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured("/Runner").ifPresent(c -> { + if (event instanceof InitialConfiguration) { + namespace = (String) c.get("namespace"); + updateNamespace(); + vmName = Optional.ofNullable((Map) c.get("vm")) + .map(vm -> vm.get("name")).orElse(null); + } + }); + } + + private void updateNamespace() { + if (namespace == null) { + var path = Path + .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); + if (Files.isReadable(path)) { + try { + namespace = Files.lines(path).findFirst().orElse(null); + } catch (IOException e) { + logger.log(Level.WARNING, e, + () -> "Cannot read namespace."); + } + } + } + if (namespace == null) { + logger.warning(() -> "Namespace is unknown, some functions" + + " won't be available."); + } + } + + /** + * Update condition. The `from` VM definition is used to determine the + * observed generation and the current status. This method is intended + * to be called in the function passed to + * {@link K8sGenericStub#updateStatus}. + * + * @param from the VM definition + * @param type the condition type + * @param state the new state + * @param reason the reason for the change + * @param message the message + * @return the updated status + */ + protected JsonObject updateCondition(VmDefinition from, String type, + boolean state, String reason, String message) { + JsonObject status = from.statusJson(); + // Avoid redundant updates, as this may be called several times + var current = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .filter(cond -> type.equals(cond.get("type").getAsString())) + .findFirst(); + var stateUnchanged = current.map(c -> c.get("status").getAsString()) + .map("True"::equals).map(s -> s == state).orElse(false); + if (stateUnchanged + && current.map(c -> c.get("reason").getAsString()) + .map(reason::equals).orElse(false) + && current.map(c -> c.get("observedGeneration").getAsLong()) + .map(from.getMetadata().getGeneration()::equals) + .orElse(false)) { + return status; + } + + // Do update + final var condition = new HashMap<>(Map.of("type", type, + "status", state ? "True" : "False", + "observedGeneration", from.getMetadata().getGeneration(), + "reason", reason, + "lastTransitionTime", stateUnchanged + ? current.get().get("lastTransitionTime").getAsString() + : Instant.now().toString())); + if (message != null) { + condition.put("message", message); + } + List toReplace = new ArrayList<>(List.of(condition)); + List newConds + = status.getAsJsonArray("conditions").asList().stream() + .map(cond -> (JsonObject) cond) + .map(cond -> type.equals(cond.get("type").getAsString()) + ? toReplace.remove(0) + : cond) + .collect(Collectors.toCollection(() -> new ArrayList<>())); + newConds.addAll(toReplace); + status.add("conditions", + apiClient.getJSON().getGson().toJsonTree(newConds)); + return status; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java new file mode 100644 index 0000000..a940d73 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/VmopAgentClient.java @@ -0,0 +1,142 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import java.io.IOException; +import java.util.Deque; +import java.util.concurrent.ConcurrentLinkedDeque; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedIn; +import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLoggedOut; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; +import org.jgrapes.core.annotation.Handler; + +/** + * A component that handles the communication over the vmop agent + * socket. + * + * If the log level for this class is set to fine, the messages + * exchanged on the socket are logged. + */ +public class VmopAgentClient extends AgentConnector { + + private final Deque> executing = new ConcurrentLinkedDeque<>(); + + /** + * Instantiates a new VM operator agent client. + * + * @param componentChannel the component channel + * @throws IOException Signals that an I/O exception has occurred. + */ + public VmopAgentClient(Channel componentChannel) throws IOException { + super(componentChannel); + } + + /** + * On vmop agent login. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmopAgentLogIn(VmopAgentLogIn event) throws IOException { + if (writer().isPresent()) { + logger.fine(() -> "Vmop agent handles:" + event); + executing.add(event); + logger.finer(() -> "vmop agent(out): login " + event.user()); + sendCommand("login " + event.user()); + } else { + logger + .warning(() -> "No vmop agent connection for sending " + event); + } + } + + /** + * On vmop agent logout. + * + * @param event the event + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmopAgentLogout(VmopAgentLogOut event) throws IOException { + if (writer().isPresent()) { + logger.fine(() -> "Vmop agent handles:" + event); + executing.add(event); + logger.finer(() -> "vmop agent(out): logout"); + sendCommand("logout"); + } + } + + @Override + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) + protected void processInput(String line) throws IOException { + logger.finer(() -> "vmop agent(in): " + line); + + // Check validity + if (line.isEmpty() || !Character.isDigit(line.charAt(0))) { + logger.warning(() -> "Illegal vmop agent response: " + line); + return; + } + + // Check positive responses + if (line.startsWith("220 ")) { + var evt = new VmopAgentConnected(); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); + return; + } + if (line.startsWith("201 ")) { + Event cmd = executing.pop(); + if (cmd instanceof VmopAgentLogIn login) { + var evt = new VmopAgentLoggedIn(login); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); + } else { + logger.severe(() -> "Response " + line + + " does not match executing command " + cmd); + } + return; + } + if (line.startsWith("202 ")) { + Event cmd = executing.pop(); + if (cmd instanceof VmopAgentLogOut logout) { + var evt = new VmopAgentLoggedOut(logout); + logger.fine(() -> "Vmop agent triggers " + evt); + rep().fire(evt); + } else { + logger.severe(() -> "Response " + line + + "does not match executing command " + cmd); + } + return; + } + + // Ignore unhandled continuations + if (line.charAt(0) == '1') { + return; + } + + // Error + logger.warning(() -> "Error response from vmop agent: " + line); + executing.pop(); + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java index e77a984..86d92f6 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java @@ -47,7 +47,7 @@ public class QmpAddCpu extends QmpCommand { cmd.put("execute", "device_add"); ObjectNode args = mapper.createObjectNode(); cmd.set("arguments", args); - args.setAll((ObjectNode) (unused.get("props"))); + args.setAll((ObjectNode) unused.get("props")); args.set("driver", unused.get("type")); args.put("id", cpuId); return cmd; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java index ffd6ca6..918b7d5 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpCapabilities extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"qmp_capabilities\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java index 158a318..b60b619 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpChangeMedium extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-change-medium\",\"arguments\": {" + "\"id\": \"\",\"filename\": \"\",\"format\": \"raw\"," diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java index 8a03ab0..0db58e2 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.runner.qemu.commands; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; @@ -29,8 +30,7 @@ import java.util.logging.Logger; */ public abstract class QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) protected static final ObjectMapper mapper = new ObjectMapper(); /** @@ -55,4 +55,30 @@ public abstract class QmpCommand { * @return the json node */ public abstract JsonNode toJson(); + + /** + * Returns the string representation. + * + * @return the string + * @throws JsonProcessingException the JSON processing exception + */ + public String asText() throws JsonProcessingException { + return mapper.writeValueAsString(toJson()); + } + + /** + * Calls {@link #asText()} but suppresses the + * {@link JsonProcessingException}. + * + * @return the string + */ + @Override + public String toString() { + try { + return asText(); + } catch (JsonProcessingException e) { + return "(no string representation)"; + } + } + } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java index 7b1abbd..0e06e34 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpCont extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"cont\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java index 46fba32..a97e6c6 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpDelCpu extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"device_del\", " + "\"arguments\": " + "{ \"id\": 0 } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java new file mode 100644 index 0000000..cf4ba72 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestGetOsinfo.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that pings the guest agent. + */ +public class QmpGuestGetOsinfo extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-get-osinfo"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestGetOsinfo()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java new file mode 100644 index 0000000..75fdf73 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestInfo.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that requests the guest info. + */ +public class QmpGuestInfo extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-info"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestInfo()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java new file mode 100644 index 0000000..257c838 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPing.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that pings the guest agent. + */ +public class QmpGuestPing extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-ping"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestPing()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java new file mode 100644 index 0000000..04110a5 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpGuestPowerdown.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A {@link QmpCommand} that powers down the guest. + */ +public class QmpGuestPowerdown extends QmpCommand { + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "guest-shutdown"); + return cmd; + } + + @Override + public String toString() { + return "QmpGuestPowerdown()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java index 2f9ad55..88a392c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpOpenTray extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-open-tray\",\"arguments\": {" + "\"id\": \"\" } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java index 108a355..dfb7d96 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpPowerdown extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"system_powerdown\" }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java index 6f87d10..d4fb5cc 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.java @@ -25,8 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; */ public class QmpQueryHotpluggableCpus extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson( "{\"execute\":\"query-hotpluggable-cpus\",\"arguments\":{}}"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java index cc74555..71360cf 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java @@ -27,8 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; */ public class QmpRemoveMedium extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"blockdev-remove-medium\",\"arguments\": {" + "\"id\": \"\" } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java new file mode 100644 index 0000000..5364811 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpReset.java @@ -0,0 +1,42 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * A {@link QmpCommand} that send a system_reset to the VM. + */ +public class QmpReset extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"system_reset\" }"); + + @Override + public JsonNode toJson() { + return jsonTemplate.deepCopy(); + } + + @Override + public String toString() { + return "QmpReset()"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java index c7f6bed..f9d4c5d 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java @@ -28,8 +28,7 @@ import java.math.BigInteger; */ public class QmpSetBalloon extends QmpCommand { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) + @SuppressWarnings({ "PMD.FieldNamingConventions" }) private static final JsonNode jsonTemplate = parseJson("{ \"execute\": \"balloon\", " + "\"arguments\": " + "{ \"value\": 0 } }"); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java new file mode 100644 index 0000000..0048b9a --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetDisplayPassword.java @@ -0,0 +1,68 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; + +/** + * A {@link QmpCommand} that sets the display password. + */ +public class QmpSetDisplayPassword extends QmpCommand { + + private final String password; + private final String protocol; + + /** + * Instantiates a new command. + * + * @param protocol the protocol + * @param password the password + */ + public QmpSetDisplayPassword(String protocol, String password) { + this.protocol = protocol; + this.password = password; + } + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "set_password"); + ObjectNode args = mapper.createObjectNode(); + cmd.set("arguments", args); + args.set("protocol", new TextNode(protocol)); + args.set("password", new TextNode(password)); + return cmd; + } + + @Override + public String toString() { + try { + var json = toJson(); + ((ObjectNode) json.get("arguments")).set("password", + new TextNode("********")); + return mapper.writeValueAsString(json); + } catch (JsonProcessingException e) { + return "(no string representation)"; + } + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetPasswordExpiry.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetPasswordExpiry.java new file mode 100644 index 0000000..672767d --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetPasswordExpiry.java @@ -0,0 +1,66 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.commands; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; + +/** + * A {@link QmpCommand} that sets the password expiry. + */ +public class QmpSetPasswordExpiry extends QmpCommand { + + private final String protocol; + private final String expiry; + + /** + * Instantiates a new command. + * + * @param protocol the protocol + * @param expiry the expiry time + */ + public QmpSetPasswordExpiry(String protocol, String expiry) { + this.protocol = protocol; + this.expiry = expiry; + } + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "expire_password"); + ObjectNode args = mapper.createObjectNode(); + cmd.set("arguments", args); + args.set("protocol", new TextNode(protocol)); + args.set("time", new TextNode(expiry)); + return cmd; + } + + @Override + public String toString() { + try { + var json = toJson(); + return mapper.writeValueAsString(json); + } catch (JsonProcessingException e) { + return "(no string representation)"; + } + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java similarity index 90% rename from org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java rename to org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java index bdb0c73..7afa738 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerConfigurationUpdate.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java @@ -19,7 +19,7 @@ package org.jdrupes.vmoperator.runner.qemu.events; import org.jdrupes.vmoperator.runner.qemu.Configuration; -import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.State; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jgrapes.core.Channel; import org.jgrapes.core.Event; @@ -31,17 +31,17 @@ import org.jgrapes.core.Event; * on the event and only {@link Event#resumeHandling() resume handling} * when the adaption has completed. */ -public class RunnerConfigurationUpdate extends Event { +public class ConfigureQemu extends Event { private final Configuration configuration; - private final State state; + private final RunState state; /** * Instantiates a new configuration event. * * @param channels the channels */ - public RunnerConfigurationUpdate(Configuration configuration, State state, + public ConfigureQemu(Configuration configuration, RunState state, Channel... channels) { super(channels); this.state = state; @@ -62,7 +62,7 @@ public class RunnerConfigurationUpdate extends Event { * * @return the state */ - public State state() { + public RunState runState() { return state; } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/DisplayPasswordChanged.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/DisplayPasswordChanged.java new file mode 100644 index 0000000..0814f50 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/DisplayPasswordChanged.java @@ -0,0 +1,39 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; + +/** + * A {@link MonitorResult} that indicates that the display password has changed. + */ +public class DisplayPasswordChanged extends MonitorResult { + + /** + * Instantiates a new display password changed. + * + * @param command the command + * @param response the response + */ + public DisplayPasswordChanged(QmpCommand command, JsonNode response) { + super(command, response); + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/Exit.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/Exit.java new file mode 100644 index 0000000..bb608f6 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/Exit.java @@ -0,0 +1,43 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.events.Stop; + +/** + * Like {@link Stop}, but sets an exit status. + */ +@SuppressWarnings("PMD.ShortClassName") +public class Exit extends Stop { + + private final int exitStatus; + + /** + * Instantiates a new exit. + * + * @param exitStatus the exit status + */ + public Exit(int exitStatus) { + this.exitStatus = exitStatus; + } + + public int exitStatus() { + return exitStatus; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java new file mode 100644 index 0000000..a1b585d --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/GuestAgentCommand.java @@ -0,0 +1,63 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * An {@link Event} that causes some component to send a QMP + * command to the guest agent process. + */ +public class GuestAgentCommand extends Event { + + private final QmpCommand command; + + /** + * Instantiates a new exec qmp command. + * + * @param command the command + */ + public GuestAgentCommand(QmpCommand command) { + this.command = command; + } + + /** + * Gets the command. + * + * @return the command + */ + public QmpCommand command() { + return command; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)) + .append(" [").append(command); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java index 68641c9..2ab2c5a 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java @@ -30,7 +30,9 @@ import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; */ public class HotpluggableCpuStatus extends MonitorResult { + @SuppressWarnings("PMD.ImmutableField") private List usedCpus = new ArrayList<>(); + @SuppressWarnings("PMD.ImmutableField") private List unusedCpus = new ArrayList<>(); /** diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java index 36d5b40..d2b5e8c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java @@ -55,8 +55,7 @@ public class MonitorCommand extends Event { builder.append(Components.objectName(this)) .append(" [").append(command); if (channels() != null) { - builder.append(", channels="); - builder.append(Channel.toString(channels())); + builder.append(", channels=").append(Channel.toString(channels())); } builder.append(']'); return builder.toString(); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java index 72647a1..93e7785 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java @@ -20,6 +20,8 @@ package org.jdrupes.vmoperator.runner.qemu.events; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; import org.jgrapes.core.Event; /** @@ -28,11 +30,14 @@ import org.jgrapes.core.Event; */ public class MonitorEvent extends Event { + private static final String EVENT_DATA = "data"; + /** * The kind of monitor event. */ public enum Kind { - READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE + READY, POWERDOWN, DEVICE_TRAY_MOVED, BALLOON_CHANGE, SHUTDOWN, + SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED, VSERPORT_CHANGE } private final Kind kind; @@ -44,23 +49,36 @@ public class MonitorEvent extends Event { * @param response the response * @return the optional */ - @SuppressWarnings("PMD.TooFewBranchesForASwitchStatement") public static Optional from(JsonNode response) { try { - var kind - = MonitorEvent.Kind.valueOf(response.get("event").asText()); + var kind = Kind.valueOf(response.get("event").asText()); switch (kind) { case POWERDOWN: return Optional.of(new PowerdownEvent(kind, null)); case DEVICE_TRAY_MOVED: return Optional - .of(new TrayMovedEvent(kind, response.get("data"))); + .of(new TrayMovedEvent(kind, response.get(EVENT_DATA))); case BALLOON_CHANGE: + return Optional.of( + new BalloonChangeEvent(kind, response.get(EVENT_DATA))); + case SHUTDOWN: return Optional - .of(new BalloonChangeEvent(kind, response.get("data"))); + .of(new ShutdownEvent(kind, response.get(EVENT_DATA))); + case SPICE_CONNECTED: + return Optional.of(new SpiceConnectedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_INITIALIZED: + return Optional.of(new SpiceInitializedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_DISCONNECTED: + return Optional.of(new SpiceDisconnectedEvent(kind, + response.get(EVENT_DATA))); + case VSERPORT_CHANGE: + return Optional.of(new VserportChangeEvent(kind, + response.get(EVENT_DATA))); default: return Optional - .of(new MonitorEvent(kind, response.get("data"))); + .of(new MonitorEvent(kind, response.get(EVENT_DATA))); } } catch (IllegalArgumentException e) { return Optional.empty(); @@ -95,4 +113,20 @@ public class MonitorEvent extends Event { public JsonNode data() { return data; } + + /* + * (non-Javadoc) + * + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [").append(data); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } } diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java index c0f55fe..6d7278c 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java @@ -25,6 +25,7 @@ import org.jdrupes.vmoperator.runner.qemu.commands.QmpCapabilities; import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; import org.jdrupes.vmoperator.runner.qemu.commands.QmpDelCpu; import org.jdrupes.vmoperator.runner.qemu.commands.QmpQueryHotpluggableCpus; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -57,6 +58,9 @@ public class MonitorResult extends Event { if (command instanceof QmpDelCpu) { return new CpuDeleted(command, response); } + if (command instanceof QmpSetDisplayPassword) { + return new DisplayPasswordChanged(command, response); + } return new MonitorResult(command, response); } @@ -148,8 +152,7 @@ public class MonitorResult extends Event { builder.append(Components.objectName(this)) .append(" [").append(executed).append(", ").append(successful()); if (channels() != null) { - builder.append(", channels="); - builder.append(Channel.toString(channels())); + builder.append(", channels=").append(Channel.toString(channels())); } builder.append(']'); return builder.toString(); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java new file mode 100644 index 0000000..0e90019 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java @@ -0,0 +1,62 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Signals information about the guest OS. + */ +public class OsinfoEvent extends Event { + + private final JsonNode osinfo; + + /** + * Instantiates a new osinfo event. + * + * @param data the data + */ + public OsinfoEvent(JsonNode data) { + osinfo = data; + } + + public JsonNode osinfo() { + return osinfo; + } + + /* + * (non-Javadoc) + * + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(osinfo); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java index 46fa1f8..261eebf 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java @@ -18,6 +18,7 @@ package org.jdrupes.vmoperator.runner.qemu.events; +import java.util.EnumSet; import org.jgrapes.core.Channel; import org.jgrapes.core.Components; import org.jgrapes.core.Event; @@ -28,26 +29,63 @@ import org.jgrapes.core.Event; public class RunnerStateChange extends Event { /** - * The state. + * The states. */ - public enum State { - INITIALIZING, STARTING, RUNNING, TERMINATING, STOPPED + public enum RunState { + INITIALIZING, STARTING, BOOTING, BOOTED, TERMINATING, STOPPED; + + /** + * Checks if the state is one of the states in which the VM is running. + * + * @return true, if is running + */ + public boolean vmRunning() { + return EnumSet.of(BOOTING, BOOTED, TERMINATING).contains(this); + } + + /** + * Checks if the state is one of the states in which the VM is active. + * + * @return true, if is active + */ + public boolean vmActive() { + return EnumSet.of(BOOTING, BOOTED).contains(this); + } } - private final State state; + private final RunState state; private final String reason; private final String message; + private final boolean failed; /** * Instantiates a new runner state change. * + * @param state the state + * @param reason the reason + * @param message the message * @param channels the channels */ - public RunnerStateChange(State state, String reason, String message, + public RunnerStateChange(RunState state, String reason, String message, Channel... channels) { + this(state, reason, message, false, channels); + } + + /** + * Instantiates a new runner state change. + * + * @param state the state + * @param reason the reason + * @param message the message + * @param failed the failed + * @param channels the channels + */ + public RunnerStateChange(RunState state, String reason, String message, + boolean failed, Channel... channels) { super(channels); this.state = state; this.reason = reason; + this.failed = failed; this.message = message; } @@ -56,7 +94,7 @@ public class RunnerStateChange extends Event { * * @return the state */ - public State state() { + public RunState runState() { return state; } @@ -78,14 +116,25 @@ public class RunnerStateChange extends Event { return message; } + /** + * Checks if is failed. + * + * @return the failed + */ + public boolean failed() { + return failed; + } + @Override public String toString() { - StringBuilder builder = new StringBuilder(); + StringBuilder builder = new StringBuilder(50); builder.append(Components.objectName(this)) .append(" [").append(state).append(": ").append(reason); + if (failed) { + builder.append(" (failed)"); + } if (channels() != null) { - builder.append(", channels="); - builder.append(Channel.toString(channels())); + builder.append(", channels=").append(Channel.toString(channels())); } builder.append(']'); return builder.toString(); diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ShutdownEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ShutdownEvent.java new file mode 100644 index 0000000..1804232 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ShutdownEvent.java @@ -0,0 +1,47 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals the reception of a SHUTDOWN event. + */ +public class ShutdownEvent extends MonitorEvent { + + /** + * Instantiates a new shutdown event. + * + * @param kind the kind + * @param data the data + */ + public ShutdownEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * returns if this is initiated by the guest. + * + * @return the value + */ + public boolean byGuest() { + return data().get("guest").asBoolean(); + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java new file mode 100644 index 0000000..c133307 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceConnectedEvent.java @@ -0,0 +1,37 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceConnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceConnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java new file mode 100644 index 0000000..cfcb489 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceDisconnectedEvent.java @@ -0,0 +1,37 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceDisconnectedEvent extends SpiceEvent { + + /** + * Instantiates a new spice disconnected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceDisconnectedEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java new file mode 100644 index 0000000..4ce27e2 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java @@ -0,0 +1,55 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceEvent extends MonitorEvent { + + /** + * Instantiates a new tray moved. + * + * @param kind the kind + * @param data the data + */ + public SpiceEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the client's host. + * + * @return the client's host address + */ + public String clientHost() { + return data().get("client").get("host").asText(); + } + + /** + * Returns the client's port. + * + * @return the client's port number + */ + public long clientPort() { + return data().get("client").get("port").asLong(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java new file mode 100644 index 0000000..7bb84b7 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceInitializedEvent.java @@ -0,0 +1,46 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a connection from a client. + */ +public class SpiceInitializedEvent extends SpiceEvent { + + /** + * Instantiates a new spice connected event. + * + * @param kind the kind + * @param data the data + */ + public SpiceInitializedEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the channel type. + * + * @return the channel type + */ + public int channelType() { + return data().get("client").get("channel-type").asInt(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java index f5ef725..e2d2286 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java @@ -50,7 +50,7 @@ public class TrayMovedEvent extends MonitorEvent { * * @return the tray state */ - public TrayState state() { + public TrayState trayState() { return data().get("tray-open").asBoolean() ? TrayState.OPEN : TrayState.CLOSED; diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java new file mode 100644 index 0000000..dc13569 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentConnected.java @@ -0,0 +1,27 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals information about the guest OS. + */ +public class VmopAgentConnected extends Event { +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java new file mode 100644 index 0000000..96db884 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogIn.java @@ -0,0 +1,45 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Sends the login command to the VM operator agent. + */ +public class VmopAgentLogIn extends Event { + + private final String user; + + /** + * Instantiates a new vmop agent logout. + */ + public VmopAgentLogIn(String user) { + this.user = user; + } + + /** + * Returns the user. + * + * @return the user + */ + public String user() { + return user; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java new file mode 100644 index 0000000..1502200 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLogOut.java @@ -0,0 +1,27 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Sends the logout command to the VM operator agent. + */ +public class VmopAgentLogOut extends Event { +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java new file mode 100644 index 0000000..f59ed71 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedIn.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals that the logout command has been processes by the + * VM operator agent. + */ +public class VmopAgentLoggedIn extends Event { + + private final VmopAgentLogIn triggering; + + /** + * Instantiates a new vmop agent logged in. + * + * @param triggeringEvent the triggering event + */ + public VmopAgentLoggedIn(VmopAgentLogIn triggeringEvent) { + this.triggering = triggeringEvent; + } + + /** + * Gets the triggering event. + * + * @return the triggering + */ + public VmopAgentLogIn triggering() { + return triggering; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java new file mode 100644 index 0000000..5f60e00 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VmopAgentLoggedOut.java @@ -0,0 +1,49 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import org.jgrapes.core.Event; + +/** + * Signals that the logout command has been processes by the + * VM operator agent. + */ +public class VmopAgentLoggedOut extends Event { + + private final VmopAgentLogOut triggering; + + /** + * Instantiates a new vmop agent logged out. + * + * @param triggeringEvent the triggering event + */ + public VmopAgentLoggedOut(VmopAgentLogOut triggeringEvent) { + this.triggering = triggeringEvent; + } + + /** + * Gets the triggering event. + * + * @return the triggering + */ + public VmopAgentLogOut triggering() { + return triggering; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java new file mode 100644 index 0000000..b590cd3 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/VserportChangeEvent.java @@ -0,0 +1,56 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Signals a virtual serial port's open state change. + */ +public class VserportChangeEvent extends MonitorEvent { + + /** + * Initializes a new instance. + * + * @param kind the kind + * @param data the data + */ + public VserportChangeEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Return the channel's id. + * + * @return the string + */ + @SuppressWarnings("PMD.ShortMethodName") + public String id() { + return data().get("id").asText(); + } + + /** + * Returns the open state of the port. + * + * @return true, if is open + */ + public boolean isOpen() { + return Boolean.parseBoolean(data().get("open").asText()); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml index b21db8f..c5c0252 100644 --- a/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/templates/Standard-VM-latest.ftl.yaml @@ -11,6 +11,22 @@ - [ "--ctrl", "type=unixio,path=${ runtimeDir }/swtpm-sock,mode=0600" ] - "--terminate" +"cloudInitImg": + # Candidate paths for the executable + "executable": [ "/bin/sh", "/usr/bin/sh" ] + + # Arguments may be specified as nested lists for better readability. + # The arguments are flattened before being passed to the process. + "arguments": + - "-c" + - >- + mformat -C -f 1440 -v CIDATA -i ${ runtimeDir }/cloud-init.img + && mcopy -i ${ runtimeDir }/cloud-init.img + ${ dataDir }/cloud-init/meta-data ${ dataDir }/cloud-init/user-data :: + && if [ -r ${ dataDir }/cloud-init/network-config ]; then + mcopy -i ${ runtimeDir }/cloud-init.img + ${ dataDir }/cloud-init/network-config :: ; fi + "qemu": # Candidate paths for the executable "executable": [ "/usr/bin/qemu-system-x86_64" ] @@ -106,11 +122,16 @@ # Best explanation found: # https://fedoraproject.org/wiki/Features/VirtioSerial - [ "-device", "virtio-serial-pci,id=virtio-serial0" ] - # - Guest agent serial connection + # - Guest agent serial connection. - [ "-device", "virtserialport,id=channel0,name=org.qemu.guest_agent.0,\ chardev=guest-agent-socket" ] - [ "-chardev","socket,id=guest-agent-socket,\ path=${ runtimeDir }/org.qemu.guest_agent.0,server=on,wait=off" ] + # - VM operator agent serial connection. + - [ "-device", "virtserialport,id=channel1,name=org.jdrupes.vmop_agent.0,\ + chardev=vmop-agent-socket" ] + - [ "-chardev","socket,id=vmop-agent-socket,\ + path=${ runtimeDir }/org.jdrupes.vmop_agent.0,server=on,wait=off" ] # * USB Hub and devices (more in SPICE configuration below) # https://qemu-project.gitlab.io/qemu/system/devices/usb.html # https://github.com/qemu/qemu/blob/master/hw/usb/hcd-xhci.c @@ -121,7 +142,8 @@ - [ "-device", "virtio-rng-pci,rng=objrng0,id=rng0" ] # * Graphics and Audio Card # This is the only video "card" without a flickering cursor. - - [ "-device", "virtio-vga,id=video0,max_outputs=1" ] + - [ "-device", "virtio-vga,id=video0,max_outputs=${ vm.display.outputs },\ + max_hostmem=${ (vm.display.outputs * 256 * 1024 * 1024)?c }" ] - [ "-device", "ich9-intel-hda,id=sound0" ] # Network <#assign nwCounter = 0/> @@ -183,18 +205,24 @@ <#break> + # Cloud-init image + <#if cloudInit??> + - [ "-blockdev", "node-name=drive-${ drvCounter }-host-resource,\ + driver=file,filename=${ runtimeDir }/cloud-init.img" ] + # - how to use the file (as sequence of literal blocks) + - [ "-blockdev", "node-name=drive-${ drvCounter }-backend,driver=raw,\ + file=drive-${ drvCounter }-host-resource" ] + # - the driver (what the guest sees) + - [ "-device", "virtio-blk-pci,drive=drive-${ drvCounter }-backend" ] + <#if vm.display??> <#if vm.display.spice??> <#assign spice = vm.display.spice/> # SPICE (display, channels ...) # https://www.linux-kvm.org/page/SPICE - <#if ticketPath??> - - [ "-object", "secret,id=spiceTicket,file=${ ticketPath }" ] - - [ "-spice", "port=${ spice.port?c }\ - <#if spice.ticket??>,password-secret=spiceTicket\ - <#else>,disable-ticketing=on\ + ,disable-ticketing=<#if hasDisplayPassword!false>off<#else>on\ <#if spice.streamingVideo??>,streaming-video=${ spice.streamingVideo }\ ,seamless-migration=on" ] - [ "-chardev", "spicevmc,id=vdagentdev,name=vdagent" ] diff --git a/org.jdrupes.vmoperator.util/.eclipse-pmd b/org.jdrupes.vmoperator.util/.eclipse-pmd index 8b394f8..5d69caa 100644 --- a/org.jdrupes.vmoperator.util/.eclipse-pmd +++ b/org.jdrupes.vmoperator.util/.eclipse-pmd @@ -2,6 +2,6 @@ - + diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java new file mode 100644 index 0000000..e83cf27 --- /dev/null +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/DataPath.java @@ -0,0 +1,176 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.util; + +import java.lang.reflect.Array; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * Utility class that supports navigation through arbitrary data structures. + */ +public final class DataPath { + + private static final Logger logger + = Logger.getLogger(DataPath.class.getName()); + + private DataPath() { + } + + /** + * Apply the given selectors on the given object and return the + * value reached. + * + * Selectors can be if type {@link String} or {@link Number}. The + * former are used to access a property of an object, the latter to + * access an element in an array or a {@link List}. + * + * Depending on the object currently visited, a {@link String} can + * be the key of a {@link Map}, the property part of a getter method + * or the name of a method that has an empty parameter list. + * + * @param the generic type + * @param from the from + * @param selectors the selectors + * @return the result + */ + public static Optional get(Object from, Object... selectors) { + Object cur = from; + for (var selector : selectors) { + if (cur == null) { + return Optional.empty(); + } + if (selector instanceof String && cur instanceof Map map) { + cur = map.get(selector); + continue; + } + if (selector instanceof Number index && cur instanceof List list) { + cur = list.get(index.intValue()); + continue; + } + if (selector instanceof String property) { + var retrieved = tryAccess(cur, property); + if (retrieved.isEmpty()) { + return Optional.empty(); + } + cur = retrieved.get(); + } + } + @SuppressWarnings("unchecked") + var result = Optional.ofNullable((T) cur); + return result; + } + + @SuppressWarnings("PMD.UseLocaleWithCaseConversions") + private static Optional tryAccess(Object obj, String property) { + Method acc = null; + try { + // Try getter + acc = obj.getClass().getMethod("get" + property.substring(0, 1) + .toUpperCase() + property.substring(1)); + } catch (SecurityException e) { + return Optional.empty(); + } catch (NoSuchMethodException e) { // NOPMD + // Can happen... + } + if (acc == null) { + try { + // Try method + acc = obj.getClass().getMethod(property); + } catch (SecurityException | NoSuchMethodException e) { + return Optional.empty(); + } + } + if (acc != null) { + try { + return Optional.ofNullable(acc.invoke(obj)); + } catch (IllegalAccessException + | InvocationTargetException e) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + /** + * Attempts to make a as-deep-as-possible copy of the given + * container. New containers will be created for Maps, Lists and + * Arrays. The method is invoked recursively for the entries/items. + * + * If invoked with an object that is neither a map, list or array, + * the methods checks if the object implements {@link Cloneable} + * and if it does, invokes its {@link Object#clone()} method. + * Else the method return the object. + * + * @param the generic type + * @param object the container + * @return the t + */ + @SuppressWarnings({ "PMD.CognitiveComplexity", "unchecked" }) + public static T deepCopy(T object) { + if (object instanceof Map map) { + Map copy; + try { + copy = (Map) object.getClass().getConstructor() + .newInstance(); + } catch (InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + logger.severe( + () -> "Cannot create new instance of " + object.getClass()); + return null; + } + for (var entry : ((Map) map).entrySet()) { + copy.put(entry.getKey(), + deepCopy(entry.getValue())); + } + return (T) copy; + } + if (object instanceof List list) { + List copy = new ArrayList<>(); + for (var item : list) { + copy.add(deepCopy(item)); + } + return (T) copy; + } + if (object.getClass().isArray()) { + var copy = Array.newInstance(object.getClass().getComponentType(), + Array.getLength(object)); + for (int i = 0; i < Array.getLength(object); i++) { + Array.set(copy, i, deepCopy(Array.get(object, i))); + } + return (T) copy; + } + if (object instanceof Cloneable) { + try { + return (T) object.getClass().getMethod("clone") + .invoke(object); + } catch (IllegalAccessException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + return object; + } + } + return object; + } +} diff --git a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java index c16b3e0..c6fb101 100644 --- a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java @@ -23,14 +23,16 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.function.Supplier; /** * Utility class for pointing to elements on a Gson (Json) tree. */ -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", - "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal" }) +@SuppressWarnings({ "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal" }) public class GsonPtr { private final JsonElement position; @@ -60,7 +62,8 @@ public class GsonPtr { * @param selectors the selectors * @return the Gson pointer */ - @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace" }) + @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace", + "PMD.AvoidDuplicateLiterals" }) public GsonPtr to(Object... selectors) { JsonElement element = position; for (Object sel : selectors) { @@ -89,6 +92,42 @@ public class GsonPtr { return new GsonPtr(element); } + /** + * Create a new instance pointing to the {@link JsonElement} + * selected by the given selectors. If a selector of type + * {@link String} denotes a non-existant member of a + * {@link JsonObject} the result is empty. + * + * @param selectors the selectors + * @return the Gson pointer + */ + @SuppressWarnings({ "PMD.PreserveStackTrace" }) + public Optional get(Object... selectors) { + JsonElement element = position; + for (Object sel : selectors) { + if (element instanceof JsonObject obj + && sel instanceof String member) { + element = obj.get(member); + if (element == null) { + return Optional.empty(); + } + continue; + } + if (element instanceof JsonArray arr + && sel instanceof Integer index) { + try { + element = arr.get(index); + } catch (IndexOutOfBoundsException e) { + throw new IllegalStateException("Selected array index" + + " may not be empty."); + } + continue; + } + throw new IllegalStateException("Invalid selection"); + } + return Optional.of(new GsonPtr(element)); + } + /** * Returns {@link JsonElement} that the pointer points to. * @@ -106,8 +145,7 @@ public class GsonPtr { * @param cls the cls * @return the result */ - @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) - public T get(Class cls) { + public T getAs(Class cls) { if (cls.isAssignableFrom(position.getClass())) { return cls.cast(position); } @@ -126,7 +164,7 @@ public class GsonPtr { */ @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) public Optional - get(Class cls, Object... selectors) { + getAs(Class cls, Object... selectors) { JsonElement element = position; for (Object sel : selectors) { if (element instanceof JsonObject obj @@ -161,7 +199,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsString(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsString); } @@ -172,7 +210,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsInt(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsInt); } @@ -183,7 +221,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsBigInteger(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsBigInteger); } @@ -194,7 +232,7 @@ public class GsonPtr { * @return the as string */ public Optional getAsLong(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsLong); } @@ -205,10 +243,25 @@ public class GsonPtr { * @return the boolean */ public Optional getAsBoolean(Object... selectors) { - return get(JsonPrimitive.class, selectors) + return getAs(JsonPrimitive.class, selectors) .map(JsonPrimitive::getAsBoolean); } + /** + * Returns the elements of the selected {@link JsonArray} as list. + * + * @param the generic type + * @param cls the cls + * @param selectors the selectors + * @return the list + */ + @SuppressWarnings("unchecked") + public List getAsListOf(Class cls, + Object... selectors) { + return getAs(JsonArray.class, selectors).map(a -> (List) a.asList()) + .orElse(Collections.emptyList()); + } + /** * Sets the selected value. This pointer must point to a * {@link JsonObject} or {@link JsonArray}. The selector must @@ -248,6 +301,42 @@ public class GsonPtr { return set(selector, new JsonPrimitive(value)); } + /** + * Short for `set(selector, new JsonPrimitive(value))`. + * + * @param selector the selector + * @param value the value + * @return the gson ptr + * @see #set(Object, JsonElement) + */ + public GsonPtr set(Object selector, Long value) { + return set(selector, new JsonPrimitive(value)); + } + + /** + * Short for `set(selector, new JsonPrimitive(value))`. + * + * @param selector the selector + * @param value the value + * @return the gson ptr + * @see #set(Object, JsonElement) + */ + public GsonPtr set(Object selector, BigInteger value) { + return set(selector, new JsonPrimitive(value)); + } + + /** + * Short for `set(selector, new JsonPrimitive(value))`. + * + * @param selector the selector + * @param value the value + * @return the gson ptr + * @see #set(Object, JsonElement) + */ + public GsonPtr set(Object selector, Boolean value) { + return set(selector, new JsonPrimitive(value)); + } + /** * Same as {@link #set(Object, JsonElement)}, but sets the value * only if it doesn't exist yet, else returns the existing value. @@ -295,4 +384,22 @@ public class GsonPtr { return this; } + /** + * Removes all properties except the specified ones. + * + * @param properties the properties + */ + public void removeExcept(String... properties) { + if (!position.isJsonObject()) { + return; + } + for (var itr = ((JsonObject) position).entrySet().iterator(); + itr.hasNext();) { + var entry = itr.next(); + if (Arrays.asList(properties).contains(entry.getKey())) { + continue; + } + itr.remove(); + } + } } diff --git a/org.jdrupes.vmoperator.util/test/org/jdrupes/vmoperator/util/DataPathTests.java b/org.jdrupes.vmoperator.util/test/org/jdrupes/vmoperator/util/DataPathTests.java new file mode 100644 index 0000000..9c7855f --- /dev/null +++ b/org.jdrupes.vmoperator.util/test/org/jdrupes/vmoperator/util/DataPathTests.java @@ -0,0 +1,17 @@ +package org.jdrupes.vmoperator.util; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class DataPathTests { + + @Test + void testArray() { + int[] orig + = { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3) }; + var copy = DataPath.deepCopy(orig); + for (int i = 0; i < orig.length; i++) { + assertEquals(orig[i], copy[i]); + } + } +} diff --git a/org.jdrupes.vmoperator.vmconlet/.checkstyle b/org.jdrupes.vmoperator.vmaccess/.checkstyle similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.checkstyle rename to org.jdrupes.vmoperator.vmaccess/.checkstyle diff --git a/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd b/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd new file mode 100644 index 0000000..60d7780 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.eclipse-pmd @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/.eslintignore b/org.jdrupes.vmoperator.vmaccess/.eslintignore new file mode 100644 index 0000000..139d3ee --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.eslintignore @@ -0,0 +1 @@ +rollup.config.mjs diff --git a/org.jdrupes.vmoperator.vmaccess/.eslintrc.json b/org.jdrupes.vmoperator.vmaccess/.eslintrc.json new file mode 100644 index 0000000..e4f80f1 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { "project": ["./tsconfig.json"] }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "constructor-super": "off" + } +} + diff --git a/org.jdrupes.vmoperator.vmconlet/.gitignore b/org.jdrupes.vmoperator.vmaccess/.gitignore similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.gitignore rename to org.jdrupes.vmoperator.vmaccess/.gitignore diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.buildship.core.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.buildship.core.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.buildship.core.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.resources.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.resources.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.resources.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.core.runtime.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.jdt.ui.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.jdt.ui.prefs similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/.settings/org.eclipse.jdt.ui.prefs rename to org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.jdt.ui.prefs diff --git a/org.jdrupes.vmoperator.vmconlet/build.gradle b/org.jdrupes.vmoperator.vmaccess/build.gradle similarity index 86% rename from org.jdrupes.vmoperator.vmconlet/build.gradle rename to org.jdrupes.vmoperator.vmaccess/build.gradle index 2031651..606c6cd 100644 --- a/org.jdrupes.vmoperator.vmconlet/build.gradle +++ b/org.jdrupes.vmoperator.vmaccess/build.gradle @@ -5,9 +5,11 @@ plugins { dependencies { implementation project(':org.jdrupes.vmoperator.manager.events') - implementation 'org.jgrapes:org.jgrapes.webconsole.base:[1.2.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' - implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' + } apply plugin: 'com.github.node-gradle.node' @@ -22,6 +24,7 @@ task extractDependencies(type: Copy) { || it.name.contains('org.jgrapes.webconsole.base') } .collect{ zipTree (it) } + exclude '*.class' into 'build/unpacked' duplicatesStrategy 'include' } diff --git a/org.jdrupes.vmoperator.vmconlet/package.json b/org.jdrupes.vmoperator.vmaccess/package.json similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/package.json rename to org.jdrupes.vmoperator.vmaccess/package.json diff --git a/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory new file mode 100644 index 0000000..ec5cf30 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory @@ -0,0 +1 @@ +org.jdrupes.vmoperator.vmaccess.VmAccessFactory diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html new file mode 100644 index 0000000..d7b9405 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/VmAccess-confirmReset.ftl.html @@ -0,0 +1,13 @@ +
+

${_("confirmResetMsg")}

+

+ + + + + + +

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

${_("confirmResetMsg")}

+

+ + + + + + +

+
\ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-l10nBundles.ftl.js b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js similarity index 100% rename from org.jdrupes.vmoperator.vmconlet/resources/org/jdrupes/vmoperator/vmconlet/VmConlet-l10nBundles.ftl.js rename to org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html new file mode 100644 index 0000000..8c9970a --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-preview.ftl.html @@ -0,0 +1,46 @@ +
+ +
+
+ {{ localize("Period") }}: +
    +
  • + +
  • +
  • + +
  • +
+
+
+ + + + + + + + + + + + + + + + +
{{ localize("VMsSummary") }}:{{ vmSummary.runningVms }} / {{ vmSummary.totalVms }}
{{ localize("currentCpus") }}:{{ vmSummary.usedCpus }}
{{ localize("currentRam") }}:{{ formatMemory(Number(vmSummary.usedRam)) }}
+
+ +
+ +
diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html new file mode 100644 index 0000000..3197440 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-view.ftl.html @@ -0,0 +1,145 @@ +
+ + + + + + + + + + + +
+ {{ localize(controller.label(key)) }} + + {{ localize("vmActions") }} +
+
diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-in-use.svg b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-in-use.svg new file mode 100644 index 0000000..00e4cc0 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-in-use.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-off.svg b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-off.svg new file mode 100644 index 0000000..27c11ae --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer-off.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer.svg b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer.svg new file mode 100644 index 0000000..f7a6b94 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/computer.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties new file mode 100644 index 0000000..95cb839 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n.properties @@ -0,0 +1,26 @@ +conletName = VM Management + +VMsSummary = VMs (running/total) + +assignedTo = Assigned to +currentCpus = Current vCPUs +currentRam = Current vRAM +guestOs = Guest OS +maximumCpus = Maximum vCPUs +maximumRam = Maximum vRAM +notInUse = Currently closed +nodeName = Node +requestedCpus = Requested vCPUs +requestedRam = Requested vRAM +runnerVersion = Runner version +running = Running +since = Since +usedBy = Used by +usedFrom = Used from +vmActions = Actions +vmname = Name + +confirmResetTitle = Confirm reset +confirmResetMsg = Resetting the VM may cause loss of data. \ + Please confirm to continue. +consoleTakenNotification = Console access is locked by another user. diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties new file mode 100644 index 0000000..abe0d46 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_de.properties @@ -0,0 +1,40 @@ +conletName = VM-Management + +VMsSummary = VMs (gestartet/gesamt) + +Period = Zeitraum +Last\ hour = Letzte Stunde +Last\ day = Letzter Tag + +assignedTo = Zugewiesen an +currentCpus = Aktuelle vCPUs +currentRam = Akuelles vRAM +guestOs = Gast BS +maximumCpus = Maximale vCPUs +maximumRam = Maximales vRAM +nodeName = Knoten +notInUse = Derzeit geschlossen +requestedCpus = Angeforderte vCPUs +requestedRam = Angefordertes vRAM +runnerVersion = Runner-Version +running = Gestartet +since = Seit +usedBy = Benutzt durch +usedFrom = Benutzt von +vmActions = Aktionen +vmname = Name +Value\ is\ above\ maximum = Wert ist zu groß +Illegal\ format = Ungültiges Format + +confirmResetTitle = Zurücksetzen bestätigen +confirmResetMsg = Zurücksetzen der VM kann zu Datenverlust führen. \ + Bitte bestätigen um fortzufahren. +consoleTakenNotification = Die Konsole wird von einem anderen Benutzer verwendet. + +Open\ console = Konsole anzeigen +Start\ VM = VM Starten +Stop\ VM = VM Anhalten +Reset\ VM = VM zurücksetzen + +Yes = Ja +No = Nein diff --git a/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_en.properties b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/l10n_en.properties new file mode 100644 index 0000000..e69de29 diff --git a/org.jdrupes.vmoperator.vmmgmt/rollup.config.mjs b/org.jdrupes.vmoperator.vmmgmt/rollup.config.mjs new file mode 100644 index 0000000..59aff08 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/rollup.config.mjs @@ -0,0 +1,36 @@ +import typescript from 'rollup-plugin-typescript2'; +import postcss from 'rollup-plugin-postcss'; + +let packagePath = "org/jdrupes/vmoperator/vmmgmt"; +let baseName = "VmMgmt" +let module = "build/generated/resources/" + packagePath + + "/" + baseName + "-functions.js"; + +let pathsMap = { + "aash-plugin": "../../page-resource/aash-vue-components/lib/aash-vue-components.js", + "jgconsole": "../../console-base-resource/jgconsole.js", + "jgwc": "../../page-resource/jgwc-vue-components/jgwc-components.js", + "l10nBundles": "./" + baseName + "-l10nBundles.ftl.js", + "vue": "../../page-resource/vue/vue.esm-browser.js", + "chartjs": "../../page-resource/chart.js/auto.js" +} + +export default { + external: ['aash-plugin', 'jgconsole', 'jgwc', 'l10nBundles', 'vue', 'chartjs'], + input: "src/" + packagePath + "/browser/" + baseName + "-functions.ts", + output: [ + { + format: "esm", + file: module, + sourcemap: true, + sourcemapPathTransform: (relativeSourcePath, _sourcemapPath) => { + return relativeSourcePath.replace(/^([^/]*\/){12}/, "./"); + }, + paths: pathsMap + } + ], + plugins: [ + typescript(), + postcss() + ] +}; diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/TimeSeries.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/TimeSeries.java new file mode 100644 index 0000000..7e1f39e --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/TimeSeries.java @@ -0,0 +1,141 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.vmmgmt; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +/** + * The Class TimeSeries. + */ +public class TimeSeries { + + @SuppressWarnings("PMD.LooseCoupling") + private final LinkedList data = new LinkedList<>(); + private final Duration period; + + /** + * Instantiates a new time series. + * + * @param period the period + */ + public TimeSeries(Duration period) { + this.period = period; + } + + /** + * Adds data to the series. + * + * @param time the time + * @param numbers the numbers + * @return the time series + */ + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.AvoidSynchronizedStatement" }) + public TimeSeries add(Instant time, Number... numbers) { + var newEntry = new Entry(time, numbers); + boolean nothingNew = false; + synchronized (data) { + if (data.size() >= 2) { + var lastEntry = data.get(data.size() - 1); + var lastButOneEntry = data.get(data.size() - 2); + nothingNew = lastEntry.valuesEqual(lastButOneEntry) + && lastEntry.valuesEqual(newEntry); + } + if (nothingNew) { + data.removeLast(); + } + data.add(new Entry(time, numbers)); + + // Purge + Instant limit = time.minus(period); + while (data.size() > 2 + && data.get(0).getTime().isBefore(limit) + && data.get(1).getTime().isBefore(limit)) { + data.removeFirst(); + } + } + return this; + } + + /** + * Returns the entries. + * + * @return the list + */ + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public List entries() { + synchronized (data) { + return new ArrayList<>(data); + } + } + + /** + * The Class Entry. + */ + public static class Entry { + private final Instant timestamp; + private final Number[] values; + + /** + * Instantiates a new entry. + * + * @param time the time + * @param numbers the numbers + */ + @SuppressWarnings("PMD.ArrayIsStoredDirectly") + public Entry(Instant time, Number... numbers) { + timestamp = time; + values = numbers; + } + + /** + * Returns the entry's time. + * + * @return the instant + */ + public Instant getTime() { + return timestamp; + } + + /** + * Returns the values. + * + * @return the number[] + */ + @SuppressWarnings("PMD.MethodReturnsInternalArray") + public Number[] getValues() { + return values; + } + + /** + * Returns `true` if both entries have the same values. + * + * @param other the other + * @return true, if successful + */ + public boolean valuesEqual(Entry other) { + return Arrays.equals(values, other.values); + } + } +} diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java new file mode 100644 index 0000000..e4380ba --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmt.java @@ -0,0 +1,516 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.vmmgmt; + +import freemarker.core.ParseException; +import freemarker.template.MalformedTemplateNameException; +import freemarker.template.Template; +import freemarker.template.TemplateNotFoundException; +import io.kubernetes.client.custom.Quantity; +import io.kubernetes.client.custom.Quantity.Format; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ResourceBundle; +import java.util.Set; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; +import org.jdrupes.vmoperator.manager.events.ChannelTracker; +import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; +import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.ResetVm; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jdrupes.vmoperator.util.DataPath; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; +import org.jgrapes.core.Manager; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.webconsole.base.Conlet.RenderMode; +import org.jgrapes.webconsole.base.ConletBaseModel; +import org.jgrapes.webconsole.base.ConsoleConnection; +import org.jgrapes.webconsole.base.ConsoleRole; +import org.jgrapes.webconsole.base.ConsoleUser; +import org.jgrapes.webconsole.base.WebConsoleUtils; +import org.jgrapes.webconsole.base.events.AddConletType; +import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; +import org.jgrapes.webconsole.base.events.ConsoleReady; +import org.jgrapes.webconsole.base.events.DisplayNotification; +import org.jgrapes.webconsole.base.events.NotifyConletModel; +import org.jgrapes.webconsole.base.events.NotifyConletView; +import org.jgrapes.webconsole.base.events.OpenModalDialog; +import org.jgrapes.webconsole.base.events.RenderConlet; +import org.jgrapes.webconsole.base.events.RenderConletRequestBase; +import org.jgrapes.webconsole.base.events.SetLocale; +import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; + +/** + * The Class {@link VmMgmt}. + */ +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports" }) +public class VmMgmt extends FreeMarkerConlet { + + private Class preferredIpVersion = Inet4Address.class; + private boolean deleteConnectionFile = true; + private static final Set MODES = RenderMode.asSet( + RenderMode.Preview, RenderMode.View); + private final ChannelTracker channelTracker = new ChannelTracker<>(); + private final TimeSeries summarySeries = new TimeSeries(Duration.ofDays(1)); + private Summary cachedSummary; + + /** + * The periodically generated update event. + */ + public static class Update extends Event { + } + + /** + * Creates a new component with its channel set to the given channel. + * + * @param componentChannel the channel that the component's handlers listen + * on by default and that {@link Manager#fire(Event, Channel...)} + * sends the event to + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public VmMgmt(Channel componentChannel) { + super(componentChannel); + setPeriodicRefresh(Duration.ofMinutes(1), () -> new Update()); + } + + /** + * Configure the component. + * + * @param event the event + */ + @SuppressWarnings({ "unchecked" }) + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured("/Manager/GuiHttpServer" + + "/ConsoleWeblet/WebConsole/ComponentCollector/VmAccess") + .ifPresent(c -> { + try { + var dispRes = (Map) c + .getOrDefault("displayResource", + Collections.emptyMap()); + switch ((String) dispRes.getOrDefault("preferredIpVersion", + "")) { + case "ipv6": + preferredIpVersion = Inet6Address.class; + break; + case "ipv4": + default: + preferredIpVersion = Inet4Address.class; + break; + } + + // Delete connection file + deleteConnectionFile + = Optional.ofNullable(c.get("deleteConnectionFile")) + .filter(v -> v instanceof String) + .map(v -> (String) v) + .map(Boolean::parseBoolean).orElse(true); + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } + + /** + * On {@link ConsoleReady}, fire the {@link AddConletType}. + * + * @param event the event + * @param channel the channel + * @throws TemplateNotFoundException the template not found exception + * @throws MalformedTemplateNameException the malformed template name + * exception + * @throws ParseException the parse exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException { + // Add conlet resources to page + channel.respond(new AddConletType(type()) + .setDisplayNames( + localizations(channel.supportedLocales(), "conletName")) + .addRenderMode(RenderMode.Preview) + .addScript(new ScriptResource().setScriptType("module") + .setScriptUri(event.renderSupport().conletResource( + type(), "VmMgmt-functions.js")))); + } + + @Override + protected Optional createStateRepresentation(Event event, + ConsoleConnection connection, String conletId) throws Exception { + return Optional.of(new VmsModel(conletId)); + } + + @Override + protected Set doRenderConlet(RenderConletRequestBase event, + ConsoleConnection channel, String conletId, VmsModel conletState) + throws Exception { + Set renderedAs = EnumSet.noneOf(RenderMode.class); + boolean sendVmInfos = false; + if (event.renderAs().contains(RenderMode.Preview)) { + Template tpl + = freemarkerConfig().getTemplate("VmMgmt-preview.ftl.html"); + channel.respond(new RenderConlet(type(), conletId, + processTemplate(event, tpl, + fmModel(event, channel, conletId, conletState))) + .setRenderAs( + RenderMode.Preview.addModifiers(event.renderAs())) + .setSupportedModes(MODES)); + renderedAs.add(RenderMode.Preview); + channel.respond(new NotifyConletView(type(), + conletId, "summarySeries", summarySeries.entries())); + var summary = evaluateSummary(false); + channel.respond(new NotifyConletView(type(), + conletId, "updateSummary", summary)); + sendVmInfos = true; + } + if (event.renderAs().contains(RenderMode.View)) { + Template tpl + = freemarkerConfig().getTemplate("VmMgmt-view.ftl.html"); + channel.respond(new RenderConlet(type(), conletId, + processTemplate(event, tpl, + fmModel(event, channel, conletId, conletState))) + .setRenderAs( + RenderMode.View.addModifiers(event.renderAs())) + .setSupportedModes(MODES)); + renderedAs.add(RenderMode.View); + sendVmInfos = true; + } + if (sendVmInfos) { + for (var item : channelTracker.values()) { + updateVm(channel, conletId, item.associated()); + } + } + return renderedAs; + } + + private void updateVm(ConsoleConnection channel, String conletId, + VmDefinition vmDef) { + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(channel.session()) + .stream().map(ConsoleRole::getName).toList(); + channel.respond(new NotifyConletView(type(), conletId, "updateVm", + simplifiedVmDefinition(vmDef, user, roles))); + } + + private Map simplifiedVmDefinition(VmDefinition vmDef, + String user, List roles) { + // Convert RAM sizes to unitless numbers + var spec = DataPath.deepCopy(vmDef.spec()); + spec.remove("cloudInit"); + var vmSpec = DataPath.> get(spec, "vm").get(); + vmSpec.remove("networks"); + vmSpec.remove("disks"); + vmSpec.put("maximumRam", Quantity.fromString( + DataPath. get(vmSpec, "maximumRam").orElse("0")).getNumber() + .toBigInteger()); + vmSpec.put("currentRam", Quantity.fromString( + DataPath. get(vmSpec, "currentRam").orElse("0")).getNumber() + .toBigInteger()); + var status = DataPath.deepCopy(vmDef.status()); + status.put(Status.RAM, Quantity.fromString( + DataPath. get(status, Status.RAM).orElse("0")).getNumber() + .toBigInteger()); + + // Build result + var perms = vmDef.permissionsFor(user, roles); + return Map.of("metadata", + Map.of("namespace", vmDef.namespace(), + "name", vmDef.name()), + "spec", spec, + "status", status, + "nodeName", vmDef.extra().nodeName(), + "consoleAccessible", vmDef.consoleAccessible(user, perms), + "permissions", perms); + } + + /** + * Track the VM definitions. + * + * @param event the event + * @param channel the channel + * @throws IOException + */ + @Handler(namedChannels = "manager") + @SuppressWarnings({ "PMD.CognitiveComplexity", + "PMD.AvoidInstantiatingObjectsInLoops" }) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) + throws IOException { + var vmName = event.vmDefinition().name(); + if (event.type() == K8sObserver.ResponseType.DELETED) { + channelTracker.remove(vmName); + for (var entry : conletIdsByConsoleConnection().entrySet()) { + for (String conletId : entry.getValue()) { + entry.getKey().respond(new NotifyConletView(type(), + conletId, "removeVm", vmName)); + } + } + } else { + var vmDef = event.vmDefinition(); + channelTracker.put(vmName, channel, vmDef); + for (var entry : conletIdsByConsoleConnection().entrySet()) { + for (String conletId : entry.getValue()) { + updateVm(entry.getKey(), conletId, vmDef); + } + } + } + + var summary = evaluateSummary(true); + summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam); + for (var entry : conletIdsByConsoleConnection().entrySet()) { + for (String conletId : entry.getValue()) { + entry.getKey().respond(new NotifyConletView(type(), + conletId, "updateSummary", summary)); + } + } + } + + /** + * Handle the periodic update event by sending {@link NotifyConletView} + * events. + * + * @param event the event + * @param connection the console connection + */ + @Handler + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onUpdate(Update event, ConsoleConnection connection) { + var summary = evaluateSummary(false); + summarySeries.add(Instant.now(), summary.usedCpus, summary.usedRam); + for (String conletId : conletIds(connection)) { + connection.respond(new NotifyConletView(type(), + conletId, "updateSummary", summary)); + } + } + + /** + * The Class Summary. + */ + @SuppressWarnings("PMD.DataClass") + public static class Summary { + + /** The total vms. */ + public int totalVms; + + /** The running vms. */ + public long runningVms; + + /** The used cpus. */ + public long usedCpus; + + /** The used ram. */ + public BigInteger usedRam = BigInteger.ZERO; + + /** + * Gets the total vms. + * + * @return the totalVms + */ + public int getTotalVms() { + return totalVms; + } + + /** + * Gets the running vms. + * + * @return the runningVms + */ + public long getRunningVms() { + return runningVms; + } + + /** + * Gets the used cpus. + * + * @return the usedCpus + */ + public long getUsedCpus() { + return usedCpus; + } + + /** + * Gets the used ram. Returned as String for Json rendering. + * + * @return the usedRam + */ + public String getUsedRam() { + return usedRam.toString(); + } + + } + + private Summary evaluateSummary(boolean force) { + if (!force && cachedSummary != null) { + return cachedSummary; + } + Summary summary = new Summary(); + for (var vmDef : channelTracker.associated()) { + summary.totalVms += 1; + summary.usedCpus += vmDef. fromStatus(Status.CPUS) + .map(Number::intValue).orElse(0); + summary.usedRam = summary.usedRam + .add(vmDef. fromStatus(Status.RAM) + .map(r -> Quantity.fromString(r).getNumber().toBigInteger()) + .orElse(BigInteger.ZERO)); + if (vmDef.conditionStatus("Running").orElse(false)) { + summary.runningVms += 1; + } + } + cachedSummary = summary; + return summary; + } + + @Override + @SuppressWarnings({ "PMD.NcssCount" }) + protected void doUpdateConletState(NotifyConletModel event, + ConsoleConnection channel, VmsModel model) throws Exception { + event.stop(); + String vmName = event.param(0); + var value = channelTracker.value(vmName); + var vmChannel = value.map(v -> v.channel()).orElse(null); + var vmDef = value.map(v -> v.associated()).orElse(null); + if (vmDef == null) { + return; + } + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + var roles = WebConsoleUtils.rolesFromSession(channel.session()) + .stream().map(ConsoleRole::getName).toList(); + var perms = vmDef.permissionsFor(user, roles); + switch (event.method()) { + case "start": + if (perms.contains(VmDefinition.Permission.START)) { + vmChannel.fire(new ModifyVm(vmName, "state", "Running")); + } + break; + case "stop": + if (perms.contains(VmDefinition.Permission.STOP)) { + vmChannel.fire(new ModifyVm(vmName, "state", "Stopped")); + } + break; + case "reset": + if (perms.contains(VmDefinition.Permission.RESET)) { + confirmReset(event, channel, model, vmName); + } + break; + case "resetConfirmed": + if (perms.contains(VmDefinition.Permission.RESET)) { + vmChannel.fire(new ResetVm(vmName)); + } + break; + case "openConsole": + openConsole(channel, model, vmChannel, vmDef, user, perms); + break; + case "cpus": + vmChannel.fire(new ModifyVm(vmName, "currentCpus", + new BigDecimal(event.param(1).toString()).toBigInteger())); + break; + case "ram": + vmChannel.fire(new ModifyVm(vmName, "currentRam", + new Quantity(new BigDecimal(event.param(1).toString()), + Format.BINARY_SI).toSuffixedString())); + break; + default:// ignore + break; + } + } + + private void confirmReset(NotifyConletModel event, + ConsoleConnection channel, VmsModel model, String vmName) + throws TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException { + Template tpl = freemarkerConfig() + .getTemplate("VmMgmt-confirmReset.ftl.html"); + ResourceBundle resourceBundle = resourceBundle(channel.locale()); + var fmModel = fmModel(event, channel, model.getConletId(), model); + fmModel.put("vmName", vmName); + channel.respond(new OpenModalDialog(type(), model.getConletId(), + processTemplate(event, tpl, fmModel)) + .addOption("cancelable", true).addOption("closeLabel", "") + .addOption("title", + resourceBundle.getString("confirmResetTitle"))); + } + + private void openConsole(ConsoleConnection channel, VmsModel model, + VmChannel vmChannel, VmDefinition vmDef, String user, + Set perms) { + ResourceBundle resourceBundle = resourceBundle(channel.locale()); + if (!vmDef.consoleAccessible(user, perms)) { + channel.respond(new DisplayNotification( + resourceBundle.getString("consoleTakenNotification"), + Map.of("autoClose", 5_000, "type", "Warning"))); + return; + } + var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user), + e -> gotPassword(channel, model, vmDef, e)); + vmChannel.fire(pwQuery); + } + + private void gotPassword(ConsoleConnection channel, VmsModel model, + VmDefinition vmDef, GetDisplaySecret event) { + if (!event.secretAvailable()) { + return; + } + vmDef.extra().connectionFile(event.secret(), + preferredIpVersion, deleteConnectionFile).ifPresent( + cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + + @Override + protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, + String conletId) throws Exception { + return true; + } + + /** + * The Class VmsModel. + */ + public class VmsModel extends ConletBaseModel { + + /** + * Instantiates a new vms model. + * + * @param conletId the conlet id + */ + public VmsModel(String conletId) { + super(conletId); + } + + } +} diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmtFactory.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmtFactory.java new file mode 100644 index 0000000..922f938 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/VmMgmtFactory.java @@ -0,0 +1,54 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.vmmgmt; + +import java.util.Map; +import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.ComponentType; +import org.jgrapes.webconsole.base.ConletComponentFactory; + +/** + * The factory service for {@link VmMgmt}s. + */ +public class VmMgmtFactory implements ConletComponentFactory { + + /* + * (non-Javadoc) + * + * @see org.jgrapes.core.ComponentFactory#componentType() + */ + @Override + public Class componentType() { + return VmMgmt.class; + } + + /* + * (non-Javadoc) + * + * @see org.jgrapes.core.ComponentFactory#create(org.jgrapes.core.Channel, + * java.util.Map) + */ + @Override + public Optional create(Channel componentChannel, + Map properties) { + return Optional.of(new VmMgmt(componentChannel)); + } + +} diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/ConditionalInputController.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/ConditionalInputController.ts new file mode 100644 index 0000000..4f3d8a0 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/ConditionalInputController.ts @@ -0,0 +1,102 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ref, nextTick } from "vue"; + +/** + * A controller for conditionally shown inputs. "Conditionally shown" + * means that the value is usually shown using some display element + * (e.g. `span`). Only when that elements gets the focus, it is replaced + * with an input element for editing the value. + */ +export default class ConditionlInputController { + + private submitCallback: (selected: string, value: number | null) + => string | null; + private readonly inputKey = ref(""); + private startValue: string | null = null; + private inputElement: HTMLInputElement | null = null; + private errorMessage = ref(""); + + /** + * Creates a new controller. + */ + constructor(submitCallback: (selected: string, value: number | null) + => string | null) { + // this.inputRef = inputRef; + this.submitCallback = submitCallback; + } + + get key() { + return this.inputKey.value; + } + + get error() { + return this.errorMessage.value; + } + + set input(element: HTMLInputElement) { + this.inputElement = element; + } + + startEdit (key: string, value: string) { + if (this.inputKey.value != "") { + return; + } + this.startValue = value; + this.errorMessage.value = ""; + this.inputKey.value = key; + nextTick(() => { + this.inputElement!.value = value; + this.inputElement!.focus(); + }); + } + + endEdit (converter?: (value: string) => number | null) : boolean { + if (typeof converter === 'undefined') { + this.inputKey.value = ""; + return false; + } + const newValue = converter(this.inputElement!.value); + if (newValue === this.startValue) { + this.inputKey.value = ""; + return false; + } + const submitResult = this.submitCallback (this.inputKey.value, newValue); + if (submitResult !== null) { + this.errorMessage.value = submitResult; + // Neither doing it directly nor doing it with nextTick works. + setTimeout(() => this.inputElement!.focus(), 10); + } else { + this.inputKey.value = ""; + } + + // In case it is called by form action + return false; + } + + get parseNumber() { + return (value: string): number | null => { + if (value.match(/^\d+$/)) { + return Number(value); + } + return null; + } + } + +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/CpuRamChart.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/CpuRamChart.ts new file mode 100644 index 0000000..d2bf26b --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/CpuRamChart.ts @@ -0,0 +1,140 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Chart } from "chartjs"; +import TimeSeries from "./TimeSeries"; +import { formatMemory } from "./MemorySize"; +import JGConsole from "jgconsole"; +import l10nBundles from "l10nBundles"; +import { JGWC } from "jgwc"; +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export default class CpuRamChart extends Chart { + + private period = 24 * 3600 * 1000; + + constructor(canvas: HTMLCanvasElement, series: TimeSeries) { + super(canvas.getContext('2d')!, { + // The type of chart we want to create + type: 'line', + + // The data for our datasets + data: { + labels: series.getTimes(), + datasets: [{ + // See localize + data: series.getSeries(0), + yAxisID: 'cpus' + }, { + // See localize + data: series.getSeries(1), + yAxisID: 'ram' + }] + }, + + // Configuration options go here + options: { + animation: false, + maintainAspectRatio: false, + scales: { + x: { + type: 'time', + time: { minUnit: 'minute' }, + adapters: { + date: { + // See localize + } + } + }, + cpus: { + type: 'linear', + display: true, + position: 'left', + min: 0 + }, + ram: { + type: 'linear', + display: true, + position: 'right', + min: 0, + grid: { drawOnChartArea: false }, + ticks: { + stepSize: 1024 * 1024 * 1024, + callback: function(value, _index, _values) { + return formatMemory(Math.round(Number(value))); + } + } + } + } + } + }); + + const css = getComputedStyle(canvas); + this.setPropValue("options.plugins.legend.labels.font.family", css.fontFamily); + this.setPropValue("options.plugins.legend.labels.color", css.color); + this.setPropValue("options.scales.x.ticks.font.family", css.fontFamily); + this.setPropValue("options.scales.x.ticks.color", css.color); + this.setPropValue("options.scales.cpus.ticks.font.family", css.fontFamily); + this.setPropValue("options.scales.cpus.ticks.color", css.color); + this.setPropValue("options.scales.ram.ticks.font.family", css.fontFamily); + this.setPropValue("options.scales.ram.ticks.color", css.color); + + this.localizeChart(); + } + + setPeriod(period: number) { + this.period = period; + this.update(); + } + + setPropValue(path: string, value: any) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let ptr: any = this; + const segs = path.split("."); + const lastSeg = segs.pop()!; + for (const seg of segs) { + const cur = ptr[seg]; + if (!cur) { + ptr[seg] = {}; + } + // ptr[seg] = ptr[seg] || {} + ptr = ptr[seg]; + } + ptr[lastSeg] = value; + } + + localizeChart() { + (this.options.scales?.x).adapters.date.locale = JGWC.lang(); + this.data.datasets[0].label + = JGConsole.localize(l10nBundles, JGWC.lang(), "Used CPUs") + this.data.datasets[1].label + = JGConsole.localize(l10nBundles, JGWC.lang(), "Used RAM") + this.update(); + } + + shift() { + this.setPropValue("options.scales.x.max", Date.now()); + this.update(); + } + + update() { + this.setPropValue("options.scales.x.min", Date.now() - this.period); + super.update(); + } +} + diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/MemorySize.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/MemorySize.ts new file mode 100644 index 0000000..162da1d --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/MemorySize.ts @@ -0,0 +1,65 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const unitMap = new Map(); +const unitMappings = new Array<{ key: string; value: number }>(); +const memorySize = /^(\d+(\.\d+)?)\s*(B|kB|MB|GB|TB|PB|EB|KiB|MiB|GiB|TiB|PiB|EiB)?$/; + +// SI units and common abbreviations +let factor = 1; +unitMap.set("", factor); +let scale = 1000; +for (const unit of ["B", "kB", "MB", "GB", "TB", "PB", "EB"]) { + unitMap.set(unit, factor); + factor = factor * scale; +} + +// Binary units +factor = 1024; +scale = 1024; +for (const unit of ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]) { + unitMap.set(unit, factor); + factor = factor * scale; +} +unitMap.forEach((value: number, key: string) => { + unitMappings.push({ key, value }); +}); +unitMappings.sort((a, b) => a.value < b.value ? 1 : a.value > b.value ? -1 : 0); + +export function formatMemory(size: number): string { + for (const mapping of unitMappings) { + if (size >= mapping.value + && (size % mapping.value) === 0) { + return (size / mapping.value + " " + mapping.key).trim(); + } + } + return size.toString(); +} + +export function parseMemory(value: string): number | null { + const match = value.match(memorySize); + if (!match) { + return null; + } + + let unit = 1; + if (match[3]) { + unit = unitMap.get(match[3])!; + } + return Number(match[1]) * unit; +} diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/TimeSeries.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/TimeSeries.ts new file mode 100644 index 0000000..53a1aa7 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/TimeSeries.ts @@ -0,0 +1,91 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +type OnChangeCallback = ((ts: TimeSeries) => void) | null; + +export default class TimeSeries { + private timestamps: Date[] = []; + private series: number[][]; + private period: number; + private onChange: OnChangeCallback; + + constructor(nbOfSeries: number, period = 24 * 3600 * 1000, + onChange: OnChangeCallback = null) { + this.period = period; + this.onChange = onChange; + this.series = []; + while (this.series.length < nbOfSeries) { + this.series.push([]); + } + } + + clear() { + this.timestamps.length = 0; + for (const values of this.series) { + values.length = 0; + } + if (this.onChange) { + this.onChange(this); + } + } + + push(time: Date, ...values: number[]) { + let adjust = false; + if (this.timestamps.length >= 2) { + adjust = true; + for (let i = 0; i < values.length; i++) { + if (values[i] !== this.series[i][this.series[i].length - 1] + || values[i] !== this.series[i][this.series[i].length - 2]) { + adjust = false; + break; + } + } + } + if (adjust) { + this.timestamps[this.timestamps.length - 1] = time; + } else { + this.timestamps.push(time); + for (let i = 0; i < values.length; i++) { + this.series[i].push(values[i]); + } + } + + // Purge + const limit = time.getTime() - this.period; + while (this.timestamps.length > 2 + && this.timestamps[0].getTime() < limit + && this.timestamps[1].getTime() < limit) { + this.timestamps.shift(); + for (const values of this.series) { + values.shift(); + } + } + if (this.onChange) { + this.onChange(this); + } + } + + getTimes(): Date[] { + return this.timestamps; + } + + getSeries(n: number): number[] { + return this.series[n]; + } +} + diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts new file mode 100644 index 0000000..f0407b7 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-functions.ts @@ -0,0 +1,245 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + reactive, ref, Ref, createApp, computed, onMounted, watch +} from "vue"; +import JGConsole from "jgconsole"; +import JgwcPlugin, { JGWC } from "jgwc"; +import l10nBundles from "l10nBundles"; +import TimeSeries from "./TimeSeries"; +import { formatMemory, parseMemory } from "./MemorySize"; +import CpuRamChart from "./CpuRamChart"; +import ConditionlInputController from "./ConditionalInputController"; + +import "./VmMgmt-style.scss"; + +// For global access +declare global { + interface Window { + orgJDrupesVmOperatorVmMgmt: { + initPreview?: (previewDom: HTMLElement, isUpdate: boolean) => void, + initView?: (viewDom: HTMLElement, isUpdate: boolean) => void, + confirmReset?: (conletType: string, conletId: string, + vmName: string) => void + } + } +} + +window.orgJDrupesVmOperatorVmMgmt = {}; + +const vmInfos = reactive(new Map()); +const vmSummary = reactive({ + totalVms: 0, + runningVms: 0, + usedCpus: 0, + usedRam: "" +}); + +const localize = (key: string) => { + return JGConsole.localize( + l10nBundles, JGWC.lang(), key); +}; + +const shortDateTime = (time: Date) => { + // https://stackoverflow.com/questions/63958875/why-do-i-get-rangeerror-date-value-is-not-finite-in-datetimeformat-format-w + return new Intl.DateTimeFormat(JGWC.lang(), + { dateStyle: "short", timeStyle: "short" }).format(new Date(time)); +}; + +// Cannot be reactive, leads to infinite recursion. +const chartData = new TimeSeries(2); +const chartDateUpdate = ref(null); + +window.orgJDrupesVmOperatorVmMgmt.initPreview = (previewDom: HTMLElement, + _isUpdate: boolean) => { + const app = createApp({ + setup(_props: object) { + let chart: CpuRamChart | null = null; + onMounted(() => { + const canvas: HTMLCanvasElement + = previewDom.querySelector(":scope .vmsChart")!; + chart = new CpuRamChart(canvas, chartData); + }) + + watch(chartDateUpdate, (_: never) => { + chart?.update(); + }) + + watch(JGWC.langRef(), (_: never) => { + chart?.localizeChart(); + }) + + const period: Ref = ref("day"); + + watch(period, (_: never) => { + const hours = (period.value === "day") ? 24 : 1; + chart?.setPeriod(hours * 3600 * 1000); + }); + + return { localize, formatMemory, vmSummary, period }; + } + }); + app.use(JgwcPlugin, []); + app.config.globalProperties.window = window; + app.mount(previewDom); +}; + +window.orgJDrupesVmOperatorVmMgmt.initView = (viewDom: HTMLElement, + _isUpdate: boolean) => { + const app = createApp({ + setup(_props: object) { + const conletId: string + = (viewDom.parentNode!).dataset["conletId"]!; + const resourceBase = (viewDom).dataset.conletResourceBase; + + const controller = reactive(new JGConsole.TableController([ + ["name", "vmname"], + ["running", "running"], + ["runningConditionSince", "since"], + ["currentCpus", "currentCpus"], + ["currentRam", "currentRam"], + ["nodeName", "nodeName"], + ["usedBy", "usedBy"], + ["assignedTo", "assignedTo"] + ], { + sortKey: "name", + sortOrder: "up" + })); + + const filteredData = computed(() => { + const infos = Array.from(vmInfos.values()); + return controller.filter(infos); + }); + + const vmAction = (vmName: string, action: string) => { + JGConsole.notifyConletModel(conletId, action, vmName); + }; + + const idScope = JGWC.createIdScope(); + const detailsByName = reactive(new Set()); + + const submitCallback = (selected: string, value: number | null) => { + if (value === null) { + return localize("Illegal format"); + } + const vmName = selected.substring(0, selected.lastIndexOf(":")); + const property = selected.substring(selected.lastIndexOf(":") + 1); + const vmDef = vmInfos.get(vmName); + const maxValue = vmDef.spec.vm["maximum" + + property.substring(0, 1).toUpperCase() + property.substring(1)]; + if (value > maxValue) { + return localize("Value is above maximum"); + } + JGConsole.notifyConletModel(conletId, property, vmName, value); + return null; + } + + const cic = new ConditionlInputController(submitCallback); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const maximumCpus = (vmDef: any) => { + if (vmDef.spec.vm["maximumCpus"]) { + return vmDef.spec.vm.maximumCpus; + } + const topo = vmDef.spec.vm.cpuTopology; + return Math.max(1, topo.coresPerDie) + * Math.max(1, topo.diesPerSocket) + * Math.max(1, topo.sockets) + * Math.max(1, topo.threadsPerCore); + } + + return { + controller, vmInfos, filteredData, detailsByName, + resourceBase, localize, shortDateTime, formatMemory, + vmAction, cic, parseMemory, maximumCpus, + scopedId: (id: string) => { return idScope.scopedId(id); } + }; + } + }); + app.use(JgwcPlugin); + app.config.globalProperties.window = window; + app.mount(viewDom); +}; + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "updateVm", function(_conletId: string, vmDefinition: any) { + // Add some short-cuts for table controller + vmDefinition.name = vmDefinition.metadata.name; + vmDefinition.currentCpus = vmDefinition.status.cpus; + vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedFrom = vmDefinition.status.consoleClient || ""; + vmDefinition.usedBy = vmDefinition.status.consoleUser || ""; + vmDefinition.assignedTo = vmDefinition.status.assignment?.user || ""; + for (const condition of vmDefinition.status.conditions) { + if (condition.type === "Running") { + vmDefinition.running = condition.status === "True"; + vmDefinition.runningConditionSince + = new Date(condition.lastTransitionTime); + break; + } + } + vmInfos.set(vmDefinition.name, vmDefinition); + }); + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", + "removeVm", function(_conletId: string, vmName: string) { + vmInfos.delete(vmName); + }); + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "summarySeries", function(_conletId: string, series: any[]) { + chartData.clear(); + for (const entry of series) { + chartData.push(new Date(entry.time * 1000), + entry.values[0], entry.values[1]); + } + chartDateUpdate.value = new Date(); +}); + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "updateSummary", function(_conletId: string, summary: any) { + chartData.push(new Date(), summary.usedCpus, Number(summary.usedRam)); + chartDateUpdate.value = new Date(); + Object.assign(vmSummary, summary); +}); + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.vmmgmt.VmMgmt", + "openConsole", function(_conletId: string, data: string) { + let target = document.getElementById( + "org.jdrupes.vmoperator.vmmgt.VmMgmt.target"); + if (!target) { + target = document.createElement("iframe"); + target.id = "org.jdrupes.vmoperator.vmmgt.VmMgmt.target"; + target.setAttribute("name", target.id); + target.setAttribute("style", "display: none;"); + document.querySelector("body")!.append(target); + } + const url = "data:application/x-virt-viewer;base64," + + window.btoa(data); + window.open(url, target.id); + }); + +window.orgJDrupesVmOperatorVmMgmt.confirmReset = + (conletType: string, conletId: string, vmName: string) => { + JGConsole.instance.closeModalDialog(conletType, conletId); + JGConsole.notifyConletModel(conletId, "resetConfirmed", vmName); +} diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss new file mode 100644 index 0000000..eb1b556 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/VmMgmt-style.scss @@ -0,0 +1,183 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* + * Conlet specific styles. + */ + +.jdrupes-vmoperator-vmmgmt-preview { + form { + float: right; + padding: 0.15em 0.3em; + border: 1px solid var(--panel-border); + border-radius: var(--corner-radius); + } + + table { + margin-bottom: 1em; + } + + .vmsChart-wrapper { + height: 12em; + } +} + +.jdrupes-vmoperator-vmmgmt-view-search { + display: flex; + justify-content: flex-end; + + form { + white-space: nowrap; + } +} + +.jdrupes-vmoperator-vmmgmt-view-table { + td { + vertical-align: top; + + &[tabindex] { + outline: 1px solid var(--primary); + cursor: text; + } + + &:not([colspan]):first-child { + white-space: nowrap; + } + + &.column-running { + text-align: center; + + span { + &.fa-check { + color: var(--success); + } + + &.fa-close { + color: var(--danger); + } + } + } + + .console-conection-closed { + color: var(--disabled); + } + } + + td.details { + padding-left: 0; + + table { + display: inline-block; + + td:nth-child(2) { + min-width: 7em; + + input { + max-width: 5em; + } + } + + input~span { + margin-left: 0.5em; + color: var(--danger); + } + } + + p { + display: inline-block; + margin: 0.25rem 0.5rem 0.25rem 0.5rem; + vertical-align: top; + } + } +} + +.jdrupes-vmoperator-vmmgmt-view-action-list { + white-space: nowrap; + + & > * + * { + margin-left: 0.5em; + } + + [role=button] { + padding: 0.25rem; + + &:not([aria-disabled]):hover, &[aria-disabled='false']:hover { + box-shadow: var(--darkening); + } + } + + span[role="button"].svg-icon { + display: inline-block; + line-height: 1; + + /* Align with forkawesome */ + font-size: 14px; + fill: var(--primary); + + &[aria-disabled="true"], &[aria-disabled=""] { + fill: var(--disabled); + } + + svg { + height: 2ex; + width: 1em; + } + } + + img { + display: inline; + height: 1.5em; + vertical-align: top; + + &[aria-disabled=''], &[aria-disabled='true'] { + opacity: 0.4; + } + } +} + +.jdrupes-vmoperator-vmmgmt.jdrupes-vmoperator-vmmgmt-confirm-reset { + + [role=button] { + padding: 0.25rem; + + &:not([aria-disabled]):hover, &[aria-disabled='false']:hover { + box-shadow: var(--darkening); + } + } + + span[role="button"].svg-icon { + display: inline-block; + line-height: 1; + /* Align with forkawesome */ + font-size: 14px; + fill: var(--danger); + + &[aria-disabled="true"], &[aria-disabled=""] { + fill: var(--disabled); + } + + svg { + width: 2.5em; + height: 2.5em; + } + } + + p { + text-align: center; + } +} diff --git a/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/l10nBundles-stub.d.ts b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/l10nBundles-stub.d.ts new file mode 100644 index 0000000..8ca03f3 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/browser/l10nBundles-stub.d.ts @@ -0,0 +1 @@ +export default new Map>(); diff --git a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/package-info.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/package-info.java similarity index 94% rename from org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/package-info.java rename to org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/package-info.java index 2cbbfa7..c39c193 100644 --- a/org.jdrupes.vmoperator.vmconlet/src/org/jdrupes/vmoperator/vmconlet/package-info.java +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/package-info.java @@ -16,4 +16,4 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.vmconlet; \ No newline at end of file +package org.jdrupes.vmoperator.vmmgmt; \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmmgmt/tsconfig.json b/org.jdrupes.vmoperator.vmmgmt/tsconfig.json new file mode 100644 index 0000000..96c4072 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2015", + "module": "es2015", + "sourceMap": true, + "inlineSources": true, + "declaration": true, + "importHelpers": true, + "strict": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "lib": ["DOM", "ES2020"], + "paths": { + "aash-plugin": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/aash-vue-components/lib/AashPlugin"], + "jgconsole": ["./build/unpacked/org/jgrapes/webconsole/base/JGConsole"], + "jgwc": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/jgwc-vue-components/jgwc-components"], + "l10nBundles": ["./src/org/jdrupes/vmoperator/vmmgmt/browser/l10nBundles-stub"], + "vue": ["./build/unpacked/org/jgrapes/webconsole/provider/vue/vue/vue"], + "chartjs": ["./build/unpacked/org/jgrapes/webconsole/provider/chartjs/chart.js/auto/auto"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "l10nBundles-stub.ts"] +} diff --git a/overview.md b/overview.md index 0677d51..e263b6a 100644 --- a/overview.md +++ b/overview.md @@ -3,5 +3,8 @@ A Kubernetes operator for running VMs as pods. VM-Operator =========== -The VM-operator is built on the [JGrapes](https://mnlipp.github.io/jgrapes/) -event driven framework. +The VM-operator enables you to easily run Qemu based VMs as pods +in Kubernetes. It is built on the +[JGrapes](https://mnlipp.github.io/jgrapes/) event driven framework. + +See the project's [home page](https://vm-operator.jdrupes.org/) for details. diff --git a/package-lock.json b/package-lock.json index 4eb8aaf..4bfe990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,16 +4,21 @@ "requires": true, "packages": { "": { + "dependencies": { + "markdownlint-cli": "^0.44.0" + }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-terser": "^0.4.0", + "@typescript-eslint/eslint-plugin": "^6.9.1", "documentation": "^14.0.1", "install": "^0.13.0", "jsdoc": "^4.0.2", + "markdownlint": "^0.37.4", "node-sass": "^9.0.0", "npm": "^8.11.0", - "rollup": "^3.17.2", + "rollup": "^4.1.5", "rollup-plugin-peer-deps-external": "^2.2.3", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-typescript2": "^0.36.0", @@ -27,118 +32,56 @@ } }, "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dev": true, "dependencies": { - "@babel/highlight": "^7.22.13", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/compat-data": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", - "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.7.tgz", + "integrity": "sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", - "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", + "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.23.0", - "@babel/helpers": "^7.23.2", - "@babel/parser": "^7.23.0", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helpers": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/template": "^7.24.7", + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -153,15 +96,24 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", + "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", "dev": true, "dependencies": { - "@babel/types": "^7.23.0", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.24.7", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "engines": { @@ -169,14 +121,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", - "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz", + "integrity": "sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-validator-option": "^7.22.15", - "browserslist": "^4.21.9", + "@babel/compat-data": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", + "browserslist": "^4.22.2", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -184,63 +136,76 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", + "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.7" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", + "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", "dev": true, "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" + "@babel/template": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", + "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", "dev": true, "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", - "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz", + "integrity": "sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -250,79 +215,80 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", + "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", - "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz", + "integrity": "sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", - "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz", + "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==", "dev": true, "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0" + "@babel/template": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -391,9 +357,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", + "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -403,34 +369,34 @@ } }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", + "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", - "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", + "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.1.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.24.7", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-function-name": "^7.24.7", + "@babel/helper-hoist-variables": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/types": "^7.24.7", + "debug": "^4.3.1", "globals": "^11.1.0" }, "engines": { @@ -438,65 +404,347 @@ } }, "node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", + "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-string-parser": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.1.tgz", + "integrity": "sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "peer": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "peer": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -506,9 +754,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -516,9 +764,9 @@ } }, "node_modules/@jsdoc/salty": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.5.tgz", - "integrity": "sha512-TfRP53RqunNe2HBobVBJ0VLhK1HbfvBYeTC1ahnN64PWvyYyGebmMiPkuwvD9fpw2ZbkoPb8Q7mwy0aR8Z9rvw==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.8.tgz", + "integrity": "sha512-5e+SFVavj1ORKlKaKr2BmTOekmXbelU7dC0cDkQLqag7xfuTPuGMUFx7KWJuv4bYZrTsoL2Z18VVCOKYxzoHcg==", "dev": true, "dependencies": { "lodash": "^4.17.21" @@ -527,6 +775,118 @@ "node": ">=v12.0.0" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", + "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/agent/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@npmcli/agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/@npmcli/agent/node_modules/socks-proxy-agent": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz", + "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.1", + "debug": "^4.3.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/@npmcli/fs": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", @@ -540,39 +900,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/@npmcli/fs/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/fs/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@npmcli/move-file": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", @@ -587,6 +914,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.2.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", @@ -613,13 +950,13 @@ } }, "node_modules/@rollup/plugin-replace": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.3.tgz", - "integrity": "sha512-je7fu05B800IrMlWjb2wzJcdXzHYW46iTipfChnBDbIbDXhASZs27W1B58T2Yf45jZtJUONegpbce+9Ut2Ti/Q==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.7.tgz", + "integrity": "sha512-PqxSfuorkHz/SPpyngLyg5GCEkOcee9M1bkxiVDr41Pd61mqP1PLOoDPbpl44SB2mQGKwV/In74gqQmGITOhEQ==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", - "magic-string": "^0.27.0" + "magic-string": "^0.30.3" }, "engines": { "node": ">=14.0.0" @@ -656,9 +993,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.5.tgz", - "integrity": "sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", + "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", "dev": true, "dependencies": { "@types/estree": "^1.0.0", @@ -677,6 +1014,214 @@ } } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", + "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz", + "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz", + "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz", + "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz", + "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz", + "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz", + "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz", + "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz", + "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz", + "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz", + "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", + "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz", + "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz", + "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz", + "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -696,82 +1241,92 @@ } }, "node_modules/@types/debug": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.9.tgz", - "integrity": "sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==", - "dev": true, + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", "dependencies": { "@types/ms": "*" } }, "node_modules/@types/estree": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.2.tgz", - "integrity": "sha512-VeiPZ9MMwXjO32/Xu7+OwflfmeoRwkE/qzndw42gGtgJwZopBnzy2gD//NN1+go1mADzkDcqf/KnFRSjTJ8xJA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, "node_modules/@types/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-CqDQhn7jxaN9zw7zAu926zIx51ZzMaX8U8Wa4jGpKI6jeBr9ejFE68AQ+h+ztfrNJD+leo7K1cLbvMjpHfZSRg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/extend/-/extend-3.0.4.tgz", + "integrity": "sha512-ArMouDUTJEz1SQRpFsT2rIw7DeqICFv5aaVzLSIYMYQSLcwcGOfT3VyglQs/p7K3F7fT4zxr0NWxYZIdifD6dA==", "dev": true }, "node_modules/@types/hast": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.6.tgz", - "integrity": "sha512-47rJE80oqPmFdVDCD7IheXBrVdwuBgsYwoczFvKmwfo2Mzsnt+V9OONsYauFmICb6lQPpCuXYJWejBNs4pDJRg==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", "dev": true, "dependencies": { "@types/unist": "^2" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "license": "MIT" + }, "node_modules/@types/linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-pTjcqY9E4nOI55Wgpz7eiI8+LzdYnw3qxXCfHyBDdPbYvbyLgWLJGh8EdPvqawwMK1Uo1794AUkkR38Fr0g+2g==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "dev": true }, "node_modules/@types/markdown-it": { - "version": "12.2.3", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", - "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-4NpsnpYl2Gt1ljyBGrKMxFYAYvpqbnnkgP/i/g+NLpjEUa3obn1XJCur9YbEXKDAkaXqsR1LbDnGEJ0MmKFxfg==", "dev": true, "dependencies": { - "@types/linkify-it": "*", - "@types/mdurl": "*" + "@types/linkify-it": "^5", + "@types/mdurl": "^2" } }, "node_modules/@types/mdast": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.13.tgz", - "integrity": "sha512-HjiGiWedR0DVFkeNljpa6Lv4/IZU1+30VY5d747K7lBudFc3R0Ibr6yJ9lN3BE28VnZyDfLF/VB1Ql1ZIbKrmg==", + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", "dev": true, "dependencies": { "@types/unist": "^2" } }, "node_modules/@types/mdurl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.3.tgz", - "integrity": "sha512-T5k6kTXak79gwmIOaDF2UUQXFbnBE0zBUzF20pz7wDYu0RQMzWg+Ml/Pz50214NsFHBITkoi5VtdjFZnJ2ijjA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "dev": true }, "node_modules/@types/minimist": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.3.tgz", - "integrity": "sha512-ZYFzrvyWUNhaPomn80dsMNgMeXxNWZBdkuG/hWlUvXvbdUH8ZERNBGXnU87McuGcWDsyzX2aChCv/SVN348k3A==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, "node_modules/@types/ms": { - "version": "0.7.32", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.32.tgz", - "integrity": "sha512-xPSg0jm4mqgEkNhowKgZFBNtwoEwF6gJ4Dhww+GFpm3IgtNseHQZ5IqdNwnquZEoANxyDAKDRAdVo4Z72VvD/g==", - "dev": true + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" }, "node_modules/@types/normalize-package-data": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz", - "integrity": "sha512-lqa4UEhhv/2sjjIQgjX8B+RBjj47eo0mzGasklVJ78UKGQY1r0VpB9XHDaZZO9qzEFDdy4MrXLuEaSmPrPSe/A==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, "node_modules/@types/parse5": { @@ -786,121 +1341,290 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, "node_modules/@types/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@types/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-dPWnWsf+kzIG140B8z2w3fr5D03TLWbOAFQl45xUpI3vcizeXriNR5VYkWZ+WTMsUHqZ9Xlt3hrxGNANFyNQfw==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@types/supports-color/-/supports-color-8.1.3.tgz", + "integrity": "sha512-Hy6UMpxhE3j1tLpl27exp1XqHD7n8chAiNPzWfz16LPZoMMoSc4dzLl6w9qijkEb/r5O1ozdu1CWGA2L83ZeZg==", "dev": true }, "node_modules/@types/unist": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.8.tgz", - "integrity": "sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw==", - "dev": true + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" }, - "node_modules/@vue/compiler-core": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz", - "integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "dependencies": { - "@babel/parser": "^7.21.3", - "@vue/shared": "3.3.4", + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true, + "peer": true + }, + "node_modules/@vue/compiler-core": { + "version": "3.4.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.29.tgz", + "integrity": "sha512-TFKiRkKKsRCKvg/jTSSKK7mYLJEQdUiUfykbG49rubC9SfDyvT2JrzTReopWlz2MxqeLyxh9UZhvxEIBgAhtrg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.24.7", + "@vue/shared": "3.4.29", + "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-dom": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz", - "integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==", + "version": "3.4.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.29.tgz", + "integrity": "sha512-A6+iZ2fKIEGnfPJejdB7b1FlJzgiD+Y/sxxKwJWg1EbJu6ZPgzaPQQ51ESGNv0CP6jm6Z7/pO6Ia8Ze6IKrX7w==", "dev": true, "dependencies": { - "@vue/compiler-core": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-core": "3.4.29", + "@vue/shared": "3.4.29" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz", - "integrity": "sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==", + "version": "3.4.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.29.tgz", + "integrity": "sha512-zygDcEtn8ZimDlrEQyLUovoWgKQic6aEQqRXce2WXBvSeHbEbcAsXyCk9oG33ZkyWH4sl9D3tkYc1idoOkdqZQ==", "dev": true, "dependencies": { - "@babel/parser": "^7.20.15", - "@vue/compiler-core": "3.3.4", - "@vue/compiler-dom": "3.3.4", - "@vue/compiler-ssr": "3.3.4", - "@vue/reactivity-transform": "3.3.4", - "@vue/shared": "3.3.4", + "@babel/parser": "^7.24.7", + "@vue/compiler-core": "3.4.29", + "@vue/compiler-dom": "3.4.29", + "@vue/compiler-ssr": "3.4.29", + "@vue/shared": "3.4.29", "estree-walker": "^2.0.2", - "magic-string": "^0.30.0", - "postcss": "^8.1.10", - "source-map-js": "^1.0.2" - } - }, - "node_modules/@vue/compiler-sfc/node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "magic-string": "^0.30.10", + "postcss": "^8.4.38", + "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz", - "integrity": "sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==", + "version": "3.4.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.29.tgz", + "integrity": "sha512-rFbwCmxJ16tDp3N8XCx5xSQzjhidYjXllvEcqX/lopkoznlNPz3jyy0WGJCyhAaVQK677WWFt3YO/WUEkMMUFQ==", "dev": true, "dependencies": { - "@vue/compiler-dom": "3.3.4", - "@vue/shared": "3.3.4" - } - }, - "node_modules/@vue/reactivity-transform": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz", - "integrity": "sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.15", - "@vue/compiler-core": "3.3.4", - "@vue/shared": "3.3.4", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.0" - } - }, - "node_modules/@vue/reactivity-transform/node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@vue/compiler-dom": "3.4.29", + "@vue/shared": "3.4.29" } }, "node_modules/@vue/shared": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz", - "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==", + "version": "3.4.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz", + "integrity": "sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA==", "dev": true }, "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", + "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -909,6 +1633,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -946,6 +1680,23 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -989,30 +1740,19 @@ "node": ">= 8" } }, - "node_modules/aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", - "dev": true - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "dev": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } }, "node_modules/arrify": { "version": "1.0.1", @@ -1045,16 +1785,18 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/bluebird": { @@ -1073,27 +1815,26 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" } }, "node_modules/browserslist": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", - "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", "dev": true, "funding": [ { @@ -1110,10 +1851,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001541", - "electron-to-chromium": "^1.4.535", - "node-releases": "^2.0.13", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001629", + "electron-to-chromium": "^1.4.796", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.16" }, "bin": { "browserslist": "cli.js" @@ -1178,6 +1919,16 @@ "node": ">=12" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -1217,9 +1968,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001547", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz", - "integrity": "sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==", + "version": "1.0.30001634", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001634.tgz", + "integrity": "sha512-fbBYXQ9q3+yp1q1gBk86tOFs4pyn/yxFm5ZNP18OXJDfA3txImOY9PhfxVggZ4vRHDqoU8NrKU81eN0OtzOgRA==", "dev": true, "funding": [ { @@ -1274,7 +2025,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -1294,23 +2044,26 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -1323,6 +2076,9 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } @@ -1377,15 +2133,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "bin": { - "color-support": "bin.js" - } - }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -1432,12 +2179,6 @@ "source-map": "^0.6.1" } }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1625,10 +2366,9 @@ "optional": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dependencies": { "ms": "2.1.2" }, @@ -1679,7 +2419,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dev": true, "dependencies": { "character-entities": "^2.0.0" }, @@ -1688,6 +2427,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "peer": true + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1697,30 +2452,61 @@ "node": ">=0.10.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "engines": { "node": ">=6" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "dev": true, "engines": { "node": ">=0.3.1" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/doctrine-temporary-fork": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine-temporary-fork/-/doctrine-temporary-fork-2.1.0.tgz", @@ -1734,9 +2520,9 @@ } }, "node_modules/documentation": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/documentation/-/documentation-14.0.2.tgz", - "integrity": "sha512-hWoTf8/u4pOjib02L7w94hwmhPfcSwyJNGtlPdGVe8GFyq8HkzcFzQQltaaikKunHEp0YSwDAbwBAO7nxrWIfA==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/documentation/-/documentation-14.0.3.tgz", + "integrity": "sha512-B7cAviVKN9Rw7Ofd+9grhVuxiHwly6Ieh+d/ceMw8UdBOv/irkuwnDEJP8tq0wgdLJDUVuIkovV+AX9mTrZFxg==", "dev": true, "dependencies": { "@babel/core": "^7.18.10", @@ -1802,6 +2588,15 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -1850,9 +2645,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.553", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.553.tgz", - "integrity": "sha512-HiRdtyKS2+VhiXvjhMvvxiMC33FJJqTA5EB2YHgFZW6v7HkK4Q9Ahv2V7O2ZPgAjw+MyCJVMQvigj13H8t+wvA==", + "version": "1.4.803", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.803.tgz", + "integrity": "sha512-61H9mLzGOCLLVsnLiRzCbc63uldP0AniRYPV3hbGVtONA1pI7qSGILdbofR7A8TMbOypDocEAjH/e+9k1QIe3g==", "dev": true }, "node_modules/emoji-regex": { @@ -1872,10 +2667,12 @@ } }, "node_modules/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", - "dev": true, + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } @@ -1905,21 +2702,270 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, "engines": { "node": ">=6" } }, "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "peer": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4.0" } }, "node_modules/estree-walker": { @@ -1943,16 +2989,81 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "dev": true + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "peer": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "peer": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "peer": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -1979,21 +3090,60 @@ } }, "node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "peer": true, "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "peer": true + }, + "node_modules/foreground-child": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -2023,8 +3173,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -2040,23 +3189,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/gaze": { @@ -2118,9 +3257,9 @@ } }, "node_modules/git-url-parse": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-13.1.0.tgz", - "integrity": "sha512-5FvPJP/70WkIprlUZ33bm4UAaFdjcLkJLpWft1BeZKqwR0uhhNGoKwlUaPtVb4LxCSQ++erHapRak9kWGj+FCA==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-13.1.1.tgz", + "integrity": "sha512-PCFJyeSSdtnbfhSNRw9Wk96dDCNx+sogTe4YNXeXSJxt7xz5hvXekuRn9JX7m+Mf4OscCu8h+mtAl3+h5Fo8lQ==", "dev": true, "dependencies": { "git-up": "^7.0.0" @@ -2133,19 +3272,17 @@ "dev": true }, "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dev": true, + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", "dependencies": { "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" }, "engines": { - "node": ">=12" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2163,6 +3300,28 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/minimatch": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", + "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -2178,6 +3337,26 @@ "integrity": "sha512-qpPnUKkWnz8NESjrCvnlGklsgiQzlq+rcCxoG5uNQ+dNA7cFMCmn231slLAwS2N/PlkzZ3COL8CcS10jXmLHqg==", "dev": true }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globule": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.4.tgz", @@ -2202,26 +3381,6 @@ "concat-map": "0.0.1" } }, - "node_modules/globule/node_modules/glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globule/node_modules/minimatch": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", @@ -2240,6 +3399,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -2249,15 +3414,6 @@ "node": ">=6" } }, - "node_modules/has": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", - "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", - "dev": true, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -2267,18 +3423,24 @@ "node": ">=4" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true - }, "node_modules/hash-sum": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz", "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==", "dev": true }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-from-parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-7.1.2.tgz", @@ -2547,10 +3709,19 @@ "postcss": "^8.1.0" } }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/immutable": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz", - "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", + "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", "dev": true }, "node_modules/import-cwd": { @@ -2565,6 +3736,23 @@ "node": ">=8" } }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/import-from": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz", @@ -2577,6 +3765,15 @@ "node": ">=8" } }, + "node_modules/import-from/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2601,16 +3798,6 @@ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", "dev": true }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2635,11 +3822,18 @@ "node": ">= 0.10" } }, - "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", - "dev": true + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } }, "node_modules/is-absolute": { "version": "1.0.0", @@ -2654,6 +3848,30 @@ "node": ">=0.10.0" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2711,17 +3929,27 @@ } }, "node_modules/is-core-module": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", - "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dev": true, "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2752,6 +3980,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -2773,6 +4011,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -2836,6 +4084,24 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/jackspeak": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", + "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-base64": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", @@ -2852,7 +4118,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -2869,22 +4134,28 @@ "xmlcreate": "^2.0.4" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, "node_modules/jsdoc": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.2.tgz", - "integrity": "sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.3.tgz", + "integrity": "sha512-Nu7Sf35kXJ1MWDZIMAuATRQTg1iIPdzh7tqJ6jjvaU/GfDf+qi5UV8zJR3Mo+/pYFvm8mzay4+6O5EWigaQBQw==", "dev": true, "dependencies": { "@babel/parser": "^7.20.15", "@jsdoc/salty": "^0.2.1", - "@types/markdown-it": "^12.2.3", + "@types/markdown-it": "^14.1.1", "bluebird": "^3.7.2", "catharsis": "^0.9.0", "escape-string-regexp": "^2.0.0", "js2xmlparser": "^4.0.2", "klaw": "^3.0.0", - "markdown-it": "^12.3.2", - "markdown-it-anchor": "^8.4.1", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", "marked": "^4.0.10", "mkdirp": "^1.0.4", "requizzle": "^0.2.3", @@ -2898,6 +4169,15 @@ "node": ">=12.0.0" } }, + "node_modules/jsdoc/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/jsdoc/node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2922,12 +4202,33 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "peer": true + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "peer": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2941,10 +4242,10 @@ } }, "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" }, "node_modules/jsonfile": { "version": "6.1.0", @@ -2958,6 +4259,50 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/katex": { + "version": "0.16.21", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz", + "integrity": "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -2995,6 +4340,20 @@ "@babel/traverse": "^7.10.5" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -3011,33 +4370,33 @@ "dev": true }, "node_modules/linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "dependencies": { - "uc.micro": "^1.0.1" + "uc.micro": "^2.0.0" } }, "node_modules/loader-utils": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", - "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", "dev": true, "engines": { "node": ">= 12.13.0" } }, "node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "peer": true, "dependencies": { - "p-locate": "^6.0.0" + "p-locate": "^5.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3061,6 +4420,13 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "peer": true + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -3093,15 +4459,12 @@ "dev": true }, "node_modules/magic-string": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", - "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "version": "0.30.10", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", + "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.4.15" } }, "node_modules/make-dir": { @@ -3119,6 +4482,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -3177,19 +4549,19 @@ } }, "node_modules/markdown-it": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", - "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", - "dev": true, + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dependencies": { "argparse": "^2.0.1", - "entities": "~2.1.0", - "linkify-it": "^3.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, "bin": { - "markdown-it": "bin/markdown-it.js" + "markdown-it": "bin/markdown-it.mjs" } }, "node_modules/markdown-it-anchor": { @@ -3212,6 +4584,559 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/markdownlint": { + "version": "0.37.4", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.37.4.tgz", + "integrity": "sha512-u00joA/syf3VhWh6/ybVFkib5Zpj2e5KB/cfCei8fkSRuums6nyisTWGqjTWIOFoFwuXoTBQQiqlB4qFKp8ncQ==", + "license": "MIT", + "dependencies": { + "markdown-it": "14.1.0", + "micromark": "4.0.1", + "micromark-core-commonmark": "2.0.2", + "micromark-extension-directive": "3.0.2", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.0", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.44.0.tgz", + "integrity": "sha512-ZJTAONlvF9NkrIBltCdW15DxN9UTbPiKMEqAh2EU2gwIFlrCMavyCEPPO121cqfYOrLUJWW8/XKWongstmmTeQ==", + "license": "MIT", + "dependencies": { + "commander": "~13.1.0", + "glob": "~10.4.5", + "ignore": "~7.0.3", + "js-yaml": "~4.1.0", + "jsonc-parser": "~3.3.1", + "jsonpointer": "~5.0.1", + "markdownlint": "~0.37.4", + "minimatch": "~9.0.5", + "run-con": "~1.3.2", + "smol-toml": "~1.3.1" + }, + "bin": { + "markdownlint": "markdownlint.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/markdownlint-cli/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/markdownlint-cli/node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/markdownlint-cli/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/markdownlint/node_modules/micromark": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.1.tgz", + "integrity": "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-core-commonmark": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", + "integrity": "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/markdownlint/node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/markdownlint/node_modules/micromark-extension-gfm-table": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.0.tgz", + "integrity": "sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/markdownlint/node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/markdownlint/node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/markdownlint/node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/markdownlint/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/markdownlint/node_modules/micromark-util-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -3529,10 +5454,9 @@ "dev": true }, "node_modules/mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", - "dev": true + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" }, "node_modules/meow": { "version": "9.0.0", @@ -3618,15 +5542,6 @@ "node": ">=8" } }, - "node_modules/meow/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/meow/node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -3698,6 +5613,15 @@ "semver": "bin/semver" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/micromark": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", @@ -3767,6 +5691,119 @@ "uvu": "^0.5.0" } }, + "node_modules/micromark-extension-directive": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", + "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromark-extension-gfm": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-2.0.3.tgz", @@ -3888,6 +5925,97 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-math/node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromark-factory-destination": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", @@ -4261,6 +6389,19 @@ } ] }, + "node_modules/micromatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4271,15 +6412,27 @@ } }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minimist-options": { @@ -4422,19 +6575,18 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nan": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", - "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", "dev": true }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true, "funding": [ { @@ -4449,6 +6601,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -4459,152 +6617,359 @@ } }, "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.1.0.tgz", + "integrity": "sha512-B4J5M1cABxPc5PwfjhbV5hoy2DP9p8lFXASnEN6hugXOa61416tnTZ29x9sSwAd0o99XNIcpvDDy1swAExsVKA==", "dev": true, "dependencies": { "env-paths": "^2.2.0", - "glob": "^7.1.4", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^3.0.0", "semver": "^7.3.5", "tar": "^6.1.2", - "which": "^2.0.2" + "which": "^4.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": ">= 10.12.0" + "node": "^16.14.0 || >=18.0.0" } }, "node_modules/node-gyp/node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", + "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", "dev": true, "dependencies": { - "@gar/promisify": "^1.0.1", "semver": "^7.3.5" - } - }, - "node_modules/node-gyp/node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" }, "engines": { - "node": ">=10" - } - }, - "node_modules/node-gyp/node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/node-gyp/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/node-gyp/node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.3.tgz", + "integrity": "sha512-qXCd4rh6I07cnDqh8V48/94Tc/WSfj+o3Gn6NZ0aZovS255bUx8O13uKxRFd2eWG0xgsco7+YItQNPaa5E85hg==", "dev": true, "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", + "minipass-pipeline": "^1.2.4", "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" }, "engines": { - "node": ">= 10" + "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/node-gyp/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "node_modules/node-gyp/node_modules/cacache/node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/node-gyp/node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "node_modules/node-gyp/node_modules/cacache/node_modules/glob/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-gyp/node_modules/cacache/node_modules/minimatch": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", + "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", "dev": true, "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">= 6" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/glob": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz", + "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "engines": { + "node": ">=16" } }, "node_modules/node-gyp/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, "engines": { - "node": ">=10" + "node": "14 || >=16.14" } }, "node_modules/node-gyp/node_modules/make-fetch-happen": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", + "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "dev": true, + "dependencies": { + "@npmcli/agent": "^2.0.0", + "cacache": "^18.0.0", + "http-cache-semantics": "^4.1.1", + "is-lambda": "^1.0.1", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/make-fetch-happen/node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/node-gyp/node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/node-gyp/node_modules/minipass-fetch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz", + "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/node-gyp/node_modules/ssri": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", + "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", + "dev": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "dev": true, + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/node-sass": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-9.0.0.tgz", + "integrity": "sha512-yltEuuLrfH6M7Pq2gAj5B6Zm7m+gdZoG66wTqG6mIZV/zijq3M2OO2HswtT6oBspPyFhHDcaxWpsBm0fRNDHPg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "async-foreach": "^0.1.3", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "gaze": "^1.0.0", + "get-stdin": "^4.0.1", + "glob": "^7.0.3", + "lodash": "^4.17.15", + "make-fetch-happen": "^10.0.4", + "meow": "^9.0.0", + "nan": "^2.17.0", + "node-gyp": "^10.1.0", + "sass-graph": "^4.0.1", + "stdout-stream": "^1.4.0", + "true-case-path": "^2.2.1" + }, + "bin": { + "node-sass": "bin/node-sass" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-sass/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/node-sass/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-sass/node_modules/node-gyp/node_modules/make-fetch-happen": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "dev": true, + "extraneous": true, "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", @@ -4627,202 +6992,6 @@ "node": ">= 10" } }, - "node_modules/node-gyp/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/node-gyp/node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "dev": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-gyp/node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "dev": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/node-gyp/node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "dev": true, - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/node-gyp/node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "dev": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/node-gyp/node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", - "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", - "dev": true - }, - "node_modules/node-sass": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-9.0.0.tgz", - "integrity": "sha512-yltEuuLrfH6M7Pq2gAj5B6Zm7m+gdZoG66wTqG6mIZV/zijq3M2OO2HswtT6oBspPyFhHDcaxWpsBm0fRNDHPg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "async-foreach": "^0.1.3", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", - "gaze": "^1.0.0", - "get-stdin": "^4.0.1", - "glob": "^7.0.3", - "lodash": "^4.17.15", - "make-fetch-happen": "^10.0.4", - "meow": "^9.0.0", - "nan": "^2.17.0", - "node-gyp": "^8.4.1", - "sass-graph": "^4.0.1", - "stdout-stream": "^1.4.0", - "true-case-path": "^2.2.1" - }, - "bin": { - "node-sass": "bin/node-sass" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/node-sass/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/node-sass/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/node-sass/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/node-sass/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/node-sass/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/node-sass/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4836,18 +7005,18 @@ } }, "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "dev": true, "dependencies": { - "abbrev": "1" + "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": ">=6" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/normalize-package-data": { @@ -4865,39 +7034,6 @@ "node": ">=10" } }, - "node_modules/normalize-package-data/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/normalize-package-data/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4999,6 +7135,11 @@ "write-file-atomic" ], "dev": true, + "workspaces": [ + "docs", + "smoke-tests", + "workspaces/*" + ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^5.6.3", @@ -5043,7 +7184,7 @@ "mkdirp": "^1.0.4", "mkdirp-infer-owner": "^2.0.0", "ms": "^2.1.2", - "node-gyp": "^9.1.0", + "node-gyp": "^10.1.0", "nopt": "^6.0.0", "npm-audit-report": "^3.0.0", "npm-install-checks": "^5.0.0", @@ -7485,21 +9626,6 @@ "inBundle": true, "license": "ISC" }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "dev": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -7512,13 +9638,22 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "peer": true, "dependencies": { - "wrappy": "1" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" } }, "node_modules/p-finally": { @@ -7531,30 +9666,32 @@ } }, "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "peer": true, "dependencies": { - "yocto-queue": "^1.0.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "peer": true, "dependencies": { - "p-limit": "^4.0.0" + "p-limit": "^3.0.2" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7612,6 +9749,38 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "peer": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/parse-filepath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", @@ -7669,21 +9838,12 @@ "dev": true }, "node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/path-key": { @@ -7722,10 +9882,50 @@ "node": ">=0.10.0" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picomatch": { @@ -7816,19 +10016,10 @@ "node": ">=8" } }, - "node_modules/pkg-dir/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "dev": true, "funding": [ { @@ -7845,9 +10036,9 @@ } ], "dependencies": { - "nanoid": "^3.3.6", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8095,9 +10286,9 @@ } }, "node_modules/postcss-modules-extract-imports": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", - "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", "dev": true, "engines": { "node": "^10 || ^12 || >= 14" @@ -8107,9 +10298,9 @@ } }, "node_modules/postcss-modules-local-by-default": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz", - "integrity": "sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", + "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", "dev": true, "dependencies": { "icss-utils": "^5.0.0", @@ -8124,9 +10315,9 @@ } }, "node_modules/postcss-modules-scope": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", - "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", + "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.4" @@ -8335,9 +10526,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", - "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", + "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -8384,6 +10575,25 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proc-log": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", + "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -8419,9 +10629,9 @@ } }, "node_modules/property-information": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.3.0.tgz", - "integrity": "sha512-gVNZ74nqhRMiIUYWGQdosYetaKc83x8oT41a0LlV3AAFCAZwCpg4vmGkq8t34+cUhp3cnM4XDiU/7xlgK7HGrg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", "dev": true, "funding": { "type": "github", @@ -8434,6 +10644,44 @@ "integrity": "sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==", "dev": true }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -8487,6 +10735,76 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/read-pkg-up/node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -8499,6 +10817,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-pkg-up/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg/node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -8511,20 +10841,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8695,12 +11011,13 @@ } }, "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "peer": true, "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/retry": { @@ -8712,10 +11029,21 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -8727,61 +11055,38 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", + "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=14.18.0", + "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.18.0", + "@rollup/rollup-android-arm64": "4.18.0", + "@rollup/rollup-darwin-arm64": "4.18.0", + "@rollup/rollup-darwin-x64": "4.18.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.18.0", + "@rollup/rollup-linux-arm-musleabihf": "4.18.0", + "@rollup/rollup-linux-arm64-gnu": "4.18.0", + "@rollup/rollup-linux-arm64-musl": "4.18.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0", + "@rollup/rollup-linux-riscv64-gnu": "4.18.0", + "@rollup/rollup-linux-s390x-gnu": "4.18.0", + "@rollup/rollup-linux-x64-gnu": "4.18.0", + "@rollup/rollup-linux-x64-musl": "4.18.0", + "@rollup/rollup-win32-arm64-msvc": "4.18.0", + "@rollup/rollup-win32-ia32-msvc": "4.18.0", + "@rollup/rollup-win32-x64-msvc": "4.18.0", "fsevents": "~2.3.2" } }, @@ -8900,39 +11205,6 @@ "node": ">= 8.0.0" } }, - "node_modules/rollup-plugin-typescript2/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/rollup-plugin-typescript2/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/rollup-plugin-typescript2/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/rollup-plugin-vue": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rollup-plugin-vue/-/rollup-plugin-vue-6.0.0.tgz", @@ -8962,6 +11234,65 @@ "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", "dev": true }, + "node_modules/run-con": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/run-con/-/run-con-1.3.2.tgz", + "integrity": "sha512-CcfE+mYiTcKEzg0IqS08+efdnH0oJ3zV0wSUFBNrMHMuxCtXvBCLzCJHatwuXDcu/RlhjTziTo/a1ruQik6/Yg==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~4.1.0", + "minimist": "^1.2.8", + "strip-json-comments": "~3.1.1" + }, + "bin": { + "run-con": "cli.js" + } + }, + "node_modules/run-con/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/run-con/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -9008,9 +11339,9 @@ "optional": true }, "node_modules/sass": { - "version": "1.69.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.3.tgz", - "integrity": "sha512-X99+a2iGdXkdWn1akFPs0ZmelUzyAQfvqYc2P/MPTrJRuIRoTffGzT9W9nFqG00S+c8hXzVmgxhUuHFdrwxkhQ==", + "version": "1.77.5", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.5.tgz", + "integrity": "sha512-oDfX1mukIlxacPdQqNb6mV2tVCrnE+P3nVYioy72V5tlk56CPNcO4TCuFcaCRKKfJ1M3lH95CleRS+dVKL2qMg==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -9042,48 +11373,6 @@ "node": ">=12" } }, - "node_modules/sass-graph/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/sass-graph/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sass-graph/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/scss-tokenizer": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.4.3.tgz", @@ -9104,29 +11393,26 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "dependencies": { "randombytes": "^2.1.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9149,9 +11435,9 @@ } }, "node_modules/shiki": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.5.tgz", - "integrity": "sha512-1gCAYOcmCFONmErGTrS1fjzJLA7MGZmKzrBNX7apqSwhyITJg2O102uFzXUeBxNnEkDA9vHIKLyeKq0V083vIw==", + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz", + "integrity": "sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==", "dev": true, "dependencies": { "ansi-sequence-parser": "^1.1.0", @@ -9161,10 +11447,25 @@ } }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } }, "node_modules/smart-buffer": { "version": "4.2.0", @@ -9177,22 +11478,34 @@ } }, "node_modules/smob": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.4.1.tgz", - "integrity": "sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", "dev": true }, + "node_modules/smol-toml": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.3.1.tgz", + "integrity": "sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "dependencies": { - "ip": "^2.0.0", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 10.13.0", + "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, @@ -9220,9 +11533,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "dev": true, "engines": { "node": ">=0.10.0" @@ -9259,9 +11572,9 @@ } }, "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, "node_modules/spdx-expression-parse": { @@ -9275,9 +11588,15 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", - "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", + "dev": true + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, "node_modules/ssri": { @@ -9338,15 +11657,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", @@ -9367,10 +11677,25 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.3.tgz", - "integrity": "sha512-BP9nNHMhhfcMbiuQKCqMjhDP5yBCAxsPu4pHFFzJ6Alo9dZgY4VLDPutXqIjpRiMoKdp7Av85Gr73Q5uH9k7+g==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", "dev": true, "dependencies": { "character-entities-html4": "^2.0.0", @@ -9393,6 +11718,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -9485,9 +11823,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dev": true, "dependencies": { "chownr": "^2.0.0", @@ -9517,9 +11855,9 @@ "dev": true }, "node_modules/terser": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.21.0.tgz", - "integrity": "sha512-WtnFKrxu9kaoXuiZFSGrcAvvBqAdmKx0SFNmVNYdJamMu9yyN3I/QF0FbH4QcqJQ+y1CJnzxGIKH0cSj+FGYRw==", + "version": "5.31.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.1.tgz", + "integrity": "sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -9540,6 +11878,13 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "peer": true + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -9581,9 +11926,9 @@ } }, "node_modules/trough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.1.0.tgz", - "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", "dev": true, "funding": { "type": "github", @@ -9596,12 +11941,37 @@ "integrity": "sha512-0z3j8R7MCjy10kc/g+qg7Ln3alJTodw9aDuVWZa3uiWqfuBMKeAeP2ocWcxoyM3D73yz3Jt/Pu4qPr4wHSdB/Q==", "dev": true }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", "dev": true }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", @@ -9615,15 +11985,15 @@ } }, "node_modules/typedoc": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.2.tgz", - "integrity": "sha512-286F7BeATBiWe/qC4PCOCKlSTwfnsLbC/4cZ68oGBbvAqb9vV33quEOXx7q176OXotD+JdEerdQ1OZGJ818lnA==", + "version": "0.25.13", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz", + "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==", "dev": true, "dependencies": { "lunr": "^2.3.9", "marked": "^4.3.0", "minimatch": "^9.0.3", - "shiki": "^0.14.1" + "shiki": "^0.14.7" }, "bin": { "typedoc": "bin/typedoc" @@ -9632,37 +12002,22 @@ "node": ">= 16" }, "peerDependencies": { - "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x" + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x" } }, "node_modules/typedoc-plugin-missing-exports": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/typedoc-plugin-missing-exports/-/typedoc-plugin-missing-exports-2.1.0.tgz", - "integrity": "sha512-+1DhqZCEu7Vu5APnrqpPwl31D+hXpt1fV0Le9ycCRL1eLVdatdl6KVt4SEVwPxnEpKwgOn2dNX6I9+0F1aO2aA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-missing-exports/-/typedoc-plugin-missing-exports-2.3.0.tgz", + "integrity": "sha512-iI9ITNNLlbsLCBBeYDyu0Qqp3GN/9AGyWNKg8bctRXuZEPT7G1L+0+MNWG9MsHcf/BFmNbXL0nQ8mC/tXRicog==", "dev": true, "peerDependencies": { "typedoc": "0.24.x || 0.25.x" } }, - "node_modules/typedoc/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -9673,10 +12028,9 @@ } }, "node_modules/uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", - "dev": true + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" }, "node_modules/unc-path-regex": { "version": "0.1.2", @@ -9840,18 +12194,18 @@ } }, "node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "engines": { "node": ">= 10.0.0" } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", "dev": true, "funding": [ { @@ -9868,8 +12222,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -9878,6 +12232,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10067,9 +12431,9 @@ "dev": true }, "node_modules/vue-template-compiler": { - "version": "2.7.14", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz", - "integrity": "sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==", + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", "dev": true, "optional": true, "dependencies": { @@ -10102,13 +12466,14 @@ "node": ">= 8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" + "peer": true, + "engines": { + "node": ">=0.10.0" } }, "node_modules/wrap-ansi": { @@ -10128,11 +12493,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } }, "node_modules/xmlcreate": { "version": "2.0.4", @@ -10201,12 +12578,13 @@ } }, "node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "peer": true, "engines": { - "node": ">=12.20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 42bf2fa..1f72d6f 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,14 @@ "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-terser": "^0.4.0", + "@typescript-eslint/eslint-plugin": "^6.9.1", "documentation": "^14.0.1", "install": "^0.13.0", "jsdoc": "^4.0.2", + "markdownlint": "^0.37.4", "node-sass": "^9.0.0", "npm": "^8.11.0", - "rollup": "^3.17.2", + "rollup": "^4.1.5", "rollup-plugin-peer-deps-external": "^2.2.3", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-typescript2": "^0.36.0", @@ -20,6 +22,10 @@ "typedoc-plugin-missing-exports": "^2.1.0", "typescript": "^5.2.2" }, + "overrides": { + "node-gyp": "^10.1.0", + "glob": "^9.0.0" + }, "eslintConfig": { "root": true, "extends": "eslint:recommended", @@ -29,5 +35,8 @@ }, "eslintIgnore": [ "node_modules/**" - ] + ], + "dependencies": { + "markdownlint-cli": "^0.44.0" + } } diff --git a/ruleset.xml b/ruleset.xml index b362eb8..365c409 100644 --- a/ruleset.xml +++ b/ruleset.xml @@ -1,9 +1,9 @@ + xmlns="http://pmd.sourceforge.net/ruleset/2.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 http://pmd.sourceforge.net/ruleset_2_0_0.xsd"> JGrapes rules @@ -27,14 +27,16 @@ JGrapes rules + - + + @@ -45,6 +47,11 @@ JGrapes rules value="Avoid variables with short names like id"/> + + + + + @@ -90,11 +97,6 @@ JGrapes rules - - - - - diff --git a/settings.gradle b/settings.gradle index cb613b6..4a3bfc8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,7 +12,9 @@ rootProject.name = 'VM-Operator' include 'org.jdrupes.vmoperator.manager' include 'org.jdrupes.vmoperator.manager.events' -include 'org.jdrupes.vmoperator.vmconlet' +include 'org.jdrupes.vmoperator.vmaccess' +include 'org.jdrupes.vmoperator.vmmgmt' include 'org.jdrupes.vmoperator.runner.qemu' include 'org.jdrupes.vmoperator.common' include 'org.jdrupes.vmoperator.util' +include 'spice-squid' diff --git a/spice-squid/.checkstyle b/spice-squid/.checkstyle new file mode 100644 index 0000000..7f2c604 --- /dev/null +++ b/spice-squid/.checkstyle @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/spice-squid/.eclipse-pmd b/spice-squid/.eclipse-pmd new file mode 100644 index 0000000..5d69caa --- /dev/null +++ b/spice-squid/.eclipse-pmd @@ -0,0 +1,7 @@ + + + + + + + diff --git a/spice-squid/.settings/net.sf.jautodoc.prefs b/spice-squid/.settings/net.sf.jautodoc.prefs new file mode 100644 index 0000000..03e8200 --- /dev/null +++ b/spice-squid/.settings/net.sf.jautodoc.prefs @@ -0,0 +1,8 @@ +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 +replacements=\n\n\nReturns the\nSets the\nAdds the\nEdits the\nRemoves the\nInits the\nParses the\nCreates the\nBuilds the\nChecks if is\nPrints the\nChecks for\n\n\n +visibility_package=false +visibility_private=false +visibility_protected=false diff --git a/spice-squid/.settings/org.eclipse.buildship.core.prefs b/spice-squid/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..258eb47 --- /dev/null +++ b/spice-squid/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments= +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) +connection.project.dir=.. +eclipse.preferences.version=1 +gradle.user.home= +java.home= +jvm.arguments= +offline.mode=false +override.workspace.settings=false +show.console.view=false +show.executions.view=false diff --git a/spice-squid/.settings/org.eclipse.core.resources.prefs b/spice-squid/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/spice-squid/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/spice-squid/.settings/org.eclipse.core.runtime.prefs b/spice-squid/.settings/org.eclipse.core.runtime.prefs new file mode 100644 index 0000000..5a0ad22 --- /dev/null +++ b/spice-squid/.settings/org.eclipse.core.runtime.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +line.separator=\n diff --git a/spice-squid/Containerfile b/spice-squid/Containerfile new file mode 100644 index 0000000..5c94829 --- /dev/null +++ b/spice-squid/Containerfile @@ -0,0 +1,11 @@ +FROM docker.io/alpine:3.19 + +RUN apk update &&\ + apk add --no-cache inotify-tools &&\ + apk add --no-cache squid + +COPY run.sh /usr/local/bin/run-squid.sh + +CMD ["/usr/local/bin/run-squid.sh"] + +EXPOSE 3128 diff --git a/spice-squid/build.gradle b/spice-squid/build.gradle new file mode 100644 index 0000000..5278098 --- /dev/null +++ b/spice-squid/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'org.jdrupes.vmoperator.java-application-conventions' +} + +dependencies { +} + +project.ext.gitBranch = grgit.branch.current.name.replace('/', '-') +def registry = "${project.rootProject.properties['docker.registry']}" +def rootVersion = rootProject.version + +task buildImage(type: Exec) { + inputs.files 'Containerfile' + + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}:${project.gitBranch}",\ + '-f', 'Containerfile', '.' +} + +task pushImage(type: Exec) { + dependsOn buildImage + + commandLine 'podman', 'push', '--tls-verify=false', \ + "${project.name}:${project.gitBranch}", \ + "${registry}/${project.name}:${project.gitBranch}" +} +task tagWithVersion(type: Exec) { + dependsOn pushImage + + enabled = !rootVersion.contains("SNAPSHOT") + + commandLine 'podman', 'push', \ + "${project.name}:${project.gitBranch}",\ + "${registry}/${project.name}:${project.version}" +} + +task tagAsLatest(type: Exec) { + dependsOn tagWithVersion + + enabled = !rootVersion.contains("SNAPSHOT") + && !rootVersion.contains("alpha") \ + && !rootVersion.contains("beta") \ + || project.rootProject.properties['docker.testRegistry'] \ + && project.rootProject.properties['docker.registry'] \ + == project.rootProject.properties['docker.testRegistry'] + + commandLine 'podman', 'push', \ + "${project.name}:${project.gitBranch}",\ + "${registry}/${project.name}:latest" +} + +task publishImage { + dependsOn pushImage + dependsOn tagWithVersion + dependsOn tagAsLatest +} +test { + enabled = project.hasProperty("k8s.testCluster") + + useJUnitPlatform() + + testLogging { + showStandardStreams = true + } + + systemProperty "k8s.testCluster", project.hasProperty("k8s.testCluster") + ? project.getProperty("k8s.testCluster") : null +} diff --git a/spice-squid/run.sh b/spice-squid/run.sh new file mode 100755 index 0000000..eddea39 --- /dev/null +++ b/spice-squid/run.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +CONF_OPT="-f /run/etc/squid/squid.conf" +/usr/sbin/squid $CONF_OPT + +inotifywait -m -e create -r /run/etc/squid | + while read file_path file_event file_name; do + if [ "$file_event" != "CREATE" ]; then + continue + fi + if [ -r /run/squid/squid.pid ]; then + echo "Reconfiguring squid" + /usr/sbin/squid $CONF_OPT -k reconfigure + else + echo "Restarting squid" + /usr/sbin/squid $CONF_OPT + fi + echo "Processed event" + done diff --git a/spice-squid/squid.conf b/spice-squid/squid.conf new file mode 100644 index 0000000..724b0df --- /dev/null +++ b/spice-squid/squid.conf @@ -0,0 +1,4 @@ +http_access deny all + +# Squid normally listens to port 3128 +http_port 3128 diff --git a/webpages/.gitignore b/webpages/.gitignore new file mode 100644 index 0000000..7615a9d --- /dev/null +++ b/webpages/.gitignore @@ -0,0 +1,4 @@ +_site +Gemfile.lock +.bundle +.jekyll-cache \ No newline at end of file diff --git a/webpages/.readthedocs.yaml b/webpages/.readthedocs.yaml new file mode 100644 index 0000000..546c09f --- /dev/null +++ b/webpages/.readthedocs.yaml @@ -0,0 +1,18 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 +# Set the OS, Python version, and other tools you might need +build: + os: ubuntu-24.04 + tools: + ruby: "3.3" + commands: + # Install dependencies + - cd webpages && gem install bundle + - cd webpages && bundle install + # Build the site and save generated files into Read the Docs directory + - cd webpages && jekyll build --destination $READTHEDOCS_OUTPUT/html + - cp webpages/robots-readthedocs.txt $READTHEDOCS_OUTPUT/html/robots.txt + \ No newline at end of file diff --git a/webpages/02_2_operator.png b/webpages/02_2_operator.png new file mode 100644 index 0000000..d3909d4 Binary files /dev/null and b/webpages/02_2_operator.png differ diff --git a/webpages/BingSiteAuth.xml b/webpages/BingSiteAuth.xml new file mode 100644 index 0000000..b0cf39a --- /dev/null +++ b/webpages/BingSiteAuth.xml @@ -0,0 +1,4 @@ + + + 0309051EFD625D32489366E1BE8189EF + \ No newline at end of file diff --git a/webpages/ConfigAccess-preview.png b/webpages/ConfigAccess-preview.png new file mode 100644 index 0000000..e7523f9 Binary files /dev/null and b/webpages/ConfigAccess-preview.png differ diff --git a/webpages/Gemfile b/webpages/Gemfile new file mode 100644 index 0000000..ecbbb7d --- /dev/null +++ b/webpages/Gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' +# gem 'github-pages', group: :jekyll_plugins +gem "jekyll", "~> 4.0" +gem "jekyll-seo-tag" +gem 'webrick', '~> 1.3', '>= 1.3.1' diff --git a/webpages/PoolAccess-preview.png b/webpages/PoolAccess-preview.png new file mode 100644 index 0000000..34db8cd Binary files /dev/null and b/webpages/PoolAccess-preview.png differ diff --git a/webpages/VM-Operator-GUI-preview.png b/webpages/VM-Operator-GUI-preview.png new file mode 100644 index 0000000..b5293d7 Binary files /dev/null and b/webpages/VM-Operator-GUI-preview.png differ diff --git a/webpages/VM-Operator-GUI-view.png b/webpages/VM-Operator-GUI-view.png new file mode 100644 index 0000000..dbda800 Binary files /dev/null and b/webpages/VM-Operator-GUI-view.png differ diff --git a/webpages/VM-Operator-with-font.svg b/webpages/VM-Operator-with-font.svg new file mode 100644 index 0000000..6240969 --- /dev/null +++ b/webpages/VM-Operator-with-font.svg @@ -0,0 +1,173 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + VM + + + + + + + + + + + + + + + + + diff --git a/webpages/VM-Operator.svg b/webpages/VM-Operator.svg new file mode 100644 index 0000000..30c1ed2 --- /dev/null +++ b/webpages/VM-Operator.svg @@ -0,0 +1,184 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webpages/VmAccess-preview.png b/webpages/VmAccess-preview.png new file mode 100644 index 0000000..a97f7e1 Binary files /dev/null and b/webpages/VmAccess-preview.png differ diff --git a/webpages/_config.yml b/webpages/_config.yml new file mode 100644 index 0000000..a2162f7 --- /dev/null +++ b/webpages/_config.yml @@ -0,0 +1,14 @@ +plugins: + - jekyll-seo-tag + +url: "https://vm-operator.jdrupes.org" + +author: Michael N. Lipp + +logo: VM-Operator.svg + +tagline: VM-Operator by mnlipp + +description: >- + A Kubernetes operator for running virtual machines (notably Qemu VMs) + as pods. diff --git a/webpages/_includes/matomo.html b/webpages/_includes/matomo.html new file mode 100644 index 0000000..adb7c30 --- /dev/null +++ b/webpages/_includes/matomo.html @@ -0,0 +1,23 @@ + + + + + diff --git a/webpages/_includes/toc.html b/webpages/_includes/toc.html new file mode 100644 index 0000000..56ac8e4 --- /dev/null +++ b/webpages/_includes/toc.html @@ -0,0 +1,96 @@ +{% capture tocWorkspace %} + {% comment %} + Version 1.0.10 + https://github.com/allejo/jekyll-toc + + "...like all things liquid - where there's a will, and ~36 hours to spare, there's usually a/some way" ~jaybe + + Usage: + {% include toc.html html=content sanitize=true class="inline_toc" id="my_toc" h_min=2 h_max=3 %} + + Parameters: + * html (string) - the HTML of compiled markdown generated by kramdown in Jekyll + + Optional Parameters: + * sanitize (bool) : false - when set to true, the headers will be stripped of any HTML in the TOC + * class (string) : '' - a CSS class assigned to the TOC + * id (string) : '' - an ID to assigned to the TOC + * h_min (int) : 1 - the minimum TOC header level to use; any header lower than this value will be ignored + * h_max (int) : 6 - the maximum TOC header level to use; any header greater than this value will be ignored + * ordered (bool) : false - when set to true, an ordered list will be outputted instead of an unordered list + * item_class (string) : '' - add custom class(es) for each list item; has support for '%level%' placeholder, which is the current heading level + * baseurl (string) : '' - add a base url to the TOC links for when your TOC is on another page than the actual content + * anchor_class (string) : '' - add custom class(es) for each anchor element + + Output: + An ordered or unordered list representing the table of contents of a markdown block. This snippet will only + generate the table of contents and will NOT output the markdown given to it + {% endcomment %} + + {% capture my_toc %}{% endcapture %} + {% assign orderedList = include.ordered | default: false %} + {% assign minHeader = include.h_min | default: 1 %} + {% assign maxHeader = include.h_max | default: 6 %} + {% assign nodes = include.html | split: ' maxHeader %} + {% continue %} + {% endif %} + + {% if firstHeader %} + {% assign firstHeader = false %} + {% assign minHeader = headerLevel %} + {% endif %} + + {% assign indentAmount = headerLevel | minus: minHeader %} + {% assign _workspace = node | split: '' | first }}>{% endcapture %} + {% assign header = _workspace[0] | replace: _hAttrToStrip, '' %} + + {% assign space = '' %} + {% for i in (1..indentAmount) %} + {% assign space = space | prepend: ' ' %} + {% endfor %} + + {% if include.item_class and include.item_class != blank %} + {% capture listItemClass %}{:.{{ include.item_class | replace: '%level%', headerLevel }}}{% endcapture %} + {% endif %} + + {% capture heading_body %}{% if include.sanitize %}{{ header | strip_html }}{% else %}{{ header }}{% endif %}{% endcapture %} + {% capture my_toc %}{{ my_toc }} +{{ space }}{{ listModifier }} {{ listItemClass }} [{{ heading_body | replace: "|", "\|" }}]({% if include.baseurl %}{{ include.baseurl }}{% endif %}#{{ html_id }}){% if include.anchor_class %}{:.{{ include.anchor_class }}}{% endif %}{% endcapture %} + {% endfor %} + + {% if include.class and include.class != blank %} + {% capture my_toc %}{:.{{ include.class }}} +{{ my_toc | lstrip }}{% endcapture %} + {% endif %} + + {% if include.id %} + {% capture my_toc %}{: #{{ include.id }}} +{{ my_toc | lstrip }}{% endcapture %} + {% endif %} +{% endcapture %}{% assign tocWorkspace = '' %}{{ my_toc | markdownify | strip }} diff --git a/webpages/_includes/umami.html b/webpages/_includes/umami.html new file mode 100644 index 0000000..8066278 --- /dev/null +++ b/webpages/_includes/umami.html @@ -0,0 +1 @@ + diff --git a/webpages/_layouts/vm-operator.html b/webpages/_layouts/vm-operator.html new file mode 100644 index 0000000..40bdff7 --- /dev/null +++ b/webpages/_layouts/vm-operator.html @@ -0,0 +1,118 @@ + + + + + + + + + + + {% include umami.html %} + + + + + {% seo %} + + +
+ +
+
+ +
+ VM-Operator Logo +
+
+
+

+ +

View GitHub Project

+ +

+ +

Overview

+

The Runner

+

The Manager

+ +

Web interface

+ +

Advanced

+ +

Hints

+

Upgrading

+

Javadoc

+ +
+
+ + {% if page.tocTitle %} +

{{ page.tocTitle }}

+ {% include toc.html html=content %} + {% endif %} + + {{ content }} +
+
+ + + + +
+
+ + {% include matomo.html %} + + + diff --git a/webpages/admin-gui.md b/webpages/admin-gui.md new file mode 100644 index 0000000..325c227 --- /dev/null +++ b/webpages/admin-gui.md @@ -0,0 +1,21 @@ +--- +title: "VM-Operator: Administrator View — Provides an overview of running VMs" +description: >- + Information about the administrator view of the VM-Operator, which provides + an overview of the defined VMs, their state and resource consumptions and + actions for starting, stopping and accessing the VMs. +layout: vm-operator +--- + +# Administrator view + +An overview display shows the current CPU and RAM usage and a graph +with recent changes. + +![VM-Operator admin GUI preview](VM-Operator-GUI-preview.png) + +The detail display lists all VMs. From here you can start and stop +the VMs and adjust the CPU and RAM usages (modifies the definition +in kubernetes). + +![VM-Operator admin GUI view](VM-Operator-GUI-view.png) diff --git a/webpages/auto-login.md b/webpages/auto-login.md new file mode 100644 index 0000000..66f0edf --- /dev/null +++ b/webpages/auto-login.md @@ -0,0 +1,87 @@ +--- +title: "VM-Operator: Auto login — Login users automatically on the guest" +layout: vm-operator +--- + +# Auto Login + +*Since 4.0.0* + +When users log into the web GUI, they have already authenticated with the +VM-Operator. In some environments, requiring an additional login on the +guest OS can be annoying. To enhance the user experience, the VM-Operator +supports automatic login on the guest operating system, thus eliminating +the need for multiple logins. However, this feature requires specific +support from the guest OS. + +## Prepare the VM + +Automatic login requires an agent running inside the guest OS. Similar +to QEMU's standard guest agent, the VM-Operator agent communicates with +the host via a tty device (provided in the guest as +`/dev/virtio-ports/org.jdrupes.vmop_agent.0`). On modern Linux systems, `udev` can +detect this device and trigger the start of an associated systemd service. + +Sample configuration files for a VM-Operator agent are available +[here](https://github.com/mnlipp/VM-Operator/tree/main/dev-example/vmop-agent). +Copy + + * `99-vmop-agent.rules` → `/usr/local/lib/udev/rules.d/99-vmop-agent.rules`, + * `vmop-agent` → `/usr/local/libexec/vmop-agent` and + * `vmop-agent.service` → `/usr/local/lib/systemd/system/vmop-agent.service`. + +Some of these target directories may not exist by default and must be +created manually. If your system uses SELinux, run `restorecon` to apply +the correct security contexts. + +Enable the agent: + +```console +# systemctl daemon-reload +# systemctl enable vmop-agent +# udevadm control --reload-rules +# udevadm trigger + ``` + +## The VM operator agent + +Communication with the VM-Operator agent follows the pattern established by +protocols such as SMTP and FTP. The agent must handle the commands +"`login `" and "`logout`" on its input. In response to +these commands, the agent sends back lines that start with a three +digit number. The first digit determines the type of message: "1" for +informational, "2" for success and "4" or "5" for errors. The second +digit provides information about the category that a response relates +to. The third digit is specific to the command. + +While this describes the general pattern, the [runner](runner.html) +only evaluates the following codes: + +| Code | Meaning | +| ---- | ------- | +| 220 | Sent by the agent on startup | +| 201 | Login command executed successfully | +| 202 | Logout command executed successfully | + +The provided sample script is written for the gnome desktop environment. +It assumes that GDM is running as a service by default. When the agent +receives a login command, it stops GDM and starts a gnome-session for +the specified user. Upon receiving the logout command, it terminates +the session and starts GDM again. + +No attempt has been made to make the script configurable. There are too +many possible options. The script should therefore be considered as a +starting point that you may need to adapt to your specific needs. + +In addition to starting the desktop for the logged in user, the sample +script automatically creates user accounts if they do not already exist. +The idea behind this behavior is further explained in the +[section about pools](pools.html#vm-pools). + +## Enable auto login for a VM + +To enable auto login for a VM, specify the user to be logged in in the VM's +definition with "`spec.vm.display.loggedInUser: user-name`". If everything has been +set up correctly, you should be able to open the console and observe the +transition from GDM's login screen to the user's desktop when updating the +VM's spec. diff --git a/webpages/controller.md b/webpages/controller.md new file mode 100644 index 0000000..c91e2d4 --- /dev/null +++ b/webpages/controller.md @@ -0,0 +1,238 @@ +--- +title: "VM-Operator: Controller — Reconciles the VM CRs" +description: >- + Information about the VM Operator's controller component its + configuration options and the CRD used to define VMs. +layout: vm-operator +--- + +# The Controller + +The controller component (which is part of the manager) monitors +custom resources of kind `VirtualMachine`. It creates or modifies +other resources in the cluster as required to get the VM defined +by the CR up and running. + +Here is the sample definition of a VM from the +["local-path" example](https://github.com/mnlipp/VM-Operator/tree/main/example/local-path): + +```yaml +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VirtualMachine +metadata: + namespace: vmop-demo + name: test-vm +spec: + guestShutdownStops: false + + vm: + state: Running + maximumCpus: 4 + currentCpus: 2 + maximumRam: 8Gi + currentRam: 4Gi + + networks: + - user: {} + + disks: + - volumeClaimTemplate: + metadata: + name: system + spec: + storageClassName: "" + selector: + matchLabels: + app.kubernetes.io/name: vmrunner + app.kubernetes.io/instance: test-vm + vmrunner.jdrupes.org/disk: system + 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 + # image: "Fedora-Workstation-Live-x86_64-38-1.6.iso" + + display: + spice: + port: 5910 + # Since 3.0.0: + # generateSecret: false +``` + +## Pod management + +The central resource created by the controller is a +[`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/) +with the same name as the VM (`metadata.name`). The pod is created only +if `spec.vm.state` is "Running" (default is "Stopped" which deletes the +pod)[^oldSts]. + +Property `spec.guestShutdownStops` (since 2.2.0) controls the effect of a +shutdown initiated by the guest. If set to `false` (default) the pod +and thus the VM is automatically restarted. If set to `true`, the +VM's state is set to "Stopped" when the VM terminates and the pod is +deleted. + +[^oldSts]: Before version 3.4, the operator created a + [stateful set](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) + that in turn created the pod and the PVCs (see below). + +## Defining the basics + +How to define the number of CPUs and the size of the RAM of the VM +should be obvious from the example. Note that changes of the current +number of CPUs and the current RAM size will be propagated to +running VMs. + +## Defining disks + +Maybe the most interesting part is the definition of the VM's disks. +This is done by adding one or more `volumeClaimTemplate`s to the +list of disks. As its name suggests, such a template is used by the +controller to generate a +[`PVC`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/). + +The example template does not define any storage. Rather it references +some PV that you must have created first. This may be your first approach +if you have existing storage from running the VM outside Kubernetes +(e.g. with libvirtd). + +If you have ceph or some other full fledged storage provider installed +and create a new VM, provisioning a disk can happen automatically +as shown in this example: + +```yaml + disks: + - volumeClaimTemplate: + metadata: + name: system + spec: + storageClassName: rook-ceph-block + resources: + requests: + storage: 40Gi +``` + +The disk will be available as "/dev/*name*-disk" in the VM, +using the string from `.volumeClaimTemplate.metadata.name` as *name*. +If no name is defined in the metadata, then "/dev/disk-*n*" +is used instead, with *n* being the index of the volume claim +template in the list of disks. + +The name of the generated PVC is the VM's name with "-*name*-disk" +(or the generated name) appended: "*vmName*-*name*-disk" +(or "*vmName*-disk-*n*"). The definition of the PVC is simply a copy +of the information from the `volumeClaimTemplate` (with some additional +labels, see below)[^oldStsDisks]. + +[^oldStsDisks]: Before version 3.4 the `volumeClaimTemplate`s were + copied in the definition of the stateful set. As a stateful set + appends the started pod's name to the name of the volume claim + templates when it creates the PVCs, the PVCs' name were + "*name*-disk-*vmName*-0" (or "disk-*n*-*vmName*-0"). + +PVCs are never removed automatically. Usually, you do not want your +VMs disks to be removed when you (maybe accidentally) remove the CR +for the VM. To simplify the lookup for an eventual (manual) removal, +all PVCs are labeled with "app.kubernetes.io/name: vm-runner", +"app.kubernetes.io/instance: *vmName*", and +"app.kubernetes.io/managed-by: vm-operator", making it easy to select +the PVCs by label in a delete command. + +## Choosing an image for the runner + +The image used for the runner can be configured with +[`spec.image`](https://github.com/mnlipp/VM-Operator/blob/7e094e720b7b59a5e50f4a9a4ad29a6000ec76e6/deploy/crds/vms-crd.yaml#L19). +This is a mapping with either a single key `source` or a detailed +configuration using the keys `repository`, `path` etc. + +Currently two runner images are maintained. One that is based on +Arch Linux (`ghcr.io/mnlipp/org.jdrupes.vmoperator.runner.qemu-arch`) and a +second one based on Alpine (`ghcr.io/mnlipp/org.jdrupes.vmoperator.runner.qemu-alpine`). + +Starting with release 1.0, all versions of runner images and managers +that have the same major release number are guaranteed to be compatible. + +## Generating cloud-init data + +*Since: 2.2.0* + +The optional object `.spec.cloudInit` with sub-objects `.cloudInit.metaData`, +`.cloudInit.userData` and `.cloudInit.networkConfig` can be used to provide +data for +[cloud-init](https://cloudinit.readthedocs.io/en/latest/index.html). +The data from the CRD will be made available to the VM by the runner +as a vfat formatted disk (see the description of +[NoCloud](https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html)). + +If `.metaData.instance-id` is not defined, the controller automatically +generates it from the CRD's `resourceVersion`. If `.metaData.local-hostname` +is not defined, the controller adds this property using the value from +`metadata.name`. + +Note that there are no schema definitions available for `.userData` +and `.networkConfig`. Whatever is defined in the CRD is copied to +the corresponding cloud-init file without any checks. (The introductory +comment `#cloud-config` required at the beginning of `.userData` is +generated automatically by the runner.) + +## Display secret/password + +*Since: 2.3.0* + +You can define a display password using a Kubernetes secret. +When you start a VM, the controller checks if there is a secret +with labels "app.kubernetes.io/name: vm-runner, +app.kubernetes.io/component: display-secret, +app.kubernetes.io/instance: *vmname*" in the namespace of the +VM definition. The name of the secret can be chosen freely. + +```yaml +kind: Secret +apiVersion: v1 +metadata: + name: test-vm-display-secret + namespace: vmop-demo + 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== + # Since 3.0.0: + # password-expiry: bmV2ZXI= +``` + +If such a secret for the VM is found, the VM is configured to use +the display password specified. The display password in the secret +can be updated while the VM runs[^delay]. Activating/deactivating +the display password while a VM runs is not supported by QEMU and +therefore requires stopping the VM, adding/removing the secret and +restarting the VM. + +[^delay]: Be aware of the possible delay, see e.g. + [here](https://web.archive.org/web/20240223073838/https://ahmet.im/blog/kubernetes-secret-volumes-delay/). + +*Since: 3.0.0* + +The secret's `data` can have an additional property `data.password-expiry` which +specifies a (base64 encoded) expiry date for the password. Supported +values are those defined by QEMU (`+n` seconds from now, `n` Unix +timestamp, `never` and `now`). + +Unless `spec.vm.display.spice.generateSecret` is set to `false` in the VM +definition (CRD), the controller creates a secret for the display +password automatically if none is found. The secret is created +with a random password that expires immediately, which makes the +display effectively inaccessible until the secret is modified. +Note that a password set manually may be overwritten by components +of the manager unless the password-expiry is set to "never" or +some time in the future. + +## Further reading + +For a detailed description of the available configuration options see the +[CRD](https://github.com/mnlipp/VM-Operator/blob/main/deploy/crds/vms-crd.yaml). diff --git a/webpages/favicon.svg b/webpages/favicon.svg new file mode 100644 index 0000000..c8616d5 --- /dev/null +++ b/webpages/favicon.svg @@ -0,0 +1,184 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webpages/hints.md b/webpages/hints.md new file mode 100644 index 0000000..1f896a4 --- /dev/null +++ b/webpages/hints.md @@ -0,0 +1,16 @@ +--- +title: "VM-Operator: Hints — Miscellaneous hints for using VM-Operator" +layout: vm-operator +--- + +# Hints + +## Disable suspend and hibernate + +Suspend and hibernate are poorly supported in VMs and usually do not +work as expected. To disable these on systemd based systems, use the +following command: + +```console +# systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target +``` diff --git a/webpages/index-pic.svg b/webpages/index-pic.svg new file mode 100644 index 0000000..d6b0ef9 --- /dev/null +++ b/webpages/index-pic.svg @@ -0,0 +1,7329 @@ + + + + diff --git a/webpages/index.md b/webpages/index.md new file mode 100644 index 0000000..6dc3c10 --- /dev/null +++ b/webpages/index.md @@ -0,0 +1,67 @@ +--- +title: "VM-Operator: Easy to use kubernetes operator for QEM/KVM VMs" +description: >- + A solution for running VMs on Kubernetes with a web interface for + admins and users. Focuses on running QEMU/KVM virtual machines and + using SPICE as display protocol. +layout: vm-operator +--- + +# Welcome to VM-Operator + +![VM-Operator summary picture](index-pic.svg) + +This project provides an easy to use and flexible solution for +running QEMU/KVM based virtual machines (VMs) in Kubernetes pods. + +The image used for the VM pods combines QEMU and a control program +for starting and managing the QEMU process. This application is called +"[the runner](runner.html)". + +While you can deploy a runner manually (or with the help of some +helm templates), the preferred way is to deploy "[the manager](manager.html)" +application which acts as a Kubernetes operator for runners +and thus the VMs. + +If you just want to try out things, you can skip the remainder of this +page and proceed to "[the manager](manager.html)". + +## Motivation + +The project was triggered by a remark in the discussion about RedHat +[dropping SPICE support](https://bugzilla.redhat.com/show_bug.cgi?id=2030592) +from the RHEL packages. Which means that you have to run QEMU in a +container on RHEL and derivatives if you want to continue using Spice. +So KubeVirt comes to mind. But +[one comment](https://bugzilla.redhat.com/show_bug.cgi?id=2030592#c4) +mentioned that the [KubeVirt](https://kubevirt.io/) project isn't +interested in supporting SPICE either. + +Time to have a look at alternatives. Libvirt has become a common +tool to configure and run QEMU. But some of its functionality, notably +the management of storage for the VMs and networking is already provided +by Kubernetes. Therefore this project takes a fresh approach of +running QEMU in a pod using a simple, lightweight manager called "runner". +Providing resources to the VM is left to Kubernetes mechanisms as +much as possible. + +## VMs and Pods + +VMs are not the typical workload managed by Kubernetes. You can neither +have replicas nor can the containers simply be restarted without a major +impact on the "application". So there are many features for managing +pods that we cannot make use of. QEMU in its container can only be +deployed as a pod or using a stateful set with replica 1, which is rather +close to simply deploying the pod (you get the restart and some PVC +management "for free"). + +A second look, however, reveals that Kubernetes has more to offer. + + * It has a well defined API for managing resources. + * It provides access to different kinds of managed storage for the VMs. + * Its managing features *are* useful for running the component that + manages the pods with the VMs. + +And if you use Kubernetes anyway, well then the VMs within Kubernetes +provide you with a unified view of all (or most of) your workloads, +which simplifies the maintenance of your platform. diff --git a/webpages/manager.md b/webpages/manager.md new file mode 100644 index 0000000..8748ad8 --- /dev/null +++ b/webpages/manager.md @@ -0,0 +1,154 @@ +--- +title: "VM-Operator: The Manager — Provides the controller and a Web UI" +description: >- + Information about the installation and configuration of the + VM Operator. +layout: vm-operator +--- + +# The Manager + +The Manager is the program that provides the controller from the +[operator pattern](https://github.com/cncf/tag-app-delivery/blob/eece8f7307f2970f46f100f51932db106db46968/operator-wg/whitepaper/Operator-WhitePaper_v1-0.md#operator-components-in-kubernetes) +together with a web user interface. It should be run in a container in the cluster. + +## Installation + +A manager instance manages the VMs in its own namespace. The only +common (and therefore cluster scoped) resource used by all instances +is the CRD. It is available +[here](https://github.com/mnlipp/VM-Operator/raw/main/deploy/crds/vms-crd.yaml) +and must be created first. + +```sh +kubectl apply -f https://github.com/mnlipp/VM-Operator/raw/main/deploy/crds/vms-crd.yaml +``` + +The example above uses the CRD from the main branch. This is okay if +you apply it once. If you want to preserve the link for automatic +upgrades, you should use a link that points to one of the release branches. + +The next step is to create a namespace for the manager and the VMs, e.g. +`vmop-demo`. + +```sh +kubectl create namespace vmop-demo +``` + +Finally you have to create an account, the role, the binding etc. The +default files for creating these resources using the default namespace +can be found in the +[deploy](https://github.com/mnlipp/VM-Operator/tree/main/deploy) +directory. I recommend to use +[kustomize](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/) +to create your own configuration. + +## Initial Configuration + +Use one of the `kustomize.yaml` files from the +[example](https://github.com/mnlipp/VM-Operator/tree/main/example) directory +as a starting point. The directory contains two examples. Here's the file +from subdirectory `local-path`: + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +# Again, I recommend to use the deploy directory from a +# release branch for anything but test environments. +- https://github.com/mnlipp/VM-Operator/deploy + +namespace: vmop-demo + +patches: +- patch: |- + kind: PersistentVolumeClaim + apiVersion: v1 + metadata: + name: vmop-image-repository + spec: + # Default is ReadOnlyMany + accessModes: + - ReadWriteOnce + resources: + requests: + # Default is 100Gi + storage: 10Gi + # Default is to use the default storage class + storageClassName: local-path + +- patch: |- + kind: ConfigMap + apiVersion: v1 + metadata: + name: vm-operator + data: + config.yaml: | + "/Manager": + # "/GuiHttpServer": + # See section about the GUI + "/Controller": + "/Reconciler": + runnerDataPvc: + # Default is to use the default storage class + storageClassName: local-path +``` + +The sample file adds a namespace (`vmop-demo`) to all resource +definitions and patches the PVC `vmop-image-repository`. This is a volume +that is mounted into all pods that run a VM. The volume is intended +to be used as a common repository for CDROM images. The PVC must exist +and it must be bound before any pods can run. + +The second patch affects the small volume that is created for each +runner and contains the VM's configuration data such as the EFI vars. +The manager's default configuration causes the PVC for this volume +to be created with no storage class (which causes the default storage +class to be used). The patch provides a new configuration file for +the manager that makes the reconciler use local-path as storage +class for this PVC. Details about the manager configuration can be +found in the next section. + +Note that you need none of the patches if you are fine with using your +cluster's default storage class and this class supports ReadOnlyMany as +access mode. + +Check that the pod with the manager is running: + +```sh +kubectl -n vmop-demo get pods -l app.kubernetes.io/name=vm-operator +``` + +Proceed to the description of [the controller](controller.html) +for creating your first VM. + +## Configuration Details + +The [config map](https://github.com/mnlipp/VM-Operator/blob/main/deploy/vmop-config-map.yaml) +for the manager may provide a configuration file (`config.yaml`) and +a file with logging properties (`logging.properties`). Both files are mounted +into the container that runs the manager and are evaluated by the manager +on startup. If no files are provided, the manager uses built-in defaults. + +The configuration file for the Manager follows the conventions of +the [JGrapes](https://jgrapes.org/) component framework. +The keys that start with a slash select the component within the +application's component hierarchy. The mapping associated with the +selected component configures this component's properties. + +The available configuration options for the components can be found +in their respective JavaDocs (e.g. +[here](latest-release/javadoc/org/jdrupes/vmoperator/manager/Reconciler.html) +for the Reconciler). + +## Development Configuration + +The [dev-example](https://github.com/mnlipp/VM-Operator/tree/main/dev-example) +directory contains a `kustomize.yaml` that uses the development namespace +`vmop-dev` and creates a deployment for the manager with 0 replicas. + +This environment can be used for running the manager in the IDE. As the +namespace to manage cannot be detected from the environment, you must use + `-c ../dev-example/config.yaml` as argument when starting the manager. This +configures it to use the namespace `vmop-dev`. diff --git a/webpages/pools.md b/webpages/pools.md new file mode 100644 index 0000000..c84264f --- /dev/null +++ b/webpages/pools.md @@ -0,0 +1,115 @@ +--- +title: "VM-Operator: VM pools — assigning VMs to users dynamically" +layout: vm-operator +--- + +# VM Pools + +*Since 4.0.0* + +Not all VMs are defined as replacements for carefully maintained +individual PCs. In many workplaces, a standardardized VM configuration +can be used where all user-specific data is stored in each user's home +directory. By using a shared file system for home directories, users +can login on any VM and find themselves in their personal +environment. + +If only a subset of users require access simultaneously, this makes it +possible to define a pool of standardardized VMs and dynamically assign +them to users as needed, eliminating the need to define a dedicated VM +for each user. + +## Pool definitions + +The VM-operator supports this use case with a CRD for pools. + +```yaml +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VmPool +metadata: + namespace: vmop-dev + name: test-vms +spec: + retention: "PT4h" + loginOnAssignment: true + permissions: + - user: admin + may: + - accessConsole + - start + - role: user + may: + - accessConsole + - start +``` + +The `retention` specifies how long the assignment of a VM from the pool to +a user remains valid after the user closes the console. This ensures that +a user can resume work within this timeframe without the risk of another +user taking over the VM. The time is specified as an +[ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations). +Specifying an ISO 8601 time is also supported, but if you consider +using an absolute time, check again whether a dedicated VM for the user +isn't the more appropriate choice. + +Setting `loginOnAssignment` to `true` (defaults to `false`) triggers automatic +login of the user (as described in [section auto login](auto-login.html)) +when the VM is assigned. The `permissions` property specifies the actions +that users or roles can perform on assigned VMs. The `may` property defaults +to `[accessConsole]` if not specified. + +VMs become members of one (or more) pools by adding the pool name to +the `spec.pools` array in the VM definition, as shown below: + +```yaml +apiVersion: "vmoperator.jdrupes.org/v1" +kind: VirtualMachine + +spec: + pools: + - test-vms +``` + +## Accessing a VM from the pool + +Users can access a VM from a pool using the widget described in +[user view](user-gui.html). The widget must be configured to +provide access to a pool instead of to a specific VM. + +![VM Access configuration](ConfigAccess-preview.png){: width="500"} + +Assignment happens when the "Start" icon is clicked. If the assigned VM +is not already running, it will be started automatically. The assigned +VM's name apears in the widget above the action icons. + +![VM Access via pool](PoolAccess-preview.png) + +Apart from showing the assigned VM, the widget behaves in the same way +as when configured for accessing a specific VM. + +## Guest OS Requirements + +To ensure proper functionality when using VM pools, certain requirements +must be met on the guest OS. + +### Shared file system + +All VMs in the pool must mount a shared file system as the home directory. +When using the +[sample agent](https://github.com/mnlipp/VM-Operator/tree/main/dev-example/vmop-agent), +the file system must support POSIX file access control lists (ACLs). + +### User management + +All VMs in the pool must map a given user name to the same user +id. This is typically accomplished by using a central user management, +such as LDAP. The drawback of such a solution is that it is rather +complicated to configure. + +As an alternative, the sample auto login agent provides a very simple +approach that uses the shared home directory for managing the user ids. +Simplified, the script searches for a home directory with the given user +name and derives the user id from it. It then checks if the user id is +known by the guest operating system. If not, the user is added. + +Details can be found in the comments of the sample script. diff --git a/webpages/robots-readthedocs.txt b/webpages/robots-readthedocs.txt new file mode 100644 index 0000000..90e0f33 --- /dev/null +++ b/webpages/robots-readthedocs.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://kubernetes-vm-operator.readthedocs.io/sitemap.xml diff --git a/webpages/robots.txt b/webpages/robots.txt new file mode 100644 index 0000000..e1ed7b0 --- /dev/null +++ b/webpages/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://vm-operator.jdrupes.org/sitemap.xml diff --git a/webpages/runner.md b/webpages/runner.md new file mode 100644 index 0000000..e677a7a --- /dev/null +++ b/webpages/runner.md @@ -0,0 +1,114 @@ +--- +title: "VM-Operator: The Runner — Starts and monitors a VM" +description: >- + Description of the VM Operator's runner component which starts + QEMU and thus the VM, optionally together with a TPM, in a + kubenernetes pod and monitors everything. +layout: vm-operator +--- + +# The Runner + +For most use cases, QEMU needs to be started and controlled by another +program that manages the QEMU process. This program is called the +runner in this context. + +The most prominent reason for this second program is that it allows +a VM to be shutdown cleanly in response to a TERM signal. QEMU handles +the TERM signal by flushing all buffers and stopping, leaving the disks in +a [crash consistent state](https://gitlab.com/qemu-project/qemu/-/issues/148). +For a graceful shutdown, a parent process must handle the TERM signal, send +the `system_powerdown` command to the qemu process and wait for its completion. + +Another reason for having the runner is that another process needs to be started +before QEMU if the VM is supposed to include a TPM (software TPM). + +Finally, we want some kind of higher level interface for applying runtime +changes to the VM such as changing the CD or configuring the number of +CPUs and the memory. + +The runner takes care of all these issues. Although it is intended to +run in a container (which runs in a Kubernetes pod) it does not require +a container. You can start and use it as an ordinary program on any +system, provided that you have the required commands (qemu, swtpm) +installed. + +## Stand-alone Configuration + +Upon startup, the runner reads its main configuration file +which defaults to `/etc/opt/vmrunner/config.yaml` and may be changed +using the `-c` (or `--config`) command line option. + +A sample configuration file with annotated options can be found +[here](https://github.com/mnlipp/VM-Operator/blob/main/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml). +As the runner implementation uses the +[JGrapes](https://jgrapes.org/) framework, the file +follows the framework's +[conventions](https://jgrapes.org/latest-release/javadoc/org/jgrapes/util/YamlConfigurationStore.html). +The top level "`/Runner`" selects the component to be configured. Nested +within is the information to be applied to the component. + +The main entries in the configuration file are the "template" and +the "vm" information. The runner processes the +[freemarker template](https://freemarker.apache.org/), using the +"vm" information to derive the qemu command. The idea is that +the "vm" section provides high level information such as the boot +mode, the number of CPUs, the RAM size and the disks. The template +defines a particular VM type, i.e. it contains the "nasty details" +that do not need to be modified for some given set of VM instances. + +The templates provided with the runner can be found +[here](https://github.com/mnlipp/VM-Operator/tree/main/org.jdrupes.vmoperator.runner.qemu/templates). +When details of the VM configuration need modification, a new VM type +(i.e. a new template) has to be defined. Authoring a new +template requires some knowledge about the +[qemu invocation](https://www.qemu.org/docs/master/system/invocation.html). +Despite many "warnings" that you find in the web, configuring the +invocation arguments of qemu is only a bit (but not much) more +challenging than editing libvirt's XML. + +## Running in a Pod + +The real purpose of the runner is to run a VM on Kubernetes in a pod. +When running in a Kubernetes pod, `/etc/opt/vmrunner/config.yaml` should be +provided by a +[ConfigMap](https://kubernetes.io/docs/concepts/configuration/configmap/). + +If additional templates are required, some ReadOnlyMany PV should +be mounted in `/opt/vmrunner/templates`. The PV should contain copies +of the standard templates as well as the additional templates. Of course, +a ConfigMap can be used for this purpose again. + +Networking options are rather limited. The assumption is that in general +the VM wants full network connectivity. To achieve this, the pod must +run with host networking and the host's networking must provide a +bridge that the VM can attach to. The only currently supported +alternative is the less performant +"[user networking](https://wiki.qemu.org/Documentation/Networking#User_Networking_(SLIRP))", +which may be used in a stand-alone development configuration. + +## Runtime changes + +The runner supports adaption to changes of the RAM size (using the +balloon device) and to changes of the number of CPUs. Note that +in order to get new CPUs online on Linux guests, you need a +[udev rule](https://docs.kernel.org/core-api/cpu_hotplug.html#user-space-notification) +which is not installed by default[^simplest]. + +The runner also changes the images loaded in CDROM drives. If the +drive is locked, i.e. if it doesn't respond to the "open tray" command +the change will be suspended until the VM opens the tray. + +Finally, `powerdownTimeout` can be changed while the qemu process runs. + +[^simplest]: The simplest form of the rule is probably: + + ```txt + ACTION=="add", SUBSYSTEM=="cpu", ATTR{online}="1" + ``` + +## Testing with Helm + +There is a +[Helm Chart](https://github.com/mnlipp/VM-Operator/tree/main/org.jdrupes.vmoperator.runner.qemu/helm-test) +for testing the runner. diff --git a/webpages/stylesheets/pygment_trac.css b/webpages/stylesheets/pygment_trac.css new file mode 100644 index 0000000..c6a6452 --- /dev/null +++ b/webpages/stylesheets/pygment_trac.css @@ -0,0 +1,69 @@ +.highlight { background: #ffffff; } +.highlight .c { color: #999988; font-style: italic } /* Comment */ +.highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ +.highlight .k { font-weight: bold } /* Keyword */ +.highlight .o { font-weight: bold } /* Operator */ +.highlight .cm { color: #999988; font-style: italic } /* Comment.Multiline */ +.highlight .cp { color: #999999; font-weight: bold } /* Comment.Preproc */ +.highlight .c1 { color: #999988; font-style: italic } /* Comment.Single */ +.highlight .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ +.highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ +.highlight .gd .x { color: #000000; background-color: #ffaaaa } /* Generic.Deleted.Specific */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .gr { color: #aa0000 } /* Generic.Error */ +.highlight .gh { color: #999999 } /* Generic.Heading */ +.highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ +.highlight .gi .x { color: #000000; background-color: #aaffaa } /* Generic.Inserted.Specific */ +.highlight .go { color: #888888 } /* Generic.Output */ +.highlight .gp { color: #555555 } /* Generic.Prompt */ +.highlight .gs { font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #800080; font-weight: bold; } /* Generic.Subheading */ +.highlight .gt { color: #aa0000 } /* Generic.Traceback */ +.highlight .kc { font-weight: bold } /* Keyword.Constant */ +.highlight .kd { font-weight: bold } /* Keyword.Declaration */ +.highlight .kn { font-weight: bold } /* Keyword.Namespace */ +.highlight .kp { font-weight: bold } /* Keyword.Pseudo */ +.highlight .kr { font-weight: bold } /* Keyword.Reserved */ +.highlight .kt { color: #445588; font-weight: bold } /* Keyword.Type */ +.highlight .m { color: #009999 } /* Literal.Number */ +.highlight .s { color: #d14 } /* Literal.String */ +.highlight .na { color: #008080 } /* Name.Attribute */ +.highlight .nb { color: #0086B3 } /* Name.Builtin */ +.highlight .nc { color: #445588; font-weight: bold } /* Name.Class */ +.highlight .no { color: #008080 } /* Name.Constant */ +.highlight .ni { color: #800080 } /* Name.Entity */ +.highlight .ne { color: #990000; font-weight: bold } /* Name.Exception */ +.highlight .nf { color: #990000; font-weight: bold } /* Name.Function */ +.highlight .nn { color: #555555 } /* Name.Namespace */ +.highlight .nt { color: #000080 } /* Name.Tag */ +.highlight .nv { color: #008080 } /* Name.Variable */ +.highlight .ow { font-weight: bold } /* Operator.Word */ +.highlight .w { color: #bbbbbb } /* Text.Whitespace */ +.highlight .mf { color: #009999 } /* Literal.Number.Float */ +.highlight .mh { color: #009999 } /* Literal.Number.Hex */ +.highlight .mi { color: #009999 } /* Literal.Number.Integer */ +.highlight .mo { color: #009999 } /* Literal.Number.Oct */ +.highlight .sb { color: #d14 } /* Literal.String.Backtick */ +.highlight .sc { color: #d14 } /* Literal.String.Char */ +.highlight .sd { color: #d14 } /* Literal.String.Doc */ +.highlight .s2 { color: #d14 } /* Literal.String.Double */ +.highlight .se { color: #d14 } /* Literal.String.Escape */ +.highlight .sh { color: #d14 } /* Literal.String.Heredoc */ +.highlight .si { color: #d14 } /* Literal.String.Interpol */ +.highlight .sx { color: #d14 } /* Literal.String.Other */ +.highlight .sr { color: #009926 } /* Literal.String.Regex */ +.highlight .s1 { color: #d14 } /* Literal.String.Single */ +.highlight .ss { color: #990073 } /* Literal.String.Symbol */ +.highlight .bp { color: #999999 } /* Name.Builtin.Pseudo */ +.highlight .vc { color: #008080 } /* Name.Variable.Class */ +.highlight .vg { color: #008080 } /* Name.Variable.Global */ +.highlight .vi { color: #008080 } /* Name.Variable.Instance */ +.highlight .il { color: #009999 } /* Literal.Number.Integer.Long */ + +.type-csharp .highlight .k { color: #0000FF } +.type-csharp .highlight .kt { color: #0000FF } +.type-csharp .highlight .nf { color: #000000; font-weight: normal } +.type-csharp .highlight .nc { color: #2B91AF } +.type-csharp .highlight .nn { color: #000000 } +.type-csharp .highlight .s { color: #A31515 } +.type-csharp .highlight .sc { color: #A31515 } diff --git a/webpages/stylesheets/styles.css b/webpages/stylesheets/styles.css new file mode 100644 index 0000000..41fb0d0 --- /dev/null +++ b/webpages/stylesheets/styles.css @@ -0,0 +1,298 @@ +body { + background-color: #fff; + padding:50px; + font: normal 16px/1.5 Verdana, Arial, Helvetica, sans-serif; + color:#595959; +} + +h1, h2, h3, h4, h5, h6, .index-title, .index-subtitle { + color:#222; + margin:0 0 20px; +} + +p, ul, ol, table, pre, dl { + margin:0 0 20px; +} + +h1, h2, h3, .index-title, .index-subtitle { + line-height:1.1; +} + +h1, .index-title { + font-size:28px; + font-weight: 500; +} + +h2 { + color:#393939; + font-weight: 500; +} + +h3, h4, h5, h6, .index-subtitle { + color:#494949; + font-weight: 500; +} + +.index-subtitle { + font-size: 1.17em; +} + +a { + color:#39c; + text-decoration:none; +} + +a:hover { + color:#069; +} + +a small { + font-size:11px; + color:#777; + margin-top:-0.3em; + display:block; +} + +a:hover small { + color:#777; +} + +.wrapper { + /* width:860px; */ + width: 100%; + margin:0 auto; +} + +blockquote { + border-left:1px solid #e5e5e5; + margin:0; + padding:0 0 0 20px; + font-style:italic; +} + +code, pre { + font-family:Monaco, Bitstream Vera Sans Mono, Lucida Console, Terminal, Consolas, Liberation Mono, DejaVu Sans Mono, Courier New, monospace; + color:#333; +} + +pre { + font-size: 15px; + padding:8px 15px; + background: #f8f8f8; + border-radius:5px; + border:1px solid #e5e5e5; + overflow-x: auto; +} + +a code { + color: inherit; +} + +table { + width:100%; + border-collapse:collapse; +} + +th, td { + text-align:left; + padding:5px 10px; + border-bottom:1px solid #e5e5e5; +} + +dt { + color:#444; + font-weight:500; +} + +th { + color:#444; +} + +img { + max-width:100%; +} + +header { + /* width:270px; */ + width:calc(29% - 50px); + height:calc(100% - 160px); + overflow: auto; + float:left; + position:fixed; + -webkit-font-smoothing:subpixel-antialiased; +} + +header li { + list-style-type: disc; +} + +header ul { + padding-left: 1rem; +} + +header ul > li { + margin-left: 1rem; +} + +ul.no-bullets { + padding-left: 0; +} + +ul.no-bullets > li { + list-style: none; +} + +strong { + color:#222; + font-weight:500; +} + +section { + width:70%; + max-width:54em; + float:right; + padding-bottom:50px; +} + +small { + font-size:11px; +} + +hr { + border:0; + background:#e5e5e5; + height:1px; + margin:0 0 20px; +} + +footer { + /* width:270px; */ + width:calc(24% - 50px); + height:40px; + float:left; + position:fixed; + padding:30px 0; + bottom:0px; + background-color:white; + -webkit-font-smoothing:subpixel-antialiased; +} + +.post-date { + float: right; +} + +.part-list-title { + margin-bottom:5px; +} + +.part-entry { + margin-bottom:5px; +} + +#search { + + --pagefind-ui-font: inherit; + --pagefind-ui-border-radius: 4px; + + position: absolute; + right: 1em; + top: 1em; + + .pagefind-ui__form { + width: 20em; + margin-left: auto; + + &::before { + top: calc(17px * var(--pagefind-ui-scale)); + } + } + + .pagefind-ui__search-input { + font-weight: inherit; + height: calc(48px * var(--pagefind-ui-scale)); + } + + .pagefind-ui__search-clear { + font-weight: inherit; + height: calc(42px * var(--pagefind-ui-scale)); + } + + .pagefind-ui__drawer { + position: absolute; + right: 0; + width: 40em; + background-color: white; + border: solid var(--pagefind-ui-border-width) var(--pagefind-ui-border); + padding: 0 1em 1em 1em; + } + + .pagefind-ui__message { + padding-top: 0; + } + + .pagefind-ui__result { + padding: 0; + } + + .pagefind-ui__result-title { + font-weight: inherit; + } +} + +@media print, screen and (max-width: 960px) { + + div.wrapper { + width:auto; + margin:0; + } + + header, section, footer { + float:none; + position:static; + width:auto; + } + + header { + padding-right:320px; + } + + section { + border:1px solid #e5e5e5; + border-width:1px 0; + padding:20px 0; + margin:0 0 20px; + } + + header a small { + display:inline; + } +} + +@media print, screen and (max-width: 720px) { + body { + word-wrap:break-word; + } + + header { + padding:0; + } + + pre, code { + word-wrap:normal; + } +} + +@media print, screen and (max-width: 480px) { + body { + padding:15px; + } + +} + +@media print { + body { + padding:0.4in; + font-size:12pt; + color:#444; + } +} diff --git a/webpages/upgrading.md b/webpages/upgrading.md new file mode 100644 index 0000000..f3119b0 --- /dev/null +++ b/webpages/upgrading.md @@ -0,0 +1,82 @@ +--- +title: "VM-Operator: Upgrading — Issues to watch out for" +description: >- + Information about issues to watch out for when upgrading the VM-Operator. +layout: vm-operator +--- + +# Upgrading + +## To version 4.0.0 + + * The VmViewer conlet has been renamed to VmAccess. This affects the + [configuration](https://vm-operator.jdrupes.org/user-gui.html). + Configuration information using the old path + `/Manager/GuiHttpServer/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer` + is still accepted for backward compatibility until the next major version, + but should be updated. + + The change of name also causes conlets added to the overview page by + users to "disappear" from the GUI. They have to be re-added. + + The latter behavior also applies to the VmConlet conlet which has been + renamed to VmMgmt. + + * The configuration property `passwordValidity` has been moved from component + `/Manager/Controller/DisplaySecretMonitor` to + `/Manager/Controller/Reconciler/DisplaySecretReconciler`. The old path is + still accepted for backward compatibility until the next major version, + but should be updated. + + * The standard [template](./runner.html#stand-alone-configuration) used + to generate the qemu command has been updated. Unless you have enabled + automatic updates of the template in the VM definition, you have to + update the template manually. If you're using your own template, you + have to add a virtual serial port (see the git history of the standard + template for the required addition). + + * Stateful sets from pre 3.4.0 versions are no longer removed automatically + (see notes below). However, PVCs with the old naming scheme are still + reused. + +## To version 3.4.0 + +Starting with this version, the VM-Operator no longer uses a stateful set +with replica set to 1 to (indirectly) start the pod with the VM. Rather +it creates the pod directly. This implies that the PVCs must also be created +by the VM-Operator, which needs additional permissions to do so (update of +`deploy/vmop-role.yaml). As it would be ridiculous to keep the naming scheme +used by the stateful set when generating PVCs, the VM-Operator uses a +[different pattern](controller.html#defining-disks) for creating new PVCs. + +The change is backward compatible: + + * Running pods created by a stateful set are left alone until stopped. + Only then will the stateful set be removed. + + * The VM-Operator looks for existing PVCs generated by a stateful + set in the pre 3.4 versions (naming pattern "*name*-disk-*vmName*-0") + and reuses them. Only new PVCs are generated using the new pattern. + +## To version 3.0.0 + +All configuration files are backward compatible to version 2.3.0. +Note that in order to make use of the new viewer component, +[permissions](https://mnlipp.github.io/VM-Operator/user-gui.html#control-access-to-vms) +must be configured in the CR definition. Also note that +[display secrets](https://mnlipp.github.io/VM-Operator/user-gui.html#securing-access) +are automatically created unless explicitly disabled. + +## To version 2.3.0 + +Starting with version 2.3.0, the web GUI uses a login conlet that +supports OIDC providers. This effects the configuration of the +web GUI components. + +## To version 2.2.0 + +Version 2.2.0 sets the stateful set's `.spec.updateStrategy.type` to +"OnDelete". This fails for no apparent reason if a definition of +the stateful set with the default value "RollingUpdate" already exists. +In order to fix this, either the stateful set or the complete VM definition +must be deleted and the manager must be restarted. diff --git a/webpages/user-gui.md b/webpages/user-gui.md new file mode 100644 index 0000000..3ff816f --- /dev/null +++ b/webpages/user-gui.md @@ -0,0 +1,152 @@ +--- +title: "VM-Operator: User View — Allows users to manage their own VMs" +description: >- + Information about the user view of the VM-Operator, which allows users + to access and optionally manage the VMs for which they have the + respective permissions. +layout: vm-operator +--- + +# User view + +*Since 3.0.0* + +The idea of the user view is to provide an intuitive widget that +allows the users to access their own VMs and to optionally start +and stop them. + +![VM Access](VmAccess-preview.png) + +The configuration options resulting from this seemingly simple +requirement are unexpectedly complex. + +## Control access to VMs + +First of all, we have to define which VMs a user can access. This +is done using the optional property `spec.permissions` of the +VM definition (CRD). + +```yaml +spec: + permissions: + - role: admin + may: + - "*" + - user: test + may: + - start + - stop + - accessConsole +``` + +Permissions can be granted to individual users or to roles. There +is a permission for each possible action. "*" grants them all. + +## Simple usage vs. expert usage + +Next, there are two ways to create the VM widgets (preview conlets +in the framework's terms). They can be created on demand or +automatically for each VM that a logged in user has permission to +access. The former is the preferred way for an administrator who +has access to all VMs and needs to open a particular VM's console +for trouble shooting only. The latter is the preferred way +for a regular user who has access to a limited number of VMs. +In this case, creating the widgets automatically has the additional +benefit that regular users don't need to know how to create and +configure the widgets using the menu and the properties dialog. + +Automatic synchronization of widgets and accessible VMs is controlled +by the property `syncPreviewsFor` of the VM viewer. It's an array with +objects that either specify a role or a user. + +```yaml +"/Manager": + # This configures the GUI + "/GuiHttpServer": + "/ConsoleWeblet": + "/WebConsole": + "/ComponentCollector": + "/VmAccess": + syncPreviewsFor: + - role: user + - user: test + displayResource: + preferredIpVersion: ipv4 +``` + +## Console access + +Access to the VM's console is implemented by generating a +[connection file](https://manpages.debian.org/testing/virt-viewer/remote-viewer.1.en.html#CONNECTION_FILE) +for virt-viewer when the user clicks on +the console icon. If automatic open is enabled for this kind of +files in the browser, the console opens without further user action. + +The file contains all required and optional information to start the +remote viewer. + + * The "host" is by default the IP address of the node that the + VM's pod is running on (remember that the runner uses host + networking). + * The "port" is simply taken from the VM definition. + +In more complex scenarios, an administrator may have set up a load +balancer that hides the worker node's IP addresses or the worker +nodes use an internal network and can only be accessed through a +proxy. For both cases, the values to include in the connection file +can be specified as properties of `spec.vm.display.spice` in the +VM definition. + +```yaml +spec: + vm: + display: + spice: + port: 5930 + server: 192.168.19.32 + proxyUrl: http://vms-spice.some.host:1234 + generateSecret: true +``` + +The value of `server` is used as value for key "host" in the +connection file, thus overriding the default value. The +value of `proxyUrl` is used as value for key "proxy". + +## Securing access + +As described [previously](./controller.html#display-secretpassword), +access to a VM's display can be secured with a password. If a secret +with a password exists for a VM, the password is +included in the connection file. + +While this approach is very convenient for the user, it is not +secure, because this leaves the password as plain text in a file on +the user's computer (the downloaded connection file). To work around +this, the display secret is updated with a random password with +limited validity, unless the display secret defines a `password-expiry` +in the future or with value "never" or doesn't define a +`password-expiry` at all. + +The automatically generated password is the base64 encoded value +of 16 (strong) random bytes (128 random bits). It is valid for +10 seconds only. This may be challenging on a slower computer +or if users may not enable automatic open for connection files +in the browser. The validity can therefore be adjusted in the +configuration.[^oldPath] + +```yaml +"/Manager": + "/Controller": + "/Reconciler": + "/DisplaySecretReconciler": + # Validity of generated password in seconds + passwordValidity: 10 +``` + +[^oldPath]: Before version 4.0, the path for `passwordValidity` was + `/Manager/Controller/DisplaySecretMonitor`. + +Taking into account that the controller generates a display +secret automatically by default, this approach to securing +console access should be sufficient in all cases. (Any feedback +if something has been missed is appreciated.) diff --git a/webpages/webgui.md b/webpages/webgui.md new file mode 100644 index 0000000..2d6e428 --- /dev/null +++ b/webpages/webgui.md @@ -0,0 +1,117 @@ +--- +title: "VM-Operator: Web user interface — Provides easy access to VM management" +layout: vm-operator +--- + +# Web user interface + +The manager component provides a GUI via a web server. This web user interface is +implemented using components from the +[JGrapes WebConsole](https://jgrapes.org/WebConsole.html) +project. Configuration of the GUI therefore follows the conventions +of that framework. + +The structure of the configuration information should be easy to +understand from the examples provided. In general, configuration values +are applied to the individual components that make up an application. +The hierarchy of the components is reflected in the configuration +information because components are "addressed" by their position in +that hierarchy. (See +[the package description](latest-release/javadoc/org/jdrupes/vmoperator/manager/package-summary.html) +for information about the complete component structure.) + +## Network access + +By default, the service is made available at port 8080 of the manager +pod. Of course, a kubernetes service and an ingress configuration must +be added as required by the environment. (See the +[definition](https://github.com/mnlipp/VM-Operator/blob/main/deploy/vmop-service.yaml) +from the +[sample deployment](https://github.com/mnlipp/VM-Operator/tree/main/deploy)). + +## User Access + +Access to the web user interface is controlled by the login conlet. The framework +does not include sophisticated components for user management. Rather, +it assumes that an OIDC provider is responsible for user authentication +and role management. + +```yaml +"/Manager": + # "/GuiSocketServer": + # port: 8080 + "/GuiHttpServer": + # This configures the GUI + "/ConsoleWeblet": + "/WebConsole": + "/LoginConlet": + # Starting with version 2.3.0 the preferred approach is to + # configure an OIDC provider for user management and + # authorization. See the text for details. + oidcProviders: {} + + # Support for "local" users is provided as a fallback mechanism. + # Note that up to Version 2.2.x "users" was an object with user names + # as its properties. Starting with 2.3.0 it is a list as shown. + users: + - name: admin + fullName: Administrator + password: "Generate hash with bcrypt" + - name: test + fullName: Test Account + password: "Generate hash with bcrypt" + + # Required for using OIDC, see the text for details. + "/OidcClient": + redirectUri: https://my.server.here/oauth/callback" + + # May be used for assigning roles to both local users and users from + # the OIDC provider. Not needed if roles are managed by the OIDC provider. + "/RoleConfigurator": + rolesByUser: + # User admin has role admin + admin: + - admin + # Non-privileged users are users + test: + - user + # All users have role other + "*": + - other + replace: false + + # Manages the permissions for the roles. + "/RoleConletFilter": + conletTypesByRole: + # Admins can use all conlets + admin: + - "*" + # Users can use the viewer conlet + user: + - org.jdrupes.vmoperator.vmviewer.VmViewer + # Others cannot use any conlet (except login conlet to log out) + other: + # Up to version 2.2.x + # - org.jgrapes.webconlet.locallogin.LoginConlet + # Starting with version 2.3.0 + - org.jgrapes.webconlet.oidclogin.LoginConlet +``` + +How local users can be configured should be obvious from the example. +The configuration of OIDC providers for user authentication (and +optionally for role assignment) is explained in the documentation of the +[login conlet](https://jgrapes.org/javadoc-webconsole/org/jgrapes/webconlet/oidclogin/LoginConlet.html). +Details about the `RoleConfigurator` and `RoleConletFilter` can also be found +in the documentation of the +[JGrapes WebConsole](https://jgrapes.org/WebConsole.html) +project. + +The configuration above allows all users with role "admin" to use all +GUI components and users with role "user" to only use the viewer conlet, +i.e. the [User view](user-gui.html). The fallback role "other" allows +all users to use the login conlet to log out. + +## Views + +The configuration of the components that provide the manager and +users views is explained in the respective sections.