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}