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.AvoidDuplicateLiterals" })
141public class Reconciler extends Component {
142
143    /** The Constant mapper. */
144    @SuppressWarnings("PMD.FieldNamingConventions")
145    protected static final ObjectMapper mapper = new ObjectMapper();
146
147    private final Configuration fmConfig;
148    private final ConfigMapReconciler cmReconciler;
149    private final DisplaySecretReconciler dsReconciler;
150    private final PvcReconciler pvcReconciler;
151    private final PodReconciler podReconciler;
152    private final LoadBalancerReconciler lbReconciler;
153    @SuppressWarnings("PMD.UseConcurrentHashMap")
154    private final Map<String, Object> config = new HashMap<>();
155
156    /**
157     * Instantiates a new reconciler.
158     *
159     * @param componentChannel the component channel
160     */
161    @SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
162    public Reconciler(Channel componentChannel) {
163        super(componentChannel);
164
165        // Configure freemarker library
166        fmConfig = new Configuration(Configuration.VERSION_2_3_32);
167        fmConfig.setDefaultEncoding("utf-8");
168        fmConfig.setObjectWrapper(new ExtendedObjectWrapper(
169            fmConfig.getIncompatibleImprovements()));
170        fmConfig.setTemplateExceptionHandler(
171            TemplateExceptionHandler.RETHROW_HANDLER);
172        fmConfig.setLogTemplateExceptions(false);
173        fmConfig.setClassForTemplateLoading(Reconciler.class, "");
174
175        cmReconciler = new ConfigMapReconciler(fmConfig);
176        dsReconciler = attach(new DisplaySecretReconciler(componentChannel));
177        pvcReconciler = new PvcReconciler(fmConfig);
178        podReconciler = new PodReconciler(fmConfig);
179        lbReconciler = new LoadBalancerReconciler(fmConfig);
180    }
181
182    /**
183     * Configures the component.
184     *
185     * @param event the event
186     */
187    @Handler
188    public void onConfigurationUpdate(ConfigurationUpdate event) {
189        event.structured(componentPath()).ifPresent(c -> {
190            config.putAll(c);
191        });
192    }
193
194    /**
195     * Handles the change event.
196     *
197     * @param event the event
198     * @param channel the channel
199     * @throws ApiException the api exception
200     * @throws TemplateException the template exception
201     * @throws IOException Signals that an I/O exception has occurred.
202     */
203    @Handler
204    public void onVmResourceChanged(VmResourceChanged event, VmChannel channel)
205            throws ApiException, TemplateException, IOException {
206        // Ownership relationships takes care of deletions
207        if (event.type() == K8sObserver.ResponseType.DELETED) {
208            return;
209        }
210
211        // Create model for processing templates
212        var vmDef = event.vmDefinition();
213        Map<String, Object> model = prepareModel(vmDef);
214        cmReconciler.reconcile(model, channel, event.specChanged());
215
216        // The remaining reconcilers depend only on changes of the spec part
217        // or the pod state.
218        if (!event.specChanged() && !event.podChanged()) {
219            return;
220        }
221        dsReconciler.reconcile(vmDef, model, channel, event.specChanged());
222        pvcReconciler.reconcile(vmDef, model, channel, event.specChanged());
223        podReconciler.reconcile(vmDef, model, channel, event.specChanged());
224        lbReconciler.reconcile(vmDef, model, channel, event.specChanged());
225    }
226
227    /**
228     * Reset the VM by incrementing the reset count and doing a 
229     * partial reconcile (configmap only).
230     *
231     * @param event the event
232     * @param channel the channel
233     * @throws IOException 
234     * @throws ApiException 
235     * @throws TemplateException 
236     */
237    @Handler
238    public void onResetVm(ResetVm event, VmChannel channel)
239            throws ApiException, IOException, TemplateException {
240        var vmDef = channel.vmDefinition();
241        var extra = vmDef.extra();
242        extra.resetCount(extra.resetCount() + 1);
243        Map<String, Object> model
244            = prepareModel(channel.vmDefinition());
245        cmReconciler.reconcile(model, channel, true);
246    }
247
248    private Map<String, Object> prepareModel(VmDefinition vmDef)
249            throws TemplateModelException, ApiException {
250        @SuppressWarnings("PMD.UseConcurrentHashMap")
251        Map<String, Object> model = new HashMap<>();
252        model.put("managerVersion",
253            Optional.ofNullable(Reconciler.class.getPackage()
254                .getImplementationVersion()).orElse("(Unknown)"));
255        model.put("cr", vmDef);
256        model.put("reconciler", config);
257        model.put("constants", constantsMap(Constants.class));
258        addLoginRequestedFor(model, vmDef);
259
260        // Methods
261        model.put("parseQuantity", parseQuantityModel);
262        model.put("formatMemory", formatMemoryModel);
263        model.put("imageLocation", imgageLocationModel);
264        model.put("toJson", toJsonModel);
265        return model;
266    }
267
268    /**
269     * Creates a map with constants. Needed because freemarker doesn't support
270     * nested classes with its static models.
271     *
272     * @param clazz the clazz
273     * @return the map
274     */
275    @SuppressWarnings("PMD.EmptyCatchBlock")
276    private Map<String, Object> constantsMap(Class<?> clazz) {
277        @SuppressWarnings("PMD.UseConcurrentHashMap")
278        Map<String, Object> result = new HashMap<>();
279        Arrays.stream(clazz.getFields()).filter(f -> {
280            var modifiers = f.getModifiers();
281            return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers)
282                && f.getType() == String.class;
283        }).forEach(f -> {
284            try {
285                result.put(f.getName(), f.get(null));
286            } catch (IllegalArgumentException | IllegalAccessException e) {
287                // Should not happen, ignore
288            }
289        });
290        Arrays.stream(clazz.getClasses()).filter(c -> {
291            var modifiers = c.getModifiers();
292            return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers);
293        }).forEach(c -> {
294            result.put(c.getSimpleName(), constantsMap(c));
295        });
296        return result;
297    }
298
299    private void addLoginRequestedFor(Map<String, Object> model,
300            VmDefinition vmDef) {
301        vmDef.assignment().filter(a -> {
302            try {
303                return newEventPipeline()
304                    .fire(new GetPools().withName(a.pool())).get()
305                    .stream().findFirst().map(VmPool::loginOnAssignment)
306                    .orElse(false);
307            } catch (InterruptedException e) {
308                logger.log(Level.WARNING, e, e::getMessage);
309            }
310            return false;
311        }).map(Assignment::user)
312            .or(() -> vmDef.fromSpec("vm", "display", "loggedInUser"))
313            .ifPresent(u -> model.put("loginRequestedFor", u));
314    }
315
316    private final TemplateMethodModelEx parseQuantityModel
317        = new TemplateMethodModelEx() {
318            @Override
319            @SuppressWarnings("PMD.PreserveStackTrace")
320            public Object exec(@SuppressWarnings("rawtypes") List arguments)
321                    throws TemplateModelException {
322                var arg = arguments.get(0);
323                if (arg instanceof SimpleNumber number) {
324                    return number.getAsNumber();
325                }
326                try {
327                    return Quantity.fromString(arg.toString()).getNumber();
328                } catch (NumberFormatException e) {
329                    throw new TemplateModelException("Cannot parse memory "
330                        + "specified as \"" + arg + "\": " + e.getMessage());
331                }
332            }
333        };
334
335    private final TemplateMethodModelEx formatMemoryModel
336        = new TemplateMethodModelEx() {
337            @Override
338            public Object exec(@SuppressWarnings("rawtypes") List arguments)
339                    throws TemplateModelException {
340                var arg = arguments.get(0);
341                if (arg instanceof SimpleNumber number) {
342                    arg = number.getAsNumber();
343                }
344                BigInteger bigInt;
345                if (arg instanceof BigInteger value) {
346                    bigInt = value;
347                } else if (arg instanceof BigDecimal dec) {
348                    try {
349                        bigInt = dec.toBigIntegerExact();
350                    } catch (ArithmeticException e) {
351                        return arg;
352                    }
353                } else if (arg instanceof Integer value) {
354                    bigInt = BigInteger.valueOf(value);
355                } else if (arg instanceof Long value) {
356                    bigInt = BigInteger.valueOf(value);
357                } else {
358                    return arg;
359                }
360                return Convertions.formatMemory(bigInt);
361            }
362        };
363
364    private final TemplateMethodModelEx imgageLocationModel
365        = new TemplateMethodModelEx() {
366            @Override
367            @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" })
368            public Object exec(@SuppressWarnings("rawtypes") List arguments)
369                    throws TemplateModelException {
370                var image = ((SimpleScalar) arguments.get(0)).getAsString();
371                if (image.isEmpty()) {
372                    return "";
373                }
374                try {
375                    var imageUri
376                        = new URI("file://" + Constants.IMAGE_REPO_PATH + "/")
377                            .resolve(image);
378                    if ("file".equals(imageUri.getScheme())) {
379                        return imageUri.getPath();
380                    }
381                    return imageUri.toString();
382                } catch (URISyntaxException e) {
383                    logger.warning(() -> "Invalid CDROM image: " + image);
384                }
385                return image;
386            }
387        };
388
389    private final TemplateMethodModelEx toJsonModel
390        = new TemplateMethodModelEx() {
391            @Override
392            public Object exec(@SuppressWarnings("rawtypes") List arguments)
393                    throws TemplateModelException {
394                try {
395                    return mapper.writeValueAsString(
396                        ((AdapterTemplateModel) arguments.get(0))
397                            .getAdaptedObject(Object.class));
398                } catch (JsonProcessingException e) {
399                    return "{}";
400                }
401            }
402        };
403}