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