001/* 002 * VM-Operator 003 * Copyright (C) 2023,2025 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.JsonObject; 022import io.kubernetes.client.apimachinery.GroupVersionKind; 023import io.kubernetes.client.custom.V1Patch; 024import io.kubernetes.client.openapi.ApiException; 025import io.kubernetes.client.openapi.models.V1PodIP; 026import io.kubernetes.client.util.Watch; 027import io.kubernetes.client.util.generic.options.ListOptions; 028import java.io.IOException; 029import java.net.HttpURLConnection; 030import java.time.Instant; 031import java.util.ArrayList; 032import java.util.Collections; 033import java.util.Optional; 034import java.util.Set; 035import java.util.stream.Collectors; 036import org.jdrupes.vmoperator.common.Constants.Crd; 037import org.jdrupes.vmoperator.common.Constants.Status; 038import org.jdrupes.vmoperator.common.K8s; 039import org.jdrupes.vmoperator.common.K8sClient; 040import org.jdrupes.vmoperator.common.K8sDynamicStub; 041import org.jdrupes.vmoperator.common.K8sGenericStub; 042import org.jdrupes.vmoperator.common.K8sObserver.ResponseType; 043import org.jdrupes.vmoperator.common.K8sV1ConfigMapStub; 044import org.jdrupes.vmoperator.common.K8sV1StatefulSetStub; 045import org.jdrupes.vmoperator.common.VmDefinition; 046import org.jdrupes.vmoperator.common.VmDefinitionStub; 047import org.jdrupes.vmoperator.common.VmDefinitions; 048import org.jdrupes.vmoperator.common.VmExtraData; 049import static org.jdrupes.vmoperator.manager.Constants.APP_NAME; 050import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME; 051import org.jdrupes.vmoperator.manager.events.ChannelManager; 052import org.jdrupes.vmoperator.manager.events.ModifyVm; 053import org.jdrupes.vmoperator.manager.events.PodChanged; 054import org.jdrupes.vmoperator.manager.events.UpdateAssignment; 055import org.jdrupes.vmoperator.manager.events.VmChannel; 056import org.jdrupes.vmoperator.manager.events.VmResourceChanged; 057import org.jdrupes.vmoperator.util.GsonPtr; 058import org.jgrapes.core.Channel; 059import org.jgrapes.core.Event; 060import org.jgrapes.core.EventPipeline; 061import org.jgrapes.core.annotation.Handler; 062 063/** 064 * Watches for changes of VM definitions. When a VM definition (CR) 065 * becomes known, is is registered with a {@link ChannelManager} and thus 066 * gets an associated {@link VmChannel} and an associated 067 * {@link EventPipeline}. 068 * 069 * The {@link EventPipeline} is used for submitting an action that processes 070 * the change data from kubernetes, eventually transforming it to a 071 * {@link VmResourceChanged} event that is handled by another 072 * {@link EventPipeline} associated with the {@link VmChannel}. This 073 * event pipeline should be used for all events related to changes of 074 * a particular VM. 075 */ 076public class VmMonitor extends 077 AbstractMonitor<VmDefinition, VmDefinitions, VmChannel> { 078 079 private final ChannelManager<String, VmChannel, 080 EventPipeline> channelManager; 081 082 /** 083 * Instantiates a new VM definition watcher. 084 * 085 * @param componentChannel the component channel 086 * @param channelManager the channel manager 087 */ 088 public VmMonitor(Channel componentChannel, 089 ChannelManager<String, VmChannel, EventPipeline> channelManager) { 090 super(componentChannel, VmDefinition.class, 091 VmDefinitions.class); 092 this.channelManager = channelManager; 093 } 094 095 @Override 096 protected void prepareMonitoring() throws IOException, ApiException { 097 client(new K8sClient()); 098 099 // Get all our API versions 100 var ctx = K8s.context(client(), Crd.GROUP, "", Crd.KIND_VM); 101 if (ctx.isEmpty()) { 102 logger.severe(() -> "Cannot get CRD context."); 103 return; 104 } 105 context(ctx.get()); 106 107 // Remove left over resources 108 purge(); 109 } 110 111 private void purge() throws ApiException { 112 // Get existing CRs (VMs) 113 var known = K8sDynamicStub.list(client(), context(), namespace()) 114 .stream().map(K8sGenericStub::name).collect(Collectors.toSet()); 115 ListOptions opts = new ListOptions(); 116 opts.setLabelSelector( 117 "app.kubernetes.io/managed-by=" + VM_OP_NAME + "," 118 + "app.kubernetes.io/name=" + APP_NAME); 119 for (var context : Set.of(K8sV1StatefulSetStub.CONTEXT, 120 K8sV1ConfigMapStub.CONTEXT)) { 121 for (var resStub : K8sDynamicStub.list(client(), context, 122 namespace(), opts)) { 123 String instance = resStub.model() 124 .map(m -> m.metadata().getName()).orElse("(unknown)"); 125 if (!known.contains(instance)) { 126 resStub.delete(); 127 } 128 } 129 } 130 } 131 132 @Override 133 protected void handleChange(K8sClient client, 134 Watch.Response<VmDefinition> response) { 135 var name = response.object.getMetadata().getName(); 136 137 // Process the response data on a VM specific pipeline to 138 // increase concurrency when e.g. starting many VMs. 139 var preparing = channelManager.associated(name) 140 .orElseGet(this::newEventPipeline); 141 preparing.submit("VmChange[" + name + "]", 142 () -> processChange(client, response, preparing)); 143 } 144 145 private void processChange(K8sClient client, 146 Watch.Response<VmDefinition> response, EventPipeline preparing) { 147 // Get full definition and associate with channel as backup 148 var vmDef = response.object; 149 if (vmDef.data() == null) { 150 // ADDED event does not provide data, see 151 // https://github.com/kubernetes-client/java/issues/3215 152 vmDef = getModel(client, vmDef); 153 } 154 var name = response.object.getMetadata().getName(); 155 var channel = channelManager.channel(name) 156 .orElseGet(() -> channelManager.createChannel(name)); 157 if (vmDef.data() != null) { 158 // New data, augment and save 159 addExtraData(vmDef, channel.vmDefinition()); 160 channel.setVmDefinition(vmDef); 161 } else { 162 // Reuse cached (e.g. if deleted) 163 vmDef = channel.vmDefinition(); 164 } 165 if (vmDef == null) { 166 logger.warning(() -> "Cannot get defintion for " 167 + response.object.getMetadata()); 168 return; 169 } 170 channelManager.put(name, channel, preparing); 171 172 // Create and fire changed event. Remove channel from channel 173 // manager on completion. 174 VmResourceChanged chgEvt 175 = new VmResourceChanged(ResponseType.valueOf(response.type), vmDef, 176 channel.setGeneration(response.object.getMetadata() 177 .getGeneration()), 178 false); 179 if (ResponseType.valueOf(response.type) == ResponseType.DELETED) { 180 chgEvt = Event.onCompletion(chgEvt, 181 e -> channelManager.remove(e.vmDefinition().name())); 182 } 183 channel.fire(chgEvt); 184 } 185 186 private VmDefinition getModel(K8sClient client, VmDefinition vmDef) { 187 try { 188 return VmDefinitionStub.get(client, context(), namespace(), 189 vmDef.metadata().getName()).model().orElse(null); 190 } catch (ApiException e) { 191 return null; 192 } 193 } 194 195 private void addExtraData(VmDefinition vmDef, VmDefinition prevState) { 196 var extra = new VmExtraData(vmDef); 197 var prevExtra = Optional.ofNullable(prevState).map(VmDefinition::extra); 198 199 // Maintain (or initialize) the resetCount 200 extra.resetCount(prevExtra.map(VmExtraData::resetCount).orElse(0L)); 201 202 // Maintain node info 203 prevExtra 204 .ifPresent(e -> extra.nodeInfo(e.nodeName(), e.nodeAddresses())); 205 } 206 207 /** 208 * On pod changed. 209 * 210 * @param event the event 211 * @param channel the channel 212 */ 213 @Handler 214 public void onPodChanged(PodChanged event, VmChannel channel) { 215 var vmDef = channel.vmDefinition(); 216 217 // Make sure that this is properly sync'd with VM CR changes. 218 channelManager.associated(vmDef.name()) 219 .orElseGet(this::activeEventPipeline) 220 .submit("NodeInfo[" + vmDef.name() + "]", 221 () -> { 222 updateNodeInfo(event, vmDef); 223 channel.fire(new VmResourceChanged(ResponseType.MODIFIED, 224 vmDef, false, true)); 225 }); 226 } 227 228 private void updateNodeInfo(PodChanged event, VmDefinition vmDef) { 229 var extra = vmDef.extra(); 230 if (event.type() == ResponseType.DELETED) { 231 // The status of a deleted pod is the status before deletion, 232 // i.e. the node info is still cached and must be removed. 233 extra.nodeInfo("", Collections.emptyList()); 234 return; 235 } 236 237 // Get current node info from pod 238 var pod = event.pod(); 239 var nodeName = Optional 240 .ofNullable(pod.getSpec().getNodeName()).orElse(""); 241 logger.finer(() -> "Adding node name " + nodeName 242 + " to VM info for " + vmDef.name()); 243 var addrs = new ArrayList<String>(); 244 Optional.ofNullable(pod.getStatus().getPodIPs()) 245 .orElse(Collections.emptyList()).stream() 246 .map(V1PodIP::getIp).forEach(addrs::add); 247 logger.finer(() -> "Adding node addresses " + addrs 248 + " to VM info for " + vmDef.name()); 249 extra.nodeInfo(nodeName, addrs); 250 } 251 252 /** 253 * On modify vm. 254 * 255 * @param event the event 256 * @throws ApiException the api exception 257 * @throws IOException Signals that an I/O exception has occurred. 258 */ 259 @Handler 260 public void onModifyVm(ModifyVm event, VmChannel channel) 261 throws ApiException, IOException { 262 patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), 263 event.value()); 264 } 265 266 private void patchVmDef(K8sClient client, String name, String path, 267 Object value) throws ApiException, IOException { 268 var vmStub = K8sDynamicStub.get(client, 269 new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), namespace(), 270 name); 271 272 // Patch running 273 String valueAsText = value instanceof String 274 ? "\"" + value + "\"" 275 : value.toString(); 276 var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, 277 new V1Patch("[{\"op\": \"replace\", \"path\": \"/" 278 + path + "\", \"value\": " + valueAsText + "}]"), 279 client.defaultPatchOptions()); 280 if (!res.isPresent()) { 281 logger.warning( 282 () -> "Cannot patch definition for Vm " + vmStub.name()); 283 } 284 } 285 286 /** 287 * Attempt to Update the assignment information in the status of the 288 * VM CR. Returns true if successful. The handler does not attempt 289 * retries, because in case of failure it will be necessary to 290 * re-evaluate the chosen VM. 291 * 292 * @param event the event 293 * @param channel the channel 294 * @throws ApiException the api exception 295 */ 296 @Handler 297 public void onUpdatedAssignment(UpdateAssignment event, VmChannel channel) 298 throws ApiException { 299 try { 300 var vmDef = channel.vmDefinition(); 301 var vmStub = VmDefinitionStub.get(channel.client(), 302 new GroupVersionKind(Crd.GROUP, "", Crd.KIND_VM), 303 vmDef.namespace(), vmDef.name()); 304 if (vmStub.updateStatus(vmDef, from -> { 305 JsonObject status = from.statusJson(); 306 if (event.toUser() == null) { 307 ((JsonObject) GsonPtr.to(status).get()) 308 .remove(Status.ASSIGNMENT); 309 } else { 310 var assignment = GsonPtr.to(status).to(Status.ASSIGNMENT); 311 assignment.set("pool", event.fromPool().name()); 312 assignment.set("user", event.toUser()); 313 assignment.set("lastUsed", Instant.now().toString()); 314 } 315 return status; 316 }).isPresent()) { 317 event.setResult(true); 318 } 319 } catch (ApiException e) { 320 // Log exceptions except for conflict, which can be expected 321 if (HttpURLConnection.HTTP_CONFLICT != e.getCode()) { 322 throw e; 323 } 324 } 325 event.setResult(false); 326 } 327 328}