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