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(), chanMgr)); 104 attach(new DisplaySecretMonitor(channel(), chanMgr)); 105 // Currently, we don't use the IP assigned by the load balancer 106 // to access the VM's console. Might change in the future. 107 // attach(new ServiceMonitor(channel()).channelManager(chanMgr)); 108 attach(new Reconciler(channel())); 109 } 110 111 /** 112 * Special handling of {@link ApiException} thrown by handlers. 113 * 114 * @param event the event 115 */ 116 @Handler(channels = Channel.class) 117 public void onHandlingError(HandlingError event) { 118 if (event.throwable() instanceof ApiException exc) { 119 logger.log(Level.WARNING, exc, 120 () -> "Problem accessing kubernetes: " + exc.getResponseBody()); 121 event.stop(); 122 } 123 } 124 125 /** 126 * Configure the component. 127 * 128 * @param event the event 129 */ 130 @Handler 131 public void onConfigurationUpdate(ConfigurationUpdate event) { 132 event.structured(componentPath()).ifPresent(c -> { 133 if (c.containsKey("namespace")) { 134 namespace = (String) c.get("namespace"); 135 } 136 }); 137 } 138 139 /** 140 * Handle the start event. Has higher priority because it configures 141 * the default Kubernetes client. 142 * 143 * @param event the event 144 * @throws IOException 145 * @throws ApiException 146 */ 147 @Handler(priority = 100) 148 public void onStart(Start event) throws IOException, ApiException { 149 // Make sure to use thread specific client 150 // https://github.com/kubernetes-client/java/issues/100 151 Configuration.setDefaultApiClient(null); 152 153 // Verify that a namespace has been configured 154 if (namespace == null) { 155 var path = Path 156 .of("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); 157 if (Files.isReadable(path)) { 158 namespace = Files.lines(path).findFirst().orElse(null); 159 fire(new ConfigurationUpdate().add(componentPath(), "namespace", 160 namespace)); 161 } 162 } 163 if (namespace == null) { 164 logger.severe(() -> "Namespace to control not configured and" 165 + " no file in kubernetes directory."); 166 event.cancel(true); 167 fire(new Exit(2)); 168 return; 169 } 170 logger.fine(() -> "Controlling namespace \"" + namespace + "\"."); 171 } 172 173 /** 174 * On modify vm. 175 * 176 * @param event the event 177 * @throws ApiException the api exception 178 * @throws IOException Signals that an I/O exception has occurred. 179 */ 180 @Handler 181 public void onModifyVm(ModifyVm event, VmChannel channel) 182 throws ApiException, IOException { 183 patchVmDef(channel.client(), event.name(), "spec/vm/" + event.path(), 184 event.value()); 185 } 186 187 private void patchVmDef(K8sClient client, String name, String path, 188 Object value) throws ApiException, IOException { 189 var vmStub = K8sDynamicStub.get(client, 190 new GroupVersionKind(VM_OP_GROUP, "", VM_OP_KIND_VM), namespace, 191 name); 192 193 // Patch running 194 String valueAsText = value instanceof String 195 ? "\"" + value + "\"" 196 : value.toString(); 197 var res = vmStub.patch(V1Patch.PATCH_FORMAT_JSON_PATCH, 198 new V1Patch("[{\"op\": \"replace\", \"path\": \"/" 199 + path + "\", \"value\": " + valueAsText + "}]"), 200 client.defaultPatchOptions()); 201 if (!res.isPresent()) { 202 logger.warning( 203 () -> "Cannot patch definition for Vm " + vmStub.name()); 204 } 205 } 206}