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.Configuration; 023import freemarker.template.TemplateException; 024import io.kubernetes.client.custom.V1Patch; 025import io.kubernetes.client.openapi.ApiClient; 026import io.kubernetes.client.openapi.ApiException; 027import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi; 028import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject; 029import io.kubernetes.client.util.generic.dynamic.Dynamics; 030import io.kubernetes.client.util.generic.options.ListOptions; 031import io.kubernetes.client.util.generic.options.PatchOptions; 032import java.io.IOException; 033import java.io.StringWriter; 034import java.util.Map; 035import java.util.logging.Logger; 036import org.jdrupes.vmoperator.common.K8s; 037import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; 038import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; 039import org.jdrupes.vmoperator.manager.events.VmChannel; 040import org.jdrupes.vmoperator.util.DataPath; 041import org.jdrupes.vmoperator.util.GsonPtr; 042import org.yaml.snakeyaml.LoaderOptions; 043import org.yaml.snakeyaml.Yaml; 044import org.yaml.snakeyaml.constructor.SafeConstructor; 045 046/** 047 * Delegee for reconciling the config map 048 */ 049@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 050/* default */ class ConfigMapReconciler { 051 052 protected final Logger logger = Logger.getLogger(getClass().getName()); 053 private final Configuration fmConfig; 054 055 /** 056 * Instantiates a new config map reconciler. 057 * 058 * @param fmConfig the fm config 059 */ 060 public ConfigMapReconciler(Configuration fmConfig) { 061 this.fmConfig = fmConfig; 062 } 063 064 /** 065 * Reconcile. 066 * 067 * @param model the model 068 * @param channel the channel 069 * @return the dynamic kubernetes object 070 * @throws IOException Signals that an I/O exception has occurred. 071 * @throws TemplateException the template exception 072 * @throws ApiException the api exception 073 */ 074 public Map<String, Object> reconcile(Map<String, Object> model, 075 VmChannel channel) 076 throws IOException, TemplateException, ApiException { 077 // Combine template and data and parse result 078 var fmTemplate = fmConfig.getTemplate("runnerConfig.ftl.yaml"); 079 StringWriter out = new StringWriter(); 080 fmTemplate.process(model, out); 081 // Avoid Yaml.load due to 082 // https://github.com/kubernetes-client/java/issues/2741 083 var mapDef = Dynamics.newFromYaml( 084 new Yaml(new SafeConstructor(new LoaderOptions())), out.toString()); 085 086 // Maybe override logging.properties from reconciler configuration. 087 DataPath.<String> get(model, "reconciler", "loggingProperties") 088 .ifPresent(props -> { 089 GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") 090 .get().addProperty("logging.properties", props); 091 }); 092 093 // Maybe override logging.properties from VM definition. 094 DataPath.<String> get(model, "cr", "spec", "loggingProperties") 095 .ifPresent(props -> { 096 GsonPtr.to(mapDef.getRaw()).get(JsonObject.class, "data") 097 .get().addProperty("logging.properties", props); 098 }); 099 100 // Get API 101 DynamicKubernetesApi cmApi = new DynamicKubernetesApi("", "v1", 102 "configmaps", channel.client()); 103 104 // Apply and maybe force pod update 105 var newState = K8s.apply(cmApi, mapDef, mapDef.getRaw().toString()); 106 maybeForceUpdate(channel.client(), newState); 107 @SuppressWarnings("unchecked") 108 var res = (Map<String, Object>) channel.client().getJSON().getGson() 109 .fromJson(newState.getRaw(), Map.class); 110 return res; 111 } 112 113 /** 114 * Triggers update of config map mounted in pod 115 * See https://ahmet.im/blog/kubernetes-secret-volumes-delay/ 116 * @param client 117 * 118 * @param newCm 119 */ 120 private void maybeForceUpdate(ApiClient client, 121 DynamicKubernetesObject newCm) { 122 ListOptions listOpts = new ListOptions(); 123 listOpts.setLabelSelector( 124 "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," 125 + "app.kubernetes.io/name=" + APP_NAME + "," 126 + "app.kubernetes.io/instance=" + newCm.getMetadata() 127 .getLabels().get("app.kubernetes.io/instance")); 128 // Get pod, selected by label 129 var podApi = new DynamicKubernetesApi("", "v1", "pods", client); 130 var pods = podApi 131 .list(newCm.getMetadata().getNamespace(), listOpts).getObject(); 132 133 // If the VM is being created, the pod may not exist yet. 134 if (pods == null || pods.getItems().isEmpty()) { 135 return; 136 } 137 var pod = pods.getItems().get(0); 138 139 // Patch pod annotation 140 PatchOptions patchOpts = new PatchOptions(); 141 patchOpts.setFieldManager("kubernetes-java-kubectl-apply"); 142 var podMeta = pod.getMetadata(); 143 var res = podApi.patch(podMeta.getNamespace(), podMeta.getName(), 144 V1Patch.PATCH_FORMAT_JSON_PATCH, 145 new V1Patch("[{\"op\": \"replace\", \"path\": " 146 + "\"/metadata/annotations/vmrunner.jdrupes.org~1cmVersion\", " 147 + "\"value\": \"" + newCm.getMetadata().getResourceVersion() 148 + "\"}]"), 149 patchOpts); 150 if (!res.isSuccess()) { 151 logger.warning( 152 () -> "Cannot patch pod annotations: " + res.getStatus()); 153 } 154 } 155 156}