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 freemarker.core.ParseException;
022import freemarker.template.Configuration;
023import freemarker.template.MalformedTemplateNameException;
024import freemarker.template.TemplateException;
025import freemarker.template.TemplateNotFoundException;
026import io.kubernetes.client.custom.V1Patch;
027import io.kubernetes.client.openapi.ApiException;
028import io.kubernetes.client.util.generic.dynamic.Dynamics;
029import io.kubernetes.client.util.generic.options.ListOptions;
030import io.kubernetes.client.util.generic.options.PatchOptions;
031import java.io.IOException;
032import java.io.StringWriter;
033import java.util.List;
034import java.util.Map;
035import java.util.Set;
036import java.util.logging.Logger;
037import java.util.stream.Collectors;
038import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
039import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
040import org.jdrupes.vmoperator.common.K8sV1PvcStub;
041import org.jdrupes.vmoperator.common.VmDefinition;
042import org.jdrupes.vmoperator.manager.events.VmChannel;
043import org.jdrupes.vmoperator.util.DataPath;
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 vmDef the VM definition
071     * @param model the model
072     * @param channel the channel
073     * @param specChanged the spec changed
074     * @throws IOException Signals that an I/O exception has occurred.
075     * @throws TemplateException the template exception
076     * @throws ApiException the api exception
077     */
078    @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" })
079    public void reconcile(VmDefinition vmDef, Map<String, Object> model,
080            VmChannel channel, boolean specChanged)
081            throws IOException, TemplateException, ApiException {
082        Set<String> knownPvcs;
083        if (!specChanged && channel.associated(this, Set.class).isPresent()) {
084            knownPvcs = (Set<String>) channel.associated(this, Set.class).get();
085        } else {
086            ListOptions listOpts = new ListOptions();
087            listOpts.setLabelSelector(
088                "app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
089                    + "app.kubernetes.io/name=" + APP_NAME + ","
090                    + "app.kubernetes.io/instance=" + vmDef.name());
091            knownPvcs = K8sV1PvcStub.list(channel.client(),
092                vmDef.namespace(), listOpts).stream().map(K8sV1PvcStub::name)
093                .collect(Collectors.toSet());
094            channel.setAssociated(this, knownPvcs);
095        }
096
097        // Reconcile runner data pvc
098        reconcileRunnerDataPvc(vmDef, model, channel, knownPvcs, specChanged);
099
100        // Reconcile pvcs for defined disks
101        var diskDefs = vmDef.<List<Map<String, Object>>> fromVm("disks")
102            .orElse(List.of());
103        var diskCounter = 0;
104        for (var diskDef : diskDefs) {
105            if (!diskDef.containsKey("volumeClaimTemplate")) {
106                continue;
107            }
108            var diskName = DataPath.get(diskDef, "volumeClaimTemplate",
109                "metadata", "name").map(name -> name + "-disk")
110                .orElse("disk-" + diskCounter);
111            diskCounter += 1;
112            diskDef.put("generatedDiskName", diskName);
113
114            // Don't do anything if pvc with old (sts generated) name exists.
115            var stsDiskPvcName = diskName + "-" + vmDef.name() + "-0";
116            if (knownPvcs.contains(stsDiskPvcName)) {
117                diskDef.put("generatedPvcName", stsDiskPvcName);
118                continue;
119            }
120
121            // Update PVC
122            reconcileRunnerDiskPvc(vmDef, model, channel, specChanged, diskDef);
123        }
124    }
125
126    private void reconcileRunnerDataPvc(VmDefinition vmDef,
127            Map<String, Object> model, VmChannel channel,
128            Set<String> knownPvcs, boolean specChanged)
129            throws TemplateNotFoundException, MalformedTemplateNameException,
130            ParseException, IOException, TemplateException, ApiException {
131
132        // Look for old (sts generated) name.
133        var stsRunnerDataPvcName
134            = "runner-data" + "-" + vmDef.name() + "-0";
135        if (knownPvcs.contains(stsRunnerDataPvcName)) {
136            model.put("runnerDataPvcName", stsRunnerDataPvcName);
137            return;
138        }
139
140        // Generate PVC
141        var runnerDataPvcName = vmDef.name() + "-runner-data";
142        logger.fine(() -> "Create/update pvc " + runnerDataPvcName);
143        model.put("runnerDataPvcName", runnerDataPvcName);
144        if (!specChanged) {
145            // Augmenting the model is all we have to do
146            return;
147        }
148        var fmTemplate = fmConfig.getTemplate("runnerDataPvc.ftl.yaml");
149        StringWriter out = new StringWriter();
150        fmTemplate.process(model, out);
151        // Avoid Yaml.load due to
152        // https://github.com/kubernetes-client/java/issues/2741
153        var pvcDef = Dynamics.newFromYaml(
154            new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
155
156        // Do apply changes
157        var pvcStub = K8sV1PvcStub.get(channel.client(),
158            vmDef.namespace(), (String) model.get("runnerDataPvcName"));
159        PatchOptions opts = new PatchOptions();
160        opts.setForce(true);
161        opts.setFieldManager("kubernetes-java-kubectl-apply");
162        if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
163            new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts)
164            .isEmpty()) {
165            logger.warning(
166                () -> "Could not patch pvc for " + pvcStub.name());
167        }
168    }
169
170    private void reconcileRunnerDiskPvc(VmDefinition vmDef,
171            Map<String, Object> model, VmChannel channel, boolean specChanged,
172            Map<String, Object> diskDef)
173            throws TemplateNotFoundException, MalformedTemplateNameException,
174            ParseException, IOException, TemplateException, ApiException {
175        // Generate PVC
176        var pvcName = vmDef.name() + "-" + diskDef.get("generatedDiskName");
177        diskDef.put("generatedPvcName", pvcName);
178        if (!specChanged) {
179            // Augmenting the model is all we have to do
180            return;
181        }
182
183        // Generate PVC
184        logger.fine(() -> "Create/update pvc " + pvcName);
185        model.put("disk", diskDef);
186        var fmTemplate = fmConfig.getTemplate("runnerDiskPvc.ftl.yaml");
187        StringWriter out = new StringWriter();
188        fmTemplate.process(model, out);
189        model.remove("disk");
190        // Avoid Yaml.load due to
191        // https://github.com/kubernetes-client/java/issues/2741
192        var pvcDef = Dynamics.newFromYaml(
193            new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
194
195        // Apply changes
196        var pvcStub
197            = K8sV1PvcStub.get(channel.client(), vmDef.namespace(), pvcName);
198        var pvc = pvcStub.model();
199        if (pvc.isEmpty()
200            || !"Bound".equals(pvc.get().getStatus().getPhase())) {
201            // Does not exist or isn't bound, use apply
202            PatchOptions opts = new PatchOptions();
203            opts.setForce(true);
204            opts.setFieldManager("kubernetes-java-kubectl-apply");
205            if (pvcStub.patch(V1Patch.PATCH_FORMAT_APPLY_YAML,
206                new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts)
207                .isEmpty()) {
208                logger.warning(
209                    () -> "Could not patch pvc for " + pvcStub.name());
210            }
211            return;
212        }
213
214        // If bound, use json merge, omitting immutable fields
215        var spec = GsonPtr.to(pvcDef.getRaw()).to("spec");
216        spec.removeExcept("volumeAttributesClassName", "resources");
217        spec.get("resources").ifPresent(p -> p.removeExcept("requests"));
218        PatchOptions opts = new PatchOptions();
219        opts.setFieldManager("kubernetes-java-kubectl-apply");
220        if (pvcStub.patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH,
221            new V1Patch(channel.client().getJSON().serialize(pvcDef)), opts)
222            .isEmpty()) {
223            logger.warning(
224                () -> "Could not patch pvc for " + pvcStub.name());
225        }
226    }
227}