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}