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 io.kubernetes.client.apimachinery.GroupVersionKind; 022import io.kubernetes.client.custom.V1Patch; 023import io.kubernetes.client.openapi.ApiException; 024import io.kubernetes.client.openapi.Configuration; 025import java.io.IOException; 026import java.nio.file.Files; 027import java.nio.file.Path; 028import java.util.logging.Level; 029import static org.jdrupes.vmoperator.common.Constants.VM_OP_GROUP; 030import static org.jdrupes.vmoperator.common.Constants.VM_OP_KIND_VM; 031import org.jdrupes.vmoperator.common.K8sClient; 032import org.jdrupes.vmoperator.common.K8sDynamicStub; 033import org.jdrupes.vmoperator.manager.events.ChannelManager; 034import org.jdrupes.vmoperator.manager.events.Exit; 035import org.jdrupes.vmoperator.manager.events.ModifyVm; 036import org.jdrupes.vmoperator.manager.events.VmChannel; 037import org.jdrupes.vmoperator.manager.events.VmDefChanged; 038import org.jgrapes.core.Channel; 039import org.jgrapes.core.Component; 040import org.jgrapes.core.annotation.Handler; 041import org.jgrapes.core.events.HandlingError; 042import org.jgrapes.core.events.Start; 043import org.jgrapes.util.events.ConfigurationUpdate; 044 045/** 046 * Implements a controller as defined in the 047 * [Operator Whitepaper](https://github.com/cncf/tag-app-delivery/blob/eece8f7307f2970f46f100f51932db106db46968/operator-wg/whitepaper/Operator-WhitePaper_v1-0.md#operator-components-in-kubernetes). 048 * 049 * The implementation splits the controller in two components. The 050 * {@link VmMonitor} and the {@link Reconciler}. The former watches 051 * the VM definitions (CRs) and generates {@link VmDefChanged} events 052 * when they change. The latter handles the changes and reconciles the 053 * resources in the cluster. 054 * 055 * The controller itself supports a single configuration property: 056 * ```yaml 057 * "/Manager": 058 * "/Controller": 059 * namespace: vmop-dev 060 * ``` 061 * This may only be set when running the Manager (and thus the Controller) 062 * outside a container during development. 063 * 064 * ![Controller components](controller-components.svg) 065 * 066 * @startuml controller-components.svg 067 * skinparam component { 068 * BackGroundColor #FEFECE 069 * BorderColor #A80036 070 * BorderThickness 1.25 071 * BackgroundColor<<internal>> #F1F1F1 072 * BorderColor<<internal>> #181818 073 * BorderThickness<<internal>> 1 074 * } 075 * 076 * [Controller] 077 * [Controller] *--> [VmWatcher] 078 * [Controller] *--> [Reconciler] 079 * @enduml 080 */ 081public class Controller extends Component { 082 083 private String namespace; 084 085 /** 086 * Creates a new instance. 087 */ 088 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 089 public Controller(Channel componentChannel) { 090 super(componentChannel); 091 // Prepare component tree 092 ChannelManager<String, VmChannel, ?> chanMgr 093 = new ChannelManager<>(name -> { 094 try { 095 return new VmChannel(channel(), newEventPipeline(), 096 new K8sClient()); 097 } catch (IOException e) { 098 logger.log(Level.SEVERE, e, () -> "Failed to create client" 099 + " for handling changes: " + e.getMessage()); 100 return null; 101 } 102 }); 103 attach(new VmMonitor(channel()).channelManager(chanMgr)); 104 attach(new DisplaySecretMonitor(channel()) 105 .channelManager(chanMgr.fixed())); 106 // Currently, we don't use the IP assigned by the load balancer 107 // to access the VM's console. Might change in the future. 108 // attach(new ServiceMonitor(channel()).channelManager(chanMgr)); 109 attach(new Reconciler(channel())); 110 } 111 112 /** 113 * Special handling of {@link ApiException} thrown by handlers. 114 * 115 * @param event the event 116 */ 117 @Handler(channels = Channel.class) 118 public void onHandlingError(HandlingError event) { 119 if (event.throwable() instanceof ApiException exc) { 120 logger.log(Level.WARNING, exc, 121 () -> "Problem accessing kubernetes: " + exc.getResponseBody()); 122 event.stop(); 123 } 124 } 125 126 /** 127 * Configure the component. 128 * 129 * @param event the event 130 */ 131 @Handler 132 public void onConfigurationUpdate(ConfigurationUpdate event) { 133 event.structured(componentPath()).ifPresent(c -> { 134 if (c.containsKey("namespace")) { 135 namespace = (String) c.get("namespace"); 136 } 137 }); 138 } 139 140 /** 141 * Handle the start event. Has higher priority because it configures 142 * the default Kubernetes client. 143 * 144 * @param event the event 145 * @throws IOException 146 * @throws ApiException 147 */ 148 @Handler(priority = 100) 149 public void onStart(Start event) throws IOException, ApiException { 150 // Make sure to use thread specific client 151 // https://github.com/kubernetes-client/java/issues/100 152 Configuration.setDefaultApiClient(null); 153 154 // Verify that a namespace has been configured 155 if (namespace == null) { 156 var path = Path 157 .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); 158 if (Files.isReadable(path)) { 159 namespace = Files.lines(path).findFirst().orElse(null); 160 fire(new ConfigurationUpdate().add(componentPath(), "namespace", 161 namespace)); 162 } 163 } 164 if (namespace == null) { 165 logger.severe(() -> "Namespace to control not configured and" 166 + " no file in kubernetes directory."); 167 event.cancel(true); 168 fire(new Exit(2)); 169 return; 170 } 171 logger.fine(() -> "Controlling namespace \"" + namespace + "\"."); 172 } 173 174 /** 175 * On modify vm. 176 * 177 * @param event the event 178 * @throws ApiException the api exception 179 * @throws IOException Signals that an I/O exception has occurred. 180 */ 181 @Handler 182 public void onModifyVm(ModifyVm event, VmChannel channel) 183 throws ApiException, IOException { 184 patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), 185 event.value()); 186 } 187 188 private void patchVmDef(K8sClient client, String name, String path, 189 Object value) throws ApiException, IOException { 190 var vmStub = K8sDynamicStub.get(client, 191 new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, 192 name); 193 194 // Patch running 195 String valueAsText = value instanceof String 196 ? "\"" + value + "\"" 197 : value.toString(); 198 var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, 199 new V1Patch("[{\"op\": \"replace\", \"path\": \"/" 200 + path + "\", \"value\": " + valueAsText + "}]"), 201 client.defaultPatchOptions()); 202 if (!res.isPresent()) { 203 logger.warning( 204 () -> "Cannot patch definition for Vm " + vmStub.name()); 205 } 206 } 207}