Compare commits

..

No commits in common. "main" and "VM-Operator-3.5.1" have entirely different histories.

253 changed files with 2770 additions and 9742 deletions

View file

@ -1,89 +0,0 @@
# 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

78
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,78 @@
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

View file

@ -1,30 +0,0 @@
# 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

View file

@ -1,38 +0,0 @@
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

View file

@ -3,23 +3,10 @@
![Latest Manager](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=manager*&label=latest) ![Latest Manager](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=manager*&label=latest)
![Latest Runner](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=runner-qemu*&label=latest) ![Latest Runner](https://img.shields.io/github/v/tag/mnlipp/vm-operator?filter=runner-qemu*&label=latest)
# Run QEMU/KVM in Kubernetes Pods # Run Qemu in Kubernetes Pods
![Overview picture](webpages/index-pic.svg) The goal of this project is to provide easy to use and flexible components
for running Qemu based VMs in Kubernetes pods.
This project provides an easy to use and flexible solution for running See the [project's home page](https://jdrupes.org/vm-operator/)
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. for details.

View file

@ -5,7 +5,7 @@ buildscript {
} }
plugins { plugins {
id 'org.ajoberstar.grgit' version '5.2.0' id 'org.ajoberstar.grgit' version '5.2.0' apply false
id 'org.ajoberstar.git-publish' version '4.2.0' apply false 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 'pl.allegro.tech.build.axion-release' version '1.17.2' apply false
id 'org.jdrupes.vmoperator.versioning-conventions' id 'org.jdrupes.vmoperator.versioning-conventions'
@ -19,7 +19,7 @@ allprojects {
} }
task stage { task stage {
description = 'To be executed by CI.' description = 'To be executed by CI, build and update JavaDoc.'
group = 'build' group = 'build'
// Build everything first // Build everything first
@ -27,6 +27,11 @@ task stage {
dependsOn subprojects.tasks.collect { dependsOn subprojects.tasks.collect {
tc -> tc.findByName("build") }.flatten() tc -> tc.findByName("build") }.flatten()
} }
if (JavaVersion.current() == JavaVersion.VERSION_21) {
// Publish JavaDoc
dependsOn gitPublishPush
}
} }
eclipse { eclipse {

View file

@ -118,3 +118,33 @@ if (System.properties['org.ajoberstar.grgit.auth.username'] == null) {
System.setProperty('org.ajoberstar.grgit.auth.username', System.setProperty('org.ajoberstar.grgit.auth.username',
project.rootProject.properties['website.push.token'] ?: "nouser") 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
}

View file

@ -1,74 +0,0 @@
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

View file

@ -994,10 +994,6 @@ spec:
type: array type: array
description: >- description: >-
Defines permissions for accessing and manipulating the VM. 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: items:
type: object type: object
description: >- description: >-
@ -1021,16 +1017,8 @@ spec:
- stop - stop
- reset - reset
- accessConsole - accessConsole
- takeConsole
- "*" - "*"
default: [] default: []
pools:
type: array
description: >-
List of pools this VM belongs to.
items:
type: string
default: []
loggingProperties: loggingProperties:
type: string type: string
description: >- description: >-
@ -1430,12 +1418,6 @@ spec:
outputs: outputs:
type: integer type: integer
default: 1 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: spice:
type: object type: object
properties: properties:
@ -1470,10 +1452,6 @@ spec:
type: object type: object
default: {} default: {}
properties: properties:
runnerVersion:
description: >-
The version string of the runner.
type: string
cpus: cpus:
description: >- description: >-
Number of CPUs currently in use. Number of CPUs currently in use.
@ -1495,39 +1473,12 @@ spec:
connection. connection.
type: string type: string
default: "" default: ""
loggedInUser:
description: >-
The name of a user that is currently logged in by the
VM operator agent.
type: string
displayPasswordSerial: displayPasswordSerial:
description: >- description: >-
Counts changes of the display password. Set to -1 Counts changes of the display password. Set to -1
by the runner if password protection is not enabled. by the runner if password protection is not enabled.
type: integer type: integer
default: 0 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: conditions:
description: >- description: >-
List of component conditions observed List of component conditions observed
@ -1538,24 +1489,6 @@ spec:
lastTransitionTime: "1970-01-01T00:00:00Z" lastTransitionTime: "1970-01-01T00:00:00Z"
reason: Creation reason: Creation
message: "Creation of CR" 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 - type: ConsoleConnected
status: "False" status: "False"
observedGeneration: 1 observedGeneration: 1

View file

@ -21,31 +21,22 @@ spec:
- name: vm-operator - name: vm-operator
image: >- image: >-
ghcr.io/mnlipp/org.jdrupes.vmoperator.manager:latest 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: volumeMounts:
- name: config - name: config
mountPath: /etc/opt/vmoperator mountPath: /etc/opt/vmoperator
- name: vmop-image-repository - name: vmop-image-repository
mountPath: /var/local/vmop-image-repository mountPath: /var/local/vmop-image-repository
imagePullPolicy: Always
securityContext: securityContext:
capabilities: capabilities:
drop: drop:
- ALL - ALL
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
resources:
requests:
cpu: 100m
memory: 128Mi
volumes: volumes:
- name: config - name: config
configMap: configMap:

View file

@ -9,7 +9,6 @@ rules:
- vmoperator.jdrupes.org - vmoperator.jdrupes.org
resources: resources:
- vms - vms
- vmpools
verbs: verbs:
- '*' - '*'
- apiGroups: - apiGroups:
@ -38,7 +37,6 @@ rules:
- persistentvolumeclaims - persistentvolumeclaims
- pods - pods
verbs: verbs:
- watch
- list - list
- get - get
- create - create

View file

@ -1,4 +1 @@
/test-vm-ci.yaml /test-vm-ci.yaml
/kubeconfig.yaml
/crds/
/.vm-operator-cmd.rc

View file

@ -1,11 +1,11 @@
# Example setup for development # Example setup for development
The CRD must be deployed independently. Apart from that, the 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. This allows you to run the manager in your IDE.

View file

@ -7,8 +7,8 @@
"/Controller": "/Controller":
namespace: vmop-dev namespace: vmop-dev
"/Reconciler": "/Reconciler":
runnerDataPvc: runnerData:
storageClassName: rook-cephfs storageClassName: null
loadBalancerService: loadBalancerService:
labels: labels:
label1: label1 label1: label1
@ -40,30 +40,15 @@
- name: admin - name: admin
fullName: Administrator fullName: Administrator
password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
- name: operator - name: test
fullName: Operator fullName: Test Account
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" password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
"/RoleConfigurator": "/RoleConfigurator":
rolesByUser: rolesByUser:
# User admin has role admin # User admin has role admin
admin: admin:
- admin - admin
operator: test:
- operator
test1:
- user
test2:
- user
test3:
- user - user
# All users have role other # All users have role other
"*": "*":
@ -74,16 +59,13 @@
# Admins can use all conlets # Admins can use all conlets
admin: admin:
- "*" - "*"
operator:
- org.jdrupes.vmoperator.vmmgmt.VmMgmt
- org.jdrupes.vmoperator.vmaccess.VmAccess
user: user:
- org.jdrupes.vmoperator.vmaccess.VmAccess - org.jdrupes.vmoperator.vmviewer.VmViewer
# Others cannot use any conlet (except login conlet to log out) # Others cannot use any conlet (except login conlet to log out)
other: other:
- org.jgrapes.webconlet.oidclogin.LoginConlet - org.jgrapes.webconlet.oidclogin.LoginConlet
"/ComponentCollector": "/ComponentCollector":
"/VmAccess": "/VmViewer":
displayResource: displayResource:
preferredIpVersion: ipv4 preferredIpVersion: ipv4
syncPreviewsFor: syncPreviewsFor:

View file

@ -1,47 +0,0 @@
#!/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

View file

@ -54,13 +54,7 @@ patches:
- name: admin - name: admin
fullName: Administrator fullName: Administrator
password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21." password: "$2b$05$NiBd74ZGdplLC63ePZf1f.UtjMKkbQ23cQoO2OKOFalDBHWAOy21."
- name: test1 - name: test
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 fullName: Test Account
password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2" password: "$2b$05$hZaI/jToXf/d3BctZdT38Or7H7h6Pn2W3WiB49p5AyhDHFkkYCvo2"
"/RoleConfigurator": "/RoleConfigurator":
@ -68,11 +62,7 @@ patches:
# User admin has role admin # User admin has role admin
admin: admin:
- admin - admin
test1: test:
- user
test2:
- user
test3:
- user - user
# All users have role other # All users have role other
"*": "*":
@ -89,7 +79,7 @@ patches:
other: other:
- org.jgrapes.webconlet.locallogin.LoginConlet - org.jgrapes.webconlet.locallogin.LoginConlet
"/ComponentCollector": "/ComponentCollector":
"/VmAccess": "/VmViewer":
displayResource: displayResource:
preferredIpVersion: ipv4 preferredIpVersion: ipv4
syncPreviewsFor: syncPreviewsFor:

View file

@ -1,66 +0,0 @@
#!/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

View file

@ -1,17 +0,0 @@
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

View file

@ -1,10 +0,0 @@
---
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

View file

@ -1,66 +0,0 @@
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)) %>

View file

@ -5,13 +5,18 @@ metadata:
name: test-vm name: test-vm
spec: spec:
image: image:
source: registry.mnl.de/org/jdrupes/vm-operator/org.jdrupes.vmoperator.runner.qemu-arch:testing repository: docker-registry.lan.mnl.de
path: vmoperator/org.jdrupes.vmoperator.runner.qemu-alpine
version: latest
pullPolicy: Always pullPolicy: Always
permissions: permissions:
- user: admin - user: admin
may: may:
- "*" - "*"
- user: test
may:
- "accessConsole"
resources: resources:
requests: requests:
@ -32,9 +37,8 @@ spec:
currentCpus: 4 currentCpus: 4
networks: networks:
# No bridge on test cluster - tap:
- user: {} mac: "02:16:3e:33:58:10"
disks: disks:
- volumeClaimTemplate: - volumeClaimTemplate:
metadata: metadata:

View file

@ -1,2 +0,0 @@
SUBSYSTEM=="virtio-ports", ATTR{name}=="org.jdrupes.vmop_agent.0", \
TAG+="systemd" ENV{SYSTEMD_WANTS}="vmop-agent.service"

View file

@ -1,3 +0,0 @@
#!/bin/sh
sed -i '/AutomaticLogin/d' /etc/gdm/custom.conf

View file

@ -1,146 +0,0 @@
#!/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

View file

@ -1,15 +0,0 @@
[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

View file

@ -16,21 +16,18 @@
var _paq = _paq || []; var _paq = _paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */ /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(["setDocumentTitle", document.domain + "/" + document.title]); _paq.push(["setDocumentTitle", document.domain + "/" + document.title]);
_paq.push(["setCookieDomain", "*.mnlipp.github.io"]); _paq.push(["setCookieDomain", "*.jdrupes.org"]);
_paq.push(["setDomains", ["*.mnlipp.github.io", "*.jdrupes.org", "kubernetes-vm-operator.readthedocs.io"]]);
_paq.push(['disableCookies']); _paq.push(['disableCookies']);
_paq.push(['trackPageView']); _paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']); _paq.push(['enableLinkTracking']);
(function() { (function() {
var u="https://piwik.mnl.de/"; var u="//jdrupes.org/";
_paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setTrackerUrl', u+'piwik.php']);
_paq.push(['setSiteId', '17']); _paq.push(['setSiteId', '15']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; 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); g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
})(); })();
</script> </script>
<noscript><p><img referrerpolicy="no-referrer-when-downgrade" <noscript><p><img referrerpolicy="no-referrer-when-downgrade" src="//piwik.mnl.de/matomo.php?idsite=15&amp;rec=1" style="border:0;" alt="" /></p></noscript>
src="//piwik.mnl.de/matomo.php?idsite=17&amp;rec=1&amp;action_name=VM-Operator" style="border:0;" alt="" /></p></noscript>
<!-- End Matomo Code --> <!-- End Matomo Code -->
<script defer src="https://gotit.mnl.de/script.js" data-website-id="14b277ad-d330-4a54-82f1-a77d111240ac"></script>
</div> </div>

View file

@ -13,5 +13,4 @@ dependencies {
api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)' api 'org.jgrapes:org.jgrapes.core:[1.22.1,2)'
api 'io.kubernetes:client-java:[19.0.0,20.0.0)' api 'io.kubernetes:client-java:[19.0.0,20.0.0)'
api 'org.yaml:snakeyaml' api 'org.yaml:snakeyaml'
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]'
} }

View file

@ -27,101 +27,15 @@ public class Constants {
/** The Constant APP_NAME. */ /** The Constant APP_NAME. */
public static final String APP_NAME = "vm-runner"; 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. */ /** The Constant VM_OP_NAME. */
public static final String VM_OP_NAME = "vm-operator"; public static final String VM_OP_NAME = "vm-operator";
/** /** The Constant VM_OP_GROUP. */
* Constants related to the CRD. public static final String VM_OP_GROUP = "vmoperator.jdrupes.org";
*/
@SuppressWarnings("PMD.ShortClassName")
public static class Crd {
/** The Constant GROUP. */
public static final String GROUP = "vmoperator.jdrupes.org";
/** The Constant KIND_VM. */ /** The Constant VM_OP_KIND_VM. */
public static final String KIND_VM = "VirtualMachine"; public static final String VM_OP_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";
}
} }

View file

