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}