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.Gson;
022import freemarker.template.Configuration;
023import freemarker.template.TemplateException;
024import io.kubernetes.client.openapi.ApiException;
025import io.kubernetes.client.openapi.models.V1APIService;
026import io.kubernetes.client.openapi.models.V1ObjectMeta;
027import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
028import io.kubernetes.client.util.generic.dynamic.Dynamics;
029import java.io.IOException;
030import java.io.StringWriter;
031import java.util.Collections;
032import java.util.LinkedHashMap;
033import java.util.Map;
034import java.util.Optional;
035import java.util.logging.Logger;
036import org.jdrupes.vmoperator.common.K8sV1ServiceStub;
037import org.jdrupes.vmoperator.common.VmDefinition;
038import org.jdrupes.vmoperator.manager.events.VmChannel;
039import org.jdrupes.vmoperator.util.DataPath;
040import org.jdrupes.vmoperator.util.GsonPtr;
041import org.yaml.snakeyaml.LoaderOptions;
042import org.yaml.snakeyaml.Yaml;
043import org.yaml.snakeyaml.constructor.SafeConstructor;
044
045/**
046 * Delegee for reconciling the service
047 */
048@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
049/* default */ class LoadBalancerReconciler {
050
051    private static final String LOAD_BALANCER_SERVICE = "loadBalancerService";
052    private static final String METADATA
053        = V1APIService.SERIALIZED_NAME_METADATA;
054    private static final String LABELS = V1ObjectMeta.SERIALIZED_NAME_LABELS;
055    private static final String ANNOTATIONS
056        = V1ObjectMeta.SERIALIZED_NAME_ANNOTATIONS;
057    protected final Logger logger = Logger.getLogger(getClass().getName());
058    private final Configuration fmConfig;
059
060    /**
061     * Instantiates a new service reconciler.
062     *
063     * @param fmConfig the fm config
064     */
065    public LoadBalancerReconciler(Configuration fmConfig) {
066        this.fmConfig = fmConfig;
067    }
068
069    /**
070     * Reconcile.
071     *
072     * @param vmDef the VM definition
073     * @param model the model
074     * @param channel the channel
075     * @param specChanged the spec changed
076     * @throws IOException Signals that an I/O exception has occurred.
077     * @throws TemplateException the template exception
078     * @throws ApiException the api exception
079     */
080    public void reconcile(VmDefinition vmDef, Map<String, Object> model,
081            VmChannel channel, boolean specChanged)
082            throws IOException, TemplateException, ApiException {
083        // Nothing to do unless spec changed
084        if (!specChanged) {
085            return;
086        }
087
088        // Check if to be generated
089        @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" })
090        var lbsDef = Optional.of(model)
091            .map(m -> (Map<String, Object>) m.get("reconciler"))
092            .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE);
093        if (!(lbsDef instanceof Map) && !(lbsDef instanceof Boolean)) {
094            logger.warning(() -> "\"" + LOAD_BALANCER_SERVICE
095                + "\" in configuration must be boolean or mapping but is "
096                + lbsDef.getClass() + ".");
097            return;
098        }
099        if (lbsDef instanceof Boolean isOn && !isOn) {
100            return;
101        }
102
103        // Load balancer can also be turned off for VM
104        if (vmDef
105            .<Map<String, Map<String, String>>> fromSpec(LOAD_BALANCER_SERVICE)
106            .map(m -> m.isEmpty()).orElse(false)) {
107            return;
108        }
109
110        // Combine template and data and parse result
111        logger.fine(() -> "Create/update load balancer service for "
112            + DataPath.<String> get(model, "cr", "name").orElse("unknown"));
113        var fmTemplate = fmConfig.getTemplate("runnerLoadBalancer.ftl.yaml");
114        StringWriter out = new StringWriter();
115        fmTemplate.process(model, out);
116        // Avoid Yaml.load due to
117        // https://github.com/kubernetes-client/java/issues/2741
118        var svcDef = Dynamics.newFromYaml(
119            new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
120        @SuppressWarnings("unchecked")
121        var defaults = lbsDef instanceof Map
122            ? (Map<String, Map<String, String>>) lbsDef
123            : null;
124        var client = channel.client();
125        mergeMetadata(client.getJSON().getGson(), svcDef, defaults, vmDef);
126
127        // Apply
128        var svcStub = K8sV1ServiceStub
129            .get(client, vmDef.namespace(), vmDef.name());
130        if (svcStub.apply(svcDef).isEmpty()) {
131            logger.warning(
132                () -> "Could not patch service for " + svcStub.name());
133        }
134    }
135
136    private void mergeMetadata(Gson gson, DynamicKubernetesObject svcDef,
137            Map<String, Map<String, String>> defaults,
138            VmDefinition vmDefinition) {
139        // Get specific load balancer metadata from VM definition
140        var vmLbMeta = vmDefinition
141            .<Map<String, Map<String, String>>> fromSpec(LOAD_BALANCER_SERVICE)
142            .orElse(Collections.emptyMap());
143
144        // Merge
145        var svcMeta = svcDef.getMetadata();
146        var svcJsonMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA);
147        Optional.ofNullable(mergeIfAbsent(svcMeta.getLabels(),
148            mergeReplace(defaults.get(LABELS), vmLbMeta.get(LABELS))))
149            .ifPresent(lbls -> svcJsonMeta.set(LABELS, gson.toJsonTree(lbls)));
150        Optional.ofNullable(mergeIfAbsent(svcMeta.getAnnotations(),
151            mergeReplace(defaults.get(ANNOTATIONS), vmLbMeta.get(ANNOTATIONS))))
152            .ifPresent(as -> svcJsonMeta.set(ANNOTATIONS, gson.toJsonTree(as)));
153    }
154
155    private Map<String, String> mergeReplace(Map<String, String> dest,
156            Map<String, String> src) {
157        if (src == null) {
158            return dest;
159        }
160        if (dest == null) {
161            dest = new LinkedHashMap<>();
162        } else {
163            dest = new LinkedHashMap<>(dest);
164        }
165        for (var e : src.entrySet()) {
166            if (e.getValue() == null) {
167                dest.remove(e.getKey());
168                continue;
169            }
170            dest.put(e.getKey(), e.getValue());
171        }
172        return dest;
173    }
174
175    private Map<String, String> mergeIfAbsent(Map<String, String> dest,
176            Map<String, String> src) {
177        if (src == null) {
178            return dest;
179        }
180        if (dest == null) {
181            dest = new LinkedHashMap<>();
182        } else {
183            dest = new LinkedHashMap<>(dest);
184        }
185        for (var e : src.entrySet()) {
186            if (dest.containsKey(e.getKey())) {
187                continue;
188            }
189            dest.put(e.getKey(), e.getValue());
190        }
191        return dest;
192    }
193
194}