@ -32,11 +32,13 @@ import java.util.regex.Pattern;
public class Convertions { public class Convertions {
@SuppressWarnings({ "PMD.UseConcurrentHashMap", @SuppressWarnings({ "PMD.UseConcurrentHashMap",
"PMD.FieldNamingConventions" }) "PMD.FieldNamingConventions", "PMD.VariableNamingConventions" })
private static final Map<String, BigInteger> unitMap = new HashMap<>(); private static final Map<String, BigInteger> unitMap = new HashMap<>();
@SuppressWarnings({ "PMD.FieldNamingConventions" }) @SuppressWarnings({ "PMD.FieldNamingConventions",
"PMD.VariableNamingConventions" })
private static final List<Map.Entry<String, BigInteger>> unitMappings; private static final List<Map.Entry<String, BigInteger>> unitMappings;
@SuppressWarnings({ "PMD.FieldNamingConventions" }) @SuppressWarnings({ "PMD.FieldNamingConventions",
"PMD.VariableNamingConventions" })
private static final Pattern memorySize private static final Pattern memorySize
= Pattern.compile("^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*"); = Pattern.compile("^\\s*(\\d+(\\.\\d+)?)\\s*([A-Za-z]*)\\s*");
@ -67,6 +69,7 @@ public class Convertions {
* @param amount the amount * @param amount the amount
* @return the big integer * @return the big integer
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public static BigInteger parseMemory(Object amount) { public static BigInteger parseMemory(Object amount) {
if (amount == null) { if (amount == null) {
return (BigInteger) amount; return (BigInteger) amount;

View file

@ -47,7 +47,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Helpers for K8s API. * Helpers for K8s API.
*/ */
@SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass" }) @SuppressWarnings({ "PMD.ShortClassName", "PMD.UseUtilityClass",
"PMD.DataflowAnomalyAnalysis" })
public class K8s { public class K8s {
/** /**
@ -112,6 +113,7 @@ public class K8s {
public static JsonObject yamlToJson(ApiClient client, Reader yaml) { public static JsonObject yamlToJson(ApiClient client, Reader yaml) {
// Avoid Yaml.load due to // Avoid Yaml.load due to
// https://github.com/kubernetes-client/java/issues/2741 // https://github.com/kubernetes-client/java/issues/2741
@SuppressWarnings("PMD.UseConcurrentHashMap")
Map<String, Object> yamlData Map<String, Object> yamlData
= new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml); = new Yaml(new SafeConstructor(new LoaderOptions())).load(yaml);

View file

@ -48,7 +48,8 @@ import okhttp3.Response;
* A client with some additional properties. * A client with some additional properties.
*/ */
@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods", @SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods",
"checkstyle:LineLength", "PMD.CouplingBetweenObjects", "PMD.GodClass" }) "PMD.LinguisticNaming", "checkstyle:LineLength",
"PMD.CouplingBetweenObjects", "PMD.GodClass" })
public class K8sClient extends ApiClient { public class K8sClient extends ApiClient {
private ApiClient apiClient; private ApiClient apiClient;
@ -230,6 +231,7 @@ public class K8sClient extends ApiClient {
* @return the api client * @return the api client
* @see ApiClient#setKeyManagers(javax.net.ssl.KeyManager[]) * @see ApiClient#setKeyManagers(javax.net.ssl.KeyManager[])
*/ */
@SuppressWarnings("PMD.UseVarargs")
@Override @Override
public ApiClient setKeyManagers(KeyManager[] managers) { public ApiClient setKeyManagers(KeyManager[] managers) {
return apiClient().setKeyManagers(managers); return apiClient().setKeyManagers(managers);
@ -636,6 +638,7 @@ public class K8sClient extends ApiClient {
* @return the string * @return the string
* @see ApiClient#selectHeaderAccept(java.lang.String[]) * @see ApiClient#selectHeaderAccept(java.lang.String[])
*/ */
@SuppressWarnings("PMD.UseVarargs")
@Override @Override
public String selectHeaderAccept(String[] accepts) { public String selectHeaderAccept(String[] accepts) {
return apiClient().selectHeaderAccept(accepts); return apiClient().selectHeaderAccept(accepts);
@ -648,6 +651,7 @@ public class K8sClient extends ApiClient {
* @return the string * @return the string
* @see ApiClient#selectHeaderContentType(java.lang.String[]) * @see ApiClient#selectHeaderContentType(java.lang.String[])
*/ */
@SuppressWarnings("PMD.UseVarargs")
@Override @Override
public String selectHeaderContentType(String[] contentTypes) { public String selectHeaderContentType(String[] contentTypes) {
return apiClient().selectHeaderContentType(contentTypes); return apiClient().selectHeaderContentType(contentTypes);
@ -814,7 +818,7 @@ public class K8sClient extends ApiClient {
* @throws ApiException the api exception * @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) * @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" }) @SuppressWarnings({ "rawtypes", "PMD.ExcessiveParameterList" })
@Override @Override
public Call buildCall(String path, String method, List<Pair> queryParams, public Call buildCall(String path, String method, List<Pair> queryParams,
List<Pair> collectionQueryParams, Object body, List<Pair> collectionQueryParams, Object body,
@ -843,7 +847,7 @@ public class K8sClient extends ApiClient {
* @throws ApiException the api exception * @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) * @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" }) @SuppressWarnings({ "rawtypes", "PMD.ExcessiveParameterList" })
@Override @Override
public Request buildRequest(String path, String method, public Request buildRequest(String path, String method,
List<Pair> queryParams, List<Pair> collectionQueryParams, List<Pair> queryParams, List<Pair> collectionQueryParams,

View file

@ -45,7 +45,7 @@ import java.util.function.Function;
* @param <O> the generic type * @param <O> the generic type
* @param <L> the generic type * @param <L> the generic type
*/ */
@SuppressWarnings({ "PMD.CouplingBetweenObjects" }) @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sClusterGenericStub<O extends KubernetesObject, public class K8sClusterGenericStub<O extends KubernetesObject,
L extends KubernetesListObject> { L extends KubernetesListObject> {
protected final K8sClient client; protected final K8sClient client;
@ -239,7 +239,6 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
* @param <L> the object list type * @param <L> the object list type
* @param <R> the result type * @param <R> the result type
*/ */
@FunctionalInterface
public interface GenericSupplier<O extends KubernetesObject, public interface GenericSupplier<O extends KubernetesObject,
L extends KubernetesListObject, L extends KubernetesListObject,
R extends K8sClusterGenericStub<O, L>> { R extends K8sClusterGenericStub<O, L>> {
@ -254,6 +253,7 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
* @param name the name * @param name the name
* @return the result * @return the result
*/ */
@SuppressWarnings("PMD.UseObjectForClearerAPI")
R get(Class<O> objectClass, Class<L> objectListClass, K8sClient client, R get(Class<O> objectClass, Class<L> objectListClass, K8sClient client,
APIResource context, String name); APIResource context, String name);
} }
@ -282,6 +282,7 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
* @return the stub if the object exists * @return the stub if the object exists
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop" })
public static <O extends KubernetesObject, L extends KubernetesListObject, public static <O extends KubernetesObject, L extends KubernetesListObject,
R extends K8sClusterGenericStub<O, L>> R extends K8sClusterGenericStub<O, L>>
R get(Class<O> objectClass, Class<L> objectListClass, R get(Class<O> objectClass, Class<L> objectListClass,
@ -312,6 +313,8 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
* @return the stub if the object exists * @return the stub if the object exists
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
"PMD.UseObjectForClearerAPI" })
public static <O extends KubernetesObject, L extends KubernetesListObject, public static <O extends KubernetesObject, L extends KubernetesListObject,
R extends K8sClusterGenericStub<O, L>> R extends K8sClusterGenericStub<O, L>>
R get(Class<O> objectClass, Class<L> objectListClass, R get(Class<O> objectClass, Class<L> objectListClass,
@ -336,6 +339,8 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
* @return the stub if the object exists * @return the stub if the object exists
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
public static <O extends KubernetesObject, L extends KubernetesListObject, public static <O extends KubernetesObject, L extends KubernetesListObject,
R extends K8sClusterGenericStub<O, L>> R extends K8sClusterGenericStub<O, L>>
R create(Class<O> objectClass, Class<L> objectListClass, R create(Class<O> objectClass, Class<L> objectListClass,
@ -368,7 +373,7 @@ public class K8sClusterGenericStub<O extends KubernetesObject,
public static <O extends KubernetesObject, L extends KubernetesListObject, public static <O extends KubernetesObject, L extends KubernetesListObject,
R extends K8sClusterGenericStub<O, L>> R extends K8sClusterGenericStub<O, L>>
Collection<R> list(Class<O> objectClass, Class<L> objectListClass, Collection<R> list(Class<O> objectClass, Class<L> objectListClass,
K8sClient client, APIResource context, K8sClient client, APIResource context,
ListOptions options, GenericSupplier<O, L, R> provider) ListOptions options, GenericSupplier<O, L, R> provider)
throws ApiException { throws ApiException {
var result = new ArrayList<R>(); var result = new ArrayList<R>();

View file

@ -29,6 +29,7 @@ import io.kubernetes.client.openapi.models.V1ObjectMeta;
* notably the metadata, is made available through the methods * notably the metadata, is made available through the methods
* defined by {@link KubernetesObject}. * defined by {@link KubernetesObject}.
*/ */
@SuppressWarnings("PMD.DataClass")
public class K8sDynamicModel implements KubernetesObject { public class K8sDynamicModel implements KubernetesObject {
private final V1ObjectMeta metadata; private final V1ObjectMeta metadata;
@ -101,7 +102,7 @@ public class K8sDynamicModel implements KubernetesObject {
* *
* @return the JSON object describing the status * @return the JSON object describing the status
*/ */
public JsonObject statusJson() { public JsonObject status() {
return data.getAsJsonObject("status"); return data.getAsJsonObject("status");
} }

View file

@ -31,6 +31,7 @@ import java.util.Collection;
* state and can therefore be used for any kind of object, especially * state and can therefore be used for any kind of object, especially
* custom objects. * custom objects.
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sDynamicStub public class K8sDynamicStub
extends K8sDynamicStubBase<K8sDynamicModel, K8sDynamicModels> { extends K8sDynamicStubBase<K8sDynamicModel, K8sDynamicModels> {
@ -63,6 +64,8 @@ public class K8sDynamicStub
* @return the stub if the object exists * @return the stub if the object exists
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
public static K8sDynamicStub get(K8sClient client, public static K8sDynamicStub get(K8sClient client,
GroupVersionKind gvk, String namespace, String name) GroupVersionKind gvk, String namespace, String name)
throws ApiException { throws ApiException {
@ -80,6 +83,8 @@ public class K8sDynamicStub
* @return the stub if the object exists * @return the stub if the object exists
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
public static K8sDynamicStub get(K8sClient client, public static K8sDynamicStub get(K8sClient client,
APIResource context, String namespace, String name) { APIResource context, String namespace, String name) {
return new K8sDynamicStub(client, context, namespace, name); return new K8sDynamicStub(client, context, namespace, name);

View file

@ -26,6 +26,7 @@ import io.kubernetes.client.Discovery.APIResource;
* state and can therefore be used for any kind of object, especially * state and can therefore be used for any kind of object, especially
* custom objects. * custom objects.
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public abstract class K8sDynamicStubBase<O extends K8sDynamicModel, public abstract class K8sDynamicStubBase<O extends K8sDynamicModel,
L extends K8sDynamicModelsBase<O>> extends K8sGenericStub<O, L> { L extends K8sDynamicModelsBase<O>> extends K8sGenericStub<O, L> {
@ -39,6 +40,7 @@ public abstract class K8sDynamicStubBase<O extends K8sDynamicModel,
* @param namespace the namespace * @param namespace the namespace
* @param name the name * @param name the name
*/ */
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
public K8sDynamicStubBase(Class<O> objectClass, public K8sDynamicStubBase(Class<O> objectClass,
Class<L> objectListClass, DynamicTypeAdapterFactory<O, L> taf, Class<L> objectListClass, DynamicTypeAdapterFactory<O, L> taf,
K8sClient client, APIResource context, String namespace, K8sClient client, APIResource context, String namespace,

View file

@ -48,7 +48,7 @@ import java.util.function.Function;
* @param <O> the generic type * @param <O> the generic type
* @param <L> the generic type * @param <L> the generic type
*/ */
@SuppressWarnings({ "PMD.TooManyMethods" }) @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyMethods" })
public class K8sGenericStub<O extends KubernetesObject, public class K8sGenericStub<O extends KubernetesObject,
L extends KubernetesListObject> { L extends KubernetesListObject> {
protected final K8sClient client; protected final K8sClient client;
@ -193,92 +193,56 @@ public class K8sGenericStub<O extends KubernetesObject,
} }
/** /**
* Updates the object's status. Does not retry in case of conflict. * Updates the object's status, retrying for the given number of times
* if the update fails due to a conflict.
* *
* @param object the current state of the object (passed to `status`) * @param object the current state of the object (passed to `status`)
* @param updater function that returns the new status * @param status function that returns the new status
* @return the updated model or empty if the object was not found * @param retries the retries
* @return the updated model or empty if not successful
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
public Optional<O> updateStatus(O object, Function<O, Object> updater) @SuppressWarnings("PMD.AssignmentInOperand")
throws ApiException { public Optional<O> updateStatus(O object,
return K8s.optional(api.updateStatus(object, updater)); Function<O, Object> status, int retries) throws ApiException {
}
/**
* Updates the status of the given object. In case of conflict,
* get the current version of the object and tries again. Retries
* up to `retries` times.
*
* @param updater the function updating the status
* @param current the current state of the object, used for the first
* attempt to update
* @param retries the retries in case of conflict
* @return the updated model or empty if the object was not found
* @throws ApiException the api exception
*/
@SuppressWarnings({ "PMD.AssignmentInOperand" })
public Optional<O> updateStatus(Function<O, Object> updater, O current,
int retries) throws ApiException {
while (true) { while (true) {
try { try {
if (current == null) { return K8s.optional(api.updateStatus(object, status));
current = api.get(namespace, name)
.throwsApiException().getObject();
}
return updateStatus(current, updater);
} catch (ApiException e) { } catch (ApiException e) {
if (HttpURLConnection.HTTP_CONFLICT != e.getCode() if (HttpURLConnection.HTTP_CONFLICT != e.getCode()
|| retries-- <= 0) { || retries-- <= 0) {
throw e; throw e;
} }
// Get current version for new attempt
current = null;
} }
} }
} }
/** /**
* Gets the object and updates the status. In case of conflict, retries * Updates the object's status, retrying up to 16 times if there
* up to `retries` times. * is a conflict.
* *
* @param updater the function updating the status * @param object the current state of the object (passed to `status`)
* @param retries the retries in case of conflict * @param status function that returns the new status
* @return the updated model or empty if the object was not found * @return the updated model or empty if not successful
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
public Optional<O> updateStatus(Function<O, Object> updater, int retries) public Optional<O> updateStatus(O object,
throws ApiException { Function<O, Object> status) throws ApiException {
return updateStatus(updater, null, retries); return updateStatus(object, status, 16);
} }
/** /**
* Updates the status of the given object. In case of conflict, * Updates the status.
* get the current version of the object and tries again. Retries
* up to `retries` times.
* *
* @param updater the function updating the status * @param status the status
* @param current the current
* @return the kubernetes api response * @return the kubernetes api response
* the updated model or empty if not successful * the updated model or empty if not successful
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
public Optional<O> updateStatus(Function<O, Object> updater, O current) public Optional<O> updateStatus(Function<O, Object> status)
throws ApiException { throws ApiException {
return updateStatus(updater, current, 16); return updateStatus(
} api.get(namespace, name).throwsApiException().getObject(), status);
/**
* 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);
} }
/** /**
@ -357,7 +321,6 @@ public class K8sGenericStub<O extends KubernetesObject,
* @param <L> the object list type * @param <L> the object list type
* @param <R> the result type * @param <R> the result type
*/ */
@FunctionalInterface
public interface GenericSupplier<O extends KubernetesObject, public interface GenericSupplier<O extends KubernetesObject,
L extends KubernetesListObject, R extends K8sGenericStub<O, L>> { L extends KubernetesListObject, R extends K8sGenericStub<O, L>> {
@ -369,6 +332,7 @@ public class K8sGenericStub<O extends KubernetesObject,
* @param name the name * @param name the name
* @return the result * @return the result
*/ */
@SuppressWarnings("PMD.UseObjectForClearerAPI")
R get(K8sClient client, String namespace, String name); R get(K8sClient client, String namespace, String name);
} }
@ -394,6 +358,8 @@ public class K8sGenericStub<O extends KubernetesObject,
* @return the stub if the object exists * @return the stub if the object exists
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
public static <O extends KubernetesObject, L extends KubernetesListObject, public static <O extends KubernetesObject, L extends KubernetesListObject,
R extends K8sGenericStub<O, L>> R extends K8sGenericStub<O, L>>
R create(Class<O> objectClass, Class<L> objectListClass, R create(Class<O> objectClass, Class<L> objectListClass,

View file

@ -27,7 +27,6 @@ import io.kubernetes.client.util.generic.GenericKubernetesApi;
import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.ListOptions;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Optional;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
@ -50,6 +49,7 @@ public class K8sObserver<O extends KubernetesObject,
ADDED, MODIFIED, DELETED ADDED, MODIFIED, DELETED
} }
@SuppressWarnings("PMD.FieldNamingConventions")
protected final Logger logger = Logger.getLogger(getClass().getName()); protected final Logger logger = Logger.getLogger(getClass().getName());
protected final K8sClient client; protected final K8sClient client;
@ -72,7 +72,8 @@ public class K8sObserver<O extends KubernetesObject,
* @param namespace the namespace * @param namespace the namespace
* @param options the options * @param options the options
*/ */
@SuppressWarnings({ "PMD.AvoidCatchingThrowable", @SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
"PMD.UseObjectForClearerAPI", "PMD.AvoidCatchingThrowable",
"PMD.CognitiveComplexity", "PMD.AvoidCatchingGenericException" }) "PMD.CognitiveComplexity", "PMD.AvoidCatchingGenericException" })
public K8sObserver(Class<O> objectClass, Class<L> objectListClass, public K8sObserver(Class<O> objectClass, Class<L> objectListClass,
K8sClient client, APIResource context, String namespace, K8sClient client, APIResource context, String namespace,
@ -88,29 +89,23 @@ public class K8sObserver<O extends KubernetesObject,
thread = (Components.useVirtualThreads() ? Thread.ofVirtual() thread = (Components.useVirtualThreads() ? Thread.ofVirtual()
: Thread.ofPlatform()).unstarted(() -> { : Thread.ofPlatform()).unstarted(() -> {
try { try {
logger.fine(() -> "Observing " + context.getResourcePlural() logger
+ " (" + context.getPreferredVersion() + ")" .config(() -> "Watching " + context.getResourcePlural()
+ Optional.ofNullable(options.getLabelSelector()) + " (" + context.getPreferredVersion() + ")"
.map(ls -> " with labels " + ls).orElse("") + " in " + namespace);
+ " in " + namespace);
// Watch sometimes terminates without apparent reason. // Watch sometimes terminates without apparent reason.
while (!Thread.currentThread().isInterrupted()) { while (!Thread.currentThread().isInterrupted()) {
Instant startedAt = Instant.now(); Instant startedAt = Instant.now();
try { try {
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
var changed var changed
= api.watch(namespace, options).iterator(); = api.watch(namespace, options).iterator();
while (changed.hasNext()) { while (changed.hasNext()) {
var response = changed.next(); handler.accept(client, changed.next());
logger.fine(() -> "Resource "
+ context.getKind() + "/"
+ response.object.getMetadata().getName()
+ " " + response.type);
handler.accept(client, response);
} }
} catch (ApiException | RuntimeException e) { } catch (ApiException | RuntimeException e) {
logger.log(Level.FINE, e, () -> "Problem watching" logger.log(Level.FINE, e, () -> "Problem watching"
+ " resource " + context.getKind()
+ " (will retry): " + e.getMessage()); + " (will retry): " + e.getMessage());
delayRestart(startedAt); delayRestart(startedAt);
} }
@ -230,6 +225,7 @@ public class K8sObserver<O extends KubernetesObject,
} }
@Override @Override
@SuppressWarnings("PMD.UseLocaleWithCaseConversions")
public String toString() { public String toString() {
return "Observer for " + K8s.toString(context) + " " + namespace; return "Observer for " + K8s.toString(context) + " " + namespace;
} }

View file

@ -26,6 +26,7 @@ import java.util.List;
/** /**
* A stub for config maps (v1). * A stub for config maps (v1).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sV1ConfigMapStub public class K8sV1ConfigMapStub
extends K8sGenericStub<V1ConfigMap, V1ConfigMapList> { extends K8sGenericStub<V1ConfigMap, V1ConfigMapList> {

View file

@ -29,6 +29,7 @@ import java.util.Optional;
/** /**
* A stub for pods (v1). * A stub for pods (v1).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sV1DeploymentStub public class K8sV1DeploymentStub
extends K8sGenericStub<V1Deployment, V1DeploymentList> { extends K8sGenericStub<V1Deployment, V1DeploymentList> {

View file

@ -29,6 +29,7 @@ import java.util.List;
/** /**
* A stub for nodes (v1). * A stub for nodes (v1).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sV1NodeStub extends K8sClusterGenericStub<V1Node, V1NodeList> { public class K8sV1NodeStub extends K8sClusterGenericStub<V1Node, V1NodeList> {
public static final APIResource CONTEXT = new APIResource("", List.of("v1"), public static final APIResource CONTEXT = new APIResource("", List.of("v1"),
@ -73,7 +74,8 @@ public class K8sV1NodeStub extends K8sClusterGenericStub<V1Node, V1NodeList> {
/** /**
* Provide {@link GenericSupplier}. * Provide {@link GenericSupplier}.
*/ */
@SuppressWarnings({ "PMD.UnusedFormalParameter" }) @SuppressWarnings({ "PMD.UnusedFormalParameter",
"PMD.UnusedPrivateMethod" })
private static K8sV1NodeStub getGeneric(Class<V1Node> objectClass, private static K8sV1NodeStub getGeneric(Class<V1Node> objectClass,
Class<V1NodeList> objectListClass, K8sClient client, Class<V1NodeList> objectListClass, K8sClient client,
APIResource context, String name) { APIResource context, String name) {

View file

@ -29,6 +29,7 @@ import java.util.List;
/** /**
* A stub for pods (v1). * A stub for pods (v1).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sV1PodStub extends K8sGenericStub<V1Pod, V1PodList> { public class K8sV1PodStub extends K8sGenericStub<V1Pod, V1PodList> {
/** The pods' context. */ /** The pods' context. */

View file

@ -29,6 +29,7 @@ import java.util.List;
/** /**
* A stub for pods (v1). * A stub for pods (v1).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sV1PvcStub extends public class K8sV1PvcStub extends
K8sGenericStub<V1PersistentVolumeClaim, V1PersistentVolumeClaimList> { K8sGenericStub<V1PersistentVolumeClaim, V1PersistentVolumeClaimList> {

View file

@ -29,6 +29,7 @@ import java.util.List;
/** /**
* A stub for secrets (v1). * A stub for secrets (v1).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sV1SecretStub extends K8sGenericStub<V1Secret, V1SecretList> { public class K8sV1SecretStub extends K8sGenericStub<V1Secret, V1SecretList> {
public static final APIResource CONTEXT = new APIResource("", List.of("v1"), public static final APIResource CONTEXT = new APIResource("", List.of("v1"),

View file

@ -29,6 +29,7 @@ import java.util.List;
/** /**
* A stub for secrets (v1). * A stub for secrets (v1).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sV1ServiceStub extends K8sGenericStub<V1Service, V1ServiceList> { public class K8sV1ServiceStub extends K8sGenericStub<V1Service, V1ServiceList> {
public static final APIResource CONTEXT = new APIResource("", List.of("v1"), public static final APIResource CONTEXT = new APIResource("", List.of("v1"),

View file

@ -26,6 +26,7 @@ import java.util.List;
/** /**
* A stub for stateful sets (v1). * A stub for stateful sets (v1).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class K8sV1StatefulSetStub public class K8sV1StatefulSetStub
extends K8sGenericStub<V1StatefulSet, V1StatefulSetList> { extends K8sGenericStub<V1StatefulSet, V1StatefulSetList> {

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2025 Michael N. Lipp * Copyright (C) 2024 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -18,48 +18,32 @@
package org.jdrupes.vmoperator.common; package org.jdrupes.vmoperator.common;
import com.fasterxml.jackson.databind.ObjectMapper; import io.kubernetes.client.openapi.models.V1ObjectMeta;
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.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function; import java.util.function.Function;
import java.util.logging.Logger;
import java.util.stream.Collectors; 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; import org.jdrupes.vmoperator.util.DataPath;
/** /**
* Represents a VM definition. * Represents a VM definition.
*/ */
@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" }) @SuppressWarnings({ "PMD.DataClass" })
public class VmDefinition extends K8sDynamicModel { public class VmDefinition {
@SuppressWarnings({ "unused" }) private String kind;
private static final Logger logger private String apiVersion;
= Logger.getLogger(VmDefinition.class.getName()); private V1ObjectMeta metadata;
@SuppressWarnings("PMD.FieldNamingConventions") private Map<String, Object> spec;
private static final Gson gson = new JSON().getGson(); private Map<String, Object> status;
@SuppressWarnings("PMD.FieldNamingConventions") private final Map<String, Object> extra = new ConcurrentHashMap<>();
private static final ObjectMapper objectMapper
= new ObjectMapper().registerModule(new JavaTimeModule());
private final Model model;
private VmExtraData extraData;
/** /**
* The VM state from the VM definition. * The VM state from the VM definition.
@ -73,7 +57,7 @@ public class VmDefinition extends K8sDynamicModel {
*/ */
public enum Permission { public enum Permission {
START("start"), STOP("stop"), RESET("reset"), START("start"), STOP("stop"), RESET("reset"),
ACCESS_CONSOLE("accessConsole"), TAKE_CONSOLE("takeConsole"); ACCESS_CONSOLE("accessConsole");
@SuppressWarnings("PMD.UseConcurrentHashMap") @SuppressWarnings("PMD.UseConcurrentHashMap")
private static Map<String, Permission> reprs = new HashMap<>(); private static Map<String, Permission> reprs = new HashMap<>();
@ -104,11 +88,6 @@ public class VmDefinition extends K8sDynamicModel {
return Set.of(reprs.get(value)); return Set.of(reprs.get(value));
} }
/**
* To string.
*
* @return the string
*/
@Override @Override
public String toString() { public String toString() {
return repr; return repr;
@ -116,51 +95,75 @@ public class VmDefinition extends K8sDynamicModel {
} }
/** /**
* Permissions granted to a user or role. * Gets the kind.
* *
* @param user the user * @return the kind
* @param role the role
* @param may the may
*/ */
public record Grant(String user, String role, Set<Permission> may) { public String getKind() {
return kind;
/**
* 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. * Sets the kind.
* *
* @param pool the pool * @param kind the kind to set
* @param user the user
* @param lastUsed the last used
*/ */
public record Assignment(String pool, String user, Instant lastUsed) { public void setKind(String kind) {
this.kind = kind;
} }
/** /**
* Instantiates a new vm definition. * Gets the api version.
* *
* @param delegate the delegate * @return the apiVersion
* @param json the json
*/ */
public VmDefinition(Gson delegate, JsonObject json) { public String getApiVersion() {
super(delegate, json); return apiVersion;
model = gson.fromJson(json, Model.class); }
/**
* Sets the api version.
*
* @param apiVersion the apiVersion to set
*/
public void setApiVersion(String apiVersion) {
this.apiVersion = apiVersion;
}
/**
* Gets the metadata.
*
* @return the metadata
*/
public V1ObjectMeta getMetadata() {
return metadata;
}
/**
* Gets the metadata.
*
* @return the metadata
*/
public V1ObjectMeta metadata() {
return metadata;
}
/**
* Sets the metadata.
*
* @param metadata the metadata to set
*/
public void setMetadata(V1ObjectMeta metadata) {
this.metadata = metadata;
}
/**
* Gets the spec.
*
* @return the spec
*/
public Map<String, Object> getSpec() {
return spec;
} }
/** /**
@ -169,7 +172,7 @@ public class VmDefinition extends K8sDynamicModel {
* @return the spec * @return the spec
*/ */
public Map<String, Object> spec() { public Map<String, Object> spec() {
return model.getSpec(); return spec;
} }
/** /**
@ -180,17 +183,7 @@ public class VmDefinition extends K8sDynamicModel {
* @return the value, if found * @return the value, if found
*/ */
public <T> Optional<T> fromSpec(Object... selectors) { public <T> Optional<T> fromSpec(Object... selectors) {
return DataPath.get(spec(), 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());
} }
/** /**
@ -201,17 +194,35 @@ public class VmDefinition extends K8sDynamicModel {
* @return the value, if found * @return the value, if found
*/ */
public <T> Optional<T> fromVm(Object... selectors) { public <T> Optional<T> fromVm(Object... selectors) {
return DataPath.get(spec(), "vm") return DataPath.get(spec, "vm")
.flatMap(vm -> DataPath.get(vm, selectors)); .flatMap(vm -> DataPath.get(vm, selectors));
} }
/**
* 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;
}
/** /**
* Gets the status. * Gets the status.
* *
* @return the status * @return the status
*/ */
public Map<String, Object> status() { public Map<String, Object> status() {
return model.getStatus(); return status;
} }
/** /**
@ -222,85 +233,39 @@ public class VmDefinition extends K8sDynamicModel {
* @return the value, if found * @return the value, if found
*/ */
public <T> Optional<T> fromStatus(Object... selectors) { public <T> Optional<T> fromStatus(Object... selectors) {
return DataPath.get(status(), selectors); return DataPath.get(status, selectors);
} }
/** /**
* The assignment information. * Sets the status.
* *
* @return the optional * @param status the status to set
*/ */
public Optional<Assignment> assignment() { public void setStatus(Map<String, Object> status) {
return this.<Map<String, Object>> fromStatus(Status.ASSIGNMENT) this.status = status;
.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. * Set extra data (locally used, unknown to kubernetes).
* *
* @param name the condition's name * @param property the property
* @return the status, if the condition is defined * @param value the value
*/
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 * @return the VM definition
*/ */
/* default */ VmDefinition extra(VmExtraData extraData) { public VmDefinition extra(String property, Object value) {
this.extraData = extraData; extra.put(property, value);
return this; return this;
} }
/** /**
* Return the extra data. * Return extra data.
* *
* @return the data * @param property the property
* @return the object
*/ */
public VmExtraData extra() { @SuppressWarnings("unchecked")
return extraData; public <T> T extra(String property) {
return (T) extra.get(property);
} }
/** /**
@ -309,7 +274,7 @@ public class VmDefinition extends K8sDynamicModel {
* @return the string * @return the string
*/ */
public String name() { public String name() {
return metadata().getName(); return metadata.getName();
} }
/** /**
@ -318,15 +283,16 @@ public class VmDefinition extends K8sDynamicModel {
* @return the string * @return the string
*/ */
public String namespace() { public String namespace() {
return metadata().getNamespace(); return metadata.getNamespace();
} }
/** /**
* Return the requested VM state. * Return the requested VM state
* *
* @return the string * @return the string
*/ */
public RequestedVmState vmState() { public RequestedVmState vmState() {
// TODO
return fromVm("state") return fromVm("state")
.map(s -> "Running".equals(s) ? RequestedVmState.RUNNING .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING
: RequestedVmState.STOPPED) : RequestedVmState.STOPPED)
@ -335,8 +301,6 @@ public class VmDefinition extends K8sDynamicModel {
/** /**
* Collect all permissions for the given user with the given roles. * 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 user the user
* @param roles the roles * @param roles the roles
@ -344,7 +308,7 @@ public class VmDefinition extends K8sDynamicModel {
*/ */
public Set<Permission> permissionsFor(String user, public Set<Permission> permissionsFor(String user,
Collection<String> roles) { Collection<String> roles) {
var result = this.<List<Map<String, Object>>> fromSpec("permissions") return this.<List<Map<String, Object>>> fromSpec("permissions")
.orElse(Collections.emptyList()).stream() .orElse(Collections.emptyList()).stream()
.filter(p -> DataPath.get(p, "user").map(u -> u.equals(user)) .filter(p -> DataPath.get(p, "user").map(u -> u.equals(user))
.orElse(false) .orElse(false)
@ -353,58 +317,7 @@ public class VmDefinition extends K8sDynamicModel {
.orElse(Collections.emptyList()).stream()) .orElse(Collections.emptyList()).stream())
.flatMap(Function.identity()) .flatMap(Function.identity())
.map(Permission::parse).map(Set::stream) .map(Permission::parse).map(Set::stream)
.flatMap(Function.identity()) .flatMap(Function.identity()).collect(Collectors.toSet());
.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));
} }
/** /**
@ -413,87 +326,7 @@ public class VmDefinition extends K8sDynamicModel {
* @return the optional * @return the optional
*/ */
public Optional<Long> displayPasswordSerial() { public Optional<Long> displayPasswordSerial() {
return this.<Number> fromStatus(Status.DISPLAY_PASSWORD_SERIAL) return this.<Number> fromStatus("displayPasswordSerial")
.map(Number::longValue); .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;
}
}
} }

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2025 Michael N. Lipp * Copyright (C) 2024 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -16,30 +16,24 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.jdrupes.vmoperator.runner.qemu.events; package org.jdrupes.vmoperator.common;
import org.jgrapes.core.Event; import com.google.gson.Gson;
import com.google.gson.JsonObject;
/** /**
* Sends the login command to the VM operator agent. * Represents a VM definition.
*/ */
public class VmopAgentLogIn extends Event<Void> { @SuppressWarnings("PMD.DataClass")
public class VmDefinitionModel extends K8sDynamicModel {
private final String user;
/** /**
* Instantiates a new vmop agent logout. * Instantiates a new model from the JSON representation.
*/
public VmopAgentLogIn(String user) {
this.user = user;
}
/**
* Returns the user.
* *
* @return the user * @param delegate the gson instance to use for extracting structured data
* @param json the JSON
*/ */
public String user() { public VmDefinitionModel(Gson delegate, JsonObject json) {
return user; super(delegate, json);
} }
} }

View file

@ -22,10 +22,10 @@ import com.google.gson.Gson;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
/** /**
* Represents a list of {@link VmDefinition}s. * Represents a list of {@link VmDefinitionModel}s.
*/ */
public class VmDefinitions public class VmDefinitionModels
extends K8sDynamicModelsBase<VmDefinition> { extends K8sDynamicModelsBase<VmDefinitionModel> {
/** /**
* Initialize the object list using the given JSON data. * Initialize the object list using the given JSON data.
@ -33,7 +33,7 @@ public class VmDefinitions
* @param delegate the gson instance to use for extracting structured data * @param delegate the gson instance to use for extracting structured data
* @param data the data * @param data the data
*/ */
public VmDefinitions(Gson delegate, JsonObject data) { public VmDefinitionModels(Gson delegate, JsonObject data) {
super(VmDefinition.class, delegate, data); super(VmDefinitionModel.class, delegate, data);
} }
} }

View file

@ -31,11 +31,12 @@ import java.util.Collection;
* state and can therefore be used for any kind of object, especially * state and can therefore be used for any kind of object, especially
* custom objects. * custom objects.
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class VmDefinitionStub public class VmDefinitionStub
extends K8sDynamicStubBase<VmDefinition, VmDefinitions> { extends K8sDynamicStubBase<VmDefinitionModel, VmDefinitionModels> {
private static DynamicTypeAdapterFactory<VmDefinition, private static DynamicTypeAdapterFactory<VmDefinitionModel,
VmDefinitions> taf = new VmDefintionModelTypeAdapterFactory(); VmDefinitionModels> taf = new VmDefintionModelTypeAdapterFactory();
/** /**
* Instantiates a new stub for VM defintions. * Instantiates a new stub for VM defintions.
@ -47,7 +48,7 @@ public class VmDefinitionStub
*/ */
public VmDefinitionStub(K8sClient client, APIResource context, public VmDefinitionStub(K8sClient client, APIResource context,
String namespace, String name) { String namespace, String name) {
super(VmDefinition.class, VmDefinitions.class, taf, client, super(VmDefinitionModel.class, VmDefinitionModels.class, taf, client,
context, namespace, name); context, namespace, name);
} }
@ -63,6 +64,8 @@ public class VmDefinitionStub
* @return the stub if the object exists * @return the stub if the object exists
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
public static VmDefinitionStub get(K8sClient client, public static VmDefinitionStub get(K8sClient client,
GroupVersionKind gvk, String namespace, String name) GroupVersionKind gvk, String namespace, String name)
throws ApiException { throws ApiException {
@ -80,6 +83,8 @@ public class VmDefinitionStub
* @return the stub if the object exists * @return the stub if the object exists
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "PMD.AvoidBranchingStatementAsLastInLoop",
"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseObjectForClearerAPI" })
public static VmDefinitionStub get(K8sClient client, public static VmDefinitionStub get(K8sClient client,
APIResource context, String namespace, String name) { APIResource context, String namespace, String name) {
return new VmDefinitionStub(client, context, namespace, name); return new VmDefinitionStub(client, context, namespace, name);
@ -96,10 +101,10 @@ public class VmDefinitionStub
*/ */
public static VmDefinitionStub createFromYaml(K8sClient client, public static VmDefinitionStub createFromYaml(K8sClient client,
APIResource context, Reader yaml) throws ApiException { APIResource context, Reader yaml) throws ApiException {
var model = new VmDefinition(client.getJSON().getGson(), var model = new VmDefinitionModel(client.getJSON().getGson(),
K8s.yamlToJson(client, yaml)); K8s.yamlToJson(client, yaml));
return K8sGenericStub.create(VmDefinition.class, return K8sGenericStub.create(VmDefinitionModel.class,
VmDefinitions.class, client, context, model, VmDefinitionModels.class, client, context, model,
(c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); (c, ns, n) -> new VmDefinitionStub(c, context, ns, n));
} }
@ -116,8 +121,8 @@ public class VmDefinitionStub
public static Collection<VmDefinitionStub> list(K8sClient client, public static Collection<VmDefinitionStub> list(K8sClient client,
APIResource context, String namespace, ListOptions options) APIResource context, String namespace, ListOptions options)
throws ApiException { throws ApiException {
return K8sGenericStub.list(VmDefinition.class, return K8sGenericStub.list(VmDefinitionModel.class,
VmDefinitions.class, client, context, namespace, options, VmDefinitionModels.class, client, context, namespace, options,
(c, ns, n) -> new VmDefinitionStub(c, context, ns, n)); (c, ns, n) -> new VmDefinitionStub(c, context, ns, n));
} }
@ -139,13 +144,13 @@ public class VmDefinitionStub
* A factory for creating VmDefinitionModel(s) objects. * A factory for creating VmDefinitionModel(s) objects.
*/ */
public static class VmDefintionModelTypeAdapterFactory extends public static class VmDefintionModelTypeAdapterFactory extends
DynamicTypeAdapterFactory<VmDefinition, VmDefinitions> { DynamicTypeAdapterFactory<VmDefinitionModel, VmDefinitionModels> {
/** /**
* Instantiates a new dynamic model type adapter factory. * Instantiates a new dynamic model type adapter factory.
*/ */
public VmDefintionModelTypeAdapterFactory() { public VmDefintionModelTypeAdapterFactory() {
super(VmDefinition.class, VmDefinitions.class); super(VmDefinitionModel.class, VmDefinitionModels.class);
} }
} }

View file

@ -1,179 +0,0 @@
/*
* 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());
}
}

View file

@ -1,226 +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 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();
}
}

View file

@ -10,4 +10,5 @@ plugins {
dependencies { dependencies {
api project(':org.jdrupes.vmoperator.common') api project(':org.jdrupes.vmoperator.common')
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.16.1,3]'
} }

View file

@ -1,60 +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.manager.events;
import org.jdrupes.vmoperator.manager.events.GetVms.VmData;
import org.jgrapes.core.Event;
/**
* Assign a VM from a pool to a user.
*/
public class AssignVm extends Event<VmData> {
private final String fromPool;
private final String toUser;
/**
* Instantiates a new event.
*
* @param fromPool the from pool
* @param toUser the to user
*/
public AssignVm(String fromPool, String toUser) {
this.fromPool = fromPool;
this.toUser = toUser;
}
/**
* Gets the pool to assign from.
*
* @return the pool
*/
public String fromPool() {
return fromPool;
}
/**
* Gets the user to assign to.
*
* @return the to user
*/
public String toUser() {
return toUser;
}
}

View file

@ -43,6 +43,7 @@ public interface ChannelDictionary<K, C extends Channel, A> {
* @param channel the channel * @param channel the channel
* @param associated the associated * @param associated the associated
*/ */
@SuppressWarnings("PMD.ShortClassName")
public record Value<C extends Channel, A>(C channel, A associated) { public record Value<C extends Channel, A>(C channel, A associated) {
} }

View file

@ -62,11 +62,6 @@ public class ChannelManager<K, C extends Channel, A>
this(k -> null); this(k -> null);
} }
/**
* Return all keys.
*
* @return the keys.
*/
@Override @Override
public Set<K> keys() { public Set<K> keys() {
return entries.keySet(); return entries.keySet();
@ -118,18 +113,6 @@ public class ChannelManager<K, C extends Channel, A>
return this; 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 * Returns the {@link Channel} for the given name, creating it using
* the supplier passed to the constructor if it doesn't exist yet. * the supplier passed to the constructor if it doesn't exist yet.
@ -149,6 +132,8 @@ public class ChannelManager<K, C extends Channel, A>
* @param supplier the supplier * @param supplier the supplier
* @return the channel * @return the channel
*/ */
@SuppressWarnings({ "PMD.AssignmentInOperand",
"PMD.DataflowAnomalyAnalysis" })
public C computeIfAbsent(K key, Function<K, C> supplier) { public C computeIfAbsent(K key, Function<K, C> supplier) {
return entries.computeIfAbsent(key, return entries.computeIfAbsent(key,
k -> new Value<>(supplier.apply(k), null)).channel(); k -> new Value<>(supplier.apply(k), null)).channel();

View file

@ -18,35 +18,34 @@
package org.jdrupes.vmoperator.manager.events; package org.jdrupes.vmoperator.manager.events;
import java.util.Optional;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jgrapes.core.Event; import org.jgrapes.core.Event;
/** /**
* Gets the current display secret and optionally updates it. * Gets the current display secret and optionally updates it.
*/ */
public class GetDisplaySecret extends Event<String> { @SuppressWarnings("PMD.DataClass")
public class GetDisplayPassword extends Event<String> {
private final VmDefinition vmDef; private final VmDefinition vmDef;
private final String user; private final String user;
/** /**
* Instantiates a new request for the display secret. * 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 vmDef the vm name
* @param user the requesting user * @param user the requesting user
*/ */
public GetDisplaySecret(VmDefinition vmDef, String user) { public GetDisplayPassword(VmDefinition vmDef, String user) {
this.vmDef = vmDef; this.vmDef = vmDef;
this.user = user; this.user = user;
} }
/** /**
* Gets the VM definition. * Gets the vm definition.
* *
* @return the VM definition * @return the vm definition
*/ */
public VmDefinition vmDefinition() { public VmDefinition vmDefinition() {
return vmDef; return vmDef;
@ -62,31 +61,14 @@ public class GetDisplaySecret extends Event<String> {
} }
/** /**
* Returns `true` if a password is available. May only be called * Return the password. May only be called when the event is completed.
* 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 * @return the optional
*/ */
public boolean secretAvailable() { public Optional<String> password() {
if (!isDone()) { if (!isDone()) {
throw new IllegalStateException("Event is not done."); throw new IllegalStateException("Event is not done.");
} }
return !currentResults().isEmpty(); return currentResults().stream().findFirst();
}
/**
* 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);
} }
} }

View file

@ -1,87 +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.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;
}
}

View file

@ -1,138 +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.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) {
}
}

View file

@ -24,6 +24,7 @@ import org.jgrapes.core.Event;
/** /**
* Modifies a VM. * Modifies a VM.
*/ */
@SuppressWarnings("PMD.DataClass")
public class ModifyVm extends Event<Void> { public class ModifyVm extends Event<Void> {
private final String name; private final String name;

View file

@ -1,75 +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.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();
}
}

View file

@ -23,6 +23,7 @@ import org.jgrapes.core.Event;
/** /**
* Triggers a reset of the VM. * Triggers a reset of the VM.
*/ */
@SuppressWarnings("PMD.DataClass")
public class ResetVm extends Event<String> { public class ResetVm extends Event<String> {
private final String vmName; private final String vmName;

View file

@ -1,60 +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.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;
}
}

View file

@ -21,13 +21,13 @@ package org.jdrupes.vmoperator.manager.events;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jgrapes.core.Channel; import org.jgrapes.core.Channel;
import org.jgrapes.core.Event;
import org.jgrapes.core.EventPipeline; import org.jgrapes.core.EventPipeline;
import org.jgrapes.core.Subchannel.DefaultSubchannel; import org.jgrapes.core.Subchannel.DefaultSubchannel;
/** /**
* A subchannel used to send the events related to a specific VM. * A subchannel used to send the events related to a specific VM.
*/ */
@SuppressWarnings("PMD.DataClass")
public class VmChannel extends DefaultSubchannel { public class VmChannel extends DefaultSubchannel {
private final EventPipeline pipeline; private final EventPipeline pipeline;
@ -55,6 +55,7 @@ public class VmChannel extends DefaultSubchannel {
* @param definition the definition * @param definition the definition
* @return the watch channel * @return the watch channel
*/ */
@SuppressWarnings("PMD.LinguisticNaming")
public VmChannel setVmDefinition(VmDefinition definition) { public VmChannel setVmDefinition(VmDefinition definition) {
this.definition = definition; this.definition = definition;
return this; return this;
@ -85,6 +86,7 @@ public class VmChannel extends DefaultSubchannel {
* @param generation the generation to set * @param generation the generation to set
* @return true if value has changed * @return true if value has changed
*/ */
@SuppressWarnings("PMD.LinguisticNaming")
public boolean setGeneration(long generation) { public boolean setGeneration(long generation) {
if (this.generation == generation) { if (this.generation == generation) {
return false; return false;
@ -102,19 +104,6 @@ public class VmChannel extends DefaultSubchannel {
return pipeline; 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. * Returns the API client.
* *

View file

@ -25,35 +25,31 @@ import org.jgrapes.core.Components;
import org.jgrapes.core.Event; import org.jgrapes.core.Event;
/** /**
* Indicates a change in a VM "resource". Note that the resource * Indicates a change in a VM definition. Note that the definition
* combines the VM CR's metadata (mostly immutable), the VM CR's * consists of the metadata (mostly immutable), the "spec" and the
* "spec" part, the VM CR's "status" subresource and state information * "status" parts. Consumers that are only interested in "spec"
* from the pod. Consumers that are only interested in "spec" changes * changes should check {@link #specChanged()} before processing
* should check {@link #specChanged()} before processing the event any * the event any further.
* further.
*/ */
@SuppressWarnings("PMD.DataClass") @SuppressWarnings("PMD.DataClass")
public class VmResourceChanged extends Event<Void> { public class VmDefChanged extends Event<Void> {
private final K8sObserver.ResponseType type; private final K8sObserver.ResponseType type;
private final VmDefinition vmDefinition;
private final boolean specChanged; private final boolean specChanged;
private final boolean podChanged; private final VmDefinition vmDefinition;
/** /**
* Instantiates a new VM changed event. * Instantiates a new VM changed event.
* *
* @param type the type * @param type the type
* @param vmDefinition the VM definition
* @param specChanged the spec part changed * @param specChanged the spec part changed
* @param vmDefinition the VM definition
*/ */
public VmResourceChanged(K8sObserver.ResponseType type, public VmDefChanged(K8sObserver.ResponseType type, boolean specChanged,
VmDefinition vmDefinition, boolean specChanged, VmDefinition vmDefinition) {
boolean podChanged) {
this.type = type; this.type = type;
this.vmDefinition = vmDefinition;
this.specChanged = specChanged; this.specChanged = specChanged;
this.podChanged = podChanged; this.vmDefinition = vmDefinition;
} }
/** /**
@ -65,15 +61,6 @@ public class VmResourceChanged extends Event<Void> {
return type; return type;
} }
/**
* Return the VM definition.
*
* @return the VM definition
*/
public VmDefinition vmDefinition() {
return vmDefinition;
}
/** /**
* Indicates if the "spec" part changed. * Indicates if the "spec" part changed.
*/ */
@ -82,10 +69,12 @@ public class VmResourceChanged extends Event<Void> {
} }
/** /**
* Indicates if the pod status changed. * Return the VM definition.
*
* @return the VM definition
*/ */
public boolean podChanged() { public VmDefinition vmDefinition() {
return podChanged; return vmDefinition;
} }
@Override @Override

View file

@ -1,87 +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.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();
}
}

View file

@ -1 +0,0 @@
/logging.properties

View file

@ -17,7 +17,7 @@ dependencies {
implementation 'org.jgrapes:org.jgrapes.io:[2.12.1,3)' 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.http:[3.5.0,4)'
implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.3.0,3)' implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)'
implementation 'org.jgrapes:org.jgrapes.webconsole.vuejs:[1.8.0,2)' 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.webconsole.rbac:[1.4.0,2)'
implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.0,2)' implementation 'org.jgrapes:org.jgrapes.webconlet.oidclogin:[1.7.0,2)'
@ -31,8 +31,8 @@ dependencies {
runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)' runtimeOnly 'org.slf4j:slf4j-jdk14:[2.0.7,3)'
runtimeOnly 'org.apache.logging.log4j:log4j-to-jul:2.20.0' runtimeOnly 'org.apache.logging.log4j:log4j-to-jul:2.20.0'
runtimeOnly project(':org.jdrupes.vmoperator.vmmgmt') runtimeOnly project(':org.jdrupes.vmoperator.vmconlet')
runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') runtimeOnly project(':org.jdrupes.vmoperator.vmviewer')
} }
application { application {

View file

@ -1,3 +1,3 @@
<footer> <footer>
Copyright &copy; Michael N. Lipp 2023, 2025 Copyright &copy; Michael N. Lipp 2023
</footer> </footer>

View file

@ -1,6 +1,6 @@
# #
# VM-Operator # VM-Operator
# Copyright (C) 2025 Michael N. Lipp # Copyright (C) 2023 Michael N. Lipp
# #
# This program is free software; you can redistribute it and/or modify it # 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 # under the terms of the GNU General Public License as published by
@ -19,7 +19,10 @@
handlers=java.util.logging.ConsoleHandler, \ handlers=java.util.logging.ConsoleHandler, \
org.jgrapes.webconlet.logviewer.LogViewerHandler org.jgrapes.webconlet.logviewer.LogViewerHandler
org.jdrupes.vmoperator.level=FINE org.jgrapes.level=FINE
org.jgrapes.core.handlerTracking.level=FINER
org.jdrupes.vmoperator.manager.level=FINE
java.util.logging.ConsoleHandler.level=ALL java.util.logging.ConsoleHandler.level=ALL
java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter

View file

@ -10,8 +10,8 @@ metadata:
annotations: annotations:
vmoperator.jdrupes.org/version: ${ managerVersion } vmoperator.jdrupes.org/version: ${ managerVersion }
ownerReferences: ownerReferences:
- apiVersion: ${ cr.apiVersion() } - apiVersion: ${ cr.apiVersion }
kind: ${ constants.Crd.KIND_VM } kind: ${ constants.VM_OP_KIND_VM }
name: ${ cr.name() } name: ${ cr.name() }
uid: ${ cr.metadata().getUid() } uid: ${ cr.metadata().getUid() }
controller: false controller: false
@ -37,7 +37,7 @@ data:
# The template to use. Resolved relative to /usr/share/vmrunner/templates. # The template to use. Resolved relative to /usr/share/vmrunner/templates.
# template: "Standard-VM-latest.ftl.yaml" # template: "Standard-VM-latest.ftl.yaml"
<#if spec.runnerTemplate?? && spec.runnerTemplate.source?? > <#if spec.runnerTemplate?? && spec.runnerTemplate.source?? >
template: ${ spec.runnerTemplate.source } template: ${ cm.spec().runnerTemplate.source }
</#if> </#if>
# The template is copied to the data diretory when the VM starts for # The template is copied to the data diretory when the VM starts for
@ -53,12 +53,16 @@ data:
# i.e. if you start the VM without a value for this property, and # 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 # decide to trigger a reset later, you have to first set the value
# and then inrement it. # and then inrement it.
resetCounter: ${ cr.extra().resetCount()?c } resetCounter: ${ cr.extra("resetCount")?c }
# Forward the cloud-init data if provided # Forward the cloud-init data if provided
<#if spec.cloudInit??> <#if spec.cloudInit??>
cloudInit: cloudInit:
metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData!{}, cr.metadata())) } <#if spec.cloudInit.metaData??>
metaData: ${ toJson(adjustCloudInitMeta(spec.cloudInit.metaData, cr.metadata())) }
<#else>
metaData: {}
</#if>
<#if spec.cloudInit.userData??> <#if spec.cloudInit.userData??>
userData: ${ toJson(spec.cloudInit.userData) } userData: ${ toJson(spec.cloudInit.userData) }
<#else> <#else>
@ -201,9 +205,6 @@ data:
<#if spec.vm.display.outputs?? > <#if spec.vm.display.outputs?? >
outputs: ${ spec.vm.display.outputs?c } outputs: ${ spec.vm.display.outputs?c }
</#if> </#if>
<#if loginRequestedFor?? >
loggedInUser: "${ loginRequestedFor }"
</#if>
<#if spec.vm.display.spice??> <#if spec.vm.display.spice??>
spice: spice:
port: ${ spec.vm.display.spice.port?c } port: ${ spec.vm.display.spice.port?c }

View file

@ -10,8 +10,8 @@ metadata:
annotations: annotations:
vmoperator.jdrupes.org/version: ${ managerVersion } vmoperator.jdrupes.org/version: ${ managerVersion }
ownerReferences: ownerReferences:
- apiVersion: ${ cr.apiVersion() } - apiVersion: ${ cr.apiVersion }
kind: ${ constants.Crd.KIND_VM } kind: ${ constants.VM_OP_KIND_VM }
name: ${ cr.name() } name: ${ cr.name() }
uid: ${ cr.metadata().getUid() } uid: ${ cr.metadata().getUid() }
controller: false controller: false

View file

@ -11,11 +11,11 @@ metadata:
annotations: annotations:
# Triggers update of config map mounted in pod # Triggers update of config map mounted in pod
# See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ # See https://ahmet.im/blog/kubernetes-secret-volumes-delay/
vmrunner.jdrupes.org/cmVersion: "${ configMapResourceVersion }" vmrunner.jdrupes.org/cmVersion: "${ cm.metadata.resourceVersion }"
vmoperator.jdrupes.org/version: ${ managerVersion } vmoperator.jdrupes.org/version: ${ managerVersion }
ownerReferences: ownerReferences:
- apiVersion: ${ cr.apiVersion() } - apiVersion: ${ cr.apiVersion }
kind: ${ constants.Crd.KIND_VM } kind: ${ constants.VM_OP_KIND_VM }
name: ${ cr.name() } name: ${ cr.name() }
uid: ${ cr.metadata().getUid() } uid: ${ cr.metadata().getUid() }
blockOwnerDeletion: true blockOwnerDeletion: true

View file

@ -51,6 +51,7 @@ import org.jgrapes.util.events.ConfigurationUpdate;
* @param <O> the object type for the context * @param <O> the object type for the context
* @param <L> the object list type for the context * @param <L> the object list type for the context
*/ */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis" })
public abstract class AbstractMonitor<O extends KubernetesObject, public abstract class AbstractMonitor<O extends KubernetesObject,
L extends KubernetesListObject, C extends Channel> extends Component { L extends KubernetesListObject, C extends Channel> extends Component {
@ -180,6 +181,7 @@ public abstract class AbstractMonitor<O extends KubernetesObject,
* @param event the event * @param event the event
*/ */
@Handler(priority = 10) @Handler(priority = 10)
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
public void onStart(Start event) { public void onStart(Start event) {
try { try {
// Get namespace // Get namespace
@ -197,6 +199,8 @@ public abstract class AbstractMonitor<O extends KubernetesObject,
assert client != null; assert client != null;
assert context != null; assert context != null;
assert namespace != null; assert namespace != null;
logger.fine(() -> "Observing " + K8s.toString(context)
+ " objects in " + namespace);
// Monitor all versions // Monitor all versions
for (var version : context.getVersions()) { for (var version : context.getVersions()) {
@ -215,7 +219,9 @@ public abstract class AbstractMonitor<O extends KubernetesObject,
observerCounter.incrementAndGet(); observerCounter.incrementAndGet();
new K8sObserver<>(objectClass, objectListClass, client, new K8sObserver<>(objectClass, objectListClass, client,
K8s.preferred(context, version), namespace, options) K8s.preferred(context, version), namespace, options)
.handler(this::handleChange).onTerminated((o, t) -> { .handler((c, r) -> {
handleChange(c, r);
}).onTerminated((o, t) -> {
if (observerCounter.decrementAndGet() == 0) { if (observerCounter.decrementAndGet() == 0) {
unregisterAsGenerator(); unregisterAsGenerator();
} }
@ -240,9 +246,7 @@ public abstract class AbstractMonitor<O extends KubernetesObject,
} }
/** /**
* Handle an observed change. The method is invoked by the observer * Handle an observed change.
* thread(s). It is the responsibility of the implementing class to
* fire derived events on the appropriate event pipeline.
* *
* @param client the client * @param client the client
* @param change the change * @param change the change

View file

@ -19,17 +19,11 @@
package org.jdrupes.vmoperator.manager; package org.jdrupes.vmoperator.manager;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import freemarker.template.AdapterTemplateModel;
import freemarker.template.Configuration; import freemarker.template.Configuration;
import freemarker.template.TemplateException; 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.custom.V1Patch;
import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.openapi.ApiException; 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.DynamicKubernetesApi;
import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
import io.kubernetes.client.util.generic.dynamic.Dynamics; import io.kubernetes.client.util.generic.dynamic.Dynamics;
@ -37,11 +31,7 @@ import io.kubernetes.client.util.generic.options.ListOptions;
import io.kubernetes.client.util.generic.options.PatchOptions; import io.kubernetes.client.util.generic.options.PatchOptions;
import java.io.IOException; import java.io.IOException;
import java.io.StringWriter; import java.io.StringWriter;
import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8s;
import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
@ -56,6 +46,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Delegee for reconciling the config map * Delegee for reconciling the config map
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
/* default */ class ConfigMapReconciler { /* default */ class ConfigMapReconciler {
protected final Logger logger = Logger.getLogger(getClass().getName()); protected final Logger logger = Logger.getLogger(getClass().getName());
@ -75,70 +66,48 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
* *
* @param model the model * @param model the model
* @param channel the channel * @param channel the channel
* @param modelChanged the model has changed * @return the dynamic kubernetes object
* @throws IOException Signals that an I/O exception has occurred. * @throws IOException Signals that an I/O exception has occurred.
* @throws TemplateException the template exception * @throws TemplateException the template exception
* @throws ApiException the API exception * @throws ApiException the api exception
*/ */
public void reconcile(Map<String, Object> model, VmChannel channel, public Map<String, Object> reconcile(Map<String, Object> model,
boolean modelChanged) VmChannel channel)
throws IOException, TemplateException, ApiException { throws IOException, TemplateException, ApiException {
// Check if an update is needed
var prevData = channel.associated(PrevData.class)
.orElseGet(() -> new PrevData(null, new HashMap<>()));
Object newInputs = model.get("loginRequestedFor");
if (!modelChanged && Objects.equals(prevData.inputs, newInputs)) {
// Make added data available in new model
model.putAll(prevData.added);
return;
}
prevData = new PrevData(newInputs, prevData.added);
channel.setAssociated(PrevData.class, prevData);
// Combine template and data and parse result // 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"); var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml");
StringWriter out = new StringWriter(); StringWriter out = new StringWriter();
fmTemplate.process(model, out); fmTemplate.process(model, out);
// Avoid Yaml.load due to // Avoid Yaml.load due to
// https://github.com/kubernetes-client/java/issues/2741 // https://github.com/kubernetes-client/java/issues/2741
var newCm = Dynamics.newFromYaml( var mapDef = Dynamics.newFromYaml(
new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
// Maybe override logging.properties from reconciler configuration. // Maybe override logging.properties from reconciler configuration.
DataPath.<String> get(model, "reconciler", "loggingProperties") DataPath.<String> get(model, "reconciler", "loggingProperties")
.ifPresent(props -> { .ifPresent(props -> {
GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data")
.get().addProperty("logging.properties", props); .get().addProperty("logging.properties", props);
}); });
// Maybe override logging.properties from VM definition. // Maybe override logging.properties from VM definition.
DataPath.<String> get(model, "cr", "spec", "loggingProperties") DataPath.<String> get(model, "cr", "spec", "loggingProperties")
.ifPresent(props -> { .ifPresent(props -> {
GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data")
.get().addProperty("logging.properties", props); .get().addProperty("logging.properties", props);
}); });
// Get API and update // Get API
DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1",
"configmaps", channel.client()); "configmaps", channel.client());
// Apply and maybe force pod update // Apply and maybe force pod update
var updatedCm = K8s.apply(cmApi, newCm, newCm.getRaw().toString()); var newState = K8s.apply(cmApi, mapDef, mapDef.getRaw().toString());
maybeForceUpdate(channel.client(), updatedCm); maybeForceUpdate(channel.client(), newState);
model.put("configMapResourceVersion", @SuppressWarnings("unchecked")
updatedCm.getMetadata().getResourceVersion()); var res = (Map<String, Object>) channel.client().getJSON().getGson()
prevData.added.put("configMapResourceVersion", .fromJson(newState.getRaw(), Map.class);
updatedCm.getMetadata().getResourceVersion()); return res;
}
/**
* Key for association.
*/
private record PrevData(Object inputs, Map<String, Object> added) {
} }
/** /**
@ -184,27 +153,4 @@ 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;
}
};
} }

View file

@ -21,8 +21,18 @@ package org.jdrupes.vmoperator.manager;
/** /**
* Some constants. * Some constants.
*/ */
@SuppressWarnings("PMD.DataClass")
public class Constants extends org.jdrupes.vmoperator.common.Constants { 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. */ /** The Constant STATE_RUNNING. */
public static final String STATE_RUNNING = "Running"; public static final String STATE_RUNNING = "Running";

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2023, 2025 Michael N. Lipp * Copyright (C) 2023 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -18,39 +18,25 @@
package org.jdrupes.vmoperator.manager; package org.jdrupes.vmoperator.manager;
import com.google.gson.JsonObject;
import io.kubernetes.client.apimachinery.GroupVersionKind; import io.kubernetes.client.apimachinery.GroupVersionKind;
import io.kubernetes.client.custom.V1Patch;
import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.Configuration; import io.kubernetes.client.openapi.Configuration;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant;
import java.util.Comparator;
import java.util.Optional;
import java.util.logging.Level; import java.util.logging.Level;
import org.jdrupes.vmoperator.common.Constants.Crd; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import org.jdrupes.vmoperator.common.Constants.Status; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.K8sDynamicStub;
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.ChannelManager;
import org.jdrupes.vmoperator.manager.events.Exit; 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.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.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmPoolChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.manager.events.VmResourceChanged;
import org.jgrapes.core.Channel; import org.jgrapes.core.Channel;
import org.jgrapes.core.Component; import org.jgrapes.core.Component;
import org.jgrapes.core.EventPipeline;
import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.annotation.Handler;
import org.jgrapes.core.events.HandlingError; import org.jgrapes.core.events.HandlingError;
import org.jgrapes.core.events.Start; import org.jgrapes.core.events.Start;
@ -62,7 +48,7 @@ import org.jgrapes.util.events.ConfigurationUpdate;
* *
* The implementation splits the controller in two components. The * The implementation splits the controller in two components. The
* {@link VmMonitor} and the {@link Reconciler}. The former watches * {@link VmMonitor} and the {@link Reconciler}. The former watches
* the VM definitions (CRs) and generates {@link VmResourceChanged} events * the VM definitions (CRs) and generates {@link VmDefChanged} events
* when they change. The latter handles the changes and reconciles the * when they change. The latter handles the changes and reconciles the
* resources in the cluster. * resources in the cluster.
* *
@ -95,7 +81,6 @@ import org.jgrapes.util.events.ConfigurationUpdate;
public class Controller extends Component { public class Controller extends Component {
private String namespace; private String namespace;
private final ChannelManager<String, VmChannel, EventPipeline> chanMgr;
/** /**
* Creates a new instance. * Creates a new instance.
@ -104,24 +89,23 @@ public class Controller extends Component {
public Controller(Channel componentChannel) { public Controller(Channel componentChannel) {
super(componentChannel); super(componentChannel);
// Prepare component tree // Prepare component tree
chanMgr = new ChannelManager<>(name -> { ChannelManager<String, VmChannel, ?> chanMgr
try { = new ChannelManager<>(name -> {
return new VmChannel(channel(), newEventPipeline(), try {
new K8sClient()); return new VmChannel(channel(), newEventPipeline(),
} catch (IOException e) { new K8sClient());
logger.log(Level.SEVERE, e, () -> "Failed to create client" } catch (IOException e) {
+ " for handling changes: " + e.getMessage()); logger.log(Level.SEVERE, e, () -> "Failed to create client"
return null; + " for handling changes: " + e.getMessage());
} return null;
}); }
});
attach(new VmMonitor(channel(), chanMgr)); attach(new VmMonitor(channel(), chanMgr));
attach(new DisplaySecretMonitor(channel(), chanMgr)); attach(new DisplaySecretMonitor(channel(), chanMgr));
// Currently, we don't use the IP assigned by the load balancer // Currently, we don't use the IP assigned by the load balancer
// to access the VM's console. Might change in the future. // to access the VM's console. Might change in the future.
// attach(new ServiceMonitor(channel()).channelManager(chanMgr)); // attach(new ServiceMonitor(channel()).channelManager(chanMgr));
attach(new Reconciler(channel())); attach(new Reconciler(channel()));
attach(new PoolMonitor(channel()));
attach(new PodMonitor(channel(), chanMgr));
} }
/** /**
@ -183,146 +167,40 @@ public class Controller extends Component {
fire(new Exit(2)); fire(new Exit(2));
return; return;
} }
logger.config(() -> "Controlling namespace \"" + namespace + "\"."); logger.fine(() -> "Controlling namespace \"" + namespace + "\".");
} }
/** /**
* Returns the VM data. * On modify vm.
*
* @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 * @param event the event
* @throws ApiException the api exception * @throws ApiException the api exception
* @throws InterruptedException * @throws IOException Signals that an I/O exception has occurred.
*/ */
@Handler @Handler
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") public void onModifyVm(ModifyVm event, VmChannel channel)
public void onAssignVm(AssignVm event) throws ApiException, IOException {
throws ApiException, InterruptedException { patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(),
while (true) { event.value());
// Search for existing assignment.
var vmQuery = chanMgr.channels().stream()
.filter(c -> c.vmDefinition().assignment().map(Assignment::pool)
.map(p -> p.equals(event.fromPool())).orElse(false))
.filter(c -> c.vmDefinition().assignment().map(Assignment::user)
.map(u -> u.equals(event.toUser())).orElse(false))
.findFirst();
if (vmQuery.isPresent()) {
var vmDef = vmQuery.get().vmDefinition();
event.setResult(new VmData(vmDef, vmQuery.get()));
return;
}
// Get the pool definition for checking possible assignment
VmPool vmPool = newEventPipeline().fire(new GetPools()
.withName(event.fromPool())).get().stream().findFirst()
.orElse(null);
if (vmPool == null) {
return;
}
// Find available VM.
vmQuery = chanMgr.channels().stream()
.filter(c -> vmPool.isAssignable(c.vmDefinition()))
.sorted(Comparator.comparing((VmChannel c) -> c.vmDefinition()
.assignment().map(Assignment::lastUsed)
.orElse(Instant.ofEpochSecond(0)))
.thenComparing(preferRunning))
.findFirst();
// None found
if (vmQuery.isEmpty()) {
return;
}
// Assign to user
var chosenVm = vmQuery.get();
if (Optional.ofNullable(chosenVm.fire(new UpdateAssignment(
vmPool, event.toUser())).get()).orElse(false)) {
var vmDef = chosenVm.vmDefinition();
event.setResult(new VmData(vmDef, chosenVm));
// Make sure that a newly assigned VM is running.
chosenVm.fire(new ModifyVm(vmDef.name(), "state", "Running"));
return;
}
}
} }
private static Comparator<VmChannel> preferRunning private void patchVmDef(K8sClient client, String name, String path,
= new Comparator<>() { Object value) throws ApiException, IOException {
@Override var vmStub = K8sDynamicStub.get(client,
public int compare(VmChannel ch1, VmChannel ch2) { new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace,
if (ch1.vmDefinition().conditionStatus("Running").orElse(false) name);
&& !ch2.vmDefinition().conditionStatus("Running")
.orElse(false)) {
return -1;
}
return 0;
}
};
/** // Patch running
* When s pool is deleted, remove all related assignments. String valueAsText = value instanceof String
* ? "\"" + value + "\""
* @param event the event : value.toString();
* @throws InterruptedException var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
*/ new V1Patch("[{\"op\": \"replace\", \"path\": \"/"
@Handler + path + "\", \"value\": " + valueAsText + "}]"),
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") client.defaultPatchOptions());
public void onPoolChanged(VmPoolChanged event) throws InterruptedException { if (!res.isPresent()) {
if (!event.deleted()) { logger.warning(
return; () -> "Cannot patch definition for Vm " + vmStub.name());
}
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;
});
} }
} }
} }

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2025 Michael N. Lipp * Copyright (C) 2024 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -18,6 +18,8 @@
package org.jdrupes.vmoperator.manager; 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.custom.V1Patch;
import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.openapi.models.V1Secret;
@ -26,25 +28,52 @@ import io.kubernetes.client.util.Watch.Response;
import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.ListOptions;
import io.kubernetes.client.util.generic.options.PatchOptions; import io.kubernetes.client.util.generic.options.PatchOptions;
import java.io.IOException; 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 java.util.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME; 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_GROUP;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sV1PodStub; import org.jdrupes.vmoperator.common.K8sV1PodStub;
import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import org.jdrupes.vmoperator.common.VmDefinitionStub;
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.ChannelDictionary;
import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jgrapes.core.Channel; 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. Updates an artifical attribute * Watches for changes of display secrets. The component supports the
* of the pod running the VM in response to force an update of the files * following configuration properties:
* in the pod that reflect the information from the secret. *
* * `passwordValidity`: the validity of the random password in seconds.
* Used to calculate the password expiry time in the generated secret.
*/ */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
public class DisplaySecretMonitor public class DisplaySecretMonitor
extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> { extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
private int passwordValidity = 10;
private final List<PendingGet> pendingGets
= Collections.synchronizedList(new LinkedList<>());
private final ChannelDictionary<String, VmChannel, ?> channelDictionary; private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
/** /**
@ -60,10 +89,31 @@ public class DisplaySecretMonitor
context(K8sV1SecretStub.CONTEXT); context(K8sV1SecretStub.CONTEXT);
ListOptions options = new ListOptions(); ListOptions options = new ListOptions();
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/component=" + DisplaySecret.NAME); + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
options(options); 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 @Override
protected void prepareMonitoring() throws IOException, ApiException { protected void prepareMonitoring() throws IOException, ApiException {
client(new K8sClient()); client(new K8sClient());
@ -118,4 +168,147 @@ public class DisplaySecretMonitor
+ "\"}]"), + "\"}]"),
patchOpts); 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 {
// Update console user in status
var vmStub = VmDefinitionStub.get(client(),
new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
event.vmDefinition().namespace(), event.vmDefinition().name());
vmStub.updateStatus(from -> {
JsonObject status = from.status();
status.addProperty("consoleUser", event.user());
return status;
});
// Look for secret
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().namespace(), options);
if (stubs.isEmpty()) {
// No secret means no password for this VM wanted
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)) {
// Fixed secret, don't touch
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().name();
for (var pending : pendingGets) {
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.
*/
@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;
}
}
} }

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2025 Michael N. Lipp * Copyright (C) 2023 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -18,9 +18,7 @@
package org.jdrupes.vmoperator.manager; package org.jdrupes.vmoperator.manager;
import com.google.gson.JsonObject;
import freemarker.template.TemplateException; import freemarker.template.TemplateException;
import io.kubernetes.client.apimachinery.GroupVersionKind;
import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.openapi.models.V1Secret;
@ -28,127 +26,51 @@ import io.kubernetes.client.util.generic.options.ListOptions;
import java.io.IOException; import java.io.IOException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; 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.Map;
import java.util.Optional;
import java.util.Scanner;
import java.util.logging.Logger; import java.util.logging.Logger;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd;
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
import org.jdrupes.vmoperator.common.Constants.Status;
import org.jdrupes.vmoperator.common.K8sV1SecretStub; import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import org.jdrupes.vmoperator.common.VmDefinition; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME;
import org.jdrupes.vmoperator.common.VmDefinitionStub; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
import org.jdrupes.vmoperator.manager.events.GetDisplaySecret; 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.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.DataPath; 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; import org.jose4j.base64url.Base64;
/** /**
* The properties of the display secret do not only depend on the * Delegee for reconciling the display secret
* VM definition, but also on events that occur during runtime.
* The reconciler for the display secret is therefore a separate
* component.
*
* The reconciler supports the following configuration properties:
*
* * `passwordValidity`: the validity of the random password in seconds.
* Used to calculate the password expiry time in the generated secret.
*/ */
public class DisplaySecretReconciler extends Component { @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
/* default */ class DisplaySecretReconciler {
protected final Logger logger = Logger.getLogger(getClass().getName()); protected final Logger logger = Logger.getLogger(getClass().getName());
private int passwordValidity = 10;
private final List<PendingRequest> pendingPrepares
= Collections.synchronizedList(new LinkedList<>());
/** /**
* Instantiates a new display secret reconciler. * Reconcile.
*
* @param componentChannel the component channel
*/
public DisplaySecretReconciler(Channel componentChannel) {
super(componentChannel);
}
/**
* On configuration update.
* *
* @param event the event * @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 model the model
* @param channel the channel * @param channel the channel
* @param specChanged the spec changed
* @throws IOException Signals that an I/O exception has occurred. * @throws IOException Signals that an I/O exception has occurred.
* @throws TemplateException the template exception * @throws TemplateException the template exception
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
public void reconcile(VmDefinition vmDef, Map<String, Object> model, public void reconcile(VmDefChanged event,
VmChannel channel, boolean specChanged) Map<String, Object> model, VmChannel channel)
throws IOException, TemplateException, ApiException { throws IOException, TemplateException, ApiException {
// Nothing to do unless spec changed
if (!specChanged) {
return;
}
// Secret needed at all? // Secret needed at all?
var display = vmDef.fromVm("display").get(); var display = event.vmDefinition().fromVm("display").get();
if (!DataPath.<Boolean> get(display, "spice", "generateSecret") if (!DataPath.<Boolean> get(display, "spice", "generateSecret")
.orElse(true)) { .orElse(true)) {
return; return;
} }
// Check if exists // Check if exists
var vmDef = event.vmDefinition();
ListOptions options = new ListOptions(); ListOptions options = new ListOptions();
options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/component=" + DisplaySecret.NAME + "," + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
+ "app.kubernetes.io/instance=" + vmDef.name()); + "app.kubernetes.io/instance=" + vmDef.name());
var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(), var stubs = K8sV1SecretStub.list(channel.client(), vmDef.namespace(),
options); options);
@ -157,13 +79,11 @@ public class DisplaySecretReconciler extends Component {
} }
// Create secret // Create secret
var secretName = vmDef.name() + "-" + DisplaySecret.NAME;
logger.fine(() -> "Create/update secret " + secretName);
var secret = new V1Secret(); var secret = new V1Secret();
secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace()) secret.setMetadata(new V1ObjectMeta().namespace(vmDef.namespace())
.name(secretName) .name(vmDef.name() + "-" + COMP_DISPLAY_SECRET)
.putLabelsItem("app.kubernetes.io/name", APP_NAME) .putLabelsItem("app.kubernetes.io/name", APP_NAME)
.putLabelsItem("app.kubernetes.io/component", DisplaySecret.NAME) .putLabelsItem("app.kubernetes.io/component", COMP_DISPLAY_SECRET)
.putLabelsItem("app.kubernetes.io/instance", vmDef.name())); .putLabelsItem("app.kubernetes.io/instance", vmDef.name()));
secret.setType("Opaque"); secret.setType("Opaque");
SecureRandom random = null; SecureRandom random = null;
@ -176,167 +96,9 @@ public class DisplaySecretReconciler extends Component {
byte[] bytes = new byte[16]; byte[] bytes = new byte[16];
random.nextBytes(bytes); random.nextBytes(bytes);
var password = Base64.encode(bytes); var password = Base64.encode(bytes);
secret.setStringData(Map.of(DisplaySecret.PASSWORD, password, secret.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
DisplaySecret.EXPIRY, "now")); DATA_PASSWORD_EXPIRY, "now"));
K8sV1SecretStub.create(channel.client(), secret); 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;
}
}
} }

View file

@ -36,7 +36,7 @@ import java.util.logging.Logger;
import org.jdrupes.vmoperator.common.K8sV1ServiceStub; import org.jdrupes.vmoperator.common.K8sV1ServiceStub;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.GsonPtr; import org.jdrupes.vmoperator.util.GsonPtr;
import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
@ -45,6 +45,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Delegee for reconciling the service * Delegee for reconciling the service
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
/* default */ class LoadBalancerReconciler { /* default */ class LoadBalancerReconciler {
private static final String LOAD_BALANCER_SERVICE = "loadBalancerService"; private static final String LOAD_BALANCER_SERVICE = "loadBalancerService";
@ -68,24 +69,18 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Reconcile. * Reconcile.
* *
* @param vmDef the VM definition * @param event the event
* @param model the model * @param model the model
* @param channel the channel * @param channel the channel
* @param specChanged the spec changed
* @throws IOException Signals that an I/O exception has occurred. * @throws IOException Signals that an I/O exception has occurred.
* @throws TemplateException the template exception * @throws TemplateException the template exception
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
public void reconcile(VmDefinition vmDef, Map<String, Object> model, public void reconcile(VmDefChanged event,
VmChannel channel, boolean specChanged) Map<String, Object> model, VmChannel channel)
throws IOException, TemplateException, ApiException { throws IOException, TemplateException, ApiException {
// Nothing to do unless spec changed
if (!specChanged) {
return;
}
// Check if to be generated // Check if to be generated
@SuppressWarnings({ "unchecked" }) @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" })
var lbsDef = Optional.of(model) var lbsDef = Optional.of(model)
.map(m -> (Map<String, Object>) m.get("reconciler")) .map(m -> (Map<String, Object>) m.get("reconciler"))
.map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE); .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE);
@ -100,6 +95,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
} }
// Load balancer can also be turned off for VM // Load balancer can also be turned off for VM
var vmDef = event.vmDefinition();
if (vmDef if (vmDef
.<Map<String, Map<String, String>>> fromSpec(LOAD_BALANCER_SERVICE) .<Map<String, Map<String, String>>> fromSpec(LOAD_BALANCER_SERVICE)
.map(m -> m.isEmpty()).orElse(false)) { .map(m -> m.isEmpty()).orElse(false)) {
@ -107,8 +103,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
} }
// Combine template and data and parse result // 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"); var fmTemplate = fmConfig.getTemplate("runnerLoadBalancer.ftl.yaml");
StringWriter out = new StringWriter(); StringWriter out = new StringWriter();
fmTemplate.process(model, out); fmTemplate.process(model, out);

View file

@ -81,7 +81,7 @@ import org.jgrapes.webconsole.vuejs.VueJsConsoleWeblet;
/** /**
* The application class. * The application class.
*/ */
@SuppressWarnings({ "PMD.ExcessiveImports" }) @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports" })
public class Manager extends Component { public class Manager extends Component {
private static String version; private static String version;
@ -97,8 +97,8 @@ public class Manager extends Component {
* @throws IOException Signals that an I/O exception has occurred. * @throws IOException Signals that an I/O exception has occurred.
* @throws URISyntaxException * @throws URISyntaxException
*/ */
@SuppressWarnings({ "PMD.NcssCount", @SuppressWarnings({ "PMD.TooFewBranchesForASwitchStatement",
"PMD.ConstructorCallsOverridableMethod" }) "PMD.NcssCount", "PMD.ConstructorCallsOverridableMethod" })
public Manager(CommandLine cmdLine) throws IOException, URISyntaxException { public Manager(CommandLine cmdLine) throws IOException, URISyntaxException {
super(new NamedChannel("manager")); super(new NamedChannel("manager"));
// Prepare component tree // Prepare component tree
@ -217,6 +217,7 @@ public class Manager extends Component {
* @param event the event * @param event the event
*/ */
@Handler @Handler
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public void onConfigurationUpdate(ConfigurationUpdate event) { public void onConfigurationUpdate(ConfigurationUpdate event) {
event.structured(componentPath()).ifPresent(c -> { event.structured(componentPath()).ifPresent(c -> {
if (c.containsKey("clusterName")) { if (c.containsKey("clusterName")) {
@ -263,7 +264,7 @@ public class Manager extends Component {
*/ */
@Handler(priority = -1000) @Handler(priority = -1000)
public void onStop(Stop event) { public void onStop(Stop event) {
logger.info(() -> "Application stopped."); logger.fine(() -> "Application stopped.");
} }
static { static {
@ -290,6 +291,7 @@ public class Manager extends Component {
* @param args the arguments * @param args the arguments
* @throws Exception the exception * @throws Exception the exception
*/ */
@SuppressWarnings("PMD.SignatureDeclareThrowsException")
public static void main(String[] args) { public static void main(String[] args) {
try { try {
// Instance logger is not available yet. // Instance logger is not available yet.

View file

@ -1,139 +0,0 @@
/*
* 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) {
}
}

View file

@ -22,20 +22,15 @@ import freemarker.template.Configuration;
import freemarker.template.TemplateException; import freemarker.template.TemplateException;
import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.util.generic.dynamic.Dynamics; 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 io.kubernetes.client.util.generic.options.PatchOptions;
import java.io.IOException; import java.io.IOException;
import java.io.StringWriter; import java.io.StringWriter;
import java.util.Map; import java.util.Map;
import java.util.logging.Logger; 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.K8sV1PodStub;
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState; import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor; import org.yaml.snakeyaml.constructor.SafeConstructor;
@ -43,6 +38,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Delegee for reconciling the pod. * Delegee for reconciling the pod.
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
/* default */ class PodReconciler { /* default */ class PodReconciler {
protected final Logger logger = Logger.getLogger(getClass().getName()); protected final Logger logger = Logger.getLogger(getClass().getName());
@ -60,18 +56,23 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Reconcile the pod. * Reconcile the pod.
* *
* @param vmDef the vm def * @param event the event
* @param model the model * @param model the model
* @param channel the channel * @param channel the channel
* @param specChanged the spec changed
* @throws IOException Signals that an I/O exception has occurred. * @throws IOException Signals that an I/O exception has occurred.
* @throws TemplateException the template exception * @throws TemplateException the template exception
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
public void reconcile(VmDefinition vmDef, Map<String, Object> model, public void reconcile(VmDefChanged event, Map<String, Object> model,
VmChannel channel, boolean specChanged) VmChannel channel)
throws IOException, TemplateException, ApiException { 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. // Get pod stub.
var vmDef = event.vmDefinition();
var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(), var podStub = K8sV1PodStub.get(channel.client(), vmDef.namespace(),
vmDef.name()); vmDef.name());
@ -90,8 +91,6 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
} }
// Create pod. First combine template and data and parse result // 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"); var fmTemplate = fmConfig.getTemplate("runnerPod.ftl.yaml");
StringWriter out = new StringWriter(); StringWriter out = new StringWriter();
fmTemplate.process(model, out); fmTemplate.process(model, out);
@ -110,19 +109,4 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
} }
} }
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());
});
}
}
} }

View file

@ -1,210 +0,0 @@
/*
* 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());
}
}

View file

@ -38,10 +38,9 @@ import java.util.stream.Collectors;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.common.K8sV1PvcStub; 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.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.DataPath; import org.jdrupes.vmoperator.util.DataPath;
import org.jdrupes.vmoperator.util.GsonPtr;
import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor; import org.yaml.snakeyaml.constructor.SafeConstructor;
@ -49,6 +48,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Delegee for reconciling the stateful set (effectively the pod). * Delegee for reconciling the stateful set (effectively the pod).
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
/* default */ class PvcReconciler { /* default */ class PvcReconciler {
protected final Logger logger = Logger.getLogger(getClass().getName()); protected final Logger logger = Logger.getLogger(getClass().getName());
@ -66,35 +66,32 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** /**
* Reconcile the PVCs. * Reconcile the PVCs.
* *
* @param vmDef the VM definition * @param event the event
* @param model the model * @param model the model
* @param channel the channel * @param channel the channel
* @param specChanged the spec changed
* @throws IOException Signals that an I/O exception has occurred. * @throws IOException Signals that an I/O exception has occurred.
* @throws TemplateException the template exception * @throws TemplateException the template exception
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@SuppressWarnings({ "unchecked" }) @SuppressWarnings("PMD.AvoidDuplicateLiterals")
public void reconcile(VmDefinition vmDef, Map<String, Object> model, public void reconcile(VmDefChanged event, Map<String, Object> model,
VmChannel channel, boolean specChanged) VmChannel channel)
throws IOException, TemplateException, ApiException { throws IOException, TemplateException, ApiException {
Set<String> knownPvcs; var vmDef = event.vmDefinition();
if (!specChanged && channel.associated(this, Set.class).isPresent()) {
knownPvcs = (Set<String>) channel.associated(this, Set.class).get(); // Existing disks
} else { ListOptions listOpts = new ListOptions();
ListOptions listOpts = new ListOptions(); listOpts.setLabelSelector(
listOpts.setLabelSelector( "app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
"app.kubernetes.io/managed-by=" + VM_OP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/instance=" + vmDef.name());
+ "app.kubernetes.io/instance=" + vmDef.name()); var knownDisks = K8sV1PvcStub.list(channel.client(),
knownPvcs = K8sV1PvcStub.list(channel.client(), vmDef.namespace(), listOpts);
vmDef.namespace(), listOpts).stream().map(K8sV1PvcStub::name) var knownPvcs = knownDisks.stream().map(K8sV1PvcStub::name)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
channel.setAssociated(this, knownPvcs);
}
// Reconcile runner data pvc // Reconcile runner data pvc
reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs, specChanged); reconcileRunnerDataPvc(event, model, channel, knownPvcs);
// Reconcile pvcs for defined disks // Reconcile pvcs for defined disks
var diskDefs = vmDef.<List<Map<String, Object>>> fromVm("disks") var diskDefs = vmDef.<List<Map<String, Object>>> fromVm("disks")
@ -118,15 +115,18 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
} }
// Update PVC // Update PVC
reconcileRunnerDiskPvc(vmDef, model, channel, specChanged, diskDef); model.put("disk", diskDef);
reconcileRunnerDiskPvc(event, model, channel);
} }
model.remove("disk");
} }
private void reconcileRunnerDataPvc(VmDefinition vmDef, private void reconcileRunnerDataPvc(VmDefChanged event,
Map<String, Object> model, VmChannel channel, Map<String, Object> model, VmChannel channel,
Set<String> knownPvcs, boolean specChanged) Set<String> knownPvcs)
throws TemplateNotFoundException, MalformedTemplateNameException, throws TemplateNotFoundException, MalformedTemplateNameException,
ParseException, IOException, TemplateException, ApiException { ParseException, IOException, TemplateException, ApiException {
var vmDef = event.vmDefinition();
// Look for old (sts generated) name. // Look for old (sts generated) name.
var stsRunnerDataPvcName var stsRunnerDataPvcName
@ -137,13 +137,7 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
} }
// Generate PVC // Generate PVC
var runnerDataPvcName = vmDef.name() + "-runner-data"; model.put("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"); var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml");
StringWriter out = new StringWriter(); StringWriter out = new StringWriter();
fmTemplate.process(model, out); fmTemplate.process(model, out);
@ -166,57 +160,32 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
} }
} }
private void reconcileRunnerDiskPvc(VmDefinition vmDef, private void reconcileRunnerDiskPvc(VmDefChanged event,
Map<String, Object> model, VmChannel channel, boolean specChanged, Map<String, Object> model, VmChannel channel)
Map<String, Object> diskDef)
throws TemplateNotFoundException, MalformedTemplateNameException, throws TemplateNotFoundException, MalformedTemplateNameException,
ParseException, IOException, TemplateException, ApiException { ParseException, IOException, TemplateException, ApiException {
// Generate PVC var vmDef = event.vmDefinition();
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 // Generate PVC
logger.fine(() -> "Create/update pvc " + pvcName); @SuppressWarnings("unchecked")
model.put("disk", diskDef); var diskDef = (Map<String, Object>) model.get("disk");
var pvcName = vmDef.name() + "-" + diskDef.get("generatedDiskName");
diskDef.put("generatedPvcName", pvcName);
var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml"); var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml");
StringWriter out = new StringWriter(); StringWriter out = new StringWriter();
fmTemplate.process(model, out); fmTemplate.process(model, out);
model.remove("disk");
// Avoid Yaml.load due to // Avoid Yaml.load due to
// https://github.com/kubernetes-client/java/issues/2741 // https://github.com/kubernetes-client/java/issues/2741
var pvcDef = Dynamics.newFromYaml( var pvcDef = Dynamics.newFromYaml(
new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
// Apply changes // Do apply changes
var pvcStub var pvcStub
= K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName); = 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(); PatchOptions opts = new PatchOptions();
opts.setForce(true);
opts.setFieldManager("kubernetes-java-kubectl-apply"); opts.setFieldManager("kubernetes-java-kubectl-apply");
if (pvcStub.patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts) new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts)
.isEmpty()) { .isEmpty()) {
logger.warning( logger.warning(

View file

@ -22,35 +22,38 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import freemarker.template.AdapterTemplateModel; import freemarker.template.AdapterTemplateModel;
import freemarker.template.Configuration; import freemarker.template.Configuration;
import freemarker.template.DefaultObjectWrapperBuilder;
import freemarker.template.SimpleNumber; import freemarker.template.SimpleNumber;
import freemarker.template.SimpleScalar; import freemarker.template.SimpleScalar;
import freemarker.template.TemplateException; import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler; import freemarker.template.TemplateExceptionHandler;
import freemarker.template.TemplateHashModel;
import freemarker.template.TemplateMethodModelEx; import freemarker.template.TemplateMethodModelEx;
import freemarker.template.TemplateModelException; import freemarker.template.TemplateModelException;
import io.kubernetes.client.custom.Quantity; import io.kubernetes.client.custom.Quantity;
import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.util.generic.options.ListOptions;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Modifier;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.BigInteger; import java.math.BigInteger;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Level; import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.common.Convertions; import org.jdrupes.vmoperator.common.Convertions;
import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sObserver; import org.jdrupes.vmoperator.common.K8sObserver;
import org.jdrupes.vmoperator.common.K8sV1SecretStub;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinition.Assignment; import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
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.ResetVm;
import org.jdrupes.vmoperator.manager.events.VmChannel; import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.DataPath;
import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jdrupes.vmoperator.util.ExtendedObjectWrapper;
import org.jgrapes.core.Channel; import org.jgrapes.core.Channel;
import org.jgrapes.core.Component; import org.jgrapes.core.Component;
@ -134,19 +137,20 @@ import org.jgrapes.util.events.ConfigurationUpdate;
* properties to be used by the runners managed by the controller. * properties to be used by the runners managed by the controller.
* This property is a string that holds the content of * This property is a string that holds the content of
* a logging.properties file. * a logging.properties file.
*
* @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler
*/ */
@SuppressWarnings({ "PMD.AvoidDuplicateLiterals" }) @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
"PMD.AvoidDuplicateLiterals" })
public class Reconciler extends Component { public class Reconciler extends Component {
/** The Constant mapper. */ /** The Constant mapper. */
@SuppressWarnings("PMD.FieldNamingConventions") @SuppressWarnings("PMD.FieldNamingConventions")
protected static final ObjectMapper mapper = new ObjectMapper(); protected static final ObjectMapper mapper = new ObjectMapper();
@SuppressWarnings("PMD.SingularField")
private final Configuration fmConfig; private final Configuration fmConfig;
private final ConfigMapReconciler cmReconciler; private final ConfigMapReconciler cmReconciler;
private final DisplaySecretReconciler dsReconciler; private final DisplaySecretReconciler dsReconciler;
private final StatefulSetReconciler stsReconciler;
private final PvcReconciler pvcReconciler; private final PvcReconciler pvcReconciler;
private final PodReconciler podReconciler; private final PodReconciler podReconciler;
private final LoadBalancerReconciler lbReconciler; private final LoadBalancerReconciler lbReconciler;
@ -158,7 +162,6 @@ public class Reconciler extends Component {
* *
* @param componentChannel the component channel * @param componentChannel the component channel
*/ */
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
public Reconciler(Channel componentChannel) { public Reconciler(Channel componentChannel) {
super(componentChannel); super(componentChannel);
@ -173,7 +176,8 @@ public class Reconciler extends Component {
fmConfig.setClassForTemplateLoading(Reconciler.class, ""); fmConfig.setClassForTemplateLoading(Reconciler.class, "");
cmReconciler = new ConfigMapReconciler(fmConfig); cmReconciler = new ConfigMapReconciler(fmConfig);
dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); dsReconciler = new DisplaySecretReconciler();
stsReconciler = new StatefulSetReconciler(fmConfig);
pvcReconciler = new PvcReconciler(fmConfig); pvcReconciler = new PvcReconciler(fmConfig);
podReconciler = new PodReconciler(fmConfig); podReconciler = new PodReconciler(fmConfig);
lbReconciler = new LoadBalancerReconciler(fmConfig); lbReconciler = new LoadBalancerReconciler(fmConfig);
@ -201,27 +205,32 @@ public class Reconciler extends Component {
* @throws IOException Signals that an I/O exception has occurred. * @throws IOException Signals that an I/O exception has occurred.
*/ */
@Handler @Handler
public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) @SuppressWarnings("PMD.ConfusingTernary")
public void onVmDefChanged(VmDefChanged event, VmChannel channel)
throws ApiException, TemplateException, IOException { throws ApiException, TemplateException, IOException {
// We're only interested in "spec" changes.
if (!event.specChanged()) {
return;
}
// Ownership relationships takes care of deletions // Ownership relationships takes care of deletions
if (event.type() == K8sObserver.ResponseType.DELETED) { if (event.type() == K8sObserver.ResponseType.DELETED) {
logger.fine(
() -> "VM \"" + event.vmDefinition().name() + "\" deleted");
return; return;
} }
// Create model for processing templates // Create model for processing templates
var vmDef = event.vmDefinition(); Map<String, Object> model
Map<String, Object> model = prepareModel(vmDef); = prepareModel(channel.client(), event.vmDefinition());
cmReconciler.reconcile(model, channel, event.specChanged()); var configMap = cmReconciler.reconcile(model, channel);
model.put("cm", configMap);
// The remaining reconcilers depend only on changes of the spec part dsReconciler.reconcile(event, model, channel);
// or the pod state. // Manage (eventual) removal of stateful set.
if (!event.specChanged() && !event.podChanged()) { stsReconciler.reconcile(event, model, channel);
return; pvcReconciler.reconcile(event, model, channel);
} podReconciler.reconcile(event, model, channel);
dsReconciler.reconcile(vmDef, model, channel, event.specChanged()); lbReconciler.reconcile(event, model, channel);
pvcReconciler.reconcile(vmDef, model, channel, event.specChanged());
podReconciler.reconcile(vmDef, model, channel, event.specChanged());
lbReconciler.reconcile(vmDef, model, channel, event.specChanged());
} }
/** /**
@ -238,81 +247,52 @@ public class Reconciler extends Component {
public void onResetVm(ResetVm event, VmChannel channel) public void onResetVm(ResetVm event, VmChannel channel)
throws ApiException, IOException, TemplateException { throws ApiException, IOException, TemplateException {
var vmDef = channel.vmDefinition(); var vmDef = channel.vmDefinition();
var extra = vmDef.extra(); vmDef.extra("resetCount", vmDef.<Long> extra("resetCount") + 1);
extra.resetCount(extra.resetCount() + 1);
Map<String, Object> model Map<String, Object> model
= prepareModel(channel.vmDefinition()); = prepareModel(channel.client(), channel.vmDefinition());
cmReconciler.reconcile(model, channel, true); cmReconciler.reconcile(model, channel);
} }
private Map<String, Object> prepareModel(VmDefinition vmDef) @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity" })
throws TemplateModelException, ApiException { private Map<String, Object> prepareModel(K8sClient client,
VmDefinition vmDef) throws TemplateModelException, ApiException {
@SuppressWarnings("PMD.UseConcurrentHashMap") @SuppressWarnings("PMD.UseConcurrentHashMap")
Map<String, Object> model = new HashMap<>(); Map<String, Object> model = new HashMap<>();
model.put("managerVersion", model.put("managerVersion",
Optional.ofNullable(Reconciler.class.getPackage() Optional.ofNullable(Reconciler.class.getPackage()
.getImplementationVersion()).orElse("(Unknown)")); .getImplementationVersion()).orElse("(Unknown)"));
model.put("cr", vmDef); model.put("cr", vmDef);
model.put("constants",
(TemplateHashModel) new DefaultObjectWrapperBuilder(
Configuration.VERSION_2_3_32)
.build().getStaticModels()
.get(Constants.class.getName()));
model.put("reconciler", config); model.put("reconciler", config);
model.put("constants", constantsMap(Constants.class));
addLoginRequestedFor(model, vmDef); // 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.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());
});
}
// Methods // Methods
model.put("parseQuantity", parseQuantityModel); model.put("parseQuantity", parseQuantityModel);
model.put("formatMemory", formatMemoryModel); model.put("formatMemory", formatMemoryModel);
model.put("imageLocation", imgageLocationModel); model.put("imageLocation", imgageLocationModel);
model.put("adjustCloudInitMeta", adjustCloudInitMetaModel);
model.put("toJson", toJsonModel); model.put("toJson", toJsonModel);
return model; 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 private final TemplateMethodModelEx parseQuantityModel
= new TemplateMethodModelEx() { = new TemplateMethodModelEx() {
@Override @Override
@ -335,6 +315,7 @@ public class Reconciler extends Component {
private final TemplateMethodModelEx formatMemoryModel private final TemplateMethodModelEx formatMemoryModel
= new TemplateMethodModelEx() { = new TemplateMethodModelEx() {
@Override @Override
@SuppressWarnings("PMD.PreserveStackTrace")
public Object exec(@SuppressWarnings("rawtypes") List arguments) public Object exec(@SuppressWarnings("rawtypes") List arguments)
throws TemplateModelException { throws TemplateModelException {
var arg = arguments.get(0); var arg = arguments.get(0);
@ -364,7 +345,8 @@ public class Reconciler extends Component {
private final TemplateMethodModelEx imgageLocationModel private final TemplateMethodModelEx imgageLocationModel
= new TemplateMethodModelEx() { = new TemplateMethodModelEx() {
@Override @Override
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) @SuppressWarnings({ "PMD.PreserveStackTrace",
"PMD.AvoidLiteralsInIfCondition" })
public Object exec(@SuppressWarnings("rawtypes") List arguments) public Object exec(@SuppressWarnings("rawtypes") List arguments)
throws TemplateModelException { throws TemplateModelException {
var image = ((SimpleScalar) arguments.get(0)).getAsString(); var image = ((SimpleScalar) arguments.get(0)).getAsString();
@ -372,9 +354,8 @@ public class Reconciler extends Component {
return ""; return "";
} }
try { try {
var imageUri var imageUri = new URI("file://" + Constants.IMAGE_REPO_PATH
= new URI("file://" + Constants.IMAGE_REPO_PATH + "/") + "/").resolve(image);
.resolve(image);
if ("file".equals(imageUri.getScheme())) { if ("file".equals(imageUri.getScheme())) {
return imageUri.getPath(); return imageUri.getPath();
} }
@ -386,9 +367,35 @@ public class Reconciler extends Component {
} }
}; };
private final TemplateMethodModelEx adjustCloudInitMetaModel
= new TemplateMethodModelEx() {
@Override
@SuppressWarnings("PMD.PreserveStackTrace")
public Object exec(@SuppressWarnings("rawtypes") List arguments)
throws TemplateModelException {
@SuppressWarnings("unchecked")
var res = (Map<String, Object>) DataPath
.deepCopy(((AdapterTemplateModel) arguments.get(0))
.getAdaptedObject(Object.class));
var metadata
= (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1))
.getAdaptedObject(Object.class);
if (!res.containsKey("instance-id")) {
res.put("instance-id",
Optional.ofNullable(metadata.getResourceVersion())
.map(s -> "v" + s).orElse("v1"));
}
if (!res.containsKey("local-hostname")) {
res.put("local-hostname", metadata.getName());
}
return res;
}
};
private final TemplateMethodModelEx toJsonModel private final TemplateMethodModelEx toJsonModel
= new TemplateMethodModelEx() { = new TemplateMethodModelEx() {
@Override @Override
@SuppressWarnings("PMD.PreserveStackTrace")
public Object exec(@SuppressWarnings("rawtypes") List arguments) public Object exec(@SuppressWarnings("rawtypes") List arguments)
throws TemplateModelException { throws TemplateModelException {
try { try {

View file

@ -0,0 +1,107 @@
/*
* 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.options.PatchOptions;
import java.io.IOException;
import java.util.Map;
import java.util.logging.Logger;
import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub;
import org.jdrupes.vmoperator.common.VmDefinition.RequestedVmState;
import org.jdrupes.vmoperator.manager.events.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmDefChanged;
/**
* 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());
/**
* Instantiates a new stateful set reconciler.
*
* @param fmConfig the fm config
*/
@SuppressWarnings("PMD.UnusedFormalParameter")
public StatefulSetReconciler(Configuration fmConfig) {
// Nothing to do
}
/**
* 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 {
model.put("usingSts", false);
// If exists, delete when not running or supposed to be not running.
var stsStub = K8sV1StatefulSetStub.get(channel.client(),
event.vmDefinition().namespace(), event.vmDefinition().name());
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.
if (event.vmDefinition().vmState() == RequestedVmState.RUNNING) {
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_JSON_PATCH,
new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/replicas"
+ "\", \"value\": 0}]"),
channel.client().defaultPatchOptions()).isEmpty()) {
logger.warning(
() -> "Could not patch stateful set for " + stsStub.name());
}
}
}

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2023,2025 Michael N. Lipp * Copyright (C) 2023,2024 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -18,64 +18,49 @@
package org.jdrupes.vmoperator.manager; 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.ApiException;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.util.Watch; import io.kubernetes.client.util.Watch;
import io.kubernetes.client.util.generic.options.ListOptions; import io.kubernetes.client.util.generic.options.ListOptions;
import java.io.IOException; import java.io.IOException;
import java.net.HttpURLConnection;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.logging.Level;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.jdrupes.vmoperator.common.Constants.Crd; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import org.jdrupes.vmoperator.common.Constants.Status;
import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.K8sDynamicStub; import org.jdrupes.vmoperator.common.K8sDynamicStub;
import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; import org.jdrupes.vmoperator.common.K8sObserver.ResponseType;
import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub;
import org.jdrupes.vmoperator.common.K8sV1PodStub;
import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub;
import org.jdrupes.vmoperator.common.VmDefinition; import org.jdrupes.vmoperator.common.VmDefinition;
import org.jdrupes.vmoperator.common.VmDefinitionModel;
import org.jdrupes.vmoperator.common.VmDefinitionModels;
import org.jdrupes.vmoperator.common.VmDefinitionStub; 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.APP_NAME;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_KIND_VM;
import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.manager.events.ChannelManager; 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.VmChannel;
import org.jdrupes.vmoperator.manager.events.VmResourceChanged; import org.jdrupes.vmoperator.manager.events.VmDefChanged;
import org.jdrupes.vmoperator.util.GsonPtr; import org.jdrupes.vmoperator.util.DataPath;
import org.jgrapes.core.Channel; import org.jgrapes.core.Channel;
import org.jgrapes.core.Event; import org.jgrapes.core.Event;
import org.jgrapes.core.EventPipeline;
import org.jgrapes.core.annotation.Handler;
/** /**
* Watches for changes of VM definitions. When a VM definition (CR) * Watches for changes of VM definitions.
* 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 public class VmMonitor extends
AbstractMonitor<VmDefinition, VmDefinitions, VmChannel> { AbstractMonitor<VmDefinitionModel, VmDefinitionModels, VmChannel> {
private final ChannelManager<String, VmChannel, private final ChannelManager<String, VmChannel, ?> channelManager;
EventPipeline> channelManager;
/** /**
* Instantiates a new VM definition watcher. * Instantiates a new VM definition watcher.
@ -84,9 +69,9 @@ public class VmMonitor extends
* @param channelManager the channel manager * @param channelManager the channel manager
*/ */
public VmMonitor(Channel componentChannel, public VmMonitor(Channel componentChannel,
ChannelManager<String, VmChannel, EventPipeline> channelManager) { ChannelManager<String, VmChannel, ?> channelManager) {
super(componentChannel, VmDefinition.class, super(componentChannel, VmDefinitionModel.class,
VmDefinitions.class); VmDefinitionModels.class);
this.channelManager = channelManager; this.channelManager = channelManager;
} }
@ -95,7 +80,7 @@ public class VmMonitor extends
client(new K8sClient()); client(new K8sClient());
// Get all our API versions // Get all our API versions
var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); var ctx = K8s.context(client(), VM_OP_GROUP, "", VM_OP_KIND_VM);
if (ctx.isEmpty()) { if (ctx.isEmpty()) {
logger.severe(() -> "Cannot get CRD context."); logger.severe(() -> "Cannot get CRD context.");
return; return;
@ -106,6 +91,7 @@ public class VmMonitor extends
purge(); purge();
} }
@SuppressWarnings("PMD.CognitiveComplexity")
private void purge() throws ApiException { private void purge() throws ApiException {
// Get existing CRs (VMs) // Get existing CRs (VMs)
var known = K8sDynamicStub.list(client(), context(), namespace()) var known = K8sDynamicStub.list(client(), context(), namespace())
@ -129,34 +115,31 @@ public class VmMonitor extends
@Override @Override
protected void handleChange(K8sClient client, protected void handleChange(K8sClient client,
Watch.Response<VmDefinition> response) { Watch.Response<VmDefinitionModel> response) {
var name = response.object.getMetadata().getName(); V1ObjectMeta metadata = response.object.getMetadata();
VmChannel channel = channelManager.channelGet(metadata.getName());
// Process the response data on a VM specific pipeline to // Remove from channel manager if deleted
// increase concurrency when e.g. starting many VMs. if (ResponseType.valueOf(response.type) == ResponseType.DELETED) {
var preparing = channelManager.associated(name) channelManager.remove(metadata.getName());
.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 // Get full definition and associate with channel as backup
var vmDef = response.object; var vmModel = response.object;
if (vmDef.data() == null) { if (vmModel.data() == null) {
// ADDED event does not provide data, see // ADDED event does not provide data, see
// https://github.com/kubernetes-client/java/issues/3215 // https://github.com/kubernetes-client/java/issues/3215
vmDef = getModel(client, vmDef); vmModel = getModel(client, vmModel);
} }
var name = response.object.getMetadata().getName(); VmDefinition vmDef = null;
var channel = channelManager.channel(name) if (vmModel.data() != null) {
.orElseGet(() -> channelManager.createChannel(name));
if (vmDef.data() != null) {
// New data, augment and save // New data, augment and save
addExtraData(vmDef, channel.vmDefinition()); vmDef = client.getJSON().getGson().fromJson(vmModel.data(),
VmDefinition.class);
addDynamicData(channel.client(), vmDef, channel.vmDefinition());
channel.setVmDefinition(vmDef); channel.setVmDefinition(vmDef);
} else { }
if (vmDef == null) {
// Reuse cached (e.g. if deleted) // Reuse cached (e.g. if deleted)
vmDef = channel.vmDefinition(); vmDef = channel.vmDefinition();
} }
@ -165,23 +148,24 @@ public class VmMonitor extends
+ response.object.getMetadata()); + response.object.getMetadata());
return; return;
} }
channelManager.put(name, channel, preparing);
// Create and fire changed event. Remove channel from channel // Create and fire changed event. Remove channel from channel
// manager on completion. // manager on completion.
VmResourceChanged chgEvt channel.pipeline()
= new VmResourceChanged(ResponseType.valueOf(response.type), vmDef, .fire(Event.onCompletion(
channel.setGeneration(response.object.getMetadata() new VmDefChanged(ResponseType.valueOf(response.type),
.getGeneration()), channel.setGeneration(response.object.getMetadata()
false); .getGeneration()),
if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { vmDef),
chgEvt = Event.onCompletion(chgEvt, e -> {
e -> channelManager.remove(e.vmDefinition().name())); if (e.type() == ResponseType.DELETED) {
} channelManager.remove(e.vmDefinition().name());
channel.fire(chgEvt); }
}), channel);
} }
private VmDefinition getModel(K8sClient client, VmDefinition vmDef) { private VmDefinitionModel getModel(K8sClient client,
VmDefinitionModel vmDef) {
try { try {
return VmDefinitionStub.get(client, context(), namespace(), return VmDefinitionStub.get(client, context(), namespace(),
vmDef.metadata().getName()).model().orElse(null); vmDef.metadata().getName()).model().orElse(null);
@ -190,137 +174,57 @@ public class VmMonitor extends
} }
} }
private void addExtraData(VmDefinition vmDef, VmDefinition prevState) { @SuppressWarnings("PMD.AvoidDuplicateLiterals")
var extra = new VmExtraData(vmDef); private void addDynamicData(K8sClient client, VmDefinition vmDef,
var prevExtra = Optional.ofNullable(prevState).map(VmDefinition::extra); VmDefinition prevState) {
// Maintain (or initialize) the resetCount // Maintain (or initialize) the resetCount
extra.resetCount(prevExtra.map(VmExtraData::resetCount).orElse(0L)); vmDef.extra("resetCount",
Optional.ofNullable(prevState).map(d -> d.extra("resetCount"))
.orElse(0L));
// Maintain node info // Node information
prevExtra // Add defaults in case the VM is not running
.ifPresent(e -> extra.nodeInfo(e.nodeName(), e.nodeAddresses())); vmDef.extra("nodeName", "");
} vmDef.extra("nodeAddress", "");
/** // VM definition status changes before the pod terminates.
* On pod changed. // This results in pod information being shown for a stopped
* // VM which is irritating. So check condition first.
* @param event the event @SuppressWarnings("PMD.LambdaCanBeMethodReference")
* @param channel the channel var isRunning
*/ = vmDef.<List<Map<String, Object>>> fromStatus("conditions")
@Handler .orElse(Collections.emptyList()).stream()
public void onPodChanged(PodChanged event, VmChannel channel) { .filter(cond -> DataPath.get(cond, "type")
var vmDef = channel.vmDefinition(); .map(t -> "Running".equals(t)).orElse(false))
.findFirst().map(cond -> DataPath.get(cond, "status")
// Make sure that this is properly sync'd with VM CR changes. .map(s -> "True".equals(s)).orElse(false))
channelManager.associated(vmDef.name()) .orElse(false);
.orElseGet(() -> activeEventPipeline()) if (!isRunning) {
.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; return;
} }
var podSearch = new ListOptions();
// Get current node info from pod podSearch.setLabelSelector("app.kubernetes.io/name=" + APP_NAME
var pod = event.pod(); + ",app.kubernetes.io/component=" + APP_NAME
var nodeName = Optional + ",app.kubernetes.io/instance=" + vmDef.name());
.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 { try {
var vmDef = channel.vmDefinition(); var podList
var vmStub = VmDefinitionStub.get(channel.client(), = K8sV1PodStub.list(client, namespace(), podSearch);
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), for (var podStub : podList) {
vmDef.namespace(), vmDef.name()); var nodeName = podStub.model().get().getSpec().getNodeName();
if (vmStub.updateStatus(vmDef, from -> { vmDef.extra("nodeName", nodeName);
JsonObject status = from.statusJson(); logger.fine(() -> "Added node name " + nodeName
if (event.toUser() == null) { + " to VM info for " + vmDef.name());
((JsonObject) GsonPtr.to(status).get()) @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
.remove(Status.ASSIGNMENT); var addrs = new ArrayList<String>();
} else { podStub.model().get().getStatus().getPodIPs().stream()
var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); .map(ip -> ip.getIp()).forEach(addrs::add);
assignment.set("pool", event.fromPool().name()); vmDef.extra("nodeAddresses", addrs);
assignment.set("user", event.toUser()); logger.fine(() -> "Added node addresses " + addrs
assignment.set("lastUsed", Instant.now().toString()); + " to VM info for " + vmDef.name());
}
return status;
}).isPresent()) {
event.setResult(true);
} }
} catch (ApiException e) { } catch (ApiException e) {
// Log exceptions except for conflict, which can be expected logger.log(Level.WARNING, e,
if (HttpURLConnection.HTTP_CONFLICT != e.getCode()) { () -> "Cannot access node information: " + e.getMessage());
throw e;
}
} }
event.setResult(false);
} }
} }

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2023,2025 Michael N. Lipp * Copyright (C) 2023 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -83,18 +83,8 @@
* [YamlConfigurationStore] *-right[hidden]- [Controller] * [YamlConfigurationStore] *-right[hidden]- [Controller]
* *
* [Manager] *-- [Controller] * [Manager] *-- [Controller]
* Component VmMonitor as VmMonitor <<internal>> * [Controller] *-- [VmWatcher]
* [Controller] *-- [VmMonitor] * [Controller] *-- [Reconciler]
* [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] * [Controller] -right[hidden]- [GuiHttpServer]
* *
* [Manager] *-down- [GuiSocketServer:8080] * [Manager] *-down- [GuiSocketServer:8080]

View file

@ -1,8 +1,8 @@
apiVersion: "vmoperator.jdrupes.org/v1" apiVersion: "vmoperator.jdrupes.org/v1"
kind: VirtualMachine kind: VirtualMachine
metadata: metadata:
namespace: vmop-test namespace: vmop-dev
name: test-vm name: unittest-vm
spec: spec:
image: image:
repository: docker-registry.lan.mnl.de repository: docker-registry.lan.mnl.de

View file

@ -1,111 +0,0 @@
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

View file

@ -12,10 +12,10 @@ import java.util.Collection;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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.APP_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd; import static org.jdrupes.vmoperator.common.Constants.COMP_DISPLAY_SECRET;
import org.jdrupes.vmoperator.common.Constants.DisplaySecret; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
@ -41,7 +41,7 @@ class BasicTests {
private static APIResource vmsContext; private static APIResource vmsContext;
private static K8sV1DeploymentStub mgrDeployment; private static K8sV1DeploymentStub mgrDeployment;
private static K8sDynamicStub vmStub; private static K8sDynamicStub vmStub;
private static final String VM_NAME = "test-vm"; private static final String VM_NAME = "unittest-vm";
private static final Object EXISTS = new Object(); private static final Object EXISTS = new Object();
@BeforeAll @BeforeAll
@ -54,24 +54,24 @@ class BasicTests {
// Update manager pod by scaling deployment // Update manager pod by scaling deployment
mgrDeployment mgrDeployment
= K8sV1DeploymentStub.get(client, "vmop-test", "vm-operator"); = K8sV1DeploymentStub.get(client, "vmop-dev", "vm-operator");
mgrDeployment.scale(0); mgrDeployment.scale(0);
mgrDeployment.scale(1); mgrDeployment.scale(1);
waitForManager(); waitForManager();
// Context for working with our CR // Context for working with our CR
var apiRes = K8s.context(client, Crd.GROUP, null, Crd.KIND_VM); var apiRes = K8s.context(client, VM_OP_GROUP, null, VM_OP_KIND_VM);
assertTrue(apiRes.isPresent()); assertTrue(apiRes.isPresent());
vmsContext = apiRes.get(); vmsContext = apiRes.get();
// Cleanup existing VM // Cleanup existing VM
K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME)
.delete(); .delete();
ListOptions listOpts = new ListOptions(); ListOptions listOpts = new ListOptions();
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/instance=" + VM_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + ","
+ "app.kubernetes.io/component=" + DisplaySecret.NAME); + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
var secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); var secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts);
for (var secret : secrets) { for (var secret : secrets) {
secret.delete(); secret.delete();
} }
@ -103,7 +103,7 @@ class BasicTests {
"app.kubernetes.io/managed-by=" + VM_OP_NAME + "," "app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
+ "app.kubernetes.io/name=" + APP_NAME + "," + "app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/instance=" + VM_NAME); + "app.kubernetes.io/instance=" + VM_NAME);
var knownPvcs = K8sV1PvcStub.list(client, "vmop-test", listOpts); var knownPvcs = K8sV1PvcStub.list(client, "vmop-dev", listOpts);
for (var pvc : knownPvcs) { for (var pvc : knownPvcs) {
pvc.delete(); pvc.delete();
} }
@ -112,7 +112,7 @@ class BasicTests {
@AfterAll @AfterAll
static void tearDownAfterClass() throws Exception { static void tearDownAfterClass() throws Exception {
// Cleanup // Cleanup
K8sDynamicStub.get(client, vmsContext, "vmop-test", VM_NAME) K8sDynamicStub.get(client, vmsContext, "vmop-dev", VM_NAME)
.delete(); .delete();
deletePvcs(); deletePvcs();
@ -124,7 +124,7 @@ class BasicTests {
void testConfigMap() void testConfigMap()
throws IOException, InterruptedException, ApiException { throws IOException, InterruptedException, ApiException {
K8sV1ConfigMapStub stub K8sV1ConfigMapStub stub
= K8sV1ConfigMapStub.get(client, "vmop-test", VM_NAME); = K8sV1ConfigMapStub.get(client, "vmop-dev", VM_NAME);
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
if (stub.model().isPresent()) { if (stub.model().isPresent()) {
break; break;
@ -134,15 +134,16 @@ class BasicTests {
// Check config map // Check config map
var config = stub.model().get(); var config = stub.model().get();
Map<List<? extends Object>, Object> toCheck = Map.of( Map<List<? extends Object>, Object> toCheck = Map.of(
List.of("namespace"), "vmop-test", List.of("namespace"), "vmop-dev",
List.of("name"), VM_NAME, List.of("name"), VM_NAME,
List.of("labels", "app.kubernetes.io/name"), Constants.APP_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/instance"), VM_NAME,
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("labels", "app.kubernetes.io/managed-by"),
Constants.VM_OP_NAME,
List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS,
List.of("ownerReferences", 0, "apiVersion"), List.of("ownerReferences", 0, "apiVersion"),
vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0),
List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM,
List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "name"), VM_NAME,
List.of("ownerReferences", 0, "uid"), EXISTS); List.of("ownerReferences", 0, "uid"), EXISTS);
checkProps(config.getMetadata(), toCheck); checkProps(config.getMetadata(), toCheck);
@ -188,10 +189,10 @@ class BasicTests {
ListOptions listOpts = new ListOptions(); ListOptions listOpts = new ListOptions();
listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," listOpts.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
+ "app.kubernetes.io/instance=" + VM_NAME + "," + "app.kubernetes.io/instance=" + VM_NAME + ","
+ "app.kubernetes.io/component=" + DisplaySecret.NAME); + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
Collection<K8sV1SecretStub> secrets = null; Collection<K8sV1SecretStub> secrets = null;
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
secrets = K8sV1SecretStub.list(client, "vmop-test", listOpts); secrets = K8sV1SecretStub.list(client, "vmop-dev", listOpts);
if (secrets.size() > 0) { if (secrets.size() > 0) {
break; break;
} }
@ -207,7 +208,7 @@ class BasicTests {
@Test @Test
void testRunnerPvc() throws ApiException, InterruptedException { void testRunnerPvc() throws ApiException, InterruptedException {
var stub var stub
= K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-runner-data"); = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-runner-data");
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
if (stub.model().isPresent()) { if (stub.model().isPresent()) {
break; break;
@ -218,7 +219,8 @@ class BasicTests {
checkProps(pvc.getMetadata(), Map.of( checkProps(pvc.getMetadata(), Map.of(
List.of("labels", "app.kubernetes.io/name"), Constants.APP_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/instance"), VM_NAME,
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); List.of("labels", "app.kubernetes.io/managed-by"),
Constants.VM_OP_NAME));
checkProps(pvc.getSpec(), Map.of( checkProps(pvc.getSpec(), Map.of(
List.of("resources", "requests", "storage"), List.of("resources", "requests", "storage"),
Quantity.fromString("1Mi"))); Quantity.fromString("1Mi")));
@ -227,7 +229,7 @@ class BasicTests {
@Test @Test
void testSystemDiskPvc() throws ApiException, InterruptedException { void testSystemDiskPvc() throws ApiException, InterruptedException {
var stub var stub
= K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-system-disk"); = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-system-disk");
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
if (stub.model().isPresent()) { if (stub.model().isPresent()) {
break; break;
@ -238,7 +240,8 @@ class BasicTests {
checkProps(pvc.getMetadata(), Map.of( checkProps(pvc.getMetadata(), Map.of(
List.of("labels", "app.kubernetes.io/name"), Constants.APP_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/instance"), VM_NAME,
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("labels", "app.kubernetes.io/managed-by"),
Constants.VM_OP_NAME,
List.of("annotations", "use_as"), "system-disk")); List.of("annotations", "use_as"), "system-disk"));
checkProps(pvc.getSpec(), Map.of( checkProps(pvc.getSpec(), Map.of(
List.of("resources", "requests", "storage"), List.of("resources", "requests", "storage"),
@ -248,7 +251,7 @@ class BasicTests {
@Test @Test
void testDisk1Pvc() throws ApiException, InterruptedException { void testDisk1Pvc() throws ApiException, InterruptedException {
var stub var stub
= K8sV1PvcStub.get(client, "vmop-test", VM_NAME + "-disk-1"); = K8sV1PvcStub.get(client, "vmop-dev", VM_NAME + "-disk-1");
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
if (stub.model().isPresent()) { if (stub.model().isPresent()) {
break; break;
@ -259,7 +262,8 @@ class BasicTests {
checkProps(pvc.getMetadata(), Map.of( checkProps(pvc.getMetadata(), Map.of(
List.of("labels", "app.kubernetes.io/name"), Constants.APP_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/instance"), VM_NAME,
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME)); List.of("labels", "app.kubernetes.io/managed-by"),
Constants.VM_OP_NAME));
checkProps(pvc.getSpec(), Map.of( checkProps(pvc.getSpec(), Map.of(
List.of("resources", "requests", "storage"), List.of("resources", "requests", "storage"),
Quantity.fromString("1Gi"))); Quantity.fromString("1Gi")));
@ -274,7 +278,7 @@ class BasicTests {
new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state" new V1Patch("[{\"op\": \"replace\", \"path\": \"/spec/vm/state"
+ "\", \"value\": \"Running\"}]"), + "\", \"value\": \"Running\"}]"),
client.defaultPatchOptions()).isPresent()); client.defaultPatchOptions()).isPresent());
var stub = K8sV1PodStub.get(client, "vmop-test", VM_NAME); var stub = K8sV1PodStub.get(client, "vmop-dev", VM_NAME);
for (int i = 0; i < 20; i++) { for (int i = 0; i < 20; i++) {
if (stub.model().isPresent()) { if (stub.model().isPresent()) {
break; break;
@ -286,12 +290,13 @@ class BasicTests {
List.of("labels", "app.kubernetes.io/name"), APP_NAME, List.of("labels", "app.kubernetes.io/name"), APP_NAME,
List.of("labels", "app.kubernetes.io/instance"), VM_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/component"), APP_NAME,
List.of("labels", "app.kubernetes.io/managed-by"), VM_OP_NAME, List.of("labels", "app.kubernetes.io/managed-by"),
Constants.VM_OP_NAME,
List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS, List.of("annotations", "vmrunner.jdrupes.org/cmVersion"), EXISTS,
List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS, List.of("annotations", "vmoperator.jdrupes.org/version"), EXISTS,
List.of("ownerReferences", 0, "apiVersion"), List.of("ownerReferences", 0, "apiVersion"),
vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0), vmsContext.getGroup() + "/" + vmsContext.getVersions().get(0),
List.of("ownerReferences", 0, "kind"), Crd.KIND_VM, List.of("ownerReferences", 0, "kind"), Constants.VM_OP_KIND_VM,
List.of("ownerReferences", 0, "name"), VM_NAME, List.of("ownerReferences", 0, "name"), VM_NAME,
List.of("ownerReferences", 0, "uid"), EXISTS)); List.of("ownerReferences", 0, "uid"), EXISTS));
checkProps(pod.getSpec(), Map.of( checkProps(pod.getSpec(), Map.of(
@ -303,7 +308,7 @@ class BasicTests {
@Test @Test
public void testLoadBalancer() throws ApiException, InterruptedException { public void testLoadBalancer() throws ApiException, InterruptedException {
var stub = K8sV1ServiceStub.get(client, "vmop-test", VM_NAME); var stub = K8sV1ServiceStub.get(client, "vmop-dev", VM_NAME);
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
if (stub.model().isPresent()) { if (stub.model().isPresent()) {
break; break;

View file

@ -1,122 +0,0 @@
/*
* 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.
}
}

View file

@ -36,6 +36,7 @@ import org.jgrapes.core.annotation.Handler;
/** /**
* The Class CdMediaController. * The Class CdMediaController.
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class CdMediaController extends Component { public class CdMediaController extends Component {
/** /**
@ -54,6 +55,7 @@ public class CdMediaController extends Component {
* *
* @param componentChannel the component channel * @param componentChannel the component channel
*/ */
@SuppressWarnings("PMD.AssignmentToNonFinalStatic")
public CdMediaController(Channel componentChannel) { public CdMediaController(Channel componentChannel) {
super(componentChannel); super(componentChannel);
} }
@ -64,7 +66,8 @@ public class CdMediaController extends Component {
* @param event the event * @param event the event
*/ */
@Handler @Handler
@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
"PMD.AvoidInstantiatingObjectsInLoops" })
public void onConfigureQemu(ConfigureQemu event) { public void onConfigureQemu(ConfigureQemu event) {
if (event.runState() == RunState.TERMINATING) { if (event.runState() == RunState.TERMINATING) {
return; return;

View file

@ -39,9 +39,11 @@ import org.jdrupes.vmoperator.util.FsdUtils;
/** /**
* The configuration information from the configuration file. * The configuration information from the configuration file.
*/ */
@SuppressWarnings("PMD.ExcessivePublicCount")
public class Configuration implements Dto { public class Configuration implements Dto {
private static final String CI_INSTANCE_ID = "instance-id"; private static final String CI_INSTANCE_ID = "instance-id";
@SuppressWarnings("PMD.FieldNamingConventions")
protected final Logger logger = Logger.getLogger(getClass().getName()); protected final Logger logger = Logger.getLogger(getClass().getName());
/** Configuration timestamp. */ /** Configuration timestamp. */
@ -93,12 +95,15 @@ public class Configuration implements Dto {
public static class CloudInit implements Dto { public static class CloudInit implements Dto {
/** The meta data. */ /** The meta data. */
@SuppressWarnings("PMD.UseConcurrentHashMap")
public Map<String, Object> metaData; public Map<String, Object> metaData;
/** The user data. */ /** The user data. */
@SuppressWarnings("PMD.UseConcurrentHashMap")
public Map<String, Object> userData; public Map<String, Object> userData;
/** The network config. */ /** The network config. */
@SuppressWarnings("PMD.UseConcurrentHashMap")
public Map<String, Object> networkConfig; public Map<String, Object> networkConfig;
} }
@ -243,9 +248,6 @@ public class Configuration implements Dto {
/** The number of outputs. */ /** The number of outputs. */
public int outputs = 1; public int outputs = 1;
/** The logged in user. */
public String loggedInUser;
/** The spice. */ /** The spice. */
public Spice spice; public Spice spice;
} }
@ -294,6 +296,7 @@ public class Configuration implements Dto {
return true; return true;
} }
@SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
private void checkDrives() { private void checkDrives() {
for (Drive drive : vm.drives) { for (Drive drive : vm.drives) {
if (drive.file != null || drive.device != null if (drive.file != null || drive.device != null
@ -313,6 +316,7 @@ public class Configuration implements Dto {
} }
} }
@SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts")
private boolean checkRuntimeDir() { private boolean checkRuntimeDir() {
// Runtime directory (sockets etc.) // Runtime directory (sockets etc.)
if (runtimeDir == null) { if (runtimeDir == null) {
@ -348,6 +352,7 @@ public class Configuration implements Dto {
return true; return true;
} }
@SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts")
private boolean checkDataDir() { private boolean checkDataDir() {
// Data directory // Data directory
if (dataDir == null) { if (dataDir == null) {

View file

@ -25,8 +25,8 @@ import io.kubernetes.client.openapi.models.EventsV1Event;
import java.io.IOException; import java.io.IOException;
import java.util.logging.Level; import java.util.logging.Level;
import static org.jdrupes.vmoperator.common.Constants.APP_NAME; import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
import org.jdrupes.vmoperator.common.Constants.Crd; import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP;
import org.jdrupes.vmoperator.common.Constants.Status; import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM;
import org.jdrupes.vmoperator.common.K8s; import org.jdrupes.vmoperator.common.K8s;
import org.jdrupes.vmoperator.common.K8sClient; import org.jdrupes.vmoperator.common.K8sClient;
import org.jdrupes.vmoperator.common.VmDefinitionStub; import org.jdrupes.vmoperator.common.VmDefinitionStub;
@ -41,6 +41,7 @@ import org.jgrapes.core.events.Start;
* A (sub)component that updates the console status in the CR status. * A (sub)component that updates the console status in the CR status.
* Created as child of {@link StatusUpdater}. * Created as child of {@link StatusUpdater}.
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class ConsoleTracker extends VmDefUpdater { public class ConsoleTracker extends VmDefUpdater {
private VmDefinitionStub vmStub; private VmDefinitionStub vmStub;
@ -52,6 +53,7 @@ public class ConsoleTracker extends VmDefUpdater {
* *
* @param componentChannel the component channel * @param componentChannel the component channel
*/ */
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
public ConsoleTracker(Channel componentChannel) { public ConsoleTracker(Channel componentChannel) {
super(componentChannel); super(componentChannel);
apiClient = (K8sClient) io.kubernetes.client.openapi.Configuration apiClient = (K8sClient) io.kubernetes.client.openapi.Configuration
@ -72,7 +74,7 @@ public class ConsoleTracker extends VmDefUpdater {
} }
try { try {
vmStub = VmDefinitionStub.get(apiClient, vmStub = VmDefinitionStub.get(apiClient,
new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM),
namespace, vmName); namespace, vmName);
} catch (ApiException e) { } catch (ApiException e) {
logger.log(Level.SEVERE, e, logger.log(Level.SEVERE, e,
@ -89,7 +91,8 @@ public class ConsoleTracker extends VmDefUpdater {
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@Handler @Handler
@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
"PMD.AvoidDuplicateLiterals" })
public void onSpiceInitialized(SpiceInitializedEvent event) public void onSpiceInitialized(SpiceInitializedEvent event)
throws ApiException { throws ApiException {
if (vmStub == null) { if (vmStub == null) {
@ -103,15 +106,16 @@ public class ConsoleTracker extends VmDefUpdater {
mainChannelClientHost = event.clientHost(); mainChannelClientHost = event.clientHost();
mainChannelClientPort = event.clientPort(); mainChannelClientPort = event.clientPort();
vmStub.updateStatus(from -> { vmStub.updateStatus(from -> {
JsonObject status = updateCondition(from, "ConsoleConnected", true, JsonObject status = from.status();
"Connected", "Connection from " + event.clientHost()); status.addProperty("consoleClient", event.clientHost());
status.addProperty(Status.CONSOLE_CLIENT, event.clientHost()); updateCondition(from, status, "ConsoleConnected", true, "Connected",
"Connection from " + event.clientHost());
return status; return status;
}); });
// Log event // Log event
var evt = new EventsV1Event() var evt = new EventsV1Event()
.reportingController(Crd.GROUP + "/" + APP_NAME) .reportingController(VM_OP_GROUP + "/" + APP_NAME)
.action("ConsoleConnectionUpdate") .action("ConsoleConnectionUpdate")
.reason("Connection from " + event.clientHost()); .reason("Connection from " + event.clientHost());
K8s.createEvent(apiClient, vmStub.model().get(), evt); K8s.createEvent(apiClient, vmStub.model().get(), evt);
@ -124,6 +128,7 @@ public class ConsoleTracker extends VmDefUpdater {
* @throws ApiException the api exception * @throws ApiException the api exception
*/ */
@Handler @Handler
@SuppressWarnings("PMD.AvoidDuplicateLiterals")
public void onSpiceDisconnected(SpiceDisconnectedEvent event) public void onSpiceDisconnected(SpiceDisconnectedEvent event)
throws ApiException { throws ApiException {
if (vmStub == null) { if (vmStub == null) {
@ -136,15 +141,16 @@ public class ConsoleTracker extends VmDefUpdater {
return; return;
} }
vmStub.updateStatus(from -> { vmStub.updateStatus(from -> {
JsonObject status = updateCondition(from, "ConsoleConnected", false, JsonObject status = from.status();
status.addProperty("consoleClient", "");
updateCondition(from, status, "ConsoleConnected", false,
"Disconnected", event.clientHost() + " has disconnected"); "Disconnected", event.clientHost() + " has disconnected");
status.addProperty(Status.CONSOLE_CLIENT, "");
return status; return status;
}); });
// Log event // Log event
var evt = new EventsV1Event() var evt = new EventsV1Event()
.reportingController(Crd.GROUP + "/" + APP_NAME) .reportingController(VM_OP_GROUP + "/" + APP_NAME)
.action("ConsoleConnectionUpdate") .action("ConsoleConnectionUpdate")
.reason("Disconnected from " + event.clientHost()); .reason("Disconnected from " + event.clientHost());
K8s.createEvent(apiClient, vmStub.model().get(), evt); K8s.createEvent(apiClient, vmStub.model().get(), evt);

View file

@ -1,41 +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.runner.qemu;
/**
* Some constants.
*/
public class Constants extends org.jdrupes.vmoperator.common.Constants {
/**
* Process names.
*/
public static class ProcessName {
/** The Constant QEMU. */
public static final String QEMU = "qemu";
/** The Constant SWTPM. */
public static final String SWTPM = "swtpm";
/** The Constant CLOUD_INIT_IMG. */
public static final String CLOUD_INIT_IMG = "cloudInitImg";
}
}

View file

@ -41,6 +41,7 @@ import org.jgrapes.core.annotation.Handler;
/** /**
* The Class CpuController. * The Class CpuController.
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class CpuController extends Component { public class CpuController extends Component {
private Integer currentCpus; private Integer currentCpus;

View file

@ -1,6 +1,6 @@
/* /*
* VM-Operator * VM-Operator
* Copyright (C) 2023,2025 Michael N. Lipp * Copyright (C) 2023 Michael N. Lipp
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as * it under the terms of the GNU Affero General Public License as
@ -22,20 +22,14 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level; import java.util.logging.Level;
import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetDisplayPassword;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry; import org.jdrupes.vmoperator.runner.qemu.commands.QmpSetPasswordExpiry;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu; import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand; import org.jdrupes.vmoperator.runner.qemu.events.MonitorCommand;
import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState; import org.jdrupes.vmoperator.runner.qemu.events.RunnerStateChange.RunState;
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentConnected;
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogIn;
import org.jdrupes.vmoperator.runner.qemu.events.VmopAgentLogOut;
import org.jgrapes.core.Channel; import org.jgrapes.core.Channel;
import org.jgrapes.core.Component; import org.jgrapes.core.Component;
import org.jgrapes.core.Event;
import org.jgrapes.core.annotation.Handler; import org.jgrapes.core.annotation.Handler;
import org.jgrapes.util.events.FileChanged; import org.jgrapes.util.events.FileChanged;
import org.jgrapes.util.events.WatchFile; import org.jgrapes.util.events.WatchFile;
@ -43,14 +37,14 @@ import org.jgrapes.util.events.WatchFile;
/** /**
* The Class DisplayController. * The Class DisplayController.
*/ */
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
public class DisplayController extends Component { public class DisplayController extends Component {
public static final String DISPLAY_PASSWORD_FILE = "display-password";
public static final String PASSWORD_EXPIRY_FILE = "password-expiry";
private String currentPassword; private String currentPassword;
private String protocol; private String protocol;
private final Path configDir; private final Path configDir;
private boolean canBeUpdated;
private boolean vmopAgentConnected;
private String loggedInUser;
/** /**
* Instantiates a new Display controller. * Instantiates a new Display controller.
@ -58,11 +52,12 @@ public class DisplayController extends Component {
* @param componentChannel the component channel * @param componentChannel the component channel
* @param configDir * @param configDir
*/ */
@SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) @SuppressWarnings({ "PMD.AssignmentToNonFinalStatic",
"PMD.ConstructorCallsOverridableMethod" })
public DisplayController(Channel componentChannel, Path configDir) { public DisplayController(Channel componentChannel, Path configDir) {
super(componentChannel); super(componentChannel);
this.configDir = configDir; this.configDir = configDir;
fire(new WatchFile(configDir.resolve(DisplaySecret.PASSWORD))); fire(new WatchFile(configDir.resolve(DISPLAY_PASSWORD_FILE)));
} }
/** /**
@ -77,33 +72,7 @@ public class DisplayController extends Component {
} }
protocol protocol
= event.configuration().vm.display.spice != null ? "spice" : null; = event.configuration().vm.display.spice != null ? "spice" : null;
loggedInUser = event.configuration().vm.display.loggedInUser; updatePassword();
configureLogin();
if (event.runState() == RunState.STARTING) {
configurePassword();
}
canBeUpdated = true;
}
/**
* On vmop agent connected.
*
* @param event the event
*/
@Handler
public void onVmopAgentConnected(VmopAgentConnected event) {
vmopAgentConnected = true;
configureLogin();
}
private void configureLogin() {
if (!vmopAgentConnected) {
return;
}
Event<?> evt = loggedInUser != null
? new VmopAgentLogIn(loggedInUser)
: new VmopAgentLogOut();
fire(evt);
} }
/** /**
@ -112,16 +81,15 @@ public class DisplayController extends Component {
* @param event the event * @param event the event
*/ */
@Handler @Handler
@SuppressWarnings("PMD.EmptyCatchBlock")
public void onFileChanged(FileChanged event) { public void onFileChanged(FileChanged event) {
if (event.path().equals(configDir.resolve(DisplaySecret.PASSWORD))) { if (event.path().equals(configDir.resolve(DISPLAY_PASSWORD_FILE))) {
logger.fine(() -> "Display password updated"); updatePassword();
if (canBeUpdated) {
configurePassword();
}
} }
} }
private void configurePassword() { @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
private void updatePassword() {
if (protocol == null) { if (protocol == null) {
return; return;
} }
@ -131,41 +99,47 @@ public class DisplayController extends Component {
} }
private boolean setDisplayPassword() { private boolean setDisplayPassword() {
return readFromFile(DisplaySecret.PASSWORD).map(password -> { String password;
if (Objects.equals(this.currentPassword, password)) { Path dpPath = configDir.resolve(DISPLAY_PASSWORD_FILE);
return true; if (dpPath.toFile().canRead()) {
logger.finer(() -> "Found display password");
try {
password = Files.readString(dpPath);
} catch (IOException e) {
logger.log(Level.WARNING, e, () -> "Cannot read display"
+ " password: " + e.getMessage());
return false;
} }
this.currentPassword = password; } else {
logger.fine(() -> "Updating display password"); logger.finer(() -> "No display password");
fire(new MonitorCommand( return false;
new QmpSetDisplayPassword(protocol, password))); }
if (Objects.equals(this.currentPassword, password)) {
return true; return true;
}).orElse(false); }
this.currentPassword = password;
logger.fine(() -> "Updating display password");
fire(new MonitorCommand(new QmpSetDisplayPassword(protocol, password)));
return true;
} }
private void setPasswordExpiry() { private void setPasswordExpiry() {
readFromFile(DisplaySecret.EXPIRY).ifPresent(expiry -> { Path pePath = configDir.resolve(PASSWORD_EXPIRY_FILE);
logger.fine(() -> "Updating expiry time to " + expiry); if (!pePath.toFile().canRead()) {
fire( return;
new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry))); }
}); logger.finer(() -> "Found expiry time");
String expiry;
try {
expiry = Files.readString(pePath);
} catch (IOException e) {
logger.log(Level.WARNING, e, () -> "Cannot read expiry"
+ " time: " + e.getMessage());
return;
}
logger.fine(() -> "Updating expiry time");
fire(new MonitorCommand(new QmpSetPasswordExpiry(protocol, expiry)));
} }
private Optional<String> readFromFile(String dataItem) {
Path path = configDir.resolve(dataItem);
String label = dataItem.replace('-', ' ');
if (path.toFile().canRead()) {
logger.finer(() -> "Found " + label);
try {
return Optional.ofNullable(Files.readString(path));
} catch (IOException e) {
logger.log(Level.WARNING, e, () -> "Cannot read " + label + ": "
+ e.getMessage());
return Optional.empty();
}
} else {
logger.finer(() -> "No " + label);
return Optional.empty();
}
}
} }

View file

@ -1,226 +0,0 @@
/*
* 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 com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.time.Instant;
import java.util.LinkedList;
import java.util.Queue;
import java.util.logging.Level;
import org.jdrupes.vmoperator.runner.qemu.Constants.ProcessName;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpCommand;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestGetOsinfo;
import org.jdrupes.vmoperator.runner.qemu.commands.QmpGuestPowerdown;
import org.jdrupes.vmoperator.runner.qemu.events.ConfigureQemu;
import org.jdrupes.vmoperator.runner.qemu.events.GuestAgentCommand;
import org.jdrupes.vmoperator.runner.qemu.events.OsinfoEvent;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Components;
import org.jgrapes.core.Components.Timer;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.core.events.Stop;
import org.jgrapes.io.events.ProcessExited;
/**
* A component that handles the communication with the guest agent.
*
* If the log level for this class is set to fine, the messages
* exchanged on the monitor socket are logged.
*/
public class GuestAgentClient extends AgentConnector {
private boolean connected;
private Instant powerdownStartedAt;
private int powerdownTimeout;
private Timer powerdownTimer;
private final Queue<QmpCommand> executing = new LinkedList<>();
private Stop suspendedStop;
/**
* Instantiates a new guest agent client.
*
* @param componentChannel the component channel
* @throws IOException Signals that an I/O exception has occurred.
*/
public GuestAgentClient(Channel componentChannel) throws IOException {
super(componentChannel);
}
/**
* When the agent has connected, request the OS information.
*/
@Override
protected void agentConnected() {
logger.fine(() -> "Guest agent connected");
connected = true;
rep().fire(new GuestAgentCommand(new QmpGuestGetOsinfo()));
}
@Override
protected void agentDisconnected() {
logger.fine(() -> "Guest agent disconnected");
connected = false;
}
/**
* Process agent input.
*
* @param line the line
* @throws IOException Signals that an I/O exception has occurred.
*/
@Override
protected void processInput(String line) throws IOException {
logger.finer(() -> "guest agent(in): " + line);
try {
var response = mapper.readValue(line, ObjectNode.class);
if (response.has("return") || response.has("error")) {
QmpCommand executed = executing.poll();
logger.finer(() -> String.format("(Previous \"guest agent(in)\""
+ " is result from executing %s)", executed));
if (executed instanceof QmpGuestGetOsinfo) {
var osInfo = new OsinfoEvent(response.get("return"));
logger.fine(() -> "Guest agent triggers: " + osInfo);
rep().fire(osInfo);
}
}
} catch (JsonProcessingException e) {
throw new IOException(e);
}
}
/**
* On guest agent command.
*
* @param event the event
* @throws IOException Signals that an I/O exception has occurred.
*/
@Handler
@SuppressWarnings({ "PMD.AvoidSynchronizedStatement",
"PMD.AvoidDuplicateLiterals" })
public void onGuestAgentCommand(GuestAgentCommand event)
throws IOException {
if (qemuChannel() == null) {
return;
}
var command = event.command();
logger.fine(() -> "Guest handles: " + event);
String asText;
try {
asText = command.asText();
logger.finer(() -> "guest agent(out): " + asText);
} catch (JsonProcessingException e) {
logger.log(Level.SEVERE, e,
() -> "Cannot serialize Json: " + e.getMessage());
return;
}
synchronized (executing) {
if (writer().isPresent()) {
executing.add(command);
sendCommand(asText);
}
}
}
/**
* Shutdown the VM.
*
* @param event the event
*/
@Handler(priority = 200)
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
public void onStop(Stop event) {
if (!connected) {
logger.fine(() -> "No guest agent connection,"
+ " cannot send shutdown command");
return;
}
// We have a connection to the guest agent attempt shutdown.
powerdownStartedAt = event.associated(Instant.class).orElseGet(() -> {
var now = Instant.now();
event.setAssociated(Instant.class, now);
return now;
});
var waitUntil = powerdownStartedAt.plusSeconds(powerdownTimeout);
if (waitUntil.isBefore(Instant.now())) {
return;
}
event.suspendHandling();
suspendedStop = event;
logger.fine(() -> "Attempting shutdown through guest agent,"
+ " waiting for termination until " + waitUntil);
powerdownTimer = Components.schedule(t -> {
logger.fine(() -> "Powerdown timeout reached.");
synchronized (this) {
powerdownTimer = null;
if (suspendedStop != null) {
suspendedStop.resumeHandling();
suspendedStop = null;
}
}
}, waitUntil);
rep().fire(new GuestAgentCommand(new QmpGuestPowerdown()));
}
/**
* On process exited.
*
* @param event the event
*/
@Handler
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
public void onProcessExited(ProcessExited event) {
if (!event.startedBy().associated(CommandDefinition.class)
.map(cd -> ProcessName.QEMU.equals(cd.name())).orElse(false)) {
return;
}
synchronized (this) {
if (powerdownTimer != null) {
powerdownTimer.cancel();
}
if (suspendedStop != null) {
suspendedStop.resumeHandling();
suspendedStop = null;
}
}
}
/**
* On configure qemu.
*
* @param event the event
*/
@Handler
@SuppressWarnings("PMD.AvoidSynchronizedStatement")
public void onConfigureQemu(ConfigureQemu event) {
int newTimeout = event.configuration().vm.powerdownTimeout;
if (powerdownTimeout != newTimeout) {
powerdownTimeout = newTimeout;
synchronized (this) {
if (powerdownTimer != null) {
powerdownTimer
.reschedule(powerdownStartedAt.plusSeconds(newTimeout));
}
}
}
}
}

View file

@ -1,249 +0,0 @@
/*
* 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 com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.Writer;
import java.lang.reflect.UndeclaredThrowableException;
import java.net.UnixDomainSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import org.jgrapes.core.Channel;
import org.jgrapes.core.Component;
import org.jgrapes.core.EventPipeline;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.core.events.Start;
import org.jgrapes.core.events.Stop;
import org.jgrapes.io.events.Closed;
import org.jgrapes.io.events.ConnectError;
import org.jgrapes.io.events.Input;
import org.jgrapes.io.events.OpenSocketConnection;
import org.jgrapes.io.util.ByteBufferWriter;
import org.jgrapes.io.util.LineCollector;
import org.jgrapes.net.SocketIOChannel;
import org.jgrapes.net.events.ClientConnected;
import org.jgrapes.util.events.ConfigurationUpdate;
import org.jgrapes.util.events.FileChanged;
import org.jgrapes.util.events.WatchFile;
/**
* A component that handles the communication with QEMU over a socket.
*
* Derived classes should log the messages exchanged on the socket
* if the log level is set to fine.
*/
public abstract class QemuConnector extends Component {
@SuppressWarnings("PMD.FieldNamingConventions")
protected static final ObjectMapper mapper = new ObjectMapper();
private EventPipeline rep;
private Path socketPath;
private SocketIOChannel qemuChannel;
/**
* Instantiates a new QEMU connector.
*
* @param componentChannel the component channel
* @throws IOException Signals that an I/O exception has occurred.
*/
public QemuConnector(Channel componentChannel) throws IOException {
super(componentChannel);
}
/**
* As the initial configuration of this component depends on the
* configuration of the {@link Runner}, it doesn't have a handler
* for the {@link ConfigurationUpdate} event. The values are
* forwarded from the {@link Runner} instead.
*
* @param socketPath the socket path
*/
/* default */ void configure(Path socketPath) {
this.socketPath = socketPath;
logger.fine(() -> getClass().getSimpleName()
+ " configured with socketPath=" + socketPath);
}
/**
* Note the runner's event processor and delete the socket.
*
* @param event the event
* @throws IOException Signals that an I/O exception has occurred.
*/
@Handler
public void onStart(Start event) throws IOException {
rep = event.associated(EventPipeline.class).get();
if (socketPath == null) {
return;
}
Files.deleteIfExists(socketPath);
fire(new WatchFile(socketPath));
}
/**
* Return the runner's event pipeline.
*
* @return the event pipeline
*/
protected EventPipeline rep() {
return rep;
}
/**
* Watch for the creation of the swtpm socket and start the
* qemu process if it has been created.
*
* @param event the event
*/
@Handler
public void onFileChanged(FileChanged event) {
if (event.change() == FileChanged.Kind.CREATED
&& event.path().equals(socketPath)) {
// qemu running, open socket
fire(new OpenSocketConnection(
UnixDomainSocketAddress.of(socketPath))
.setAssociated(this, this));
}
}
/**
* Check if this is from opening the agent socket and if true,
* save the socket in the context and associate the channel with
* the context.
*
* @param event the event
* @param channel the channel
*/
@SuppressWarnings("resource")
@Handler
public void onClientConnected(ClientConnected event,
SocketIOChannel channel) {
event.openEvent().associated(this, getClass()).ifPresent(qc -> {
qemuChannel = channel;
channel.setAssociated(this, this);
channel.setAssociated(Writer.class, new ByteBufferWriter(
channel).nativeCharset());
channel.setAssociated(LineCollector.class,
new LineCollector()
.consumer(line -> {
try {
qc.processInput(line);
} catch (IOException e) {
throw new UndeclaredThrowableException(e);
}
}));
qc.socketConnected();
});
}
/**
* Return the QEMU channel if the connection has been established.
*
* @return the socket IO channel
*/
protected Optional<SocketIOChannel> qemuChannel() {
return Optional.ofNullable(qemuChannel);
}
/**
* Return the {@link Writer} for the connection if the connection
* has been established.
*
* @return the optional
*/
protected Optional<Writer> writer() {
return qemuChannel().flatMap(c -> c.associated(Writer.class));
}
/**
* Send the given command to QEMU. A newline is appended to the
* command automatically.
*
* @param command the command
* @return true, if successful
* @throws IOException Signals that an I/O exception has occurred.
*/
protected boolean sendCommand(String command) throws IOException {
if (writer().isEmpty()) {
return false;
}
writer().get().append(command).append('\n').flush();
return true;
}
/**
* Called when the connector has been connected to the socket.
*/
@SuppressWarnings("PMD.EmptyMethodInAbstractClassShouldBeAbstract")
protected void socketConnected() {
// Default is to do nothing.
}
/**
* Called when a connection attempt fails.
*
* @param event the event
*/
@Handler
public void onConnectError(ConnectError event) {
event.event().associated(this, getClass()).ifPresent(qc -> {
rep.fire(new Stop());
});
}
/**
* Handle data from the socket connection.
*
* @param event the event
* @param channel the channel
*/
@Handler
public void onInput(Input<?> event, SocketIOChannel channel) {
if (channel.associated(this, getClass()).isEmpty()) {
return;
}
channel.associated(LineCollector.class).ifPresent(collector -> {
collector.feed(event);
});
}
/**
* Process agent input.
*
* @param line the line
* @throws IOException Signals that an I/O exception has occurred.
*/
protected abstract void processInput(String line) throws IOException;
/**
* On closed.
*
* @param event the event
* @param channel the channel
*/
@Handler
public void onClosed(Closed<?> event, SocketIOChannel channel) {
channel.associated(this, getClass()).ifPresent(qc -> {
qemuChannel = null;
});
}
}

Some files were not shown because too many files have changed in this diff Show more