Compare commits
322 commits
runner-qem
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6c6358426 | |||
| 470c266157 | |||
| 7b8df80828 | |||
| fccf2a6b65 | |||
| 00e9affee4 | |||
| fa84110e1d | |||
| 76b579c404 | |||
| a5433c869b | |||
| 10f3028f06 | |||
| b7fad4614d | |||
| 7d298ce24b | |||
| 6ef4c2aaa2 | |||
| 5bcf0ba051 | |||
| d67f374de7 | |||
| 2b3420c801 | |||
| bd54d293eb | |||
| cb2ae7c33e | |||
| 85a9b41046 | |||
| fb976802cf | |||
| af112bb66b | |||
| 592b30f6c5 | |||
| c716e32534 | |||
| c79d678a2a | |||
| f30ea79abb | |||
| d7d5c860a2 | |||
| 991763f228 | |||
| db49f5ba2f | |||
| 2e70ef2b98 | |||
| e8097d87d9 | |||
| 7a70d73330 | |||
| 3143a1be93 | |||
| 4bcbafb4f1 | |||
| 331b6d8d61 | |||
| 725fb663c8 | |||
| d9799df861 | |||
| fe1d56517b | |||
| 359b1fdb84 | |||
| 16a15bc9ad | |||
| 7644e65ab0 | |||
| dbc89e6e09 | |||
| 9baf9b7673 | |||
| 3686629a28 | |||
| 5991fe0d2d | |||
| 3b0a4c8a23 | |||
| 5ca45d7620 | |||
| efd489b22f | |||
| 9644e5fd83 | |||
| fe18bb3cdf | |||
| 9a557f5019 | |||
| fd0f4f8eb2 | |||
| 9bd17e8899 | |||
| 227c097c01 | |||
| ce4d0bfb72 | |||
| 017607f2e2 | |||
| fcdb537f35 | |||
| 5309460fbf | |||
| dc228295d1 | |||
| 1b1e5ffb8c | |||
| a3d6db3178 | |||
| 197c21bc32 | |||
| 8bc413b2da | |||
| ba582d877e | |||
| 3a4404b758 | |||
| f0ebea5353 | |||
| 084bdd1f46 | |||
| 5947bd3684 | |||
| c02b3d99cb | |||
| 407aa4b4d1 | |||
| 3df01fcad0 | |||
| 2d16cbc352 | |||
| 2a3774ae24 | |||
| d637cb2c72 | |||
| f493a2c582 | |||
| 72e1b8a580 | |||
| ecb43db83e | |||
| 2a33f468f2 | |||
| 36877666f3 | |||
| fae75dafa9 | |||
| 46f079504c | |||
| 5d0c6c6423 | |||
| 19968ab73e | |||
| 68a688c4ce | |||
| 44868464b9 | |||
| 61286a528c | |||
| ce1a9afec7 | |||
| 591278a07f | |||
| 29bc6f539c | |||
| 2aa4116e95 | |||
| 7104984ac7 | |||
| 7f7306fc4a | |||
| 7130c128bb | |||
| 3557e5fc27 | |||
| f3907ffae9 | |||
| 9459c367ac | |||
| c5a00acf3d | |||
| 1a8412d767 | |||
| 21d2fe2dbd | |||
| 607379b06d | |||
| c51da8650a | |||
| 12c72b3f52 | |||
| 7dc68b5ac7 | |||
| 5ae162445c | |||
| fb73a17748 | |||
| 961098a984 | |||
| 0e57a4e862 | |||
| 2524172c12 | |||
| 7437a17c9f | |||
| 7e650bf980 | |||
| 940913cf89 | |||
| 04ccdd7dee | |||
| bfe4c2bb32 | |||
| 28b1903acc | |||
| dfe3038463 | |||
| 701194799f | |||
| 17e2a7c6f0 | |||
| eb3979dc83 | |||
| e871fc059b | |||
| d4fdd4209f | |||
| 51a72a162d | |||
| fa53a07a52 | |||
| 6b6a33e702 | |||
| cc78c38efe | |||
| c004265f5e | |||
| 5c7a9f6e5f | |||
| 03fdabe85a | |||
| 214085119c | |||
| 30bc119178 | |||
| 987f634f40 | |||
| 083c6db2da | |||
| 7670857d0a | |||
| d6e2a92fe8 | |||
| d8cff8b942 | |||
| 4965845f3d | |||
| a0dfd25192 | |||
| fd0bcc9307 | |||
| 0e28bcd038 | |||
| 60349bca78 | |||
| 199cd8185e | |||
| f6338758d8 | |||
| 3a94602a0d | |||
| 07fb07a6a4 | |||
| 687a050ec4 | |||
| 2f6b3d2127 | |||
| 05d53c58b1 | |||
| d7af1f5d06 | |||
| 6b7c78ed2c | |||
| 502842f486 | |||
| 5b7531c5e5 | |||
| d0298eb7e8 | |||
| 01db49397a | |||
| f8cc26e657 | |||
| 41ae658e0c | |||
| e822d472f9 | |||
| 4a242c4657 | |||
| 62a7210117 | |||
| 5e282c4d2b | |||
| 5366e24092 | |||
| 3152ff842b | |||
| b409443499 | |||
| 5ec220d0a6 | |||
| 5cbdab9da8 | |||
| b4cb3b8694 | |||
| 59bf4937ef | |||
| 3119349450 | |||
| b4bc0c7b0f | |||
| a1e941276e | |||
| 4a7a309f07 | |||
| d2c39dc06a | |||
| 2119c215fc | |||
| d1bc335db9 | |||
| c6704c886f | |||
| bc33640c98 | |||
| ddab466fd0 | |||
| c45c452c83 | |||
| e3b5f5a04d | |||
| f236b376ae | |||
| 558f4d96c9 | |||
| 5b8b47f95c | |||
| 3012da3e87 | |||
| 0828d03835 | |||
| 81b128e4a3 | |||
| e291352828 | |||
| 5ad052ffe4 | |||
| c582763fbf | |||
| 777ae73c74 | |||
| bccc4ac219 | |||
| ec8152bd51 | |||
| e4bba582a0 | |||
| 0287ae7998 | |||
| 46cb2466fe | |||
| 3e713b4ff2 | |||
| 0ded0ff9a9 | |||
| 5078001f4b | |||
| 1fc26647b6 | |||
| aea8b9540e | |||
| d27339b1e9 | |||
| d5e589709f | |||
| 21108771d9 | |||
| b7ea6860ff | |||
| 85a4521299 | |||
| b250398213 | |||
| 54747b25e8 | |||
| 9986e4c8bf | |||
| b5ae22a8ea | |||
| b78b33a6f1 | |||
| b159bae5da | |||
| 23bc41d68d | |||
| 6a1273e701 | |||
| 4fc0d6fc63 | |||
| ecd7ba7baf | |||
| 150b9f2908 | |||
| 29dd6aab82 | |||
| 99c96e44c3 | |||
| 1f4d69075a | |||
| ad79e8542a | |||
| e447a944dc | |||
| 49566584a2 | |||
| e4e00c8ec8 | |||
| ebda41346a | |||
| 8d96307bb5 | |||
| af41c78c07 | |||
| 5cd4edcec1 | |||
| 85be5b9cbf | |||
| 50ad911265 | |||
| 86f6ece264 | |||
| 1b5ad5b73e | |||
| 3ca632c8da | |||
| e7cc7cc879 | |||
| 981cbe2744 | |||
| 224855efd3 | |||
| aaf1a0c545 | |||
| 53a58a2aca | |||
| 574ad5226b | |||
| a0d626cc31 | |||
| 2a70c74234 | |||
| 5d722abd2e | |||
| 877d4c69cd | |||
| 80d4165500 | |||
| a5ddf6ac97 | |||
| 9318b1279a | |||
| fb69c1d793 | |||
| edc3596e7d | |||
| ba18e1f0d0 | |||
| 8799bcc8f2 | |||
| 1cb90b0c94 | |||
| 6d5ba8829c | |||
| d060a9334a | |||
| 9b47ad3136 | |||
| 76be59a5b3 | |||
| 5bd6700541 | |||
| bd5227fda3 | |||
| 4943baf3e3 | |||
| 15ac0721a6 | |||
| db7fbe2b7c | |||
| 84ac4bb28c | |||
| c3428ea4a5 | |||
| 2dc93f1370 | |||
| 367aebeee5 | |||
| 77cfcff2ed | |||
| 4c600e7118 | |||
| e839f7b3b2 | |||
| 4ceaaa9fa2 | |||
| 2be88d0f34 | |||
| c361f9296d | |||
| dc21dc8a7b | |||
| 64035d8dc1 | |||
| 22446c3618 | |||
| 5efef2a083 | |||
| eabb2d9cf0 | |||
| 27f983c18d | |||
| 00adeba625 | |||
| 0f4768d707 | |||
| 5a68b976c8 | |||
| 4690b897e9 | |||
| 043666a932 | |||
| bc0d25d00e | |||
| 44ae5d405b | |||
| e7da41f838 | |||
| 5d90a6a8a9 | |||
| 1495301ec8 | |||
| f513e6c395 | |||
| dec4c11785 | |||
| 13cd262a47 | |||
| a7ee3d0515 | |||
| 31758b5ef1 | |||
| 97e94a8e9a | |||
| 28df6ede15 | |||
| d8132de6c2 | |||
| 558d8f9548 | |||
| dc7382dc86 | |||
| 6e3f554d8d | |||
| e864f677c3 | |||
| 93a1a2b2f9 | |||
| 69507b540c | |||
| 0ba8d922ef | |||
| 4ea568ea17 | |||
| 811164f7b9 | |||
| f1d973502d | |||
| 4d447717c2 | |||
| 9773207307 | |||
| 82a6d53156 | |||
| 5ec7f58bbd | |||
| 228322748b | |||
| 40cbeb694b | |||
| 5897b4b386 | |||
| 65ceed93b6 | |||
| 12d6745d75 | |||
| 0aaa375ffc | |||
| b8aa925a49 | |||
| 355eded86b | |||
| 090d504b77 | |||
| e5fd45ebcb | |||
| 12408143a7 | |||
| c7b65ca581 | |||
| 4d76225442 | |||
| 9019907224 | |||
| 5b209c935e | |||
| 3d446836b5 | |||
| 2d51421e19 | |||
| abe06b4658 | |||
| a9c31a378e | |||
| c8781c2d8e |
265 changed files with 11531 additions and 3384 deletions
89
.github/workflows/jekyll.yml
vendored
Normal file
89
.github/workflows/jekyll.yml
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
stages:
|
||||
- build
|
||||
- test
|
||||
- publish
|
||||
- deploy
|
||||
|
||||
.any-job:
|
||||
rules:
|
||||
- if: $CI_SERVER_HOST == "gitlab.mnl.de"
|
||||
|
||||
.gradle-job:
|
||||
extends: .any-job
|
||||
image: registry.mnl.de/org/jgrapes/jdk21-builder:v2
|
||||
cache:
|
||||
- key: dependencies-${CI_COMMIT_BRANCH}
|
||||
policy: pull-push
|
||||
paths:
|
||||
- .gradle
|
||||
- node_modules
|
||||
- key: "$CI_COMMIT_SHA"
|
||||
policy: pull-push
|
||||
paths:
|
||||
- build
|
||||
- "*/build"
|
||||
before_script:
|
||||
- echo -n $CI_REGISTRY_PASSWORD | podman login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
|
||||
- git switch $(git branch -r --sort="authordate" --contains $CI_COMMIT_SHA | head -1 | sed -e 's#[^/]*/##')
|
||||
- git pull
|
||||
- git reset --hard $CI_COMMIT_SHA
|
||||
|
||||
build-jars:
|
||||
stage: build
|
||||
extends: .gradle-job
|
||||
script:
|
||||
- ./gradlew -Pdocker.registry=$CI_REGISTRY_IMAGE build apidocs
|
||||
|
||||
publish-images:
|
||||
stage: publish
|
||||
extends: .gradle-job
|
||||
dependencies:
|
||||
- build-jars
|
||||
script:
|
||||
- ./gradlew -Pdocker.registry=$CI_REGISTRY_IMAGE publishImage
|
||||
|
||||
.pages-job:
|
||||
extends: .any-job
|
||||
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/ruby:3.2
|
||||
variables:
|
||||
JEKYLL_ENV: production
|
||||
LC_ALL: C.UTF-8
|
||||
before_script:
|
||||
- git fetch origin gh-pages
|
||||
- git checkout gh-pages
|
||||
- gem install bundler
|
||||
- bundle install
|
||||
|
||||
test-pages:
|
||||
stage: test
|
||||
extends: .pages-job
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "gh-pages"
|
||||
script:
|
||||
- bundle exec jekyll build -d test
|
||||
artifacts:
|
||||
paths:
|
||||
- test
|
||||
|
||||
#publish-pages:
|
||||
# stage: publish
|
||||
# extends: .pages-job
|
||||
# rules:
|
||||
# - if: $CI_COMMIT_BRANCH == "gh-pages"
|
||||
# script:
|
||||
# - bundle exec jekyll build -d public
|
||||
# artifacts:
|
||||
# paths:
|
||||
# - public
|
||||
# environment: production
|
||||
30
.markdownlint.yaml
Normal file
30
.markdownlint.yaml
Normal file
|
|
@ -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
|
||||
38
.woodpecker/build.yaml
Normal file
38
.woodpecker/build.yaml
Normal file
|
|
@ -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
|
||||
21
README.md
21
README.md
|
|
@ -3,10 +3,23 @@
|
|||

|
||||

|
||||
|
||||
# Run Qemu in Kubernetes Pods
|
||||
# Run QEMU/KVM in Kubernetes Pods
|
||||
|
||||
The goal of this project is to provide easy to use and flexible components
|
||||
for running Qemu based VMs in Kubernetes pods.
|
||||

