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}