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}