001/*
002 * VM-Operator
003 * Copyright (C) 2023 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.vmoperator.manager;
020
021import com.google.gson.JsonElement;
022import com.google.gson.JsonObject;
023import freemarker.core.ParseException;
024import freemarker.template.Configuration;
025import freemarker.template.MalformedTemplateNameException;
026import freemarker.template.TemplateException;
027import freemarker.template.TemplateNotFoundException;
028import io.kubernetes.client.custom.V1Patch;
029import io.kubernetes.client.openapi.ApiException;
030import io.kubernetes.client.util.generic.dynamic.Dynamics;
031import io.kubernetes.client.util.generic.options.ListOptions;
032import io.kubernetes.client.util.generic.options.PatchOptions;
033import java.io.IOException;
034import java.io.StringWriter;
035import java.util.Map;
036import java.util.Set;
037import java.util.logging.Logger;
038import java.util.stream.Collectors;
039import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
040import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
041import org.jdrupes.vmoperator.common.K8sV1PvcStub;
042import org.jdrupes.vmoperator.manager.events.VmChannel;
043import org.jdrupes.vmoperator.manager.events.VmDefChanged;
044import org.jdrupes.vmoperator.util.GsonPtr;
045import org.yaml.snakeyaml.LoaderOptions;
046import org.yaml.snakeyaml.Yaml;
047import org.yaml.snakeyaml.constructor.SafeConstructor;
048
049/**
050 * Delegee for reconciling the stateful set (effectively the pod).
051 */
052@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
053/* default */ class PvcReconciler {
054
055    protected final Logger logger = Logger.getLogger(getClass().getName());
056    private final Configuration fmConfig;
057
058    /**
059     * Instantiates a new pvc reconciler.
060     *
061     * @param fmConfig the fm config
062     */
063    public PvcReconciler(Configuration fmConfig) {
064        this.fmConfig = fmConfig;
065    }
066
067    /**
068     * Reconcile the PVCs.
069     *
070     * @param event the event
071     * @param model the model
072     * @param channel the channel
073     * @throws IOException Signals that an I/O exception has occurred.
074     * @throws TemplateException the template exception
075     * @throws ApiException the api exception
076     */
077    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
078    public void reconcile(VmDefChanged event, Map<String, Object> model,
079            VmChannel channel)
080            throws IOException, TemplateException, ApiException {
081        var metadata = event.vmDefinition().getMetadata();
082
083        // Existing disks
084        ListOptions listOpts = new ListOptions();
085        listOpts.setLabelSelector(
086            "app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
087                + "app.kubernetes.io/name=" + APP_NAME + ","
088                + "app.kubernetes.io/instance=" + metadata.getName());
089        var knownDisks = K8sV1PvcStub.list(channel.client(),
090            metadata.getNamespace(), listOpts);
091        var knownPvcs = knownDisks.stream().map(K8sV1PvcStub::name)
092            .collect(Collectors.toSet());
093
094        // Reconcile runner data pvc
095        reconcileRunnerDataPvc(event, model, channel, knownPvcs);
096
097        // Reconcile pvcs for defined disks
098        var diskDefs = GsonPtr.to((JsonObject) model.get("cr"))
099            .getAsListOf(JsonObject.class, "spec", "vm", "disks");
100        var diskCounter = 0;
101        for (var diskDef : diskDefs) {
102            if (!diskDef.has("volumeClaimTemplate")) {
103                continue;
104            }
105            var diskName = GsonPtr.to(diskDef)
106                .getAsString("volumeClaimTemplate", "metadata", "name")
107                .map(name -> name + "-disk").orElse("disk-" + diskCounter);
108            diskCounter += 1;
109            diskDef.addProperty("generatedDiskName", diskName);
110
111            // Don't do anything if pvc with old (sts generated) name exists.
112            var stsDiskPvcName = diskName + "-" + metadata.getName() + "-0";
113            if (knownPvcs.contains(stsDiskPvcName)) {
114                diskDef.addProperty("generatedPvcName", stsDiskPvcName);
115                continue;
116            }
117
118            // Update PVC
119            model.put("disk", diskDef);
120            reconcileRunnerDiskPvc(event, model, channel);
121        }
122        model.remove("disk");
123    }
124
125    private void reconcileRunnerDataPvc(VmDefChanged event,
126            Map<String, Object> model, VmChannel channel,
127            Set<String> knownPvcs)
128            throws TemplateNotFoundException, MalformedTemplateNameException,
129            ParseException, IOException, TemplateException, ApiException {
130        var metadata = event.vmDefinition().getMetadata();
131
132        // Look for old (sts generated) name.
133        var stsRunnerDataPvcName
134            = "runner-data" + "-" + metadata.getName() + "-0";
135        if (knownPvcs.contains(stsRunnerDataPvcName)) {
136            model.put("runnerDataPvcName", stsRunnerDataPvcName);
137            return;
138        }
139
140        // Generate PVC
141        model.put("runnerDataPvcName", metadata.getName() + "-runner-data");
142        var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml");
143        StringWriter out = new StringWriter();
144        fmTemplate.process(model, out);
145        // Avoid Yaml.load due to
146        // https://github.com/kubernetes-client/java/issues/2741
147        var pvcDef = Dynamics.newFromYaml(
148            new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
149
150        // Do apply changes
151        var pvcStub = K8sV1PvcStub.get(channel.client(),
152            metadata.getNamespace(), (String) model.get("runnerDataPvcName"));
153        PatchOptions opts = new PatchOptions();
154        opts.setForce(true);
155        opts.setFieldManager("kubernetes-java-kubectl-apply");
156        if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
157            new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts)
158            .isEmpty()) {
159            logger.warning(
160                () -> "Could not patch pvc for " + pvcStub.name());
161        }
162    }
163
164    private void reconcileRunnerDiskPvc(VmDefChanged event,
165            Map<String, Object> model, VmChannel channel)
166            throws TemplateNotFoundException, MalformedTemplateNameException,
167            ParseException, IOException, TemplateException, ApiException {
168        var metadata = event.vmDefinition().getMetadata();
169
170        // Generate PVC
171        var diskDef = GsonPtr.to((JsonElement) model.get("disk"));
172        var pvcName = metadata.getName() + "-"
173            + diskDef.getAsString("generatedDiskName").get();
174        diskDef.set("generatedPvcName", pvcName);
175        var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml");
176        StringWriter out = new StringWriter();
177        fmTemplate.process(model, out);
178        // Avoid Yaml.load due to
179        // https://github.com/kubernetes-client/java/issues/2741
180        var pvcDef = Dynamics.newFromYaml(
181            new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
182
183        // Do apply changes
184        var pvcStub = K8sV1PvcStub.get(channel.client(),
185            metadata.getNamespace(), GsonPtr.to((JsonElement) model.get("disk"))
186                .getAsString("generatedPvcName").get());
187        PatchOptions opts = new PatchOptions();
188        opts.setForce(true);
189        opts.setFieldManager("kubernetes-java-kubectl-apply");
190        if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
191            new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts)
192            .isEmpty()) {
193            logger.warning(
194                () -> "Could not patch pvc for " + pvcStub.name());
195        }
196    }
197}