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}