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