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}