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.AvoidDuplicateLiterals" }) 141public class Reconciler extends Component { 142 143 /** The Constant mapper. */ 144 @SuppressWarnings("PMD.FieldNamingConventions") 145 protected static final ObjectMapper mapper = new ObjectMapper(); 146 147 private final Configuration fmConfig; 148 private final ConfigMapReconciler cmReconciler; 149 private final DisplaySecretReconciler dsReconciler; 150 private final PvcReconciler pvcReconciler; 151 private final PodReconciler podReconciler; 152 private final LoadBalancerReconciler lbReconciler; 153 @SuppressWarnings("PMD.UseConcurrentHashMap") 154 private final Map<String, Object> config = new HashMap<>(); 155 156 /** 157 * Instantiates a new reconciler. 158 * 159 * @param componentChannel the component channel 160 */ 161 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 162 public Reconciler(Channel componentChannel) { 163 super(componentChannel); 164 165 // Configure freemarker library 166 fmConfig = new Configuration(Configuration.VERSION_2_3_32); 167 fmConfig.setDefaultEncoding("utf-8"); 168 fmConfig.setObjectWrapper(new ExtendedObjectWrapper( 169 fmConfig.getIncompatibleImprovements())); 170 fmConfig.setTemplateExceptionHandler( 171 TemplateExceptionHandler.RETHROW_HANDLER); 172 fmConfig.setLogTemplateExceptions(false); 173 fmConfig.setClassForTemplateLoading(Reconciler.class, ""); 174 175 cmReconciler = new ConfigMapReconciler(fmConfig); 176 dsReconciler = attach(new DisplaySecretReconciler(componentChannel)); 177 pvcReconciler = new PvcReconciler(fmConfig); 178 podReconciler = new PodReconciler(fmConfig); 179 lbReconciler = new LoadBalancerReconciler(fmConfig); 180 } 181 182 /** 183 * Configures the component. 184 * 185 * @param event the event 186 */ 187 @Handler 188 public void onConfigurationUpdate(ConfigurationUpdate event) { 189 event.structured(componentPath()).ifPresent(c -> { 190 config.putAll(c); 191 }); 192 } 193 194 /** 195 * Handles the change event. 196 * 197 * @param event the event 198 * @param channel the channel 199 * @throws ApiException the api exception 200 * @throws TemplateException the template exception 201 * @throws IOException Signals that an I/O exception has occurred. 202 */ 203 @Handler 204 public void onVmResourceChanged(VmResourceChanged event, VmChannel channel) 205 throws ApiException, TemplateException, IOException { 206 // Ownership relationships takes care of deletions 207 if (event.type() == K8sObserver.ResponseType.DELETED) { 208 return; 209 } 210 211 // Create model for processing templates 212 var vmDef = event.vmDefinition(); 213 Map<String, Object> model = prepareModel(vmDef); 214 cmReconciler.reconcile(model, channel, event.specChanged()); 215 216 // The remaining reconcilers depend only on changes of the spec part 217 // or the pod state. 218 if (!event.specChanged() && !event.podChanged()) { 219 return; 220 } 221 dsReconciler.reconcile(vmDef, model, channel, event.specChanged()); 222 pvcReconciler.reconcile(vmDef, model, channel, event.specChanged()); 223 podReconciler.reconcile(vmDef, model, channel, event.specChanged()); 224 lbReconciler.reconcile(vmDef, model, channel, event.specChanged()); 225 } 226 227 /** 228 * Reset the VM by incrementing the reset count and doing a 229 * partial reconcile (configmap only). 230 * 231 * @param event the event 232 * @param channel the channel 233 * @throws IOException 234 * @throws ApiException 235 * @throws TemplateException 236 */ 237 @Handler 238 public void onResetVm(ResetVm event, VmChannel channel) 239 throws ApiException, IOException, TemplateException { 240 var vmDef = channel.vmDefinition(); 241 var extra = vmDef.extra(); 242 extra.resetCount(extra.resetCount() + 1); 243 Map<String, Object> model 244 = prepareModel(channel.vmDefinition()); 245 cmReconciler.reconcile(model, channel, true); 246 } 247 248 private Map<String, Object> prepareModel(VmDefinition vmDef) 249 throws TemplateModelException, ApiException { 250 @SuppressWarnings("PMD.UseConcurrentHashMap") 251 Map<String, Object> model = new HashMap<>(); 252 model.put("managerVersion", 253 Optional.ofNullable(Reconciler.class.getPackage() 254 .getImplementationVersion()).orElse("(Unknown)")); 255 model.put("cr", vmDef); 256 model.put("reconciler", config); 257 model.put("constants", constantsMap(Constants.class)); 258 addLoginRequestedFor(model, vmDef); 259 260 // Methods 261 model.put("parseQuantity", parseQuantityModel); 262 model.put("formatMemory", formatMemoryModel); 263 model.put("imageLocation", imgageLocationModel); 264 model.put("toJson", toJsonModel); 265 return model; 266 } 267 268 /** 269 * Creates a map with constants. Needed because freemarker doesn't support 270 * nested classes with its static models. 271 * 272 * @param clazz the clazz 273 * @return the map 274 */ 275 @SuppressWarnings("PMD.EmptyCatchBlock") 276 private Map<String, Object> constantsMap(Class<?> clazz) { 277 @SuppressWarnings("PMD.UseConcurrentHashMap") 278 Map<String, Object> result = new HashMap<>(); 279 Arrays.stream(clazz.getFields()).filter(f -> { 280 var modifiers = f.getModifiers(); 281 return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers) 282 && f.getType() == String.class; 283 }).forEach(f -> { 284 try { 285 result.put(f.getName(), f.get(null)); 286 } catch (IllegalArgumentException | IllegalAccessException e) { 287 // Should not happen, ignore 288 } 289 }); 290 Arrays.stream(clazz.getClasses()).filter(c -> { 291 var modifiers = c.getModifiers(); 292 return Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers); 293 }).forEach(c -> { 294 result.put(c.getSimpleName(), constantsMap(c)); 295 }); 296 return result; 297 } 298 299 private void addLoginRequestedFor(Map<String, Object> model, 300 VmDefinition vmDef) { 301 vmDef.assignment().filter(a -> { 302 try { 303 return newEventPipeline() 304 .fire(new GetPools().withName(a.pool())).get() 305 .stream().findFirst().map(VmPool::loginOnAssignment) 306 .orElse(false); 307 } catch (InterruptedException e) { 308 logger.log(Level.WARNING, e, e::getMessage); 309 } 310 return false; 311 }).map(Assignment::user) 312 .or(() -> vmDef.fromSpec("vm", "display", "loggedInUser")) 313 .ifPresent(u -> model.put("loginRequestedFor", u)); 314 } 315 316 private final TemplateMethodModelEx parseQuantityModel 317 = new TemplateMethodModelEx() { 318 @Override 319 @SuppressWarnings("PMD.PreserveStackTrace") 320 public Object exec(@SuppressWarnings("rawtypes") List arguments) 321 throws TemplateModelException { 322 var arg = arguments.get(0); 323 if (arg instanceof SimpleNumber number) { 324 return number.getAsNumber(); 325 } 326 try { 327 return Quantity.fromString(arg.toString()).getNumber(); 328 } catch (NumberFormatException e) { 329 throw new TemplateModelException("Cannot parse memory " 330 + "specified as \"" + arg + "\": " + e.getMessage()); 331 } 332 } 333 }; 334 335 private final TemplateMethodModelEx formatMemoryModel 336 = new TemplateMethodModelEx() { 337 @Override 338 public Object exec(@SuppressWarnings("rawtypes") List arguments) 339 throws TemplateModelException { 340 var arg = arguments.get(0); 341 if (arg instanceof SimpleNumber number) { 342 arg = number.getAsNumber(); 343 } 344 BigInteger bigInt; 345 if (arg instanceof BigInteger value) { 346 bigInt = value; 347 } else if (arg instanceof BigDecimal dec) { 348 try { 349 bigInt = dec.toBigIntegerExact(); 350 } catch (ArithmeticException e) { 351 return arg; 352 } 353 } else if (arg instanceof Integer value) { 354 bigInt = BigInteger.valueOf(value); 355 } else if (arg instanceof Long value) { 356 bigInt = BigInteger.valueOf(value); 357 } else { 358 return arg; 359 } 360 return Convertions.formatMemory(bigInt); 361 } 362 }; 363 364 private final TemplateMethodModelEx imgageLocationModel 365 = new TemplateMethodModelEx() { 366 @Override 367 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) 368 public Object exec(@SuppressWarnings("rawtypes") List arguments) 369 throws TemplateModelException { 370 var image = ((SimpleScalar) arguments.get(0)).getAsString(); 371 if (image.isEmpty()) { 372 return ""; 373 } 374 try { 375 var imageUri 376 = new URI("file://" + Constants.IMAGE_REPO_PATH + "/") 377 .resolve(image); 378 if ("file".equals(imageUri.getScheme())) { 379 return imageUri.getPath(); 380 } 381 return imageUri.toString(); 382 } catch (URISyntaxException e) { 383 logger.warning(() -> "Invalid CDROM image: " + image); 384 } 385 return image; 386 } 387 }; 388 389 private final TemplateMethodModelEx toJsonModel 390 = new TemplateMethodModelEx() { 391 @Override 392 public Object exec(@SuppressWarnings("rawtypes") List arguments) 393 throws TemplateModelException { 394 try { 395 return mapper.writeValueAsString( 396 ((AdapterTemplateModel) arguments.get(0)) 397 .getAdaptedObject(Object.class)); 398 } catch (JsonProcessingException e) { 399 return "{}"; 400 } 401 } 402 }; 403}