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}