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.manager.events.VmDefChanged;
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 event the event
073     * @param model the model
074     * @param channel the channel
075     * @throws IOException Signals that an I/O exception has occurred.
076     * @throws TemplateException the template exception
077     * @throws ApiException the api exception
078     */
079    public void reconcile(VmDefChanged event,
080            Map<String, Object> model, VmChannel channel)
081            throws IOException, TemplateException, ApiException {
082        // Check if to be generated
083        @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "unchecked" })
084        var lbsDef = Optional.of(model)
085            .map(m -> (Map<String, Object>) m.get("reconciler"))
086            .map(c -> c.get(LOAD_BALANCER_SERVICE)).orElse(Boolean.FALSE);
087        if (!(lbsDef instanceof Map) && !(lbsDef instanceof Boolean)) {
088            logger.warning(() -> "\"" + LOAD_BALANCER_SERVICE
089                + "\" in configuration must be boolean or mapping but is "
090                + lbsDef.getClass() + ".");
091            return;
092        }
093        if (lbsDef instanceof Boolean isOn && !isOn) {
094            return;
095        }
096
097        // Load balancer can also be turned off for VM
098        var vmDef = event.vmDefinition();
099        if (vmDef
100            .<Map<String, Map<String, String>>> fromSpec(LOAD_BALANCER_SERVICE)
101            .map(m -> m.isEmpty()).orElse(false)) {
102            return;
103        }
104
105        // Combine template and data and parse result
106        var fmTemplate = fmConfig.getTemplate("runnerLoadBalancer.ftl.yaml");
107        StringWriter out = new StringWriter();
108        fmTemplate.process(model, out);
109        // Avoid Yaml.load due to
110        // https://github.com/kubernetes-client/java/issues/2741
111        var svcDef = Dynamics.newFromYaml(
112            new Yaml(new SafeConstructor(new LoaderOptions())), out.toString());
113        @SuppressWarnings("unchecked")
114        var defaults = lbsDef instanceof Map
115            ? (Map<String, Map<String, String>>) lbsDef
116            : null;
117        var client = channel.client();
118        mergeMetadata(client.getJSON().getGson(), svcDef, defaults, vmDef);
119
120        // Apply
121        var svcStub = K8sV1ServiceStub
122            .get(client, vmDef.namespace(), vmDef.name());
123        if (svcStub.apply(svcDef).isEmpty()) {
124            logger.warning(
125                () -> "Could not patch service for " + svcStub.name());
126        }
127    }
128
129    private void mergeMetadata(Gson gson, DynamicKubernetesObject svcDef,
130            Map<String, Map<String, String>> defaults,
131            VmDefinition vmDefinition) {
132        // Get specific load balancer metadata from VM definition
133        var vmLbMeta = vmDefinition
134            .<Map<String, Map<String, String>>> fromSpec(LOAD_BALANCER_SERVICE)
135            .orElse(Collections.emptyMap());
136
137        // Merge
138        var svcMeta = svcDef.getMetadata();
139        var svcJsonMeta = GsonPtr.to(svcDef.getRaw()).to(METADATA);
140        Optional.ofNullable(mergeIfAbsent(svcMeta.getLabels(),
141            mergeReplace(defaults.get(LABELS), vmLbMeta.get(LABELS))))
142            .ifPresent(lbls -> svcJsonMeta.set(LABELS, gson.toJsonTree(lbls)));
143        Optional.ofNullable(mergeIfAbsent(svcMeta.getAnnotations(),
144            mergeReplace(defaults.get(ANNOTATIONS), vmLbMeta.get(ANNOTATIONS))))
145            .ifPresent(as -> svcJsonMeta.set(ANNOTATIONS, gson.toJsonTree(as)));
146    }
147
148    private Map<String, String> mergeReplace(Map<String, String> dest,
149            Map<String, String> src) {
150        if (src == null) {
151            return dest;
152        }
153        if (dest == null) {
154            dest = new LinkedHashMap<>();
155        } else {
156            dest = new LinkedHashMap<>(dest);
157        }
158        for (var e : src.entrySet()) {
159            if (e.getValue() == null) {
160                dest.remove(e.getKey());
161                continue;
162            }
163            dest.put(e.getKey(), e.getValue());
164        }
165        return dest;
166    }
167
168    private Map<String, String> mergeIfAbsent(Map<String, String> dest,
169            Map<String, String> src) {
170        if (src == null) {
171            return dest;
172        }
173        if (dest == null) {
174            dest = new LinkedHashMap<>();
175        } else {
176            dest = new LinkedHashMap<>(dest);
177        }
178        for (var e : src.entrySet()) {
179            if (dest.containsKey(e.getKey())) {
180                continue;
181            }
182            dest.put(e.getKey(), e.getValue());
183        }
184        return dest;
185    }
186
187}