diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ad8e2c3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{html,md,yml,yaml}] +indent_size = 2 +indent_style = space + +[*.{gradle,js,ts}] +indent_size = 4 +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/codeql.yml b/.github/workflows/codeql.yml index 32e0219..380981e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,7 @@ name: "CodeQL" on: push: - branches: [ "main" ] + branches: [ "*" ] pull_request: # The branches below must be a subset of the branches above branches: [ "main" ] diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 89feebf..f47366a 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -1,10 +1,8 @@ name: Java CI with Gradle on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] + push: {} + pull_request: {} permissions: contents: read @@ -20,12 +18,14 @@ jobs: steps: - uses: actions/checkout@v3 + with: + 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 5f9b579..beab0c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,13 +8,17 @@ permissions: contents: read packages: write +concurrency: + group: doc_generation + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install graphviz @@ -27,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 -Pdocker.registry=ghcr.io/${{ github.actor }} pushContainer + run: ./gradlew -Pwebsite.push.token=${{ secrets.WEBSITE_PUSH_TOKEN }} -Pdocker.registry=ghcr.io/${{ github.actor }} stage publishImage diff --git a/.gitignore b/.gitignore index 2595367..ca012b5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ bin .classpath .project org.eclipse.jdt.core.prefs +**/.externalToolBuilders/ + +# Node +**/node_modules/ 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/.project b/.project deleted file mode 100644 index 505b6f1..0000000 --- a/.project +++ /dev/null @@ -1,42 +0,0 @@ - - - VM-Operator - - - - org.eclipse.jdt.core.javanature - org.eclipse.buildship.core.gradleprojectnature - net.sf.eclipsecs.core.CheckstyleNature - ch.acanda.eclipse.pmd.builder.PMDNature - - - - org.eclipse.jdt.core.javabuilder - - - - org.eclipse.buildship.core.gradleprojectbuilder - - - - net.sf.eclipsecs.core.CheckstyleBuilder - - - - ch.acanda.eclipse.pmd.builder.PMDBuilder - - - - - - - 1 - 30 - - - org.eclipse.core.resources.regexFilterMatcher - node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ - - - - diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs index 33d02f4..72733d9 100644 --- a/.settings/org.eclipse.buildship.core.prefs +++ b/.settings/org.eclipse.buildship.core.prefs @@ -1,11 +1,11 @@ -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) connection.project.dir= eclipse.preferences.version=1 gradle.user.home= -java.home=/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.362.b09-2.fc37.x86_64 +java.home= jvm.arguments= offline.mode=false override.workspace.settings=true diff --git a/.vscode/launch.json b/.vscode/launch.json index b6d2ca5..070f376 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,7 +24,7 @@ "mainClass": "org.jdrupes.vmoperator.runner.qemu.Runner", "projectName": "org.jdrupes.vmoperator.runner.qemu", "cwd": "${workspaceFolder}/org.jdrupes.vmoperator.runner.qemu", - "vmArgs": "-ea -Djava.util.logging.config.file=jul-debug.properties -Dorg.jdrupes.vmoperator.runner.qemu.config=./config.yaml" + "vmArgs": "-ea -Djava.util.logging.manager=org.jdrupes.vmoperator.util.LongLoggingManager" } ] } \ No newline at end of file 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 e1c2df1..09fcd25 100644 --- a/README.md +++ b/README.md @@ -1,11 +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/VM-Operator.png b/VM-Operator.png new file mode 100644 index 0000000..9ecb022 Binary files /dev/null and b/VM-Operator.png differ diff --git a/build.gradle b/build.gradle index 20c844f..eb8e59a 100644 --- a/build.gradle +++ b/build.gradle @@ -2,22 +2,24 @@ buildscript { repositories { gradlePluginPortal() } - dependencies { - classpath 'org.ajoberstar.grgit:grgit-gradle:4.1.0' - classpath 'org.ajoberstar:gradle-git-publish:3.0.0' - } } plugins { - id 'pl.allegro.tech.build.axion-release' version '1.15.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.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" } -project.group = 'org.jdrupes.vmoperator' +allprojects { + project.group = 'org.jdrupes.vmoperator' +} task stage { - description = 'To be executed by CI, build and update JavaDoc.' + description = 'To be executed by CI.' group = 'build' // Build everything first @@ -25,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/.project b/buildSrc/.project deleted file mode 100644 index 8624d9f..0000000 --- a/buildSrc/.project +++ /dev/null @@ -1,11 +0,0 @@ - - - buildSrc - - - - - - - - diff --git a/buildSrc/.settings/org.eclipse.core.resources.prefs b/buildSrc/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/buildSrc/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/buildSrc/.settings/org.eclipse.core.runtime.prefs b/buildSrc/.settings/org.eclipse.core.runtime.prefs new file mode 100644 index 0000000..5a0ad22 --- /dev/null +++ b/buildSrc/.settings/org.eclipse.core.runtime.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +line.separator=\n diff --git a/buildSrc/.settings/org.eclipse.jdt.core.prefs b/buildSrc/.settings/org.eclipse.jdt.core.prefs index a15493a..b25073a 100644 --- a/buildSrc/.settings/org.eclipse.jdt.core.prefs +++ b/buildSrc/.settings/org.eclipse.jdt.core.prefs @@ -1,24 +1,13 @@ # -#Sun May 07 20:03:15 CEST 2023 -org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate -org.eclipse.jdt.core.compiler.problem.assertIdentifier=error -org.eclipse.jdt.core.compiler.problem.nullReference=warning -org.eclipse.jdt.core.compiler.debug.localVariable=generate -org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +#Wed Oct 02 14:48:43 CEST 2024 eclipse.preferences.version=1 -org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled -org.eclipse.jdt.core.compiler.problem.enumIdentifier=error -org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable -org.eclipse.jdt.core.compiler.debug.sourceFile=generate org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled -org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error -org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 -org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error -org.eclipse.jdt.core.compiler.problem.potentialNullReference=ignore -org.eclipse.jdt.core.compiler.source=17 -org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull -org.eclipse.jdt.core.compiler.release=disabled -org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning -org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=21 +org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +org.eclipse.jdt.core.compiler.compliance=21 org.eclipse.jdt.core.compiler.debug.lineNumber=generate -org.eclipse.jdt.core.compiler.compliance=17 +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.enumIdentifier=error +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 8bad6d4..4a5db6d 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -1,39 +1,34 @@ -/* - * 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 // as plugins in the main build. id 'groovy-gradle-plugin' -} -repositories { - // Use the plugin portal to apply community plugins in convention plugins. - gradlePluginPortal() + // Apply eclipse plugin + id 'eclipse' } sourceSets { - main { - groovy { - srcDirs = ['src'] - } - } + main { + groovy { + srcDirs = ['src'] + } + resources { + srcDirs = ['resources'] + } + } +} - test { - groovy { - srcDirs = ['test'] +eclipse { + + jdt { + file { + withProperties { properties -> + def formatterPrefs = new Properties() + rootProject.file("../gradle/org.eclipse.jdt.core.formatter.prefs") + .withInputStream { formatterPrefs.load(it) } + properties.putAll(formatterPrefs) + } } } } - -/* -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} -*/ 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 74fc72b..605dc09 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.java-common-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.java-common-conventions.gradle @@ -5,20 +5,24 @@ */ 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' - // Git based versioning - id 'pl.allegro.tech.build.axion-release' - // Apply eclipse plugin id 'eclipse' + + // Access to git information + id 'org.ajoberstar.grgit' } repositories { // Use Maven Central for resolving dependencies. mavenCentral() - mavenLocal() } dependencies { @@ -51,20 +55,29 @@ sourceSets { java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } -scmVersion { - versionIncrementer 'incrementMinor' - tag { - def shortened = project.name.startsWith(project.group + ".") ? - project.name.substring(project.group.length() + 1) : project.name - prefix = shortened.replace('.', '-') + "-" +jar { + manifest { + 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 ${gitDesc})", + "Implementation-Vendor": grgit.repository.jgit.repository.config.getString("user", null, "name") + + " (" + grgit.repository.jgit.repository.config.getString("user", null, "email") + ")", + "Git-Descriptor": gitDesc, + "Git-SHA": grgit.head().id, + ]) } } -version = scmVersion.version -ext.isSnapshot = version.endsWith('-SNAPSHOT') eclipse { diff --git a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle index 964be78..6af8fa7 100644 --- a/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle +++ b/buildSrc/src/org.jdrupes.vmoperator.java-doc-conventions.gradle @@ -1,10 +1,15 @@ plugins { - // Apply the common convention plugin for shared build configuration between library and application projects. - id 'org.jdrupes.vmoperator.java-common-conventions' - + // Apply the common versioning conventions. + id 'org.jdrupes.vmoperator.versioning-conventions' + id 'org.ajoberstar.git-publish' } +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() +} + var docDestinationDir = file("${rootProject.buildDir}/javadoc") configurations { @@ -17,31 +22,28 @@ configurations { } dependencies { - markdownDoclet "org.jdrupes.mdoclet:doclet:3.1.0" - javadocTaglets "org.jdrupes.taglets:plantuml-taglet:2.1.0" + markdownDoclet "org.jdrupes.mdoclet:doclet:4.0.0" + javadocTaglets "org.jdrupes.taglets:plantuml-taglet:3.0.0" } -task javadocResources(type: Copy) { - into file(docDestinationDir) - from ("${rootProject.rootDir}/misc") { - include '*.woff2' - } -} - -task java11doc (type: JavaExec) { +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 @@ -61,10 +63,11 @@ task java11doc (type: JavaExec) { args = ['-doctitle', """VM-Operator (runner.qemu-${-> findProject(':org.jdrupes.vmoperator.runner.qemu').version}, manager-${-> findProject(':org.jdrupes.vmoperator.manager').version})""", + '-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', @@ -82,7 +85,7 @@ task java11doc (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' ] @@ -91,34 +94,27 @@ task java11doc (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 java11doc + 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 new file mode 100644 index 0000000..49b6f74 --- /dev/null +++ b/buildSrc/src/org.jdrupes.vmoperator.versioning-conventions.gradle @@ -0,0 +1,36 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + // Required by axion-release + id 'org.ajoberstar.grgit' + // Git based versioning + 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 { + prefix = project.tagName + } +} +project.version = scmVersion.version +ext.isSnapshot = version.endsWith('-SNAPSHOT') diff --git a/checkstyle.xml b/checkstyle.xml index d8bab57..088e543 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -1,7 +1,7 @@ + + + + \ 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/.checkstyle b/org.jdrupes.vmoperator.common/.checkstyle new file mode 100644 index 0000000..7f2c604 --- /dev/null +++ b/org.jdrupes.vmoperator.common/.checkstyle @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.common/.eclipse-pmd b/org.jdrupes.vmoperator.common/.eclipse-pmd new file mode 100644 index 0000000..5d69caa --- /dev/null +++ b/org.jdrupes.vmoperator.common/.eclipse-pmd @@ -0,0 +1,7 @@ + + + + + + + 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/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.common/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..258eb47 --- /dev/null +++ b/org.jdrupes.vmoperator.common/.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/org.jdrupes.vmoperator.common/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.common/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/org.jdrupes.vmoperator.common/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/org.jdrupes.vmoperator.common/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.common/.settings/org.eclipse.core.runtime.prefs new file mode 100644 index 0000000..5a0ad22 --- /dev/null +++ b/org.jdrupes.vmoperator.common/.settings/org.eclipse.core.runtime.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +line.separator=\n diff --git a/org.jdrupes.vmoperator.common/build.gradle b/org.jdrupes.vmoperator.common/build.gradle new file mode 100644 index 0000000..e72cb14 --- /dev/null +++ b/org.jdrupes.vmoperator.common/build.gradle @@ -0,0 +1,17 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id 'org.jdrupes.vmoperator.java-library-conventions' +} + +dependencies { + api project(':org.jdrupes.vmoperator.util') + api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' + api 'io.kubernetes:client-java:[19.0.0,20.0.0)' + api 'org.yaml:snakeyaml' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]' +} diff --git a/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.java new file mode 100644 index 0000000..b9de69f --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Constants.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.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"; + + /** + * 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 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 new file mode 100644 index 0000000..68f52eb --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/Convertions.java @@ -0,0 +1,113 @@ +/* + * 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.common; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Provides methods for parsing "official" memory sizes.. + */ +@SuppressWarnings("PMD.UseUtilityClass") +public class Convertions { + + @SuppressWarnings({ "PMD.UseConcurrentHashMap", + "PMD.FieldNamingConventions" }) + private static final Map unitMap = new HashMap<>(); + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final List> unitMappings; + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final Pattern memorySize + = Pattern.compile("^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*"); + + static { + // SI units and common abbreviations + BigInteger factor = BigInteger.ONE; + unitMap.put("", factor); + BigInteger scale = BigInteger.valueOf(1000); + for (var unit : List.of("B", "kB", "MB", "GB", "TB", "PB", "EB")) { + unitMap.put(unit, factor); + factor = factor.multiply(scale); + } + // Binary units + factor = BigInteger.valueOf(1024); + scale = BigInteger.valueOf(1024); + for (var unit : List.of("KiB", "MiB", "GiB", "TiB", "PiB", "EiB")) { + unitMap.put(unit, factor); + factor = factor.multiply(scale); + } + unitMappings = unitMap.entrySet().stream() + .sorted((a, b) -> -1 * a.getValue().compareTo(b.getValue())) + .toList(); + } + + /** + * Parses a memory size specification. + * + * @param amount the amount + * @return the big integer + */ + public static BigInteger parseMemory(Object amount) { + if (amount == null) { + return (BigInteger) amount; + } + if (amount instanceof BigInteger number) { + return number; + } + if (amount instanceof Number number) { + return BigInteger.valueOf(number.longValue()); + } + var matcher = memorySize.matcher(amount.toString()); + if (!matcher.matches()) { + throw new NumberFormatException(amount.toString()); + } + var unit = BigInteger.ONE; + if (matcher.group(3) != null) { + unit = unitMap.get(matcher.group(3)); + if (unit == null) { + throw new NumberFormatException("Illegal unit \"" + + matcher.group(3) + "\" in \"" + amount.toString() + "\""); + } + } + var number = matcher.group(1); + return new BigDecimal(number).multiply(new BigDecimal(unit)) + .toBigInteger(); + } + + /** + * Format memory size for humans. + * + * @param size the size + * @return the string + */ + public static String formatMemory(BigInteger size) { + for (var mapping : unitMappings) { + if (size.compareTo(mapping.getValue()) >= 0 + && size.mod(mapping.getValue()).equals(BigInteger.ZERO)) { + return (size.divide(mapping.getValue()).toString() + + " " + mapping.getKey()).trim(); + } + } + return size.toString(); + } +} 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 new file mode 100644 index 0000000..3870337 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/K8s.java @@ -0,0 +1,249 @@ +/* + * 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.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.EventsV1Api; +import io.kubernetes.client.openapi.models.EventsV1Event; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1ObjectReference; +import io.kubernetes.client.util.Strings; +import io.kubernetes.client.util.generic.GenericKubernetesApi; +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" }) +public class K8s { + + /** + * 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 response the response + * @return the optional + * @throws ApiException the API exception + */ + 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(); + } + + /** + * Returns a new context with the given version as preferred version. + * + * @param context the context + * @param version the version + * @return the API resource + */ + 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()); + } + + /** + * Return a string representation of the context (API resource). + * + * @param context the context + * @return the string + */ + @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); + } + + /** + * Apply the given patch data. + * + * @param the generic type + * @param the generic type + * @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 { + PatchOptions opts = new PatchOptions(); + opts.setForce(true); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + var response = api.patch(existing.getMetadata().getNamespace(), + existing.getMetadata().getName(), V1Patch.PATCH_FORMAT_APPLY_YAML, + new V1Patch(update), opts).throwsApiException(); + 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.common/src/org/jdrupes/vmoperator/common/package-info.java b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/package-info.java new file mode 100644 index 0000000..1e3e750 --- /dev/null +++ b/org.jdrupes.vmoperator.common/src/org/jdrupes/vmoperator/common/package-info.java @@ -0,0 +1,22 @@ +/* + * 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 . + */ + +/** + * Classes and methods shared among the VM operator modules. + */ +package org.jdrupes.vmoperator.common; \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager.events/.checkstyle b/org.jdrupes.vmoperator.manager.events/.checkstyle new file mode 100644 index 0000000..7f2c604 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/.checkstyle @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.manager.events/.eclipse-pmd b/org.jdrupes.vmoperator.manager.events/.eclipse-pmd new file mode 100644 index 0000000..5d69caa --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/.eclipse-pmd @@ -0,0 +1,7 @@ + + + + + + + diff --git a/org.jdrupes.vmoperator.manager.events/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.manager.events/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..258eb47 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/.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/org.jdrupes.vmoperator.manager.events/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.manager.events/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/org.jdrupes.vmoperator.manager.events/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.manager.events/.settings/org.eclipse.core.runtime.prefs new file mode 100644 index 0000000..5a0ad22 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/.settings/org.eclipse.core.runtime.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +line.separator=\n diff --git a/org.jdrupes.vmoperator.manager.events/build.gradle b/org.jdrupes.vmoperator.manager.events/build.gradle new file mode 100644 index 0000000..bb4b8d8 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/build.gradle @@ -0,0 +1,13 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id 'org.jdrupes.vmoperator.java-library-conventions' +} + +dependencies { + api project(':org.jdrupes.vmoperator.common') +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java new file mode 100644 index 0000000..7252c6a --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/AssignVm.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; +import org.jgrapes.core.Event; + +/** + * Assign a VM from a pool to a user. + */ +public class AssignVm extends Event { + + private final String fromPool; + private final String toUser; + + /** + * Instantiates a new event. + * + * @param fromPool the from pool + * @param toUser the to user + */ + public AssignVm(String fromPool, String toUser) { + this.fromPool = fromPool; + this.toUser = toUser; + } + + /** + * Gets the pool to assign from. + * + * @return the pool + */ + public String fromPool() { + return fromPool; + } + + /** + * Gets the user to assign to. + * + * @return the to user + */ + public String toUser() { + return toUser; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java new file mode 100644 index 0000000..2b23532 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelDictionary.java @@ -0,0 +1,112 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import org.jgrapes.core.Channel; + +/** + * Supports the lookup of a channel by a name (an id). As a convenience, + * it is possible to additionally associate arbitrary data with the entry + * (and thus with the channel). Note that this interface defines a + * read-only view of the dictionary. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public interface ChannelDictionary { + + /** + * Combines the channel and the associated data. + * + * @param the channel type + * @param the type of the associated data + * @param channel the channel + * @param associated the associated + */ + public record Value(C channel, A associated) { + } + + /** + * Returns all known keys. + * + * @return the keys + */ + Set keys(); + + /** + * Return all known values. + * + * @return the collection + */ + Collection> values(); + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no entry exists. + * + * @param key the key + * @return the result + */ + Optional> value(K key); + + /** + * Return all known channels. + * + * @return the collection + */ + default Collection channels() { + return values().stream().map(v -> v.channel).toList(); + } + + /** + * Returns the channel registered for the key or an empty optional + * if no mapping exists. + * + * @param key the key + * @return the optional + */ + default Optional channel(K key) { + return value(key).map(b -> b.channel); + } + + /** + * Returns all known associated data. + * + * @return the collection + */ + default Collection associated() { + return values().stream() + .filter(v -> v.associated() != null) + .map(v -> v.associated).toList(); + } + + /** + * Return the data associated with the entry for the channel. + * + * @param key the key + * @return the data + */ + default Optional associated(K key) { + return value(key).map(b -> b.associated); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java new file mode 100644 index 0000000..da36123 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelManager.java @@ -0,0 +1,179 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.jgrapes.core.Channel; + +/** + * Provides an actively managed implementation of the {@link ChannelDictionary}. + * + * The {@link ChannelManager} can be used for housekeeping by any component + * that creates channels. It can be shared between this component and + * some other component, preferably passing it as {@link ChannelDictionary} + * (the read-only view) to the second component. Alternatively, the other + * component can use a {@link ChannelTracker} to track the mappings using + * events. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public class ChannelManager + implements ChannelDictionary { + + private final Map> entries = new ConcurrentHashMap<>(); + private final Function supplier; + + /** + * Instantiates a new channel manager. + * + * @param supplier the supplier that creates new channels + */ + public ChannelManager(Function supplier) { + this.supplier = supplier; + } + + /** + * Instantiates a new channel manager without a default supplier. + */ + public ChannelManager() { + this(k -> null); + } + + /** + * Return all keys. + * + * @return the keys. + */ + @Override + public Set keys() { + return entries.keySet(); + } + + /** + * Return all known values. + * + * @return the collection + */ + @Override + public Collection> values() { + return entries.values(); + } + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no mapping exists. + * + * @param key the key + * @return the result + */ + public Optional> value(K key) { + return Optional.ofNullable(entries.get(key)); + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @param associated the associated + * @return the channel manager + */ + public ChannelManager put(K key, C channel, A associated) { + entries.put(key, new Value<>(channel, associated)); + return this; + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @return the channel manager + */ + public ChannelManager put(K key, C channel) { + put(key, channel, null); + return this; + } + + /** + * Creates a new channel without adding it to the channel manager. + * After fully initializing the channel, it should be added to the + * manager using {@link #put(K, C)}. + * + * @param key the key + * @return the c + */ + public C createChannel(K key) { + return supplier.apply(key); + } + + /** + * Returns the {@link Channel} for the given name, creating it using + * the supplier passed to the constructor if it doesn't exist yet. + * + * @param key the key + * @return the channel + */ + public C channelGet(K key) { + return computeIfAbsent(key, supplier); + } + + /** + * Returns the {@link Channel} for the given name, creating it using + * the given supplier if it doesn't exist yet. + * + * @param key the key + * @param supplier the supplier + * @return the channel + */ + public C computeIfAbsent(K key, Function supplier) { + return entries.computeIfAbsent(key, + k -> new Value<>(supplier.apply(k), null)).channel(); + } + + /** + * Associate the entry for the channel with the given data. The entry + * for the channel must already exist. + * + * @param key the key + * @param data the data + * @return the channel manager + */ + public ChannelManager associate(K key, A data) { + Optional.ofNullable(entries.computeIfPresent(key, + (k, existing) -> new Value<>(existing.channel(), data))); + return this; + } + + /** + * Removes the channel with the given name. + * + * @param name the name + */ + public void remove(String name) { + entries.remove(name); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java new file mode 100644 index 0000000..8a41908 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ChannelTracker.java @@ -0,0 +1,161 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.jgrapes.core.Channel; + +/** + * Used to track mapping from a key to a channel. Entries must + * be maintained by handlers for "add/remove" (or "open/close") + * events delivered on the channels that are to be + * made available by the tracker. + * + * The channels are stored in the dictionary using {@link WeakReference}s. + * Removing entries is therefore best practice but not an absolute necessity + * as entries for cleared references are removed when one of the methods + * {@link #values()}, {@link #channels()} or {@link #associated()} is called. + * + * @param the key type + * @param the channel type + * @param the type of the associated data + */ +public class ChannelTracker + implements ChannelDictionary { + + private final Map> entries = new ConcurrentHashMap<>(); + + /** + * Combines the channel and associated data. + * + * @param the generic type + * @param the generic type + */ + @SuppressWarnings("PMD.ShortClassName") + private static class Data { + public final WeakReference channel; + public A associated; + + /** + * Instantiates a new value. + * + * @param channel the channel + */ + public Data(C channel) { + this.channel = new WeakReference<>(channel); + } + } + + @Override + public Set keys() { + return entries.keySet(); + } + + @Override + public Collection> values() { + var result = new ArrayList>(); + for (var itr = entries.entrySet().iterator(); itr.hasNext();) { + var value = itr.next().getValue(); + var channel = value.channel.get(); + if (channel == null) { + itr.remove(); + continue; + } + result.add(new Value<>(channel, value.associated)); + } + return result; + } + + /** + * Returns the channel and associates data registered for the key + * or an empty optional if no mapping exists. + * + * @param key the key + * @return the result + */ + public Optional> value(K key) { + var value = entries.get(key); + if (value == null) { + return Optional.empty(); + } + var channel = value.channel.get(); + if (channel == null) { + // Cleanup old reference + entries.remove(key); + return Optional.empty(); + } + return Optional.of(new Value<>(channel, value.associated)); + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @param associated the associated + * @return the channel manager + */ + public ChannelTracker put(K key, C channel, A associated) { + Data data = new Data<>(channel); + data.associated = associated; + entries.put(key, data); + return this; + } + + /** + * Store the given data. + * + * @param key the key + * @param channel the channel + * @return the channel manager + */ + public ChannelTracker put(K key, C channel) { + put(key, channel, null); + return this; + } + + /** + * Associate the entry for the channel with the given data. The entry + * for the channel must already exist. + * + * @param key the key + * @param data the data + * @return the channel manager + */ + public ChannelTracker associate(K key, A data) { + Optional.ofNullable(entries.get(key)) + .ifPresent(v -> v.associated = data); + return this; + } + + /** + * Removes the channel with the given name. + * + * @param name the name + */ + public void remove(String name) { + entries.remove(name); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java new file mode 100644 index 0000000..1c11a4e --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/Exit.java @@ -0,0 +1,43 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jgrapes.core.events.Stop; + +/** + * Like {@link Stop}, but sets an exit status. + */ +@SuppressWarnings("PMD.ShortClassName") +public class Exit extends Stop { + + private final int exitStatus; + + /** + * Instantiates a new exit. + * + * @param exitStatus the exit status + */ + public Exit(int exitStatus) { + this.exitStatus = exitStatus; + } + + public int exitStatus() { + return exitStatus; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java new file mode 100644 index 0000000..dc47b4a --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetDisplaySecret.java @@ -0,0 +1,92 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the current display secret and optionally updates it. + */ +public class GetDisplaySecret extends Event { + + private final VmDefinition vmDef; + private final String user; + + /** + * Instantiates a new request for the display secret. + * After handling the event, a result of `null` means that + * no secret is needed. No result means that the console + * is not accessible. + * + * @param vmDef the vm name + * @param user the requesting user + */ + public GetDisplaySecret(VmDefinition vmDef, String user) { + this.vmDef = vmDef; + this.user = user; + } + + /** + * Gets the VM definition. + * + * @return the VM definition + */ + public VmDefinition vmDefinition() { + return vmDef; + } + + /** + * Return the id of the user who has requested the password. + * + * @return the string + */ + public String user() { + return user; + } + + /** + * Returns `true` if a password is available. May only be called + * when the event is completed. Note that the password returned + * by {@link #secret()} may be `null`, indicating that no password + * is needed. + * + * @return true, if successful + */ + public boolean secretAvailable() { + if (!isDone()) { + throw new IllegalStateException("Event is not done."); + } + return !currentResults().isEmpty(); + } + + /** + * Return the secret. May only be called when the event has been + * completed with a valid result (see {@link #secretAvailable()}). + * + * @return the password. A value of `null` means that no password + * is required. + */ + public String secret() { + if (!isDone() || currentResults().isEmpty()) { + throw new IllegalStateException("Event is not done."); + } + return currentResults().get(0); + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java new file mode 100644 index 0000000..b563c9c --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetPools.java @@ -0,0 +1,87 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Event; + +/** + * Gets the known pools' definitions. + */ +public class GetPools extends Event> { + + private String name; + private String user; + private List roles = Collections.emptyList(); + + /** + * Return only the pool with the given name. + * + * @param name the name + * @return the returns the vms + */ + public GetPools withName(String name) { + this.name = name; + return this; + } + + /** + * Return only {@link VmPool}s that are accessible by + * the given user or roles. + * + * @param user the user + * @param roles the roles + * @return the event + */ + public GetPools accessibleFor(String user, List roles) { + this.user = user; + this.roles = roles; + return this; + } + + /** + * Returns the name filter criterion, if set. + * + * @return the optional + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional forUser() { + return Optional.ofNullable(user); + } + + /** + * Returns the roles criterion. + * + * @return the list + */ + public List forRoles() { + return roles; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java new file mode 100644 index 0000000..0e24013 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/GetVms.java @@ -0,0 +1,138 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jgrapes.core.Event; + +/** + * Gets the known VMs' definitions and channels. + */ +public class GetVms extends Event> { + + private String name; + private String user; + private List roles = Collections.emptyList(); + private String fromPool; + private String toUser; + + /** + * Return only the VMs with the given name. + * + * @param name the name + * @return the returns the vms + */ + public GetVms withName(String name) { + this.name = name; + return this; + } + + /** + * Return only {@link VmDefinition}s that are accessible by + * the given user or roles. + * + * @param user the user + * @param roles the roles + * @return the event + */ + public GetVms accessibleFor(String user, List roles) { + this.user = user; + this.roles = roles; + return this; + } + + /** + * Return only {@link VmDefinition}s that are assigned from the given pool. + * + * @param pool the pool + * @return the returns the vms + */ + public GetVms assignedFrom(String pool) { + this.fromPool = pool; + return this; + } + + /** + * Return only {@link VmDefinition}s that are assigned to the given user. + * + * @param user the user + * @return the returns the vms + */ + public GetVms assignedTo(String user) { + this.toUser = user; + return this; + } + + /** + * Returns the name filter criterion, if set. + * + * @return the optional + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional user() { + return Optional.ofNullable(user); + } + + /** + * Returns the roles criterion. + * + * @return the list + */ + public List roles() { + return roles; + } + + /** + * Returns the pool filter criterion, if set. + * + * @return the optional + */ + public Optional fromPool() { + return Optional.ofNullable(fromPool); + } + + /** + * Returns the user filter criterion, if set. + * + * @return the optional + */ + public Optional toUser() { + return Optional.ofNullable(toUser); + } + + /** + * Return tuple. + * + * @param definition the definition + * @param channel the channel + */ + public record VmData(VmDefinition definition, VmChannel channel) { + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java new file mode 100644 index 0000000..9e19255 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ModifyVm.java @@ -0,0 +1,74 @@ +/* + * 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.jgrapes.core.Channel; +import org.jgrapes.core.Event; + +/** + * Modifies a VM. + */ +public class ModifyVm extends Event { + + private final String name; + private final String path; + private final Object value; + + /** + * Instantiates a new modify vm event. + * + * @param channels the channels + * @param name the name + */ + public ModifyVm(String name, String path, Object value, + Channel... channels) { + super(channels); + this.name = name; + this.path = path; + this.value = value; + } + + /** + * Gets the name. + * + * @return the name + */ + public String name() { + 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/ResetVm.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java new file mode 100644 index 0000000..778820e --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/ResetVm.java @@ -0,0 +1,47 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jgrapes.core.Event; + +/** + * Triggers a reset of the VM. + */ +public class ResetVm extends Event { + + private final String vmName; + + /** + * Instantiates a new event. + * + * @param vmName the vm name + */ + public ResetVm(String vmName) { + this.vmName = vmName; + } + + /** + * Gets the vm name. + * + * @return the vm name + */ + public String vmName() { + return vmName; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java new file mode 100644 index 0000000..b4fcf56 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/UpdateAssignment.java @@ -0,0 +1,60 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.manager.events; + +import org.jdrupes.vmoperator.common.VmPool; +import org.jgrapes.core.Event; + +/** + * Note the assignment to a user in the VM status. + */ +public class UpdateAssignment extends Event { + + private final VmPool fromPool; + private final String toUser; + + /** + * Instantiates a new event. + * + * @param fromPool the pool from which the VM was assigned + * @param toUser the to user + */ + public UpdateAssignment(VmPool fromPool, String toUser) { + this.fromPool = fromPool; + this.toUser = toUser; + } + + /** + * Gets the pool from which the VM was assigned. + * + * @return the pool + */ + public VmPool fromPool() { + return fromPool; + } + + /** + * Gets the user to whom the VM was assigned. + * + * @return the to user + */ + public String toUser() { + return toUser; + } +} diff --git a/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java new file mode 100644 index 0000000..73507ae --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmChannel.java @@ -0,0 +1,126 @@ +/* + * 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.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. + */ +public class VmChannel extends DefaultSubchannel { + + private final EventPipeline pipeline; + private final K8sClient client; + private VmDefinition definition; + private long generation = -1; + + /** + * Instantiates a new watch channel. + * + * @param mainChannel the main channel + * @param pipeline the pipeline + * @param client the client + */ + public VmChannel(Channel mainChannel, EventPipeline pipeline, + K8sClient client) { + super(mainChannel); + this.pipeline = pipeline; + this.client = client; + } + + /** + * Sets the last known definition of the resource. + * + * @param definition the definition + * @return the watch channel + */ + public VmChannel setVmDefinition(VmDefinition definition) { + this.definition = definition; + return this; + } + + /** + * Returns the last known definition of the VM. + * + * @return the defintion + */ + public VmDefinition vmDefinition() { + return definition; + } + + /** + * Gets the last processed generation. Returns -1 if no + * definition has been processed yet. + * + * @return the generation + */ + public long generation() { + return generation; + } + + /** + * Sets the last processed generation. + * + * @param generation the generation to set + * @return true if value has changed + */ + public boolean setGeneration(long generation) { + if (this.generation == generation) { + return false; + } + this.generation = generation; + return true; + } + + /** + * Returns the pipeline. + * + * @return the event pipeline + */ + public EventPipeline pipeline() { + 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 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/VmResourceChanged.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java new file mode 100644 index 0000000..eac30fb --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/VmResourceChanged.java @@ -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 . + */ + +package org.jdrupes.vmoperator.manager.events; + +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 "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 VmResourceChanged extends Event { + + private final K8sObserver.ResponseType type; + private final VmDefinition vmDefinition; + private final boolean specChanged; + private final boolean podChanged; + + /** + * Instantiates a new VM changed event. + * + * @param type the type + * @param vmDefinition the VM definition + * @param specChanged the spec part changed + */ + public VmResourceChanged(K8sObserver.ResponseType type, + VmDefinition vmDefinition, boolean specChanged, + boolean podChanged) { + this.type = type; + this.vmDefinition = vmDefinition; + this.specChanged = specChanged; + this.podChanged = podChanged; + } + + /** + * Returns the type. + * + * @return the 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. + */ + public boolean specChanged() { + return specChanged; + } + + /** + * Indicates if the pod status changed. + */ + public boolean podChanged() { + return podChanged; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(vmDefinition.name()).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/package-info.java b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/package-info.java new file mode 100644 index 0000000..7492a88 --- /dev/null +++ b/org.jdrupes.vmoperator.manager.events/src/org/jdrupes/vmoperator/manager/events/package-info.java @@ -0,0 +1,25 @@ +/* + * 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 . + */ + +/** + * Domain specific {@link org.jgrapes.core.Event}s (and + * {@link org.jgrapes.core.Channel}s) used to communicate between + * the core components of the {@link org.jgrapes.core.Manager} and + * "plugin" components such as the conlets. + */ +package org.jdrupes.vmoperator.manager.events; \ No newline at end of file diff --git a/org.jdrupes.vmoperator.manager/.checkstyle b/org.jdrupes.vmoperator.manager/.checkstyle index 2add381..7f2c604 100644 --- a/org.jdrupes.vmoperator.manager/.checkstyle +++ b/org.jdrupes.vmoperator.manager/.checkstyle @@ -1,10 +1,10 @@ - + - + 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 new file mode 100644 index 0000000..03e8200 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/.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/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index f28c8fe..4ce4ed0 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -9,13 +9,123 @@ plugins { } dependencies { - implementation 'org.jgrapes:org.jgrapes.core:[1.19.0,2)' - implementation 'org.jgrapes:org.jgrapes.io:[2.5.0,3)' - implementation 'org.jgrapes:org.jgrapes.http:[3.1.0,4)' - implementation 'org.jgrapes:org.jgrapes.util:[1.28.0,2)' + implementation project(':org.jdrupes.vmoperator.manager.events') + + implementation 'commons-cli:commons-cli:1.5.0' + + 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:[2.3.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.8.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.4.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.0,2)' + implementation 'org.jgrapes:org.jgrapes.webconlet.markdowndisplay:[1.2.0,2)' + + runtimeOnly 'org.jgrapes:org.jgrapes.webconlet.sysinfo:[1.4.0,2)' + 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.vmmgmt') + runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') } application { + applicationName = 'vm-manager' + 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', '--pull', + '-t', "${project.name}:${project.gitBranch}",\ + '-f', 'src/org/jdrupes/vmoperator/manager/Containerfile', '.' +} + +task pushImage(type: Exec) { + dependsOn buildImage + // Don't push without testing first + dependsOn test + + 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 +} + +task pushForTest(type: Exec) { + dependsOn buildImage + + commandLine 'podman', 'push', '--tls-verify=false', \ + "${project.name}:${project.gitBranch}", \ + "${project.rootProject.properties['docker.testRegistry']}" \ + + "/${project.name}:test" +} + +test { + enabled = project.hasProperty("k8s.testCluster") + + if (enabled) { + dependsOn project.tasks["pushForTest"] + } + + useJUnitPlatform() + + testLogging { + showStandardStreams = true + } + + systemProperty "k8s.testCluster", project.hasProperty("k8s.testCluster") + ? project.getProperty("k8s.testCluster") : null +} + +// Update favicon: +// # Convert with inkscape to png because convert cannot handle svg +// # background transparency, then +// convert VM-Operator.png -background transparent \ +// -define icon:auto-resize=256,64,48,32,16 favicon.ico 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/VM-Operator.png b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/VM-Operator.png new file mode 100644 index 0000000..9ecb022 Binary files /dev/null and b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/VM-Operator.png differ diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/VM-Operator.svg b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/VM-Operator.svg new file mode 100644 index 0000000..c8616d5 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/VM-Operator.svg @@ -0,0 +1,184 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..9c9de88 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/console-brand.ftl.html @@ -0,0 +1,5 @@ +${_("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/favicon.ico b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/favicon.ico new file mode 100644 index 0000000..1b9443d Binary files /dev/null and b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/favicon.ico differ 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 new file mode 100644 index 0000000..6bcc3a2 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/l10n.properties @@ -0,0 +1,20 @@ +# +# 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 . +# + +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/jul-debug.properties b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties similarity index 70% rename from org.jdrupes.vmoperator.manager/jul-debug.properties rename to org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties index a013704..2a16af6 100644 --- a/org.jdrupes.vmoperator.manager/jul-debug.properties +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/logging.properties @@ -1,6 +1,6 @@ # -# Ad Hoc Polling Application -# Copyright (C) 2018 Michael N. Lipp +# 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 General Public License as published by @@ -16,13 +16,13 @@ # with this program; if not, see . # +handlers=java.util.logging.ConsoleHandler, \ + org.jgrapes.webconlet.logviewer.LogViewerHandler -handlers=java.util.logging.ConsoleHandler - -org.jgrapes.level=FINE -org.jgrapes.core.handlerTracking.level=FINER - +org.jdrupes.vmoperator.level=FINE java.util.logging.ConsoleHandler.level=ALL java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter -java.util.logging.SimpleFormatter.format=%1$tY-%1$tm-%1$td %5$s%6$s%n +java.util.logging.SimpleFormatter.format=%1$tb %1$td %1$tT %4$s %5$s%6$s%n + +org.jgrapes.webconlet.logviewer.LogViewerHandler.level=CONFIG 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 new file mode 100644 index 0000000..0200021 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerConfig.ftl.yaml @@ -0,0 +1,229 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + namespace: ${ cr.namespace() } + name: ${ cr.name() } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + 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() } + 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.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.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 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 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.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 spec.vm.machineUuid??> + uuid: "${ spec.vm.machineUuid }" + + + # Whether to provide a software TPM (defaults to false) + # useTpm: false + 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: ${ spec.vm.firmware } + + # Whether to show a boot menu. + # bootMenu: false + 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: ${ spec.vm.powerdownTimeout?c } + + # CPU settings + 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 spec.vm.maximumCpus?? > + maximumCpus: ${ parseQuantity(spec.vm.maximumCpus)?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 spec.vm.currentCpus?? > + currentCpus: ${ parseQuantity(spec.vm.currentCpus)?c } + + + # RAM settings + # Maximum defaults to 1G + maximumRam: "${ formatMemory(parseQuantity(spec.vm.maximumRam)) }" + <#if spec.vm.currentRam?? > + currentRam: "${ formatMemory(parseQuantity(spec.vm.currentRam)) }" + + + # RTC settings. + # rtcBase: utc + # rtcClock: rt + rtcBase: ${ spec.vm.rtcBase } + rtcClock: ${ spec.vm.rtcClock } + + # Network settings + # Supported types are "tap" and "user" (for debugging). Type "user" + # supports only the property "net". + # network: + # - type: tap + # bridge: br0 + # device: virtio-net + # mac: (undefined) + network: + <#assign nwCounter = 0/> + <#list spec.vm.networks as itf> + <#if itf.tap??> + - type: tap + device: ${ itf.tap.device } + bridge: ${ itf.tap.bridge } + <#if itf.tap.mac??> + mac: "${ itf.tap.mac }" + + <#elseif itf.user??> + - type: user + device: ${ itf.user.device } + <#if itf.user.net??> + net: "${ itf.user.net }" + + + <#assign nwCounter += 1/> + + + # There are no default drives. The supported types are "ide-cd" + # and "raw". All types support a "bootindex" property. + # Type "raw" can have a property "file" (if backed by a file on + # the host) or a property "device" (if backed by a device). + # drives: + # - type: ide-cd + # bootindex: (undefined) + # file: (undefined) + drives: + <#assign drvCounter = 0/> + <#list spec.vm.disks as disk> + <#if disk.volumeClaimTemplate?? + && disk.volumeClaimTemplate.metadata?? + && disk.volumeClaimTemplate.metadata.name??> + <#assign diskName = disk.volumeClaimTemplate.metadata.name + "-disk"> + <#else> + <#assign diskName = "disk-" + drvCounter> + + <#if disk.volumeClaimTemplate??> + - type: raw + resource: /dev/${ diskName } + <#if disk.bootindex??> + bootindex: ${ disk.bootindex?c } + + <#assign drvCounter = drvCounter + 1/> + + <#if disk.cdrom??> + - type: ide-cd + file: "${ imageLocation(disk.cdrom.image) }" + <#if disk.bootindex??> + bootindex: ${ disk.bootindex?c } + + + + + display: + <#if spec.vm.display.outputs?? > + outputs: ${ spec.vm.display.outputs?c } + + <#if loginRequestedFor?? > + loggedInUser: "${ loginRequestedFor }" + + <#if spec.vm.display.spice??> + spice: + port: ${ spec.vm.display.spice.port?c } + <#if spec.vm.display.spice.ticket??> + ticket: "${ spec.vm.display.spice.ticket }" + + <#if spec.vm.display.spice.streamingVideo??> + streaming-video: "${ spec.vm.display.spice.streamingVideo }" + + usbRedirects: ${ spec.vm.display.spice.usbRedirects?c } + + + logging.properties: | + handlers=java.util.logging.ConsoleHandler + + #org.jgrapes.level=FINE + #org.jgrapes.core.handlerTracking.level=FINER + + org.jdrupes.vmoperator.runner.qemu.level=FINE + + 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 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 new file mode 100644 index 0000000..b7215a5 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/runnerLoadBalancer.ftl.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Service +metadata: + namespace: ${ cr.namespace() } + name: ${ cr.name() } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + 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() } + 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?c } + selector: + app.kubernetes.io/name: ${ constants.APP_NAME } + 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/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 new file mode 100644 index 0000000..0ca6312 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/ConfigMapReconciler.java @@ -0,0 +1,210 @@ +/* + * 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.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; +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.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 config map + */ +/* default */ class ConfigMapReconciler { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + private final Configuration fmConfig; + + /** + * Instantiates a new config map reconciler. + * + * @param fmConfig the fm config + */ + public ConfigMapReconciler(Configuration fmConfig) { + this.fmConfig = fmConfig; + } + + /** + * Reconcile. + * + * @param model the model + * @param channel the channel + * @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 + */ + public void reconcile(Map model, VmChannel channel, + boolean modelChanged) + throws IOException, TemplateException, ApiException { + // 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 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 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) { + } + + /** + * Triggers update of config map mounted in pod + * See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ + * @param client + * + * @param newCm + */ + private void maybeForceUpdate(ApiClient client, + DynamicKubernetesObject newCm) { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_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().isEmpty()) { + return; + } + var pod = pods.getItems().get(0); + + // Patch pod annotation + PatchOptions patchOpts = new PatchOptions(); + patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); + var podMeta = pod.getMetadata(); + var res = podApi.patch(podMeta.getNamespace(), podMeta.getName(), + V1Patch.PATCH_FORMAT_JSON_PATCH, + new V1Patch("[{\"op\": \"replace\", \"path\": " + + "\"/metadata/annotations/vmrunner.jdrupes.org~1cmVersion\", " + + "\"value\": \"" + newCm.getMetadata().getResourceVersion() + + "\"}]"), + patchOpts); + if (!res.isSuccess()) { + logger.warning( + () -> "Cannot patch pod annotations: " + res.getStatus()); + } + } + + 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 new file mode 100644 index 0000000..2ef4199 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Constants.java @@ -0,0 +1,35 @@ +/* + * 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; + +/** + * Some constants. + */ +public class Constants extends org.jdrupes.vmoperator.common.Constants { + + /** The Constant STATE_RUNNING. */ + public static final String STATE_RUNNING = "Running"; + + /** The Constant STATE_STOPPED. */ + public static final String STATE_STOPPED = "Stopped"; + + /** The Constant IMAGE_REPO_PATH. */ + public static final String IMAGE_REPO_PATH + = "/var/local/vmop-image-repository"; +} diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile new file mode 100644 index 0000000..08c4bff --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Containerfile @@ -0,0 +1,5 @@ +FROM docker.io/eclipse-temurin:21-jre-alpine + +COPY build/install/vm-manager /opt/vmmanager + +CMD ["/opt/vmmanager/bin/vm-manager"] 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 new file mode 100644 index 0000000..ce14488 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Controller.java @@ -0,0 +1,328 @@ +/* + * 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.openapi.ApiException; +import io.kubernetes.client.openapi.Configuration; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Comparator; +import java.util.Optional; +import java.util.logging.Level; +import 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.util.events.ConfigurationUpdate; + +/** + * Implements a controller as defined in the + * [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 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. + * + * The controller itself supports a single configuration property: + * ```yaml + * "/Manager": + * "/Controller": + * namespace: vmop-dev + * ``` + * This may only be set when running the Manager (and thus the Controller) + * outside a container during development. + * + * ![Controller components](controller-components.svg) + * + * @startuml controller-components.svg + * skinparam component { + * BackGroundColor #FEFECE + * BorderColor #A80036 + * BorderThickness 1.25 + * BackgroundColor<> #F1F1F1 + * BorderColor<> #181818 + * BorderThickness<> 1 + * } + * + * [Controller] + * [Controller] *--> [VmWatcher] + * [Controller] *--> [Reconciler] + * @enduml + */ +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 + 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)); + } + + /** + * Special handling of {@link ApiException} thrown by handlers. + * + * @param event the event + */ + @Handler(channels = Channel.class) + public void onHandlingError(HandlingError event) { + if (event.throwable() instanceof ApiException exc) { + logger.log(Level.WARNING, exc, + () -> "Problem accessing kubernetes: " + exc.getResponseBody()); + event.stop(); + } + } + + /** + * Configure the component. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()).ifPresent(c -> { + if (c.containsKey("namespace")) { + namespace = (String) c.get("namespace"); + } + }); + } + + /** + * Handle the start event. Has higher priority because it configures + * the default Kubernetes client. + * + * @param event the event + * @throws IOException + * @throws ApiException + */ + @Handler(priority = 100) + public void onStart(Start event) throws IOException, ApiException { + // Make sure to use thread specific client + // https://github.com/kubernetes-client/java/issues/100 + Configuration.setDefaultApiClient(null); + + // Verify that a namespace has been configured + 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); + 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 Exit(2)); + return; + } + logger.config(() -> "Controlling namespace \"" + namespace + "\"."); + } + + /** + * Returns the VM data. + * + * @param event the event + */ + @Handler + public void onGetVms(GetVms event) { + event.setResult(chanMgr.channels().stream() + .filter(c -> event.name().isEmpty() + || c.vmDefinition().name().equals(event.name().get())) + .filter(c -> event.user().isEmpty() && event.roles().isEmpty() + || !c.vmDefinition().permissionsFor(event.user().orElse(null), + event.roles()).isEmpty()) + .filter(c -> event.fromPool().isEmpty() + || c.vmDefinition().assignment().map(Assignment::pool) + .map(p -> p.equals(event.fromPool().get())).orElse(false)) + .filter(c -> event.toUser().isEmpty() + || c.vmDefinition().assignment().map(Assignment::user) + .map(u -> u.equals(event.toUser().get())).orElse(false)) + .map(c -> new VmData(c.vmDefinition(), c)) + .toList()); + } + + /** + * Assign a VM if not already assigned. + * + * @param event the event + * @throws ApiException the api exception + * @throws InterruptedException + */ + @Handler + @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 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; + } + 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 new file mode 100644 index 0000000..a66b432 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/LoadBalancerReconciler.java @@ -0,0 +1,193 @@ +/* + * 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.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.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.K8sV1ServiceStub; +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 service + */ +/* default */ class LoadBalancerReconciler { + + private static final String LOAD_BALANCER_SERVICE = "loadBalancerService"; + private static final String METADATA + = V1APIService.SERIALIZED_NAME_METADATA; + private static final String LABELS = V1ObjectMeta.SERIALIZED_NAME_LABELS; + private static final String ANNOTATIONS + = V1ObjectMeta.SERIALIZED_NAME_ANNOTATIONS; + protected final Logger logger = Logger.getLogger(getClass().getName()); + private final Configuration fmConfig; + + /** + * Instantiates a new service reconciler. + * + * @param fmConfig the fm config + */ + public LoadBalancerReconciler(Configuration fmConfig) { + this.fmConfig = fmConfig; + } + + /** + * Reconcile. + * + * @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; + } + + // 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 " + + 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); + // Avoid Yaml.load due to + // https://github.com/kubernetes-client/java/issues/2741 + var svcDef = Dynamics.newFromYaml( + new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); + @SuppressWarnings("unchecked") + var defaults = lbsDef instanceof Map + ? (Map>) lbsDef + : null; + var client = channel.client(); + mergeMetadata(client.getJSON().getGson(), svcDef, defaults, vmDef); + + // Apply + var svcStub = K8sV1ServiceStub + .get(client, vmDef.namespace(), vmDef.name()); + if (svcStub.apply(svcDef).isEmpty()) { + logger.warning( + () -> "Could not patch service for " + svcStub.name()); + } + } + + private void mergeMetadata(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() == null) { + dest.remove(e.getKey()); + continue; + } + dest.put(e.getKey(), e.getValue()); + } + return dest; + } + + 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.containsKey(e.getKey())) { + continue; + } + 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 7eee721..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,37 +18,270 @@ 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; +import java.net.InetSocketAddress; +import java.net.URI; +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; +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.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; import org.jgrapes.core.Components; +import org.jgrapes.core.NamedChannel; import org.jgrapes.core.annotation.Handler; -import org.jgrapes.core.events.Start; +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.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; +import org.jgrapes.webconsole.base.KVStoreBasedConsolePolicy; +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; - public Manager() throws IOException { - // Attach a general nio dispatcher + /** + * Instantiates a new manager. + * @param cmdLine + * + * @throws IOException Signals that an I/O exception has occurred. + * @throws URISyntaxException + */ + @SuppressWarnings({ "PMD.NcssCount", + "PMD.ConstructorCallsOverridableMethod" }) + public Manager(CommandLine cmdLine) throws IOException, URISyntaxException { + super(new NamedChannel("manager")); + // Prepare component tree attach(new NioDispatcher()); + 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")); + 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(channel(), cfgFile, false)); + fire(new WatchFile(cfgFile.toPath()), channel()); + + // Prepare GUI + Channel httpTransport = new NamedChannel("guiTransport"); + attach(new SocketServer(httpTransport) + .setConnectionLimiter(new PermitsPool(300)) + .setMinimalPurgeableTime(1000) + .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. + HttpServer guiHttpServer = attach(new HttpServer(httpChannel, + httpTransport, Request.In.Get.class, Request.In.Post.class)); + guiHttpServer.setName("GuiHttpServer"); + + // Build HTTP application layer + guiHttpServer.attach(new InMemorySessionManager(httpChannel)); + guiHttpServer.attach(new LanguageSelector(httpChannel)); + URI rootUri; + try { + rootUri = new URI("/"); + } catch (URISyntaxException e) { + // Cannot happen + return; + } + ConsoleWeblet consoleWeblet = guiHttpServer + .attach(new VueJsConsoleWeblet(httpChannel, SELF, rootUri) { + @Override + protected Map createConsoleBaseModel() { + return augmentBaseModel(super.createConsoleBaseModel()); + } + }) + .prependClassTemplateLoader(getClass()) + .prependResourceBundleProvider(getClass()) + .prependConsoleResourceProvider(getClass()); + consoleWeblet.setName("ConsoleWeblet"); + WebConsole console = consoleWeblet.console(); + 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 -> { + if (LoginConlet.class.getName().equals(type)) { + // Explicitly added, see above + return Collections.emptyList(); + } else { + return Arrays.asList(Collections.emptyMap()); + } + })); + } + + 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; } /** - * Handle the start event. + * Configure the component. * * @param event the event */ @Handler - public void onStart(Start event) { - System.out.println("Hello World!"); + 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. + * + * @param event the event + */ + @Handler(channels = Channel.class, priority = -10_000) + @SuppressWarnings("PMD.GuardLogStatement") + public void onHandlingError(HandlingError event) { + logger.log(Level.WARNING, event.throwable(), + () -> "Problem invoking handler with " + event.event() + ": " + + event.message()); + event.stop(); + } + + /** + * On exit. + * + * @param event the event + */ @Handler + public void onExit(Exit event) { + exitStatus = event.exitStatus(); + } + + /** + * On stop. + * + * @param event the event + */ + @Handler(priority = -1000) public void onStop(Stop event) { - System.out.println("(Done.)"); + logger.info(() -> "Application stopped."); + } + + static { + try { + // Get logging properties from file and put them in effect + InputStream props; + var path = FsdUtils.findConfigFile(VM_OP_NAME.replace("-", ""), + "logging.properties"); + if (path.isPresent()) { + props = Files.newInputStream(path.get()); + } else { + props + = Manager.class.getResourceAsStream("logging.properties"); + } + LogManager.getLogManager().readConfiguration(props); + } catch (IOException e) { + e.printStackTrace(); // NOPMD + } } /** @@ -57,23 +290,50 @@ public class Manager extends Component { * @param args the arguments * @throws Exception the exception */ - @SuppressWarnings("PMD.SignatureDeclareThrowsException") - public static void main(String[] args) throws Exception { - // The Manager is the root component - app = new Manager(); + public static void main(String[] args) { + try { + // Instance logger is not available yet. + var logger = Logger.getLogger(Manager.class.getName()); + 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") + ")" + + " from " + System.getProperty("java.vm.vendor")); - // Prepare Stop - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - try { - app.fire(new Stop(), Channel.BROADCAST); - Components.awaitExhaustion(); - } catch (InterruptedException e) { - // Cannot do anything about this. - } - })); + // Parse the command line arguments + CommandLineParser parser = new DefaultParser(); + final Options options = new Options(); + options.addOption(new Option("c", "config", true, "The configura" + + "tion file (defaults to /etc/opt/vmoperator/config.yaml).")); + CommandLine cmd = parser.parse(options, args); - // Start application - Components.start(app); + // The Manager is the root component + app = new Manager(cmd); + + // Prepare generation of Stop event + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + app.fire(new Stop()); + Components.awaitExhaustion(); + } catch (InterruptedException e) { // NOPMD + // Cannot do anything about this. + } + })); + + // Start the application + Components.start(app); + + // 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 new file mode 100644 index 0000000..e580c48 --- /dev/null +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -0,0 +1,403 @@ +/* + * 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.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import freemarker.template.AdapterTemplateModel; +import freemarker.template.Configuration; +import freemarker.template.SimpleNumber; +import freemarker.template.SimpleScalar; +import freemarker.template.TemplateException; +import freemarker.template.TemplateExceptionHandler; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModelException; +import io.kubernetes.client.custom.Quantity; +import io.kubernetes.client.openapi.ApiException; +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.VmResourceChanged; +import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.util.events.ConfigurationUpdate; + +/** + * Adapts Kubenetes resources for instances of the Runner + * application (the VMs) to changes in VM definitions (the CRs). + * + * In particular, the reconciler generates and updates: + * + * * A [`PVC`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) + * for storage used by all VMs as a common repository for CDROM images. + * + * * A [`ConfigMap`](https://kubernetes.io/docs/concepts/configuration/configmap/) + * that defines the configuration file for the runner. + * + * * 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 + * "/Manager": + * "/Controller": + * "/Reconciler": + * ... + * ``` + * + * The reconciler supports the following configuration properties: + * + * * `runnerDataPvc.storageClassName`: The storage class name + * to be used for the "runnerDataPvc" (the small volume used + * by the runner for information such as the EFI variables). By + * default, no `storageClassName` is generated, which causes + * Kubernetes to use storage from the default storage class. + * Define this if you want to use a specific storage class. + * + * * `cpuOvercommit`: The amount by which the current cpu count + * from the VM definition is divided when generating the + * [`resources`](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources) + * properties for the VM (defaults to 2). + * + * * `ramOvercommit`: The amount by which the current ram size + * from the VM definition is divided when generating the + * [`resources`](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources) + * properties for the VM (defaults to 1.25). + * + * * `loadBalancerService`: If defined, causes a load balancer service + * to be created. This property may be a boolean or + * YAML that defines additional labels or annotations to be merged + * into the service defintion. Here's an example for using + * [MetalLb](https://metallb.universe.tf/) as "internal load balancer": + * ```yaml + * loadBalancerService: + * 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 + * ``` + * 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.AvoidDuplicateLiterals" }) +public class Reconciler extends Component { + + /** The Constant mapper. */ + @SuppressWarnings("PMD.FieldNamingConventions") + protected static final ObjectMapper mapper = new ObjectMapper(); + + private final Configuration fmConfig; + private final ConfigMapReconciler cmReconciler; + private final DisplaySecretReconciler dsReconciler; + private final PvcReconciler pvcReconciler; + private final PodReconciler podReconciler; + private final LoadBalancerReconciler lbReconciler; + @SuppressWarnings("PMD.UseConcurrentHashMap") + private final Map config = new HashMap<>(); + + /** + * Instantiates a new reconciler. + * + * @param componentChannel the component channel + */ + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public Reconciler(Channel componentChannel) { + super(componentChannel); + + // Configure freemarker library + fmConfig = new Configuration(Configuration.VERSION_2_3_32); + fmConfig.setDefaultEncoding("utf-8"); + fmConfig.setObjectWrapper(new ExtendedObjectWrapper( + fmConfig.getIncompatibleImprovements())); + fmConfig.setTemplateExceptionHandler( + TemplateExceptionHandler.RETHROW_HANDLER); + fmConfig.setLogTemplateExceptions(false); + fmConfig.setClassForTemplateLoading(Reconciler.class, ""); + + cmReconciler = new ConfigMapReconciler(fmConfig); + dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); + pvcReconciler = new PvcReconciler(fmConfig); + podReconciler = new PodReconciler(fmConfig); + lbReconciler = new LoadBalancerReconciler(fmConfig); + } + + /** + * Configures the component. + * + * @param event the event + */ + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()).ifPresent(c -> { + config.putAll(c); + }); + } + + /** + * Handles the change event. + * + * @param event the event + * @param channel the channel + * @throws ApiException the api exception + * @throws TemplateException the template exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) + throws ApiException, TemplateException, IOException { + // Ownership relationships takes care of deletions + if (event.type() == K8sObserver.ResponseType.DELETED) { + return; + } + + // Create model for processing templates + var vmDef = event.vmDefinition(); + Map model = prepareModel(vmDef); + cmReconciler.reconcile(model, channel, event.specChanged()); + + // The remaining reconcilers depend only on changes of the spec part + // or the pod state. + if (!event.specChanged() && !event.podChanged()) { + return; + } + dsReconciler.reconcile(vmDef, model, channel, event.specChanged()); + pvcReconciler.reconcile(vmDef, model, channel, event.specChanged()); + podReconciler.reconcile(vmDef, model, channel, event.specChanged()); + lbReconciler.reconcile(vmDef, model, channel, event.specChanged()); + } + + /** + * Reset the VM by incrementing the reset count and doing a + * partial reconcile (configmap only). + * + * @param event the event + * @param channel the channel + * @throws IOException + * @throws ApiException + * @throws TemplateException + */ + @Handler + public void onResetVm(ResetVm event, VmChannel channel) + throws ApiException, IOException, TemplateException { + var vmDef = channel.vmDefinition(); + var extra = vmDef.extra(); + extra.resetCount(extra.resetCount() + 1); + Map model + = prepareModel(channel.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); + model.put("reconciler", config); + model.put("constants", constantsMap(Constants.class)); + addLoginRequestedFor(model, vmDef); + + // Methods + 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 SimpleNumber number) { + return number.getAsNumber(); + } + try { + return Quantity.fromString(arg.toString()).getNumber(); + } catch (NumberFormatException e) { + throw new TemplateModelException("Cannot parse memory " + + "specified as \"" + arg + "\": " + e.getMessage()); + } + } + }; + + private final TemplateMethodModelEx formatMemoryModel + = new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) + throws TemplateModelException { + var arg = arguments.get(0); + if (arg instanceof SimpleNumber number) { + arg = number.getAsNumber(); + } + BigInteger bigInt; + if (arg instanceof BigInteger value) { + bigInt = value; + } else if (arg instanceof BigDecimal dec) { + try { + bigInt = dec.toBigIntegerExact(); + } catch (ArithmeticException e) { + return arg; + } + } else if (arg instanceof Integer value) { + bigInt = BigInteger.valueOf(value); + } else if (arg instanceof Long value) { + bigInt = BigInteger.valueOf(value); + } else { + return arg; + } + return Convertions.formatMemory(bigInt); + } + }; + + 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/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/package-info.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/package-info.java index 6de13e3..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 @@ -16,4 +16,179 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.manager; \ No newline at end of file +/** + * The following diagram shows the components of the manager application. + * + * In framework terms, the {@link org.jdrupes.vmoperator.manager.Manager} + * is the root component of the application. Two of its child components, + * the {@link org.jdrupes.vmoperator.manager.Controller} and the WebGui + * provide the functions that are apparent to the user. + * + * The position of the components in the component tree is important + * when writing the configuration file for the manager and therefore + * shown below. In order to keep the diagram readable, the + * components attached to the + * {@link org.jgrapes.webconsole.base.WebConsole} are shown in a + * separate diagram further down. + * + * ![Manager components](manager-components.svg) + * + * Component hierarchy of the web console: + * + * ![Web console components](console-components.svg) + * + * The components marked as "<<internal>>" have no + * configuration options or use their default values when used + * in this application. + * + * As an example, the following YAML configures a different port for the + * GUI and some users. The relationship to the component tree should + * be obvious. + * ``` + * "/Manager": + * "/GuiSocketServer": + * port: 8888 + * "/GuiHttpServer": + * "/ConsoleWeblet": + * "/WebConsole": + * "/LoginConlet": + * users: + * ... + * ``` + * + * Developers may also be interested in the usage of channels + * by the application's components: + * + * ![Main channels](app-channels.svg) + * + * @startuml manager-components.svg + * skinparam component { + * BackGroundColor #FEFECE + * BorderColor #A80036 + * BorderThickness 1.25 + * BackgroundColor<> #F1F1F1 + * BorderColor<> #181818 + * BorderThickness<> 1 + * } + * skinparam packageStyle rectangle + * + * 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 <> + * [Manager] *-left- [YamlConfigurationStore] + * [YamlConfigurationStore] *-right[hidden]- [Controller] + * + * [Manager] *-- [Controller] + * 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] + * [Manager] *-- [GuiHttpServer] + * Component PreferencesStore as PreferencesStore <> + * [GuiHttpServer] *-up- [PreferencesStore] + * Component InMemorySessionManager as InMemorySessionManager <> + * [GuiHttpServer] *-up- [InMemorySessionManager] + * Component LanguageSelector as LanguageSelector <> + * [GuiHttpServer] *-right- [LanguageSelector] + * + * package "Conceptual WebConsole" { + * [ConsoleWeblet] *-- [WebConsole] + * } + * [GuiHttpServer] *-- [ConsoleWeblet] + * @enduml + * + * @startuml console-components.svg + * skinparam component { + * BackGroundColor #FEFECE + * BorderColor #A80036 + * BorderThickness 1.25 + * BackgroundColor<> #F1F1F1 + * BorderColor<> #181818 + * BorderThickness<> 1 + * } + * skinparam packageStyle rectangle + * + * Component BrowserLocalBackedKVStore as BrowserLocalBackedKVStore <> + * [WebConsole] *-up- [BrowserLocalBackedKVStore] + * Component KVStoreBasedConsolePolicy as KVStoreBasedConsolePolicy <> + * [WebConsole] *-up- [KVStoreBasedConsolePolicy] + * + * [WebConsole] *-- [RoleConfigurator] + * [WebConsole] *-- [RoleConletFilter] + * [WebConsole] *-left- [LoginConlet] + * [WebConsole] *-right- [OidcClient] + * + * Component "ComponentCollector\nfor page resources" as cpr <> + * [WebConsole] *-- [cpr] + * Component "ComponentCollector\nfor conlets" as cc <> + * [WebConsole] *-- [cc] + * + * package "Providers and Conlets" { + * [Some component] + * } + * + * [cpr] *-- [Some component] + * [cc] *-- [Some component] + * @enduml + * + * @startuml app-channels.svg + * skinparam packageStyle rectangle + * + * () "manager" as mgr + * 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] .. http + * [OidcClient] .up. http + * [LanguageSelector] .left. http + * [InMemorySessionManager] .up. http + * + * package "Conceptual WebConsole" { + * [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/org/jdrupes/vmoperator/manager/BasicTests.java b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java new file mode 100644 index 0000000..d600d3c --- /dev/null +++ b/org.jdrupes.vmoperator.manager/test/org/jdrupes/vmoperator/manager/BasicTests.java @@ -0,0 +1,341 @@ +package org.jdrupes.vmoperator.manager; + +import io.kubernetes.client.Discovery.APIResource; +import io.kubernetes.client.custom.Quantity; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.generic.options.ListOptions; +import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.FileReader; +import java.io.IOException; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import 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; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +class BasicTests { + + private static K8sClient client; + private static APIResource vmsContext; + private static K8sV1DeploymentStub mgrDeployment; + private static K8sDynamicStub vmStub; + private static final String VM_NAME = "test-vm"; + private static final Object EXISTS = new Object(); + + @BeforeAll + static void setUpBeforeClass() throws Exception { + var testCluster = System.getProperty("k8s.testCluster"); + assertNotNull(testCluster); + + // Get client + client = new K8sClient(); + + // Update manager pod by scaling deployment + mgrDeployment + = K8sV1DeploymentStub.get(client, "vmop-test", "vm-operator"); + mgrDeployment.scale(0); + mgrDeployment.scale(1); + waitForManager(); + + // Context for working with our CR + var apiRes = K8s.context(client, 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 (mgrDeployment.model().get().getStatus().getConditions() + .stream().filter(c -> "Available".equals(c.getType())).findAny() + .isPresent()) { + return; + } + Thread.sleep(1000); + } + fail("vm-operator not deployed."); + } + + private static void deletePvcs() throws ApiException { + ListOptions listOpts = new ListOptions(); + listOpts.setLabelSelector( + "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + + "app.kubernetes.io/name=" + APP_NAME + "," + + "app.kubernetes.io/instance=" + VM_NAME); + var knownPvcs = K8sV1PvcStub.list(client, "vmop-test", listOpts); + for (var pvc : knownPvcs) { + pvc.delete(); + } + } + + @AfterAll + static void tearDownAfterClass() throws Exception { + // Cleanup + K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) + .delete(); + deletePvcs(); + + // Bring down manager + mgrDeployment.scale(0); + } + + @Test + void 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 = stub.model().get(); + Map, Object> toCheck = Map.of( + List.of("namespace"), "vmop-test", + List.of("name"), VM_NAME, + List.of("labels", "app.kubernetes.io/name"), Constants.APP_NAME, + List.of("labels", "app.kubernetes.io/instance"), VM_NAME, + List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, + List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, + List.of("ownerReferences", 0, "apiVersion"), + vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), + List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, + List.of("ownerReferences", 0, "name"), VM_NAME, + List.of("ownerReferences", 0, "uid"), EXISTS); + checkProps(config.getMetadata(), toCheck); + + toCheck = new LinkedHashMap<>(); + toCheck.put(List.of("/Runner", "guestShutdownStops"), false); + toCheck.put(List.of("/Runner", "cloudInit", "metaData", "instance-id"), + EXISTS); + toCheck.put( + List.of("/Runner", "cloudInit", "metaData", "local-hostname"), + VM_NAME); + toCheck.put(List.of("/Runner", "cloudInit", "userData"), Map.of()); + toCheck.put(List.of("/Runner", "vm", "maximumRam"), "4 GiB"); + toCheck.put(List.of("/Runner", "vm", "currentRam"), "2 GiB"); + toCheck.put(List.of("/Runner", "vm", "maximumCpus"), 4); + toCheck.put(List.of("/Runner", "vm", "currentCpus"), 2); + toCheck.put(List.of("/Runner", "vm", "powerdownTimeout"), 1); + toCheck.put(List.of("/Runner", "vm", "network", 0, "type"), "user"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "file"), + "https://test.com/test.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 0, "bootindex"), 0); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "type"), "ide-cd"); + toCheck.put(List.of("/Runner", "vm", "drives", 1, "file"), + "/var/local/vmop-image-repository/image.iso"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 2, "resource"), + "/dev/system-disk"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "type"), "raw"); + toCheck.put(List.of("/Runner", "vm", "drives", 3, "resource"), + "/dev/disk-1"); + toCheck.put(List.of("/Runner", "vm", "display", "outputs"), 2); + toCheck.put(List.of("/Runner", "vm", "display", "spice", "port"), 5812); + toCheck.put( + List.of("/Runner", "vm", "display", "spice", "usbRedirects"), 2); + var cm = new Yaml(new SafeConstructor(new LoaderOptions())) + .load(config.getData().get("config.yaml")); + checkProps(cm, toCheck); + } + + @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++) { + secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); + if (secrets.size() > 0) { + break; + } + Thread.sleep(1000); + } + 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"))); + } + + @Test + void testRunnerPvc() throws ApiException, InterruptedException { + var stub + = K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-runner-data"); + for (int i = 0; i < 10; i++) { + if (stub.model().isPresent()) { + 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("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/.checkstyle b/org.jdrupes.vmoperator.runner.qemu/.checkstyle index 2add381..7f2c604 100644 --- a/org.jdrupes.vmoperator.runner.qemu/.checkstyle +++ b/org.jdrupes.vmoperator.runner.qemu/.checkstyle @@ -1,10 +1,10 @@ - + - + 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/.gitignore b/org.jdrupes.vmoperator.runner.qemu/.gitignore index 3c9dbbf..c43bf05 100644 --- a/org.jdrupes.vmoperator.runner.qemu/.gitignore +++ b/org.jdrupes.vmoperator.runner.qemu/.gitignore @@ -1,3 +1,4 @@ config.yaml /config-w11.yaml /config-fedora.yaml +/config-test-vm.yaml diff --git a/org.jdrupes.vmoperator.runner.qemu/.settings/net.sf.jautodoc.prefs b/org.jdrupes.vmoperator.runner.qemu/.settings/net.sf.jautodoc.prefs new file mode 100644 index 0000000..6f3b6d4 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/.settings/net.sf.jautodoc.prefs @@ -0,0 +1,8 @@ +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 */ +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/org.jdrupes.vmoperator.runner.qemu/build.gradle b/org.jdrupes.vmoperator.runner.qemu/build.gradle index ea5477a..695c815 100644 --- a/org.jdrupes.vmoperator.runner.qemu/build.gradle +++ b/org.jdrupes.vmoperator.runner.qemu/build.gradle @@ -9,69 +9,121 @@ 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.28.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 'org.freemarker:freemarker:[2.3.32,2.4)' - implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:[2.15.1,3]' + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:[2.16.1]' - implementation project(':org.jdrupes.vmoperator.util') - - runtimeOnly 'com.electronwill.night-config:yaml:3.6.6' + runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)' } application { applicationName = 'vm-runner.qemu' - applicationDefaultJvmArgs = ['-Xms50m', + // Keep sync'd with deploy/vmop-deployment.yaml + applicationDefaultJvmArgs = ['-Xmx32m', '-XX:+UseParallelGC', '-Djava.util.logging.manager=org.jdrupes.vmoperator.util.LongLoggingManager' ] // Define the main class for the application. mainClass = 'org.jdrupes.vmoperator.runner.qemu.Runner' } -task buildArchContainer(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}:${project.version}",\ + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}-arch:${project.gitBranch}",\ '-f', 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.arch', '.' } -task tagArchContainer(type: Exec) { - dependsOn buildArchContainer +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") - commandLine 'podman', 'tag', "${project.name}:${project.version}",\ - "${project.name}:latest" + commandLine 'podman', 'push', \ + "${project.name}-arch:${project.gitBranch}",\ + "${registry}/${project.name}-arch:${project.version}" } -task buildContainer { - dependsOn buildArchContainer - dependsOn tagArchContainer +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 pushArchContainer(type: Exec) { - dependsOn buildContainer +task buildImageAlpine(type: Exec) { + dependsOn installDist + inputs.files 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine' + + commandLine 'podman', 'build', '--pull', + '-t', "${project.name}-alpine:${project.gitBranch}",\ + '-f', 'src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine', '.' +} + +task pushImageAlpine(type: Exec) { + dependsOn buildImageAlpine commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-arch:${project.version}" + "localhost/${project.name}-alpine:${project.gitBranch}", \ + "${registry}/${project.name}-alpine:${project.gitBranch}" } -task pushArchContainerAsLatest(type: Exec) { - dependsOn buildContainer +task tagWithVersionAlpine(type: Exec) { + dependsOn pushImageAlpine - commandLine 'podman', 'push', '--tls-verify=false', \ - "localhost/${project.name}:${project.version}", \ - "${project.rootProject.properties['docker.registry']}" \ - + "/${project.name}-arch:latest" + enabled = !rootVersion.contains("SNAPSHOT") + + commandLine 'podman', 'push', \ + "${project.name}-alpine:${project.gitBranch}",\ + "${registry}/${project.name}-alpine:${project.version}" } -task pushContainer { - dependsOn pushArchContainer - dependsOn pushArchContainerAsLatest +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 publishImage { + dependsOn pushImageArch + dependsOn tagWithVersionArch + dependsOn tagAsLatestArch + dependsOn pushImageAlpine + dependsOn tagWithVersionAlpine + dependsOn tagAsLatestAlpine +} diff --git a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml index a798b6e..e23a2ec 100644 --- a/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/config-sample.yaml @@ -22,6 +22,36 @@ # the first time. Subsequent starts use the copy unless this option is set. # "updateTemplate": false + # The namespace that this runner runs in. Usually obtained from + # /var/run/secrets/kubernetes.io/serviceaccount/namespace. Should only + # 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) @@ -83,13 +113,18 @@ # There are no default drives. The supported types are "ide-cd" # and "raw". All types support a "bootindex" property. # Type "raw" can have a property "file" (if backed by a file on - # the host) or a property "device" (if backed by a device). + # the host) or a property "device" (if backed by a device). + # Alternatively you can use "resource", which automatically be + # interpreted as file or device as appropriate + # (see https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/file/Files.html#isRegularFile(java.nio.file.Path,java.nio.file.LinkOption...)). # "drives": # - "type": "ide-cd" # "bootindex": (undefined) - # "file": (undefined) + # "resource": (undefined) - # "spice": - # "port": 5900 - # "usbRedirects": 2 - \ No newline at end of file + # "display": + # "spice": + # "port": 5900 + # "ticket": (default is to disable ticketing) + # "streamingVideo": (default) + # "usbRedirects": 2 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/helm-test/templates/etcConfig.yaml b/org.jdrupes.vmoperator.runner.qemu/helm-test/templates/etcConfig.yaml index 6759d92..1417241 100644 --- a/org.jdrupes.vmoperator.runner.qemu/helm-test/templates/etcConfig.yaml +++ b/org.jdrupes.vmoperator.runner.qemu/helm-test/templates/etcConfig.yaml @@ -113,10 +113,17 @@ data: - "type": "raw" "device": "/dev/disk-{{ $index }}" {{- end }} - - "spice": - "port": {{ .Values.vm.spicePort }} - # "usbRedirects": 2 + + "display": + "spice": + "port": {{ .Values.vm.spicePort }} + {{- if hasKey .Values.vm "spiceTicket" }} + "ticket": "{{ .Values.vm.spiceTicket }}" + {{- end }} + {{- if hasKey .Values.vm "streamingVideo" }} + "streamingVideo": "{{ .Values.vm.streamingVideo }}" + {{- end }} + # "usbRedirects": 2 logging.properties: | handlers=java.util.logging.ConsoleHandler diff --git a/org.jdrupes.vmoperator.runner.qemu/logging.properties b/org.jdrupes.vmoperator.runner.qemu/logging.properties index 6b0542d..1cf84fe 100644 --- a/org.jdrupes.vmoperator.runner.qemu/logging.properties +++ b/org.jdrupes.vmoperator.runner.qemu/logging.properties @@ -19,8 +19,8 @@ handlers=java.util.logging.ConsoleHandler -#org.jgrapes.level=FINE -#org.jgrapes.core.handlerTracking.level=FINER +org.jgrapes.level=FINE +org.jgrapes.core.handlerTracking.level=FINER org.jdrupes.vmoperator.runner.qemu.level=FINE 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 70de565..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 @@ -7,9 +7,17 @@ "rom": - "/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 new file mode 100644 index 0000000..c4ac871 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CdMediaController.java @@ -0,0 +1,122 @@ +/* + * 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; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +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.RunnerStateChange.RunState; +import org.jdrupes.vmoperator.runner.qemu.events.TrayMovedEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; + +/** + * The Class CdMediaController. + */ +public class CdMediaController extends Component { + + /** + * The Enum TrayState. + */ + public enum TrayState { + OPEN, CLOSED + } + + private final Map trayState = new ConcurrentHashMap<>(); + private final Map current = new ConcurrentHashMap<>(); + private final Map pending = new ConcurrentHashMap<>(); + + /** + * Instantiates a new cdrom controller. + * + * @param componentChannel the component channel + */ + public CdMediaController(Channel componentChannel) { + super(componentChannel); + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) + public void onConfigureQemu(ConfigureQemu event) { + if (event.runState() == RunState.TERMINATING) { + return; + } + + // Compare known and desired images. + int cdCounter = 0; + var drives = event.configuration().vm.drives; + for (int i = 0; i < drives.length; i++) { + if (!"ide-cd".equals(drives[i].type)) { + continue; + } + var driveId = "cd" + cdCounter++; + var newFile = Optional.ofNullable(drives[i].file).orElse(""); + if (event.runState() == RunState.STARTING) { + current.put(driveId, newFile); + continue; + } + if (!Objects.equals(current.get(driveId), newFile)) { + pending.put(driveId, newFile); + if (trayState.computeIfAbsent(driveId, + k -> TrayState.CLOSED) == TrayState.CLOSED) { + fire(new MonitorCommand(new QmpOpenTray(driveId))); + continue; + } + changeMedium(driveId); + } + } + + } + + private void changeMedium(String driveId) { + current.put(driveId, pending.get(driveId)); + if (pending.get(driveId).isEmpty()) { + fire(new MonitorCommand(new QmpRemoveMedium(driveId))); + } else { + fire(new MonitorCommand( + new QmpChangeMedium(driveId, pending.get(driveId)))); + } + } + + /** + * On monitor event. + * + * @param event the event + */ + @Handler + public void onTrayMovedEvent(TrayMovedEvent event) { + 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 5b6fae8..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 @@ -23,117 +23,154 @@ import java.math.BigInteger; 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.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.regex.Pattern; +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; /** * The configuration information from the configuration file. */ -@SuppressWarnings("PMD.ExcessivePublicCount") -class Configuration implements Dto { - @SuppressWarnings("PMD.FieldNamingConventions") +public class Configuration implements Dto { + private static final String CI_INSTANCE_ID = "instance-id"; + protected final Logger logger = Logger.getLogger(getClass().getName()); - @SuppressWarnings({ "PMD.UseConcurrentHashMap", - "PMD.FieldNamingConventions", "PMD.VariableNamingConventions" }) - private static final Map unitMap = new HashMap<>(); - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) - private static final Pattern memorySize - = Pattern.compile("\\s*(\\d+)\\s*([^\\s]*)"); - - static { - // SI units and common abbreviations - BigInteger factor = BigInteger.ONE; - unitMap.put("", factor); - BigInteger scale = BigInteger.valueOf(1000); - for (var unit : List.of("B", "kB", "MB", "GB", "TB", "PB", "EB")) { - unitMap.put(unit, factor); - factor = factor.multiply(scale); - } - // Binary units - factor = BigInteger.valueOf(1024); - scale = BigInteger.valueOf(1024); - for (var unit : List.of("KiB", "MiB", "GiB", "TiB", "PiB", "EiB")) { - unitMap.put(unit, factor); - unitMap.put(unit.substring(0, 2), factor); - factor = factor.multiply(scale); - } - } + /** Configuration timestamp. */ + public Instant asOf; + /** The data dir. */ public Path dataDir; + + /** The runtime dir. */ public Path runtimeDir; + + /** The template. */ public String template; + + /** The update template. */ public boolean updateTemplate; + + /** The swtpm socket. */ public Path swtpmSocket; + + /** The monitor socket. */ public Path monitorSocket; + + /** The firmware rom. */ public Path firmwareRom; + + /** 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; /** - * Parses a memory size specification. - * - * @param amount the amount - * @return the big integer + * Subsection "cloud-init". */ - public static BigInteger parseMemory(Object amount) { - if (amount == null) { - return (BigInteger) amount; - } - if (amount instanceof BigInteger number) { - return number; - } - if (amount instanceof Number number) { - return BigInteger.valueOf(number.longValue()); - } - var matcher = memorySize.matcher(amount.toString()); - if (!matcher.matches()) { - throw new NumberFormatException(amount.toString()); - } - var unit = unitMap.get(matcher.group(2)); - if (unit == null) { - throw new NumberFormatException(amount.toString()); - } - var number = matcher.group(1); - return new BigInteger(number).multiply(unit); + 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. */ public String name; + + /** The uuid. */ public String uuid; + + /** The use tpm. */ public boolean useTpm; + + /** The boot menu. */ public boolean bootMenu; + + /** The firmware. */ public String firmware = "uefi"; + + /** The maximum ram. */ public BigInteger maximumRam; + + /** The current ram. */ public BigInteger currentRam; + + /** The cpu model. */ public String cpuModel = "host"; + + /** The maximum cpus. */ public int maximumCpus = 1; + + /** The current cpus. */ public int currentCpus = 1; - public int cpuSockets; + + /** The cpu sockets. */ + public int sockets; + + /** The dies per socket. */ public int diesPerSocket; + + /** The cores per die. */ public int coresPerDie; + + /** The threads per core. */ public int threadsPerCore; + + /** The accelerator. */ public String accelerator = "kvm"; + + /** The rtc base. */ public String rtcBase = "utc"; + + /** The rtc clock. */ public String rtcClock = "rt"; + + /** The powerdown timeout. */ public int powerdownTimeout = 900; + + /** The network. */ public Network[] network = { new Network() }; - public Drive[] drives; - public Spice spice; + + /** The drives. */ + public Drive[] drives = new Drive[0]; + + /** The display. */ + public Display display; /** * Convert value from JSON parser. @@ -141,7 +178,7 @@ class Configuration implements Dto { * @param value the new maximum ram */ public void setMaximumRam(String value) { - maximumRam = parseMemory(value); + maximumRam = Convertions.parseMemory(value); } /** @@ -150,36 +187,85 @@ class Configuration implements Dto { * @param value the new current ram */ public void setCurrentRam(String value) { - currentRam = parseMemory(value); + currentRam = Convertions.parseMemory(value); } } /** * Subsection "network". */ + @SuppressWarnings("PMD.DataClass") public static class Network implements Dto { + + /** The type. */ public String type = "tap"; - public String bridge = "br0"; + + /** The bridge. */ + public String bridge; + + /** The device. */ public String device = "virtio-net"; + + /** The mac. */ public String mac; + + /** The net. */ public String net; } /** * Subsection "drive". */ + @SuppressWarnings("PMD.DataClass") public static class Drive implements Dto { + + /** The type. */ public String type; + + /** The bootindex. */ public Integer bootindex; + + /** The device. */ public String device; + + /** The file. */ public String file; + + /** The resource. */ + public String resource; + } + + /** + * 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. */ public int port = 5900; + + /** The ticket. */ + public String ticket; + + /** The streaming video. */ + public String streamingVideo; + + /** The usb redirects. */ public int usbRedirects = 2; } @@ -202,20 +288,56 @@ class Configuration implements Dto { vm.maximumCpus = vm.currentCpus; } + checkDrives(); + checkCloudInit(); + return true; } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") + private void checkDrives() { + for (Drive drive : vm.drives) { + if (drive.file != null || drive.device != null + || "ide-cd".equals(drive.type)) { + continue; + } + if (drive.resource == null) { + logger.severe( + () -> "Drive configuration is missing its resource."); + + } + if (Files.isRegularFile(Path.of(drive.resource))) { + drive.file = drive.resource; + } else { + drive.device = drive.resource; + } + } + } + private boolean checkRuntimeDir() { - // Runtime directory (sockets) + // Runtime directory (sockets etc.) if (runtimeDir == null) { - runtimeDir = FsdUtils.runtimeDir(Runner.APP_NAME).resolve(vm.name); + var appDir = FsdUtils.runtimeDir(APP_NAME.replace("-", "")); + if (!Files.exists(appDir) && appDir.toFile().mkdirs()) { + try { + // When appDir is derived from XDG_RUNTIME_DIR + // the latter should already have these permissions, + // but let's be on the safe side. + Files.setPosixFilePermissions(appDir, + Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE)); + } catch (IOException e) { + logger.warning(() -> String.format( + "Cannot set permissions rwx------ on \"%s\".", + runtimeDir)); + } + } + runtimeDir = FsdUtils.runtimeDir(APP_NAME.replace("-", "")) + .resolve(vm.name); + runtimeDir.toFile().mkdir(); swtpmSocket = runtimeDir.resolve("swtpm-sock"); monitorSocket = runtimeDir.resolve("monitor.sock"); } - if (!Files.exists(runtimeDir)) { - runtimeDir.toFile().mkdirs(); - } if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) { logger.severe(() -> String.format( "Configured runtime directory \"%s\"" @@ -226,11 +348,11 @@ class Configuration implements Dto { return true; } - @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") private boolean checkDataDir() { // Data directory if (dataDir == null) { - dataDir = FsdUtils.dataHome(Runner.APP_NAME).resolve(vm.name); + dataDir + = FsdUtils.dataHome(APP_NAME.replace("-", "")).resolve(vm.name); } if (!Files.exists(dataDir)) { dataDir.toFile().mkdirs(); @@ -278,4 +400,18 @@ class Configuration implements Dto { return true; } + + private void checkCloudInit() { + if (cloudInit == null) { + return; + } + + // Provide default for instance-id + if (cloudInit.metaData == null) { + cloudInit.metaData = new HashMap<>(); + } + if (!cloudInit.metaData.containsKey(CI_INSTANCE_ID)) { + cloudInit.metaData.put(CI_INSTANCE_ID, "v" + asOf.getEpochSecond()); + } + } } \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java new file mode 100644 index 0000000..b50b481 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/ConsoleTracker.java @@ -0,0 +1,152 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.EventsV1Event; +import java.io.IOException; +import java.util.logging.Level; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.Crd; +import org.jdrupes.vmoperator.common.Constants.Status; +import org.jdrupes.vmoperator.common.K8s; +import org.jdrupes.vmoperator.common.K8sClient; +import org.jdrupes.vmoperator.common.VmDefinitionStub; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceDisconnectedEvent; +import org.jdrupes.vmoperator.runner.qemu.events.SpiceInitializedEvent; +import org.jgrapes.core.Channel; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; + +/** + * A (sub)component that updates the console status in the CR status. + * Created as child of {@link StatusUpdater}. + */ +public class ConsoleTracker extends VmDefUpdater { + + private VmDefinitionStub vmStub; + private String mainChannelClientHost; + private long mainChannelClientPort; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + */ + public ConsoleTracker(Channel componentChannel) { + super(componentChannel); + apiClient = (K8sClient) io.kubernetes.client.openapi.Configuration + .getDefaultApiClient(); + } + + /** + * Handle the start event. + * + * @param event the event + * @throws IOException + * @throws ApiException + */ + @Handler + public void onStart(Start event) { + if (namespace == null) { + return; + } + try { + vmStub = VmDefinitionStub.get(apiClient, + new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), + namespace, vmName); + } catch (ApiException e) { + logger.log(Level.SEVERE, e, + () -> "Cannot access VM object, terminating."); + event.cancel(true); + fire(new Exit(1)); + } + } + + /** + * On spice connected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) + public void onSpiceInitialized(SpiceInitializedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process connections using main channel. + if (event.channelType() != 1) { + return; + } + mainChannelClientHost = event.clientHost(); + mainChannelClientPort = event.clientPort(); + vmStub.updateStatus(from -> { + JsonObject status = updateCondition(from, "ConsoleConnected", true, + "Connected", "Connection from " + event.clientHost()); + status.addProperty(Status.CONSOLE_CLIENT, event.clientHost()); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(Crd.GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Connection from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } + + /** + * On spice disconnected. + * + * @param event the event + * @throws ApiException the api exception + */ + @Handler + public void onSpiceDisconnected(SpiceDisconnectedEvent event) + throws ApiException { + if (vmStub == null) { + return; + } + + // Only process disconnects from main channel. + if (!event.clientHost().equals(mainChannelClientHost) + || event.clientPort() != mainChannelClientPort) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = updateCondition(from, "ConsoleConnected", false, + "Disconnected", event.clientHost() + " has disconnected"); + status.addProperty(Status.CONSOLE_CLIENT, ""); + return status; + }); + + // Log event + var evt = new EventsV1Event() + .reportingController(Crd.GROUP + "/" + APP_NAME) + .action("ConsoleConnectionUpdate") + .reason("Disconnected from " + event.clientHost()); + K8s.createEvent(apiClient, vmStub.model().get(), evt); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java new file mode 100644 index 0000000..eac05fa --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Constants.java @@ -0,0 +1,41 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu; + +/** + * Some constants. + */ +public class Constants extends org.jdrupes.vmoperator.common.Constants { + + /** + * Process names. + */ + public static class ProcessName { + + /** The Constant QEMU. */ + public static final String QEMU = "qemu"; + + /** The Constant SWTPM. */ + public static final String SWTPM = "swtpm"; + + /** The Constant CLOUD_INIT_IMG. */ + public static final String CLOUD_INIT_IMG = "cloudInitImg"; + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine new file mode 100644 index 0000000..d0104f3 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/Containerfile.alpine @@ -0,0 +1,12 @@ +FROM docker.io/alpine + +RUN apk update + +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 + +COPY build/install/vm-runner.qemu /opt/vmrunner +COPY templates/* /opt/vmrunner/templates/ + +CMD ["/opt/vmrunner/bin/vm-runner.qemu"] 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 b81cace..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 --setup-machine-id +RUN systemd-firstboot -RUN pacman -Suy --noconfirm \ - && pacman -S --noconfirm which qemu-base virtiofsd \ - edk2-ovmf swtpm iproute2 bridge-utils jre17-openjdk-headless \ +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 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 new file mode 100644 index 0000000..440da91 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/CpuController.java @@ -0,0 +1,178 @@ +/* + * 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; + +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.RunnerStateChange.RunState; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; + +/** + * The Class CpuController. + */ +public class CpuController extends Component { + + private Integer currentCpus; + private Integer desiredCpus; + private ConfigureQemu suspendedConfigure; + + /** + * Instantiates a new CPU controller. + * + * @param componentChannel the component channel + */ + public CpuController(Channel componentChannel) { + super(componentChannel); + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + public void onConfigureQemu(ConfigureQemu event) { + if (event.runState() == RunState.TERMINATING) { + return; + } + Optional.ofNullable(event.configuration().vm.currentCpus) + .ifPresent(cpus -> { + if (desiredCpus != null && desiredCpus.equals(cpus)) { + return; + } + event.suspendHandling(); + suspendedConfigure = event; + desiredCpus = cpus; + fire(new MonitorCommand(new QmpQueryHotpluggableCpus())); + }); + } + + /** + * On monitor result. + * + * @param event the result + */ + @Handler + public void onHotpluggableCpuStatus(HotpluggableCpuStatus event) { + if (!event.successful()) { + logger.warning(() -> "Failed to get hotpluggable CPU status " + + "(won't adjust number of CPUs.): " + event.errorMessage()); + } + if (desiredCpus == null) { + return; + } + // Process + currentCpus = event.usedCpus().size(); + int diff = currentCpus - desiredCpus; + if (diff == 0) { + return; + } + diff = addCpus(event.usedCpus(), event.unusedCpus(), diff); + removeCpus(event.usedCpus(), diff); + + // Report result + fire(new MonitorCommand(new QmpQueryHotpluggableCpus())); + } + + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + private int addCpus(List used, List unused, + int diff) { + Set usedIds = new HashSet<>(); + for (var cpu : used) { + String qomPath = cpu.get("qom-path").asText(); + if (qomPath.startsWith("/machine/peripheral/cpu-")) { + usedIds + .add(qomPath.substring(qomPath.lastIndexOf('/') + 1)); + } + } + int nextId = 1; + List remaining = new LinkedList<>(unused); + while (diff < 0 && !remaining.isEmpty()) { + String id; + do { + id = "cpu-" + nextId++; + } while (usedIds.contains(id)); + fire(new MonitorCommand(new QmpAddCpu(remaining.get(0), id))); + remaining.remove(0); + diff += 1; + } + return diff; + } + + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + private int removeCpus(List used, int diff) { + List removable = new LinkedList<>(used); + while (diff > 0 && !removable.isEmpty()) { + ObjectNode cpu = removable.remove(0); + String qomPath = cpu.get("qom-path").asText(); + if (!qomPath.startsWith("/machine/peripheral/cpu-")) { + continue; + } + String id = qomPath.substring(qomPath.lastIndexOf('/') + 1); + fire(new MonitorCommand(new QmpDelCpu(id))); + diff -= 1; + } + return diff; + } + + /** + * On cpu added. + * + * @param event the event + */ + @Handler + public void onCpuAdded(CpuAdded event) { + currentCpus += 1; + checkCpus(); + } + + /** + * On cpu deleted. + * + * @param event the event + */ + @Handler + public void onCpuDeleted(CpuDeleted event) { + currentCpus -= 1; + checkCpus(); + } + + private void checkCpus() { + if (suspendedConfigure != null && desiredCpus != null + && 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 a870769..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,37 +19,33 @@ package org.jdrupes.vmoperator.runner.qemu; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; +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.util.HashMap; -import java.util.Map; +import java.time.Instant; +import java.util.LinkedList; +import java.util.Queue; import java.util.logging.Level; -import java.util.logging.Logger; +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.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.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.FileChanged.Kind; -import org.jgrapes.util.events.WatchFile; /** * A component that handles the communication over the Qemu monitor @@ -58,169 +54,81 @@ 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. */ -public class QemuMonitor extends Component { +public class QemuMonitor extends QemuConnector { - @SuppressWarnings({ "PMD.FieldNamingConventions", - "PMD.VariableNamingConventions" }) - private static final Logger logger - = Logger.getLogger(QemuMonitor.class.getName()); - - @SuppressWarnings("PMD.UseConcurrentHashMap") - private final Map monitorMessages = new HashMap<>(Map.of( - "connect", "{ \"execute\": \"qmp_capabilities\" }", - "powerdown", "{ \"execute\": \"system_powerdown\" }", - "setBalloon", "{ \"execute\": \"balloon\", \"arguments\": " - + "{ \"value\": %d } }")); - - 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. */ - public QemuMonitor(Channel componentChannel) { + 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())); } /** - * As the 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. + * 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 * @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 { - 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() == 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); - } - })); - writeToMonitor(monitorMessages.get("connect")); - }); - } - - /** - * 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 -> { - fire(new Stop()); - }); - } - - private void writeToMonitor(String message) { - logger.fine(() -> "monitor(out): " + message); - monitorChannel.associated(Writer.class).ifPresent(writer -> { - try { - writer.append(message).append('\n').flush(); - } catch (IOException e) { - // Cannot happen, but... - logger.log(Level.WARNING, e, () -> e.getMessage()); - } - }); - } - - /** - * Sets the current ram. - * - * @param amount the new current ram - */ - public void setCurrentRam(Number amount) { - writeToMonitor( - String.format(monitorMessages.get("setBalloon"), amount)); - } - - /** - * 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 - = ((Runner) channel()).mapper().readValue(line, JsonNode.class); + var response = mapper.readValue(line, ObjectNode.class); if (response.has("QMP")) { - fire(new QemuMonitorOpened()); + monitorReady = true; + logger.fine(() -> "QMP connection ready"); + rep().fire(new MonitorReady()); + return; + } + if (response.has("return") || response.has("error")) { + QmpCommand executed = executing.poll(); + logger.finer( + () -> String.format("(Previous \"monitor(in)\" is result " + + "from executing %s)", executed)); + var monRes = MonitorResult.from(executed, response); + logger.fine(() -> "QMP triggers: " + monRes); + rep().fire(monRes); + return; + } + if (response.has("event")) { + MonitorEvent.from(response).ifPresent(me -> { + logger.fine(() -> "QMP triggers: " + me); + rep().fire(me); + }); } } catch (JsonProcessingException e) { throw new IOException(e); @@ -234,40 +142,169 @@ public class QemuMonitor extends Component { */ @Handler public void onClosed(Closed event, SocketIOChannel channel) { - channel.associated(QemuMonitor.class).ifPresent(qm -> { - monitorChannel = null; - synchronized (this) { - if (suspendedStop != null) { - powerdownTimer.cancel(); - suspendedStop.resumeHandling(); - suspendedStop = null; - } - } + channel.associated(this, getClass()).ifPresent(qm -> { + super.onClosed(event, channel); + logger.fine(() -> "QMP connection closed."); + monitorReady = false; }); } + /** + * On monitor command. + * + * @param event the event + * @throws IOException + */ + @Handler + @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 = command.asText(); + logger.finer(() -> "monitor(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 = 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; - writeToMonitor(monitorMessages.get("powerdown")); + if (!monitorReady) { + logger.fine(() -> "Not sending QMP powerdown command" + + " because QMP connection is closed"); + return; + } - // Schedule timer as fallback + // 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())); + } + + /** + * 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 + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + + // (Re-)schedule timer as fallback + 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; } } - }, Duration.ofSeconds(powerdownTimeout)); + }, waitUntil); + powerdownConfirmed = true; } } + + /** + * On process exited. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onProcessExited(ProcessExited event) { + if (!event.startedBy().associated(CommandDefinition.class) + .map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) { + return; + } + synchronized (this) { + if (powerdownTimer != null) { + powerdownTimer.cancel(); + } + if (suspendedStop != null) { + suspendedStop.resumeHandling(); + suspendedStop = null; + } + } + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + @SuppressWarnings("PMD.AvoidSynchronizedStatement") + public void onConfigureQemu(ConfigureQemu event) { + int newTimeout = event.configuration().vm.powerdownTimeout; + if (powerdownTimeout != newTimeout) { + powerdownTimeout = newTimeout; + synchronized (this) { + if (powerdownTimer != null && powerdownConfirmed) { + powerdownTimer + .reschedule(powerdownStartedAt.plusSeconds(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 new file mode 100644 index 0000000..81a10f9 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/RamController.java @@ -0,0 +1,63 @@ +/* + * 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; + +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.jgrapes.core.Channel; +import org.jgrapes.core.Component; +import org.jgrapes.core.annotation.Handler; + +/** + * The Class RamController. + */ +public class RamController extends Component { + + private BigInteger currentRam; + + /** + * Instantiates a new CPU controller. + * + * @param componentChannel the component channel + */ + public RamController(Channel componentChannel) { + super(componentChannel); + } + + /** + * On configure qemu. + * + * @param event the event + */ + @Handler + public void onConfigureQemu(ConfigureQemu event) { + Optional.ofNullable(event.configuration().vm.currentRam) + .ifPresent(cr -> { + if (currentRam != null && currentRam.equals(cr)) { + return; + } + currentRam = cr; + fire(new MonitorCommand(new QmpSetBalloon(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 bffcb8a..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,28 +39,47 @@ 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 org.jdrupes.vmoperator.runner.qemu.StateController.State; +import static org.jdrupes.vmoperator.common.Constants.APP_NAME; +import org.jdrupes.vmoperator.common.Constants.DisplaySecret; +import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCont; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpReset; +import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; +import org.jdrupes.vmoperator.runner.qemu.events.Exit; +import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; +import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent; +import org.jdrupes.vmoperator.runner.qemu.events.QmpConfigured; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jdrupes.vmoperator.util.FsdUtils; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.Components; +import org.jgrapes.core.EventPipeline; import org.jgrapes.core.TypedIdKey; import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.Start; import org.jgrapes.core.events.Started; import org.jgrapes.core.events.Stop; +import org.jgrapes.core.internal.EventProcessor; import org.jgrapes.io.NioDispatcher; import org.jgrapes.io.events.Input; import org.jgrapes.io.events.ProcessExited; @@ -84,6 +104,13 @@ import org.jgrapes.util.events.WatchFile; * * ![Runner state diagram](RunnerStates.svg) * + * The {@link Runner} associates an {@link EventProcessor} with the + * {@link Start} event. This "runner event processor" must be used + * for all events related to the application level function. Components + * that handle events from other sources (and thus event processors) + * must fire any resulting events on the runner event processor in order + * to maintain synchronization. + * * @startuml RunnerStates.svg * [*] --> Initializing * Initializing -> Initializing: InitialConfiguration/configure Runner @@ -91,31 +118,54 @@ 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] * * monitor: entry/fire OpenSocketConnection - * monitor --> success: ClientConnected[for monitor]/set balloon value + * monitor --> waitForConfigured: ClientConnected[for monitor] * monitor -> error: ConnectError[for monitor] + * + * waitForConfigured: entry/fire QmpCapabilities + * waitForConfigured --> configure: QmpConfigured + * + * 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 <> @@ -139,49 +189,85 @@ import org.jgrapes.util.events.WatchFile; * error --> terminate * StartingProcess --> terminate: ProcessExited * + * state Stopped { + * state stopped <> * - * terminated --> [*] + * stopped --> [*] + * } + * + * terminated --> stopped * * @enduml * */ -@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace" }) +@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.AvoidPrintStackTrace", + "PMD.TooManyMethods", "PMD.CouplingBetweenObjects" }) public class Runner extends Component { - public static final String APP_NAME = "vmrunner"; private static final String TEMPLATE_DIR - = "/opt/" + APP_NAME + "/templates"; + = "/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 final ObjectMapper mapper = 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 final StateController state; private CommandDefinition swtpmDefinition; + private CommandDefinition cloudInitImgDefinition; private CommandDefinition qemuDefinition; private final QemuMonitor qemuMonitor; + 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. - * @param cmdLine * + * @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 { - state = new StateController(this); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, + yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // Get defaults - defaults = mapper.readValue( + 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); @@ -198,72 +284,107 @@ public class Runner extends Component { attach(new FileSystemWatcher(channel())); attach(new ProcessManager(channel())); attach(new SocketConnector(channel())); - attach(qemuMonitor = new QemuMonitor(channel())); - - // Configuration store with file in /etc/opt (default) - File config = new File(cmdLine.getOptionValue('c', - "/etc/opt/" + APP_NAME + "/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())); - } - - /* default */ ObjectMapper mapper() { - return mapper; + attach(qemuMonitor = new QemuMonitor(channel(), configDir)); + attach(guestAgentClient = new GuestAgentClient(channel())); + attach(vmopAgentClient = new VmopAgentClient(channel())); + attach(new StatusUpdater(channel())); + attach(new YamlConfigurationStore(channel(), configFile, false)); + fire(new WatchFile(configFile.toPath())); } /** - * On configuration update. + * Log the exception when a handling error is reported. + * + * @param event the event + */ + @Handler(channels = Channel.class, priority = -10_000) + @SuppressWarnings("PMD.GuardLogStatement") + public void onHandlingError(HandlingError event) { + logger.log(Level.WARNING, event.throwable(), + () -> "Problem invoking handler with " + event.event() + ": " + + event.message()); + event.stop(); + } + + /** + * Process the initial configuration. The initial configuration + * and any subsequent updates will be forwarded to other components + * only when the QMP connection is ready + * (see @link #onQmpConfigured(QmpConfigured)). * * @param event the event */ @Handler public void onConfigurationUpdate(ConfigurationUpdate event) { event.structured(componentPath()).ifPresent(c -> { + logger.fine(() -> "Runner configuratation updated"); + var newConf = yamlMapper.convertValue(c, Configuration.class); + + // Add some values from other sources to configuration + newConf.asOf = Instant.ofEpochSecond(configFile.lastModified()); + Path dsPath = configDir.resolve(DisplaySecret.PASSWORD); + newConf.hasDisplayPassword = dsPath.toFile().canRead(); + + // Special actions for initial configuration (startup) if (event instanceof InitialConfiguration) { - processInitialConfiguration(c); + 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 = mapper.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"); @@ -274,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)) { @@ -287,7 +414,7 @@ public class Runner extends Component { } } - private JsonNode dataFromTemplate() + private JsonNode dataFromTemplate(Configuration config) throws IOException, TemplateNotFoundException, MalformedTemplateNameException, ParseException, TemplateException, JsonProcessingException, JsonMappingException { @@ -299,6 +426,9 @@ public class Runner extends Component { .ofNullable(config.template).orElse(DEFAULT_TEMPLATE)); Files.deleteIfExists(templatePath); Files.copy(sourcePath, templatePath); + logger.fine(() -> "Using template " + sourcePath); + } else { + logger.fine(() -> "Using saved template."); } // Configure data model @@ -309,14 +439,33 @@ 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); + 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); - return mapper.readValue(out.toString(), JsonNode.class); + 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; + } } /** @@ -324,24 +473,39 @@ public class Runner extends Component { * * @param event the event */ - @Handler + @Handler(priority = 100) public void onStart(Start event) { - try { - if (config == null) { - // Missing configuration, fail - fire(new Stop()); - return; - } + if (initialConfig == null) { + // Missing configuration, fail + event.cancel(true); + fire(new Stop()); + return; + } + // Make sure to use thread specific client + // https://github.com/kubernetes-client/java/issues/100 + io.kubernetes.client.openapi.Configuration.setDefaultApiClient(null); + + // 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(initialConfig.vm.display) + .map(d -> d.spice).map(s -> s.ticket); + if (ticket.isPresent()) { + Files.write(initialConfig.runtimeDir.resolve("ticket.txt"), + ticket.get().getBytes()); + } } catch (IOException e) { logger.log(Level.SEVERE, e, () -> "Cannot start runner: " + e.getMessage()); @@ -356,19 +520,73 @@ public class Runner extends Component { */ @Handler public void onStarted(Started event) { - state.set(State.STARTING); - // Start first process - if (config.vm.useTpm && swtpmDefinition != null) { + state = RunState.STARTING; + rep.fire(new RunnerStateChange(state, "RunnerStarted", + "Runner has been started")); + // 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; } @@ -382,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); } } @@ -398,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); @@ -439,15 +654,51 @@ public class Runner extends Component { } /** - * On qemu monitor started. + * Whenever a new QEMU configuration is available, check if it + * is supposed to trigger a reset. * * @param event the event */ @Handler - public void onQemuMonitorOpened(QemuMonitorOpened event) { - Optional.ofNullable(config.vm.currentRam) - .ifPresent(qemuMonitor::setCurrentRam); - state.set(State.RUNNING); + 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; + } + } + + /** + * 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 onConfigureQemuFinal(ConfigureQemu event) { + if (state == RunState.STARTING) { + state = RunState.BOOTING; + fire(new MonitorCommand(new QmpCont())); + 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.")); + } } /** @@ -459,37 +710,94 @@ 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.get() == 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."); - fire(new Stop()); + rep.fire(new Stop()); return; } - if (procDef.equals(qemuDefinition) - && state.get() == State.RUNNING) { - 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. * * @param event the event */ @Handler(priority = 10_000) - public void onStop(Stop event) { - state.set(State.TERMINATING); + public void onStopFirst(Stop event) { + state = RunState.TERMINATING; + rep.fire(new RunnerStateChange(state, "VmTerminating", + "The VM is being shut down", exitStatus != 0)); + } + + /** + * On stop. + * + * @param event the event + */ + @Handler(priority = -10_000) + public void onStopLast(Stop event) { + state = RunState.STOPPED; + rep.fire(new RunnerStateChange(state, "VmStopped", + "The VM has been shut down")); + } + + private void shutdown() { + if (!Set.of(RunState.TERMINATING, RunState.STOPPED).contains(state)) { + fire(new Stop()); + } + try { + Components.awaitExhaustion(); + } catch (InterruptedException e) { + logger.log(Level.WARNING, e, () -> "Proper shutdown failed."); + } + + Optional.ofNullable(initialConfig).map(c -> c.runtimeDir) + .ifPresent(runtimeDir -> { + try { + Files.walk(runtimeDir).sorted(Comparator.reverseOrder()) + .map(Path::toFile).forEach(File::delete); + } catch (IOException e) { + logger.warning(() -> String.format( + "Cannot delete runtime directory \"%s\".", + runtimeDir)); + } + }); } static { try { InputStream props; - var path = FsdUtils.findConfigFile(Runner.APP_NAME, + var path = FsdUtils.findConfigFile(APP_NAME.replace("-", ""), "logging.properties"); if (path.isPresent()) { props = Files.newInputStream(path.get()); @@ -497,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(); } @@ -510,6 +822,12 @@ public class Runner extends Component { public static void main(String[] args) { // The Runner is the root component try { + var logger = Logger.getLogger(Runner.class.getName()); + logger.fine(() -> "Version: " + + Runner.class.getPackage().getImplementationVersion()); + logger.fine(() -> "running on " + System.getProperty("java.vm.name") + + " (" + System.getProperty("java.vm.version") + ")" + + " from " + System.getProperty("java.vm.vendor")); CommandLineParser parser = new DefaultParser(); // parse the command line arguments final Options options = new Options(); @@ -520,16 +838,16 @@ public class Runner extends Component { // Prepare Stop Runtime.getRuntime().addShutdownHook(new Thread(() -> { - try { - app.fire(new Stop(), Channel.BROADCAST); - Components.awaitExhaustion(); - } catch (InterruptedException e) { - // Cannot do anything about this. - } + app.shutdown(); })); // 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 new file mode 100644 index 0000000..127c070 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StatusUpdater.java @@ -0,0 +1,449 @@ +/* + * 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 com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.kubernetes.client.apimachinery.GroupVersionKind; +import io.kubernetes.client.custom.Quantity; +import io.kubernetes.client.custom.Quantity.Format; +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.models.EventsV1Event; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.Optional; +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.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.OsinfoEvent; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange; +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.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; + +/** + * Updates the CR status. + */ +@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) +public class StatusUpdater extends VmDefUpdater { + + @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 boolean guestShutdownStops; + private boolean shutdownByGuest; + private VmDefinitionStub vmStub; + private String loggedInUser; + private BigInteger lastRamValue; + private Instant lastRamChange; + private Timer balloonTimer; + private BigInteger targetRamValue; + + /** + * Instantiates a new status updater. + * + * @param componentChannel the component channel + */ + public StatusUpdater(Channel componentChannel) { + super(componentChannel); + attach(new ConsoleTracker(componentChannel)); + } + + /** + * On handling error. + * + * @param event the event + */ + @Handler(channels = Channel.class) + public void onHandlingError(HandlingError event) { + if (event.throwable() instanceof ApiException exc) { + logger.log(Level.WARNING, exc, + () -> "Problem accessing kubernetes: " + exc.getResponseBody()); + event.stop(); + } + } + + /** + * 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); + 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)); + } + } + + /** + * On runner configuration update. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onConfigureQemu(ConfigureQemu event) + throws ApiException { + guestShutdownStops = event.configuration().guestShutdownStops; + loggedInUser = event.configuration().vm.display.loggedInUser; + targetRamValue = event.configuration().vm.currentRam; + + // Remainder applies only if we have a connection to k8s. + if (vmStub == null) { + return; + } + 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 -> Condition.RUNNING + .equals(cond.get("type").getAsString())) + .forEach(cond -> cond.addProperty("observedGeneration", + from.getMetadata().getGeneration())); + updateUserLoggedIn(from); + return status; + }); + } + + /** + * On runner state changed. + * + * @param event the event + * @throws ApiException + */ + @Handler + @SuppressWarnings({ "PMD.AssignmentInOperand" }) + public void onRunnerStateChanged(RunnerStateChange event) + throws ApiException { + VmDefinition vmDef; + if (vmStub == null || (vmDef = vmStub.model().orElse(null)) == null) { + return; + } + 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(Status.CPUS, 1); + } else if (event.runState() == RunState.STOPPED) { + status.addProperty(Status.RAM, "0"); + status.addProperty(Status.CPUS, 0); + status.remove(Status.LOGGED_IN_USER); + } + + if (!running) { + // In case console connection was still present + status.addProperty(Status.CONSOLE_CLIENT, ""); + updateCondition(from, Condition.CONSOLE_CONNECTED, false, + "VmStopped", + "The VM is not running"); + + // In case we had an irregular shutdown + updateCondition(from, Condition.USER_LOGGED_IN, false, + "VmStopped", "The VM is not running"); + status.remove(Status.OSINFO); + updateCondition(vmDef, "VmopAgentConnected", false, "VmStopped", + "The VM is not running"); + } + return status; + }, vmDef); + + // Maybe stop VM + if (event.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 updateUserLoggedIn(VmDefinition from) { + if (loggedInUser == null) { + updateCondition(from, Condition.USER_LOGGED_IN, false, + Reason.NOT_REQUESTED, "No user to be logged in"); + return; + } + if (!from.conditionStatus(Condition.VMOP_AGENT).orElse(false)) { + updateCondition(from, Condition.USER_LOGGED_IN, false, + "VmopAgentDisconnected", "Waiting for VMOP agent to connect"); + return; + } + 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"); + } + + /** + * 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 (vmStub == null) { + return; + } + Instant now = Instant.now(); + if (lastRamChange == null + || lastRamChange.isBefore(now.minusSeconds(15)) + || event.size().equals(targetRamValue)) { + if (balloonTimer != null) { + balloonTimer.cancel(); + balloonTimer = null; + } + lastRamChange = now; + lastRamValue = event.size(); + updateRam(); + return; + } + + // Save for later processing and maybe start timer + lastRamChange = now; + lastRamValue = event.size(); + if (balloonTimer != null) { + return; + } + final var pipeline = activeEventPipeline(); + balloonTimer = Components.schedule(t -> { + pipeline.submit("Update RAM size", () -> { + try { + updateRam(); + } catch (ApiException e) { + logger.log(Level.WARNING, e, + () -> "Failed to update ram size: " + e.getMessage()); + } + balloonTimer = null; + }); + }, now.plusSeconds(15)); + } + + private void updateRam() throws ApiException { + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.RAM, + new Quantity(new BigDecimal(lastRamValue), Format.BINARY_SI) + .toSuffixedString()); + return status; + }); + } + + /** + * On ballon change. + * + * @param event the event + * @throws ApiException + */ + @Handler + public void onCpuChange(HotpluggableCpuStatus event) throws ApiException { + if (vmStub == null) { + return; + } + vmStub.updateStatus(from -> { + JsonObject status = from.statusJson(); + status.addProperty(Status.CPUS, event.usedCpus().size()); + return status; + }); + } + + /** + * 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 new file mode 100644 index 0000000..86d92f6 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpAddCpu.java @@ -0,0 +1,61 @@ +/* + * 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 plugs a CPU into an unused slot. + */ +public class QmpAddCpu extends QmpCommand { + + private final JsonNode unused; + private final String cpuId; + + /** + * Instantiates a new command. + * + * @param unused description of an unused cpu slot + * @param cpuId the cpu id + */ + public QmpAddCpu(JsonNode unused, String cpuId) { + super(); + this.unused = unused.deepCopy(); + this.cpuId = cpuId; + } + + @Override + public JsonNode toJson() { + ObjectNode cmd = mapper.createObjectNode(); + cmd.put("execute", "device_add"); + ObjectNode args = mapper.createObjectNode(); + cmd.set("arguments", args); + args.setAll((ObjectNode) unused.get("props")); + args.set("driver", unused.get("type")); + args.put("id", cpuId); + return cmd; + } + + @Override + public String toString() { + return "QmpAddCpu(" + unused.get("type") + ", " + cpuId + ")"; + } + +} 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 new file mode 100644 index 0000000..918b7d5 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCapabilities.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 configures the Qmp capabilities. + */ +public class QmpCapabilities extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"qmp_capabilities\" }"); + + @Override + public JsonNode toJson() { + return jsonTemplate.deepCopy(); + } + + @Override + public String toString() { + return "QmpCapabilities()"; + } + +} 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 new file mode 100644 index 0000000..b60b619 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpChangeMedium.java @@ -0,0 +1,62 @@ +/* + * 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; + +// TODO: Auto-generated Javadoc +/** + * A {@link QmpCommand} that triggers a media change in a CDROM drive. + */ +public class QmpChangeMedium extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"blockdev-change-medium\",\"arguments\": {" + + "\"id\": \"\",\"filename\": \"\",\"format\": \"raw\"," + + "\"read-only-mode\": \"read-only\" } }"); + private final String driveId; + private final String file; + + /** + * Instantiates a new qmp change medium. + * + * @param driveId the drive id + * @param file the file + */ + public QmpChangeMedium(String driveId, String file) { + this.driveId = driveId; + this.file = file; + } + + @Override + public JsonNode toJson() { + var cmd = jsonTemplate.deepCopy(); + ((ObjectNode) cmd.get("arguments")).put("id", driveId); + ((ObjectNode) cmd.get("arguments")).put("filename", file); + return cmd; + } + + @Override + public String toString() { + return "QmpChangeMedium(" + driveId + ", " + file + ")"; + } + +} 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 new file mode 100644 index 0000000..0db58e2 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCommand.java @@ -0,0 +1,84 @@ +/* + * 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.ObjectMapper; +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * The Class QmpCommand. + */ +public abstract class QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + protected static final ObjectMapper mapper = new ObjectMapper(); + + /** + * Parses the json. + * + * @param json the json + * @return the json node + */ + protected static JsonNode parseJson(String json) { + try { + return mapper.readValue(json, JsonNode.class); + } catch (IOException e) { + Logger.getLogger(QmpCommand.class.getName()).log(Level.SEVERE, e, + () -> "Cannot initialize class: " + e.getMessage()); + return null; + } + } + + /** + * Returns the Json to be sent to the Qemu process. + * + * @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 new file mode 100644 index 0000000..0e06e34 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpCont.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 causes the Qemu process to continue execution. + */ +public class QmpCont extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"cont\" }"); + + @Override + public JsonNode toJson() { + return jsonTemplate.deepCopy(); + } + + @Override + public String toString() { + return "QmpCont()"; + } + +} 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 new file mode 100644 index 0000000..a97e6c6 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpDelCpu.java @@ -0,0 +1,57 @@ +/* + * 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; + +// TODO: Auto-generated Javadoc +/** + * A {@link QmpCommand} that removes a CPU with the given id. + */ +public class QmpDelCpu extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"device_del\", " + + "\"arguments\": " + "{ \"id\": 0 } }"); + private final String cpuId; + + /** + * Instantiates a new qmp del cpu. + * + * @param cpuId the cpu id + */ + public QmpDelCpu(String cpuId) { + this.cpuId = cpuId; + } + + @Override + public JsonNode toJson() { + var cmd = jsonTemplate.deepCopy(); + ((ObjectNode) cmd.get("arguments")).put("id", cpuId); + return cmd; + } + + @Override + public String toString() { + return "QmpDelCpu(" + cpuId + ")"; + } + +} 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 new file mode 100644 index 0000000..88a392c --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpOpenTray.java @@ -0,0 +1,57 @@ +/* + * 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; + +// TODO: Auto-generated Javadoc +/** + * A {@link QmpCommand} that causes a CDROM drive's tray to be opened. + */ +public class QmpOpenTray extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"blockdev-open-tray\",\"arguments\": {" + + "\"id\": \"\" } }"); + private final String driveId; + + /** + * Instantiates a new qmp open tray. + * + * @param driveId the drive id + */ + public QmpOpenTray(String driveId) { + this.driveId = driveId; + } + + @Override + public JsonNode toJson() { + var cmd = jsonTemplate.deepCopy(); + ((ObjectNode) cmd.get("arguments")).put("id", driveId); + return cmd; + } + + @Override + public String toString() { + return "QmpOpenTray(" + driveId + ")"; + } + +} 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 new file mode 100644 index 0000000..dfb7d96 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpPowerdown.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_powerdown to the VM. + */ +public class QmpPowerdown extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"system_powerdown\" }"); + + @Override + public JsonNode toJson() { + return jsonTemplate.deepCopy(); + } + + @Override + public String toString() { + return "QmpPowerdown()"; + } + +} 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 new file mode 100644 index 0000000..d4fb5cc --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpQueryHotpluggableCpus.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 queries the hotpluggable CPUs. + */ +public class QmpQueryHotpluggableCpus extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate = parseJson( + "{\"execute\":\"query-hotpluggable-cpus\",\"arguments\":{}}"); + + @Override + public JsonNode toJson() { + return jsonTemplate.deepCopy(); + } + + @Override + public String toString() { + return "QmpQueryHotpluggableCpus()"; + } + +} 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 new file mode 100644 index 0000000..71360cf --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpRemoveMedium.java @@ -0,0 +1,62 @@ +/* + * 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; + +// TODO: Auto-generated Javadoc +/** + * A {@link QmpCommand} that removes the medium from a CDROM drive. + */ +public class QmpRemoveMedium extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"blockdev-remove-medium\",\"arguments\": {" + + "\"id\": \"\" } }"); + private final String driveId; + + /** + * Instantiates a new qmp remove medium. + * + * @param driveId the drive id + */ + public QmpRemoveMedium(String driveId) { + this.driveId = driveId; + } + + @Override + public JsonNode toJson() { + var cmd = jsonTemplate.deepCopy(); + ((ObjectNode) cmd.get("arguments")).put("id", driveId); + return cmd; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "QmpRemoveMedium(" + driveId + ")"; + } + +} 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 new file mode 100644 index 0000000..f9d4c5d --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/QmpSetBalloon.java @@ -0,0 +1,58 @@ +/* + * 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; +import java.math.BigInteger; + +// TODO: Auto-generated Javadoc +/** + * A {@link QmpCommand} that sets the balloon value. + */ +public class QmpSetBalloon extends QmpCommand { + + @SuppressWarnings({ "PMD.FieldNamingConventions" }) + private static final JsonNode jsonTemplate + = parseJson("{ \"execute\": \"balloon\", " + + "\"arguments\": " + "{ \"value\": 0 } }"); + private final BigInteger size; + + /** + * Instantiates a new qmp set balloon. + * + * @param size the size + */ + public QmpSetBalloon(BigInteger size) { + this.size = size; + } + + @Override + public JsonNode toJson() { + var cmd = jsonTemplate.deepCopy(); + ((ObjectNode) cmd.get("arguments")).put("value", size); + return cmd; + } + + @Override + public String toString() { + return "QmpSetBalloon(" + size + ")"; + } + +} 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/commands/package-info.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/package-info.java new file mode 100644 index 0000000..f7881b9 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/commands/package-info.java @@ -0,0 +1 @@ +package org.jdrupes.vmoperator.runner.qemu.commands; \ No newline at end of file diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/BalloonChangeEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/BalloonChangeEvent.java new file mode 100644 index 0000000..9cc67c8 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/BalloonChangeEvent.java @@ -0,0 +1,47 @@ +/* + * 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; +import java.math.BigInteger; + +/** + * Signals a change of the balloon. + */ +public class BalloonChangeEvent extends MonitorEvent { + + /** + * Instantiates a new tray moved. + * + * @param kind the kind + * @param data the data + */ + public BalloonChangeEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * Returns the actual value. + * + * @return the actual value + */ + public BigInteger size() { + return new BigInteger(data().get("actual").asText()); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.java new file mode 100644 index 0000000..7afa738 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/ConfigureQemu.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.events; + +import org.jdrupes.vmoperator.runner.qemu.Configuration; +import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Event; + +/** + * An {@link Event} that notifies controllers about an updated + * configuration. Controllers should adapt the resource that they + * manage to the new configuration. If the adaption cannot be + * made by the handler alone, it should call {@link Event#suspendHandling()} + * on the event and only {@link Event#resumeHandling() resume handling} + * when the adaption has completed. + */ +public class ConfigureQemu extends Event { + + private final Configuration configuration; + private final RunState state; + + /** + * Instantiates a new configuration event. + * + * @param channels the channels + */ + public ConfigureQemu(Configuration configuration, RunState state, + Channel... channels) { + super(channels); + this.state = state; + this.configuration = configuration; + } + + /** + * Returns the configuration. + * + * @return the configuration + */ + public Configuration configuration() { + return configuration; + } + + /** + * Returns the runner's state when the event was fired. + * + * @return the state + */ + public RunState runState() { + return state; + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/CpuAdded.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/CpuAdded.java new file mode 100644 index 0000000..142d9b9 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/CpuAdded.java @@ -0,0 +1,39 @@ +/* + * 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; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; + +/** + * A {@link MonitorResult} that indicates that a CPU has been added. + */ +public class CpuAdded extends MonitorResult { + + /** + * Instantiates a new cpu added. + * + * @param command the command + * @param response the response + */ + public CpuAdded(QmpCommand command, JsonNode response) { + super(command, response); + } + +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/CpuDeleted.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/CpuDeleted.java new file mode 100644 index 0000000..b42c83a --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/CpuDeleted.java @@ -0,0 +1,39 @@ +/* + * 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; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; + +/** + * A {@link MonitorResult} that indicates that a CPU has been deleted. + */ +public class CpuDeleted extends MonitorResult { + + /** + * Instantiates a new cpu deleted. + * + * @param command the command + * @param response the response + */ + public CpuDeleted(QmpCommand command, JsonNode response) { + super(command, response); + } + +} 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 new file mode 100644 index 0000000..2ab2c5a --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/HotpluggableCpuStatus.java @@ -0,0 +1,81 @@ +/* + * 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; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; + +/** + * A {@link MonitorResult} that reports the hot pluggable CPU status. + */ +public class HotpluggableCpuStatus extends MonitorResult { + + @SuppressWarnings("PMD.ImmutableField") + private List usedCpus = new ArrayList<>(); + @SuppressWarnings("PMD.ImmutableField") + private List unusedCpus = new ArrayList<>(); + + /** + * Instantiates a new hotpluggable cpu result. + * + * @param command the command + * @param response the response + */ + public HotpluggableCpuStatus(QmpCommand command, JsonNode response) { + super(command, response); + if (!successful()) { + return; + } + + // Sort + for (var itr = values().iterator(); itr.hasNext();) { + ObjectNode cpu = (ObjectNode) itr.next(); + if (cpu.has("qom-path")) { + usedCpus.add(cpu); + } else { + unusedCpus.add(cpu); + } + } + usedCpus = Collections.unmodifiableList(usedCpus); + unusedCpus = Collections.unmodifiableList(unusedCpus); + } + + /** + * Gets the used cpus. + * + * @return the usedCpus + */ + public List usedCpus() { + return usedCpus; + } + + /** + * Gets the unused cpus. + * + * @return the unusedCpus + */ + public List unusedCpus() { + return unusedCpus; + } + +} 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 new file mode 100644 index 0000000..d2b5e8c --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorCommand.java @@ -0,0 +1,63 @@ +/* + * 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 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 Qemu process. + */ +public class MonitorCommand extends Event { + + private final QmpCommand command; + + /** + * Instantiates a new exec qmp command. + * + * @param command the command + */ + public MonitorCommand(QmpCommand command) { + this.command = command; + } + + /** + * Gets the command. + * + * @return the command + */ + public QmpCommand command() { + return command; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)) + .append(" [").append(command); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java new file mode 100644 index 0000000..93e7785 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorEvent.java @@ -0,0 +1,132 @@ +/* + * 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; +import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * An {@link Event} that signals the reception of a QMP event from + * the Qemu process. + */ +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, SHUTDOWN, + SPICE_CONNECTED, SPICE_INITIALIZED, SPICE_DISCONNECTED, VSERPORT_CHANGE + } + + private final Kind kind; + private final JsonNode data; + + /** + * Create event from response. + * + * @param response the response + * @return the optional + */ + public static Optional from(JsonNode response) { + try { + 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(EVENT_DATA))); + case BALLOON_CHANGE: + return Optional.of( + new BalloonChangeEvent(kind, response.get(EVENT_DATA))); + case SHUTDOWN: + return Optional + .of(new ShutdownEvent(kind, response.get(EVENT_DATA))); + case SPICE_CONNECTED: + return Optional.of(new SpiceConnectedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_INITIALIZED: + return Optional.of(new SpiceInitializedEvent(kind, + response.get(EVENT_DATA))); + case SPICE_DISCONNECTED: + return Optional.of(new SpiceDisconnectedEvent(kind, + response.get(EVENT_DATA))); + case VSERPORT_CHANGE: + return Optional.of(new VserportChangeEvent(kind, + response.get(EVENT_DATA))); + default: + return Optional + .of(new MonitorEvent(kind, response.get(EVENT_DATA))); + } + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + /** + * Instantiates a new monitor event. + * + * @param kind the kind + * @param data the data + */ + protected MonitorEvent(Kind kind, JsonNode data) { + this.kind = kind; + this.data = data; + } + + /** + * Returns the kind of event. + * + * @return the kind + */ + public Kind kind() { + return kind; + } + + /** + * Returns the data associated with the event. + * + * @return the object[] + */ + 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/QemuMonitorOpened.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorReady.java similarity index 78% rename from org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitorOpened.java rename to org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorReady.java index 2eea438..a19dea9 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/QemuMonitorOpened.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorReady.java @@ -16,14 +16,19 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.runner.qemu; - -import org.jgrapes.io.events.Opened; +package org.jdrupes.vmoperator.runner.qemu.events; /** * Signals that the connection to the Qemu monitor socket has been * established successfully. */ -public class QemuMonitorOpened extends Opened { +public class MonitorReady extends MonitorEvent { + + /** + * Instantiates a new monitor ready. + */ + public MonitorReady() { + super(Kind.READY, null); + } } 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 new file mode 100644 index 0000000..6d7278c --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/MonitorResult.java @@ -0,0 +1,160 @@ +/* + * 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; +import java.util.Optional; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpAddCpu; +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; + +/** + * Signals the reception of a result from executing a QMP command. + */ +public class MonitorResult extends Event { + + private final QmpCommand executed; + private final JsonNode response; + + /** + * Create event from data. + * + * @param command the command + * @param response the response + * @return the monitor result + */ + public static MonitorResult from(QmpCommand command, JsonNode response) { + if (command instanceof QmpCapabilities) { + return new QmpConfigured(command, response); + } + if (command instanceof QmpQueryHotpluggableCpus) { + return new HotpluggableCpuStatus(command, response); + } + if (command instanceof QmpAddCpu) { + return new CpuAdded(command, response); + } + if (command instanceof QmpDelCpu) { + return new CpuDeleted(command, response); + } + if (command instanceof QmpSetDisplayPassword) { + return new DisplayPasswordChanged(command, response); + } + return new MonitorResult(command, response); + } + + /** + * Instantiates a new monitor result. + * + * @param command the command + * @param response the response + */ + protected MonitorResult(QmpCommand command, JsonNode response) { + this.executed = command; + this.response = response; + } + + /** + * Returns the executed executed. + * + * @return the executed + */ + public QmpCommand executed() { + return executed; + } + + /** + * Returns true if executed has been executed successfully. + * + * @return true, if successful + */ + public boolean successful() { + return response.has("return"); + } + + /** + * Returns the values that come with the response. + * + * @return the json node + */ + @SuppressWarnings("PMD.AvoidDuplicateLiterals") + public JsonNode values() { + if (response.has("return")) { + return response.get("return"); + } + if (response.has("error")) { + return response.get("error"); + } + return null; + } + + /** + * Returns the error class if this result is an error. + * + * @return the optional + */ + public Optional errorClass() { + if (!response.has("error")) { + return Optional.empty(); + } + return Optional.ofNullable(response.get("error").get("class").asText()); + } + + /** + * Returns the error description if this result is an error. + * + * @return the optional + */ + public Optional errorDescription() { + if (!response.has("error")) { + return Optional.empty(); + } + return Optional.ofNullable(response.get("error").get("desc").asText()); + } + + /** + * Combines error class and error description to a string + * "class: desc". Returns an empty string is this result is not an error. + * + * @return the string + */ + public String errorMessage() { + if (successful()) { + return ""; + } + return errorClass().get() + ": " + errorDescription().get(); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)) + .append(" [").append(executed).append(", ").append(successful()); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java new file mode 100644 index 0000000..0e90019 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/OsinfoEvent.java @@ -0,0 +1,62 @@ +/* + * VM-Operator + * Copyright (C) 2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * Signals information about the guest OS. + */ +public class OsinfoEvent extends Event { + + private final JsonNode osinfo; + + /** + * Instantiates a new osinfo event. + * + * @param data the data + */ + public OsinfoEvent(JsonNode data) { + osinfo = data; + } + + public JsonNode osinfo() { + return osinfo; + } + + /* + * (non-Javadoc) + * + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(Components.objectName(this)).append(" [") + .append(osinfo); + if (channels() != null) { + builder.append(", channels=").append(Channel.toString(channels())); + } + builder.append(']'); + return builder.toString(); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/PowerdownEvent.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/PowerdownEvent.java new file mode 100644 index 0000000..6c2edb5 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/PowerdownEvent.java @@ -0,0 +1,38 @@ +/* + * 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; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpPowerdown; + +/** + * Signals the processing of the {@link QmpPowerdown} event. + */ +public class PowerdownEvent extends MonitorEvent { + + /** + * Instantiates a new powerdown event. + * + * @param kind the kind + * @param data the data + */ + public PowerdownEvent(Kind kind, JsonNode data) { + super(kind, data); + } +} diff --git a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/QmpConfigured.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/QmpConfigured.java new file mode 100644 index 0000000..918177b --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/QmpConfigured.java @@ -0,0 +1,36 @@ +/* + * 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; +import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand; + +/** + * Signals that the QMP capabilities have been enabled. + */ +public class QmpConfigured extends MonitorResult { + + /** + * Instantiates a new monitor ready. + */ + public QmpConfigured(QmpCommand command, JsonNode response) { + super(command, response); + } + +} 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 new file mode 100644 index 0000000..261eebf --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/RunnerStateChange.java @@ -0,0 +1,143 @@ +/* + * 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 java.util.EnumSet; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; + +/** + * The Class RunnerStateChange. + */ +public class RunnerStateChange extends Event { + + /** + * The states. + */ + 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 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(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; + } + + /** + * Returns the new state. + * + * @return the state + */ + public RunState runState() { + return state; + } + + /** + * Gets the reason. + * + * @return the reason + */ + public String reason() { + return reason; + } + + /** + * Gets the message. + * + * @return the message + */ + public String message() { + return message; + } + + /** + * Checks if is failed. + * + * @return the failed + */ + public boolean failed() { + return failed; + } + + @Override + public String toString() { + 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=").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/StateController.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java similarity index 52% rename from org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StateController.java rename to org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java index 8561b88..4ce27e2 100644 --- a/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/StateController.java +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/SpiceEvent.java @@ -16,53 +16,40 @@ * along with this program. If not, see . */ -package org.jdrupes.vmoperator.runner.qemu; +package org.jdrupes.vmoperator.runner.qemu.events; + +import com.fasterxml.jackson.databind.JsonNode; /** - * The context. + * Signals a connection from a client. */ -/* default */ class StateController { - - private final Runner runner; +public class SpiceEvent extends MonitorEvent { /** - * The state. - */ - enum State { - INITIALIZING, STARTING, RUNNING, TERMINATING - } - - private State state = State.INITIALIZING; - - /** - * Instantiates a new state controller. + * Instantiates a new tray moved. * - * @param runner the runner + * @param kind the kind + * @param data the data */ - public StateController(Runner runner) { - this.runner = runner; + public SpiceEvent(Kind kind, JsonNode data) { + super(kind, data); } /** - * Sets the state. + * Returns the client's host. * - * @param state the new state + * @return the client's host address */ - public void set(State state) { - this.state = state; + public String clientHost() { + return data().get("client").get("host").asText(); } /** - * Returns the state. + * Returns the client's port. * - * @return the state + * @return the client's port number */ - public State get() { - return state; - } - - @Override - public String toString() { - return "StateController [state=" + state + "]"; + 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 new file mode 100644 index 0000000..e2d2286 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/TrayMovedEvent.java @@ -0,0 +1,58 @@ +/* + * 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; +import org.jdrupes.vmoperator.runner.qemu.CdMediaController.TrayState; + +/** + * Signals a change of tray state of one of the VM's CDROM drives. + */ +public class TrayMovedEvent extends MonitorEvent { + + /** + * Instantiates a new tray moved. + * + * @param kind the kind + * @param data the data + */ + public TrayMovedEvent(Kind kind, JsonNode data) { + super(kind, data); + } + + /** + * returns the drive id. + * + * @return the string + */ + public String driveId() { + return data().get("id").asText(); + } + + /** + * Returns the tray state. + * + * @return the tray 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/src/org/jdrupes/vmoperator/runner/qemu/events/package-info.java b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/package-info.java new file mode 100644 index 0000000..d1a1894 --- /dev/null +++ b/org.jdrupes.vmoperator.runner.qemu/src/org/jdrupes/vmoperator/runner/qemu/events/package-info.java @@ -0,0 +1 @@ +package org.jdrupes.vmoperator.runner.qemu.events; \ No newline at end of file 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 585fd77..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" ] @@ -23,6 +39,8 @@ # Useful links: # - https://joonas.fi/2021/02/uefi-pc-boot-process-and-uefi-with-qemu/ "arguments": + # Mandatory + - "-S" # Qemu configuration - "-no-user-config" # * https://www.kernel.org/doc/Documentation/virtual/kvm/api.txt @@ -50,12 +68,14 @@ - [ "-global", "ICH9-LPC.disable_s3=1" ] - [ "-global", "ICH9-LPC.disable_s4=1" ] <#if firmwareRom??> - # * Provide ROM/EEPROM devices (instead of built-in BIOS) - - [ "-blockdev", "node-name=fw-rom-file,driver=file,cache.direct=on,\ + # * Provide ROM/EEPROM devices (instead of built-in BIOS). + # Don't use cache.direct=on for these as this can results in + # incredibly bad performance when booting. + - [ "-blockdev", "node-name=fw-rom-file,driver=file,\ filename=${ firmwareRom },auto-read-only=true,discard=unmap" ] - [ "-blockdev", "node-name=fw-rom-device,driver=raw,\ read-only=true,file=fw-rom-file" ] - - [ "-blockdev", "node-name=fw-eeprom-file,driver=file,cache.direct=on,\ + - [ "-blockdev", "node-name=fw-eeprom-file,driver=file,\ filename=${ firmwareVars },auto-read-only=true,discard=unmap" ] - [ "-blockdev", "node-name=fw-eeprom-device,driver=raw,\ read-only=false,file=fw-eeprom-file" ] @@ -82,15 +102,16 @@ - [ "-cpu", "${ vm.cpuModel }" ] <#if vm.maximumCpus gt 1> - - [ "-smp", "${ vm.currentCpus },maxcpus=${ vm.maximumCpus }\ - <#if vm.cpuSockets gt 0>,sockets=${ vm.cpuSockets }\ - <#if vm.diesPerSocket gt 0>,cores=${ vm.diesPerSocket }\ + - [ "-smp", "1,maxcpus=${ vm.maximumCpus }\ + <#if vm.sockets gt 0>,sockets=${ vm.sockets }\ + <#if vm.diesPerSocket gt 0>,dies=${ vm.diesPerSocket }\ <#if vm.coresPerDie gt 0>,cores=${ vm.coresPerDie }\ - <#if vm.threadsPerCore gt 0>,cores=${ vm.threadsPerCore }" ] + <#if vm.threadsPerCore gt 0>,threads=${ vm.threadsPerCore }" ] <#if vm.accelerator != "none"> - - [ "-accel", "${ vm.accelerator }"] + - [ "-accel", "${ vm.accelerator }" ] + - [ "-device", "intel-iommu,device-iotlb=on" ] # (More devices:) # * RTC - [ "-rtc", "base=${ vm.rtcBase },clock=${ vm.rtcClock },driftfix=slew" ] @@ -101,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 @@ -116,14 +142,16 @@ - [ "-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/> <#list vm.network![] as itf> <#switch itf.type!"tap"> <#case "tap"> - - [ "-netdev", "bridge,id=hostnet${ nwCounter }" ] + - [ "-netdev", "bridge,id=hostnet${ nwCounter }\ + <#if itf.bridge??>,br=${ itf.bridge }" ] - [ "-device", "${ itf.device },netdev=hostnet${ nwCounter }\ <#if itf.mac??>,mac=${ itf.mac }" ] <#break> @@ -141,8 +169,8 @@ <#assign cdCounter = 0/> <#list vm.drives![] as drive> <#if (drive.type!"") == "ide-cd"> - - [ "-drive", "id=drive-cdrom${ cdCounter },if=none,media=cdrom,cache=none\ - <#if drive.file??>,file=${ drive.file }" ] + - [ "-drive", "id=drive-cdrom${ cdCounter },if=none,media=cdrom,\ + readonly=on<#if drive.file??>,file=${ drive.file }" ] # (IDE is old, but faster than usb-storage. virtio-blk-pci does not # work without file [empty drive]) - [ "-device", "ide-cd,id=cd${ cdCounter },bus=ide.${ cdCounter },\ @@ -177,10 +205,25 @@ <#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 - - [ "-spice", "port=${ vm.spice.port?c },disable-ticketing=on\ + - [ "-spice", "port=${ spice.port?c }\ + ,disable-ticketing=<#if hasDisplayPassword!false>off<#else>on\ + <#if spice.streamingVideo??>,streaming-video=${ spice.streamingVideo }\ ,seamless-migration=on" ] - [ "-chardev", "spicevmc,id=vdagentdev,name=vdagent" ] - [ "-device", "virtserialport,name=com.redhat.spice.0,\ @@ -189,7 +232,9 @@ - [ "-audiodev", "driver=spice,id=audio1" ] - [ "-device", "hda-duplex,audiodev=audio1" ] # * USB redirection - <#list 0.. + <#list 0.. - [ "-chardev", "spicevmc,id=charredir${ index },name=usbredir" ] - [ "-device", "usb-redir,id=redir${ index },chardev=charredir${ index }" ] + + diff --git a/org.jdrupes.vmoperator.util/.checkstyle b/org.jdrupes.vmoperator.util/.checkstyle index 2add381..7f2c604 100644 --- a/org.jdrupes.vmoperator.util/.checkstyle +++ b/org.jdrupes.vmoperator.util/.checkstyle @@ -1,10 +1,10 @@ - + - + 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/.settings/org.eclipse.jdt.core.prefs b/org.jdrupes.vmoperator.util/.settings/org.eclipse.jdt.core.prefs deleted file mode 100644 index 4775e3e..0000000 --- a/org.jdrupes.vmoperator.util/.settings/org.eclipse.jdt.core.prefs +++ /dev/null @@ -1,318 +0,0 @@ -# -#Tue Jun 06 15:40:34 CEST 2023 -org.eclipse.jdt.core.formatter.insert_space_after_ellipsis=insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations=insert -org.eclipse.jdt.core.formatter.insert_new_line_in_empty_annotation_declaration=insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression=do not insert -org.eclipse.jdt.core.formatter.parentheses_positions_in_for_statment=common_lines -org.eclipse.jdt.core.formatter.comment.new_lines_at_block_boundaries=true -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters=insert -org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_package=insert -org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while=do not insert -org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration=do not insert -org.eclipse.jdt.core.formatter.comment.format_javadoc_comments=false -org.eclipse.jdt.core.formatter.indentation.size=4 -org.eclipse.jdt.core.formatter.parentheses_positions_in_enum_constant_declaration=common_lines -org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration=insert -org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for=insert -org.eclipse.jdt.core.formatter.continuation_indentation=2 -org.eclipse.jdt.core.formatter.blank_lines_after_package=1 -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations=insert -org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16 -org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference=do not insert -org.eclipse.jdt.core.formatter.wrap_before_or_operator_multicatch=true -org.eclipse.jdt.core.formatter.comment.indent_root_tags=false -org.eclipse.jdt.core.formatter.enabling_tag=@formatter\:on -org.eclipse.jdt.core.formatter.comment.count_line_length_from_starting_position=false -org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration=16 -org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter=do not insert -org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line=false -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments=insert -org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer=insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method=insert -org.eclipse.jdt.core.formatter.alignment_for_parameterized_type_references=16 -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant=insert -org.eclipse.jdt.core.compiler.problem.enumIdentifier=error -org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter=insert -org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator=insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration=insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable=insert -org.eclipse.jdt.core.formatter.comment.preserve_white_space_between_code_and_line_comments=true -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation=do not insert -org.eclipse.jdt.core.formatter.alignment_for_union_type_in_multicatch=16 -org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body=0 -org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line=false -org.eclipse.jdt.core.formatter.parentheses_positions_in_catch_clause=common_lines -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference=insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation=do not insert -org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call=20 -org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header=true -org.eclipse.jdt.core.formatter.brace_position_for_block=end_of_line -org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line=true -org.eclipse.jdt.core.compiler.debug.lineNumber=generate -org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration=16 -org.eclipse.jdt.core.formatter.alignment_for_type_parameters=16 -org.eclipse.jdt.core.compiler.problem.assertIdentifier=error -org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment=false -org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer=insert -org.eclipse.jdt.core.formatter.insert_space_before_unary_operator=do not insert -org.eclipse.jdt.core.formatter.format_line_comment_starting_on_first_column=false -org.eclipse.jdt.core.formatter.parentheses_positions_in_annotation=common_lines -org.eclipse.jdt.core.formatter.insert_space_before_ellipsis=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_try_resources=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert=insert -org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter=insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression=do not insert -org.eclipse.jdt.core.formatter.align_type_members_on_columns=false -org.eclipse.jdt.core.formatter.alignment_for_module_statements=16 -org.eclipse.jdt.core.formatter.alignment_for_assignment=20 -org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header=true -org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant=do not insert -org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration=0 -org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line=false -org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if=insert -org.eclipse.jdt.core.formatter.brace_position_for_block_in_case=end_of_line -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration=do not insert -org.eclipse.jdt.core.formatter.comment.format_header=false -org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=20 -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch=do not insert -org.eclipse.jdt.core.formatter.alignment_for_method_declaration=16 -org.eclipse.jdt.core.formatter.comment.new_lines_at_javadoc_boundaries=true -org.eclipse.jdt.core.formatter.align_fields_grouping_blank_lines=2147483647 -org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration=end_of_line -org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for=insert -org.eclipse.jdt.core.formatter.alignment_for_resources_in_try=16 -org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation=20 -org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column=true -org.eclipse.jdt.core.compiler.source=17 -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_constant=insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression=insert -org.eclipse.jdt.core.formatter.comment.format_source_code=false -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer=insert -org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation=do not insert -org.eclipse.jdt.core.formatter.blank_lines_before_field=0 -org.eclipse.jdt.core.formatter.blank_lines_before_method=1 -org.eclipse.jdt.core.compiler.debug.sourceFile=generate -org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration=16 -org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw=insert -org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 -org.eclipse.jdt.core.formatter.insert_new_line_after_type_annotation=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer=insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration=do not insert -org.eclipse.jdt.core.formatter.comment.format_html=false -org.eclipse.jdt.core.formatter.parentheses_positions_in_method_delcaration=common_lines -org.eclipse.jdt.core.formatter.alignment_for_compact_if=16 -org.eclipse.jdt.core.formatter.indent_empty_lines=false -org.eclipse.jdt.core.formatter.alignment_for_type_arguments=16 -org.eclipse.jdt.core.formatter.insert_space_after_unary_operator=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant=do not insert -org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation=20 -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations=do not insert -org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch=false -org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator=insert -org.eclipse.jdt.core.formatter.insert_new_line_after_label=do not insert -org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header=true -org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional=insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_declaration=insert -org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases=true -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_try=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow=insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration=do not insert -org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line=false -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration=insert -org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression=do not insert -org.eclipse.jdt.core.formatter.alignment_for_multiple_fields=16 -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments=insert -org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator=do not insert -org.eclipse.jdt.core.formatter.brace_position_for_array_initializer=end_of_line -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration=insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters=insert -org.eclipse.jdt.core.compiler.compliance=17 -org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration=insert -org.eclipse.jdt.core.formatter.wrap_outer_expressions_when_nested=true -org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast=insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized=insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration=do not insert -org.eclipse.jdt.core.formatter.alignment_for_expressions_in_for_loop_header=16 -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow=insert -org.eclipse.jdt.core.formatter.join_lines_in_comments=false -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations=do not insert -org.eclipse.jdt.core.formatter.blank_lines_between_import_groups=1 -org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration=insert -org.eclipse.jdt.core.formatter.parentheses_positions_in_method_invocation=common_lines -org.eclipse.jdt.core.formatter.blank_lines_after_imports=1 -org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags=insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws=do not insert -org.eclipse.jdt.core.formatter.parentheses_positions_in_switch_statement=common_lines -org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments=insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments=insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits=do not insert -org.eclipse.jdt.core.formatter.disabling_tag=@formatter\:off -org.eclipse.jdt.core.formatter.alignment_for_enum_constants=16 -org.eclipse.jdt.core.formatter.blank_lines_before_imports=1 -org.eclipse.jdt.core.formatter.insert_space_after_binary_operator=insert -org.eclipse.jdt.core.formatter.parentheses_positions_in_if_while_statement=common_lines -org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block=insert -org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return=insert -org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field=insert -org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block=insert -org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations=1 -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration=insert -org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column=true -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits=insert -org.eclipse.jdt.core.formatter.indent_statements_compare_to_block=true -org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration=end_of_line -org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch=insert -org.eclipse.jdt.core.formatter.comment.line_length=80 -org.eclipse.jdt.core.formatter.use_on_off_tags=false -org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant=insert -org.eclipse.jdt.core.formatter.brace_position_for_method_declaration=end_of_line -org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for=insert -org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments=insert -org.eclipse.jdt.core.formatter.alignment_for_binary_expression=20 -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations=insert -org.eclipse.jdt.core.compiler.debug.localVariable=generate -org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces=insert -org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional=insert -org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration=end_of_line -org.eclipse.jdt.core.formatter.compact_else_if=true -org.eclipse.jdt.core.formatter.brace_position_for_lambda_body=end_of_line -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch=insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments=insert -org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation=20 -org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration=16 -org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_try=insert -org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment=false -org.eclipse.jdt.core.formatter.insert_space_before_binary_operator=insert -org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer=16 -org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve=1 -org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case=insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments=do not insert -org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration=insert -org.eclipse.jdt.core.formatter.comment.format_line_comments=true -org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement=insert -org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body=insert -org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration=16 -org.eclipse.jdt.core.formatter.alignment_for_conditional_expression=84 -org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type=insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block=insert -org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration=end_of_line -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while=insert -org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled -org.eclipse.jdt.core.formatter.join_wrapped_lines=false -org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration=do not insert -org.eclipse.jdt.core.formatter.wrap_before_conditional_operator=true -org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression=do not insert -org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases=true -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized=do not insert -org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations=true -org.eclipse.jdt.core.formatter.parentheses_positions_in_try_clause=common_lines -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws=insert -org.eclipse.jdt.core.formatter.tabulation.size=4 -org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional=insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_try=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_try_resources=insert -org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard=do not insert -org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer=1 -org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration=16 -org.eclipse.jdt.core.formatter.wrap_before_assignment_operator=true -org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement=do not insert -org.eclipse.jdt.core.formatter.brace_position_for_switch=end_of_line -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters=insert -org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters=insert -eclipse.preferences.version=1 -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference=do not insert -org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line=false -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration=do not insert -org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk=1 -org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert=insert -org.eclipse.jdt.core.formatter.blank_lines_before_member_type=1 -org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression=20 -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_semicolon=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast=do not insert -org.eclipse.jdt.core.formatter.comment.format_block_comments=true -org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration=16 -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws=insert -org.eclipse.jdt.core.formatter.indent_statements_compare_to_body=true -org.eclipse.jdt.core.formatter.wrap_before_binary_operator=true -org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference=do not insert -org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation=insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments=do not insert -org.eclipse.jdt.core.formatter.parentheses_positions_in_lambda_declaration=common_lines -org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration=do not insert -org.eclipse.jdt.core.formatter.brace_position_for_enum_constant=end_of_line -org.eclipse.jdt.core.formatter.brace_position_for_type_declaration=end_of_line -org.eclipse.jdt.core.formatter.blank_lines_before_package=0 -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for=insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation=do not insert -org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header=true -org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration=insert -org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters=do not insert -org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional=insert -org.eclipse.jdt.core.javaFormatter=org.eclipse.jdt.core.defaultJavaFormatter -org.eclipse.jdt.core.formatter.comment.indent_parameter_description=true -org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement=do not insert -org.eclipse.jdt.core.formatter.tabulation.char=space -org.eclipse.jdt.core.formatter.lineSplit=80 -org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch=insert -org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation=do not insert diff --git a/org.jdrupes.vmoperator.util/build.gradle b/org.jdrupes.vmoperator.util/build.gradle index 0dac0c5..5f2b065 100644 --- a/org.jdrupes.vmoperator.util/build.gradle +++ b/org.jdrupes.vmoperator.util/build.gradle @@ -9,5 +9,6 @@ plugins { } dependencies { - implementation 'org.freemarker:freemarker:[2.3.32,2.4)' + api 'org.freemarker:freemarker:[2.3.32,2.4)' + api 'com.google.code.gson:gson:2.10.1' } 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 new file mode 100644 index 0000000..c6fb101 --- /dev/null +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/GsonPtr.java @@ -0,0 +1,405 @@ +/* + * 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 com.google.gson.JsonArray; +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.ClassWithOnlyPrivateConstructorsShouldBeFinal" }) +public class GsonPtr { + + private final JsonElement position; + + private GsonPtr(JsonElement root) { + this.position = root; + } + + /** + * Create a new instance pointing to the given element. + * + * @param root the root + * @return the Gson pointer + */ + @SuppressWarnings("PMD.ShortMethodName") + public static GsonPtr to(JsonElement root) { + return new GsonPtr(root); + } + + /** + * 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}, a new member (of type {@link JsonObject} + * is added. + * + * @param selectors the selectors + * @return the Gson pointer + */ + @SuppressWarnings({ "PMD.ShortMethodName", "PMD.PreserveStackTrace", + "PMD.AvoidDuplicateLiterals" }) + public GsonPtr to(Object... selectors) { + JsonElement element = position; + for (Object sel : selectors) { + if (element instanceof JsonObject obj + && sel instanceof String member) { + element = Optional.ofNullable(obj.get(member)).orElseGet(() -> { + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + var child = new JsonObject(); + obj.add(member, child); + return child; + }); + 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 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. + * + * @return the result + */ + public JsonElement get() { + return position; + } + + /** + * Returns {@link JsonElement} that the pointer points to, + * casted to the given type. + * + * @param the generic type + * @param cls the cls + * @return the result + */ + public T getAs(Class cls) { + if (cls.isAssignableFrom(position.getClass())) { + return cls.cast(position); + } + throw new IllegalArgumentException("Not positioned at element" + + " of desired type."); + } + + /** + * Returns the selected {@link JsonElement}, cast to the class + * specified. + * + * @param the generic type + * @param cls the cls + * @param selectors the selectors + * @return the optional + */ + @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" }) + public Optional + getAs(Class cls, 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) { + return Optional.empty(); + } + continue; + } + return Optional.empty(); + } + if (cls.isAssignableFrom(element.getClass())) { + return Optional.of(cls.cast(element)); + } + return Optional.empty(); + } + + /** + * Returns the String value of the selected {@link JsonPrimitive}. + * + * @param selectors the selectors + * @return the as string + */ + public Optional getAsString(Object... selectors) { + return getAs(JsonPrimitive.class, selectors) + .map(JsonPrimitive::getAsString); + } + + /** + * Returns the Integer value of the selected {@link JsonPrimitive}. + * + * @param selectors the selectors + * @return the as string + */ + public Optional getAsInt(Object... selectors) { + return getAs(JsonPrimitive.class, selectors) + .map(JsonPrimitive::getAsInt); + } + + /** + * Returns the Integer value of the selected {@link JsonPrimitive}. + * + * @param selectors the selectors + * @return the as string + */ + public Optional getAsBigInteger(Object... selectors) { + return getAs(JsonPrimitive.class, selectors) + .map(JsonPrimitive::getAsBigInteger); + } + + /** + * Returns the Long value of the selected {@link JsonPrimitive}. + * + * @param selectors the selectors + * @return the as string + */ + public Optional getAsLong(Object... selectors) { + return getAs(JsonPrimitive.class, selectors) + .map(JsonPrimitive::getAsLong); + } + + /** + * Returns the boolean value of the selected {@link JsonPrimitive}. + * + * @param selectors the selectors + * @return the boolean + */ + public Optional getAsBoolean(Object... 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 + * be a {@link String} or an integer respectively. + * + * @param selector the selector + * @param value the value + * @return the Gson pointer + */ + public GsonPtr set(Object selector, JsonElement value) { + if (position instanceof JsonObject obj + && selector instanceof String member) { + obj.add(member, value); + return this; + } + if (position instanceof JsonArray arr + && selector instanceof Integer index) { + if (index >= arr.size()) { + arr.add(value); + } else { + arr.set(index, value); + } + return this; + } + throw new IllegalStateException("Invalid selection"); + } + + /** + * 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, String 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, 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. + * If this pointer points to a {@link JsonArray} and the selector + * if larger than or equal to the size of the array, the supplied + * value will be appended. + * + * @param the generic type + * @param selector the selector + * @param supplier the supplier of the missing value + * @return the existing or supplied value + */ + @SuppressWarnings("unchecked") + public T + computeIfAbsent(Object selector, Supplier supplier) { + if (position instanceof JsonObject obj + && selector instanceof String member) { + return Optional.ofNullable((T) obj.get(member)).orElseGet(() -> { + var res = supplier.get(); + obj.add(member, res); + return res; + }); + } + if (position instanceof JsonArray arr + && selector instanceof Integer index) { + if (index >= arr.size()) { + var res = supplier.get(); + arr.add(res); + return res; + } + return (T) arr.get(index); + } + throw new IllegalStateException("Invalid selection"); + } + + /** + * Short for `computeIfAbsent(selector, () -> new JsonPrimitive(value))`. + * + * @param selector the selector + * @param value the value + * @return the Gson pointer + */ + public GsonPtr getOrSet(Object selector, String value) { + computeIfAbsent(selector, () -> new JsonPrimitive(value)); + 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/src/org/jdrupes/vmoperator/util/package-info.java b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/package-info.java index 24ffeb7..e1ce2f7 100644 --- a/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/package-info.java +++ b/org.jdrupes.vmoperator.util/src/org/jdrupes/vmoperator/util/package-info.java @@ -16,4 +16,8 @@ * along with this program. If not, see . */ +/** + * General utility classes and methods that are independent of the + * specific domain (VM operator). + */ package org.jdrupes.vmoperator.util; \ No newline at end of file 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.vmaccess/.checkstyle b/org.jdrupes.vmoperator.vmaccess/.checkstyle new file mode 100644 index 0000000..7f2c604 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.checkstyle @@ -0,0 +1,10 @@ + + + + + + + + + + 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.vmaccess/.gitignore b/org.jdrupes.vmoperator.vmaccess/.gitignore new file mode 100644 index 0000000..a53e74c --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.gitignore @@ -0,0 +1,4 @@ +/bin/ +/bin_test/ +/generated/ +/build/ diff --git a/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..641c156 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.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.vmaccess/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs new file mode 100644 index 0000000..5a0ad22 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.core.runtime.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +line.separator=\n diff --git a/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.jdt.ui.prefs b/org.jdrupes.vmoperator.vmaccess/.settings/org.eclipse.jdt.ui.prefs new file mode 100644 index 0000000..784d01f --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/.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.vmaccess/build.gradle b/org.jdrupes.vmoperator.vmaccess/build.gradle new file mode 100644 index 0000000..606c6cd --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/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.vmaccess/package.json b/org.jdrupes.vmoperator.vmaccess/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/package.json @@ -0,0 +1 @@ +{} 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.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_en.properties b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/l10n_en.properties new file mode 100644 index 0000000..e69de29 diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt new file mode 100644 index 0000000..ac24b16 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/Licenses.txt @@ -0,0 +1,20 @@ +almalinux.svg: + Source: https://commons.wikimedia.org/wiki/File:AlmaLinux_Icon_Logo.svg + License: https://github.com/AlmaLinux/wiki/blob/master/LICENSE + +archlinux.svg: + Source: https://commons.wikimedia.org/wiki/File:Arch_Linux_%22Crystal%22_icon.svghttps://commons.wikimedia.org/wiki/File:Arch_Linux_%22Crystal%22_icon.svg + License: GPL v2 or later + +debian.svg: + Source: https://commons.wikimedia.org/wiki/File:Openlogo-debianV2.svg + License : LGPL + +fedora.svg: + Source: https://commons.wikimedia.org/wiki/File:Fedora_icon_(2021).svg + License: Public Domain + +tux.svg: + Source: https://commons.wikimedia.org/wiki/File:Tux.svghttps://commons.wikimedia.org/wiki/File:Tux.svg + License: Creative Commons CC0 1.0 Universal Public Domain Dedication. Creative Commons CC0 1.0 Universal Public Domain Dedication. + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg new file mode 100644 index 0000000..b2e050a --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/almalinux.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg new file mode 100644 index 0000000..ca8204c --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/arch.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg new file mode 100644 index 0000000..685f632 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/debian.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg new file mode 100644 index 0000000..e227311 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/fedora.svg @@ -0,0 +1,16 @@ + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/tux.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/tux.svg new file mode 100644 index 0000000..6b558e7 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/tux.svg @@ -0,0 +1,438 @@ + + + Tux + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg new file mode 100644 index 0000000..f217bc8 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/ubuntu.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg new file mode 100644 index 0000000..51f3016 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/unknown.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + OS + + + diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg new file mode 100644 index 0000000..2c7392e --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/osicons/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/reset-icon.svg b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/reset-icon.svg new file mode 100644 index 0000000..d47e33d --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/resources/org/jdrupes/vmoperator/vmaccess/reset-icon.svg @@ -0,0 +1,42 @@ + + + + + + + diff --git a/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs b/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs new file mode 100644 index 0000000..ab1aae9 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/rollup.config.mjs @@ -0,0 +1,35 @@ +import typescript from 'rollup-plugin-typescript2'; +import postcss from 'rollup-plugin-postcss'; + +let packagePath = "org/jdrupes/vmoperator/vmaccess"; +let baseName = "VmAccess" +let module = "build/generated/resources/" + packagePath + + "/" + baseName + "-functions.js"; + +let pathsMap = { + "aash-plugin": "../../page-resource/aash-vue-components/lib/aash-vue-components.js", + "jgconsole": "../../console-base-resource/jgconsole.js", + "jgwc": "../../page-resource/jgwc-vue-components/jgwc-components.js", + "l10nBundles": "./" + baseName + "-l10nBundles.ftl.js", + "vue": "../../page-resource/vue/vue.esm-browser.js" +} + +export default { + external: ['aash-plugin', 'jgconsole', 'jgwc', 'l10nBundles', 'vue', 'chartjs'], + input: "src/" + packagePath + "/browser/" + baseName + "-functions.ts", + output: [ + { + format: "esm", + file: module, + sourcemap: true, + sourcemapPathTransform: (relativeSourcePath, _sourcemapPath) => { + return relativeSourcePath.replace(/^([^/]*\/){12}/, "./"); + }, + paths: pathsMap + } + ], + plugins: [ + typescript(), + postcss() + ] +}; diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java new file mode 100644 index 0000000..f30b771 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccess.java @@ -0,0 +1,991 @@ +/* + * VM-Operator + * Copyright (C) 2023,2025 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.vmaccess; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.JsonSyntaxException; +import freemarker.core.ParseException; +import freemarker.template.MalformedTemplateNameException; +import freemarker.template.Template; +import freemarker.template.TemplateNotFoundException; +import io.kubernetes.client.util.Strings; +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.time.Duration; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ResourceBundle; +import java.util.Set; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.bouncycastle.util.Objects; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Assignment; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.AssignVm; +import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; +import org.jdrupes.vmoperator.manager.events.GetPools; +import org.jdrupes.vmoperator.manager.events.GetVms; +import org.jdrupes.vmoperator.manager.events.GetVms.VmData; +import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.ResetVm; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jdrupes.vmoperator.manager.events.VmResourceChanged; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; +import org.jgrapes.core.EventPipeline; +import org.jgrapes.core.Manager; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.core.events.Start; +import org.jgrapes.http.Session; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.KeyValueStoreQuery; +import org.jgrapes.util.events.KeyValueStoreUpdate; +import org.jgrapes.webconsole.base.Conlet.RenderMode; +import org.jgrapes.webconsole.base.ConletBaseModel; +import org.jgrapes.webconsole.base.ConsoleConnection; +import org.jgrapes.webconsole.base.ConsoleRole; +import org.jgrapes.webconsole.base.ConsoleUser; +import org.jgrapes.webconsole.base.WebConsoleUtils; +import org.jgrapes.webconsole.base.events.AddConletRequest; +import org.jgrapes.webconsole.base.events.AddConletType; +import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; +import org.jgrapes.webconsole.base.events.ConletDeleted; +import org.jgrapes.webconsole.base.events.ConsoleConfigured; +import org.jgrapes.webconsole.base.events.ConsolePrepared; +import org.jgrapes.webconsole.base.events.ConsoleReady; +import org.jgrapes.webconsole.base.events.DeleteConlet; +import org.jgrapes.webconsole.base.events.DisplayNotification; +import org.jgrapes.webconsole.base.events.NotifyConletModel; +import org.jgrapes.webconsole.base.events.NotifyConletView; +import org.jgrapes.webconsole.base.events.OpenModalDialog; +import org.jgrapes.webconsole.base.events.RenderConlet; +import org.jgrapes.webconsole.base.events.RenderConletRequestBase; +import org.jgrapes.webconsole.base.events.SetLocale; +import org.jgrapes.webconsole.base.events.UpdateConletType; +import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; + +/** + * The Class {@link VmAccess}. The component supports the following + * configuration properties: + * + * * `displayResource`: a map with the following entries: + * - `preferredIpVersion`: `ipv4` or `ipv6` (default: `ipv4`). + * Determines the IP addresses uses in the generated + * connection file. + * * `deleteConnectionFile`: `true` or `false` (default: `true`). + * If `true`, the downloaded connection file will be deleted by + * the remote viewer when opened. + * * `syncPreviewsFor`: a list objects with either property `user` or + * `role` and the associated name (default: `[]`). + * The remote viewer will synchronize the previews for the specified + * users and roles. + * + */ +@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.CouplingBetweenObjects", + "PMD.GodClass", "PMD.TooManyMethods", "PMD.CyclomaticComplexity" }) +public class VmAccess extends FreeMarkerConlet { + + private static final String VM_NAME_PROPERTY = "vmName"; + private static final String POOL_NAME_PROPERTY = "poolName"; + private static final String RENDERED + = VmAccess.class.getName() + ".rendered"; + private static final String PENDING + = VmAccess.class.getName() + ".pending"; + private static final Set MODES = RenderMode.asSet( + RenderMode.Preview, RenderMode.Edit); + private static final Set MODES_FOR_GENERATED = RenderMode.asSet( + RenderMode.Preview, RenderMode.StickyPreview); + private EventPipeline appPipeline; + private static ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); + + private Class preferredIpVersion = Inet4Address.class; + private Set syncUsers = Collections.emptySet(); + private Set syncRoles = Collections.emptySet(); + private boolean deleteConnectionFile = true; + + /** + * The periodically generated update event. + */ + public static class Update extends Event { + } + + /** + * Creates a new component with its channel set to the given channel. + * + * @param componentChannel the channel that the component's handlers listen + * on by default and that {@link Manager#fire(Event, Channel...)} + * sends the event to + */ + public VmAccess(Channel componentChannel) { + super(componentChannel); + } + + /** + * On start. + * + * @param event the event + */ + @Handler + public void onStart(Start event) { + appPipeline = event.processedBy().get(); + } + + /** + * Configure the component. + * + * @param event the event + */ + @SuppressWarnings({ "unchecked" }) + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()) + .or(() -> { + var oldConfig = event.structured("/Manager/GuiHttpServer" + + "/ConsoleWeblet/WebConsole/ComponentCollector/VmViewer"); + if (oldConfig.isPresent()) { + logger.warning(() -> "Using configuration with old " + + "component name \"VmViewer\", please update to " + + "\"VmAccess\""); + } + return oldConfig; + }) + .ifPresent(c -> { + try { + var dispRes = (Map) c + .getOrDefault("displayResource", + Collections.emptyMap()); + switch ((String) dispRes.getOrDefault("preferredIpVersion", + "")) { + case "ipv6": + preferredIpVersion = Inet6Address.class; + break; + case "ipv4": + default: + preferredIpVersion = Inet4Address.class; + break; + } + + // Delete connection file + deleteConnectionFile + = Optional.ofNullable(c.get("deleteConnectionFile")) + .map(Object::toString).map(Boolean::parseBoolean) + .orElse(true); + + // Users or roles for which previews should be synchronized + syncUsers = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("user")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for users: " + + syncUsers.toString()); + syncRoles = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("role")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for roles: " + + syncRoles.toString()); + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } + + private boolean syncPreviews(Session session) { + return WebConsoleUtils.userFromSession(session) + .filter(u -> syncUsers.contains(u.getName())).isPresent() + || WebConsoleUtils.rolesFromSession(session).stream() + .filter(cr -> syncRoles.contains(cr.getName())).findAny() + .isPresent(); + } + + /** + * On {@link ConsoleReady}, fire the {@link AddConletType}. + * + * @param event the event + * @param channel the channel + * @throws TemplateNotFoundException the template not found exception + * @throws MalformedTemplateNameException the malformed template name + * exception + * @throws ParseException the parse exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException { + // Add conlet resources to page + channel.respond(new AddConletType(type()) + .setDisplayNames( + localizations(channel.supportedLocales(), "conletName")) + .addRenderMode(RenderMode.Preview) + .addScript(new ScriptResource().setScriptType("module") + .setScriptUri(event.renderSupport().conletResource( + type(), "VmAccess-functions.js")))); + channel.session().put(RENDERED, new HashSet<>()); + } + + /** + * On console configured. + * + * @param event the event + * @param connection the console connection + * @throws InterruptedException the interrupted exception + */ + @Handler + public void onConsoleConfigured(ConsoleConfigured event, + ConsoleConnection connection) throws InterruptedException, + IOException { + @SuppressWarnings({ "unchecked" }) + final var rendered + = (Set) connection.session().get(RENDERED); + connection.session().remove(RENDERED); + if (!syncPreviews(connection.session())) { + return; + } + addMissingConlets(event, connection, rendered); + } + + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) + private void addMissingConlets(ConsoleConfigured event, + ConsoleConnection connection, final Set rendered) + throws InterruptedException { + var session = connection.session(); + + // Evaluate missing VMs + var missingVms = appPipeline.fire(new GetVms().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(d -> d.definition().name()) + .collect(Collectors.toCollection(HashSet::new)); + missingVms.removeAll(rendered.stream() + .filter(r -> r.mode() == ResourceModel.Mode.VM) + .map(ResourceModel::name).toList()); + + // Evaluate missing pools + var missingPools = appPipeline.fire(new GetPools().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(VmPool::name) + .collect(Collectors.toCollection(HashSet::new)); + missingPools.removeAll(rendered.stream() + .filter(r -> r.mode() == ResourceModel.Mode.POOL) + .map(ResourceModel::name).toList()); + + // Nothing to do + if (missingVms.isEmpty() && missingPools.isEmpty()) { + return; + } + + // Suspending to allow rendering of conlets to be noticed + var failSafe = Components.schedule(t -> event.resumeHandling(), + Duration.ofSeconds(1)); + event.suspendHandling(failSafe::cancel); + connection.setAssociated(PENDING, event); + + // Create conlets for VMs and pools that haven't been rendered + for (var vmName : missingVms) { + fire(new AddConletRequest(event.event().event().renderSupport(), + VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview)) + .addProperty(VM_NAME_PROPERTY, vmName), + connection); + } + for (var poolName : missingPools) { + fire(new AddConletRequest(event.event().event().renderSupport(), + VmAccess.class.getName(), RenderMode.asSet(RenderMode.Preview)) + .addProperty(POOL_NAME_PROPERTY, poolName), + connection); + } + } + + /** + * On console prepared. + * + * @param event the event + * @param connection the connection + */ + @Handler + public void onConsolePrepared(ConsolePrepared event, + ConsoleConnection connection) { + if (syncPreviews(connection.session())) { + connection.respond(new UpdateConletType(type())); + } + } + + private String storagePath(Session session, String conletId) { + return "/" + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse("") + + "/" + VmAccess.class.getName() + "/" + conletId; + } + + @Override + protected Optional createNewState(AddConletRequest event, + ConsoleConnection connection, String conletId) throws Exception { + var model = new ResourceModel(conletId); + var poolName = (String) event.properties().get(POOL_NAME_PROPERTY); + if (poolName != null) { + model.setMode(ResourceModel.Mode.POOL); + model.setName(poolName); + } else { + model.setMode(ResourceModel.Mode.VM); + model.setName((String) event.properties().get(VM_NAME_PROPERTY)); + } + String jsonState = objectMapper.writeValueAsString(model); + connection.respond(new KeyValueStoreUpdate().update( + storagePath(connection.session(), model.getConletId()), jsonState)); + return Optional.of(model); + } + + @Override + protected Optional createStateRepresentation(Event event, + ConsoleConnection connection, String conletId) throws Exception { + var model = new ResourceModel(conletId); + String jsonState = objectMapper.writeValueAsString(model); + connection.respond(new KeyValueStoreUpdate().update( + storagePath(connection.session(), model.getConletId()), jsonState)); + return Optional.of(model); + } + + @Override + @SuppressWarnings("PMD.EmptyCatchBlock") + protected Optional recreateState(Event event, + ConsoleConnection channel, String conletId) throws Exception { + KeyValueStoreQuery query = new KeyValueStoreQuery( + storagePath(channel.session(), conletId), channel); + newEventPipeline().fire(query, channel); + try { + if (!query.results().isEmpty()) { + var json = query.results().get(0).values().stream().findFirst() + .get(); + ResourceModel model + = objectMapper.readValue(json, ResourceModel.class); + return Optional.of(model); + } + } catch (InterruptedException e) { + // Means we have no result. + } + + // Fall back to creating default state. + return createStateRepresentation(event, channel, conletId); + } + + @Override + protected Set doRenderConlet(RenderConletRequestBase event, + ConsoleConnection channel, String conletId, ResourceModel model) + throws Exception { + if (event.renderAs().contains(RenderMode.Preview)) { + return renderPreview(event, channel, conletId, model); + } + + // Render edit + ResourceBundle resourceBundle = resourceBundle(channel.locale()); + Set renderedAs = EnumSet.noneOf(RenderMode.class); + if (event.renderAs().contains(RenderMode.Edit)) { + var session = channel.session(); + var vmNames = appPipeline.fire(new GetVms().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(d -> d.definition().name()).sorted() + .toList(); + var poolNames = appPipeline.fire(new GetPools().accessibleFor( + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().map(VmPool::name).sorted().toList(); + Template tpl + = freemarkerConfig().getTemplate("VmAccess-edit.ftl.html"); + var fmModel = fmModel(event, channel, conletId, model); + fmModel.put("vmNames", vmNames); + fmModel.put("poolNames", poolNames); + channel.respond(new OpenModalDialog(type(), conletId, + processTemplate(event, tpl, fmModel)) + .addOption("cancelable", true) + .addOption("okayLabel", + resourceBundle.getString("okayLabel"))); + } + return renderedAs; + } + + @SuppressWarnings("unchecked") + private Set renderPreview(RenderConletRequestBase event, + ConsoleConnection channel, String conletId, ResourceModel model) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException, InterruptedException { + channel.associated(PENDING, Event.class) + .ifPresent(e -> { + e.resumeHandling(); + channel.setAssociated(PENDING, null); + }); + + VmDefinition vmDef = null; + if (model.mode() == ResourceModel.Mode.VM && model.name() != null) { + // Remove conlet if VM definition has been removed + // or user has not at least one permission + vmDef = getVmData(model, channel).map(VmData::definition) + .orElse(null); + if (vmDef == null) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + } + + if (model.mode() == ResourceModel.Mode.POOL && model.name() != null) { + // Remove conlet if pool definition has been removed + // or user has not at least one permission + VmPool pool = appPipeline + .fire(new GetPools().withName(model.name())).get() + .stream().findFirst().orElse(null); + if (pool == null + || permissions(pool, channel.session()).isEmpty()) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + vmDef = getVmData(model, channel).map(VmData::definition) + .orElse(null); + } + + // Render + Template tpl + = freemarkerConfig().getTemplate("VmAccess-preview.ftl.html"); + channel.respond(new RenderConlet(type(), conletId, + processTemplate(event, tpl, + fmModel(event, channel, conletId, model))) + .setRenderAs( + RenderMode.Preview.addModifiers(event.renderAs())) + .setSupportedModes(syncPreviews(channel.session()) + ? MODES_FOR_GENERATED + : MODES)); + if (!Strings.isNullOrEmpty(model.name())) { + Optional.ofNullable(channel.session().get(RENDERED)) + .ifPresent(s -> ((Set) s).add(model)); + updatePreview(channel, model, vmDef); + } + return EnumSet.of(RenderMode.Preview); + } + + private Optional getVmData(ResourceModel model, + ConsoleConnection channel) throws InterruptedException { + if (model.mode() == ResourceModel.Mode.VM) { + // Get the VM data by name. + var session = channel.session(); + return appPipeline.fire(new GetVms().withName(model.name()) + .accessibleFor(WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null), + WebConsoleUtils.rolesFromSession(session).stream() + .map(ConsoleRole::getName).toList())) + .get().stream().findFirst(); + } + + // Look for an (already) assigned VM + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(null); + return appPipeline.fire(new GetVms().assignedFrom(model.name()) + .assignedTo(user)).get().stream().findFirst(); + } + + /** + * Returns the permissions from the VM definition. + * + * @param vmDef the VM definition + * @param session the session + * @return the sets the + */ + private Set permissions(VmDefinition vmDef, Session session) { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + return vmDef.permissionsFor(user, roles); + } + + /** + * Returns the permissions from the pool. + * + * @param pool the pool + * @param session the session + * @return the sets the + */ + private Set permissions(VmPool pool, Session session) { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + return pool.permissionsFor(user, roles); + } + + /** + * Returns the permissions from the VM definition or the pool depending + * on the state of the model. + * + * @param session the session + * @param model the model + * @param vmDef the vm def + * @return the sets the + * @throws InterruptedException the interrupted exception + */ + private Set permissions(Session session, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + if (model.mode() == ResourceModel.Mode.POOL) { + // Use permissions from pool + var pool = appPipeline.fire(new GetPools().withName(model.name())) + .get().stream().findFirst().orElse(null); + if (pool == null) { + return Collections.emptySet(); + } + return pool.permissionsFor(user, roles); + } + + // Use permissions from VM + if (vmDef == null) { + vmDef = appPipeline.fire(new GetVms().assignedFrom(model.name()) + .assignedTo(user)).get().stream().map(VmData::definition) + .findFirst().orElse(null); + } + if (vmDef == null) { + return Collections.emptySet(); + } + return vmDef.permissionsFor(user, roles); + } + + private void updatePreview(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + updateConfig(channel, model, vmDef); + updateVmDef(channel, model, vmDef); + } + + private void updateConfig(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + channel.respond(new NotifyConletView(type(), + model.getConletId(), "updateConfig", model.mode(), model.name(), + permissions(channel.session(), model, vmDef).stream() + .map(VmDefinition.Permission::toString).toList())); + } + + private void updateVmDef(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef) throws InterruptedException { + Map data = null; + if (vmDef == null) { + model.setAssignedVm(null); + } else { + model.setAssignedVm(vmDef.name()); + var session = channel.session(); + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var perms = permissions(session, model, vmDef); + try { + data = Map.of( + "metadata", Map.of("namespace", vmDef.namespace(), + "name", vmDef.name()), + "spec", vmDef.spec(), + "status", vmDef.status(), + "consoleAccessible", vmDef.consoleAccessible(user, perms)); + } catch (JsonSyntaxException e) { + logger.log(Level.SEVERE, e, + () -> "Failed to serialize VM definition"); + return; + } + } + channel.respond(new NotifyConletView(type(), + model.getConletId(), "updateVmDefinition", data)); + } + + @Override + protected void doConletDeleted(ConletDeleted event, + ConsoleConnection channel, String conletId, + ResourceModel conletState) + throws Exception { + if (event.renderModes().isEmpty()) { + channel.respond(new KeyValueStoreUpdate().delete( + storagePath(channel.session(), conletId))); + } + } + + /** + * Track the VM definitions and update conlets. + * + * @param event the event + * @param channel the channel + * @throws IOException + * @throws InterruptedException + */ + @Handler(namedChannels = "manager") + @SuppressWarnings({ "PMD.CognitiveComplexity", + "PMD.AvoidInstantiatingObjectsInLoops" }) + public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) + throws IOException, InterruptedException { + var vmDef = event.vmDefinition(); + + // Update known conlets + for (var entry : conletIdsByConsoleConnection().entrySet()) { + var connection = entry.getKey(); + var user = WebConsoleUtils.userFromSession(connection.session()) + .map(ConsoleUser::getName).orElse(null); + for (var conletId : entry.getValue()) { + var model = stateFromSession(connection.session(), conletId); + if (model.isEmpty() + || Strings.isNullOrEmpty(model.get().name())) { + continue; + } + if (model.get().mode() == ResourceModel.Mode.VM) { + // Check if this VM is used by conlet + if (!Objects.areEqual(model.get().name(), vmDef.name())) { + continue; + } + if (event.type() == K8sObserver.ResponseType.DELETED + || permissions(vmDef, connection.session()).isEmpty()) { + connection.respond( + new DeleteConlet(conletId, Collections.emptySet())); + continue; + } + } else { + // Check if VM is used by pool conlet or to be assigned to + // it + var toBeUsedByConlet = vmDef.assignment() + .map(Assignment::pool) + .map(p -> p.equals(model.get().name())).orElse(false) + && vmDef.assignment().map(Assignment::user) + .map(u -> u.equals(user)).orElse(false); + if (!Objects.areEqual(model.get().assignedVm(), + vmDef.name()) && !toBeUsedByConlet) { + continue; + } + + // Now unassigned if VM is deleted or no longer to be used + if (event.type() == K8sObserver.ResponseType.DELETED + || !toBeUsedByConlet) { + updateVmDef(connection, model.get(), null); + continue; + } + } + + // Full update because permissions may have changed + updatePreview(connection, model.get(), vmDef); + } + } + } + + /** + * On vm pool changed. + * + * @param event the event + * @throws InterruptedException the interrupted exception + */ + @Handler(namedChannels = "manager") + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onVmPoolChanged(VmPoolChanged event) + throws InterruptedException { + var poolName = event.vmPool().name(); + // Update known conlets + for (var entry : conletIdsByConsoleConnection().entrySet()) { + var connection = entry.getKey(); + for (var conletId : entry.getValue()) { + var model = stateFromSession(connection.session(), conletId); + if (model.isEmpty() + || model.get().mode() != ResourceModel.Mode.POOL + || !Objects.areEqual(model.get().name(), poolName)) { + continue; + } + if (event.deleted() + || permissions(event.vmPool(), connection.session()) + .isEmpty()) { + connection.respond( + new DeleteConlet(conletId, Collections.emptySet())); + continue; + } + updateConfig(connection, model.get(), null); + } + } + } + + @SuppressWarnings({ "PMD.NcssCount", "PMD.CognitiveComplexity", + "PMD.AvoidLiteralsInIfCondition" }) + @Override + protected void doUpdateConletState(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model) throws Exception { + event.stop(); + if ("selectedResource".equals(event.method())) { + selectResource(event, channel, model); + return; + } + + Optional vmData = getVmData(model, channel); + if (vmData.isEmpty()) { + if (model.mode() == ResourceModel.Mode.VM) { + return; + } + if ("start".equals(event.method())) { + // Assign a VM. + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(null); + vmData = Optional.ofNullable(appPipeline + .fire(new AssignVm(model.name(), user)).get()); + if (vmData.isEmpty()) { + ResourceBundle resourceBundle + = resourceBundle(channel.locale()); + channel.respond(new DisplayNotification( + resourceBundle.getString("poolEmptyNotification"), + Map.of("autoClose", 10_000, "type", "Error"))); + return; + } + } + } + + // Handle command for selected VM + var vmChannel = vmData.get().channel(); + var vmDef = vmData.get().definition(); + var vmName = vmDef.metadata().getName(); + var perms = permissions(channel.session(), model, vmDef); + var resourceBundle = resourceBundle(channel.locale()); + switch (event.method()) { + case "start": + if (perms.contains(VmDefinition.Permission.START)) { + vmChannel.fire(new ModifyVm(vmName, "state", "Running")); + } + break; + case "stop": + if (perms.contains(VmDefinition.Permission.STOP)) { + vmChannel.fire(new ModifyVm(vmName, "state", "Stopped")); + } + break; + case "reset": + if (perms.contains(VmDefinition.Permission.RESET)) { + confirmReset(event, channel, model, resourceBundle); + } + break; + case "resetConfirmed": + if (perms.contains(VmDefinition.Permission.RESET)) { + vmChannel.fire(new ResetVm(vmName)); + } + break; + case "openConsole": + openConsole(channel, model, vmChannel, vmDef, perms); + break; + default:// ignore + break; + } + } + + private void confirmReset(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model, + ResourceBundle resourceBundle) throws TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException { + Template tpl = freemarkerConfig() + .getTemplate("VmAccess-confirmReset.ftl.html"); + channel.respond(new OpenModalDialog(type(), model.getConletId(), + processTemplate(event, tpl, + fmModel(event, channel, model.getConletId(), model))) + .addOption("cancelable", true).addOption("closeLabel", "") + .addOption("title", + resourceBundle.getString("confirmResetTitle"))); + } + + private void openConsole(ConsoleConnection channel, ResourceModel model, + VmChannel vmChannel, VmDefinition vmDef, Set perms) { + var resourceBundle = resourceBundle(channel.locale()); + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + if (!vmDef.consoleAccessible(user, perms)) { + channel.respond(new DisplayNotification( + resourceBundle.getString("consoleInaccessibleNotification"), + Map.of("autoClose", 5_000, "type", "Warning"))); + return; + } + var pwQuery = Event.onCompletion(new GetDisplaySecret(vmDef, user), + e -> gotPassword(channel, model, vmDef, e)); + vmChannel.fire(pwQuery); + } + + private void gotPassword(ConsoleConnection channel, ResourceModel model, + VmDefinition vmDef, GetDisplaySecret event) { + if (!event.secretAvailable()) { + return; + } + vmDef.extra().connectionFile(event.secret(), + preferredIpVersion, deleteConnectionFile) + .ifPresent(cf -> channel.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", cf))); + } + + @SuppressWarnings({ "PMD.UseLocaleWithCaseConversions" }) + private void selectResource(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model) + throws JsonProcessingException, InterruptedException { + try { + model.setMode(ResourceModel.Mode + .valueOf(event. param(0).toUpperCase())); + model.setName(event.param(1)); + String jsonState = objectMapper.writeValueAsString(model); + channel.respond(new KeyValueStoreUpdate().update(storagePath( + channel.session(), model.getConletId()), jsonState)); + updatePreview(channel, model, + getVmData(model, channel).map(VmData::definition).orElse(null)); + } catch (IllegalArgumentException e) { + logger.warning(() -> "Invalid resource type: " + e.getMessage()); + } + } + + @Override + protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, + String conletId) throws Exception { + return true; + } + + /** + * The Class AccessModel. + */ + public static class ResourceModel extends ConletBaseModel { + + /** + * The Enum ResourceType. + */ + @SuppressWarnings("PMD.ShortVariable") + public enum Mode { + VM, POOL + } + + private Mode mode; + private String name; + private String assignedVm; + + /** + * Instantiates a new resource model. + * + * @param conletId the conlet id + */ + public ResourceModel(@JsonProperty("conletId") String conletId) { + super(conletId); + } + + /** + * Returns the mode. + * + * @return the resourceType + */ + @JsonGetter("mode") + public Mode mode() { + return mode; + } + + /** + * Sets the mode. + * + * @param mode the resource mode to set + */ + public void setMode(Mode mode) { + this.mode = mode; + } + + /** + * Gets the resource name. + * + * @return the string + */ + @JsonGetter("name") + public String name() { + return name; + } + + /** + * Sets the name. + * + * @param name the resource name to set + */ + public void setName(String name) { + this.name = name; + } + + /** + * Gets the assigned vm. + * + * @return the string + */ + @JsonGetter("assignedVm") + public String assignedVm() { + return assignedVm; + } + + /** + * Sets the assigned vm. + * + * @param name the assigned vm + */ + public void setAssignedVm(String name) { + this.assignedVm = name; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = super.hashCode(); + result = prime * result + java.util.Objects.hash(mode, name); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!super.equals(obj)) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ResourceModel other = (ResourceModel) obj; + return mode == other.mode + && java.util.Objects.equals(name, other.name); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(50); + builder.append("AccessModel [mode=").append(mode) + .append(", name=").append(name).append(']'); + return builder.toString(); + } + } +} diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.java new file mode 100644 index 0000000..5140056 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/VmAccessFactory.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.vmaccess; + +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 VmAccess}s. + */ +public class VmAccessFactory implements ConletComponentFactory { + + /* + * (non-Javadoc) + * + * @see org.jgrapes.core.ComponentFactory#componentType() + */ + @Override + public Class componentType() { + return VmAccess.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 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.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub.d.ts b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub.d.ts new file mode 100644 index 0000000..8ca03f3 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub.d.ts @@ -0,0 +1 @@ +export default new Map>(); diff --git a/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java new file mode 100644 index 0000000..745ded7 --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/src/org/jdrupes/vmoperator/vmaccess/package-info.java @@ -0,0 +1,19 @@ +/* + * VM-Operator + * Copyright (C) 2023, 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.vmaccess; diff --git a/org.jdrupes.vmoperator.vmaccess/tsconfig.json b/org.jdrupes.vmoperator.vmaccess/tsconfig.json new file mode 100644 index 0000000..d9dbb3f --- /dev/null +++ b/org.jdrupes.vmoperator.vmaccess/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2015", + "module": "es2015", + "sourceMap": true, + "inlineSources": true, + "declaration": true, + "importHelpers": true, + "strict": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "lib": ["DOM", "ES2020"], + "paths": { + "aash-plugin": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/aash-vue-components/lib/AashPlugin"], + "jgconsole": ["./build/unpacked/org/jgrapes/webconsole/base/JGConsole"], + "jgwc": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/jgwc-vue-components/jgwc-components"], + "l10nBundles": ["./src/org/jdrupes/vmoperator/vmaccess/browser/l10nBundles-stub"], + "vue": ["./build/unpacked/org/jgrapes/webconsole/provider/vue/vue/vue"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "l10nBundles-stub.ts"] +} diff --git a/org.jdrupes.vmoperator.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.vmmgmt/.eclipse-pmd b/org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd new file mode 100644 index 0000000..5d69caa --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/.eclipse-pmd @@ -0,0 +1,7 @@ + + + + + + + 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.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js new file mode 100644 index 0000000..a4f1d50 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/resources/org/jdrupes/vmoperator/vmmgmt/VmMgmt-l10nBundles.ftl.js @@ -0,0 +1,31 @@ +/* + * Moodle Tools Console + * Copyright (C) 2022 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 + * 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 General Public License + * for more details. + * + * You should have received a copy of the GNU 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.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.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/package-info.java b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/package-info.java new file mode 100644 index 0000000..c39c193 --- /dev/null +++ b/org.jdrupes.vmoperator.vmmgmt/src/org/jdrupes/vmoperator/vmmgmt/package-info.java @@ -0,0 +1,19 @@ +/* + * 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; \ 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 new file mode 100644 index 0000000..4bfe990 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12604 @@ +{ + "name": "VM-Operator", + "lockfileVersion": 3, + "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": "^4.1.5", + "rollup-plugin-peer-deps-external": "^2.2.3", + "rollup-plugin-postcss": "^4.0.2", + "rollup-plugin-typescript2": "^0.36.0", + "rollup-plugin-vue": "^6.0.0", + "sass": "^1.49.9", + "terser": "^5.14.2", + "tslib": "^2.3.1", + "typedoc": "^0.25.1", + "typedoc-plugin-missing-exports": "^2.1.0", + "typescript": "^5.2.2" + } + }, + "node_modules/@ampproject/remapping": { + "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.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "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.24.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "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.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.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", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "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.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.24.7", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "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.24.7", + "@babel/helper-validator-option": "^7.24.7", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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.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.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "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.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "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/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "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.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" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "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/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "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.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "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.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.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.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.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "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.24.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/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/highlight/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/highlight/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/highlight/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/highlight/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/highlight/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/parser": { + "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" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "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.24.7", + "@babel/parser": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "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.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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "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.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/@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": { + "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.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "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.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.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.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "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", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdoc/salty": { + "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" + }, + "engines": { + "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", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "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", + "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-builtin-module": "^3.2.1", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "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.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "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", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/debug": { + "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.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.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.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": "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": "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": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "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": "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.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.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.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": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", + "integrity": "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==", + "dev": true + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "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.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.10", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + }, + "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": { + "@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.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "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.4.29", + "@vue/shared": "3.4.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "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.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.10", + "postcss": "^8.4.38", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "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.4.29", + "@vue/shared": "3.4.29" + } + }, + "node_modules/@vue/shared": { + "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": "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.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" + }, + "engines": { + "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", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dev": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "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", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-sequence-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz", + "integrity": "sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==", + "dev": true + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "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==" + }, + "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", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/async-foreach": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", + "integrity": "sha512-VUeSMD8nEGBWaZK4lizI1sf3yEC7pnAQ/mrI7pC2fBz2s/tq5jWWEngTwaf0Gruu/OoXRGLGg1XFqpYBiGTYJA==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==" + }, + "node_modules/binary-extensions": { + "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": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "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.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "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" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "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", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "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": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dev": true, + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "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==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "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==", + "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.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, + "dependencies": { + "source-map": "^0.6.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", + "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", + "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "dev": true, + "dependencies": { + "cssnano-preset-default": "^5.2.14", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", + "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "dev": true, + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.2", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "optional": true + }, + "node_modules/debug": { + "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" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decode-named-character-reference": { + "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==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "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", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "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.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", + "integrity": "sha512-nliqOv5NkE4zMON4UA6AMJE6As35afs8aYXATpU4pTUdIKiARZwrJVEP1boA3Rx1ZXHVkwxkhcq4VkqvsuRLsA==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/documentation": { + "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", + "@babel/generator": "^7.18.10", + "@babel/parser": "^7.18.11", + "@babel/traverse": "^7.18.11", + "@babel/types": "^7.18.10", + "chalk": "^5.0.1", + "chokidar": "^3.5.3", + "diff": "^5.1.0", + "doctrine-temporary-fork": "2.1.0", + "git-url-parse": "^13.1.0", + "github-slugger": "1.4.0", + "glob": "^8.0.3", + "globals-docs": "^2.4.1", + "highlight.js": "^11.6.0", + "ini": "^3.0.0", + "js-yaml": "^4.1.0", + "konan": "^2.1.1", + "lodash": "^4.17.21", + "mdast-util-find-and-replace": "^2.2.1", + "mdast-util-inject": "^1.1.0", + "micromark-util-character": "^1.1.0", + "parse-filepath": "^1.0.2", + "pify": "^6.0.0", + "read-pkg-up": "^9.1.0", + "remark": "^14.0.2", + "remark-gfm": "^3.0.1", + "remark-html": "^15.0.1", + "remark-reference-links": "^6.0.1", + "remark-toc": "^8.0.1", + "resolve": "^1.22.1", + "strip-json-comments": "^5.0.0", + "unist-builder": "^3.0.0", + "unist-util-visit": "^4.1.0", + "vfile": "^5.3.4", + "vfile-reporter": "^7.0.4", + "vfile-sort": "^3.0.0", + "yargs": "^17.5.1" + }, + "bin": { + "documentation": "bin/documentation.js" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@vue/compiler-sfc": "^3.2.37", + "vue-template-compiler": "^2.7.8" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "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", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "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": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/entities": { + "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" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "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": "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": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "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.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" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "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": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "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", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "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==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaze": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", + "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", + "dev": true, + "dependencies": { + "globule": "^1.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/generic-names": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", + "integrity": "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==", + "dev": true, + "dependencies": { + "loader-utils": "^3.2.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/git-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/git-up/-/git-up-7.0.0.tgz", + "integrity": "sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==", + "dev": true, + "dependencies": { + "is-ssh": "^1.4.0", + "parse-url": "^8.1.0" + } + }, + "node_modules/git-url-parse": { + "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" + } + }, + "node_modules/github-slugger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.4.0.tgz", + "integrity": "sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==", + "dev": true + }, + "node_modules/glob": { + "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", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "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", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/globals-docs": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/globals-docs/-/globals-docs-2.4.1.tgz", + "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", + "integrity": "sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==", + "dev": true, + "dependencies": { + "glob": "~7.1.1", + "lodash": "^4.17.21", + "minimatch": "~3.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/globule/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/globule/node_modules/minimatch": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", + "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "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", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "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", + "integrity": "sha512-Nz7FfPBuljzsN3tCQ4kCBKqdNhQE2l0Tn+X1ubgKBPRoiDIu1mL08Cfw4k7q71+Duyaw7DXDN+VTAp4Vh3oCOw==", + "dev": true, + "dependencies": { + "@types/hast": "^2.0.0", + "@types/unist": "^2.0.0", + "hastscript": "^7.0.0", + "property-information": "^6.0.0", + "vfile": "^5.0.0", + "vfile-location": "^4.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz", + "integrity": "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==", + "dev": true, + "dependencies": { + "@types/hast": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-7.2.3.tgz", + "integrity": "sha512-RujVQfVsOrxzPOPSzZFiwofMArbQke6DJjnFfceiEbFh7S05CbPt0cYN+A5YeD3pso0JQk6O1aHBnx9+Pm2uqg==", + "dev": true, + "dependencies": { + "@types/hast": "^2.0.0", + "@types/parse5": "^6.0.0", + "hast-util-from-parse5": "^7.0.0", + "hast-util-to-parse5": "^7.0.0", + "html-void-elements": "^2.0.0", + "parse5": "^6.0.0", + "unist-util-position": "^4.0.0", + "unist-util-visit": "^4.0.0", + "vfile": "^5.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-4.1.0.tgz", + "integrity": "sha512-Hd9tU0ltknMGRDv+d6Ro/4XKzBqQnP/EZrpiTbpFYfXv/uOhWeKc+2uajcbEvAEH98VZd7eII2PiXm13RihnLw==", + "dev": true, + "dependencies": { + "@types/hast": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-8.0.4.tgz", + "integrity": "sha512-4tpQTUOr9BMjtYyNlt0P50mH7xj0Ks2xpo8M943Vykljf99HW6EzulIoJP1N3eKOSScEHzyzi9dm7/cn0RfGwA==", + "dev": true, + "dependencies": { + "@types/hast": "^2.0.0", + "@types/unist": "^2.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-raw": "^7.0.0", + "hast-util-whitespace": "^2.0.0", + "html-void-elements": "^2.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-7.1.0.tgz", + "integrity": "sha512-YNRgAJkH2Jky5ySkIqFXTQiaqcAtJyVE+D5lkN6CdtOqrnkLfGYYrEcKuHOJZlp+MwjSwuD3fZuawI+sic/RBw==", + "dev": true, + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", + "integrity": "sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-7.2.0.tgz", + "integrity": "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==", + "dev": true, + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^3.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "optional": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.9.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz", + "integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/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/hosted-git-info/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/html-void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz", + "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-replace-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", + "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==", + "dev": true + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "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.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": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", + "integrity": "sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==", + "dev": true, + "dependencies": { + "import-from": "^3.0.0" + }, + "engines": { + "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", + "integrity": "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "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", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/ini": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-3.0.1.tgz", + "integrity": "sha512-it4HyVAUTKBc6m8e1iXWvXSTdndF7HbdN713+kvLrymxTaU4AUBWrJ4vEooP+V7fexnVD3LKcBshjGGPefSMUQ==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/install": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz", + "integrity": "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "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", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "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", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "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" + } + ], + "engines": { + "node": ">=4" + } + }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-core-module": { + "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": { + "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", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "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", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "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", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-ssh": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.0.tgz", + "integrity": "sha512-x7+VxdxOdlV3CYpjvRLBv5Lo9OJerlYanjwFrPR9fuGPjCiNiCzFgAWpiLAohSbsnH4ZAys3SBh+hq5rJosxUQ==", + "dev": true, + "dependencies": { + "protocols": "^2.0.1" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "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", + "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dev": true, + "dependencies": { + "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.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": "^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": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "underscore": "~1.13.2" + }, + "bin": { + "jsdoc": "jsdoc.js" + }, + "engines": { + "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", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "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", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "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", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "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", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.9" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/konan": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/konan/-/konan-2.1.1.tgz", + "integrity": "sha512-7ZhYV84UzJ0PR/RJnnsMZcAbn+kLasJhVNWsu8ZyVEJYRpGA5XESQ9d/7zOa08U0Ou4cmB++hMNY/3OSV9KIbg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.10.5", + "@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", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/linkify-it": { + "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": "^2.0.0" + } + }, + "node_modules/loader-utils": { + "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": "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": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "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", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true + }, + "node_modules/magic-string": { + "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.15" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "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", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-it": { + "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": "^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.mjs" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "dev": true, + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/markdown-table": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz", + "integrity": "sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==", + "dev": true, + "funding": { + "type": "github", + "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", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/mdast-util-definitions": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", + "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz", + "integrity": "sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^5.0.0", + "unist-util-visit-parents": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", + "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "mdast-util-to-string": "^3.1.0", + "micromark": "^3.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-decode-string": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "unist-util-stringify-position": "^3.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown/node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-2.0.2.tgz", + "integrity": "sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==", + "dev": true, + "dependencies": { + "mdast-util-from-markdown": "^1.0.0", + "mdast-util-gfm-autolink-literal": "^1.0.0", + "mdast-util-gfm-footnote": "^1.0.0", + "mdast-util-gfm-strikethrough": "^1.0.0", + "mdast-util-gfm-table": "^1.0.0", + "mdast-util-gfm-task-list-item": "^1.0.0", + "mdast-util-to-markdown": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-1.0.3.tgz", + "integrity": "sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "ccount": "^2.0.0", + "mdast-util-find-and-replace": "^2.0.0", + "micromark-util-character": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-1.0.2.tgz", + "integrity": "sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.3.0", + "micromark-util-normalize-identifier": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-1.0.3.tgz", + "integrity": "sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-1.0.7.tgz", + "integrity": "sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^1.0.0", + "mdast-util-to-markdown": "^1.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-1.0.2.tgz", + "integrity": "sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-inject": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-inject/-/mdast-util-inject-1.1.0.tgz", + "integrity": "sha512-CcJ0mHa36QYumDKiZ2OIR+ClhfOM7zIzN+Wfy8tRZ1hpH9DKLCS+Mh4DyK5bCxzE9uxMWcbIpeNFWsg1zrj/2g==", + "dev": true, + "dependencies": { + "mdast-util-to-string": "^1.0.0" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "unist-util-is": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz", + "integrity": "sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==", + "dev": true, + "dependencies": { + "@types/hast": "^2.0.0", + "@types/mdast": "^3.0.0", + "mdast-util-definitions": "^5.0.0", + "micromark-util-sanitize-uri": "^1.1.0", + "trim-lines": "^3.0.0", + "unist-util-generated": "^2.0.0", + "unist-util-position": "^4.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz", + "integrity": "sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^3.0.0", + "mdast-util-to-string": "^3.0.0", + "micromark-util-decode-string": "^1.0.0", + "unist-util-visit": "^4.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown/node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz", + "integrity": "sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-toc": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-toc/-/mdast-util-toc-6.1.1.tgz", + "integrity": "sha512-Er21728Kow8hehecK2GZtb7Ny3omcoPUVrmObiSUwmoRYVZaXLR751QROEFjR8W/vAQdHMLj49Lz20J55XaNpw==", + "dev": true, + "dependencies": { + "@types/extend": "^3.0.0", + "@types/mdast": "^3.0.0", + "extend": "^3.0.0", + "github-slugger": "^2.0.0", + "mdast-util-to-string": "^3.1.0", + "unist-util-is": "^5.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-toc/node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "dev": true + }, + "node_modules/mdast-util-toc/node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true + }, + "node_modules/mdurl": { + "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", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", + "dev": true, + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize": "^1.2.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/meow/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "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", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/meow/node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "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", + "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "micromark-core-commonmark": "^1.0.1", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", + "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-factory-destination": "^1.0.0", + "micromark-factory-label": "^1.0.0", + "micromark-factory-space": "^1.0.0", + "micromark-factory-title": "^1.0.0", + "micromark-factory-whitespace": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-classify-character": "^1.0.0", + "micromark-util-html-tag-name": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "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", + "integrity": "sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==", + "dev": true, + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^1.0.0", + "micromark-extension-gfm-footnote": "^1.0.0", + "micromark-extension-gfm-strikethrough": "^1.0.0", + "micromark-extension-gfm-table": "^1.0.0", + "micromark-extension-gfm-tagfilter": "^1.0.0", + "micromark-extension-gfm-task-list-item": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-types": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-1.0.5.tgz", + "integrity": "sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==", + "dev": true, + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-1.1.2.tgz", + "integrity": "sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==", + "dev": true, + "dependencies": { + "micromark-core-commonmark": "^1.0.0", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-1.0.7.tgz", + "integrity": "sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==", + "dev": true, + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-classify-character": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-1.0.7.tgz", + "integrity": "sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==", + "dev": true, + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-1.0.2.tgz", + "integrity": "sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==", + "dev": true, + "dependencies": { + "micromark-util-types": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-1.0.5.tgz", + "integrity": "sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==", + "dev": true, + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "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", + "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", + "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", + "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", + "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", + "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", + "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", + "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", + "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", + "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", + "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", + "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", + "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", + "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", + "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", + "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "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", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "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/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": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass/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/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/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/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "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==" + }, + "node_modules/nan": { + "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.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "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", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-gyp": { + "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", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^4.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/@npmcli/fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", + "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-gyp/node_modules/cacache": { + "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": "^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.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "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", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "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": "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/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==", + "extraneous": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "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", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nopt": { + "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": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/npm/-/npm-8.19.4.tgz", + "integrity": "sha512-3HANl8i9DKnUA89P4KEgVNN28EjSeDCmvEqbzOAuxCFDzdBZzjUl99zgnGpOUumvW5lvJo2HKcjrsc+tfyv1Hw==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/ci-detect", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/run-script", + "abbrev", + "archy", + "cacache", + "chalk", + "chownr", + "cli-columns", + "cli-table3", + "columnify", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmhook", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "mkdirp", + "mkdirp-infer-owner", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "npmlog", + "opener", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "read-package-json", + "read-package-json-fast", + "readdir-scoped-modules", + "rimraf", + "semver", + "ssri", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which", + "write-file-atomic" + ], + "dev": true, + "workspaces": [ + "docs", + "smoke-tests", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^5.6.3", + "@npmcli/ci-detect": "^2.0.0", + "@npmcli/config": "^4.2.1", + "@npmcli/fs": "^2.1.0", + "@npmcli/map-workspaces": "^2.0.3", + "@npmcli/package-json": "^2.0.0", + "@npmcli/run-script": "^4.2.1", + "abbrev": "~1.1.1", + "archy": "~1.0.0", + "cacache": "^16.1.3", + "chalk": "^4.1.2", + "chownr": "^2.0.0", + "cli-columns": "^4.0.0", + "cli-table3": "^0.6.2", + "columnify": "^1.6.0", + "fastest-levenshtein": "^1.0.12", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "graceful-fs": "^4.2.10", + "hosted-git-info": "^5.2.1", + "ini": "^3.0.1", + "init-package-json": "^3.0.2", + "is-cidr": "^4.0.2", + "json-parse-even-better-errors": "^2.3.1", + "libnpmaccess": "^6.0.4", + "libnpmdiff": "^4.0.5", + "libnpmexec": "^4.0.14", + "libnpmfund": "^3.0.5", + "libnpmhook": "^8.0.4", + "libnpmorg": "^4.0.4", + "libnpmpack": "^4.1.3", + "libnpmpublish": "^6.0.5", + "libnpmsearch": "^5.0.4", + "libnpmteam": "^4.0.4", + "libnpmversion": "^3.0.7", + "make-fetch-happen": "^10.2.0", + "minimatch": "^5.1.0", + "minipass": "^3.1.6", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "mkdirp-infer-owner": "^2.0.0", + "ms": "^2.1.2", + "node-gyp": "^10.1.0", + "nopt": "^6.0.0", + "npm-audit-report": "^3.0.0", + "npm-install-checks": "^5.0.0", + "npm-package-arg": "^9.1.0", + "npm-pick-manifest": "^7.0.2", + "npm-profile": "^6.2.0", + "npm-registry-fetch": "^13.3.1", + "npm-user-validate": "^1.0.1", + "npmlog": "^6.0.2", + "opener": "^1.5.2", + "p-map": "^4.0.0", + "pacote": "^13.6.2", + "parse-conflict-json": "^2.0.2", + "proc-log": "^2.0.1", + "qrcode-terminal": "^0.12.0", + "read": "~1.0.7", + "read-package-json": "^5.0.2", + "read-package-json-fast": "^2.0.3", + "readdir-scoped-modules": "^1.1.0", + "rimraf": "^3.0.2", + "semver": "^7.3.7", + "ssri": "^9.0.1", + "tar": "^6.1.11", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^2.0.0", + "validate-npm-package-name": "^4.0.0", + "which": "^2.0.2", + "write-file-atomic": "^4.0.1" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@colors/colors": { + "version": "1.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/npm/node_modules/@gar/promisify": { + "version": "1.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "5.6.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/installed-package-contents": "^1.0.7", + "@npmcli/map-workspaces": "^2.0.3", + "@npmcli/metavuln-calculator": "^3.0.1", + "@npmcli/move-file": "^2.0.0", + "@npmcli/name-from-folder": "^1.0.1", + "@npmcli/node-gyp": "^2.0.0", + "@npmcli/package-json": "^2.0.0", + "@npmcli/query": "^1.2.0", + "@npmcli/run-script": "^4.1.3", + "bin-links": "^3.0.3", + "cacache": "^16.1.3", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^5.2.1", + "json-parse-even-better-errors": "^2.3.1", + "json-stringify-nice": "^1.1.4", + "minimatch": "^5.1.0", + "mkdirp": "^1.0.4", + "mkdirp-infer-owner": "^2.0.0", + "nopt": "^6.0.0", + "npm-install-checks": "^5.0.0", + "npm-package-arg": "^9.0.0", + "npm-pick-manifest": "^7.0.2", + "npm-registry-fetch": "^13.0.0", + "npmlog": "^6.0.2", + "pacote": "^13.6.1", + "parse-conflict-json": "^2.0.1", + "proc-log": "^2.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^1.0.1", + "read-package-json-fast": "^2.0.2", + "readdir-scoped-modules": "^1.1.0", + "rimraf": "^3.0.2", + "semver": "^7.3.7", + "ssri": "^9.0.0", + "treeverse": "^2.0.0", + "walk-up-path": "^1.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/ci-detect": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "4.2.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^2.0.2", + "ini": "^3.0.0", + "mkdirp-infer-owner": "^2.0.0", + "nopt": "^6.0.0", + "proc-log": "^2.0.0", + "read-package-json-fast": "^2.0.3", + "semver": "^7.3.5", + "walk-up-path": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/disparity-colors": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ansi-styles": "^4.3.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^3.0.0", + "lru-cache": "^7.4.4", + "mkdirp": "^1.0.4", + "npm-pick-manifest": "^7.0.0", + "proc-log": "^2.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^2.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "1.0.7", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^1.1.1", + "npm-normalize-package-bin": "^1.0.1" + }, + "bin": { + "installed-package-contents": "index.js" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents/node_modules/npm-bundled": { + "version": "1.1.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "2.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^1.0.1", + "glob": "^8.0.1", + "minimatch": "^5.0.1", + "read-package-json-fast": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^16.0.0", + "json-parse-even-better-errors": "^2.3.1", + "pacote": "^13.0.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/move-file": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^2.3.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "infer-owner": "^1.0.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "1.2.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^9.1.0", + "postcss-selector-parser": "^6.0.10", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "4.2.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^2.0.0", + "@npmcli/promise-spawn": "^3.0.0", + "node-gyp": "^9.0.0", + "read-package-json-fast": "^2.0.3", + "which": "^2.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@tootallnate/once": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/agent-base": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/npm/node_modules/agentkeepalive": { + "version": "4.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "depd": "^1.1.2", + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/npm/node_modules/aggregate-error": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/are-we-there-yet": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/asap": { + "version": "2.0.6", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/bin-links": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^5.0.0", + "mkdirp-infer-owner": "^2.0.0", + "npm-normalize-package-bin": "^2.0.0", + "read-cmd-shim": "^3.0.0", + "rimraf": "^3.0.0", + "write-file-atomic": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/bin-links/node_modules/npm-normalize-package-bin": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "2.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/builtins": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "semver": "^7.0.0" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "16.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "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/npm/node_modules/chownr": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^4.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/clean-stack": { + "version": "2.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/cli-table3": { + "version": "0.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/npm/node_modules/clone": { + "version": "1.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mkdirp-infer-owner": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/color-support": { + "version": "1.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/npm/node_modules/columnify": { + "version": "1.6.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "strip-ansi": "^6.0.1", + "wcwidth": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/console-control-strings": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.3.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/debuglog": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/npm/node_modules/defaults": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + } + }, + "node_modules/npm/node_modules/delegates": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/depd": { + "version": "1.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/dezalgo": { + "version": "1.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/npm/node_modules/diff": { + "version": "5.1.0", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.12", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/function-bind": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/gauge": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "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" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.10", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/has": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/npm/node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/has-unicode": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "5.2.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^7.5.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/npm/node_modules/humanize-ms": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^5.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/npm/node_modules/indent-string": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/infer-owner": { + "version": "1.0.4", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/npm/node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/ini": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^9.0.1", + "promzard": "^0.3.0", + "read": "^1.0.7", + "read-package-json": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/ip": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/ip-regex": { + "version": "4.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^3.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/is-core-module": { + "version": "2.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/is-lambda": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "5.1.1", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.4.1", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "6.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "minipass": "^3.1.1", + "npm-package-arg": "^9.0.1", + "npm-registry-fetch": "^13.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "4.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/disparity-colors": "^2.0.0", + "@npmcli/installed-package-contents": "^1.0.7", + "binary-extensions": "^2.2.0", + "diff": "^5.1.0", + "minimatch": "^5.0.1", + "npm-package-arg": "^9.0.1", + "pacote": "^13.6.1", + "tar": "^6.1.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "4.0.14", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^5.6.3", + "@npmcli/ci-detect": "^2.0.0", + "@npmcli/fs": "^2.1.1", + "@npmcli/run-script": "^4.2.0", + "chalk": "^4.1.0", + "mkdirp-infer-owner": "^2.0.0", + "npm-package-arg": "^9.0.1", + "npmlog": "^6.0.2", + "pacote": "^13.6.1", + "proc-log": "^2.0.0", + "read": "^1.0.7", + "read-package-json-fast": "^2.0.2", + "semver": "^7.3.7", + "walk-up-path": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "3.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^5.6.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmhook": { + "version": "8.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^13.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^13.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "4.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/run-script": "^4.1.3", + "npm-package-arg": "^9.0.1", + "pacote": "^13.6.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "6.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "normalize-package-data": "^4.0.0", + "npm-package-arg": "^9.0.1", + "npm-registry-fetch": "^13.0.0", + "semver": "^7.3.7", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "5.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^13.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^13.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "3.0.7", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^3.0.0", + "@npmcli/run-script": "^4.1.3", + "json-parse-even-better-errors": "^2.3.1", + "proc-log": "^2.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "7.13.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "10.2.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "5.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "3.3.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "2.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-json-stream": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsonparse": "^1.3.1", + "minipass": "^3.0.0" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/mkdirp-infer-owner": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "infer-owner": "^1.0.4", + "mkdirp": "^1.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "0.0.8", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/negotiator": { + "version": "0.6.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "9.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "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/npm/node_modules/node-gyp/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/nopt": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/normalize-package-data": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^5.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-bundled/node_modules/npm-normalize-package-bin": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "9.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^5.0.0", + "proc-log": "^2.0.1", + "semver": "^7.3.5", + "validate-npm-package-name": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "5.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^8.0.1", + "ignore-walk": "^5.0.1", + "npm-bundled": "^2.0.0", + "npm-normalize-package-bin": "^2.0.0" + }, + "bin": { + "npm-packlist": "bin/index.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-packlist/node_modules/npm-normalize-package-bin": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^5.0.0", + "npm-normalize-package-bin": "^2.0.0", + "npm-package-arg": "^9.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest/node_modules/npm-normalize-package-bin": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "6.2.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^13.0.1", + "proc-log": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "13.3.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "make-fetch-happen": "^10.0.6", + "minipass": "^3.1.6", + "minipass-fetch": "^2.0.3", + "minipass-json-stream": "^1.0.1", + "minizlib": "^2.1.2", + "npm-package-arg": "^9.0.1", + "proc-log": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/npmlog": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "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/npm/node_modules/once": { + "version": "1.4.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/npm/node_modules/opener": { + "version": "1.5.2", + "dev": true, + "inBundle": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/pacote": { + "version": "13.6.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^3.0.0", + "@npmcli/installed-package-contents": "^1.0.7", + "@npmcli/promise-spawn": "^3.0.0", + "@npmcli/run-script": "^4.1.0", + "cacache": "^16.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "infer-owner": "^1.0.4", + "minipass": "^3.1.6", + "mkdirp": "^1.0.4", + "npm-package-arg": "^9.0.0", + "npm-packlist": "^5.1.0", + "npm-pick-manifest": "^7.0.0", + "npm-registry-fetch": "^13.0.1", + "proc-log": "^2.0.0", + "promise-retry": "^2.0.1", + "read-package-json": "^5.0.0", + "read-package-json-fast": "^2.0.3", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "lib/bin.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^2.3.1", + "just-diff": "^5.0.1", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-inflight": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "0.3.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "1" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "1.0.7", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/read-package-json": { + "version": "5.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^8.0.1", + "json-parse-even-better-errors": "^2.3.1", + "normalize-package-data": "^4.0.0", + "npm-normalize-package-bin": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/read-package-json-fast": { + "version": "2.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^2.3.0", + "npm-normalize-package-bin": "^1.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/read-package-json/node_modules/npm-normalize-package-bin": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/readable-stream": { + "version": "3.6.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/npm/node_modules/readdir-scoped-modules": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "debuglog": "^1.0.1", + "dezalgo": "^1.0.0", + "graceful-fs": "^4.1.2", + "once": "^1.3.0" + } + }, + "node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm/node_modules/rimraf": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm/node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "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/npm/node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/npm/node_modules/safe-buffer": { + "version": "5.2.1", + "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" + } + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.3.7", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/set-blocking": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.7.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/spdx-correct": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.3.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.11", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/ssri": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/string_decoder": { + "version": "1.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "6.1.11", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^3.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/treeverse": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/unique-filename": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/unique-slug": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "builtins": "^5.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/wcwidth": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/npm/node_modules/which": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/wide-align": { + "version": "1.1.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/npm/node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "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": { + "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": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "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": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "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": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "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", + "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "dev": true, + "dependencies": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-path": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.0.0.tgz", + "integrity": "sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==", + "dev": true, + "dependencies": { + "protocols": "^2.0.0" + } + }, + "node_modules/parse-url": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-8.1.0.tgz", + "integrity": "sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==", + "dev": true, + "dependencies": { + "parse-path": "^7.0.0" + } + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "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/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "dev": true, + "dependencies": { + "path-root-regex": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "dev": true, + "engines": { + "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.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-6.1.0.tgz", + "integrity": "sha512-KocF8ve28eFjjuBKKGvzOBGzG8ew2OqOOSxTTZhirkzH7h3BI1vyzqlR0qbfcDBve1Yzo3FVlWUAtCRrbVN8Fw==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", + "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", + "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-4.3.1.tgz", + "integrity": "sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==", + "dev": true, + "dependencies": { + "generic-names": "^4.0.0", + "icss-replace-symbols": "^1.1.0", + "lodash.camelcase": "^4.3.0", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "string-hash": "^1.1.1" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-modules-extract-imports": { + "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" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "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", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "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" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", + "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-selector-parser": { + "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", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "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", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise.series": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/promise.series/-/promise.series-0.2.0.tgz", + "integrity": "sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==", + "dev": true, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/property-information": { + "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", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/protocols": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.1.tgz", + "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", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/read-pkg": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-7.1.0.tgz", + "integrity": "sha512-5iOehe+WF75IccPc30bWTbpdDQLOCc3Uu8bi3Dte3Eueij81yx1Mrufk8qBx/YAbR4uL1FdUr+7BKXDwEtisXg==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.1", + "normalize-package-data": "^3.0.2", + "parse-json": "^5.2.0", + "type-fest": "^2.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-9.1.0.tgz", + "integrity": "sha512-vaMRR1AC1nrd5CQM0PhlRsO5oc2AAigqr7cCrZ/MW/Rsaflz4RlgzkpL4qoU/z1F6wrbd85iFv1OQj/y5RdGvg==", + "dev": true, + "dependencies": { + "find-up": "^6.3.0", + "read-pkg": "^7.1.0", + "type-fest": "^2.5.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/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", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "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", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/remark": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.3.tgz", + "integrity": "sha512-bfmJW1dmR2LvaMJuAnE88pZP9DktIFYXazkTfOIKZzi3Knk9lT0roItIA24ydOucI3bV/g/tXBA6hzqq3FV9Ew==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "remark-parse": "^10.0.0", + "remark-stringify": "^10.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz", + "integrity": "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-gfm": "^2.0.0", + "micromark-extension-gfm": "^2.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-html": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/remark-html/-/remark-html-15.0.2.tgz", + "integrity": "sha512-/CIOI7wzHJzsh48AiuIyIe1clxVkUtreul73zcCXLub0FmnevQE0UMFDQm7NUx8/3rl/4zCshlMfqBdWScQthw==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "hast-util-sanitize": "^4.0.0", + "hast-util-to-html": "^8.0.0", + "mdast-util-to-hast": "^12.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.2.tgz", + "integrity": "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-from-markdown": "^1.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-reference-links": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/remark-reference-links/-/remark-reference-links-6.0.1.tgz", + "integrity": "sha512-34wY2C6HXSuKVTRtyJJwefkUD8zBOZOSHFZ4aSTnU2F656gr9WeuQ2dL6IJDK3NPd2F6xKF2t4XXcQY9MygAXg==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-10.0.3.tgz", + "integrity": "sha512-koyOzCMYoUHudypbj4XpnAKFbkddRMYZHwghnxd7ue5210WzGw6kOBwauJTRUMq16jsovXx8dYNvSSWP89kZ3A==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-toc": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/remark-toc/-/remark-toc-8.0.1.tgz", + "integrity": "sha512-7he2VOm/cy13zilnOTZcyAoyoolV26ULlon6XyCFU+vG54Z/LWJnwphj/xKIDLOt66QmJUgTyUvLVHi2aAElyg==", + "dev": true, + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-toc": "^6.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "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": ">=4" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "engines": { + "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" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "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": ">=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" + } + }, + "node_modules/rollup-plugin-peer-deps-external": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz", + "integrity": "sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g==", + "dev": true, + "peerDependencies": { + "rollup": "*" + } + }, + "node_modules/rollup-plugin-postcss": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz", + "integrity": "sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "concat-with-sourcemaps": "^1.1.0", + "cssnano": "^5.0.1", + "import-cwd": "^3.0.0", + "p-queue": "^6.6.2", + "pify": "^5.0.0", + "postcss-load-config": "^3.0.0", + "postcss-modules": "^4.0.0", + "promise.series": "^0.2.0", + "resolve": "^1.19.0", + "rollup-pluginutils": "^2.8.2", + "safe-identifier": "^0.4.2", + "style-inject": "^0.3.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "postcss": "8.x" + } + }, + "node_modules/rollup-plugin-postcss/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/rollup-plugin-postcss/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/rollup-plugin-postcss/node_modules/pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-postcss/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, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-typescript2": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz", + "integrity": "sha512-NB2CSQDxSe9+Oe2ahZbf+B4bh7pHwjV5L+RSYpCu7Q5ROuN94F9b6ioWwKfz3ueL3KTtmX4o2MUH2cgHDIEUsw==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^4.1.2", + "find-cache-dir": "^3.3.2", + "fs-extra": "^10.0.0", + "semver": "^7.5.4", + "tslib": "^2.6.2" + }, + "peerDependencies": { + "rollup": ">=1.26.3", + "typescript": ">=2.4.0" + } + }, + "node_modules/rollup-plugin-typescript2/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/rollup-plugin-vue": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-vue/-/rollup-plugin-vue-6.0.0.tgz", + "integrity": "sha512-oVvUd84d5u73M2HYM3XsMDLtZRIA/tw2U0dmHlXU2UWP5JARYHzh/U9vcxaN/x/9MrepY7VH3pHFeOhrWpxs/Q==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "hash-sum": "^2.0.0", + "rollup-pluginutils": "^2.8.2" + }, + "peerDependencies": { + "@vue/compiler-sfc": "*" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/rollup-pluginutils/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "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", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "optional": true + }, + "node_modules/sass": { + "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", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-graph": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-4.0.1.tgz", + "integrity": "sha512-5YCfmGBmxoIRYHnKK2AKzrAkCoQ8ozO+iumT8K4tXJXRVCPf+7s1/9KxTSW3Rbvf+7Y7b4FR3mWyLnQr3PHocA==", + "dev": true, + "dependencies": { + "glob": "^7.0.0", + "lodash": "^4.17.11", + "scss-tokenizer": "^0.4.3", + "yargs": "^17.2.1" + }, + "bin": { + "sassgraph": "bin/sassgraph" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/scss-tokenizer": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.4.3.tgz", + "integrity": "sha512-raKLgf1LI5QMQnG+RxHz6oK0sL3x3I4FN2UDLqgLOGO8hodECNnNh5BXn7fAyBxrA8zVzdQizQ6XjNJQ+uBwMw==", + "dev": true, + "dependencies": { + "js-base64": "^2.4.9", + "source-map": "^0.7.3" + } + }, + "node_modules/scss-tokenizer/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/semver": { + "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.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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "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", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, + "node_modules/signal-exit": { + "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", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smob": { + "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.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "dev": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "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" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "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": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "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": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "dev": true + }, + "node_modules/stdout-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", + "integrity": "sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA==", + "dev": true, + "dependencies": { + "readable-stream": "^2.0.1" + } + }, + "node_modules/stdout-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/stdout-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/stdout-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", + "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==", + "dev": true + }, + "node_modules/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/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.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", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/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-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", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.1.tgz", + "integrity": "sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-inject": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz", + "integrity": "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==", + "dev": true + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/supports-color": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", + "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tar": { + "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", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/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/terser": { + "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", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "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", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/trough": { + "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", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/true-case-path": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-2.2.1.tgz", + "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.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", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typedoc": { + "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.7" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "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 || 5.3.x || 5.4.x" + } + }, + "node_modules/typedoc-plugin-missing-exports": { + "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/typescript": { + "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", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "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", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dev": true + }, + "node_modules/unified": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", + "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0", + "bail": "^2.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unified/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unist-builder": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-3.0.1.tgz", + "integrity": "sha512-gnpOw7DIpCA0vpr6NqdPvTWnlPTApCTRzr+38E6hCWx3rz/cjo83SsKIlS1Z+L5ttScQ2AwutNnb8+tAvpb6qQ==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-generated": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-2.0.1.tgz", + "integrity": "sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", + "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz", + "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", + "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^5.0.0", + "unist-util-visit-parents": "^5.1.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", + "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "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.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "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", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/uvu": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", + "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", + "dev": true, + "dependencies": { + "dequal": "^2.0.0", + "diff": "^5.0.0", + "kleur": "^4.0.3", + "sade": "^1.7.3" + }, + "bin": { + "uvu": "bin.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vfile": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", + "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^3.0.0", + "vfile-message": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-4.1.0.tgz", + "integrity": "sha512-YF23YMyASIIJXpktBa4vIGLJ5Gs88UB/XePgqPmTa7cDA+JeO3yclbpheQYCHjVHBn/yePzrXuygIL+xbvRYHw==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", + "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "dev": true, + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-reporter": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/vfile-reporter/-/vfile-reporter-7.0.5.tgz", + "integrity": "sha512-NdWWXkv6gcd7AZMvDomlQbK3MqFWL1RlGzMn++/O2TI+68+nqxCPTvLugdOtfSzXmjh+xUyhp07HhlrbJjT+mw==", + "dev": true, + "dependencies": { + "@types/supports-color": "^8.0.0", + "string-width": "^5.0.0", + "supports-color": "^9.0.0", + "unist-util-stringify-position": "^3.0.0", + "vfile": "^5.0.0", + "vfile-message": "^3.0.0", + "vfile-sort": "^3.0.0", + "vfile-statistics": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-reporter/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/vfile-reporter/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/vfile-reporter/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/vfile-reporter/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/vfile-sort": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vfile-sort/-/vfile-sort-3.0.1.tgz", + "integrity": "sha512-1os1733XY6y0D5x0ugqSeaVJm9lYgj0j5qdcZQFyxlZOSy1jYarL77lLyb5gK4Wqr1d5OxmuyflSO3zKyFnTFw==", + "dev": true, + "dependencies": { + "vfile": "^5.0.0", + "vfile-message": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-statistics": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vfile-statistics/-/vfile-statistics-2.0.1.tgz", + "integrity": "sha512-W6dkECZmP32EG/l+dp2jCLdYzmnDBIw6jwiLZSER81oR5AHRcVqL+k3Z+pfH1R73le6ayDkJRMk0sutj1bMVeg==", + "dev": true, + "dependencies": { + "vfile": "^5.0.0", + "vfile-message": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==", + "dev": true + }, + "node_modules/vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==", + "dev": true + }, + "node_modules/vue-template-compiler": { + "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": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/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", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "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": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f72d6f --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "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": "^4.1.5", + "rollup-plugin-peer-deps-external": "^2.2.3", + "rollup-plugin-postcss": "^4.0.2", + "rollup-plugin-typescript2": "^0.36.0", + "rollup-plugin-vue": "^6.0.0", + "sass": "^1.49.9", + "terser": "^5.14.2", + "tslib": "^2.3.1", + "typedoc": "^0.25.1", + "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", + "rules": { + "quotes": "off" + } + }, + "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 9074ac6..4a3bfc8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,5 +11,10 @@ rootProject.name = 'VM-Operator' include 'org.jdrupes.vmoperator.manager' +include 'org.jdrupes.vmoperator.manager.events' +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.