|
||||
|
||||
See the [project's home page](https://jdrupes.org/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.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ buildscript {
|
|||
}
|
||||
|
||||
plugins {
|
||||
id 'org.ajoberstar.grgit' version '5.2.0' apply false
|
||||
id 'org.ajoberstar.grgit' version '5.2.0'
|
||||
id 'org.ajoberstar.git-publish' version '4.2.0' apply false
|
||||
id 'pl.allegro.tech.build.axion-release' version '1.17.2' apply false
|
||||
id 'org.jdrupes.vmoperator.versioning-conventions'
|
||||
|
|
@ -19,7 +19,7 @@ allprojects {
|
|||
}
|
||||
|
||||
task stage {
|
||||
description = 'To be executed by CI, build and update JavaDoc.'
|
||||
description = 'To be executed by CI.'
|
||||
group = 'build'
|
||||
|
||||
// Build everything first
|
||||
|
|
@ -27,11 +27,6 @@ task stage {
|
|||
dependsOn subprojects.tasks.collect {
|
||||
tc -> tc.findByName("build") }.flatten()
|
||||
}
|
||||
|
||||
if (JavaVersion.current() == JavaVersion.VERSION_21) {
|
||||
// Publish JavaDoc
|
||||
dependsOn gitPublishPush
|
||||
}
|
||||
}
|
||||
|
||||
eclipse {
|
||||
|
|
|
|||
|
|
@ -118,33 +118,3 @@ if (System.properties['org.ajoberstar.grgit.auth.username'] == null) {
|
|||
System.setProperty('org.ajoberstar.grgit.auth.username',
|
||||
project.rootProject.properties['website.push.token'] ?: "nouser")
|
||||
}
|
||||
|
||||
gitPublish {
|
||||
repoUri = 'https://github.com/mnlipp/jdrupes.org.git'
|
||||
branch = 'main'
|
||||
contents {
|
||||
from("${rootProject.projectDir}/webpages") {
|
||||
include '_layouts/vm-operator.html'
|
||||
include 'vm-operator/**'
|
||||
}
|
||||
from("${rootProject.buildDir}/javadoc") {
|
||||
into 'vm-operator/javadoc'
|
||||
}
|
||||
if (!findProject(':org.jdrupes.vmoperator.runner.qemu').isSnapshot
|
||||
&& !findProject(':org.jdrupes.vmoperator.manager').isSnapshot) {
|
||||
from("${rootProject.buildDir}/javadoc") {
|
||||
into 'vm-operator/latest-release/javadoc'
|
||||
}
|
||||
}
|
||||
}
|
||||
preserve { include '**/*' }
|
||||
commitMessage = "Updated."
|
||||
}
|
||||
|
||||
gradle.projectsEvaluated {
|
||||
tasks.gitPublishReset.mustRunAfter subprojects.tasks
|
||||
.collect { tc -> tc.findByName("build") }.flatten()
|
||||
tasks.gitPublishReset.mustRunAfter subprojects.tasks
|
||||
.collect { tc -> tc.findByName("test") }.flatten()
|
||||
tasks.gitPublishCopy.dependsOn apidocs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ if (shortened == "manager") {
|
|||
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('/', '-') + "-"
|
||||
|
|
|
|||
74
deploy/crds/vmpools-crd.yaml
Normal file
74
deploy/crds/vmpools-crd.yaml
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: vmpools.vmoperator.jdrupes.org
|
||||
spec:
|
||||
group: vmoperator.jdrupes.org
|
||||
# list of versions supported by this CustomResourceDefinition
|
||||
versions:
|
||||
- name: v1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
properties:
|
||||
spec:
|
||||
type: object
|
||||
properties:
|
||||
retention:
|
||||
description: >-
|
||||
Defines the timeout for assignments. The time may be
|
||||
specified as ISO 8601 time or duration. When specifying
|
||||
a duration, it will be added to the last time the VM's
|
||||
console was used to obtain the timeout.
|
||||
type: string
|
||||
pattern: '^(?:\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3])(?:|:?[0-5]\d))|P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+[Hh])?(?:\d+[Mm])?(?:\d+(?:\.\d{1,9})?[Ss])?)?)$'
|
||||
default: "PT1h"
|
||||
loginOnAssignment:
|
||||
description: >-
|
||||
If set to true, the user will be automatically logged in
|
||||
to the VM's console when the VM is assigned to him.
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
type: array
|
||||
description: >-
|
||||
Defines permissions for accessing and manipulating the Pool.
|
||||
items:
|
||||
type: object
|
||||
description: >-
|
||||
Permissions can be granted to a user or to a role.
|
||||
oneOf:
|
||||
- required:
|
||||
- user
|
||||
- required:
|
||||
- role
|
||||
properties:
|
||||
user:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
may:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- start
|
||||
- stop
|
||||
- reset
|
||||
- accessConsole
|
||||
- "*"
|
||||
default: ["accessConsole"]
|
||||
required:
|
||||
- permissions
|
||||
# either Namespaced or Cluster
|
||||
scope: Namespaced
|
||||
names:
|
||||
# plural name to be used in the URL: /apis/<group>/<version>/<plural>
|
||||
plural: vmpools
|
||||
# singular name to be used as an alias on the CLI and for display
|
||||
singular: vmpool
|
||||
# kind is normally the CamelCased singular type. Your resource manifests use this.
|
||||
kind: VmPool
|
||||
listKind: VmPoolList
|
||||
|
|
@ -994,6 +994,10 @@ spec:
|
|||
type: array
|
||||
description: >-
|
||||
Defines permissions for accessing and manipulating the VM.
|
||||
The meaning of most permissions should be obvious. The
|
||||
difference between "accessConsole" and "takeConsole" is
|
||||
that "takeConsole" allows the user to take control of
|
||||
the console even if it is already in use by another user.
|
||||
items:
|
||||
type: object
|
||||
description: >-
|
||||
|
|
@ -1017,8 +1021,21 @@ spec:
|
|||
- stop
|
||||
- reset
|
||||
- accessConsole
|
||||
- takeConsole
|
||||
- "*"
|
||||
default: []
|
||||
pools:
|
||||
type: array
|
||||
description: >-
|
||||
List of pools this VM belongs to.
|
||||
items:
|
||||
type: string
|
||||
default: []
|
||||
loggingProperties:
|
||||
type: string
|
||||
description: >-
|
||||
Override the default logging properties for
|
||||
the runner for this VM.
|
||||
vm:
|
||||
type: object
|
||||
description: Defines the VM.
|
||||
|
|
@ -1413,6 +1430,12 @@ spec:
|
|||
outputs:
|
||||
type: integer
|
||||
default: 1
|
||||
loggedInUser:
|
||||
description: >-
|
||||
The name of a user that should be automatically
|
||||
logged in on the display. Note that this requires
|
||||
support from an agent in the guest OS.
|
||||
type: string
|
||||
spice:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -1447,6 +1470,10 @@ spec:
|
|||
type: object
|
||||
default: {}
|
||||
properties:
|
||||
runnerVersion:
|
||||
description: >-
|
||||
The version string of the runner.
|
||||
type: string
|
||||
cpus:
|
||||
description: >-
|
||||
Number of CPUs currently in use.
|
||||
|
|
@ -1457,12 +1484,50 @@ spec:
|
|||
Amount of memory in use.
|
||||
type: string
|
||||
default: "0"
|
||||
consoleClient:
|
||||
description: >-
|
||||
The hostname of the currently connected client.
|
||||
type: string
|
||||
default: ""
|
||||
consoleUser:
|
||||
description: >-
|
||||
The id of the user who has last requested a console
|
||||
connection.
|
||||
type: string
|
||||
default: ""
|
||||
loggedInUser:
|
||||
description: >-
|
||||
The name of a user that is currently logged in by the
|
||||
VM operator agent.
|
||||
type: string
|
||||
displayPasswordSerial:
|
||||
description: >-
|
||||
Counts changes of the display password. Set to -1
|
||||
by the runner if password protection is not enabled.
|
||||
type: integer
|
||||
default: 0
|
||||
osinfo:
|
||||
description: Copy of the OS info provided by the guest agent.
|
||||
type: object
|
||||
x-kubernetes-preserve-unknown-fields: true
|
||||
assignment:
|
||||
description: >-
|
||||
The assignment of this VM to a a particular user.
|
||||
type: object
|
||||
properties:
|
||||
pool:
|
||||
description: >-
|
||||
The pool this VM is taken from.
|
||||
type: string
|
||||
user:
|
||||
description: >-
|
||||
The user this VM is assigned to.
|
||||
type: string
|
||||
lastUsed:
|
||||
description: >-
|
||||
The last time this VM was used by the user.
|
||||
type: string
|
||||
default: {}
|
||||
conditions:
|
||||
description: >-
|
||||
List of component conditions observed
|
||||
|
|
@ -1473,6 +1538,30 @@ spec:
|
|||
lastTransitionTime: "1970-01-01T00:00:00Z"
|
||||
reason: Creation
|
||||
message: "Creation of CR"
|
||||
- type: Booted
|
||||
status: "False"
|
||||
observedGeneration: 1
|
||||
lastTransitionTime: "1970-01-01T00:00:00Z"
|
||||
reason: Creation
|
||||
message: "Creation of CR"
|
||||
- type: VmopAgentConnected
|
||||
status: "False"
|
||||
observedGeneration: 1
|
||||
lastTransitionTime: "1970-01-01T00:00:00Z"
|
||||
reason: Creation
|
||||
message: "Creation of CR"
|
||||
- type: UserLoggedIn
|
||||
status: "False"
|
||||
observedGeneration: 1
|
||||
lastTransitionTime: "1970-01-01T00:00:00Z"
|
||||
reason: Creation
|
||||
message: "Creation of CR"
|
||||
- type: ConsoleConnected
|
||||
status: "False"
|
||||
observedGeneration: 1
|
||||
lastTransitionTime: "1970-01-01T00:00:00Z"
|
||||
reason: Creation
|
||||
message: "Creation of CR"
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -21,22 +21,31 @@ spec:
|
|||
- name: vm-operator
|
||||
image: >-
|
||||
ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: JAVA_OPTS
|
||||
# The VM operator needs about 25 MB of memory, plus 1 MB for
|
||||
# each VM. The reason is that for the sake of effeciency, we
|
||||
# have to keep a parsed representation of the CRD in memory,
|
||||
# which requires about 512 KB per VM. While handling updates,
|
||||
# we temporarily have the old and the new version of the CRD
|
||||
# in memory, so we need another 512 KB per VM.
|
||||
value: "-Xmx128m"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/opt/vmoperator
|
||||
- name: vmop-image-repository
|
||||
mountPath: /var/local/vmop-image-repository
|
||||
imagePullPolicy: Always
|
||||
securityContext:
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,15 @@ rules:
|
|||
- vmoperator.jdrupes.org
|
||||
resources:
|
||||
- vms
|
||||
- vmpools
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- vmoperator.jdrupes.org
|
||||
resources:
|
||||
- vms/status
|
||||
verbs:
|
||||
- patch
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
|
|
@ -31,6 +38,7 @@ rules:
|
|||
- persistentvolumeclaims
|
||||
- pods
|
||||
verbs:
|
||||
- watch
|
||||
- list
|
||||
- get
|
||||
- create
|
||||
|
|
|
|||
3
dev-example/.gitignore
vendored
3
dev-example/.gitignore
vendored
|
|
@ -1 +1,4 @@
|
|||
/test-vm-ci.yaml
|
||||
/kubeconfig.yaml
|
||||
/crds/
|
||||
/.vm-operator-cmd.rc
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Example setup for development
|
||||
|
||||
The CRD must be deployed independently. Apart from that, the
|
||||
`kustomize.yaml`
|
||||
`kustomize.yaml`
|
||||
|
||||
* creates a small cdrom image repository and
|
||||
* creates a small cdrom image repository and
|
||||
|
||||
* deploys the operator in namespace `vmop-dev` with a replica of 0.
|
||||
* deploys the operator in namespace `vmop-dev` with a replica of 0.
|
||||
|
||||
This allows you to run the manager in your IDE.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,28 @@
|
|||
"/Controller":
|
||||
namespace: vmop-dev
|
||||
"/Reconciler":
|
||||
runnerData:
|
||||
storageClassName: null
|
||||
runnerDataPvc:
|
||||
storageClassName: rook-cephfs
|
||||
loadBalancerService:
|
||||
labels:
|
||||
label1: label1
|
||||
label2: toBeReplaced
|
||||
annotations:
|
||||
metallb.universe.tf/loadBalancerIPs: 192.168.168.1
|
||||
metallb.universe.tf/ip-allocated-from-pool: single-common
|
||||
metallb.universe.tf/allow-shared-ip: single-common
|
||||
loggingProperties: |
|
||||
# Defaults for namespace (VM domain)
|
||||
handlers=java.util.logging.ConsoleHandler
|
||||
|
||||
#org.jgrapes.level=FINE
|
||||
#org.jgrapes.core.handlerTracking.level=FINER
|
||||
|
||||
org.jdrupes.vmoperator.runner.qemu.level=FINEST
|
||||
|
||||
java.util.logging.ConsoleHandler.level=ALL
|
||||
java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter
|
||||
java.util.logging.SimpleFormatter.format=%1$tb %1$td %1$tT %4$s %5$s%6$s%n
|
||||
"/GuiSocketServer":
|
||||
port: 8888
|
||||
"/GuiHttpServer":
|
||||
|
|
@ -17,18 +37,33 @@
|
|||
"/WebConsole":
|
||||
"/LoginConlet":
|
||||
users:
|
||||
- name: admin
|
||||
fullName: Administrator
|
||||
password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
|
||||
- name: test
|
||||
fullName: Test Account
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
- name: admin
|
||||
fullName: Administrator
|
||||
password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
|
||||
- name: operator
|
||||
fullName: Operator
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
- name: test1
|
||||
fullName: Test Account 1
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
- name: test2
|
||||
fullName: Test Account 2
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
- name: test3
|
||||
fullName: Test Account 3
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
"/RoleConfigurator":
|
||||
rolesByUser:
|
||||
# User admin has role admin
|
||||
admin:
|
||||
- admin
|
||||
test:
|
||||
operator:
|
||||
- operator
|
||||
test1:
|
||||
- user
|
||||
test2:
|
||||
- user
|
||||
test3:
|
||||
- user
|
||||
# All users have role other
|
||||
"*":
|
||||
|
|
@ -39,13 +74,16 @@
|
|||
# Admins can use all conlets
|
||||
admin:
|
||||
- "*"
|
||||
operator:
|
||||
- org.jdrupes.vmoperator.vmmgmt.VmMgmt
|
||||
- org.jdrupes.vmoperator.vmaccess.VmAccess
|
||||
user:
|
||||
- org.jdrupes.vmoperator.vmviewer.VmViewer
|
||||
- org.jdrupes.vmoperator.vmaccess.VmAccess
|
||||
# Others cannot use any conlet (except login conlet to log out)
|
||||
other:
|
||||
- org.jgrapes.webconlet.oidclogin.LoginConlet
|
||||
"/ComponentCollector":
|
||||
"/VmViewer":
|
||||
"/VmAccess":
|
||||
displayResource:
|
||||
preferredIpVersion: ipv4
|
||||
syncPreviewsFor:
|
||||
|
|
|
|||
47
dev-example/gen-pool-vm-crds
Executable file
47
dev-example/gen-pool-vm-crds
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
#!/bin/bash
|
||||
|
||||
function usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $0 [OPTION]... [TEMPLATE]
|
||||
Generate VM CRDs using TEMPLATE.
|
||||
|
||||
-c, --count Count of VMs to generate
|
||||
-d, --destination DIR Generate into given directory (default: ".")
|
||||
-h, --help Print this help
|
||||
-p, --prefix PREFIX Prefix for generated file (default: basename of template)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
count=0
|
||||
destination=.
|
||||
template=""
|
||||
prefix=""
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-c|--count) shift; count=$1;;
|
||||
-d|--destination) shift; destination="$1";;
|
||||
-h|--help) shift; usage;;
|
||||
-p|--prefix) shift; prefix="$1";;
|
||||
-*) echo >&2 "Unknown option: $1"; exit 1;;
|
||||
*) template="$1";;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ -z "$template" ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ "$count" = "0" ]; then
|
||||
exit 0
|
||||
fi
|
||||
for number in $(seq 1 $count); do
|
||||
if [ -z "$prefix" ]; then
|
||||
prefix=$(basename $template .tpl.yaml)
|
||||
fi
|
||||
name="$prefix$(printf %03d $number)"
|
||||
index=$(($number - 1))
|
||||
esh -o $destination/$name.yaml $template number=$number index=$index
|
||||
done
|
||||
|
|
@ -35,6 +35,14 @@ patches:
|
|||
"/Reconciler":
|
||||
runnerData:
|
||||
storageClassName: null
|
||||
loadBalancerService:
|
||||
labels:
|
||||
label1: label1
|
||||
label2: toBeReplaced
|
||||
annotations:
|
||||
metallb.universe.tf/loadBalancerIPs: 192.168.168.1
|
||||
metallb.universe.tf/ip-allocated-from-pool: single-common
|
||||
metallb.universe.tf/allow-shared-ip: single-common
|
||||
"/GuiSocketServer":
|
||||
port: 8888
|
||||
"/GuiHttpServer":
|
||||
|
|
@ -43,18 +51,28 @@ patches:
|
|||
"/WebConsole":
|
||||
"/LoginConlet":
|
||||
users:
|
||||
admin:
|
||||
fullName: Administrator
|
||||
password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
|
||||
test:
|
||||
fullName: Test Account
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
- name: admin
|
||||
fullName: Administrator
|
||||
password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
|
||||
- name: test1
|
||||
fullName: Test Account
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
- name: test2
|
||||
fullName: Test Account
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
- name: test3
|
||||
fullName: Test Account
|
||||
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
|
||||
"/RoleConfigurator":
|
||||
rolesByUser:
|
||||
# User admin has role admin
|
||||
admin:
|
||||
- admin
|
||||
test:
|
||||
test1:
|
||||
- user
|
||||
test2:
|
||||
- user
|
||||
test3:
|
||||
- user
|
||||
# All users have role other
|
||||
"*":
|
||||
|
|
@ -71,7 +89,7 @@ patches:
|
|||
other:
|
||||
- org.jgrapes.webconlet.locallogin.LoginConlet
|
||||
"/ComponentCollector":
|
||||
"/VmViewer":
|
||||
"/VmAccess":
|
||||
displayResource:
|
||||
preferredIpVersion: ipv4
|
||||
syncPreviewsFor:
|
||||
|
|
|
|||
66
dev-example/pool-action
Executable file
66
dev-example/pool-action
Executable file
|
|
@ -0,0 +1,66 @@
|
|||
#!/bin/bash
|
||||
|
||||
function usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $0 pool-name action
|
||||
Applys action to all VMs in the pool.
|
||||
|
||||
--context Context to be passed to kubectl (required)
|
||||
-n, --namespace Namespace to be passed to kubectl
|
||||
|
||||
Action is one of "start", "stop", "delete" or "delete-disks"
|
||||
|
||||
Defaults for context and namespace are read from .vm-operator-cmd.rc.
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
unset pool
|
||||
unset action
|
||||
unset context
|
||||
namespace=default
|
||||
|
||||
if [ -r .vm-operator-cmd.rc ]; then
|
||||
. .vm-operator-cmd.rc
|
||||
fi
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--context) shift; context="$1";;
|
||||
--context=*) IFS='=' read -r option value <<< "$1"; context="$value";;
|
||||
-n|--namespace) shift; namespace="$1";;
|
||||
-*) echo >&2 "Unknown option: $1"; exit 1;;
|
||||
*) if [ ! -v pool ]; then
|
||||
pool="$1"
|
||||
elif [ ! -v action ]; then
|
||||
action="$1"
|
||||
else
|
||||
usage
|
||||
fi;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ ! -v pool -o ! -v "action" -o ! -v context ]; then
|
||||
echo >&2 "Missing arguments or context not set."
|
||||
echo >&2
|
||||
usage
|
||||
fi
|
||||
case "$action" in
|
||||
"start"|"stop"|"delete"|"delete-disks") ;;
|
||||
*) usage;;
|
||||
esac
|
||||
|
||||
kubectl --context="$context" -n "$namespace" get vms -o json \
|
||||
| jq -r '.items[] | select(.spec.pools | contains(["'${pool}'"])) | .metadata.name' \
|
||||
| while read vmName; do
|
||||
case "$action" in
|
||||
start) kubectl --context="$context" -n "$namespace" patch vms "$vmName" \
|
||||
--type='merge' -p '{"spec":{"vm":{"state":"Running"}}}';;
|
||||
stop) kubectl --context="$context" -n "$namespace" patch vms "$vmName" \
|
||||
--type='merge' -p '{"spec":{"vm":{"state":"Stopped"}}}';;
|
||||
delete) kubectl --context="$context" -n "$namespace" delete vm/"$vmName";;
|
||||
delete-disks) kubectl --context="$context" -n "$namespace" delete \
|
||||
pvc -l app.kubernetes.io/instance="$vmName" ;;
|
||||
esac
|
||||
done
|
||||
17
dev-example/test-pool.yaml
Normal file
17
dev-example/test-pool.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
apiVersion: "vmoperator.jdrupes.org/v1"
|
||||
kind: VmPool
|
||||
metadata:
|
||||
namespace: vmop-dev
|
||||
name: test-vms
|
||||
spec:
|
||||
retention: "PT1m"
|
||||
loginOnAssignment: true
|
||||
permissions:
|
||||
- user: admin
|
||||
may:
|
||||
- accessConsole
|
||||
- start
|
||||
- role: user
|
||||
may:
|
||||
- accessConsole
|
||||
- start
|
||||
10
dev-example/test-vm-snapshot.yaml
Normal file
10
dev-example/test-vm-snapshot.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
apiVersion: snapshot.storage.k8s.io/v1
|
||||
kind: VolumeSnapshot
|
||||
metadata:
|
||||
namespace: vmop-dev
|
||||
name: test-vm-system-disk-snapshot
|
||||
spec:
|
||||
volumeSnapshotClassName: csi-rbdplugin-snapclass
|
||||
source:
|
||||
persistentVolumeClaimName: test-vm-system-disk
|
||||
66
dev-example/test-vm.tpl.yaml
Normal file
66
dev-example/test-vm.tpl.yaml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
apiVersion: "vmoperator.jdrupes.org/v1"
|
||||
kind: VirtualMachine
|
||||
metadata:
|
||||
namespace: vmop-dev
|
||||
name: test-vm<%= $(printf "%02d" ${number}) %>
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "20"
|
||||
|
||||
spec:
|
||||
image:
|
||||
source: ghcr.io/mnlipp/org.jdrupes.vmoperator.runner.qemu-arch:latest
|
||||
# source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing
|
||||
# source: docker-registry.lan.mnl.de/vmoperator/org.jdrupes.vmoperator.runner.qemu-arch:latest
|
||||
pullPolicy: Always
|
||||
|
||||
runnerTemplate:
|
||||
update: true
|
||||
|
||||
permissions:
|
||||
- role: admin
|
||||
may:
|
||||
- "*"
|
||||
|
||||
guestShutdownStops: true
|
||||
|
||||
cloudInit:
|
||||
metaData: {}
|
||||
|
||||
pools:
|
||||
- test-vms
|
||||
|
||||
vm:
|
||||
# state: Running
|
||||
bootMenu: true
|
||||
maximumCpus: 4
|
||||
currentCpus: 2
|
||||
maximumRam: 6Gi
|
||||
currentRam: 4Gi
|
||||
|
||||
networks:
|
||||
# No bridge on TC1
|
||||
# - tap: {}
|
||||
- user: {}
|
||||
|
||||
disks:
|
||||
- volumeClaimTemplate:
|
||||
metadata:
|
||||
name: system
|
||||
spec:
|
||||
storageClassName: ceph-rbd3slow
|
||||
dataSource:
|
||||
name: test-vm-system-disk-snapshot
|
||||
kind: VolumeSnapshot
|
||||
apiGroup: snapshot.storage.k8s.io
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 40Gi
|
||||
- cdrom:
|
||||
image: ""
|
||||
# image: https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso
|
||||
|
||||
display:
|
||||
spice:
|
||||
port: <%= $((5910 + number)) %>
|
||||
|
|
@ -5,18 +5,13 @@ metadata:
|
|||
name: test-vm
|
||||
spec:
|
||||
image:
|
||||
repository: docker-registry.lan.mnl.de
|
||||
path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine
|
||||
version: latest
|
||||
source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing
|
||||
pullPolicy: Always
|
||||
|
||||
permissions:
|
||||
- user: admin
|
||||
may:
|
||||
- "*"
|
||||
- user: test
|
||||
may:
|
||||
- "accessConsole"
|
||||
- user: admin
|
||||
may:
|
||||
- "*"
|
||||
|
||||
resources:
|
||||
requests:
|
||||
|
|
@ -37,8 +32,9 @@ spec:
|
|||
currentCpus: 4
|
||||
|
||||
networks:
|
||||
- tap:
|
||||
mac: "02:16:3e:33:58:10"
|
||||
# No bridge on test cluster
|
||||
- user: {}
|
||||
|
||||
disks:
|
||||
- volumeClaimTemplate:
|
||||
metadata:
|
||||
|
|
@ -62,3 +58,5 @@ spec:
|
|||
spice:
|
||||
port: 5810
|
||||
generateSecret: true
|
||||
|
||||
loadBalancerService: {}
|
||||
|
|
|
|||
2
dev-example/vmop-agent/99-vmop-agent.rules
Normal file
2
dev-example/vmop-agent/99-vmop-agent.rules
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
SUBSYSTEM=="virtio-ports", ATTR{name}=="org.jdrupes.vmop_agent.0", \
|
||||
TAG+="systemd" ENV{SYSTEMD_WANTS}="vmop-agent.service"
|
||||
3
dev-example/vmop-agent/gdm/PostLogin/Default
Executable file
3
dev-example/vmop-agent/gdm/PostLogin/Default
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf
|
||||
146
dev-example/vmop-agent/vmop-agent
Executable file
146
dev-example/vmop-agent/vmop-agent
Executable file
|
|
@ -0,0 +1,146 @@
|
|||
#!/usr/bin/bash
|
||||
|
||||
# Note that this script requires "jq" to be installed and a version
|
||||
# of loginctl that accepts the "-j" option.
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--path) shift; ttyPath="$1";;
|
||||
--path=*) IFS='=' read -r option value <<< "$1"; ttyPath="$value";;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
ttyPath="${ttyPath:-/dev/virtio-ports/org.jdrupes.vmop_agent.0}"
|
||||
|
||||
if [ ! -w "$ttyPath" ]; then
|
||||
echo >&2 "Device $ttyPath not writable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create fd for the tty in variable con
|
||||
if ! exec {con}<>"$ttyPath"; then
|
||||
echo >&2 "Cannot open device $ttyPath"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Temporary file for logging error messages, clear tty and signal ready
|
||||
temperr=$(mktemp)
|
||||
clear >/dev/tty1
|
||||
echo >&${con} "220 Hello"
|
||||
|
||||
# This script uses the (shared) home directory as "dictonary" for
|
||||
# synchronizing the username and the uid between hosts.
|
||||
#
|
||||
# Every user has a directory with his username. The directory is
|
||||
# owned by root to prevent changes of access rights by the user.
|
||||
# The uid and gid of the directory are equal. Thus the name of the
|
||||
# directory and the id from the group ownership also provide the
|
||||
# association between the username and the uid.
|
||||
|
||||
# Add the user with name $1 to the host's "user database". This
|
||||
# may not be invoked concurrently.
|
||||
createUser() {
|
||||
local missing=$1
|
||||
local uid
|
||||
local userHome="/home/$missing"
|
||||
local createOpts=""
|
||||
|
||||
# Retrieve or create the uid for the username
|
||||
if [ -d "$userHome" ]; then
|
||||
# If a home directory exists, use the id from the group ownership as uid
|
||||
uid=$(ls -ldn "$userHome" | head -n 1 | awk '{print $4}')
|
||||
createOpts="--no-create-home"
|
||||
else
|
||||
# Else get the maximum of all ids from the group ownership +1
|
||||
uid=$(ls -ln "/home" | tail -n +2 | awk '{print $4}' | sort | tail -1)
|
||||
uid=$(( $uid + 1 ))
|
||||
if [ $uid -lt 1100 ]; then
|
||||
uid=1100
|
||||
fi
|
||||
createOpts="--create-home"
|
||||
fi
|
||||
groupadd -g $uid $missing
|
||||
useradd $missing -u $uid -g $uid $createOpts
|
||||
}
|
||||
|
||||
# Login the user, i.e. create a desktopn for the user.
|
||||
doLogin() {
|
||||
user=$1
|
||||
if [ "$user" = "root" ]; then
|
||||
echo >&${con} "504 Won't log in root"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check if this user is already logged in on tty2
|
||||
curUser=$(loginctl -j | jq -r '.[] | select(.tty=="tty2") | .user')
|
||||
if [ "$curUser" = "$user" ]; then
|
||||
echo >&${con} "201 User already logged in"
|
||||
return
|
||||
fi
|
||||
|
||||
# Terminate a running desktop (fail safe)
|
||||
attemptLogout
|
||||
|
||||
# Check if username is known on this host. If not, create user
|
||||
uid=$(id -u ${user} 2>/dev/null)
|
||||
if [ $? != 0 ]; then
|
||||
( flock 200
|
||||
createUser ${user}
|
||||
) 200>/home/.gen-uid-lock
|
||||
|
||||
# This should now work, else something went wrong
|
||||
uid=$(id -u ${user} 2>/dev/null)
|
||||
if [ $? != 0 ]; then
|
||||
echo >&${con} "451 Cannot determine uid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Configure user as auto login user
|
||||
sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf
|
||||
sed -i '/\[daemon\]/a AutomaticLoginEnable=true\nAutomaticLogin='$user \
|
||||
/etc/gdm/custom.conf
|
||||
|
||||
# Activate user
|
||||
systemctl restart gdm
|
||||
if [ $? -eq 0 ]; then
|
||||
echo >&${con} "201 User logged in successfully"
|
||||
else
|
||||
echo >&${con} "451 $(tr '\n' ' ' <${temperr})"
|
||||
fi
|
||||
}
|
||||
|
||||
# Attempt to log out a user currently using tty1. This is an intermediate
|
||||
# operation that can be invoked from other operations
|
||||
attemptLogout() {
|
||||
sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf
|
||||
systemctl stop gdm
|
||||
echo >&${con} "102 Desktop stopped"
|
||||
}
|
||||
|
||||
# Log out any user currently using tty1. This is invoked when executing
|
||||
# the logout command and therefore sends back a 2xx return code.
|
||||
# Also try to restart gdm, if it is not running.
|
||||
doLogout() {
|
||||
attemptLogout
|
||||
systemctl restart gdm
|
||||
echo >&${con} "202 User logged out"
|
||||
}
|
||||
|
||||
while read line <&${con}; do
|
||||
case $line in
|
||||
"login "*) IFS=' ' read -ra args <<< "$line"; doLogin ${args[1]};;
|
||||
"logout") doLogout;;
|
||||
esac
|
||||
done
|
||||
|
||||
onExit() {
|
||||
doLogout
|
||||
if [ -n "$temperr" ]; then
|
||||
rm -f $temperr
|
||||
fi
|
||||
echo >&${con} "240 Quit"
|
||||
}
|
||||
|
||||
trap onExit EXIT
|
||||
15
dev-example/vmop-agent/vmop-agent.service
Normal file
15
dev-example/vmop-agent/vmop-agent.service
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description=VM-Operator (Guest) Agent
|
||||
BindsTo=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device
|
||||
After=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device multi-user.target
|
||||
IgnoreOnIsolate=True
|
||||
|
||||
[Service]
|
||||
UMask=0077
|
||||
#EnvironmentFile=/etc/sysconfig/vmop-agent
|
||||
ExecStart=/usr/local/libexec/vmop-agent
|
||||
Restart=always
|
||||
RestartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=dev-virtio\x2dports-org.jdrupes.vmop_agent.0.device
|
||||
|
|
@ -16,18 +16,21 @@
|
|||
var _paq = _paq || [];
|
||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||
_paq.push(["setDocumentTitle", document.domain + "/" + document.title]);
|
||||
_paq.push(["setCookieDomain", "*.jdrupes.org"]);
|
||||
_paq.push(["setCookieDomain", "*.mnlipp.github.io"]);
|
||||
_paq.push(["setDomains", ["*.mnlipp.github.io", "*.jdrupes.org", "kubernetes-vm-operator.readthedocs.io"]]);
|
||||
_paq.push(['disableCookies']);
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function() {
|
||||
var u="//jdrupes.org/";
|
||||
var u="https://piwik.mnl.de/";
|
||||
_paq.push(['setTrackerUrl', u+'piwik.php']);
|
||||
_paq.push(['setSiteId', '15']);
|
||||
_paq.push(['setSiteId', '17']);
|
||||
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
||||
g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
|
||||
})();
|
||||
</script>
|
||||
<noscript><p><img referrerpolicy="no-referrer-when-downgrade" src="//piwik.mnl.de/matomo.php?idsite=15&rec=1" style="border:0;" alt="" /></p></noscript>
|
||||
<noscript><p><img referrerpolicy="no-referrer-when-downgrade"
|
||||
src="//piwik.mnl.de/matomo.php?idsite=17&rec=1&action_name=VM-Operator" style="border:0;" alt="" /></p></noscript>
|
||||
<!-- End Matomo Code -->
|
||||
<script defer src="https://gotit.mnl.de/script.js" data-website-id="14b277ad-d330-4a54-82f1-a77d111240ac"></script>
|
||||
</div>
|
||||
|
|
@ -13,4 +13,5 @@ dependencies {
|
|||
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]'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,15 +27,101 @@ public class Constants {
|
|||
/** The Constant APP_NAME. */
|
||||
public static final String APP_NAME = "vm-runner";
|
||||
|
||||
/** The Constant COMP_DISPLAY_SECRETS. */
|
||||
public static final String COMP_DISPLAY_SECRET = "display-secret";
|
||||
|
||||
/** The Constant VM_OP_NAME. */
|
||||
public static final String VM_OP_NAME = "vm-operator";
|
||||
|
||||
/** The Constant VM_OP_GROUP. */
|
||||
public static final String VM_OP_GROUP = "vmoperator.jdrupes.org";
|
||||
/**
|
||||
* Constants related to the CRD.
|
||||
*/
|
||||
@SuppressWarnings("PMD.ShortClassName")
|
||||
public static class Crd {
|
||||
/** The Constant GROUP. */
|
||||
public static final String GROUP = "vmoperator.jdrupes.org";
|
||||
|
||||
/** The Constant VM_OP_KIND_VM. */
|
||||
public static final String VM_OP_KIND_VM = "VirtualMachine";
|
||||
/** The Constant KIND_VM. */
|
||||
public static final String KIND_VM = "VirtualMachine";
|
||||
|
||||
/** The Constant KIND_VM_POOL. */
|
||||
public static final String KIND_VM_POOL = "VmPool";
|
||||
}
|
||||
|
||||
/**
|
||||
* Status related constants.
|
||||
*/
|
||||
public static class Status {
|
||||
/** The Constant RUNNER_VERSION. */
|
||||
public static final String RUNNER_VERSION = "runnerVersion";
|
||||
|
||||
/** The Constant CPUS. */
|
||||
public static final String CPUS = "cpus";
|
||||
|
||||
/** The Constant RAM. */
|
||||
public static final String RAM = "ram";
|
||||
|
||||
/** The Constant OSINFO. */
|
||||
public static final String OSINFO = "osinfo";
|
||||
|
||||
/** The Constant DISPLAY_PASSWORD_SERIAL. */
|
||||
public static final String DISPLAY_PASSWORD_SERIAL
|
||||
= "displayPasswordSerial";
|
||||
|
||||
/** The Constant LOGGED_IN_USER. */
|
||||
public static final String LOGGED_IN_USER = "loggedInUser";
|
||||
|
||||
/** The Constant CONSOLE_CLIENT. */
|
||||
public static final String CONSOLE_CLIENT = "consoleClient";
|
||||
|
||||
/** The Constant CONSOLE_USER. */
|
||||
public static final String CONSOLE_USER = "consoleUser";
|
||||
|
||||
/** The Constant ASSIGNMENT. */
|
||||
public static final String ASSIGNMENT = "assignment";
|
||||
|
||||
/**
|
||||
* Conditions used in Status.
|
||||
*/
|
||||
public static class Condition {
|
||||
/** The Constant COND_RUNNING. */
|
||||
public static final String RUNNING = "Running";
|
||||
|
||||
/** The Constant COND_BOOTED. */
|
||||
public static final String BOOTED = "Booted";
|
||||
|
||||
/** The Constant COND_VMOP_AGENT. */
|
||||
public static final String VMOP_AGENT = "VmopAgentConnected";
|
||||
|
||||
/** The Constant COND_USER_LOGGED_IN. */
|
||||
public static final String USER_LOGGED_IN = "UserLoggedIn";
|
||||
|
||||
/** The Constant COND_CONSOLE. */
|
||||
public static final String CONSOLE_CONNECTED = "ConsoleConnected";
|
||||
|
||||
/**
|
||||
* Reasons used in conditions.
|
||||
*/
|
||||
public static class Reason {
|
||||
/** The Constant NOT_REQUESTED. */
|
||||
public static final String NOT_REQUESTED = "NotRequested";
|
||||
|
||||
/** The Constant USER_LOGGED_IN. */
|
||||
public static final String LOGGED_IN = "LoggedIn";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DisplaySecret related constants.
|
||||
*/
|
||||
public static class DisplaySecret {
|
||||
|
||||
/** The Constant NAME. */
|
||||
public static final String NAME = "display-secret";
|
||||
|
||||
/** The Constant PASSWORD. */
|
||||
public static final String PASSWORD = "display-password";
|
||||
|
||||
/** The Constant EXPIRY. */
|
||||
public static final String EXPIRY = "password-expiry";
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,13 +32,11 @@ import java.util.regex.Pattern;
|
|||
public class Convertions {
|
||||
|
||||
@SuppressWarnings({ "PMD.UseConcurrentHashMap",
|
||||
"PMD.FieldNamingConventions", "PMD.VariableNamingConventions" })
|
||||
"PMD.FieldNamingConventions" })
|
||||
private static final Map<String, BigInteger> unitMap = new HashMap<>();
|
||||
@SuppressWarnings({ "PMD.FieldNamingConventions",
|
||||
"PMD.VariableNamingConventions" })
|
||||
@SuppressWarnings({ "PMD.FieldNamingConventions" })
|
||||
private static final List<Map.Entry<String, BigInteger>> unitMappings;
|
||||
@SuppressWarnings({ "PMD.FieldNamingConventions",
|
||||
"PMD.VariableNamingConventions" })
|
||||
@SuppressWarnings({ "PMD.FieldNamingConventions" })
|
||||
private static final Pattern memorySize
|
||||
= Pattern.compile("^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*");
|
||||
|
||||
|
|
@ -69,7 +67,6 @@ public class Convertions {
|
|||
* @param amount the amount
|
||||
* @return the big integer
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public static BigInteger parseMemory(Object amount) {
|
||||
if (amount == null) {
|
||||
return (BigInteger) amount;
|
||||
|
|
|
|||
|
|
@ -47,8 +47,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
/**
|
||||
* Helpers for K8s API.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass",
|
||||
"PMD.DataflowAnomalyAnalysis" })
|
||||
@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass" })
|
||||
public class K8s {
|
||||
|
||||
/**
|
||||
|
|
@ -113,7 +112,6 @@ public class K8s {
|
|||
public static JsonObject yamlToJson(ApiClient client, Reader yaml) {
|
||||
// Avoid Yaml.load due to
|
||||
// https://github.com/kubernetes-client/java/issues/2741
|
||||
@SuppressWarnings("PMD.UseConcurrentHashMap")
|
||||
Map<String, Object> yamlData
|
||||
= new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml);
|
||||
|
||||
|
|
@ -157,27 +155,6 @@ public class K8s {
|
|||
return Optional.of(apiRes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object from its metadata.
|
||||
*
|
||||
* @param <T> the generic type
|
||||
* @param <LT> the generic type
|
||||
* @param api the api
|
||||
* @param meta the meta
|
||||
* @return the object
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("PMD.GenericsNaming")
|
||||
public static <T extends KubernetesObject, LT extends KubernetesListObject>
|
||||
Optional<T>
|
||||
get(GenericKubernetesApi<T, LT> api, V1ObjectMeta meta) {
|
||||
var response = api.get(meta.getNamespace(), meta.getName());
|
||||
if (response.isSuccess()) {
|
||||
return Optional.of(response.getObject());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given patch data.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -48,8 +48,7 @@ import okhttp3.Response;
|
|||
* A client with some additional properties.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods",
|
||||
"PMD.LinguisticNaming", "checkstyle:LineLength",
|
||||
"PMD.CouplingBetweenObjects", "PMD.GodClass" })
|
||||
"checkstyle:LineLength", "PMD.CouplingBetweenObjects", "PMD.GodClass" })
|
||||
public class K8sClient extends ApiClient {
|
||||
|
||||
private ApiClient apiClient;
|
||||
|
|
@ -231,7 +230,6 @@ public class K8sClient extends ApiClient {
|
|||
* @return the api client
|
||||
* @see ApiClient#setKeyManagers(javax.net.ssl.KeyManager[])
|
||||
*/
|
||||
@SuppressWarnings("PMD.UseVarargs")
|
||||
@Override
|
||||
public ApiClient setKeyManagers(KeyManager[] managers) {
|
||||
return apiClient().setKeyManagers(managers);
|
||||
|
|
@ -638,7 +636,6 @@ public class K8sClient extends ApiClient {
|
|||
* @return the string
|
||||
* @see ApiClient#selectHeaderAccept(java.lang.String[])
|
||||
*/
|
||||
@SuppressWarnings("PMD.UseVarargs")
|
||||
@Override
|
||||
public String selectHeaderAccept(String[] accepts) {
|
||||
return apiClient().selectHeaderAccept(accepts);
|
||||
|
|
@ -651,7 +648,6 @@ public class K8sClient extends ApiClient {
|
|||
* @return the string
|
||||
* @see ApiClient#selectHeaderContentType(java.lang.String[])
|
||||
*/
|
||||
@SuppressWarnings("PMD.UseVarargs")
|
||||
@Override
|
||||
public String selectHeaderContentType(String[] contentTypes) {
|
||||
return apiClient().selectHeaderContentType(contentTypes);
|
||||
|
|
@ -818,7 +814,7 @@ public class K8sClient extends ApiClient {
|
|||
* @throws ApiException the api exception
|
||||
* @see ApiClient#buildCall(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback)
|
||||
*/
|
||||
@SuppressWarnings({ "rawtypes", "PMD.ExcessiveParameterList" })
|
||||
@SuppressWarnings({ "rawtypes" })
|
||||
@Override
|
||||
public Call buildCall(String path, String method, List<Pair> queryParams,
|
||||
List<Pair> collectionQueryParams, Object body,
|
||||
|
|
@ -847,7 +843,7 @@ public class K8sClient extends ApiClient {
|
|||
* @throws ApiException the api exception
|
||||
* @see ApiClient#buildRequest(java.lang.String, java.lang.String, java.util.List, java.util.List, java.lang.Object, java.util.Map, java.util.Map, java.util.Map, java.lang.String[], io.kubernetes.client.openapi.ApiCallback)
|
||||
*/
|
||||
@SuppressWarnings({ "rawtypes", "PMD.ExcessiveParameterList" })
|
||||
@SuppressWarnings({ "rawtypes" })
|
||||
@Override
|
||||
public Request buildRequest(String path, String method,
|
||||
List<Pair> queryParams, List<Pair> collectionQueryParams,
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import java.util.function.Function;
|
|||
* @param <O> the generic type
|
||||
* @param <L> the generic type
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
@SuppressWarnings({ "PMD.CouplingBetweenObjects" })
|
||||
public class K8sClusterGenericStub<O extends KubernetesObject,
|
||||
L extends KubernetesListObject> {
|
||||
protected final K8sClient client;
|
||||
|
|
@ -239,6 +239,7 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
|
|||
* @param <L> the object list type
|
||||
* @param <R> the result type
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GenericSupplier<O extends KubernetesObject,
|
||||
L extends KubernetesListObject,
|
||||
R extends K8sClusterGenericStub<O, L>> {
|
||||
|
|
@ -253,7 +254,6 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
|
|||
* @param name the name
|
||||
* @return the result
|
||||
*/
|
||||
@SuppressWarnings("PMD.UseObjectForClearerAPI")
|
||||
R get(Class<O> objectClass, Class<L> objectListClass, K8sClient client,
|
||||
APIResource context, String name);
|
||||
}
|
||||
|
|
@ -282,7 +282,6 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
|
|||
* @return the stub if the object exists
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" })
|
||||
public static <O extends KubernetesObject, L extends KubernetesListObject,
|
||||
R extends K8sClusterGenericStub<O, L>>
|
||||
R get(Class<O> objectClass, Class<L> objectListClass,
|
||||
|
|
@ -313,8 +312,6 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
|
|||
* @return the stub if the object exists
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
|
||||
"PMD.UseObjectForClearerAPI" })
|
||||
public static <O extends KubernetesObject, L extends KubernetesListObject,
|
||||
R extends K8sClusterGenericStub<O, L>>
|
||||
R get(Class<O> objectClass, Class<L> objectListClass,
|
||||
|
|
@ -339,8 +336,6 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
|
|||
* @return the stub if the object exists
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
|
||||
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
|
||||
public static <O extends KubernetesObject, L extends KubernetesListObject,
|
||||
R extends K8sClusterGenericStub<O, L>>
|
||||
R create(Class<O> objectClass, Class<L> objectListClass,
|
||||
|
|
@ -373,7 +368,7 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
|
|||
public static <O extends KubernetesObject, L extends KubernetesListObject,
|
||||
R extends K8sClusterGenericStub<O, L>>
|
||||
Collection<R> list(Class<O> objectClass, Class<L> objectListClass,
|
||||
K8sClient client, APIResource context,
|
||||
K8sClient client, APIResource context,
|
||||
ListOptions options, GenericSupplier<O, L, R> provider)
|
||||
throws ApiException {
|
||||
var result = new ArrayList<R>();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
|||
* notably the metadata, is made available through the methods
|
||||
* defined by {@link KubernetesObject}.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
public class K8sDynamicModel implements KubernetesObject {
|
||||
|
||||
private final V1ObjectMeta metadata;
|
||||
|
|
@ -102,7 +101,7 @@ public class K8sDynamicModel implements KubernetesObject {
|
|||
*
|
||||
* @return the JSON object describing the status
|
||||
*/
|
||||
public JsonObject status() {
|
||||
public JsonObject statusJson() {
|
||||
return data.getAsJsonObject("status");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import java.util.Collection;
|
|||
* state and can therefore be used for any kind of object, especially
|
||||
* custom objects.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sDynamicStub
|
||||
extends K8sDynamicStubBase<K8sDynamicModel, K8sDynamicModels> {
|
||||
|
||||
|
|
@ -64,8 +63,6 @@ public class K8sDynamicStub
|
|||
* @return the stub if the object exists
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
|
||||
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
|
||||
public static K8sDynamicStub get(K8sClient client,
|
||||
GroupVersionKind gvk, String namespace, String name)
|
||||
throws ApiException {
|
||||
|
|
@ -83,8 +80,6 @@ public class K8sDynamicStub
|
|||
* @return the stub if the object exists
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
|
||||
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
|
||||
public static K8sDynamicStub get(K8sClient client,
|
||||
APIResource context, String namespace, String name) {
|
||||
return new K8sDynamicStub(client, context, namespace, name);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import io.kubernetes.client.Discovery.APIResource;
|
|||
* state and can therefore be used for any kind of object, especially
|
||||
* custom objects.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public abstract class K8sDynamicStubBase<O extends K8sDynamicModel,
|
||||
L extends K8sDynamicModelsBase<O>> extends K8sGenericStub<O, L> {
|
||||
|
||||
|
|
@ -40,7 +39,6 @@ public abstract class K8sDynamicStubBase<O extends K8sDynamicModel,
|
|||
* @param namespace the namespace
|
||||
* @param name the name
|
||||
*/
|
||||
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
|
||||
public K8sDynamicStubBase(Class<O> objectClass,
|
||||
Class<L> objectListClass, DynamicTypeAdapterFactory<O, L> taf,
|
||||
K8sClient client, APIResource context, String namespace,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import io.kubernetes.client.openapi.ApiException;
|
|||
import io.kubernetes.client.util.Strings;
|
||||
import io.kubernetes.client.util.generic.GenericKubernetesApi;
|
||||
import io.kubernetes.client.util.generic.KubernetesApiResponse;
|
||||
import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
|
||||
import io.kubernetes.client.util.generic.options.GetOptions;
|
||||
import io.kubernetes.client.util.generic.options.ListOptions;
|
||||
import io.kubernetes.client.util.generic.options.PatchOptions;
|
||||
|
|
@ -47,7 +48,7 @@ import java.util.function.Function;
|
|||
* @param <O> the generic type
|
||||
* @param <L> the generic type
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
@SuppressWarnings({ "PMD.TooManyMethods" })
|
||||
public class K8sGenericStub<O extends KubernetesObject,
|
||||
L extends KubernetesListObject> {
|
||||
protected final K8sClient client;
|
||||
|
|
@ -192,30 +193,92 @@ public class K8sGenericStub<O extends KubernetesObject,
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates the object's status.
|
||||
* Updates the object's status. Does not retry in case of conflict.
|
||||
*
|
||||
* @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
|
||||
* @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<O> updateStatus(O object,
|
||||
Function<O, Object> status) throws ApiException {
|
||||
return K8s.optional(api.updateStatus(object, status));
|
||||
public Optional<O> updateStatus(O object, Function<O, Object> updater)
|
||||
throws ApiException {
|
||||
return K8s.optional(api.updateStatus(object, updater));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status.
|
||||
* Updates the status of the given object. In case of conflict,
|
||||
* get the current version of the object and tries again. Retries
|
||||
* up to `retries` times.
|
||||
*
|
||||
* @param status the status
|
||||
* @param updater the function updating the status
|
||||
* @param current the current state of the object, used for the first
|
||||
* attempt to update
|
||||
* @param retries the retries in case of conflict
|
||||
* @return the updated model or empty if the object was not found
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AssignmentInOperand" })
|
||||
public Optional<O> updateStatus(Function<O, Object> 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<O> updateStatus(Function<O, Object> 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<O> updateStatus(Function<O, Object> status)
|
||||
public Optional<O> updateStatus(Function<O, Object> updater, O current)
|
||||
throws ApiException {
|
||||
return updateStatus(
|
||||
api.get(namespace, name).throwsApiException().getObject(), status);
|
||||
return updateStatus(updater, current, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status. In case of conflict, retries up to 16 times.
|
||||
*
|
||||
* @param updater the function updating the status
|
||||
* @return the kubernetes api response
|
||||
* the updated model or empty if not successful
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
public Optional<O> updateStatus(Function<O, Object> updater)
|
||||
throws ApiException {
|
||||
return updateStatus(updater, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -224,7 +287,7 @@ public class K8sGenericStub<O extends KubernetesObject,
|
|||
* @param patchType the patch type
|
||||
* @param patch the patch
|
||||
* @param options the options
|
||||
* @return the kubernetes api response
|
||||
* @return the kubernetes api response if successful
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
public Optional<O> patch(String patchType, V1Patch patch,
|
||||
|
|
@ -239,7 +302,7 @@ public class K8sGenericStub<O extends KubernetesObject,
|
|||
*
|
||||
* @param patchType the patch type
|
||||
* @param patch the patch
|
||||
* @return the kubernetes api response
|
||||
* @return the kubernetes api response if successful
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
public Optional<O>
|
||||
|
|
@ -248,6 +311,21 @@ public class K8sGenericStub<O extends KubernetesObject,
|
|||
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<O> apply(DynamicKubernetesObject def) throws ApiException {
|
||||
PatchOptions opts = new PatchOptions();
|
||||
opts.setForce(true);
|
||||
opts.setFieldManager("kubernetes-java-kubectl-apply");
|
||||
return patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
|
||||
new V1Patch(client.getJSON().serialize(def)), opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the object.
|
||||
*
|
||||
|
|
@ -279,6 +357,7 @@ public class K8sGenericStub<O extends KubernetesObject,
|
|||
* @param <L> the object list type
|
||||
* @param <R> the result type
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GenericSupplier<O extends KubernetesObject,
|
||||
L extends KubernetesListObject, R extends K8sGenericStub<O, L>> {
|
||||
|
||||
|
|
@ -290,7 +369,6 @@ public class K8sGenericStub<O extends KubernetesObject,
|
|||
* @param name the name
|
||||
* @return the result
|
||||
*/
|
||||
@SuppressWarnings("PMD.UseObjectForClearerAPI")
|
||||
R get(K8sClient client, String namespace, String name);
|
||||
}
|
||||
|
||||
|
|
@ -316,8 +394,6 @@ public class K8sGenericStub<O extends KubernetesObject,
|
|||
* @return the stub if the object exists
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
|
||||
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
|
||||
public static <O extends KubernetesObject, L extends KubernetesListObject,
|
||||
R extends K8sGenericStub<O, L>>
|
||||
R create(Class<O> objectClass, Class<L> objectListClass,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ 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;
|
||||
|
|
@ -49,7 +50,6 @@ public class K8sObserver<O extends KubernetesObject,
|
|||
ADDED, MODIFIED, DELETED
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.FieldNamingConventions")
|
||||
protected final Logger logger = Logger.getLogger(getClass().getName());
|
||||
|
||||
protected final K8sClient client;
|
||||
|
|
@ -72,8 +72,7 @@ public class K8sObserver<O extends KubernetesObject,
|
|||
* @param namespace the namespace
|
||||
* @param options the options
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
|
||||
"PMD.UseObjectForClearerAPI", "PMD.AvoidCatchingThrowable",
|
||||
@SuppressWarnings({ "PMD.AvoidCatchingThrowable",
|
||||
"PMD.CognitiveComplexity", "PMD.AvoidCatchingGenericException" })
|
||||
public K8sObserver(Class<O> objectClass, Class<L> objectListClass,
|
||||
K8sClient client, APIResource context, String namespace,
|
||||
|
|
@ -89,23 +88,29 @@ public class K8sObserver<O extends KubernetesObject,
|
|||
thread = (Components.useVirtualThreads() ? Thread.ofVirtual()
|
||||
: Thread.ofPlatform()).unstarted(() -> {
|
||||
try {
|
||||
logger
|
||||
.config(() -> "Watching " + context.getResourcePlural()
|
||||
+ " (" + context.getPreferredVersion() + ")"
|
||||
+ " in " + namespace);
|
||||
logger.fine(() -> "Observing " + context.getResourcePlural()
|
||||
+ " (" + context.getPreferredVersion() + ")"
|
||||
+ Optional.ofNullable(options.getLabelSelector())
|
||||
.map(ls -> " with labels " + ls).orElse("")
|
||||
+ " in " + namespace);
|
||||
|
||||
// Watch sometimes terminates without apparent reason.
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
Instant startedAt = Instant.now();
|
||||
try {
|
||||
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||
var changed
|
||||
= api.watch(namespace, options).iterator();
|
||||
while (changed.hasNext()) {
|
||||
handler.accept(client, changed.next());
|
||||
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);
|
||||
}
|
||||
|
|
@ -225,7 +230,6 @@ public class K8sObserver<O extends KubernetesObject,
|
|||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("PMD.UseLocaleWithCaseConversions")
|
||||
public String toString() {
|
||||
return "Observer for " + K8s.toString(context) + " " + namespace;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import java.util.List;
|
|||
/**
|
||||
* A stub for config maps (v1).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sV1ConfigMapStub
|
||||
extends K8sGenericStub<V1ConfigMap, V1ConfigMapList> {
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import java.util.Optional;
|
|||
/**
|
||||
* A stub for pods (v1).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sV1DeploymentStub
|
||||
extends K8sGenericStub<V1Deployment, V1DeploymentList> {
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import java.util.List;
|
|||
/**
|
||||
* A stub for nodes (v1).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sV1NodeStub extends K8sClusterGenericStub<V1Node, V1NodeList> {
|
||||
|
||||
public static final APIResource CONTEXT = new APIResource("", List.of("v1"),
|
||||
|
|
@ -74,8 +73,7 @@ public class K8sV1NodeStub extends K8sClusterGenericStub<V1Node, V1NodeList> {
|
|||
/**
|
||||
* Provide {@link GenericSupplier}.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.UnusedFormalParameter",
|
||||
"PMD.UnusedPrivateMethod" })
|
||||
@SuppressWarnings({ "PMD.UnusedFormalParameter" })
|
||||
private static K8sV1NodeStub getGeneric(Class<V1Node> objectClass,
|
||||
Class<V1NodeList> objectListClass, K8sClient client,
|
||||
APIResource context, String name) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import java.util.List;
|
|||
/**
|
||||
* A stub for pods (v1).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sV1PodStub extends K8sGenericStub<V1Pod, V1PodList> {
|
||||
|
||||
/** The pods' context. */
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import java.util.List;
|
|||
/**
|
||||
* A stub for pods (v1).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sV1PvcStub extends
|
||||
K8sGenericStub<V1PersistentVolumeClaim, V1PersistentVolumeClaimList> {
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import java.util.List;
|
|||
/**
|
||||
* A stub for secrets (v1).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sV1SecretStub extends K8sGenericStub<V1Secret, V1SecretList> {
|
||||
|
||||
public static final APIResource CONTEXT = new APIResource("", List.of("v1"),
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import java.util.List;
|
|||
/**
|
||||
* A stub for secrets (v1).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sV1ServiceStub extends K8sGenericStub<V1Service, V1ServiceList> {
|
||||
|
||||
public static final APIResource CONTEXT = new APIResource("", List.of("v1"),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import java.util.List;
|
|||
/**
|
||||
* A stub for stateful sets (v1).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class K8sV1StatefulSetStub
|
||||
extends K8sGenericStub<V1StatefulSet, V1StatefulSetList> {
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String, Permission> 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<Permission> 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<Permission> 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<String, Object> spec() {
|
||||
return model.getSpec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the spec using {@link DataPath#get}.
|
||||
*
|
||||
* @param <T> the generic type
|
||||
* @param selectors the selectors
|
||||
* @return the value, if found
|
||||
*/
|
||||
public <T> Optional<T> fromSpec(Object... selectors) {
|
||||
return DataPath.get(spec(), selectors);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pools that this VM belongs to.
|
||||
*
|
||||
* @return the list
|
||||
*/
|
||||
public List<String> pools() {
|
||||
return this.<List<String>> fromSpec("pools")
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the `spec().get("vm")` using {@link DataPath#get}.
|
||||
*
|
||||
* @param <T> the generic type
|
||||
* @param selectors the selectors
|
||||
* @return the value, if found
|
||||
*/
|
||||
public <T> Optional<T> fromVm(Object... selectors) {
|
||||
return DataPath.get(spec(), "vm")
|
||||
.flatMap(vm -> DataPath.get(vm, selectors));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status.
|
||||
*
|
||||
* @return the status
|
||||
*/
|
||||
public Map<String, Object> status() {
|
||||
return model.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the status using {@link DataPath#get}.
|
||||
*
|
||||
* @param <T> the generic type
|
||||
* @param selectors the selectors
|
||||
* @return the value, if found
|
||||
*/
|
||||
public <T> Optional<T> fromStatus(Object... selectors) {
|
||||
return DataPath.get(status(), selectors);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignment information.
|
||||
*
|
||||
* @return the optional
|
||||
*/
|
||||
public Optional<Assignment> assignment() {
|
||||
return this.<Map<String, Object>> 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<V1Condition> condition(String name) {
|
||||
return this.<List<Map<String, Object>>> 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<Boolean> conditionStatus(String name) {
|
||||
return this.<List<Map<String, Object>>> 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<String> consoleUser() {
|
||||
return this.<String> 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<Permission> permissionsFor(String user,
|
||||
Collection<String> roles) {
|
||||
var result = this.<List<Map<String, Object>>> 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.<List<String>> 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<Permission> 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<Long> displayPasswordSerial() {
|
||||
return this.<Number> 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<String, Object> spec;
|
||||
private Map<String, Object> status;
|
||||
|
||||
/**
|
||||
* Gets the spec.
|
||||
*
|
||||
* @return the spec
|
||||
*/
|
||||
public Map<String, Object> getSpec() {
|
||||
return spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the spec.
|
||||
*
|
||||
* @param spec the spec to set
|
||||
*/
|
||||
public void setSpec(Map<String, Object> spec) {
|
||||
this.spec = spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status.
|
||||
*
|
||||
* @return the status
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the status.
|
||||
*
|
||||
* @param status the status to set
|
||||
*/
|
||||
public void setStatus(Map<String, Object> status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2024 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.jdrupes.vmoperator.common;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
|
||||
/**
|
||||
* Represents a VM definition.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
public class VmDefinitionModel extends K8sDynamicModel {
|
||||
|
||||
/**
|
||||
* 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");
|
||||
|
||||
@SuppressWarnings("PMD.UseConcurrentHashMap")
|
||||
private static Map<String, Permission> 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<Permission> parse(String value) {
|
||||
if ("*".equals(value)) {
|
||||
return EnumSet.allOf(Permission.class);
|
||||
}
|
||||
return Set.of(reprs.get(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return repr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new model from the JSON representation.
|
||||
*
|
||||
* @param delegate the gson instance to use for extracting structured data
|
||||
* @param json the JSON
|
||||
*/
|
||||
public VmDefinitionModel(Gson delegate, JsonObject json) {
|
||||
super(delegate, json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all permissions for the given user with the given roles.
|
||||
*
|
||||
* @param user the user
|
||||
* @param roles the roles
|
||||
* @return the sets the
|
||||
*/
|
||||
public Set<Permission> permissionsFor(String user,
|
||||
Collection<String> roles) {
|
||||
return GsonPtr.to(data())
|
||||
.getAsListOf(JsonObject.class, "spec", "permissions")
|
||||
.stream().filter(p -> GsonPtr.to(p).getAsString("user")
|
||||
.map(u -> u.equals(user)).orElse(false)
|
||||
|| GsonPtr.to(p).getAsString("role").map(roles::contains)
|
||||
.orElse(false))
|
||||
.map(p -> GsonPtr.to(p).getAsListOf(JsonPrimitive.class, "may")
|
||||
.stream())
|
||||
.flatMap(Function.identity()).map(p -> p.getAsString())
|
||||
.map(Permission::parse).map(Set::stream)
|
||||
.flatMap(Function.identity()).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the requested VM state
|
||||
*
|
||||
* @return the string
|
||||
*/
|
||||
public RequestedVmState vmState() {
|
||||
return GsonPtr.to(data()).getAsString("spec", "vm", "state")
|
||||
.map(s -> "Running".equals(s) ? RequestedVmState.RUNNING
|
||||
: RequestedVmState.STOPPED)
|
||||
.orElse(RequestedVmState.STOPPED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display password serial.
|
||||
*
|
||||
* @return the optional
|
||||
*/
|
||||
public Optional<Long> displayPasswordSerial() {
|
||||
return GsonPtr.to(status())
|
||||
.get(JsonPrimitive.class, "displayPasswordSerial")
|
||||
.map(JsonPrimitive::getAsLong);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,12 +31,11 @@ import java.util.Collection;
|
|||
* state and can therefore be used for any kind of object, especially
|
||||
* custom objects.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class VmDefinitionStub
|
||||
extends K8sDynamicStubBase<VmDefinitionModel, VmDefinitionModels> {
|
||||
extends K8sDynamicStubBase<VmDefinition, VmDefinitions> {
|
||||
|
||||
private static DynamicTypeAdapterFactory<VmDefinitionModel,
|
||||
VmDefinitionModels> taf = new VmDefintionModelTypeAdapterFactory();
|
||||
private static DynamicTypeAdapterFactory<VmDefinition,
|
||||
VmDefinitions> taf = new VmDefintionModelTypeAdapterFactory();
|
||||
|
||||
/**
|
||||
* Instantiates a new stub for VM defintions.
|
||||
|
|
@ -48,7 +47,7 @@ public class VmDefinitionStub
|
|||
*/
|
||||
public VmDefinitionStub(K8sClient client, APIResource context,
|
||||
String namespace, String name) {
|
||||
super(VmDefinitionModel.class, VmDefinitionModels.class, taf, client,
|
||||
super(VmDefinition.class, VmDefinitions.class, taf, client,
|
||||
context, namespace, name);
|
||||
}
|
||||
|
||||
|
|
@ -64,8 +63,6 @@ public class VmDefinitionStub
|
|||
* @return the stub if the object exists
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
|
||||
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
|
||||
public static VmDefinitionStub get(K8sClient client,
|
||||
GroupVersionKind gvk, String namespace, String name)
|
||||
throws ApiException {
|
||||
|
|
@ -83,8 +80,6 @@ public class VmDefinitionStub
|
|||
* @return the stub if the object exists
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
|
||||
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
|
||||
public static VmDefinitionStub get(K8sClient client,
|
||||
APIResource context, String namespace, String name) {
|
||||
return new VmDefinitionStub(client, context, namespace, name);
|
||||
|
|
@ -101,10 +96,10 @@ public class VmDefinitionStub
|
|||
*/
|
||||
public static VmDefinitionStub createFromYaml(K8sClient client,
|
||||
APIResource context, Reader yaml) throws ApiException {
|
||||
var model = new VmDefinitionModel(client.getJSON().getGson(),
|
||||
var model = new VmDefinition(client.getJSON().getGson(),
|
||||
K8s.yamlToJson(client, yaml));
|
||||
return K8sGenericStub.create(VmDefinitionModel.class,
|
||||
VmDefinitionModels.class, client, context, model,
|
||||
return K8sGenericStub.create(VmDefinition.class,
|
||||
VmDefinitions.class, client, context, model,
|
||||
(c, ns, n) -> new VmDefinitionStub(c, context, ns, n));
|
||||
}
|
||||
|
||||
|
|
@ -121,8 +116,8 @@ public class VmDefinitionStub
|
|||
public static Collection<VmDefinitionStub> list(K8sClient client,
|
||||
APIResource context, String namespace, ListOptions options)
|
||||
throws ApiException {
|
||||
return K8sGenericStub.list(VmDefinitionModel.class,
|
||||
VmDefinitionModels.class, client, context, namespace, options,
|
||||
return K8sGenericStub.list(VmDefinition.class,
|
||||
VmDefinitions.class, client, context, namespace, options,
|
||||
(c, ns, n) -> new VmDefinitionStub(c, context, ns, n));
|
||||
}
|
||||
|
||||
|
|
@ -144,13 +139,13 @@ public class VmDefinitionStub
|
|||
* A factory for creating VmDefinitionModel(s) objects.
|
||||
*/
|
||||
public static class VmDefintionModelTypeAdapterFactory extends
|
||||
DynamicTypeAdapterFactory<VmDefinitionModel, VmDefinitionModels> {
|
||||
DynamicTypeAdapterFactory<VmDefinition, VmDefinitions> {
|
||||
|
||||
/**
|
||||
* Instantiates a new dynamic model type adapter factory.
|
||||
*/
|
||||
public VmDefintionModelTypeAdapterFactory() {
|
||||
super(VmDefinitionModel.class, VmDefinitionModels.class);
|
||||
super(VmDefinition.class, VmDefinitions.class);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ import com.google.gson.Gson;
|
|||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* Represents a list of {@link VmDefinitionModel}s.
|
||||
* Represents a list of {@link VmDefinition}s.
|
||||
*/
|
||||
public class VmDefinitionModels
|
||||
extends K8sDynamicModelsBase<VmDefinitionModel> {
|
||||
public class VmDefinitions
|
||||
extends K8sDynamicModelsBase<VmDefinition> {
|
||||
|
||||
/**
|
||||
* Initialize the object list using the given JSON data.
|
||||
|
|
@ -33,7 +33,7 @@ public class VmDefinitionModels
|
|||
* @param delegate the gson instance to use for extracting structured data
|
||||
* @param data the data
|
||||
*/
|
||||
public VmDefinitionModels(Gson delegate, JsonObject data) {
|
||||
super(VmDefinitionModel.class, delegate, data);
|
||||
public VmDefinitions(Gson delegate, JsonObject data) {
|
||||
super(VmDefinition.class, delegate, data);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String> 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<String> 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<String> 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<String> 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.<Number> 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.<String> 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<InetAddress> displayIp(Class<?> preferredIpVersion) {
|
||||
Optional<String> 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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Grant> permissions = Collections.emptyList();
|
||||
private final Set<String> 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<Grant> permissions() {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the VM names.
|
||||
*
|
||||
* @return the vms
|
||||
*/
|
||||
public Set<String> 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<Permission> permissionsFor(String user,
|
||||
Collection<String> 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.<Set<Permission>> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -10,5 +10,4 @@ plugins {
|
|||
|
||||
dependencies {
|
||||
api project(':org.jdrupes.vmoperator.common')
|
||||
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,45 +18,43 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager.events;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionModel;
|
||||
import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
|
||||
import org.jgrapes.core.Event;
|
||||
|
||||
/**
|
||||
* Gets the current display secret and optionally updates it.
|
||||
* Assign a VM from a pool to a user.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
public class GetDisplayPassword extends Event<String> {
|
||||
public class AssignVm extends Event<VmData> {
|
||||
|
||||
private final VmDefinitionModel vmDef;
|
||||
private final String fromPool;
|
||||
private final String toUser;
|
||||
|
||||
/**
|
||||
* Instantiates a new returns the display secret.
|
||||
* Instantiates a new event.
|
||||
*
|
||||
* @param vmDef the vm name
|
||||
* @param fromPool the from pool
|
||||
* @param toUser the to user
|
||||
*/
|
||||
public GetDisplayPassword(VmDefinitionModel vmDef) {
|
||||
this.vmDef = vmDef;
|
||||
public AssignVm(String fromPool, String toUser) {
|
||||
this.fromPool = fromPool;
|
||||
this.toUser = toUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the vm definition.
|
||||
* Gets the pool to assign from.
|
||||
*
|
||||
* @return the vm definition
|
||||
* @return the pool
|
||||
*/
|
||||
public VmDefinitionModel vmDefinition() {
|
||||
return vmDef;
|
||||
public String fromPool() {
|
||||
return fromPool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the password. May only be called when the event is completed.
|
||||
* Gets the user to assign to.
|
||||
*
|
||||
* @return the optional
|
||||
* @return the to user
|
||||
*/
|
||||
public Optional<String> password() {
|
||||
if (!isDone()) {
|
||||
throw new IllegalStateException("Event is not done.");
|
||||
}
|
||||
return currentResults().stream().findFirst();
|
||||
public String toUser() {
|
||||
return toUser;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,6 @@ public interface ChannelDictionary<K, C extends Channel, A> {
|
|||
* @param channel the channel
|
||||
* @param associated the associated
|
||||
*/
|
||||
@SuppressWarnings("PMD.ShortClassName")
|
||||
public record Value<C extends Channel, A>(C channel, A associated) {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ public class ChannelManager<K, C extends Channel, A>
|
|||
this(k -> null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all keys.
|
||||
*
|
||||
* @return the keys.
|
||||
*/
|
||||
@Override
|
||||
public Set<K> keys() {
|
||||
return entries.keySet();
|
||||
|
|
@ -113,6 +118,18 @@ public class ChannelManager<K, C extends Channel, A>
|
|||
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.
|
||||
|
|
@ -132,8 +149,6 @@ public class ChannelManager<K, C extends Channel, A>
|
|||
* @param supplier the supplier
|
||||
* @return the channel
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AssignmentInOperand",
|
||||
"PMD.DataflowAnomalyAnalysis" })
|
||||
public C computeIfAbsent(K key, Function<K, C> supplier) {
|
||||
return entries.computeIfAbsent(key,
|
||||
k -> new Value<>(supplier.apply(k), null)).channel();
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<List<VmPool>> {
|
||||
|
||||
private String name;
|
||||
private String user;
|
||||
private List<String> 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<String> roles) {
|
||||
this.user = user;
|
||||
this.roles = roles;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name filter criterion, if set.
|
||||
*
|
||||
* @return the optional
|
||||
*/
|
||||
public Optional<String> name() {
|
||||
return Optional.ofNullable(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user filter criterion, if set.
|
||||
*
|
||||
* @return the optional
|
||||
*/
|
||||
public Optional<String> forUser() {
|
||||
return Optional.ofNullable(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the roles criterion.
|
||||
*
|
||||
* @return the list
|
||||
*/
|
||||
public List<String> forRoles() {
|
||||
return roles;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<List<GetVms.VmData>> {
|
||||
|
||||
private String name;
|
||||
private String user;
|
||||
private List<String> 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<String> 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<String> name() {
|
||||
return Optional.ofNullable(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user filter criterion, if set.
|
||||
*
|
||||
* @return the optional
|
||||
*/
|
||||
public Optional<String> user() {
|
||||
return Optional.ofNullable(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the roles criterion.
|
||||
*
|
||||
* @return the list
|
||||
*/
|
||||
public List<String> roles() {
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pool filter criterion, if set.
|
||||
*
|
||||
* @return the optional
|
||||
*/
|
||||
public Optional<String> fromPool() {
|
||||
return Optional.ofNullable(fromPool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user filter criterion, if set.
|
||||
*
|
||||
* @return the optional
|
||||
*/
|
||||
public Optional<String> toUser() {
|
||||
return Optional.ofNullable(toUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return tuple.
|
||||
*
|
||||
* @param definition the definition
|
||||
* @param channel the channel
|
||||
*/
|
||||
public record VmData(VmDefinition definition, VmChannel channel) {
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,6 @@ import org.jgrapes.core.Event;
|
|||
/**
|
||||
* Modifies a VM.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
public class ModifyVm extends Event<Void> {
|
||||
|
||||
private final String name;
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Void> {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,6 @@ import org.jgrapes.core.Event;
|
|||
/**
|
||||
* Triggers a reset of the VM.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
public class ResetVm extends Event<String> {
|
||||
|
||||
private final String vmName;
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Boolean> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,20 +19,20 @@
|
|||
package org.jdrupes.vmoperator.manager.events;
|
||||
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionModel;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.Event;
|
||||
import org.jgrapes.core.EventPipeline;
|
||||
import org.jgrapes.core.Subchannel.DefaultSubchannel;
|
||||
|
||||
/**
|
||||
* A subchannel used to send the events related to a specific VM.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
public class VmChannel extends DefaultSubchannel {
|
||||
|
||||
private final EventPipeline pipeline;
|
||||
private final K8sClient client;
|
||||
private VmDefinitionModel vmDefinition;
|
||||
private VmDefinition definition;
|
||||
private long generation = -1;
|
||||
|
||||
/**
|
||||
|
|
@ -55,19 +55,18 @@ public class VmChannel extends DefaultSubchannel {
|
|||
* @param definition the definition
|
||||
* @return the watch channel
|
||||
*/
|
||||
@SuppressWarnings("PMD.LinguisticNaming")
|
||||
public VmChannel setVmDefinition(VmDefinitionModel definition) {
|
||||
this.vmDefinition = definition;
|
||||
public VmChannel setVmDefinition(VmDefinition definition) {
|
||||
this.definition = definition;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last known definition of the VM.
|
||||
*
|
||||
* @return the json object
|
||||
* @return the defintion
|
||||
*/
|
||||
public VmDefinitionModel vmDefinition() {
|
||||
return vmDefinition;
|
||||
public VmDefinition vmDefinition() {
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -86,7 +85,6 @@ public class VmChannel extends DefaultSubchannel {
|
|||
* @param generation the generation to set
|
||||
* @return true if value has changed
|
||||
*/
|
||||
@SuppressWarnings("PMD.LinguisticNaming")
|
||||
public boolean setGeneration(long generation) {
|
||||
if (this.generation == generation) {
|
||||
return false;
|
||||
|
|
@ -104,6 +102,19 @@ public class VmChannel extends DefaultSubchannel {
|
|||
return pipeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the given event on this channel, using the associated
|
||||
* {@link #pipeline()}.
|
||||
*
|
||||
* @param <T> the generic type
|
||||
* @param event the event
|
||||
* @return the t
|
||||
*/
|
||||
public <T extends Event<?>> T fire(T event) {
|
||||
pipeline.fire(event, this);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the API client.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Void> {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -19,37 +19,41 @@
|
|||
package org.jdrupes.vmoperator.manager.events;
|
||||
|
||||
import org.jdrupes.vmoperator.common.K8sObserver;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionModel;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.Components;
|
||||
import org.jgrapes.core.Event;
|
||||
|
||||
/**
|
||||
* Indicates a change in a VM definition. Note that the definition
|
||||
* consists of the metadata (mostly immutable), the "spec" and the
|
||||
* "status" parts. Consumers that are only interested in "spec"
|
||||
* changes should check {@link #specChanged()} before processing
|
||||
* the event any further.
|
||||
* Indicates a change in a VM "resource". Note that the resource
|
||||
* combines the VM CR's metadata (mostly immutable), the VM CR's
|
||||
* "spec" part, the VM CR's "status" subresource and state information
|
||||
* from the pod. Consumers that are only interested in "spec" changes
|
||||
* should check {@link #specChanged()} before processing the event any
|
||||
* further.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
public class VmDefChanged extends Event<Void> {
|
||||
public class VmResourceChanged extends Event<Void> {
|
||||
|
||||
private final K8sObserver.ResponseType type;
|
||||
private final VmDefinition vmDefinition;
|
||||
private final boolean specChanged;
|
||||
private final VmDefinitionModel vmDef;
|
||||
private final boolean podChanged;
|
||||
|
||||
/**
|
||||
* Instantiates a new VM changed event.
|
||||
*
|
||||
* @param type the type
|
||||
* @param specChanged the spec part changed
|
||||
* @param vmDefinition the VM definition
|
||||
* @param specChanged the spec part changed
|
||||
*/
|
||||
public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged,
|
||||
VmDefinitionModel vmDefinition) {
|
||||
public VmResourceChanged(K8sObserver.ResponseType type,
|
||||
VmDefinition vmDefinition, boolean specChanged,
|
||||
boolean podChanged) {
|
||||
this.type = type;
|
||||
this.vmDefinition = vmDefinition;
|
||||
this.specChanged = specChanged;
|
||||
this.vmDef = vmDefinition;
|
||||
this.podChanged = podChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,6 +65,15 @@ public class VmDefChanged extends Event<Void> {
|
|||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the VM definition.
|
||||
*
|
||||
* @return the VM definition
|
||||
*/
|
||||
public VmDefinition vmDefinition() {
|
||||
return vmDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the "spec" part changed.
|
||||
*/
|
||||
|
|
@ -69,19 +82,17 @@ public class VmDefChanged extends Event<Void> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the object.
|
||||
*
|
||||
* @return the object.
|
||||
* Indicates if the pod status changed.
|
||||
*/
|
||||
public VmDefinitionModel vmDefinition() {
|
||||
return vmDef;
|
||||
public boolean podChanged() {
|
||||
return podChanged;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(Components.objectName(this)).append(" [")
|
||||
.append(vmDef.getMetadata().getName()).append(' ').append(type);
|
||||
.append(vmDefinition.name()).append(' ').append(type);
|
||||
if (channels() != null) {
|
||||
builder.append(", channels=").append(Channel.toString(channels()));
|
||||
}
|
||||
1
org.jdrupes.vmoperator.manager/.gitignore
vendored
Normal file
1
org.jdrupes.vmoperator.manager/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/logging.properties
|
||||
|
|
@ -17,8 +17,8 @@ dependencies {
|
|||
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.1.0,3)'
|
||||
implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.5.0,2)'
|
||||
implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.3.0,3)'
|
||||
implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.8.0,2)'
|
||||
implementation 'org.jgrapes:org.jgrapes.webconsole.rbac:[1.4.0,2)'
|
||||
implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.0,2)'
|
||||
implementation 'org.jgrapes:org.jgrapes.webconlet.markdowndisplay:[1.2.0,2)'
|
||||
|
|
@ -31,8 +31,8 @@ dependencies {
|
|||
runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)'
|
||||
runtimeOnly 'org.apache.logging.log4j:log4j-to-jul:2.20.0'
|
||||
|
||||
runtimeOnly project(':org.jdrupes.vmoperator.vmconlet')
|
||||
runtimeOnly project(':org.jdrupes.vmoperator.vmviewer')
|
||||
runtimeOnly project(':org.jdrupes.vmoperator.vmmgmt')
|
||||
runtimeOnly project(':org.jdrupes.vmoperator.vmaccess')
|
||||
}
|
||||
|
||||
application {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
<footer>
|
||||
Copyright © Michael N. Lipp 2023
|
||||
Copyright © Michael N. Lipp 2023, 2025
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#
|
||||
# VM-Operator
|
||||
# Copyright (C) 2023 Michael N. Lipp
|
||||
# Copyright (C) 2025 Michael N. Lipp
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by
|
||||
|
|
@ -19,10 +19,7 @@
|
|||
handlers=java.util.logging.ConsoleHandler, \
|
||||
org.jgrapes.webconlet.logviewer.LogViewerHandler
|
||||
|
||||
org.jgrapes.level=FINE
|
||||
org.jgrapes.core.handlerTracking.level=FINER
|
||||
|
||||
org.jdrupes.vmoperator.manager.level=FINE
|
||||
org.jdrupes.vmoperator.level=FINE
|
||||
|
||||
java.util.logging.ConsoleHandler.level=ALL
|
||||
java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter
|
||||
|
|
|
|||
|
|
@ -1,141 +1,138 @@
|
|||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
namespace: ${ cr.namespace() }
|
||||
name: ${ cr.name() }
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/instance: ${ cr.name() }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
annotations:
|
||||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||
ownerReferences:
|
||||
- apiVersion: ${ cr.apiVersion.asString }
|
||||
kind: ${ constants.VM_OP_KIND_VM }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
uid: ${ cr.metadata.uid.asString }
|
||||
- apiVersion: ${ cr.apiVersion() }
|
||||
kind: ${ constants.Crd.KIND_VM }
|
||||
name: ${ cr.name() }
|
||||
uid: ${ cr.metadata().getUid() }
|
||||
controller: false
|
||||
|
||||
|
||||
data:
|
||||
config.yaml: |
|
||||
"/Runner":
|
||||
# The directory used to store data files. Defaults to (depending on
|
||||
# values available):
|
||||
# * $XDG_DATA_HOME/vmrunner/${ cr.metadata.name.asString }
|
||||
# * $HOME/.local/share/vmrunner/${ cr.metadata.name.asString }
|
||||
# * ./${ cr.metadata.name.asString }
|
||||
# * $XDG_DATA_HOME/vmrunner/${ cr.name() }
|
||||
# * $HOME/.local/share/vmrunner/${ cr.name() }
|
||||
# * ./${ cr.name() }
|
||||
dataDir: /var/local/vm-data
|
||||
|
||||
# The directory used to store runtime files. Defaults to (depending on
|
||||
# values available):
|
||||
# * $XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString }
|
||||
# * /tmp/$USER/vmrunner/${ cr.metadata.name.asString }
|
||||
# * /tmp/vmrunner/${ cr.metadata.name.asString }
|
||||
# runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.metadata.name.asString }"
|
||||
# * $XDG_RUNTIME_DIR/vmrunner/${ cr.name() }
|
||||
# * /tmp/$USER/vmrunner/${ cr.name() }
|
||||
# * /tmp/vmrunner/${ cr.name() }
|
||||
# runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ cr.name() }"
|
||||
|
||||
<#assign spec = cr.spec() />
|
||||
# The template to use. Resolved relative to /usr/share/vmrunner/templates.
|
||||
# template: "Standard-VM-latest.ftl.yaml"
|
||||
<#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.source?? >
|
||||
template: ${ cr.spec.runnerTemplate.source.asString }
|
||||
<#if spec.runnerTemplate?? && spec.runnerTemplate.source?? >
|
||||
template: ${ spec.runnerTemplate.source }
|
||||
</#if>
|
||||
|
||||
# The template is copied to the data diretory when the VM starts for
|
||||
# the first time. Subsequent starts use the copy unless this option is set.
|
||||
<#if cr.spec.runnerTemplate?? && cr.spec.runnerTemplate.update?? >
|
||||
updateTemplate: ${ cr.spec.runnerTemplate.update.asBoolean?c }
|
||||
<#if spec.runnerTemplate?? && spec.runnerTemplate.update?? >
|
||||
updateTemplate: ${ spec.runnerTemplate.update?c }
|
||||
</#if>
|
||||
|
||||
# Whether a shutdown initiated by the guest stops the pod deployment
|
||||
guestShutdownStops: ${ cr.spec.guestShutdownStops!false?c }
|
||||
guestShutdownStops: ${ (spec.guestShutdownStops!false)?c }
|
||||
|
||||
# When incremented, the VM is reset. The value has no default value,
|
||||
# i.e. if you start the VM without a value for this property, and
|
||||
# decide to trigger a reset later, you have to first set the value
|
||||
# and then inrement it.
|
||||
resetCounter: ${ cr.resetCount }
|
||||
resetCounter: ${ cr.extra().resetCount()?c }
|
||||
|
||||
# Forward the cloud-init data if provided
|
||||
<#if cr.spec.cloudInit??>
|
||||
<#if spec.cloudInit??>
|
||||
cloudInit:
|
||||
<#if cr.spec.cloudInit.metaData??>
|
||||
metaData: ${ cr.spec.cloudInit.metaData.toString() }
|
||||
<#else>
|
||||
metaData: {}
|
||||
</#if>
|
||||
<#if cr.spec.cloudInit.userData??>
|
||||
userData: ${ cr.spec.cloudInit.userData.toString() }
|
||||
metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData!{}, cr.metadata())) }
|
||||
<#if spec.cloudInit.userData??>
|
||||
userData: ${ toJson(spec.cloudInit.userData) }
|
||||
<#else>
|
||||
userData: {}
|
||||
</#if>
|
||||
<#if cr.spec.cloudInit.networkConfig??>
|
||||
networkConfig: ${ cr.spec.cloudInit.networkConfig.toString() }
|
||||
<#if spec.cloudInit.networkConfig??>
|
||||
networkConfig: ${ toJson(spec.cloudInit.networkConfig) }
|
||||
</#if>
|
||||
</#if>
|
||||
|
||||
# Define the VM (required)
|
||||
vm:
|
||||
# The VM's name (required)
|
||||
name: ${ cr.metadata.name.asString }
|
||||
name: ${ cr.name() }
|
||||
|
||||
# The machine's uuid. If none is specified, a uuid is generated
|
||||
# and stored in the data directory. If the uuid is important
|
||||
# (e.g. because licenses depend on it) it is recommaned to specify
|
||||
# it here explicitly or to carefully backup the data directory.
|
||||
# uuid: "generated uuid"
|
||||
<#if cr.spec.vm.machineUuid??>
|
||||
uuid: "${ cr.spec.vm.machineUuid.asString }"
|
||||
<#if spec.vm.machineUuid??>
|
||||
uuid: "${ spec.vm.machineUuid }"
|
||||
</#if>
|
||||
|
||||
# Whether to provide a software TPM (defaults to false)
|
||||
# useTpm: false
|
||||
useTpm: ${ cr.spec.vm.useTpm.asBoolean?c }
|
||||
useTpm: ${ spec.vm.useTpm?c }
|
||||
|
||||
# How to boot (see https://github.com/mnlipp/VM-Operator/blob/main/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml):
|
||||
# * bios
|
||||
# * uefi[-4m]
|
||||
# * secure[-4m]
|
||||
firmware: ${ cr.spec.vm.firmware.asString }
|
||||
firmware: ${ spec.vm.firmware }
|
||||
|
||||
# Whether to show a boot menu.
|
||||
# bootMenu: false
|
||||
bootMenu: ${ cr.spec.vm.bootMenu.asBoolean?c }
|
||||
bootMenu: ${ spec.vm.bootMenu?c }
|
||||
|
||||
# When terminating, a graceful powerdown is attempted. If it
|
||||
# doesn't succeed within the given timeout (seconds) SIGTERM
|
||||
# is sent to Qemu.
|
||||
# powerdownTimeout: 900
|
||||
powerdownTimeout: ${ cr.spec.vm.powerdownTimeout.asLong?c }
|
||||
powerdownTimeout: ${ spec.vm.powerdownTimeout?c }
|
||||
|
||||
# CPU settings
|
||||
cpuModel: ${ cr.spec.vm.cpuModel.asString }
|
||||
cpuModel: ${ spec.vm.cpuModel }
|
||||
# Setting maximumCpus to 1 omits the "-smp" options. The defaults (0)
|
||||
# cause the corresponding property to be omitted from the "-smp" option.
|
||||
# If currentCpus is greater than maximumCpus, the latter is adjusted.
|
||||
<#if cr.spec.vm.maximumCpus?? >
|
||||
maximumCpus: ${ parseQuantity(cr.spec.vm.maximumCpus.asString)?c }
|
||||
<#if spec.vm.maximumCpus?? >
|
||||
maximumCpus: ${ parseQuantity(spec.vm.maximumCpus)?c }
|
||||
</#if>
|
||||
<#if cr.spec.vm.cpuTopology?? >
|
||||
sockets: ${ cr.spec.vm.cpuTopology.sockets.asInt?c }
|
||||
diesPerSocket: ${ cr.spec.vm.cpuTopology.diesPerSocket.asInt?c }
|
||||
coresPerDie: ${ cr.spec.vm.cpuTopology.coresPerDie.asInt?c }
|
||||
threadsPerCore: ${ cr.spec.vm.cpuTopology.threadsPerCore.asInt?c }
|
||||
<#if spec.vm.cpuTopology?? >
|
||||
sockets: ${ spec.vm.cpuTopology.sockets?c }
|
||||
diesPerSocket: ${ spec.vm.cpuTopology.diesPerSocket?c }
|
||||
coresPerDie: ${ spec.vm.cpuTopology.coresPerDie?c }
|
||||
threadsPerCore: ${ spec.vm.cpuTopology.threadsPerCore?c }
|
||||
</#if>
|
||||
<#if cr.spec.vm.currentCpus?? >
|
||||
currentCpus: ${ parseQuantity(cr.spec.vm.currentCpus.asString)?c }
|
||||
<#if spec.vm.currentCpus?? >
|
||||
currentCpus: ${ parseQuantity(spec.vm.currentCpus)?c }
|
||||
</#if>
|
||||
|
||||
# RAM settings
|
||||
# Maximum defaults to 1G
|
||||
maximumRam: "${ formatMemory(parseQuantity(cr.spec.vm.maximumRam.asString)) }"
|
||||
<#if cr.spec.vm.currentRam?? >
|
||||
currentRam: "${ formatMemory(parseQuantity(cr.spec.vm.currentRam.asString)) }"
|
||||
maximumRam: "${ formatMemory(parseQuantity(spec.vm.maximumRam)) }"
|
||||
<#if spec.vm.currentRam?? >
|
||||
currentRam: "${ formatMemory(parseQuantity(spec.vm.currentRam)) }"
|
||||
</#if>
|
||||
|
||||
# RTC settings.
|
||||
# rtcBase: utc
|
||||
# rtcClock: rt
|
||||
rtcBase: ${ cr.spec.vm.rtcBase.asString }
|
||||
rtcClock: ${ cr.spec.vm.rtcClock.asString }
|
||||
rtcBase: ${ spec.vm.rtcBase }
|
||||
rtcClock: ${ spec.vm.rtcClock }
|
||||
|
||||
# Network settings
|
||||
# Supported types are "tap" and "user" (for debugging). Type "user"
|
||||
|
|
@ -147,19 +144,19 @@ data:
|
|||
# mac: (undefined)
|
||||
network:
|
||||
<#assign nwCounter = 0/>
|
||||
<#list cr.spec.vm.networks.asList() as itf>
|
||||
<#list spec.vm.networks as itf>
|
||||
<#if itf.tap??>
|
||||
- type: tap
|
||||
device: ${ itf.tap.device.asString }
|
||||
bridge: ${ itf.tap.bridge.asString }
|
||||
device: ${ itf.tap.device }
|
||||
bridge: ${ itf.tap.bridge }
|
||||
<#if itf.tap.mac??>
|
||||
mac: "${ itf.tap.mac.asString }"
|
||||
mac: "${ itf.tap.mac }"
|
||||
</#if>
|
||||
<#elseif itf.user??>
|
||||
- type: user
|
||||
device: ${ itf.user.device.asString }
|
||||
device: ${ itf.user.device }
|
||||
<#if itf.user.net??>
|
||||
net: "${ itf.user.net.asString }"
|
||||
net: "${ itf.user.net }"
|
||||
</#if>
|
||||
</#if>
|
||||
<#assign nwCounter += 1/>
|
||||
|
|
@ -175,11 +172,11 @@ data:
|
|||
# file: (undefined)
|
||||
drives:
|
||||
<#assign drvCounter = 0/>
|
||||
<#list cr.spec.vm.disks.asList() as disk>
|
||||
<#list spec.vm.disks as disk>
|
||||
<#if disk.volumeClaimTemplate??
|
||||
&& disk.volumeClaimTemplate.metadata??
|
||||
&& disk.volumeClaimTemplate.metadata.name??>
|
||||
<#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk">
|
||||
<#assign diskName = disk.volumeClaimTemplate.metadata.name + "-disk">
|
||||
<#else>
|
||||
<#assign diskName = "disk-" + drvCounter>
|
||||
</#if>
|
||||
|
|
@ -187,33 +184,36 @@ data:
|
|||
- type: raw
|
||||
resource: /dev/${ diskName }
|
||||
<#if disk.bootindex??>
|
||||
bootindex: ${ disk.bootindex.asInt?c }
|
||||
bootindex: ${ disk.bootindex?c }
|
||||
</#if>
|
||||
<#assign drvCounter = drvCounter + 1/>
|
||||
</#if>
|
||||
<#if disk.cdrom??>
|
||||
- type: ide-cd
|
||||
file: "${ disk.cdrom.image.asString }"
|
||||
file: "${ imageLocation(disk.cdrom.image) }"
|
||||
<#if disk.bootindex??>
|
||||
bootindex: ${ disk.bootindex.asInt?c }
|
||||
bootindex: ${ disk.bootindex?c }
|
||||
</#if>
|
||||
</#if>
|
||||
</#list>
|
||||
|
||||
display:
|
||||
<#if cr.spec.vm.display.outputs?? >
|
||||
outputs: ${ cr.spec.vm.display.outputs.asInt?c }
|
||||
<#if spec.vm.display.outputs?? >
|
||||
outputs: ${ spec.vm.display.outputs?c }
|
||||
</#if>
|
||||
<#if cr.spec.vm.display.spice??>
|
||||
<#if loginRequestedFor?? >
|
||||
loggedInUser: "${ loginRequestedFor }"
|
||||
</#if>
|
||||
<#if spec.vm.display.spice??>
|
||||
spice:
|
||||
port: ${ cr.spec.vm.display.spice.port.asInt?c }
|
||||
<#if cr.spec.vm.display.spice.ticket??>
|
||||
ticket: "${ cr.spec.vm.display.spice.ticket.asString }"
|
||||
port: ${ spec.vm.display.spice.port?c }
|
||||
<#if spec.vm.display.spice.ticket??>
|
||||
ticket: "${ spec.vm.display.spice.ticket }"
|
||||
</#if>
|
||||
<#if cr.spec.vm.display.spice.streamingVideo??>
|
||||
streaming-video: "${ cr.spec.vm.display.spice.streamingVideo.asString }"
|
||||
<#if spec.vm.display.spice.streamingVideo??>
|
||||
streaming-video: "${ spec.vm.display.spice.streamingVideo }"
|
||||
</#if>
|
||||
usbRedirects: ${ cr.spec.vm.display.spice.usbRedirects.asInt?c }
|
||||
usbRedirects: ${ spec.vm.display.spice.usbRedirects?c }
|
||||
</#if>
|
||||
|
||||
logging.properties: |
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
namespace: ${ cr.namespace() }
|
||||
name: ${ runnerDataPvcName }
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/instance: ${ cr.name() }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
spec:
|
||||
accessModes:
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
namespace: ${ cr.namespace() }
|
||||
name: ${ disk.generatedPvcName }
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/instance: ${ cr.name() }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
<#if disk.volumeClaimTemplate.metadata??
|
||||
&& disk.volumeClaimTemplate.metadata.annotations??>
|
||||
annotations:
|
||||
${ disk.volumeClaimTemplate.metadata.annotations.toString() }
|
||||
${ toJson(disk.volumeClaimTemplate.metadata.annotations) }
|
||||
</#if>
|
||||
spec:
|
||||
${ disk.volumeClaimTemplate.spec.toString() }
|
||||
${ toJson(disk.volumeClaimTemplate.spec) }
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
namespace: ${ cr.namespace() }
|
||||
name: ${ cr.name() }
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/instance: ${ cr.name() }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
annotations:
|
||||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||
ownerReferences:
|
||||
- apiVersion: ${ cr.apiVersion.asString }
|
||||
kind: ${ constants.VM_OP_KIND_VM }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
uid: ${ cr.metadata.uid.asString }
|
||||
- apiVersion: ${ cr.apiVersion() }
|
||||
kind: ${ constants.Crd.KIND_VM }
|
||||
name: ${ cr.name() }
|
||||
uid: ${ cr.metadata().getUid() }
|
||||
controller: false
|
||||
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- name: spice
|
||||
port: ${ cr.spec.vm.display.spice.port.asInt?c }
|
||||
port: ${ cr.spec().vm.display.spice.port?c }
|
||||
selector:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/instance: ${ cr.name() }
|
||||
|
|
|
|||
|
|
@ -1,42 +1,43 @@
|
|||
kind: Pod
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
namespace: ${ cr.namespace() }
|
||||
name: ${ cr.name() }
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/instance: ${ cr.name() }
|
||||
app.kubernetes.io/component: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
annotations:
|
||||
# Triggers update of config map mounted in pod
|
||||
# See https://ahmet.im/blog/kubernetes-secret-volumes-delay/
|
||||
vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion.asString }"
|
||||
vmrunner.jdrupes.org/cmVersion: "${ configMapResourceVersion }"
|
||||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||
ownerReferences:
|
||||
- apiVersion: ${ cr.apiVersion.asString }
|
||||
kind: ${ constants.VM_OP_KIND_VM }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
uid: ${ cr.metadata.uid.asString }
|
||||
- apiVersion: ${ cr.apiVersion() }
|
||||
kind: ${ constants.Crd.KIND_VM }
|
||||
name: ${ cr.name() }
|
||||
uid: ${ cr.metadata().getUid() }
|
||||
blockOwnerDeletion: true
|
||||
controller: false
|
||||
<#assign spec = cr.spec() />
|
||||
spec:
|
||||
containers:
|
||||
- name: ${ cr.metadata.name.asString }
|
||||
<#assign image = cr.spec.image>
|
||||
- name: ${ cr.name() }
|
||||
<#assign image = spec.image>
|
||||
<#if image.source??>
|
||||
image: ${ image.source.asString }
|
||||
image: ${ image.source }
|
||||
<#else>
|
||||
image: ${ image.repository.asString }/${ image.path.asString }<#if image.version??>:${ image.version.asString }</#if>
|
||||
image: ${ image.repository }/${ image.path }<#if image.version??>:${ image.version }</#if>
|
||||
</#if>
|
||||
<#if image.pullPolicy??>
|
||||
imagePullPolicy: ${ image.pullPolicy.asString }
|
||||
imagePullPolicy: ${ image.pullPolicy }
|
||||
</#if>
|
||||
<#if cr.spec.vm.display.spice??>
|
||||
<#if spec.vm.display.spice??>
|
||||
ports:
|
||||
<#if cr.spec.vm.display.spice??>
|
||||
<#if spec.vm.display.spice??>
|
||||
- name: spice
|
||||
containerPort: ${ cr.spec.vm.display.spice.port.asInt?c }
|
||||
containerPort: ${ spec.vm.display.spice.port?c }
|
||||
protocol: TCP
|
||||
</#if>
|
||||
</#if>
|
||||
|
|
@ -55,33 +56,33 @@ spec:
|
|||
- name: vmop-image-repository
|
||||
mountPath: ${ constants.IMAGE_REPO_PATH }
|
||||
volumeDevices:
|
||||
<#list cr.spec.vm.disks.asList() as disk>
|
||||
<#list spec.vm.disks as disk>
|
||||
<#if disk.volumeClaimTemplate??>
|
||||
- name: ${ disk.generatedDiskName.asString }
|
||||
devicePath: /dev/${ disk.generatedDiskName.asString }
|
||||
- name: ${ disk.generatedDiskName }
|
||||
devicePath: /dev/${ disk.generatedDiskName }
|
||||
</#if>
|
||||
</#list>
|
||||
securityContext:
|
||||
privileged: true
|
||||
<#if cr.spec.resources??>
|
||||
resources: ${ cr.spec.resources.toString() }
|
||||
<#if spec.resources??>
|
||||
resources: ${ toJson(spec.resources) }
|
||||
<#else>
|
||||
<#if cr.spec.vm.currentCpus?? || cr.spec.vm.currentRam?? >
|
||||
<#if spec.vm.currentCpus?? || spec.vm.currentRam?? >
|
||||
resources:
|
||||
requests:
|
||||
<#if cr.spec.vm.currentCpus?? >
|
||||
<#if spec.vm.currentCpus?? >
|
||||
<#assign factor = 2.0 />
|
||||
<#if reconciler.cpuOvercommit??>
|
||||
<#assign factor = reconciler.cpuOvercommit * 1.0 />
|
||||
</#if>
|
||||
cpu: ${ (parseQuantity(cr.spec.vm.currentCpus.asString) / factor)?c }
|
||||
cpu: ${ (parseQuantity(spec.vm.currentCpus) / factor)?c }
|
||||
</#if>
|
||||
<#if cr.spec.vm.currentRam?? >
|
||||
<#if spec.vm.currentRam?? >
|
||||
<#assign factor = 1.25 />
|
||||
<#if reconciler.ramOvercommit??>
|
||||
<#assign factor = reconciler.ramOvercommit * 1.0 />
|
||||
</#if>
|
||||
memory: ${ (parseQuantity(cr.spec.vm.currentRam.asString) / factor)?floor?c }
|
||||
memory: ${ (parseQuantity(spec.vm.currentRam) / factor)?floor?c }
|
||||
</#if>
|
||||
</#if>
|
||||
</#if>
|
||||
|
|
@ -102,7 +103,7 @@ spec:
|
|||
projected:
|
||||
sources:
|
||||
- configMap:
|
||||
name: ${ cr.metadata.name.asString }
|
||||
name: ${ cr.name() }
|
||||
<#if displaySecret??>
|
||||
- secret:
|
||||
name: ${ displaySecret }
|
||||
|
|
@ -113,22 +114,22 @@ spec:
|
|||
- name: runner-data
|
||||
persistentVolumeClaim:
|
||||
claimName: ${ runnerDataPvcName }
|
||||
<#list cr.spec.vm.disks.asList() as disk>
|
||||
<#list spec.vm.disks as disk>
|
||||
<#if disk.volumeClaimTemplate??>
|
||||
- name: ${ disk.generatedDiskName.asString }
|
||||
- name: ${ disk.generatedDiskName }
|
||||
persistentVolumeClaim:
|
||||
claimName: ${ disk.generatedPvcName.asString }
|
||||
claimName: ${ disk.generatedPvcName }
|
||||
</#if>
|
||||
</#list>
|
||||
hostNetwork: true
|
||||
terminationGracePeriodSeconds: ${ (cr.spec.vm.powerdownTimeout.asInt + 5)?c }
|
||||
<#if cr.spec.nodeName??>
|
||||
nodeName: ${ cr.spec.nodeName.asString }
|
||||
terminationGracePeriodSeconds: ${ (spec.vm.powerdownTimeout + 5)?c }
|
||||
<#if spec.nodeName??>
|
||||
nodeName: ${ spec.nodeName }
|
||||
</#if>
|
||||
<#if cr.spec.nodeSelector??>
|
||||
nodeSelector: ${ cr.spec.nodeSelector.toString() }
|
||||
<#if spec.nodeSelector??>
|
||||
nodeSelector: ${ toJson(spec.nodeSelector) }
|
||||
</#if>
|
||||
<#if cr.spec.affinity??>
|
||||
affinity: ${ cr.spec.affinity.toString() }
|
||||
<#if spec.affinity??>
|
||||
affinity: ${ toJson(spec.affinity) }
|
||||
</#if>
|
||||
serviceAccountName: vm-runner
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
annotations:
|
||||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||
ownerReferences:
|
||||
- apiVersion: ${ cr.apiVersion.asString }
|
||||
kind: ${ constants.VM_OP_KIND_VM }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
uid: ${ cr.metadata.uid.asString }
|
||||
blockOwnerDeletion: true
|
||||
controller: false
|
||||
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
replicas: ${ (cr.spec.vm.state.asString == "Running")?then(1, 0) }
|
||||
updateStrategy:
|
||||
type: OnDelete
|
||||
template:
|
||||
metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
name: ${ cr.metadata.name.asString }
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/component: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
annotations:
|
||||
# Triggers update of config map mounted in pod
|
||||
# See https://ahmet.im/blog/kubernetes-secret-volumes-delay/
|
||||
vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion.asString }"
|
||||
vmoperator.jdrupes.org/version: ${ managerVersion }
|
||||
spec:
|
||||
containers:
|
||||
- name: ${ cr.metadata.name.asString }
|
||||
<#assign image = cr.spec.image>
|
||||
<#if image.source??>
|
||||
image: ${ image.source.asString }
|
||||
<#else>
|
||||
image: ${ image.repository.asString }/${ image.path.asString }<#if image.version??>:${ image.version.asString }</#if>
|
||||
</#if>
|
||||
<#if image.pullPolicy??>
|
||||
imagePullPolicy: ${ image.pullPolicy.asString }
|
||||
</#if>
|
||||
<#if cr.spec.vm.display.spice??>
|
||||
ports:
|
||||
<#if cr.spec.vm.display.spice??>
|
||||
- name: spice
|
||||
containerPort: ${ cr.spec.vm.display.spice.port.asInt?c }
|
||||
protocol: TCP
|
||||
</#if>
|
||||
</#if>
|
||||
volumeMounts:
|
||||
# Not needed because pod is priviledged:
|
||||
# - mountPath: /dev/kvm
|
||||
# name: dev-kvm
|
||||
# - mountPath: /dev/net/tun
|
||||
# name: dev-tun
|
||||
# - mountPath: /sys/fs/cgroup
|
||||
# name: cgroup
|
||||
- name: config
|
||||
mountPath: /etc/opt/vmrunner
|
||||
- name: runner-data
|
||||
mountPath: /var/local/vm-data
|
||||
- name: vmop-image-repository
|
||||
mountPath: ${ constants.IMAGE_REPO_PATH }
|
||||
volumeDevices:
|
||||
<#assign diskCounter = 0/>
|
||||
<#list cr.spec.vm.disks.asList() as disk>
|
||||
<#if disk.volumeClaimTemplate??>
|
||||
<#if disk.volumeClaimTemplate.metadata??
|
||||
&& disk.volumeClaimTemplate.metadata.name??>
|
||||
<#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk">
|
||||
<#else>
|
||||
<#assign diskName = "disk-" + diskCounter>
|
||||
</#if>
|
||||
- name: ${ diskName }
|
||||
devicePath: /dev/${ diskName }
|
||||
<#assign diskCounter = diskCounter + 1/>
|
||||
</#if>
|
||||
</#list>
|
||||
securityContext:
|
||||
privileged: true
|
||||
<#if cr.spec.resources??>
|
||||
resources: ${ cr.spec.resources.toString() }
|
||||
<#else>
|
||||
<#if cr.spec.vm.currentCpus?? || cr.spec.vm.currentRam?? >
|
||||
resources:
|
||||
requests:
|
||||
<#if cr.spec.vm.currentCpus?? >
|
||||
<#assign factor = 2.0 />
|
||||
<#if reconciler.cpuOvercommit??>
|
||||
<#assign factor = reconciler.cpuOvercommit * 1.0 />
|
||||
</#if>
|
||||
cpu: ${ (parseQuantity(cr.spec.vm.currentCpus.asString) / factor)?c }
|
||||
</#if>
|
||||
<#if cr.spec.vm.currentRam?? >
|
||||
<#assign factor = 1.25 />
|
||||
<#if reconciler.ramOvercommit??>
|
||||
<#assign factor = reconciler.ramOvercommit * 1.0 />
|
||||
</#if>
|
||||
memory: ${ (parseQuantity(cr.spec.vm.currentRam.asString) / factor)?floor?c }
|
||||
</#if>
|
||||
</#if>
|
||||
</#if>
|
||||
volumes:
|
||||
# Not needed because pod is priviledged:
|
||||
# - name: dev-kvm
|
||||
# hostPath:
|
||||
# path: /dev/kvm
|
||||
# type: CharDevice
|
||||
# - hostPath:
|
||||
# path: /dev/net/tun
|
||||
# type: CharDevice
|
||||
# name: dev-tun
|
||||
# - name: cgroup
|
||||
# hostPath:
|
||||
# path: /sys/fs/cgroup
|
||||
- name: config
|
||||
projected:
|
||||
sources:
|
||||
- configMap:
|
||||
name: ${ cr.metadata.name.asString }
|
||||
<#if displaySecret??>
|
||||
- secret:
|
||||
name: ${ displaySecret }
|
||||
</#if>
|
||||
- name: vmop-image-repository
|
||||
persistentVolumeClaim:
|
||||
claimName: vmop-image-repository
|
||||
hostNetwork: true
|
||||
terminationGracePeriodSeconds: ${ (cr.spec.vm.powerdownTimeout.asInt + 5)?c }
|
||||
<#if cr.spec.nodeName??>
|
||||
nodeName: ${ cr.spec.nodeName.asString }
|
||||
</#if>
|
||||
<#if cr.spec.nodeSelector??>
|
||||
nodeSelector: ${ cr.spec.nodeSelector.toString() }
|
||||
</#if>
|
||||
<#if cr.spec.affinity??>
|
||||
affinity: ${ cr.spec.affinity.toString() }
|
||||
</#if>
|
||||
serviceAccountName: vm-runner
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
name: runner-data
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
<#if reconciler.runnerDataPvc?? && reconciler.runnerDataPvc.storageClassName??>
|
||||
storageClassName: ${ reconciler.runnerDataPvc.storageClassName }
|
||||
</#if>
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Mi
|
||||
<#assign diskCounter = 0/>
|
||||
<#list cr.spec.vm.disks.asList() as disk>
|
||||
<#if disk.volumeClaimTemplate??>
|
||||
<#if disk.volumeClaimTemplate.metadata??
|
||||
&& disk.volumeClaimTemplate.metadata.name??>
|
||||
<#assign diskName = disk.volumeClaimTemplate.metadata.name.asString + "-disk">
|
||||
<#else>
|
||||
<#assign diskName = "disk-" + diskCounter>
|
||||
</#if>
|
||||
- metadata:
|
||||
namespace: ${ cr.metadata.namespace.asString }
|
||||
name: ${ diskName }
|
||||
labels:
|
||||
app.kubernetes.io/name: ${ constants.APP_NAME }
|
||||
app.kubernetes.io/instance: ${ cr.metadata.name.asString }
|
||||
app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME }
|
||||
<#if disk.volumeClaimTemplate.metadata??
|
||||
&& disk.volumeClaimTemplate.metadata.annotations??>
|
||||
annotations:
|
||||
${ disk.volumeClaimTemplate.metadata.annotations.toString() }
|
||||
</#if>
|
||||
spec:
|
||||
${ disk.volumeClaimTemplate.spec.toString() }
|
||||
<#assign diskCounter = diskCounter + 1/>
|
||||
</#if>
|
||||
</#list>
|
||||
|
|
@ -51,7 +51,6 @@ import org.jgrapes.util.events.ConfigurationUpdate;
|
|||
* @param <O> the object type for the context
|
||||
* @param <L> the object list type for the context
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis" })
|
||||
public abstract class AbstractMonitor<O extends KubernetesObject,
|
||||
L extends KubernetesListObject, C extends Channel> extends Component {
|
||||
|
||||
|
|
@ -181,7 +180,6 @@ public abstract class AbstractMonitor<O extends KubernetesObject,
|
|||
* @param event the event
|
||||
*/
|
||||
@Handler(priority = 10)
|
||||
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||
public void onStart(Start event) {
|
||||
try {
|
||||
// Get namespace
|
||||
|
|
@ -199,8 +197,6 @@ public abstract class AbstractMonitor<O extends KubernetesObject,
|
|||
assert client != null;
|
||||
assert context != null;
|
||||
assert namespace != null;
|
||||
logger.fine(() -> "Observing " + K8s.toString(context)
|
||||
+ " objects in " + namespace);
|
||||
|
||||
// Monitor all versions
|
||||
for (var version : context.getVersions()) {
|
||||
|
|
@ -219,9 +215,7 @@ public abstract class AbstractMonitor<O extends KubernetesObject,
|
|||
observerCounter.incrementAndGet();
|
||||
new K8sObserver<>(objectClass, objectListClass, client,
|
||||
K8s.preferred(context, version), namespace, options)
|
||||
.handler((c, r) -> {
|
||||
handleChange(c, r);
|
||||
}).onTerminated((o, t) -> {
|
||||
.handler(this::handleChange).onTerminated((o, t) -> {
|
||||
if (observerCounter.decrementAndGet() == 0) {
|
||||
unregisterAsGenerator();
|
||||
}
|
||||
|
|
@ -246,7 +240,9 @@ public abstract class AbstractMonitor<O extends KubernetesObject,
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle an observed change.
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -18,11 +18,18 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import freemarker.template.AdapterTemplateModel;
|
||||
import freemarker.template.Configuration;
|
||||
import freemarker.template.TemplateException;
|
||||
import freemarker.template.TemplateMethodModelEx;
|
||||
import freemarker.template.TemplateModel;
|
||||
import freemarker.template.TemplateModelException;
|
||||
import freemarker.template.utility.DeepUnwrap;
|
||||
import io.kubernetes.client.custom.V1Patch;
|
||||
import io.kubernetes.client.openapi.ApiClient;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
||||
import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi;
|
||||
import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
|
||||
import io.kubernetes.client.util.generic.dynamic.Dynamics;
|
||||
|
|
@ -30,12 +37,18 @@ import io.kubernetes.client.util.generic.options.ListOptions;
|
|||
import io.kubernetes.client.util.generic.options.PatchOptions;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Logger;
|
||||
import org.jdrupes.vmoperator.common.K8s;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.util.DataPath;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
|
@ -43,7 +56,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
/**
|
||||
* Delegee for reconciling the config map
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
/* default */ class ConfigMapReconciler {
|
||||
|
||||
protected final Logger logger = Logger.getLogger(getClass().getName());
|
||||
|
|
@ -63,31 +75,70 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
*
|
||||
* @param model the model
|
||||
* @param channel the channel
|
||||
* @return the dynamic kubernetes object
|
||||
* @param modelChanged the model has changed
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
* @throws TemplateException the template exception
|
||||
* @throws ApiException the api exception
|
||||
* @throws ApiException the API exception
|
||||
*/
|
||||
public DynamicKubernetesObject reconcile(Map<String, Object> model,
|
||||
VmChannel channel)
|
||||
public void reconcile(Map<String, Object> model, VmChannel channel,
|
||||
boolean modelChanged)
|
||||
throws IOException, TemplateException, ApiException {
|
||||
// Get API
|
||||
DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1",
|
||||
"configmaps", channel.client());
|
||||
// Check if an update is needed
|
||||
var prevData = channel.associated(PrevData.class)
|
||||
.orElseGet(() -> new PrevData(null, new HashMap<>()));
|
||||
Object newInputs = model.get("loginRequestedFor");
|
||||
if (!modelChanged && Objects.equals(prevData.inputs, newInputs)) {
|
||||
// Make added data available in new model
|
||||
model.putAll(prevData.added);
|
||||
return;
|
||||
}
|
||||
prevData = new PrevData(newInputs, prevData.added);
|
||||
channel.setAssociated(PrevData.class, prevData);
|
||||
|
||||
// Combine template and data and parse result
|
||||
logger.fine(() -> "Create/update configmap "
|
||||
+ DataPath.<String> get(model, "cr", "name").orElse("unknown"));
|
||||
model.put("adjustCloudInitMeta", adjustCloudInitMetaModel);
|
||||
prevData.added.put("adjustCloudInitMeta", adjustCloudInitMetaModel);
|
||||
var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml");
|
||||
StringWriter out = new StringWriter();
|
||||
fmTemplate.process(model, out);
|
||||
// Avoid Yaml.load due to
|
||||
// https://github.com/kubernetes-client/java/issues/2741
|
||||
var mapDef = Dynamics.newFromYaml(
|
||||
var newCm = Dynamics.newFromYaml(
|
||||
new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
|
||||
|
||||
// Maybe override logging.properties from reconciler configuration.
|
||||
DataPath.<String> 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.<String> get(model, "cr", "spec", "loggingProperties")
|
||||
.ifPresent(props -> {
|
||||
GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data")
|
||||
.get().addProperty("logging.properties", props);
|
||||
});
|
||||
|
||||
// Get API and update
|
||||
DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1",
|
||||
"configmaps", channel.client());
|
||||
|
||||
// Apply and maybe force pod update
|
||||
var newState = K8s.apply(cmApi, mapDef, out.toString());
|
||||
maybeForceUpdate(channel.client(), newState);
|
||||
return newState;
|
||||
var updatedCm = K8s.apply(cmApi, newCm, newCm.getRaw().toString());
|
||||
maybeForceUpdate(channel.client(), updatedCm);
|
||||
model.put("configMapResourceVersion",
|
||||
updatedCm.getMetadata().getResourceVersion());
|
||||
prevData.added.put("configMapResourceVersion",
|
||||
updatedCm.getMetadata().getResourceVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* Key for association.
|
||||
*/
|
||||
private record PrevData(Object inputs, Map<String, Object> added) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -133,4 +184,27 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
}
|
||||
}
|
||||
|
||||
private final TemplateMethodModelEx adjustCloudInitMetaModel
|
||||
= new TemplateMethodModelEx() {
|
||||
@Override
|
||||
public Object exec(@SuppressWarnings("rawtypes") List arguments)
|
||||
throws TemplateModelException {
|
||||
@SuppressWarnings("unchecked")
|
||||
var res = new HashMap<>((Map<String, Object>) 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;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,18 +21,8 @@ package org.jdrupes.vmoperator.manager;
|
|||
/**
|
||||
* Some constants.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
public class Constants extends org.jdrupes.vmoperator.common.Constants {
|
||||
|
||||
/** The Constant COMP_DISPLAY_SECRET. */
|
||||
public static final String COMP_DISPLAY_SECRET = "display-secret";
|
||||
|
||||
/** The Constant DATA_DISPLAY_PASSWORD. */
|
||||
public static final String DATA_DISPLAY_PASSWORD = "display-password";
|
||||
|
||||
/** The Constant DATA_PASSWORD_EXPIRY. */
|
||||
public static final String DATA_PASSWORD_EXPIRY = "password-expiry";
|
||||
|
||||
/** The Constant STATE_RUNNING. */
|
||||
public static final String STATE_RUNNING = "Running";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2023 Michael N. Lipp
|
||||
* Copyright (C) 2023, 2025 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
|
|
@ -18,25 +18,39 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import io.kubernetes.client.apimachinery.GroupVersionKind;
|
||||
import io.kubernetes.client.custom.V1Patch;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.openapi.Configuration;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
||||
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
import org.jdrupes.vmoperator.common.VmPool;
|
||||
import org.jdrupes.vmoperator.manager.events.AssignVm;
|
||||
import org.jdrupes.vmoperator.manager.events.ChannelManager;
|
||||
import org.jdrupes.vmoperator.manager.events.Exit;
|
||||
import org.jdrupes.vmoperator.manager.events.GetPools;
|
||||
import org.jdrupes.vmoperator.manager.events.GetVms;
|
||||
import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
|
||||
import org.jdrupes.vmoperator.manager.events.ModifyVm;
|
||||
import org.jdrupes.vmoperator.manager.events.PodChanged;
|
||||
import org.jdrupes.vmoperator.manager.events.UpdateAssignment;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.manager.events.VmPoolChanged;
|
||||
import org.jdrupes.vmoperator.manager.events.VmResourceChanged;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.Component;
|
||||
import org.jgrapes.core.EventPipeline;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
import org.jgrapes.core.events.HandlingError;
|
||||
import org.jgrapes.core.events.Start;
|
||||
|
|
@ -48,7 +62,7 @@ import org.jgrapes.util.events.ConfigurationUpdate;
|
|||
*
|
||||
* The implementation splits the controller in two components. The
|
||||
* {@link VmMonitor} and the {@link Reconciler}. The former watches
|
||||
* the VM definitions (CRs) and generates {@link VmDefChanged} events
|
||||
* the VM definitions (CRs) and generates {@link VmResourceChanged} events
|
||||
* when they change. The latter handles the changes and reconciles the
|
||||
* resources in the cluster.
|
||||
*
|
||||
|
|
@ -81,6 +95,7 @@ import org.jgrapes.util.events.ConfigurationUpdate;
|
|||
public class Controller extends Component {
|
||||
|
||||
private String namespace;
|
||||
private final ChannelManager<String, VmChannel, EventPipeline> chanMgr;
|
||||
|
||||
/**
|
||||
* Creates a new instance.
|
||||
|
|
@ -89,23 +104,24 @@ public class Controller extends Component {
|
|||
public Controller(Channel componentChannel) {
|
||||
super(componentChannel);
|
||||
// Prepare component tree
|
||||
ChannelManager<String, VmChannel, ?> 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;
|
||||
}
|
||||
});
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -167,40 +183,146 @@ public class Controller extends Component {
|
|||
fire(new Exit(2));
|
||||
return;
|
||||
}
|
||||
logger.fine(() -> "Controlling namespace \"" + namespace + "\".");
|
||||
logger.config(() -> "Controlling namespace \"" + namespace + "\".");
|
||||
}
|
||||
|
||||
/**
|
||||
* On modify vm.
|
||||
* Returns the VM data.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
public void onGetVms(GetVms event) {
|
||||
event.setResult(chanMgr.channels().stream()
|
||||
.filter(c -> event.name().isEmpty()
|
||||
|| c.vmDefinition().name().equals(event.name().get()))
|
||||
.filter(c -> event.user().isEmpty() && event.roles().isEmpty()
|
||||
|| !c.vmDefinition().permissionsFor(event.user().orElse(null),
|
||||
event.roles()).isEmpty())
|
||||
.filter(c -> event.fromPool().isEmpty()
|
||||
|| c.vmDefinition().assignment().map(Assignment::pool)
|
||||
.map(p -> p.equals(event.fromPool().get())).orElse(false))
|
||||
.filter(c -> event.toUser().isEmpty()
|
||||
|| c.vmDefinition().assignment().map(Assignment::user)
|
||||
.map(u -> u.equals(event.toUser().get())).orElse(false))
|
||||
.map(c -> new VmData(c.vmDefinition(), c))
|
||||
.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a VM if not already assigned.
|
||||
*
|
||||
* @param event the event
|
||||
* @throws ApiException the api exception
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
@Handler
|
||||
public void onModifyVm(ModifyVm event, VmChannel channel)
|
||||
throws ApiException, IOException {
|
||||
patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(),
|
||||
event.value());
|
||||
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||
public void onAssignVm(AssignVm event)
|
||||
throws ApiException, InterruptedException {
|
||||
while (true) {
|
||||
// Search for existing assignment.
|
||||
var vmQuery = chanMgr.channels().stream()
|
||||
.filter(c -> c.vmDefinition().assignment().map(Assignment::pool)
|
||||
.map(p -> p.equals(event.fromPool())).orElse(false))
|
||||
.filter(c -> c.vmDefinition().assignment().map(Assignment::user)
|
||||
.map(u -> u.equals(event.toUser())).orElse(false))
|
||||
.findFirst();
|
||||
if (vmQuery.isPresent()) {
|
||||
var vmDef = vmQuery.get().vmDefinition();
|
||||
event.setResult(new VmData(vmDef, vmQuery.get()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the pool definition for checking possible assignment
|
||||
VmPool vmPool = newEventPipeline().fire(new GetPools()
|
||||
.withName(event.fromPool())).get().stream().findFirst()
|
||||
.orElse(null);
|
||||
if (vmPool == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find available VM.
|
||||
vmQuery = chanMgr.channels().stream()
|
||||
.filter(c -> vmPool.isAssignable(c.vmDefinition()))
|
||||
.sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition()
|
||||
.assignment().map(Assignment::lastUsed)
|
||||
.orElse(Instant.ofEpochSecond(0)))
|
||||
.thenComparing(preferRunning))
|
||||
.findFirst();
|
||||
|
||||
// None found
|
||||
if (vmQuery.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign to user
|
||||
var chosenVm = vmQuery.get();
|
||||
if (Optional.ofNullable(chosenVm.fire(new UpdateAssignment(
|
||||
vmPool, event.toUser())).get()).orElse(false)) {
|
||||
var vmDef = chosenVm.vmDefinition();
|
||||
event.setResult(new VmData(vmDef, chosenVm));
|
||||
|
||||
// Make sure that a newly assigned VM is running.
|
||||
chosenVm.fire(new ModifyVm(vmDef.name(), "state", "Running"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void patchVmDef(K8sClient client, String name, String path,
|
||||
Object value) throws ApiException, IOException {
|
||||
var vmStub = K8sDynamicStub.get(client,
|
||||
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace,
|
||||
name);
|
||||
private static Comparator<VmChannel> preferRunning
|
||||
= new Comparator<>() {
|
||||
@Override
|
||||
public int compare(VmChannel ch1, VmChannel ch2) {
|
||||
if (ch1.vmDefinition().conditionStatus("Running").orElse(false)
|
||||
&& !ch2.vmDefinition().conditionStatus("Running")
|
||||
.orElse(false)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Patch running
|
||||
String valueAsText = value instanceof String
|
||||
? "\"" + value + "\""
|
||||
: value.toString();
|
||||
var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
|
||||
new V1Patch("[{\"op\": \"replace\", \"path\": \"/"
|
||||
+ path + "\", \"value\": " + valueAsText + "}]"),
|
||||
client.defaultPatchOptions());
|
||||
if (!res.isPresent()) {
|
||||
logger.warning(
|
||||
() -> "Cannot patch definition for Vm " + vmStub.name());
|
||||
/**
|
||||
* When s pool is deleted, remove all related assignments.
|
||||
*
|
||||
* @param event the event
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||
public void onPoolChanged(VmPoolChanged event) throws InterruptedException {
|
||||
if (!event.deleted()) {
|
||||
return;
|
||||
}
|
||||
var vms = newEventPipeline()
|
||||
.fire(new GetVms().assignedFrom(event.vmPool().name())).get();
|
||||
for (var vm : vms) {
|
||||
vm.channel().fire(new UpdateAssignment(event.vmPool(), null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove runner version from status when pod is deleted
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@Handler
|
||||
public void onPodChange(PodChanged event, VmChannel channel)
|
||||
throws ApiException {
|
||||
if (event.type() == ResponseType.DELETED) {
|
||||
// Remove runner info from status
|
||||
var vmDef = channel.vmDefinition();
|
||||
var vmStub = VmDefinitionStub.get(channel.client(),
|
||||
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||
vmDef.namespace(), vmDef.name());
|
||||
vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.remove(Status.RUNNER_VERSION);
|
||||
return status;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2024 Michael N. Lipp
|
||||
* Copyright (C) 2025 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
|
|
@ -26,49 +26,25 @@ import io.kubernetes.client.util.Watch.Response;
|
|||
import io.kubernetes.client.util.generic.options.ListOptions;
|
||||
import io.kubernetes.client.util.generic.options.PatchOptions;
|
||||
import java.io.IOException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Scanner;
|
||||
import java.util.logging.Level;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sV1PodStub;
|
||||
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
|
||||
import org.jdrupes.vmoperator.manager.events.ChannelDictionary;
|
||||
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.CompletionLock;
|
||||
import org.jgrapes.core.Event;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
import org.jgrapes.util.events.ConfigurationUpdate;
|
||||
import org.jose4j.base64url.Base64;
|
||||
|
||||
/**
|
||||
* Watches for changes of display secrets. The component 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.
|
||||
* Watches for changes of display secrets. Updates an artifical attribute
|
||||
* of the pod running the VM in response to force an update of the files
|
||||
* in the pod that reflect the information from the secret.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
|
||||
public class DisplaySecretMonitor
|
||||
extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
|
||||
|
||||
private int passwordValidity = 10;
|
||||
private final List<PendingGet> pendingGets
|
||||
= Collections.synchronizedList(new LinkedList<>());
|
||||
private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
|
||||
|
||||
/**
|
||||
|
|
@ -84,31 +60,10 @@ public class DisplaySecretMonitor
|
|||
context(K8sV1SecretStub.CONTEXT);
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME);
|
||||
options(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* On configuration update.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
@Override
|
||||
public void onConfigurationUpdate(ConfigurationUpdate event) {
|
||||
super.onConfigurationUpdate(event);
|
||||
event.structured(componentPath()).ifPresent(c -> {
|
||||
try {
|
||||
if (c.containsKey("passwordValidity")) {
|
||||
passwordValidity = Integer
|
||||
.parseInt((String) c.get("passwordValidity"));
|
||||
}
|
||||
} catch (ClassCastException e) {
|
||||
logger.config("Malformed configuration: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareMonitoring() throws IOException, ApiException {
|
||||
client(new K8sClient());
|
||||
|
|
@ -163,135 +118,4 @@ public class DisplaySecretMonitor
|
|||
+ "\"}]"),
|
||||
patchOpts);
|
||||
}
|
||||
|
||||
/**
|
||||
* On get display secrets.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.StringInstantiation")
|
||||
public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel)
|
||||
throws ApiException {
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
|
||||
+ "app.kubernetes.io/instance="
|
||||
+ event.vmDefinition().metadata().getName());
|
||||
var stubs = K8sV1SecretStub.list(client(),
|
||||
event.vmDefinition().metadata().getNamespace(), options);
|
||||
if (stubs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
var stub = stubs.iterator().next();
|
||||
|
||||
// Check validity
|
||||
var model = stub.model().get();
|
||||
@SuppressWarnings("PMD.StringInstantiation")
|
||||
var expiry = Optional.ofNullable(model.getData()
|
||||
.get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null);
|
||||
if (model.getData().get(DATA_DISPLAY_PASSWORD) != null
|
||||
&& stillValid(expiry)) {
|
||||
event.setResult(
|
||||
new String(model.getData().get(DATA_DISPLAY_PASSWORD)));
|
||||
return;
|
||||
}
|
||||
updatePassword(stub, event);
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.StringInstantiation")
|
||||
private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event)
|
||||
throws ApiException {
|
||||
SecureRandom random = null;
|
||||
try {
|
||||
random = SecureRandom.getInstanceStrong();
|
||||
} catch (NoSuchAlgorithmException e) { // NOPMD
|
||||
// "Every implementation of the Java platform is required
|
||||
// to support at least one strong SecureRandom implementation."
|
||||
}
|
||||
byte[] bytes = new byte[16];
|
||||
random.nextBytes(bytes);
|
||||
var password = Base64.encode(bytes);
|
||||
var model = stub.model().get();
|
||||
model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
|
||||
DATA_PASSWORD_EXPIRY,
|
||||
Long.toString(Instant.now().getEpochSecond() + passwordValidity)));
|
||||
event.setResult(password);
|
||||
|
||||
// Prepare wait for confirmation (by VM status change)
|
||||
var pending = new PendingGet(event,
|
||||
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
|
||||
new CompletionLock(event, 1500));
|
||||
pendingGets.add(pending);
|
||||
Event.onCompletion(event, e -> {
|
||||
pendingGets.remove(pending);
|
||||
});
|
||||
|
||||
// Update, will (eventually) trigger confirmation
|
||||
stub.update(model).getObject();
|
||||
}
|
||||
|
||||
private boolean stillValid(String expiry) {
|
||||
if (expiry == null || "never".equals(expiry)) {
|
||||
return true;
|
||||
}
|
||||
@SuppressWarnings({ "PMD.CloseResource", "resource" })
|
||||
var scanner = new Scanner(expiry);
|
||||
if (!scanner.hasNextLong()) {
|
||||
return false;
|
||||
}
|
||||
long expTime = scanner.nextLong();
|
||||
return expTime > Instant.now().getEpochSecond() + passwordValidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* On vm def changed.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
||||
public void onVmDefChanged(VmDefChanged event, Channel channel) {
|
||||
synchronized (pendingGets) {
|
||||
String vmName = event.vmDefinition().metadata().getName();
|
||||
for (var pending : pendingGets) {
|
||||
if (pending.event.vmDefinition().metadata().getName()
|
||||
.equals(vmName)
|
||||
&& event.vmDefinition().displayPasswordSerial()
|
||||
.map(s -> s >= pending.expectedSerial).orElse(false)) {
|
||||
pending.lock.remove();
|
||||
// pending will be removed from pendingGest by
|
||||
// waiting thread, see updatePassword
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Class PendingGet.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataClass")
|
||||
private static class PendingGet {
|
||||
public final GetDisplayPassword event;
|
||||
public final long expectedSerial;
|
||||
public final CompletionLock lock;
|
||||
|
||||
/**
|
||||
* Instantiates a new pending get.
|
||||
*
|
||||
* @param event the event
|
||||
* @param expectedSerial the expected serial
|
||||
*/
|
||||
public PendingGet(GetDisplayPassword event, long expectedSerial,
|
||||
CompletionLock lock) {
|
||||
super();
|
||||
this.event = event;
|
||||
this.expectedSerial = expectedSerial;
|
||||
this.lock = lock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2023 Michael N. Lipp
|
||||
* Copyright (C) 2025 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
|
|
@ -18,8 +18,9 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.JsonObject;
|
||||
import freemarker.template.TemplateException;
|
||||
import io.kubernetes.client.apimachinery.GroupVersionKind;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
||||
import io.kubernetes.client.openapi.models.V1Secret;
|
||||
|
|
@ -27,66 +28,143 @@ import io.kubernetes.client.util.generic.options.ListOptions;
|
|||
import java.io.IOException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Scanner;
|
||||
import java.util.logging.Logger;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
import org.jdrupes.vmoperator.manager.events.GetDisplaySecret;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
import org.jdrupes.vmoperator.manager.events.VmResourceChanged;
|
||||
import org.jdrupes.vmoperator.util.DataPath;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.CompletionLock;
|
||||
import org.jgrapes.core.Component;
|
||||
import org.jgrapes.core.Event;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
import org.jgrapes.util.events.ConfigurationUpdate;
|
||||
import org.jose4j.base64url.Base64;
|
||||
|
||||
/**
|
||||
* Delegee for reconciling the display secret
|
||||
* The properties of the display secret do not only depend on the
|
||||
* VM definition, but also on events that occur during runtime.
|
||||
* The reconciler for the display secret is therefore a separate
|
||||
* component.
|
||||
*
|
||||
* The reconciler supports the following configuration properties:
|
||||
*
|
||||
* * `passwordValidity`: the validity of the random password in seconds.
|
||||
* Used to calculate the password expiry time in the generated secret.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
/* default */ class DisplaySecretReconciler {
|
||||
public class DisplaySecretReconciler extends Component {
|
||||
|
||||
protected final Logger logger = Logger.getLogger(getClass().getName());
|
||||
private int passwordValidity = 10;
|
||||
private final List<PendingRequest> pendingPrepares
|
||||
= Collections.synchronizedList(new LinkedList<>());
|
||||
|
||||
/**
|
||||
* Reconcile.
|
||||
* Instantiates a new display secret reconciler.
|
||||
*
|
||||
* @param componentChannel the component channel
|
||||
*/
|
||||
public DisplaySecretReconciler(Channel componentChannel) {
|
||||
super(componentChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* On configuration update.
|
||||
*
|
||||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
public void onConfigurationUpdate(ConfigurationUpdate event) {
|
||||
event.structured(componentPath())
|
||||
// for backward compatibility
|
||||
.or(() -> {
|
||||
var oldConfig = event
|
||||
.structured("/Manager/Controller/DisplaySecretMonitor");
|
||||
if (oldConfig.isPresent()) {
|
||||
logger.warning(() -> "Using configuration with old "
|
||||
+ "path '/Manager/Controller/DisplaySecretMonitor' "
|
||||
+ "for `passwordValidity`, please update "
|
||||
+ "the configuration.");
|
||||
}
|
||||
return oldConfig;
|
||||
}).ifPresent(c -> {
|
||||
try {
|
||||
Optional.ofNullable(c.get("passwordValidity"))
|
||||
.map(p -> p instanceof Integer ? (Integer) p
|
||||
: Integer.valueOf((String) p))
|
||||
.ifPresent(p -> {
|
||||
passwordValidity = p;
|
||||
});
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warning(
|
||||
() -> "Malformed configuration: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile. If the configuration prevents generating a secret
|
||||
* or the secret already exists, do nothing. Else generate a new
|
||||
* secret with a random password and immediate expiration, thus
|
||||
* preventing access to the display.
|
||||
*
|
||||
* @param vmDef the VM definition
|
||||
* @param model the model
|
||||
* @param channel the channel
|
||||
* @param specChanged the spec changed
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
* @throws TemplateException the template exception
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
public void reconcile(VmDefChanged event,
|
||||
Map<String, Object> model, VmChannel channel)
|
||||
public void reconcile(VmDefinition vmDef, Map<String, Object> model,
|
||||
VmChannel channel, boolean specChanged)
|
||||
throws IOException, TemplateException, ApiException {
|
||||
// Nothing to do unless spec changed
|
||||
if (!specChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Secret needed at all?
|
||||
var display = GsonPtr.to(event.vmDefinition().data()).to("spec", "vm",
|
||||
"display");
|
||||
if (!display.get(JsonPrimitive.class, "spice", "generateSecret")
|
||||
.map(JsonPrimitive::getAsBoolean).orElse(true)) {
|
||||
var display = vmDef.fromVm("display").get();
|
||||
if (!DataPath.<Boolean> get(display, "spice", "generateSecret")
|
||||
.orElse(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if exists
|
||||
var metadata = event.vmDefinition().getMetadata();
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
|
||||
+ "app.kubernetes.io/instance=" + metadata.getName());
|
||||
var stubs = K8sV1SecretStub.list(channel.client(),
|
||||
metadata.getNamespace(), options);
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + vmDef.name());
|
||||
var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(),
|
||||
options);
|
||||
if (!stubs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create secret
|
||||
var secretName = vmDef.name() + "-" + DisplaySecret.NAME;
|
||||
logger.fine(() -> "Create/update secret " + secretName);
|
||||
var secret = new V1Secret();
|
||||
secret.setMetadata(new V1ObjectMeta().namespace(metadata.getNamespace())
|
||||
.name(metadata.getName() + "-" + COMP_DISPLAY_SECRET)
|
||||
secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace())
|
||||
.name(secretName)
|
||||
.putLabelsItem("app.kubernetes.io/name", APP_NAME)
|
||||
.putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET)
|
||||
.putLabelsItem("app.kubernetes.io/instance", metadata.getName()));
|
||||
.putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME)
|
||||
.putLabelsItem("app.kubernetes.io/instance", vmDef.name()));
|
||||
secret.setType("Opaque");
|
||||
SecureRandom random = null;
|
||||
try {
|
||||
|
|
@ -98,9 +176,167 @@ import org.jose4j.base64url.Base64;
|
|||
byte[] bytes = new byte[16];
|
||||
random.nextBytes(bytes);
|
||||
var password = Base64.encode(bytes);
|
||||
secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
|
||||
DATA_PASSWORD_EXPIRY, "now"));
|
||||
secret.setStringData(Map.of(DisplaySecret.PASSWORD, password,
|
||||
DisplaySecret.EXPIRY, "now"));
|
||||
K8sV1SecretStub.create(channel.client(), secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares access to the console for the user from the event.
|
||||
* Generates a new password and sends it to the runner.
|
||||
* Requests the VM (via the runner) to login the user if specified
|
||||
* in the event.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@Handler
|
||||
public void onGetDisplaySecret(GetDisplaySecret event, VmChannel channel)
|
||||
throws ApiException {
|
||||
// Get VM definition and check if running
|
||||
var vmStub = VmDefinitionStub.get(channel.client(),
|
||||
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM),
|
||||
event.vmDefinition().namespace(), event.vmDefinition().name());
|
||||
var vmDef = vmStub.model().orElse(null);
|
||||
if (vmDef == null || !vmDef.conditionStatus("Running").orElse(false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update console user in status
|
||||
vmDef = vmStub.updateStatus(from -> {
|
||||
JsonObject status = from.statusJson();
|
||||
status.addProperty(Status.CONSOLE_USER, event.user());
|
||||
return status;
|
||||
}).get();
|
||||
|
||||
// Get secret and update password in secret
|
||||
var stub = getSecretStub(event, channel, vmDef);
|
||||
if (stub == null) {
|
||||
return;
|
||||
}
|
||||
var secret = stub.model().get();
|
||||
if (!updatePassword(secret, event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register wait for confirmation (by VM status change,
|
||||
// after secret update)
|
||||
var pending = new PendingRequest(event,
|
||||
event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
|
||||
new CompletionLock(event, 1500));
|
||||
pendingPrepares.add(pending);
|
||||
Event.onCompletion(event, e -> {
|
||||
pendingPrepares.remove(pending);
|
||||
});
|
||||
|
||||
// Update, will (eventually) trigger confirmation
|
||||
stub.update(secret).getObject();
|
||||
}
|
||||
|
||||
private K8sV1SecretStub getSecretStub(GetDisplaySecret event,
|
||||
VmChannel channel, VmDefinition vmDef) throws ApiException {
|
||||
// Look for secret
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + vmDef.name());
|
||||
var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(),
|
||||
options);
|
||||
if (stubs.isEmpty()) {
|
||||
// No secret means no password for this VM wanted
|
||||
event.setResult(null);
|
||||
return null;
|
||||
}
|
||||
return stubs.iterator().next();
|
||||
}
|
||||
|
||||
private boolean updatePassword(V1Secret secret, GetDisplaySecret event) {
|
||||
var expiry = Optional.ofNullable(secret.getData()
|
||||
.get(DisplaySecret.EXPIRY)).map(b -> new String(b)).orElse(null);
|
||||
if (secret.getData().get(DisplaySecret.PASSWORD) != null
|
||||
&& stillValid(expiry)) {
|
||||
// Fixed secret, don't touch
|
||||
event.setResult(
|
||||
new String(secret.getData().get(DisplaySecret.PASSWORD)));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate password and set expiry
|
||||
SecureRandom random = null;
|
||||
try {
|
||||
random = SecureRandom.getInstanceStrong();
|
||||
} catch (NoSuchAlgorithmException e) { // NOPMD
|
||||
// "Every implementation of the Java platform is required
|
||||
// to support at least one strong SecureRandom implementation."
|
||||
}
|
||||
byte[] bytes = new byte[16];
|
||||
random.nextBytes(bytes);
|
||||
var password = Base64.encode(bytes);
|
||||
secret.setStringData(Map.of(DisplaySecret.PASSWORD, password,
|
||||
DisplaySecret.EXPIRY,
|
||||
Long.toString(Instant.now().getEpochSecond() + passwordValidity)));
|
||||
event.setResult(password);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean stillValid(String expiry) {
|
||||
if (expiry == null || "never".equals(expiry)) {
|
||||
return true;
|
||||
}
|
||||
@SuppressWarnings({ "PMD.CloseResource", "resource" })
|
||||
var scanner = new Scanner(expiry);
|
||||
if (!scanner.hasNextLong()) {
|
||||
return false;
|
||||
}
|
||||
long expTime = scanner.nextLong();
|
||||
return expTime > Instant.now().getEpochSecond() + passwordValidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* On vm def changed.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
|
||||
public void onVmResourceChanged(VmResourceChanged event, Channel channel) {
|
||||
synchronized (pendingPrepares) {
|
||||
String vmName = event.vmDefinition().name();
|
||||
for (var pending : pendingPrepares) {
|
||||
if (pending.event.vmDefinition().name().equals(vmName)
|
||||
&& event.vmDefinition().displayPasswordSerial()
|
||||
.map(s -> s >= pending.expectedSerial).orElse(false)) {
|
||||
pending.lock.remove();
|
||||
// pending will be removed from pendingGest by
|
||||
// waiting thread, see updatePassword
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Class PendingGet.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
public final GetDisplaySecret event;
|
||||
public final long expectedSerial;
|
||||
public final CompletionLock lock;
|
||||
|
||||
/**
|
||||
* Instantiates a new pending get.
|
||||
*
|
||||
* @param event the event
|
||||
* @param expectedSerial the expected serial
|
||||
*/
|
||||
public PendingRequest(GetDisplaySecret event, long expectedSerial,
|
||||
CompletionLock lock) {
|
||||
super();
|
||||
this.event = event;
|
||||
this.expectedSerial = expectedSerial;
|
||||
this.lock = lock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,24 +18,25 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.Gson;
|
||||
import freemarker.template.Configuration;
|
||||
import freemarker.template.TemplateException;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.openapi.models.V1APIService;
|
||||
import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
||||
import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi;
|
||||
import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
|
||||
import io.kubernetes.client.util.generic.dynamic.Dynamics;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Logger;
|
||||
import org.jdrupes.vmoperator.common.K8s;
|
||||
import org.jdrupes.vmoperator.common.K8sDynamicModel;
|
||||
import org.jdrupes.vmoperator.common.K8sV1ServiceStub;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.util.DataPath;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
|
@ -44,7 +45,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
/**
|
||||
* Delegee for reconciling the service
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
/* default */ class LoadBalancerReconciler {
|
||||
|
||||
private static final String LOAD_BALANCER_SERVICE = "loadBalancerService";
|
||||
|
|
@ -68,18 +68,24 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
/**
|
||||
* Reconcile.
|
||||
*
|
||||
* @param event the event
|
||||
* @param vmDef the VM definition
|
||||
* @param model the model
|
||||
* @param channel the channel
|
||||
* @param specChanged the spec changed
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
* @throws TemplateException the template exception
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
public void reconcile(VmDefChanged event,
|
||||
Map<String, Object> model, VmChannel channel)
|
||||
public void reconcile(VmDefinition vmDef, Map<String, Object> model,
|
||||
VmChannel channel, boolean specChanged)
|
||||
throws IOException, TemplateException, ApiException {
|
||||
// Nothing to do unless spec changed
|
||||
if (!specChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if to be generated
|
||||
@SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" })
|
||||
@SuppressWarnings({ "unchecked" })
|
||||
var lbsDef = Optional.of(model)
|
||||
.map(m -> (Map<String, Object>) m.get("reconciler"))
|
||||
.map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE);
|
||||
|
|
@ -92,14 +98,17 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
if (lbsDef instanceof Boolean isOn && !isOn) {
|
||||
return;
|
||||
}
|
||||
JsonObject cfgMeta = new JsonObject();
|
||||
if (lbsDef instanceof Map) {
|
||||
var json = channel.client().getJSON();
|
||||
cfgMeta
|
||||
= json.deserialize(json.serialize(lbsDef), JsonObject.class);
|
||||
|
||||
// Load balancer can also be turned off for VM
|
||||
if (vmDef
|
||||
.<Map<String, Map<String, String>>> 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.<String> get(model, "cr", "name").orElse("unknown"));
|
||||
var fmTemplate = fmConfig.getTemplate("runnerLoadBalancer.ftl.yaml");
|
||||
StringWriter out = new StringWriter();
|
||||
fmTemplate.process(model, out);
|
||||
|
|
@ -107,53 +116,78 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
// https://github.com/kubernetes-client/java/issues/2741
|
||||
var svcDef = Dynamics.newFromYaml(
|
||||
new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
|
||||
mergeMetadata(svcDef, cfgMeta, event.vmDefinition());
|
||||
@SuppressWarnings("unchecked")
|
||||
var defaults = lbsDef instanceof Map
|
||||
? (Map<String, Map<String, String>>) lbsDef
|
||||
: null;
|
||||
var client = channel.client();
|
||||
mergeMetadata(client.getJSON().getGson(), svcDef, defaults, vmDef);
|
||||
|
||||
// Apply
|
||||
DynamicKubernetesApi svcApi = new DynamicKubernetesApi("", "v1",
|
||||
"services", channel.client());
|
||||
K8s.apply(svcApi, svcDef, svcDef.getRaw().toString());
|
||||
var svcStub = K8sV1ServiceStub
|
||||
.get(client, vmDef.namespace(), vmDef.name());
|
||||
if (svcStub.apply(svcDef).isEmpty()) {
|
||||
logger.warning(
|
||||
() -> "Could not patch service for " + svcStub.name());
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeMetadata(DynamicKubernetesObject svcDef,
|
||||
JsonObject cfgMeta, K8sDynamicModel vmDefinition) {
|
||||
// Get metadata from VM definition
|
||||
var vmMeta = GsonPtr.to(vmDefinition.data()).to("spec")
|
||||
.get(JsonObject.class, LOAD_BALANCER_SERVICE)
|
||||
.map(JsonObject::deepCopy).orElseGet(() -> new JsonObject());
|
||||
private void mergeMetadata(Gson gson, DynamicKubernetesObject svcDef,
|
||||
Map<String, Map<String, String>> defaults,
|
||||
VmDefinition vmDefinition) {
|
||||
// Get specific load balancer metadata from VM definition
|
||||
var vmLbMeta = vmDefinition
|
||||
.<Map<String, Map<String, String>>> fromSpec(LOAD_BALANCER_SERVICE)
|
||||
.orElse(Collections.emptyMap());
|
||||
|
||||
// Merge Data from VM definition into config data
|
||||
mergeReplace(GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class),
|
||||
GsonPtr.to(vmMeta).to(LABELS).get(JsonObject.class));
|
||||
mergeReplace(
|
||||
GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class),
|
||||
GsonPtr.to(vmMeta).to(ANNOTATIONS).get(JsonObject.class));
|
||||
|
||||
// Merge additional data into service definition
|
||||
var svcMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA);
|
||||
mergeIfAbsent(svcMeta.to(LABELS).get(JsonObject.class),
|
||||
GsonPtr.to(cfgMeta).to(LABELS).get(JsonObject.class));
|
||||
mergeIfAbsent(svcMeta.to(ANNOTATIONS).get(JsonObject.class),
|
||||
GsonPtr.to(cfgMeta).to(ANNOTATIONS).get(JsonObject.class));
|
||||
// Merge
|
||||
var svcMeta = svcDef.getMetadata();
|
||||
var svcJsonMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA);
|
||||
Optional.ofNullable(mergeIfAbsent(svcMeta.getLabels(),
|
||||
mergeReplace(defaults.get(LABELS), vmLbMeta.get(LABELS))))
|
||||
.ifPresent(lbls -> svcJsonMeta.set(LABELS, gson.toJsonTree(lbls)));
|
||||
Optional.ofNullable(mergeIfAbsent(svcMeta.getAnnotations(),
|
||||
mergeReplace(defaults.get(ANNOTATIONS), vmLbMeta.get(ANNOTATIONS))))
|
||||
.ifPresent(as -> svcJsonMeta.set(ANNOTATIONS, gson.toJsonTree(as)));
|
||||
}
|
||||
|
||||
private void mergeReplace(JsonObject dest, JsonObject src) {
|
||||
private Map<String, String> mergeReplace(Map<String, String> dest,
|
||||
Map<String, String> src) {
|
||||
if (src == null) {
|
||||
return dest;
|
||||
}
|
||||
if (dest == null) {
|
||||
dest = new LinkedHashMap<>();
|
||||
} else {
|
||||
dest = new LinkedHashMap<>(dest);
|
||||
}
|
||||
for (var e : src.entrySet()) {
|
||||
if (e.getValue().isJsonNull()) {
|
||||
if (e.getValue() == null) {
|
||||
dest.remove(e.getKey());
|
||||
continue;
|
||||
}
|
||||
dest.add(e.getKey(), e.getValue());
|
||||
dest.put(e.getKey(), e.getValue());
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
private void mergeIfAbsent(JsonObject dest, JsonObject src) {
|
||||
private Map<String, String> mergeIfAbsent(Map<String, String> dest,
|
||||
Map<String, String> src) {
|
||||
if (src == null) {
|
||||
return dest;
|
||||
}
|
||||
if (dest == null) {
|
||||
dest = new LinkedHashMap<>();
|
||||
} else {
|
||||
dest = new LinkedHashMap<>(dest);
|
||||
}
|
||||
for (var e : src.entrySet()) {
|
||||
if (dest.has(e.getKey())) {
|
||||
if (dest.containsKey(e.getKey())) {
|
||||
continue;
|
||||
}
|
||||
dest.add(e.getKey(), e.getValue());
|
||||
dest.put(e.getKey(), e.getValue());
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ import org.jgrapes.webconsole.vuejs.VueJsConsoleWeblet;
|
|||
/**
|
||||
* The application class.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" })
|
||||
@SuppressWarnings({ "PMD.ExcessiveImports" })
|
||||
public class Manager extends Component {
|
||||
|
||||
private static String version;
|
||||
|
|
@ -97,8 +97,8 @@ public class Manager extends Component {
|
|||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
* @throws URISyntaxException
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.TooFewBranchesForASwitchStatement",
|
||||
"PMD.NcssCount", "PMD.ConstructorCallsOverridableMethod" })
|
||||
@SuppressWarnings({ "PMD.NcssCount",
|
||||
"PMD.ConstructorCallsOverridableMethod" })
|
||||
public Manager(CommandLine cmdLine) throws IOException, URISyntaxException {
|
||||
super(new NamedChannel("manager"));
|
||||
// Prepare component tree
|
||||
|
|
@ -217,7 +217,6 @@ public class Manager extends Component {
|
|||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public void onConfigurationUpdate(ConfigurationUpdate event) {
|
||||
event.structured(componentPath()).ifPresent(c -> {
|
||||
if (c.containsKey("clusterName")) {
|
||||
|
|
@ -264,7 +263,7 @@ public class Manager extends Component {
|
|||
*/
|
||||
@Handler(priority = -1000)
|
||||
public void onStop(Stop event) {
|
||||
logger.fine(() -> "Application stopped.");
|
||||
logger.info(() -> "Application stopped.");
|
||||
}
|
||||
|
||||
static {
|
||||
|
|
@ -291,7 +290,6 @@ public class Manager extends Component {
|
|||
* @param args the arguments
|
||||
* @throws Exception the exception
|
||||
*/
|
||||
@SuppressWarnings("PMD.SignatureDeclareThrowsException")
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
// Instance logger is not available yet.
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<V1Pod, V1PodList, VmChannel> {
|
||||
|
||||
private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
|
||||
|
||||
private final Map<String, PendingChange> pendingChanges
|
||||
= new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Instantiates a new pod monitor.
|
||||
*
|
||||
* @param componentChannel the component channel
|
||||
* @param channelDictionary the channel dictionary
|
||||
*/
|
||||
public PodMonitor(Channel componentChannel,
|
||||
ChannelDictionary<String, VmChannel, ?> 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<V1Pod> 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<V1Pod> change) {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -20,18 +20,22 @@ package org.jdrupes.vmoperator.manager;
|
|||
|
||||
import freemarker.template.Configuration;
|
||||
import freemarker.template.TemplateException;
|
||||
import io.kubernetes.client.custom.V1Patch;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.util.generic.dynamic.Dynamics;
|
||||
import io.kubernetes.client.util.generic.options.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.VmDefinitionModel.RequestedVmState;
|
||||
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.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
|
@ -39,7 +43,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
/**
|
||||
* Delegee for reconciling the pod.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
/* default */ class PodReconciler {
|
||||
|
||||
protected final Logger logger = Logger.getLogger(getClass().getName());
|
||||
|
|
@ -57,34 +60,29 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
/**
|
||||
* Reconcile the pod.
|
||||
*
|
||||
* @param event the event
|
||||
* @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(VmDefChanged event, Map<String, Object> model,
|
||||
VmChannel channel)
|
||||
public void reconcile(VmDefinition vmDef, Map<String, Object> model,
|
||||
VmChannel channel, boolean specChanged)
|
||||
throws IOException, TemplateException, ApiException {
|
||||
// Don't do anything if stateful set is still in use (pre v3.4)
|
||||
if ((Boolean) model.get("usingSts")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get pod stub.
|
||||
var metadata = event.vmDefinition().getMetadata();
|
||||
var podStub = K8sV1PodStub.get(channel.client(),
|
||||
metadata.getNamespace(), metadata.getName());
|
||||
var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(),
|
||||
vmDef.name());
|
||||
|
||||
// Nothing to do if exists and should be running
|
||||
if (event.vmDefinition().vmState() == RequestedVmState.RUNNING
|
||||
if (vmDef.vmState() == RequestedVmState.RUNNING
|
||||
&& podStub.model().isPresent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete if running but should be stopped
|
||||
if (event.vmDefinition().vmState() == RequestedVmState.STOPPED) {
|
||||
if (vmDef.vmState() == RequestedVmState.STOPPED) {
|
||||
if (podStub.model().isPresent()) {
|
||||
podStub.delete();
|
||||
}
|
||||
|
|
@ -92,6 +90,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -104,12 +104,25 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
PatchOptions opts = new PatchOptions();
|
||||
opts.setForce(true);
|
||||
opts.setFieldManager("kubernetes-java-kubectl-apply");
|
||||
if (podStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
|
||||
new V1Patch(channel.client().getJSON().serialize(podDef)), opts)
|
||||
.isEmpty()) {
|
||||
if (podStub.apply(podDef).isEmpty()) {
|
||||
logger.warning(
|
||||
() -> "Could not patch pod for " + podStub.name());
|
||||
}
|
||||
}
|
||||
|
||||
private void addDisplaySecret(K8sClient client, Map<String, Object> 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<K8sDynamicModel, K8sDynamicModels, Channel> {
|
||||
|
||||
private final Map<String, VmPool> 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<K8sDynamicModel> 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.<List<String>> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -18,8 +18,6 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import freemarker.core.ParseException;
|
||||
import freemarker.template.Configuration;
|
||||
import freemarker.template.MalformedTemplateNameException;
|
||||
|
|
@ -32,6 +30,7 @@ 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;
|
||||
|
|
@ -39,8 +38,9 @@ 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.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.util.DataPath;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
|
@ -49,7 +49,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
/**
|
||||
* Delegee for reconciling the stateful set (effectively the pod).
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
/* default */ class PvcReconciler {
|
||||
|
||||
protected final Logger logger = Logger.getLogger(getClass().getName());
|
||||
|
|
@ -67,78 +66,84 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
/**
|
||||
* Reconcile the PVCs.
|
||||
*
|
||||
* @param event the event
|
||||
* @param vmDef the VM definition
|
||||
* @param model the model
|
||||
* @param channel the channel
|
||||
* @param specChanged the spec changed
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
* @throws TemplateException the template exception
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings("PMD.AvoidDuplicateLiterals")
|
||||
public void reconcile(VmDefChanged event, Map<String, Object> model,
|
||||
VmChannel channel)
|
||||
@SuppressWarnings({ "unchecked" })
|
||||
public void reconcile(VmDefinition vmDef, Map<String, Object> model,
|
||||
VmChannel channel, boolean specChanged)
|
||||
throws IOException, TemplateException, ApiException {
|
||||
var metadata = event.vmDefinition().getMetadata();
|
||||
|
||||
// Existing disks
|
||||
ListOptions listOpts = new ListOptions();
|
||||
listOpts.setLabelSelector(
|
||||
"app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
|
||||
+ "app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + metadata.getName());
|
||||
var knownDisks = K8sV1PvcStub.list(channel.client(),
|
||||
metadata.getNamespace(), listOpts);
|
||||
var knownPvcs = knownDisks.stream().map(K8sV1PvcStub::name)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> knownPvcs;
|
||||
if (!specChanged && channel.associated(this, Set.class).isPresent()) {
|
||||
knownPvcs = (Set<String>) 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(event, model, channel, knownPvcs);
|
||||
reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs, specChanged);
|
||||
|
||||
// Reconcile pvcs for defined disks
|
||||
var diskDefs = GsonPtr.to((JsonObject) model.get("cr"))
|
||||
.getAsListOf(JsonObject.class, "spec", "vm", "disks");
|
||||
var diskDefs = vmDef.<List<Map<String, Object>>> fromVm("disks")
|
||||
.orElse(List.of());
|
||||
var diskCounter = 0;
|
||||
for (var diskDef : diskDefs) {
|
||||
if (!diskDef.has("volumeClaimTemplate")) {
|
||||
if (!diskDef.containsKey("volumeClaimTemplate")) {
|
||||
continue;
|
||||
}
|
||||
var diskName = GsonPtr.to(diskDef)
|
||||
.getAsString("volumeClaimTemplate", "metadata", "name")
|
||||
.map(name -> name + "-disk").orElse("disk-" + diskCounter);
|
||||
var diskName = DataPath.get(diskDef, "volumeClaimTemplate",
|
||||
"metadata", "name").map(name -> name + "-disk")
|
||||
.orElse("disk-" + diskCounter);
|
||||
diskCounter += 1;
|
||||
diskDef.addProperty("generatedDiskName", diskName);
|
||||
diskDef.put("generatedDiskName", diskName);
|
||||
|
||||
// Don't do anything if pvc with old (sts generated) name exists.
|
||||
var stsDiskPvcName = diskName + "-" + metadata.getName() + "-0";
|
||||
var stsDiskPvcName = diskName + "-" + vmDef.name() + "-0";
|
||||
if (knownPvcs.contains(stsDiskPvcName)) {
|
||||
diskDef.addProperty("generatedPvcName", stsDiskPvcName);
|
||||
diskDef.put("generatedPvcName", stsDiskPvcName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update PVC
|
||||
model.put("disk", diskDef);
|
||||
reconcileRunnerDiskPvc(event, model, channel);
|
||||
reconcileRunnerDiskPvc(vmDef, model, channel, specChanged, diskDef);
|
||||
}
|
||||
model.remove("disk");
|
||||
}
|
||||
|
||||
private void reconcileRunnerDataPvc(VmDefChanged event,
|
||||
private void reconcileRunnerDataPvc(VmDefinition vmDef,
|
||||
Map<String, Object> model, VmChannel channel,
|
||||
Set<String> knownPvcs)
|
||||
Set<String> knownPvcs, boolean specChanged)
|
||||
throws TemplateNotFoundException, MalformedTemplateNameException,
|
||||
ParseException, IOException, TemplateException, ApiException {
|
||||
var metadata = event.vmDefinition().getMetadata();
|
||||
|
||||
// Look for old (sts generated) name.
|
||||
var stsRunnerDataPvcName
|
||||
= "runner-data" + "-" + metadata.getName() + "-0";
|
||||
= "runner-data" + "-" + vmDef.name() + "-0";
|
||||
if (knownPvcs.contains(stsRunnerDataPvcName)) {
|
||||
model.put("runnerDataPvcName", stsRunnerDataPvcName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate PVC
|
||||
model.put("runnerDataPvcName", metadata.getName() + "-runner-data");
|
||||
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);
|
||||
|
|
@ -149,7 +154,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
|
||||
// Do apply changes
|
||||
var pvcStub = K8sV1PvcStub.get(channel.client(),
|
||||
metadata.getNamespace(), (String) model.get("runnerDataPvcName"));
|
||||
vmDef.namespace(), (String) model.get("runnerDataPvcName"));
|
||||
PatchOptions opts = new PatchOptions();
|
||||
opts.setForce(true);
|
||||
opts.setFieldManager("kubernetes-java-kubectl-apply");
|
||||
|
|
@ -161,33 +166,57 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||
}
|
||||
}
|
||||
|
||||
private void reconcileRunnerDiskPvc(VmDefChanged event,
|
||||
Map<String, Object> model, VmChannel channel)
|
||||
private void reconcileRunnerDiskPvc(VmDefinition vmDef,
|
||||
Map<String, Object> model, VmChannel channel, boolean specChanged,
|
||||
Map<String, Object> diskDef)
|
||||
throws TemplateNotFoundException, MalformedTemplateNameException,
|
||||
ParseException, IOException, TemplateException, ApiException {
|
||||
var metadata = event.vmDefinition().getMetadata();
|
||||
// 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
|
||||
var diskDef = GsonPtr.to((JsonElement) model.get("disk"));
|
||||
var pvcName = metadata.getName() + "-"
|
||||
+ diskDef.getAsString("generatedDiskName").get();
|
||||
diskDef.set("generatedPvcName", pvcName);
|
||||
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());
|
||||
|
||||
// Do apply changes
|
||||
var pvcStub = K8sV1PvcStub.get(channel.client(),
|
||||
metadata.getNamespace(), GsonPtr.to((JsonElement) model.get("disk"))
|
||||
.getAsString("generatedPvcName").get());
|
||||
// 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.setForce(true);
|
||||
opts.setFieldManager("kubernetes-java-kubectl-apply");
|
||||
if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
|
||||
if (pvcStub.patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH,
|
||||
new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts)
|
||||
.isEmpty()) {
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -18,41 +18,40 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import freemarker.template.AdapterTemplateModel;
|
||||
import freemarker.template.Configuration;
|
||||
import freemarker.template.DefaultObjectWrapperBuilder;
|
||||
import freemarker.template.SimpleNumber;
|
||||
import freemarker.template.SimpleScalar;
|
||||
import freemarker.template.TemplateException;
|
||||
import freemarker.template.TemplateExceptionHandler;
|
||||
import freemarker.template.TemplateHashModel;
|
||||
import freemarker.template.TemplateMethodModelEx;
|
||||
import freemarker.template.TemplateModelException;
|
||||
import io.kubernetes.client.custom.Quantity;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
|
||||
import io.kubernetes.client.util.generic.options.ListOptions;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import java.util.logging.Level;
|
||||
import org.jdrupes.vmoperator.common.Convertions;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sDynamicModel;
|
||||
import org.jdrupes.vmoperator.common.K8sObserver;
|
||||
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition.Assignment;
|
||||
import org.jdrupes.vmoperator.common.VmPool;
|
||||
import org.jdrupes.vmoperator.manager.events.GetPools;
|
||||
import org.jdrupes.vmoperator.manager.events.ResetVm;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.manager.events.VmResourceChanged;
|
||||
import org.jdrupes.vmoperator.util.ExtendedObjectWrapper;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.Component;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
|
|
@ -130,16 +129,24 @@ import org.jgrapes.util.events.ConfigurationUpdate;
|
|||
* ```
|
||||
* This makes all VM consoles available at IP address 192.168.168.1
|
||||
* with the port numbers from the VM definitions.
|
||||
*
|
||||
* * `loggingProperties`: If defined, specifies the default logging
|
||||
* properties to be used by the runners managed by the controller.
|
||||
* This property is a string that holds the content of
|
||||
* a logging.properties file.
|
||||
*
|
||||
* @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
|
||||
"PMD.AvoidDuplicateLiterals" })
|
||||
@SuppressWarnings({ "PMD.AvoidDuplicateLiterals" })
|
||||
public class Reconciler extends Component {
|
||||
|
||||
@SuppressWarnings("PMD.SingularField")
|
||||
/** The Constant mapper. */
|
||||
@SuppressWarnings("PMD.FieldNamingConventions")
|
||||
protected static final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private final Configuration fmConfig;
|
||||
private final ConfigMapReconciler cmReconciler;
|
||||
private final DisplaySecretReconciler dsReconciler;
|
||||
private final StatefulSetReconciler stsReconciler;
|
||||
private final PvcReconciler pvcReconciler;
|
||||
private final PodReconciler podReconciler;
|
||||
private final LoadBalancerReconciler lbReconciler;
|
||||
|
|
@ -151,6 +158,7 @@ public class Reconciler extends Component {
|
|||
*
|
||||
* @param componentChannel the component channel
|
||||
*/
|
||||
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
|
||||
public Reconciler(Channel componentChannel) {
|
||||
super(componentChannel);
|
||||
|
||||
|
|
@ -165,8 +173,7 @@ public class Reconciler extends Component {
|
|||
fmConfig.setClassForTemplateLoading(Reconciler.class, "");
|
||||
|
||||
cmReconciler = new ConfigMapReconciler(fmConfig);
|
||||
dsReconciler = new DisplaySecretReconciler();
|
||||
stsReconciler = new StatefulSetReconciler(fmConfig);
|
||||
dsReconciler = attach(new DisplaySecretReconciler(componentChannel));
|
||||
pvcReconciler = new PvcReconciler(fmConfig);
|
||||
podReconciler = new PodReconciler(fmConfig);
|
||||
lbReconciler = new LoadBalancerReconciler(fmConfig);
|
||||
|
|
@ -194,32 +201,27 @@ public class Reconciler extends Component {
|
|||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings("PMD.ConfusingTernary")
|
||||
public void onVmDefChanged(VmDefChanged event, VmChannel channel)
|
||||
public void onVmResourceChanged(VmResourceChanged event, VmChannel channel)
|
||||
throws ApiException, TemplateException, IOException {
|
||||
// We're only interested in "spec" changes.
|
||||
if (!event.specChanged()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ownership relationships takes care of deletions
|
||||
var defMeta = event.vmDefinition().getMetadata();
|
||||
if (event.type() == K8sObserver.ResponseType.DELETED) {
|
||||
logger.fine(() -> "VM \"" + defMeta.getName() + "\" deleted");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconcile, use "augmented" vm definition for model
|
||||
Map<String, Object> model
|
||||
= prepareModel(channel.client(), patchCr(event.vmDefinition()));
|
||||
var configMap = cmReconciler.reconcile(model, channel);
|
||||
model.put("cm", configMap.getRaw());
|
||||
dsReconciler.reconcile(event, model, channel);
|
||||
// Manage (eventual) removal of stateful set.
|
||||
stsReconciler.reconcile(event, model, channel);
|
||||
pvcReconciler.reconcile(event, model, channel);
|
||||
podReconciler.reconcile(event, model, channel);
|
||||
lbReconciler.reconcile(event, model, channel);
|
||||
// Create model for processing templates
|
||||
var vmDef = event.vmDefinition();
|
||||
Map<String, Object> 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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -235,111 +237,91 @@ public class Reconciler extends Component {
|
|||
@Handler
|
||||
public void onResetVm(ResetVm event, VmChannel channel)
|
||||
throws ApiException, IOException, TemplateException {
|
||||
var defRoot
|
||||
= GsonPtr.to(channel.vmDefinition().data()).get(JsonObject.class);
|
||||
defRoot.addProperty("resetCount",
|
||||
defRoot.get("resetCount").getAsLong() + 1);
|
||||
var vmDef = channel.vmDefinition();
|
||||
var extra = vmDef.extra();
|
||||
extra.resetCount(extra.resetCount() + 1);
|
||||
Map<String, Object> model
|
||||
= prepareModel(channel.client(), patchCr(channel.vmDefinition()));
|
||||
cmReconciler.reconcile(model, channel);
|
||||
= prepareModel(channel.vmDefinition());
|
||||
cmReconciler.reconcile(model, channel, true);
|
||||
}
|
||||
|
||||
private DynamicKubernetesObject patchCr(K8sDynamicModel vmDef) {
|
||||
var json = vmDef.data().deepCopy();
|
||||
// Adjust cdromImage path
|
||||
adjustCdRomPaths(json);
|
||||
|
||||
// Adjust cloud-init data
|
||||
adjustCloudInitData(json);
|
||||
|
||||
return new DynamicKubernetesObject(json);
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
|
||||
private void adjustCdRomPaths(JsonObject json) {
|
||||
var disks
|
||||
= GsonPtr.to(json).to("spec", "vm", "disks").get(JsonArray.class);
|
||||
for (var disk : disks) {
|
||||
var cdrom = (JsonObject) ((JsonObject) disk).get("cdrom");
|
||||
if (cdrom == null) {
|
||||
continue;
|
||||
}
|
||||
String image = cdrom.get("image").getAsString();
|
||||
if (image.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||
var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH
|
||||
+ "/").resolve(image);
|
||||
if ("file".equals(imageUri.getScheme())) {
|
||||
cdrom.addProperty("image", imageUri.getPath());
|
||||
} else {
|
||||
cdrom.addProperty("image", imageUri.toString());
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
logger.warning(() -> "Invalid CDROM image: " + image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void adjustCloudInitData(JsonObject json) {
|
||||
var spec = GsonPtr.to(json).to("spec").get(JsonObject.class);
|
||||
if (!spec.has("cloudInit")) {
|
||||
return;
|
||||
}
|
||||
var metaData = GsonPtr.to(spec).to("cloudInit", "metaData");
|
||||
if (metaData.getAsString("instance-id").isEmpty()) {
|
||||
metaData.set("instance-id",
|
||||
GsonPtr.to(json).getAsString("metadata", "resourceVersion")
|
||||
.map(s -> "v" + s).orElse("v1"));
|
||||
}
|
||||
if (metaData.getAsString("local-hostname").isEmpty()) {
|
||||
metaData.set("local-hostname",
|
||||
GsonPtr.to(json).getAsString("metadata", "name").get());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.CognitiveComplexity")
|
||||
private Map<String, Object> prepareModel(K8sClient client,
|
||||
DynamicKubernetesObject vmDef)
|
||||
private Map<String, Object> prepareModel(VmDefinition vmDef)
|
||||
throws TemplateModelException, ApiException {
|
||||
@SuppressWarnings("PMD.UseConcurrentHashMap")
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("managerVersion",
|
||||
Optional.ofNullable(Reconciler.class.getPackage()
|
||||
.getImplementationVersion()).orElse("(Unknown)"));
|
||||
model.put("cr", vmDef.getRaw());
|
||||
model.put("constants",
|
||||
(TemplateHashModel) new DefaultObjectWrapperBuilder(
|
||||
Configuration.VERSION_2_3_32)
|
||||
.build().getStaticModels()
|
||||
.get(Constants.class.getName()));
|
||||
model.put("cr", vmDef);
|
||||
model.put("reconciler", config);
|
||||
|
||||
// Check if we have a display secret
|
||||
ListOptions options = new ListOptions();
|
||||
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
|
||||
+ "app.kubernetes.io/instance=" + vmDef.getMetadata().getName());
|
||||
var dsStub = K8sV1SecretStub
|
||||
.list(client, vmDef.getMetadata().getNamespace(), options).stream()
|
||||
.findFirst();
|
||||
if (dsStub.isPresent()) {
|
||||
dsStub.get().model().ifPresent(m -> {
|
||||
model.put("displaySecret", m.getMetadata().getName());
|
||||
});
|
||||
}
|
||||
model.put("constants", constantsMap(Constants.class));
|
||||
addLoginRequestedFor(model, vmDef);
|
||||
|
||||
// Methods
|
||||
model.put("parseQuantity", new TemplateMethodModelEx() {
|
||||
model.put("parseQuantity", parseQuantityModel);
|
||||
model.put("formatMemory", formatMemoryModel);
|
||||
model.put("imageLocation", imgageLocationModel);
|
||||
model.put("toJson", toJsonModel);
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a map with constants. Needed because freemarker doesn't support
|
||||
* nested classes with its static models.
|
||||
*
|
||||
* @param clazz the clazz
|
||||
* @return the map
|
||||
*/
|
||||
@SuppressWarnings("PMD.EmptyCatchBlock")
|
||||
private Map<String, Object> constantsMap(Class<?> clazz) {
|
||||
@SuppressWarnings("PMD.UseConcurrentHashMap")
|
||||
Map<String, Object> 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<String, Object> model,
|
||||
VmDefinition vmDef) {
|
||||
vmDef.assignment().filter(a -> {
|
||||
try {
|
||||
return newEventPipeline()
|
||||
.fire(new GetPools().withName(a.pool())).get()
|
||||
.stream().findFirst().map(VmPool::loginOnAssignment)
|
||||
.orElse(false);
|
||||
} catch (InterruptedException e) {
|
||||
logger.log(Level.WARNING, e, e::getMessage);
|
||||
}
|
||||
return false;
|
||||
}).map(Assignment::user)
|
||||
.or(() -> vmDef.fromSpec("vm", "display", "loggedInUser"))
|
||||
.ifPresent(u -> model.put("loginRequestedFor", u));
|
||||
}
|
||||
|
||||
private final TemplateMethodModelEx parseQuantityModel
|
||||
= new TemplateMethodModelEx() {
|
||||
@Override
|
||||
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||
public Object exec(@SuppressWarnings("rawtypes") List arguments)
|
||||
throws TemplateModelException {
|
||||
var arg = arguments.get(0);
|
||||
if (arg instanceof Number number) {
|
||||
return number;
|
||||
if (arg instanceof SimpleNumber number) {
|
||||
return number.getAsNumber();
|
||||
}
|
||||
try {
|
||||
return Quantity.fromString(arg.toString()).getNumber();
|
||||
|
|
@ -348,10 +330,11 @@ public class Reconciler extends Component {
|
|||
+ "specified as \"" + arg + "\": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
model.put("formatMemory", new TemplateMethodModelEx() {
|
||||
};
|
||||
|
||||
private final TemplateMethodModelEx formatMemoryModel
|
||||
= new TemplateMethodModelEx() {
|
||||
@Override
|
||||
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||
public Object exec(@SuppressWarnings("rawtypes") List arguments)
|
||||
throws TemplateModelException {
|
||||
var arg = arguments.get(0);
|
||||
|
|
@ -376,7 +359,45 @@ public class Reconciler extends Component {
|
|||
}
|
||||
return Convertions.formatMemory(bigInt);
|
||||
}
|
||||
});
|
||||
return model;
|
||||
}
|
||||
};
|
||||
|
||||
private final TemplateMethodModelEx imgageLocationModel
|
||||
= new TemplateMethodModelEx() {
|
||||
@Override
|
||||
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" })
|
||||
public Object exec(@SuppressWarnings("rawtypes") List arguments)
|
||||
throws TemplateModelException {
|
||||
var image = ((SimpleScalar) arguments.get(0)).getAsString();
|
||||
if (image.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
var imageUri
|
||||
= new URI("file://" + Constants.IMAGE_REPO_PATH + "/")
|
||||
.resolve(image);
|
||||
if ("file".equals(imageUri.getScheme())) {
|
||||
return imageUri.getPath();
|
||||
}
|
||||
return imageUri.toString();
|
||||
} catch (URISyntaxException e) {
|
||||
logger.warning(() -> "Invalid CDROM image: " + image);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
};
|
||||
|
||||
private final TemplateMethodModelEx toJsonModel
|
||||
= new TemplateMethodModelEx() {
|
||||
@Override
|
||||
public Object exec(@SuppressWarnings("rawtypes") List arguments)
|
||||
throws TemplateModelException {
|
||||
try {
|
||||
return mapper.writeValueAsString(
|
||||
((AdapterTemplateModel) arguments.get(0))
|
||||
.getAdaptedObject(Object.class));
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2023 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import freemarker.template.Configuration;
|
||||
import freemarker.template.TemplateException;
|
||||
import io.kubernetes.client.custom.V1Patch;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.util.generic.dynamic.Dynamics;
|
||||
import io.kubernetes.client.util.generic.options.PatchOptions;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/**
|
||||
* Before version 3.4, the pod running the VM was created by a stateful set.
|
||||
* Starting with version 3.4, this reconciler simply deletes the stateful
|
||||
* set, provided that the VM is not running.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
/* default */ class StatefulSetReconciler {
|
||||
|
||||
protected final Logger logger = Logger.getLogger(getClass().getName());
|
||||
private final Configuration fmConfig;
|
||||
|
||||
/**
|
||||
* Instantiates a new stateful set reconciler.
|
||||
*
|
||||
* @param fmConfig the fm config
|
||||
*/
|
||||
public StatefulSetReconciler(Configuration fmConfig) {
|
||||
this.fmConfig = fmConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile stateful set.
|
||||
*
|
||||
* @param event the event
|
||||
* @param model the model
|
||||
* @param channel the channel
|
||||
* @throws IOException Signals that an I/O exception has occurred.
|
||||
* @throws TemplateException the template exception
|
||||
* @throws ApiException the api exception
|
||||
*/
|
||||
@SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
|
||||
public void reconcile(VmDefChanged event, Map<String, Object> model,
|
||||
VmChannel channel)
|
||||
throws IOException, TemplateException, ApiException {
|
||||
var metadata = event.vmDefinition().getMetadata();
|
||||
model.put("usingSts", false);
|
||||
|
||||
// If exists, delete when not running or supposed to be not running.
|
||||
var stsStub = K8sV1StatefulSetStub.get(channel.client(),
|
||||
metadata.getNamespace(), metadata.getName());
|
||||
if (stsStub.model().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stateful set still exists, check if replicas is 0 so we can
|
||||
// delete it.
|
||||
var stsModel = stsStub.model().get();
|
||||
if (stsModel.getSpec().getReplicas() == 0) {
|
||||
stsStub.delete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot yet delete the stateful set.
|
||||
model.put("usingSts", true);
|
||||
|
||||
// Check if VM is supposed to be stopped. If so,
|
||||
// set replicas to 0. This is the first step of the transition,
|
||||
// the stateful set will be deleted when the VM is restarted.
|
||||
var fmTemplate = fmConfig.getTemplate("runnerSts.ftl.yaml");
|
||||
StringWriter out = new StringWriter();
|
||||
fmTemplate.process(model, out);
|
||||
// Avoid Yaml.load due to
|
||||
// https://github.com/kubernetes-client/java/issues/2741
|
||||
var stsDef = Dynamics.newFromYaml(
|
||||
new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
|
||||
var desired = GsonPtr.to(stsDef.getRaw())
|
||||
.to("spec").getAsInt("replicas").orElse(1);
|
||||
if (desired == 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do apply changes (set replicas to 0)
|
||||
PatchOptions opts = new PatchOptions();
|
||||
opts.setForce(true);
|
||||
opts.setFieldManager("kubernetes-java-kubectl-apply");
|
||||
if (stsStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
|
||||
new V1Patch(channel.client().getJSON().serialize(stsDef)), opts)
|
||||
.isEmpty()) {
|
||||
logger.warning(
|
||||
() -> "Could not patch stateful set for " + stsStub.name());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* VM-Operator
|
||||
* Copyright (C) 2023,2024 Michael N. Lipp
|
||||
* Copyright (C) 2023,2025 Michael N. Lipp
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
|
|
@ -18,46 +18,64 @@
|
|||
|
||||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.kubernetes.client.apimachinery.GroupVersionKind;
|
||||
import io.kubernetes.client.custom.V1Patch;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.openapi.models.V1ObjectMeta;
|
||||
import io.kubernetes.client.util.Watch;
|
||||
import io.kubernetes.client.util.generic.options.ListOptions;
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.stream.Collectors;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.Status;
|
||||
import org.jdrupes.vmoperator.common.K8s;
|
||||
import org.jdrupes.vmoperator.common.K8sClient;
|
||||
import org.jdrupes.vmoperator.common.K8sDynamicStub;
|
||||
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
|
||||
import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub;
|
||||
import org.jdrupes.vmoperator.common.K8sV1PodStub;
|
||||
import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionModel;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionModels;
|
||||
import org.jdrupes.vmoperator.common.VmDefinition;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitionStub;
|
||||
import org.jdrupes.vmoperator.common.VmDefinitions;
|
||||
import org.jdrupes.vmoperator.common.VmExtraData;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM;
|
||||
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
|
||||
import org.jdrupes.vmoperator.manager.events.ChannelManager;
|
||||
import org.jdrupes.vmoperator.manager.events.ModifyVm;
|
||||
import org.jdrupes.vmoperator.manager.events.PodChanged;
|
||||
import org.jdrupes.vmoperator.manager.events.UpdateAssignment;
|
||||
import org.jdrupes.vmoperator.manager.events.VmChannel;
|
||||
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
|
||||
import org.jdrupes.vmoperator.manager.events.VmResourceChanged;
|
||||
import org.jdrupes.vmoperator.util.GsonPtr;
|
||||
import org.jgrapes.core.Channel;
|
||||
import org.jgrapes.core.Event;
|
||||
import org.jgrapes.core.EventPipeline;
|
||||
import org.jgrapes.core.annotation.Handler;
|
||||
|
||||
/**
|
||||
* Watches for changes of VM definitions.
|
||||
* Watches for changes of VM definitions. When a VM definition (CR)
|
||||
* becomes known, is is registered with a {@link ChannelManager} and thus
|
||||
* gets an associated {@link VmChannel} and an associated
|
||||
* {@link EventPipeline}.
|
||||
*
|
||||
* The {@link EventPipeline} is used for submitting an action that processes
|
||||
* the change data from kubernetes, eventually transforming it to a
|
||||
* {@link VmResourceChanged} event that is handled by another
|
||||
* {@link EventPipeline} associated with the {@link VmChannel}. This
|
||||
* event pipeline should be used for all events related to changes of
|
||||
* a particular VM.
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" })
|
||||
public class VmMonitor extends
|
||||
AbstractMonitor<VmDefinitionModel, VmDefinitionModels, VmChannel> {
|
||||
AbstractMonitor<VmDefinition, VmDefinitions, VmChannel> {
|
||||
|
||||
private final ChannelManager<String, VmChannel, ?> channelManager;
|
||||
private final ChannelManager<String, VmChannel,
|
||||
EventPipeline> channelManager;
|
||||
|
||||
/**
|
||||
* Instantiates a new VM definition watcher.
|
||||
|
|
@ -66,9 +84,9 @@ public class VmMonitor extends
|
|||
* @param channelManager the channel manager
|
||||
*/
|
||||
public VmMonitor(Channel componentChannel,
|
||||
ChannelManager<String, VmChannel, ?> channelManager) {
|
||||
super(componentChannel, VmDefinitionModel.class,
|
||||
VmDefinitionModels.class);
|
||||
ChannelManager<String, VmChannel, EventPipeline> channelManager) {
|
||||
super(componentChannel, VmDefinition.class,
|
||||
VmDefinitions.class);
|
||||
this.channelManager = channelManager;
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +95,7 @@ public class VmMonitor extends
|
|||
client(new K8sClient());
|
||||
|
||||
// Get all our API versions
|
||||
var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM);
|
||||
var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM);
|
||||
if (ctx.isEmpty()) {
|
||||
logger.severe(() -> "Cannot get CRD context.");
|
||||
return;
|
||||
|
|
@ -88,7 +106,6 @@ public class VmMonitor extends
|
|||
purge();
|
||||
}
|
||||
|
||||
@SuppressWarnings("PMD.CognitiveComplexity")
|
||||
private void purge() throws ApiException {
|
||||
// Get existing CRs (VMs)
|
||||
var known = K8sDynamicStub.list(client(), context(), namespace())
|
||||
|
|
@ -112,10 +129,19 @@ public class VmMonitor extends
|
|||
|
||||
@Override
|
||||
protected void handleChange(K8sClient client,
|
||||
Watch.Response<VmDefinitionModel> response) {
|
||||
V1ObjectMeta metadata = response.object.getMetadata();
|
||||
VmChannel channel = channelManager.channelGet(metadata.getName());
|
||||
Watch.Response<VmDefinition> 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<VmDefinition> response, EventPipeline preparing) {
|
||||
// Get full definition and associate with channel as backup
|
||||
var vmDef = response.object;
|
||||
if (vmDef.data() == null) {
|
||||
|
|
@ -123,41 +149,39 @@ public class VmMonitor extends
|
|||
// https://github.com/kubernetes-client/java/issues/3215
|
||||
vmDef = getModel(client, vmDef);
|
||||
}
|
||||
var name = response.object.getMetadata().getName();
|
||||
var channel = channelManager.channel(name)
|
||||
.orElseGet(() -> channelManager.createChannel(name));
|
||||
if (vmDef.data() != null) {
|
||||
// New data, augment and save
|
||||
addDynamicData(channel.client(), vmDef, channel.vmDefinition());
|
||||
addExtraData(vmDef, channel.vmDefinition());
|
||||
channel.setVmDefinition(vmDef);
|
||||
} else {
|
||||
// Reuse cached
|
||||
// Reuse cached (e.g. if deleted)
|
||||
vmDef = channel.vmDefinition();
|
||||
}
|
||||
if (vmDef == null) {
|
||||
logger.warning(
|
||||
() -> "Cannot get model for " + response.object.getMetadata());
|
||||
logger.warning(() -> "Cannot get defintion for "
|
||||
+ response.object.getMetadata());
|
||||
return;
|
||||
}
|
||||
if (ResponseType.valueOf(response.type) == ResponseType.DELETED) {
|
||||
channelManager.remove(metadata.getName());
|
||||
}
|
||||
channelManager.put(name, channel, preparing);
|
||||
|
||||
// Create and fire changed event. Remove channel from channel
|
||||
// manager on completion.
|
||||
channel.pipeline()
|
||||
.fire(Event.onCompletion(
|
||||
new VmDefChanged(ResponseType.valueOf(response.type),
|
||||
channel.setGeneration(
|
||||
response.object.getMetadata().getGeneration()),
|
||||
vmDef),
|
||||
e -> {
|
||||
if (e.type() == ResponseType.DELETED) {
|
||||
channelManager
|
||||
.remove(e.vmDefinition().metadata().getName());
|
||||
}
|
||||
}), channel);
|
||||
VmResourceChanged chgEvt
|
||||
= new VmResourceChanged(ResponseType.valueOf(response.type), vmDef,
|
||||
channel.setGeneration(response.object.getMetadata()
|
||||
.getGeneration()),
|
||||
false);
|
||||
if (ResponseType.valueOf(response.type) == ResponseType.DELETED) {
|
||||
chgEvt = Event.onCompletion(chgEvt,
|
||||
e -> channelManager.remove(e.vmDefinition().name()));
|
||||
}
|
||||
channel.fire(chgEvt);
|
||||
}
|
||||
|
||||
private VmDefinitionModel getModel(K8sClient client,
|
||||
VmDefinitionModel vmDef) {
|
||||
private VmDefinition getModel(K8sClient client, VmDefinition vmDef) {
|
||||
try {
|
||||
return VmDefinitionStub.get(client, context(), namespace(),
|
||||
vmDef.metadata().getName()).model().orElse(null);
|
||||
|
|
@ -166,55 +190,137 @@ public class VmMonitor extends
|
|||
}
|
||||
}
|
||||
|
||||
private void addDynamicData(K8sClient client, VmDefinitionModel vmState,
|
||||
VmDefinitionModel prevState) {
|
||||
var rootNode = GsonPtr.to(vmState.data()).get(JsonObject.class);
|
||||
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
|
||||
rootNode.addProperty("resetCount", Optional.ofNullable(prevState)
|
||||
.map(ps -> GsonPtr.to(ps.data()))
|
||||
.flatMap(d -> d.getAsLong("resetCount")).orElse(0L));
|
||||
extra.resetCount(prevExtra.map(VmExtraData::resetCount).orElse(0L));
|
||||
|
||||
// Add defaults in case the VM is not running
|
||||
rootNode.addProperty("nodeName", "");
|
||||
rootNode.addProperty("nodeAddress", "");
|
||||
// Maintain node info
|
||||
prevExtra
|
||||
.ifPresent(e -> extra.nodeInfo(e.nodeName(), e.nodeAddresses()));
|
||||
}
|
||||
|
||||
// VM definition status changes before the pod terminates.
|
||||
// This results in pod information being shown for a stopped
|
||||
// VM which is irritating. So check condition first.
|
||||
var isRunning = GsonPtr.to(rootNode).to("status", "conditions")
|
||||
.get(JsonArray.class)
|
||||
.asList().stream().filter(el -> "Running"
|
||||
.equals(((JsonObject) el).get("type").getAsString()))
|
||||
.findFirst().map(el -> "True"
|
||||
.equals(((JsonObject) el).get("status").getAsString()))
|
||||
.orElse(false);
|
||||
if (!isRunning) {
|
||||
/**
|
||||
* On pod changed.
|
||||
*
|
||||
* @param event the event
|
||||
* @param channel the channel
|
||||
*/
|
||||
@Handler
|
||||
public void onPodChanged(PodChanged event, VmChannel channel) {
|
||||
var vmDef = channel.vmDefinition();
|
||||
|
||||
// Make sure that this is properly sync'd with VM CR changes.
|
||||
channelManager.associated(vmDef.name())
|
||||
.orElseGet(() -> activeEventPipeline())
|
||||
.submit("NodeInfo[" + vmDef.name() + "]",
|
||||
() -> {
|
||||
updateNodeInfo(event, vmDef);
|
||||
channel.fire(new VmResourceChanged(ResponseType.MODIFIED,
|
||||
vmDef, false, true));
|
||||
});
|
||||
}
|
||||
|
||||
private void updateNodeInfo(PodChanged event, VmDefinition vmDef) {
|
||||
var extra = vmDef.extra();
|
||||
if (event.type() == ResponseType.DELETED) {
|
||||
// The status of a deleted pod is the status before deletion,
|
||||
// i.e. the node info is still cached and must be removed.
|
||||
extra.nodeInfo("", Collections.emptyList());
|
||||
return;
|
||||
}
|
||||
var podSearch = new ListOptions();
|
||||
podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME
|
||||
+ ",app.kubernetes.io/component=" + APP_NAME
|
||||
+ ",app.kubernetes.io/instance=" + vmState.getMetadata().getName());
|
||||
try {
|
||||
var podList
|
||||
= K8sV1PodStub.list(client, namespace(), podSearch);
|
||||
for (var podStub : podList) {
|
||||
var nodeName = podStub.model().get().getSpec().getNodeName();
|
||||
rootNode.addProperty("nodeName", nodeName);
|
||||
logger.fine(() -> "Added node name " + nodeName
|
||||
+ " to VM info for " + vmState.getMetadata().getName());
|
||||
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||
var addrs = new JsonArray();
|
||||
podStub.model().get().getStatus().getPodIPs().stream()
|
||||
.map(ip -> ip.getIp()).forEach(addrs::add);
|
||||
rootNode.add("nodeAddresses", addrs);
|
||||
logger.fine(() -> "Added node addresses " + addrs
|
||||
+ " to VM info for " + vmState.getMetadata().getName());
|
||||
}
|
||||
} catch (ApiException e) {
|
||||
logger.log(Level.WARNING, e,
|
||||
() -> "Cannot access node information: " + e.getMessage());
|
||||
|
||||
// Get current node info from pod
|
||||
var pod = event.pod();
|
||||
var nodeName = Optional
|
||||
.ofNullable(pod.getSpec().getNodeName()).orElse("");
|
||||
logger.finer(() -> "Adding node name " + nodeName
|
||||
+ " to VM info for " + vmDef.name());
|
||||
var addrs = new ArrayList<String>();
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -83,8 +83,18 @@
|
|||
* [YamlConfigurationStore] *-right[hidden]- [Controller]
|
||||
*
|
||||
* [Manager] *-- [Controller]
|
||||
* [Controller] *-- [VmWatcher]
|
||||
* [Controller] *-- [Reconciler]
|
||||
* Component VmMonitor as VmMonitor <<internal>>
|
||||
* [Controller] *-- [VmMonitor]
|
||||
* [VmMonitor] -right[hidden]- [PoolMonitor]
|
||||
* Component PoolMonitor as PoolMonitor <<internal>>
|
||||
* [Controller] *-- [PoolMonitor]
|
||||
* Component PodMonitor as PodMonitor <<internal>>
|
||||
* [Controller] *-- [PodMonitor]
|
||||
* [PodMonitor] -up[hidden]- VmMonitor
|
||||
* Component DisplaySecretMonitor as DisplaySecretMonitor <<internal>>
|
||||
* [Controller] *-- [DisplaySecretMonitor]
|
||||
* [DisplaySecretMonitor] -up[hidden]- VmMonitor
|
||||
* [Controller] *-left- [Reconciler]
|
||||
* [Controller] -right[hidden]- [GuiHttpServer]
|
||||
*
|
||||
* [Manager] *-down- [GuiSocketServer:8080]
|
||||
|
|
|
|||
64
org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml
Normal file
64
org.jdrupes.vmoperator.manager/test-resources/basic-vm.yaml
Normal file
|
|
@ -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
|
||||
111
org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml
Normal file
111
org.jdrupes.vmoperator.manager/test-resources/kustomization.yaml
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
apiVersion: "vmoperator.jdrupes.org/v1"
|
||||
kind: VirtualMachine
|
||||
metadata:
|
||||
namespace: vmop-dev
|
||||
name: unittest-vm
|
||||
spec:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 2Gi
|
||||
|
||||
loadBalancerService:
|
||||
labels:
|
||||
test2: null
|
||||
test3: added
|
||||
|
||||
vm:
|
||||
# state: Running
|
||||
maximumRam: 4Gi
|
||||
currentRam: 2Gi
|
||||
maximumCpus: 4
|
||||
currentCpus: 2
|
||||
powerdownTimeout: 1
|
||||
|
||||
networks:
|
||||
- user: {}
|
||||
disks:
|
||||
- cdrom:
|
||||
# image: ""
|
||||
image: https://download.fedoraproject.org/pub/fedora/linux/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso
|
||||
# image: "Fedora-Workstation-Live-x86_64-38-1.6.iso"
|
||||
|
||||
display:
|
||||
spice:
|
||||
port: 5812
|
||||
|
|
@ -1,21 +1,32 @@
|
|||
package org.jdrupes.vmoperator.manager;
|
||||
|
||||
import io.kubernetes.client.Discovery.APIResource;
|
||||
import io.kubernetes.client.custom.Quantity;
|
||||
import io.kubernetes.client.custom.V1Patch;
|
||||
import io.kubernetes.client.openapi.ApiException;
|
||||
import io.kubernetes.client.util.generic.options.ListOptions;
|
||||
import io.kubernetes.client.util.generic.options.PatchOptions;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.jdrupes.vmoperator.common.Constants;
|
||||
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
|
||||
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
|
||||
import org.jdrupes.vmoperator.common.Constants.Crd;
|
||||
import org.jdrupes.vmoperator.common.Constants.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;
|
||||
|
|
@ -29,6 +40,9 @@ class BasicTests {
|
|||
private static K8sClient client;
|
||||
private static APIResource vmsContext;
|
||||
private static K8sV1DeploymentStub mgrDeployment;
|
||||
private static K8sDynamicStub vmStub;
|
||||
private static final String VM_NAME = "test-vm";
|
||||
private static final Object EXISTS = new Object();
|
||||
|
||||
@BeforeAll
|
||||
static void setUpBeforeClass() throws Exception {
|
||||
|
|
@ -38,23 +52,40 @@ class BasicTests {
|
|||
// Get client
|
||||
client = new K8sClient();
|
||||
|
||||
// Update manager pod by scaling deployment
|
||||
mgrDeployment
|
||||
= K8sV1DeploymentStub.get(client, "vmop-test", "vm-operator");
|
||||
mgrDeployment.scale(0);
|
||||
mgrDeployment.scale(1);
|
||||
waitForManager();
|
||||
|
||||
// Context for working with our CR
|
||||
var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM);
|
||||
var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM);
|
||||
assertTrue(apiRes.isPresent());
|
||||
vmsContext = apiRes.get();
|
||||
|
||||
// Cleanup existing VM
|
||||
K8sDynamicStub.get(client, vmsContext, "vmop-dev", "unittest-vm")
|
||||
K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME)
|
||||
.delete();
|
||||
ListOptions listOpts = new ListOptions();
|
||||
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
|
||||
+ "app.kubernetes.io/instance=" + VM_NAME + ","
|
||||
+ "app.kubernetes.io/component=" + DisplaySecret.NAME);
|
||||
var secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts);
|
||||
for (var secret : secrets) {
|
||||
secret.delete();
|
||||
}
|
||||
deletePvcs();
|
||||
|
||||
// Update manager pod by scaling deployment
|
||||
mgrDeployment
|
||||
= K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator");
|
||||
mgrDeployment.scale(0);
|
||||
mgrDeployment.scale(1);
|
||||
// Load from Yaml
|
||||
var rdr = new FileReader("test-resources/basic-vm.yaml");
|
||||
vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr);
|
||||
assertTrue(vmStub.model().isPresent());
|
||||
}
|
||||
|
||||
private static void waitForManager()
|
||||
throws ApiException, InterruptedException {
|
||||
// Wait until available
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (mgrDeployment.model().get().getStatus().getConditions()
|
||||
.stream().filter(c -> "Available".equals(c.getType())).findAny()
|
||||
|
|
@ -66,70 +97,245 @@ class BasicTests {
|
|||
fail("vm-operator not deployed.");
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownAfterClass() throws Exception {
|
||||
// Bring down manager
|
||||
mgrDeployment.scale(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test() throws IOException, InterruptedException, ApiException {
|
||||
// Load from Yaml
|
||||
var rdr = new FileReader("test-resources/unittest-vm.yaml");
|
||||
var vmStub = K8sDynamicStub.createFromYaml(client, vmsContext, rdr);
|
||||
assertTrue(vmStub.model().isPresent());
|
||||
|
||||
// Wait for created resources
|
||||
assertTrue(waitForConfigMap(client));
|
||||
assertTrue(waitForPvc(client));
|
||||
|
||||
// Check config map
|
||||
var config = K8sV1ConfigMapStub.get(client, "vmop-dev", "unittest-vm")
|
||||
.model().get();
|
||||
var yaml = new Yaml(new SafeConstructor(new LoaderOptions()))
|
||||
.load(config.getData().get("config.yaml"));
|
||||
@SuppressWarnings("unchecked")
|
||||
var maximumRam = ((Map<String, Map<String, Map<String, String>>>) yaml)
|
||||
.get("/Runner").get("vm").get("maximumRam");
|
||||
assertEquals("4 GiB", maximumRam);
|
||||
|
||||
// Cleanup
|
||||
K8sDynamicStub.get(client, vmsContext, "vmop-dev", "unittest-vm")
|
||||
.delete();
|
||||
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=unittest-vm");
|
||||
var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts);
|
||||
+ "app.kubernetes.io/instance=" + VM_NAME);
|
||||
var knownPvcs = K8sV1PvcStub.list(client, "vmop-test", listOpts);
|
||||
for (var pvc : knownPvcs) {
|
||||
pvc.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean waitForConfigMap(K8sClient client)
|
||||
throws InterruptedException, ApiException {
|
||||
var stub = K8sV1ConfigMapStub.get(client, "vmop-dev", "unittest-vm");
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (stub.model().isPresent()) {
|
||||
return true;
|
||||
}
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
return false;
|
||||
@AfterAll
|
||||
static void tearDownAfterClass() throws Exception {
|
||||
// Cleanup
|
||||
K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME)
|
||||
.delete();
|
||||
deletePvcs();
|
||||
|
||||
// Bring down manager
|
||||
mgrDeployment.scale(0);
|
||||
}
|
||||
|
||||
private boolean waitForPvc(K8sClient client)
|
||||
throws InterruptedException, ApiException {
|
||||
var stub
|
||||
= K8sV1PvcStub.get(client, "vmop-dev", "unittest-vm-runner-data");
|
||||
@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()) {
|
||||
return true;
|
||||
break;
|
||||
}
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
return false;
|
||||
// Check config map
|
||||
var config = stub.model().get();
|
||||
Map<List<? extends Object>, 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<K8sV1SecretStub> 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<? extends List<? extends Object>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@
|
|||
- "/usr/share/edk2/ovmf/OVMF_CODE.fd"
|
||||
- "/usr/share/edk2/x64/OVMF_CODE.fd"
|
||||
- "/usr/share/OVMF/OVMF_CODE.fd"
|
||||
# Use 4M version as fallback (if smaller version not available)
|
||||
- "/usr/share/edk2/ovmf-4m/OVMF_CODE.fd"
|
||||
- "/usr/share/edk2/x64/OVMF_CODE.4m.fd"
|
||||
"vars":
|
||||
- "/usr/share/edk2/ovmf/OVMF_VARS.fd"
|
||||
- "/usr/share/edk2/x64/OVMF_VARS.fd"
|
||||
- "/usr/share/OVMF/OVMF_VARS.fd"
|
||||
# Use 4M version as fallback (if smaller version not available)
|
||||
- "/usr/share/edk2/ovmf-4m/OVMF_VARS.fd"
|
||||
- "/usr/share/edk2/x64/OVMF_VARS.4m.fd"
|
||||
"uefi-4m":
|
||||
"rom":
|
||||
- "/usr/share/edk2/ovmf-4m/OVMF_CODE.fd"
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String> 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.
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -36,7 +36,6 @@ import org.jgrapes.core.annotation.Handler;
|
|||
/**
|
||||
* The Class CdMediaController.
|
||||
*/
|
||||
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
|
||||
public class CdMediaController extends Component {
|
||||
|
||||
/**
|
||||
|
|
@ -55,7 +54,6 @@ public class CdMediaController extends Component {
|
|||
*
|
||||
* @param componentChannel the component channel
|
||||
*/
|
||||
@SuppressWarnings("PMD.AssignmentToNonFinalStatic")
|
||||
public CdMediaController(Channel componentChannel) {
|
||||
super(componentChannel);
|
||||
}
|
||||
|
|
@ -66,8 +64,7 @@ public class CdMediaController extends Component {
|
|||
* @param event the event
|
||||
*/
|
||||
@Handler
|
||||
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.AvoidInstantiatingObjectsInLoops" })
|
||||
@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" })
|
||||
public void onConfigureQemu(ConfigureQemu event) {
|
||||
if (event.runState() == RunState.TERMINATING) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -69,4 +69,9 @@ class CommandDefinition {
|
|||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Command " + name + ": " + command;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue