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.fasterxml.jackson.core.JsonProcessingException; 022import com.fasterxml.jackson.databind.ObjectMapper; 023import freemarker.template.AdapterTemplateModel; 024import freemarker.template.Configuration; 025import freemarker.template.SimpleNumber; 026import freemarker.template.SimpleScalar; 027import freemarker.template.TemplateException; 028import freemarker.template.TemplateExceptionHandler; 029import freemarker.template.TemplateMethodModelEx; 030import freemarker.template.TemplateModel; 031import freemarker.template.TemplateModelException; 032import freemarker.template.utility.DeepUnwrap; 033import io.kubernetes.client.custom.Quantity; 034import io.kubernetes.client.openapi.ApiException; 035import io.kubernetes.client.openapi.models.V1ObjectMeta; 036import io.kubernetes.client.util.generic.options.ListOptions; 037import java.io.IOException; 038import java.lang.reflect.Modifier; 039import java.math.BigDecimal; 040import java.math.BigInteger; 041import java.net.URI; 042import java.net.URISyntaxException; 043import java.util.Arrays; 044import java.util.HashMap; 045import java.util.List; 046import java.util.Map; 047import java.util.Optional; 048import static org.jdrupes.vmoperator.common.Constants.APP_NAME; 049import org.jdrupes.vmoperator.common.Constants.DisplaySecret; 050import org.jdrupes.vmoperator.common.Convertions; 051import org.jdrupes.vmoperator.common.K8sClient; 052import org.jdrupes.vmoperator.common.K8sObserver; 053import org.jdrupes.vmoperator.common.K8sV1SecretStub; 054import org.jdrupes.vmoperator.common.VmDefinition; 055import org.jdrupes.vmoperator.manager.events.ResetVm; 056import org.jdrupes.vmoperator.manager.events.VmChannel; 057import org.jdrupes.vmoperator.manager.events.VmDefChanged; 058import org.jdrupes.vmoperator.util.ExtendedObjectWrapper; 059import org.jgrapes.core.Channel; 060import org.jgrapes.core.Component; 061import org.jgrapes.core.annotation.Handler; 062import org.jgrapes.util.events.ConfigurationUpdate; 063 064/** 065 * Adapts Kubenetes resources for instances of the Runner 066 * application (the VMs) to changes in VM definitions (the CRs). 067 * 068 * In particular, the reconciler generates and updates: 069 * 070 * * A [`PVC`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) 071 * for storage used by all VMs as a common repository for CDROM images. 072 * 073 * * A [`ConfigMap`](https://kubernetes.io/docs/concepts/configuration/configmap/) 074 * that defines the configuration file for the runner. 075 * 076 * * A [`PVC`](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) 077 * for 1 MiB of persistent storage used by the Runner (referred to as the 078 * "runnerDataPvc") 079 * 080 * * The PVCs for the VM's disks. 081 * 082 * * A [`Pod`](https://kubernetes.io/docs/concepts/workloads/pods/) with the 083 * runner instance[^oldSts]. 084 * 085 * * (Optional) A load balancer 086 * [`Service`](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/) 087 * that allows the user to access a VM's console without knowing which 088 * node it runs on. 089 * 090 * [^oldSts]: Before version 3.4, the operator created a 091 * [`StatefulSet`](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) 092 * that created the pod. 093 * 094 * The reconciler is part of the {@link Controller} component. It's 095 * configuration properties are therefore defined in 096 * ```yaml 097 * "/Manager": 098 * "/Controller": 099 * "/Reconciler": 100 * ... 101 * ``` 102 * 103 * The reconciler supports the following configuration properties: 104 * 105 * * `runnerDataPvc.storageClassName`: The storage class name 106 * to be used for the "runnerDataPvc" (the small volume used 107 * by the runner for information such as the EFI variables). By 108 * default, no `storageClassName` is generated, which causes 109 * Kubernetes to use storage from the default storage class. 110 * Define this if you want to use a specific storage class. 111 * 112 * * `cpuOvercommit`: The amount by which the current cpu count 113 * from the VM definition is divided when generating the 114 * [`resources`](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources) 115 * properties for the VM (defaults to 2). 116 * 117 * * `ramOvercommit`: The amount by which the current ram size 118 * from the VM definition is divided when generating the 119 * [`resources`](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources) 120 * properties for the VM (defaults to 1.25). 121 * 122 * * `loadBalancerService`: If defined, causes a load balancer service 123 * to be created. This property may be a boolean or 124 * YAML that defines additional labels or annotations to be merged 125 * into the service defintion. Here's an example for using 126 * [MetalLb](https://metallb.universe.tf/) as "internal load balancer": 127 * ```yaml 128 * loadBalancerService: 129 * annotations: 130 * metallb.universe.tf/loadBalancerIPs: 192.168.168.1 131 * metallb.universe.tf/ip-allocated-from-pool: single-common 132 * metallb.universe.tf/allow-shared-ip: single-common 133 * ``` 134 * This makes all VM consoles available at IP address 192.168.168.1 135 * with the port numbers from the VM definitions. 136 * 137 * * `loggingProperties`: If defined, specifies the default logging 138 * properties to be used by the runners managed by the controller. 139 * This property is a string that holds the content of 140 * a logging.properties file. 141 * 142 * @see org.jdrupes.vmoperator.manager.DisplaySecretReconciler 143 */ 144@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", 145 "PMD.AvoidDuplicateLiterals" }) 146public class Reconciler extends Component { 147 148 /** The Constant mapper. */ 149 @SuppressWarnings("PMD.FieldNamingConventions") 150 protected static final ObjectMapper mapper = new ObjectMapper(); 151 152 @SuppressWarnings("PMD.SingularField") 153 private final Configuration fmConfig; 154 private final ConfigMapReconciler cmReconciler; 155 private final DisplaySecretReconciler dsReconciler; 156 private final StatefulSetReconciler stsReconciler; 157 private final PvcReconciler pvcReconciler; 158 private final PodReconciler podReconciler; 159 private final LoadBalancerReconciler lbReconciler; 160 @SuppressWarnings("PMD.UseConcurrentHashMap") 161 private final Map<String, Object> config = new HashMap<>(); 162 163 /** 164 * Instantiates a new reconciler. 165 * 166 * @param componentChannel the component channel 167 */ 168 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 169 public Reconciler(Channel componentChannel) { 170 super(componentChannel); 171 172 // Configure freemarker library 173 fmConfig = new Configuration(Configuration.VERSION_2_3_32); 174 fmConfig.setDefaultEncoding("utf-8"); 175 fmConfig.setObjectWrapper(new ExtendedObjectWrapper( 176 fmConfig.getIncompatibleImprovements())); 177 fmConfig.setTemplateExceptionHandler( 178 TemplateExceptionHandler.RETHROW_HANDLER); 179 fmConfig.setLogTemplateExceptions(false); 180 fmConfig.setClassForTemplateLoading(Reconciler.class, ""); 181 182 cmReconciler = new ConfigMapReconciler(fmConfig); 183 dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); 184 stsReconciler = new StatefulSetReconciler(fmConfig); 185 pvcReconciler = new PvcReconciler(fmConfig); 186 podReconciler = new PodReconciler(fmConfig); 187 lbReconciler = new LoadBalancerReconciler(fmConfig); 188 } 189 190 /** 191 * Configures the component. 192 * 193 * @param event the event 194 */ 195 @Handler 196 public void onConfigurationUpdate(ConfigurationUpdate event) { 197 event.structured(componentPath()).ifPresent(c -> { 198 config.putAll(c); 199 }); 200 } 201 202 /** 203 * Handles the change event. 204 * 205 * @param event the event 206 * @param channel the channel 207 * @throws ApiException the api exception 208 * @throws TemplateException the template exception 209 * @throws IOException Signals that an I/O exception has occurred. 210 */ 211 @Handler 212 @SuppressWarnings("PMD.ConfusingTernary") 213 public void onVmDefChanged(VmDefChanged event, VmChannel channel) 214 throws ApiException, TemplateException, IOException { 215 // We're only interested in "spec" changes. 216 if (!event.specChanged()) { 217 return; 218 } 219 220 // Ownership relationships takes care of deletions 221 if (event.type() == K8sObserver.ResponseType.DELETED) { 222 logger.fine( 223 () -> "VM \"" + event.vmDefinition().name() + "\" deleted"); 224 return; 225 } 226 227 // Create model for processing templates 228 Map<String, Object> model 229 = prepareModel(channel.client(), event.vmDefinition()); 230 var configMap = cmReconciler.reconcile(model, channel); 231 model.put("cm", configMap); 232 dsReconciler.reconcile(event, model, channel); 233 // Manage (eventual) removal of stateful set. 234 stsReconciler.reconcile(event, model, channel); 235 pvcReconciler.reconcile(event, model, channel); 236 podReconciler.reconcile(event, model, channel); 237 lbReconciler.reconcile(event, model, channel); 238 } 239 240 /** 241 * Reset the VM by incrementing the reset count and doing a 242 * partial reconcile (configmap only). 243 * 244 * @param event the event 245 * @param channel the channel 246 * @throws IOException 247 * @throws ApiException 248 * @throws TemplateException 249 */ 250 @Handler 251 public void onResetVm(ResetVm event, VmChannel channel) 252 throws ApiException, IOException, TemplateException { 253 var vmDef = channel.vmDefinition(); 254 vmDef.extra().ifPresent(e -> e.resetCount(e.resetCount() + 1)); 255 Map<String, Object> model 256 = prepareModel(channel.client(), channel.vmDefinition()); 257 cmReconciler.reconcile(model, channel); 258 } 259 260 @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity" }) 261 private Map<String, Object> prepareModel(K8sClient client, 262 VmDefinition vmDef) throws TemplateModelException, ApiException { 263 @SuppressWarnings("PMD.UseConcurrentHashMap") 264 Map<String, Object> model = new HashMap<>(); 265 model.put("managerVersion", 266 Optional.ofNullable(Reconciler.class.getPackage() 267 .getImplementationVersion()).orElse("(Unknown)")); 268 model.put("cr", vmDef); 269 // Freemarker's static models don't handle nested classes. 270 model.put("constants", constantsMap(Constants.class)); 271 model.put("reconciler", config); 272 273 // Check if we have a display secret 274 ListOptions options = new ListOptions(); 275 options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + "," 276 + "app.kubernetes.io/component=" + DisplaySecret.NAME + "," 277 + "app.kubernetes.io/instance=" + vmDef.name()); 278 var dsStub = K8sV1SecretStub 279 .list(client, vmDef.namespace(), options) 280 .stream() 281 .findFirst(); 282 if (dsStub.isPresent()) { 283 dsStub.get().model().ifPresent(m -> { 284 model.put("displaySecret", m.getMetadata().getName()); 285 }); 286 } 287 288 // Methods 289 model.put("parseQuantity", parseQuantityModel); 290 model.put("formatMemory", formatMemoryModel); 291 model.put("imageLocation", imgageLocationModel); 292 model.put("adjustCloudInitMeta", adjustCloudInitMetaModel); 293 model.put("toJson", toJsonModel); 294 return model; 295 } 296 297 @SuppressWarnings("PMD.EmptyCatchBlock") 298 private Map<String, Object> constantsMap(Class<?> clazz) { 299 @SuppressWarnings("PMD.UseConcurrentHashMap") 300 Map<String, Object> result = new HashMap<>(); 301 Arrays.stream(clazz.getFields()).filter(f -> { 302 var modifiers = f.getModifiers(); 303 return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers) 304 && f.getType() == String.class; 305 }).forEach(f -> { 306 try { 307 result.put(f.getName(), f.get(null)); 308 } catch (IllegalArgumentException | IllegalAccessException e) { 309 // Should not happen, ignore 310 } 311 }); 312 Arrays.stream(clazz.getClasses()).filter(c -> { 313 var modifiers = c.getModifiers(); 314 return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers); 315 }).forEach(c -> { 316 result.put(c.getSimpleName(), constantsMap(c)); 317 }); 318 return result; 319 } 320 321 private final TemplateMethodModelEx parseQuantityModel 322 = new TemplateMethodModelEx() { 323 @Override 324 @SuppressWarnings("PMD.PreserveStackTrace") 325 public Object exec(@SuppressWarnings("rawtypes") List arguments) 326 throws TemplateModelException { 327 var arg = arguments.get(0); 328 if (arg instanceof SimpleNumber number) { 329 return number.getAsNumber(); 330 } 331 try { 332 return Quantity.fromString(arg.toString()).getNumber(); 333 } catch (NumberFormatException e) { 334 throw new TemplateModelException("Cannot parse memory " 335 + "specified as \"" + arg + "\": " + e.getMessage()); 336 } 337 } 338 }; 339 340 private final TemplateMethodModelEx formatMemoryModel 341 = new TemplateMethodModelEx() { 342 @Override 343 @SuppressWarnings("PMD.PreserveStackTrace") 344 public Object exec(@SuppressWarnings("rawtypes") List arguments) 345 throws TemplateModelException { 346 var arg = arguments.get(0); 347 if (arg instanceof SimpleNumber number) { 348 arg = number.getAsNumber(); 349 } 350 BigInteger bigInt; 351 if (arg instanceof BigInteger value) { 352 bigInt = value; 353 } else if (arg instanceof BigDecimal dec) { 354 try { 355 bigInt = dec.toBigIntegerExact(); 356 } catch (ArithmeticException e) { 357 return arg; 358 } 359 } else if (arg instanceof Integer value) { 360 bigInt = BigInteger.valueOf(value); 361 } else if (arg instanceof Long value) { 362 bigInt = BigInteger.valueOf(value); 363 } else { 364 return arg; 365 } 366 return Convertions.formatMemory(bigInt); 367 } 368 }; 369 370 private final TemplateMethodModelEx imgageLocationModel 371 = new TemplateMethodModelEx() { 372 @Override 373 @SuppressWarnings({ "PMD.PreserveStackTrace", 374 "PMD.AvoidLiteralsInIfCondition" }) 375 public Object exec(@SuppressWarnings("rawtypes") List arguments) 376 throws TemplateModelException { 377 var image = ((SimpleScalar) arguments.get(0)).getAsString(); 378 if (image.isEmpty()) { 379 return ""; 380 } 381 try { 382 var imageUri 383 = new URI("file://" + Constants.IMAGE_REPO_PATH + "/") 384 .resolve(image); 385 if ("file".equals(imageUri.getScheme())) { 386 return imageUri.getPath(); 387 } 388 return imageUri.toString(); 389 } catch (URISyntaxException e) { 390 logger.warning(() -> "Invalid CDROM image: " + image); 391 } 392 return image; 393 } 394 }; 395 396 private final TemplateMethodModelEx adjustCloudInitMetaModel 397 = new TemplateMethodModelEx() { 398 @Override 399 @SuppressWarnings("PMD.PreserveStackTrace") 400 public Object exec(@SuppressWarnings("rawtypes") List arguments) 401 throws TemplateModelException { 402 @SuppressWarnings("unchecked") 403 var res = new HashMap<>((Map<String, Object>) DeepUnwrap 404 .unwrap((TemplateModel) arguments.get(0))); 405 var metadata 406 = (V1ObjectMeta) ((AdapterTemplateModel) arguments.get(1)) 407 .getAdaptedObject(Object.class); 408 if (!res.containsKey("instance-id")) { 409 res.put("instance-id", 410 Optional.ofNullable(metadata.getResourceVersion()) 411 .map(s -> "v" + s).orElse("v1")); 412 } 413 if (!res.containsKey("local-hostname")) { 414 res.put("local-hostname", metadata.getName()); 415 } 416 return res; 417 } 418 }; 419 420 private final TemplateMethodModelEx toJsonModel 421 = new TemplateMethodModelEx() { 422 @Override 423 @SuppressWarnings("PMD.PreserveStackTrace") 424 public Object exec(@SuppressWarnings("rawtypes") List arguments) 425 throws TemplateModelException { 426 try { 427 return mapper.writeValueAsString( 428 ((AdapterTemplateModel) arguments.get(0)) 429 .getAdaptedObject(Object.class)); 430 } catch (JsonProcessingException e) { 431 return "{}"; 432 } 433 } 434 }; 435}