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