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.fasterxml.jackson.core.JsonProcessingException;
022import com.fasterxml.jackson.databind.ObjectMapper;
023import freemarker.template.AdapterTemplateModel;
024import freemarker.template.Configuration;
025import freemarker.template.SimpleNumber;
026import freemarker.template.SimpleScalar;
027import freemarker.template.TemplateException;
028import freemarker.template.TemplateExceptionHandler;
029import freemarker.template.TemplateMethodModelEx;
030import freemarker.template.TemplateModel;
031import freemarker.template.TemplateModelException;
032import freemarker.template.utility.DeepUnwrap;
033import io.kubernetes.client.custom.Quantity;
034import io.kubernetes.client.openapi.ApiException;
035import io.kubernetes.client.openapi.models.V1ObjectMeta;
036import io.kubernetes.client.util.generic.options.ListOptions;
037import java.io.IOException;
038import java.lang.reflect.Modifier;
039import java.math.BigDecimal;
040import java.math.BigInteger;
041import java.net.URI;
042import java.net.URISyntaxException;
043import java.util.Arrays;
044import java.util.HashMap;
045import java.util.List;
046import java.util.Map;
047import java.util.Optional;
048import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
049import org.jdrupes.vmoperator.common.Constants.DisplaySecret;
050import org.jdrupes.vmoperator.common.Convertions;
051import org.jdrupes.vmoperator.common.K8sClient;
052import org.jdrupes.vmoperator.common.K8sObserver;
053import org.jdrupes.vmoperator.common.K8sV1SecretStub;
054import org.jdrupes.vmoperator.common.VmDefinition;
055import org.jdrupes.vmoperator.manager.events.ResetVm;
056import org.jdrupes.vmoperator.manager.events.VmChannel;
057import org.jdrupes.vmoperator.manager.events.VmDefChanged;
058import org.jdrupes.vmoperator.util.ExtendedObjectWrapper;
059import org.jgrapes.core.Channel;
060import org.jgrapes.core.Component;
061import org.jgrapes.core.annotation.Handler;
062import org.jgrapes.util.events.ConfigurationUpdate;
063
064/**
065 * Adapts Kubenetes resources for instances of the Runner 
066 * application (the VMs) to changes in VM definitions (the CRs). 
067 * 
068 * In particular, the reconciler generates and updates:  
069 * 
070 * * A [`PVC`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/)
071 *   for storage used by all VMs as a common repository for CDROM images.
072 * 
073 * * A [`ConfigMap`](https://kubernetes.io/docs/concepts/configuration/configmap/)
074 *   that defines the configuration file for the runner.
075 *
076 * * A [`PVC`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/)
077 *   for 1 MiB of persistent storage used by the Runner (referred to as the
078 *   "runnerDataPvc")
079 *   
080 * * The PVCs for the VM's disks.
081 *
082 * * A [`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/) with the
083 *   runner instance[^oldSts].
084 *
085 * * (Optional) A load balancer
086 *   [`Service`](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/)
087 *   that allows the user to access a VM's console without knowing which
088 *   node it runs on.
089 * 
090 * [^oldSts]: Before version 3.4, the operator created a
091 *    [`StatefulSet`](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/)
092 *    that created the pod.
093 * 
094 * The reconciler is part of the {@link Controller} component. It's 
095 * configuration properties are therefore defined in
096 * ```yaml
097 * "/Manager":
098 *   "/Controller":
099 *     "/Reconciler":
100 *       ...
101 * ```
102 *   
103 * The reconciler supports the following configuration properties:
104 * 
105 * * `runnerDataPvc.storageClassName`: The storage class name
106 *   to be used for the "runnerDataPvc" (the small volume used
107 *   by the runner for information such as the EFI variables). By
108 *   default, no `storageClassName` is generated, which causes
109 *   Kubernetes to use storage from the default storage class.
110 *   Define this if you want to use a specific storage class.
111 *
112 * * `cpuOvercommit`: The amount by which the current cpu count
113 *   from the VM definition is divided when generating the 
114 *   [`resources`](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources)
115 *   properties for the VM (defaults to 2).
116 * 
117 * * `ramOvercommit`: The amount by which the current ram size
118 *   from the VM definition is divided when generating the
119 *   [`resources`](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources)
120 *   properties for the VM (defaults to 1.25).
121 *   
122 * * `loadBalancerService`: If defined, causes a load balancer service 
123 *   to be created. This property may be a boolean or
124 *   YAML that defines additional labels or annotations to be merged
125 *   into the service defintion. Here's an example for using
126 *   [MetalLb](https://metallb.universe.tf/) as "internal load balancer":
127 *   ```yaml 
128 *   loadBalancerService:
129 *     annotations:
130 *       metallb.universe.tf/loadBalancerIPs: 192.168.168.1
131 *       metallb.universe.tf/ip-allocated-from-pool: single-common
132 *       metallb.universe.tf/allow-shared-ip: single-common
133 *   ```
134 *   This makes all VM consoles available at IP address 192.168.168.1
135 *   with the port numbers from the VM definitions.
136 *   
137 * * `loggingProperties`: If defined, specifies the default logging
138 *   properties to be used by the runners managed by the controller.
139 *   This property is a string that holds the content of
140 *   a logging.properties file.
141 *   
142 * @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler
143 */
144@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
145    "PMD.AvoidDuplicateLiterals" })
146public class Reconciler extends Component {
147
148    /** The Constant mapper. */
149    @SuppressWarnings("PMD.FieldNamingConventions")
150    protected static final ObjectMapper mapper = new ObjectMapper();
151
152    @SuppressWarnings("PMD.SingularField")
153    private final Configuration fmConfig;
154    private final ConfigMapReconciler cmReconciler;
155    private final DisplaySecretReconciler dsReconciler;
156    private final StatefulSetReconciler stsReconciler;
157    private final PvcReconciler pvcReconciler;
158    private final PodReconciler podReconciler;
159    private final LoadBalancerReconciler lbReconciler;
160    @SuppressWarnings("PMD.UseConcurrentHashMap")
161    private final Map<String, Object> config = new HashMap<>();
162
163    /**
164     * Instantiates a new reconciler.
165     *
166     * @param componentChannel the component channel
167     */
168    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
169    public Reconciler(Channel componentChannel) {
170        super(componentChannel);
171
172        // Configure freemarker library
173        fmConfig = new Configuration(Configuration.VERSION_2_3_32);
174        fmConfig.setDefaultEncoding("utf-8");
175        fmConfig.setObjectWrapper(new ExtendedObjectWrapper(
176            fmConfig.getIncompatibleImprovements()));
177        fmConfig.setTemplateExceptionHandler(
178            TemplateExceptionHandler.RETHROW_HANDLER);
179        fmConfig.setLogTemplateExceptions(false);
180        fmConfig.setClassForTemplateLoading(Reconciler.class, "");
181
182        cmReconciler = new ConfigMapReconciler(fmConfig);
183        dsReconciler = attach(new DisplaySecretReconciler(componentChannel));
184        stsReconciler = new StatefulSetReconciler(fmConfig);
185        pvcReconciler = new PvcReconciler(fmConfig);
186        podReconciler = new PodReconciler(fmConfig);
187        lbReconciler = new LoadBalancerReconciler(fmConfig);
188    }
189
190    /**
191     * Configures the component.
192     *
193     * @param event the event
194     */
195    @Handler
196    public void onConfigurationUpdate(ConfigurationUpdate event) {
197        event.structured(componentPath()).ifPresent(c -> {
198            config.putAll(c);
199        });
200    }
201
202    /**
203     * Handles the change event.
204     *
205     * @param event the event
206     * @param channel the channel
207     * @throws ApiException the api exception
208     * @throws TemplateException the template exception
209     * @throws IOException Signals that an I/O exception has occurred.
210     */
211    @Handler
212    @SuppressWarnings("PMD.ConfusingTernary")
213    public void onVmDefChanged(VmDefChanged event, VmChannel channel)
214            throws ApiException, TemplateException, IOException {
215        // We're only interested in "spec" changes.
216        if (!event.specChanged()) {
217            return;
218        }
219
220        // Ownership relationships takes care of deletions
221        if (event.type() == K8sObserver.ResponseType.DELETED) {
222            logger.fine(
223                () -> "VM \"" + event.vmDefinition().name() + "\" deleted");
224            return;
225        }
226
227        // Create model for processing templates
228        Map<String, Object> model
229            = prepareModel(channel.client(), event.vmDefinition());
230        var configMap = cmReconciler.reconcile(model, channel);
231        model.put("cm", configMap);
232        dsReconciler.reconcile(event, model, channel);
233        // Manage (eventual) removal of stateful set.
234        stsReconciler.reconcile(event, model, channel);
235        pvcReconciler.reconcile(event, model, channel);
236        podReconciler.reconcile(event, model, channel);
237        lbReconciler.reconcile(event, model, channel);
238    }
239
240    /**
241     * Reset the VM by incrementing the reset count and doing a 
242     * partial reconcile (configmap only).
243     *
244     * @param event the event
245     * @param channel the channel
246     * @throws IOException 
247     * @throws ApiException 
248     * @throws TemplateException 
249     */
250    @Handler
251    public void onResetVm(ResetVm event, VmChannel channel)
252            throws ApiException, IOException, TemplateException {
253        var vmDef = channel.vmDefinition();
254        vmDef.extra().ifPresent(e -> e.resetCount(e.resetCount() + 1));
255        Map<String, Object> model
256            = prepareModel(channel.client(), channel.vmDefinition());
257        cmReconciler.reconcile(model, channel);
258    }
259
260    @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity" })
261    private Map<String, Object> prepareModel(K8sClient client,
262            VmDefinition vmDef) throws TemplateModelException, ApiException {
263        @SuppressWarnings("PMD.UseConcurrentHashMap")
264        Map<String, Object> model = new HashMap<>();
265        model.put("managerVersion",
266            Optional.ofNullable(Reconciler.class.getPackage()
267                .getImplementationVersion()).orElse("(Unknown)"));
268        model.put("cr", vmDef);
269        // Freemarker's static models don't handle nested classes.
270        model.put("constants", constantsMap(Constants.class));
271        model.put("reconciler", config);
272
273        // Check if we have a display secret
274        ListOptions options = new ListOptions();
275        options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
276            + "app.kubernetes.io/component=" + DisplaySecret.NAME + ","
277            + "app.kubernetes.io/instance=" + vmDef.name());
278        var dsStub = K8sV1SecretStub
279            .list(client, vmDef.namespace(), options)
280            .stream()
281            .findFirst();
282        if (dsStub.isPresent()) {
283            dsStub.get().model().ifPresent(m -> {
284                model.put("displaySecret", m.getMetadata().getName());
285            });
286        }
287
288        // Methods
289        model.put("parseQuantity", parseQuantityModel);
290        model.put("formatMemory", formatMemoryModel);
291        model.put("imageLocation", imgageLocationModel);
292        model.put("adjustCloudInitMeta", adjustCloudInitMetaModel);
293        model.put("toJson", toJsonModel);
294        return model;
295    }
296
297    @SuppressWarnings("PMD.EmptyCatchBlock")
298    private Map<String, Object> constantsMap(Class<?> clazz) {
299        @SuppressWarnings("PMD.UseConcurrentHashMap")
300        Map<String, Object> result = new HashMap<>();
301        Arrays.stream(clazz.getFields()).filter(f -> {
302            var modifiers = f.getModifiers();
303            return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers)
304                && f.getType() == String.class;
305        }).forEach(f -> {
306            try {
307                result.put(f.getName(), f.get(null));
308            } catch (IllegalArgumentException | IllegalAccessException e) {
309                // Should not happen, ignore
310            }
311        });
312        Arrays.stream(clazz.getClasses()).filter(c -> {
313            var modifiers = c.getModifiers();
314            return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers);
315        }).forEach(c -> {
316            result.put(c.getSimpleName(), constantsMap(c));
317        });
318        return result;
319    }
320
321    private final TemplateMethodModelEx parseQuantityModel
322        = new TemplateMethodModelEx() {
323            @Override
324            @SuppressWarnings("PMD.PreserveStackTrace")
325            public Object exec(@SuppressWarnings("rawtypes") List arguments)
326                    throws TemplateModelException {
327                var arg = arguments.get(0);
328                if (arg instanceof SimpleNumber number) {
329                    return number.getAsNumber();
330                }
331                try {
332                    return Quantity.fromString(arg.toString()).getNumber();
333                } catch (NumberFormatException e) {
334                    throw new TemplateModelException("Cannot parse memory "
335                        + "specified as \"" + arg + "\": " + e.getMessage());
336                }
337            }
338        };
339
340    private final TemplateMethodModelEx formatMemoryModel
341        = new TemplateMethodModelEx() {
342            @Override
343            @SuppressWarnings("PMD.PreserveStackTrace")
344            public Object exec(@SuppressWarnings("rawtypes") List arguments)
345                    throws TemplateModelException {
346                var arg = arguments.get(0);
347                if (arg instanceof SimpleNumber number) {
348                    arg = number.getAsNumber();
349                }
350                BigInteger bigInt;
351                if (arg instanceof BigInteger value) {
352                    bigInt = value;
353                } else if (arg instanceof BigDecimal dec) {
354                    try {
355                        bigInt = dec.toBigIntegerExact();
356                    } catch (ArithmeticException e) {
357                        return arg;
358                    }
359                } else if (arg instanceof Integer value) {
360                    bigInt = BigInteger.valueOf(value);
361                } else if (arg instanceof Long value) {
362                    bigInt = BigInteger.valueOf(value);
363                } else {
364                    return arg;
365                }
366                return Convertions.formatMemory(bigInt);
367            }
368        };
369
370    private final TemplateMethodModelEx imgageLocationModel
371        = new TemplateMethodModelEx() {
372            @Override
373            @SuppressWarnings({ "PMD.PreserveStackTrace",
374                "PMD.AvoidLiteralsInIfCondition" })
375            public Object exec(@SuppressWarnings("rawtypes") List arguments)
376                    throws TemplateModelException {
377                var image = ((SimpleScalar) arguments.get(0)).getAsString();
378                if (image.isEmpty()) {
379                    return "";
380                }
381                try {
382                    var imageUri
383                        = new URI("file://" + Constants.IMAGE_REPO_PATH + "/")
384                            .resolve(image);
385                    if ("file".equals(imageUri.getScheme())) {
386                        return imageUri.getPath();
387                    }
388                    return imageUri.toString();
389                } catch (URISyntaxException e) {
390                    logger.warning(() -> "Invalid CDROM image: " + image);
391                }
392                return image;
393            }
394        };
395
396    private final TemplateMethodModelEx adjustCloudInitMetaModel
397        = new TemplateMethodModelEx() {
398            @Override
399            @SuppressWarnings("PMD.PreserveStackTrace")
400            public Object exec(@SuppressWarnings("rawtypes") List arguments)
401                    throws TemplateModelException {
402                @SuppressWarnings("unchecked")
403                var res = new HashMap<>((Map<String, Object>) DeepUnwrap
404                    .unwrap((TemplateModel) arguments.get(0)));
405                var metadata
406                    = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1))
407                        .getAdaptedObject(Object.class);
408                if (!res.containsKey("instance-id")) {
409                    res.put("instance-id",
410                        Optional.ofNullable(metadata.getResourceVersion())
411                            .map(s -> "v" + s).orElse("v1"));
412                }
413                if (!res.containsKey("local-hostname")) {
414                    res.put("local-hostname", metadata.getName());
415                }
416                return res;
417            }
418        };
419
420    private final TemplateMethodModelEx toJsonModel
421        = new TemplateMethodModelEx() {
422            @Override
423            @SuppressWarnings("PMD.PreserveStackTrace")
424            public Object exec(@SuppressWarnings("rawtypes") List arguments)
425                    throws TemplateModelException {
426                try {
427                    return mapper.writeValueAsString(
428                        ((AdapterTemplateModel) arguments.get(0))
429                            .getAdaptedObject(Object.class));
430                } catch (JsonProcessingException e) {
431                    return "{}";
432                }
433            }
434        };
435}