diff --git a/deploy/crds/vmoperator-crd.yaml b/deploy/crds/vmoperator-crd.yaml index ae11f95..1cfb88f 100644 --- a/deploy/crds/vmoperator-crd.yaml +++ b/deploy/crds/vmoperator-crd.yaml @@ -78,20 +78,18 @@ spec: maximumCpus: description: >- Either maximumCpus or cpuTopology may be specified. - If currentCpus is greater than maximumCpus, the - latter is adjusted. Setting maximumCpus to 1 omits - the "-smp" options. + If neither is specified, maximum cpus is set to 4. + Setting maximumCpus to 1 omits the "-smp" options. type: integer - default: 4 cpuTopology: description: >- - The defaults (0) cause the corresponding property + Values of 0 cause the corresponding property to be omitted from the "-smp" option. type: object properties: sockets: type: integer - default: 0 + default: 1 diesPerSocket: type: integer default: 0 @@ -102,8 +100,9 @@ spec: type: integer default: 0 currentCpus: + description: >- + Defaults to maximumCpus. type: integer - default: 2 maximumRam: type: string default: "1G" @@ -113,6 +112,10 @@ spec: description: Passed to Qemu unmodified. type: string default: "utc" + rtcClock: + description: Passed to Qemu unmodified. + type: string + default: "rt" networks: type: array items: @@ -136,12 +139,17 @@ spec: user: type: object properties: + device: + description: The device to use. + type: string + default: "virtio-net" net: type: string oneOf: - properties: tap: user: + default: [] disks: description: >- Disks make persistent storage available. The @@ -151,8 +159,6 @@ spec: items: type: object properties: - hostDevice: - type: string volumeClaimTemplate: description: >- A PVC spec to be used to provide the disk. The easiest @@ -180,36 +186,8 @@ spec: description: >- EmbeddedMetadata contains metadata relevant to an EmbeddedResource. + type: object properties: - annotations: - additionalProperties: - type: string - description: >- - Annotations is an unstructured key value - map stored with a resource that may be set by external - tools to store and retrieve arbitrary metadata. They - are not queryable and should be preserved when modifying - objects. More info: http://kubernetes.io/docs/user-guide/annotations - type: object - labels: - additionalProperties: - type: string - description: >- - Map of string keys and values that can be - used to organize and categorize (scope and select) objects. - May match selectors of replication controllers and services. - More info: http://kubernetes.io/docs/user-guide/labels - type: object - name: - description: >- - Name must be unique within a namespace. - Is required when creating resources, although some resources - may allow a client to request the generation of an appropriate - name automatically. Name is primarily intended for creation - idempotence and configuration definition. Cannot be - updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names. - The name is generated automatically but can be overriden. - type: string namespace: description: >- Namespace defines the space within which each @@ -221,7 +199,35 @@ spec: More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces. The default value is the VM's namespace. type: string - type: object + name: + description: >- + Name must be unique within a namespace. + Is required when creating resources, although some resources + may allow a client to request the generation of an appropriate + name automatically. Name is primarily intended for creation + idempotence and configuration definition. Cannot be + updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names. + The name is generated automatically but can be overriden. + type: string + labels: + description: >- + Map of string keys and values that can be + used to organize and categorize (scope and select) objects. + May match selectors of replication controllers and services. + More info: http://kubernetes.io/docs/user-guide/labels + type: object + additionalProperties: + type: string + annotations: + description: >- + Annotations is an unstructured key value + map stored with a resource that may be set by external + tools to store and retrieve arbitrary metadata. They + are not queryable and should be preserved when modifying + objects. More info: http://kubernetes.io/docs/user-guide/annotations + type: object + additionalProperties: + type: string spec: description: >- Spec defines the desired characteristics of @@ -420,24 +426,26 @@ spec: type: object bootindex: type: integer - displays: - type: array - items: - type: object - properties: - spice: - type: object - properties: - port: - type: integer - default: 5900 - ticket: - type: string - streamingVideo: - type: string - usbRedirects: - type: integer - default: 2 + required: + - volumeClaimTemplate + default: [] + display: + type: object + properties: + spice: + type: object + properties: + port: + type: integer + default: 5900 + ticket: + type: string + streamingVideo: + type: string + usbRedirects: + type: integer + default: 2 + default: { spice: { port: 5900, usbRedirects: 2 } } oneOf: - properties: maximumCpus: diff --git a/deploy/test-vm.yaml b/deploy/test-vm.yaml index feecc6d..5d55076 100644 --- a/deploy/test-vm.yaml +++ b/deploy/test-vm.yaml @@ -26,6 +26,6 @@ spec: storage: 40Gi # - hostDevice: /dev/vgmain/test-vm - displays: - - spice: + display: + spice: port: 5910 diff --git a/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/etcConfig.ftl.yaml b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/etcConfig.ftl.yaml new file mode 100644 index 0000000..20f9a4e --- /dev/null +++ b/org.jdrupes.vmoperator.manager/resources/org/jdrupes/vmoperator/manager/etcConfig.ftl.yaml @@ -0,0 +1,172 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + namespace: ${ metadata.namespace.asString } + name: ${ metadata.name.asString } + labels: + app.kubernetes.io/name: ${ constants.APP_NAME } + app.kubernetes.io/instance: ${ metadata.name.asString } + app.kubernetes.io/managed-by: ${ constants.VM_OP_NAME } + +data: + config.yaml: | + "/Runner": + # The directory used to store data files. Defaults to (depending on + # values available): + # * $XDG_DATA_HOME/vmrunner/${ metadata.name.asString } + # * $HOME/.local/share/vmrunner/${ metadata.name.asString } + # * ./${ metadata.name.asString } + dataDir: /var/local/vm-data + + # The directory used to store runtime files. Defaults to (depending on + # values available): + # * $XDG_RUNTIME_DIR/vmrunner/${ metadata.name.asString } + # * /tmp/$USER/vmrunner/${ metadata.name.asString } + # * /tmp/vmrunner/${ metadata.name.asString } + # runtimeDir: "$XDG_RUNTIME_DIR/vmrunner/${ metadata.name.asString }" + + # The template to use. Resolved relative to /usr/share/vmrunner/templates. + # template: "Standard-VM-latest.ftl.yaml" + + # The template is copied to the data diretory when the VM starts for + # the first time. Subsequent starts use the copy unless this option is set. + updateTemplate: true + + # Define the VM (required) + vm: + # The VM's name (required) + name: ${ metadata.name.asString } + + # The machine's uuid. If none is specified, a uuid is generated + # and stored in the data directory. If the uuid is important + # (e.g. because licenses depend on it) it is recommaned to specify + # it here explicitly or to carefully backup the data directory. + # uuid: "generated uuid" + <#if spec.vm.machineUuid??> + uuid: "${ spec.vm.machineUuid.asString }" + + + # Whether to provide a software TPM (defaults to false) + # useTpm: false + useTpm: ${ spec.vm.useTpm.asBoolean?c } + + # How to boot (see https://github.com/mnlipp/VM-Operator/blob/main/org.jdrupes.vmoperator.runner.qemu/resources/org/jdrupes/vmoperator/runner/qemu/defaults.yaml): + # * bios + # * uefi[-4m] + # * secure[-4m] + firmware: ${ spec.vm.firmware.asString } + + # Whether to show a boot menu. + # bootMenu: false + bootMenu: ${ spec.vm.bootMenu.asBoolean?c } + + # When terminating, a graceful powerdown is attempted. If it + # doesn't succeed within the given timeout (seconds) SIGTERM + # is sent to Qemu. + # powerdownTimeout: 900 + powerdownTimeout: ${ spec.vm.powerdownTimeout.asLong?c } + + # CPU settings + cpuModel: ${ spec.vm.cpuModel.asString } + # Setting maximumCpus to 1 omits the "-smp" options. The defaults (0) + # cause the corresponding property to be omitted from the "-smp" option. + # If currentCpus is greater than maximumCpus, the latter is adjusted. + <#if spec.vm.maximumCpus?? > + maximumCpus: ${ spec.vm.maximumCpus.asInt?c } + + <#if spec.vm.cpuTopology?? > + cpuSockets: ${ spec.vm.cpuTopology.cpuSockets.asInt?c } + diesPerSocket: ${ spec.vm.cpuTopology.diesPerSocket.asInt?c } + coresPerSocket: ${ spec.vm.cpuTopology.coresPerSocket.asInt?c } + threadsPerCore: ${ spec.vm.cpuTopology.threadsPerCore.asInt?c } + + <#if spec.vm.currentCpus?? > + currentCpus: ${ spec.vm.currentCpus.asInt?c } + + + # RAM settings + # Maximum defaults to 1G + maximumRam: "${ spec.vm.maximumRam.asString }" + <#if spec.vm.currentRam?? > + currentRam: "${ spec.vm.currentRam.asString }" + + + # RTC settings. + # rtcBase: utc + # rtcClock: rt + rtcBase: ${ spec.vm.rtcBase.asString } + rtcClock: ${ spec.vm.rtcClock.asString } + + # Network settings + # Supported types are "tap" and "user" (for debugging). Type "user" + # supports only the property "net". + # network: + # - type: tap + # bridge: br0 + # device: virtio-net + # mac: (undefined) + network: + <#assign nwCounter = 0/> + <#list spec.vm.networks.asList() as itf> + <#if itf.tap??> + - type: tap + device: ${ itf.tap.device.asString } + bridge: ${ itf.tap.bridge.asString } + <#if itf.tap.mac??> + mac: "${ itf.tap.mac.asString }" + + <#elseif itf.user??> + - type: user + device: ${ itf.tap.device.asString } + <#if itf.user.net??> + net: "${ itf.user.net.asString }" + + + <#assign nwCounter += 1/> + + + # There are no default drives. The supported types are "ide-cd" + # and "raw". All types support a "bootindex" property. + # Type "raw" can have a property "file" (if backed by a file on + # the host) or a property "device" (if backed by a device). + # drives: + # - type: ide-cd + # bootindex: (undefined) + # file: (undefined) + drives: + <#assign drvCounter = 0/> + <#list spec.vm.disks.asList() as disk> + <#if disk.volumeClaimTemplate.metadata?? + && disk.volumeClaimTemplate.metadata.name??> + <#assign name = disk.volumeClaimTemplate.metadata.name.asString> + <#else> + <#assign name = "" + drvCounter> + + - type: raw + resource: /dev/disk-${ name } + + + display: + <#if spec.vm.display.spice??> + spice: + port: ${ spec.vm.display.spice.port.asInt?c } + <#if spec.vm.display.spice.ticket??> + ticket: "${ spec.vm.display.spice.ticket.asString }" + + <#if spec.vm.display.spice.streamingVideo??> + ticket: "${ spec.vm.display.spice.streamingVideo.asString }" + + usbRedirects: ${ spec.vm.display.spice.usbRedirects.asInt?c } + + + logging.properties: | + handlers=java.util.logging.ConsoleHandler + + #org.jgrapes.level=FINE + #org.jgrapes.core.handlerTracking.level=FINER + + org.jdrupes.vmoperator.runner.qemu.level=FINE + + java.util.logging.ConsoleHandler.level=ALL + java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter + java.util.logging.SimpleFormatter.format=%1$tb %1$td %1$tT %4$s %5$s%6$s%n diff --git a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java index 9eb41d8..1448c12 100644 --- a/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java +++ b/org.jdrupes.vmoperator.manager/src/org/jdrupes/vmoperator/manager/Reconciler.java @@ -21,16 +21,29 @@ package org.jdrupes.vmoperator.manager; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import freemarker.core.ParseException; +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapperBuilder; +import freemarker.template.MalformedTemplateNameException; +import freemarker.template.TemplateException; +import freemarker.template.TemplateExceptionHandler; +import freemarker.template.TemplateHashModel; +import freemarker.template.TemplateNotFoundException; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; import io.kubernetes.client.util.generic.options.PatchOptions; +import java.io.IOException; +import java.io.StringWriter; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_GROUP; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; import static org.jdrupes.vmoperator.manager.Constants.VM_OP_VERSION; +import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; import org.jgrapes.core.Channel; import org.jgrapes.core.Component; import org.jgrapes.core.annotation.Handler; @@ -38,9 +51,12 @@ import org.jgrapes.core.annotation.Handler; /** * Adapts Kubenetes resources to changes in VM definitions (CRs). */ -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", + "PMD.AvoidDuplicateLiterals" }) public class Reconciler extends Component { + private final Configuration fmConfig; + /** * Instantiates a new reconciler. * @@ -48,6 +64,16 @@ public class Reconciler extends Component { */ public Reconciler(Channel componentChannel) { super(componentChannel); + + // Configure freemarker library + fmConfig = new Configuration(Configuration.VERSION_2_3_32); + fmConfig.setDefaultEncoding("utf-8"); + fmConfig.setObjectWrapper(new ExtendedObjectWrapper( + fmConfig.getIncompatibleImprovements())); + fmConfig.setTemplateExceptionHandler( + TemplateExceptionHandler.RETHROW_HANDLER); + fmConfig.setLogTemplateExceptions(false); + fmConfig.setClassForTemplateLoading(Reconciler.class, ""); } /** @@ -56,18 +82,30 @@ public class Reconciler extends Component { * @param event the event * @param channel the channel * @throws ApiException the api exception + * @throws IOException + * @throws ParseException + * @throws MalformedTemplateNameException + * @throws TemplateNotFoundException + * @throws TemplateException * @throws KubectlException */ @Handler public void onVmDefChanged(VmDefChanged event, WatchChannel channel) - throws ApiException { + throws ApiException, TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException, + TemplateException { DynamicKubernetesApi vmDefApi = new DynamicKubernetesApi(VM_OP_GROUP, VM_OP_VERSION, event.crd().getName(), channel.client()); var defMeta = event.metadata(); var vmDef = vmDefApi.get(defMeta.getNamespace(), defMeta.getName()) .getObject(); - @SuppressWarnings("PMD.AvoidDuplicateLiterals") + reconcileDisks(vmDef, channel); + reconcileConfigMap(vmDef, channel); + } + + private void reconcileDisks(DynamicKubernetesObject vmDef, + WatchChannel channel) throws ApiException { var disks = GsonPtr.to(vmDef.getRaw()) .get(JsonArray.class, "spec", "vm", "disks") .map(JsonArray::asList).orElse(Collections.emptyList()); @@ -82,13 +120,13 @@ public class Reconciler extends Component { int index, JsonObject diskDef, WatchChannel channel) throws ApiException { var pvcObject = new DynamicKubernetesObject(); - pvcObject.setApiVersion("v1"); - pvcObject.setKind("PersistentVolumeClaim"); var pvcDef = GsonPtr.to(pvcObject.getRaw()); var vmDef = GsonPtr.to(vmDefinition.getRaw()); var pvcTpl = GsonPtr.to(diskDef).to("volumeClaimTemplate"); - // Copy metadata from template and add missing/additional data. + // Copy base and metadata from template and add missing/additional data. + pvcObject.setApiVersion(pvcTpl.getAsString("apiVersion").get()); + pvcObject.setKind(pvcTpl.getAsString("kind").get()); var vmName = vmDef.getAsString("metadata", "name").orElse("default"); pvcDef.get(JsonObject.class).add("metadata", pvcTpl.to("metadata").get(JsonObject.class).deepCopy()); @@ -114,11 +152,6 @@ public class Reconciler extends Component { // PVC does not exist yet, copy spec from template pvcDef.get(JsonObject.class).add("spec", pvcTpl.to("spec").get(JsonObject.class).deepCopy()); - // Add missing - pvcDef.to("spec").computeIfAbsent("accessModes", - () -> GsonPtr.to(new JsonArray()).set(0, "ReadWriteOnce") - .get()); - pvcDef.to("spec").getOrSet("volumeMode", "Block"); pvcApi.create(pvcObject); } else { // spec is immutable, so mix in existing spec @@ -135,4 +168,34 @@ public class Reconciler extends Component { } } + private void reconcileConfigMap(DynamicKubernetesObject vmDefinition, + WatchChannel channel) throws TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException, + TemplateException, ApiException { + // Combine template and data and parse result + // (tempting, but no need to use a pipe here) + var fmTemplate = fmConfig.getTemplate("etcConfig.ftl.yaml"); + StringWriter out = new StringWriter(); + @SuppressWarnings("PMD.UseConcurrentHashMap") + Map model = new HashMap<>(); + model.putAll(vmDefinition.getRaw().asMap()); + model.put("constants", + (TemplateHashModel) new DefaultObjectWrapperBuilder( + Configuration.VERSION_2_3_32) + .build().getStaticModels().get(Constants.class.getName())); + fmTemplate.process(model, out); + + // Apply + PatchOptions opts = new PatchOptions(); + opts.setForce(false); + opts.setFieldManager("kubernetes-java-kubectl-apply"); + DynamicKubernetesApi pvcApi = new DynamicKubernetesApi("", "v1", + "configmaps", channel.client()); + var vmDef = GsonPtr.to(vmDefinition.getRaw()); + pvcApi.patch(vmDef.getAsString("metadata", "namespace").get(), + vmDef.getAsString("metadata", "name").get(), + V1Patch.PATCH_FORMAT_APPLY_YAML, new V1Patch(out.toString()), + opts).throwsApiException(); + } + }