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.JsonObject; 022import freemarker.template.AdapterTemplateModel; 023import freemarker.template.Configuration; 024import freemarker.template.TemplateException; 025import freemarker.template.TemplateMethodModelEx; 026import freemarker.template.TemplateModel; 027import freemarker.template.TemplateModelException; 028import freemarker.template.utility.DeepUnwrap; 029import io.kubernetes.client.custom.V1Patch; 030import io.kubernetes.client.openapi.ApiClient; 031import io.kubernetes.client.openapi.ApiException; 032import io.kubernetes.client.openapi.models.V1ObjectMeta; 033import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; 034import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; 035import io.kubernetes.client.util.generic.dynamic.Dynamics; 036import io.kubernetes.client.util.generic.options.ListOptions; 037import io.kubernetes.client.util.generic.options.PatchOptions; 038import java.io.IOException; 039import java.io.StringWriter; 040import java.util.HashMap; 041import java.util.List; 042import java.util.Map; 043import java.util.Objects; 044import java.util.Optional; 045import java.util.logging.Logger; 046import org.jdrupes.vmoperator.common.K8s; 047import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; 048import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; 049import org.jdrupes.vmoperator.manager.events.VmChannel; 050import org.jdrupes.vmoperator.util.DataPath; 051import org.jdrupes.vmoperator.util.GsonPtr; 052import org.yaml.snakeyaml.LoaderOptions; 053import org.yaml.snakeyaml.Yaml; 054import org.yaml.snakeyaml.constructor.SafeConstructor; 055 056/** 057 * Delegee for reconciling the config map 058 */ 059/* default */ class ConfigMapReconciler { 060 061 protected final Logger logger = Logger.getLogger(getClass().getName()); 062 private final Configuration fmConfig; 063 064 /** 065 * Instantiates a new config map reconciler. 066 * 067 * @param fmConfig the fm config 068 */ 069 public ConfigMapReconciler(Configuration fmConfig) { 070 this.fmConfig = fmConfig; 071 } 072 073 /** 074 * Reconcile. 075 * 076 * @param model the model 077 * @param channel the channel 078 * @param modelChanged the model has changed 079 * @throws IOException Signals that an I/O exception has occurred. 080 * @throws TemplateException the template exception 081 * @throws ApiException the API exception 082 */ 083 public void reconcile(Map<String, Object> model, VmChannel channel, 084 boolean modelChanged) 085 throws IOException, TemplateException, ApiException { 086 // Check if an update is needed 087 var prevData = channel.associated(PrevData.class) 088 .orElseGet(() -> new PrevData(null, new HashMap<>())); 089 Object newInputs = model.get("loginRequestedFor"); 090 if (!modelChanged && Objects.equals(prevData.inputs, newInputs)) { 091 // Make added data available in new model 092 model.putAll(prevData.added); 093 return; 094 } 095 prevData = new PrevData(newInputs, prevData.added); 096 channel.setAssociated(PrevData.class, prevData); 097 098 // Combine template and data and parse result 099 logger.fine(() -> "Create/update configmap " 100 + DataPath.<String> get(model, "cr", "name").orElse("unknown")); 101 model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); 102 prevData.added.put("adjustCloudInitMeta", adjustCloudInitMetaModel); 103 var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); 104 StringWriter out = new StringWriter(); 105 fmTemplate.process(model, out); 106 // Avoid Yaml.load due to 107 // https://github.com/kubernetes-client/java/issues/2741 108 var newCm = Dynamics.newFromYaml( 109 new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); 110 111 // Maybe override logging.properties from reconciler configuration. 112 DataPath.<String> get(model, "reconciler", "loggingProperties") 113 .ifPresent(props -> { 114 GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") 115 .get().addProperty("logging.properties", props); 116 }); 117 118 // Maybe override logging.properties from VM definition. 119 DataPath.<String> get(model, "cr", "spec", "loggingProperties") 120 .ifPresent(props -> { 121 GsonPtr.to(newCm.getRaw()).getAs(JsonObject.class, "data") 122 .get().addProperty("logging.properties", props); 123 }); 124 125 // Get API and update 126 DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", 127 "configmaps", channel.client()); 128 129 // Apply and maybe force pod update 130 var updatedCm = K8s.apply(cmApi, newCm, newCm.getRaw().toString()); 131 maybeForceUpdate(channel.client(), updatedCm); 132 model.put("configMapResourceVersion", 133 updatedCm.getMetadata().getResourceVersion()); 134 prevData.added.put("configMapResourceVersion", 135 updatedCm.getMetadata().getResourceVersion()); 136 } 137 138 /** 139 * Key for association. 140 */ 141 private record PrevData(Object inputs, Map<String, Object> added) { 142 } 143 144 /** 145 * Triggers update of config map mounted in pod 146 * See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ 147 * @param client 148 * 149 * @param newCm 150 */ 151 private void maybeForceUpdate(ApiClient client, 152 DynamicKubernetesObject newCm) { 153 ListOptions listOpts = new ListOptions(); 154 listOpts.setLabelSelector( 155 "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," 156 + "app.kubernetes.io/name=" + APP_NAME + "," 157 + "app.kubernetes.io/instance=" + newCm.getMetadata() 158 .getLabels().get("app.kubernetes.io/instance")); 159 // Get pod, selected by label 160 var podApi = new DynamicKubernetesApi("", "v1", "pods", client); 161 var pods = podApi 162 .list(newCm.getMetadata().getNamespace(), listOpts).getObject(); 163 164 // If the VM is being created, the pod may not exist yet. 165 if (pods == null || pods.getItems().isEmpty()) { 166 return; 167 } 168 var pod = pods.getItems().get(0); 169 170 // Patch pod annotation 171 PatchOptions patchOpts = new PatchOptions(); 172 patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); 173 var podMeta = pod.getMetadata(); 174 var res = podApi.patch(podMeta.getNamespace(), podMeta.getName(), 175 V1Patch.PATCH_FORMAT_JSON_PATCH, 176 new V1Patch("[{\"op\": \"replace\", \"path\": " 177 + "\"/metadata/annotations/vmrunner.jdrupes.org~1cmVersion\", " 178 + "\"value\": \"" + newCm.getMetadata().getResourceVersion() 179 + "\"}]"), 180 patchOpts); 181 if (!res.isSuccess()) { 182 logger.warning( 183 () -> "Cannot patch pod annotations: " + res.getStatus()); 184 } 185 } 186 187 private final TemplateMethodModelEx adjustCloudInitMetaModel 188 = new TemplateMethodModelEx() { 189 @Override 190 public Object exec(@SuppressWarnings("rawtypes") List arguments) 191 throws TemplateModelException { 192 @SuppressWarnings("unchecked") 193 var res = new HashMap<>((Map<String, Object>) DeepUnwrap 194 .unwrap((TemplateModel) arguments.get(0))); 195 var metadata 196 = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) 197 .getAdaptedObject(Object.class); 198 if (!res.containsKey("instance-id")) { 199 res.put("instance-id", 200 Optional.ofNullable(metadata.getGeneration()) 201 .map(s -> "v" + s).orElse("v1")); 202 } 203 if (!res.containsKey("local-hostname")) { 204 res.put("local-hostname", metadata.getName()); 205 } 206 return res; 207 } 208 }; 209 210}