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}