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