001/*
002 * VM-Operator
003 * Copyright (C) 2024 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.custom.V1Patch;
022import io.kubernetes.client.openapi.ApiException;
023import io.kubernetes.client.openapi.models.V1Secret;
024import io.kubernetes.client.openapi.models.V1SecretList;
025import io.kubernetes.client.util.Watch.Response;
026import io.kubernetes.client.util.generic.options.ListOptions;
027import io.kubernetes.client.util.generic.options.PatchOptions;
028import java.io.IOException;
029import java.security.NoSuchAlgorithmException;
030import java.security.SecureRandom;
031import java.time.Instant;
032import java.util.Collections;
033import java.util.LinkedList;
034import java.util.List;
035import java.util.Map;
036import java.util.Optional;
037import java.util.Scanner;
038import java.util.logging.Level;
039import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
040import static org.jdrupes.vmoperator.common.Constants.VM_OP_NAME;
041import org.jdrupes.vmoperator.common.K8sClient;
042import org.jdrupes.vmoperator.common.K8sV1PodStub;
043import org.jdrupes.vmoperator.common.K8sV1SecretStub;
044import static org.jdrupes.vmoperator.manager.Constants.COMP_DISPLAY_SECRET;
045import static org.jdrupes.vmoperator.manager.Constants.DATA_DISPLAY_PASSWORD;
046import static org.jdrupes.vmoperator.manager.Constants.DATA_PASSWORD_EXPIRY;
047import org.jdrupes.vmoperator.manager.events.ChannelDictionary;
048import org.jdrupes.vmoperator.manager.events.GetDisplayPassword;
049import org.jdrupes.vmoperator.manager.events.VmChannel;
050import org.jdrupes.vmoperator.manager.events.VmDefChanged;
051import org.jgrapes.core.Channel;
052import org.jgrapes.core.CompletionLock;
053import org.jgrapes.core.Event;
054import org.jgrapes.core.annotation.Handler;
055import org.jgrapes.util.events.ConfigurationUpdate;
056import org.jose4j.base64url.Base64;
057
058/**
059 * Watches for changes of display secrets. The component supports the
060 * following configuration properties:
061 * 
062 *   * `passwordValidity`: the validity of the random password in seconds.
063 *     Used to calculate the password expiry time in the generated secret.
064 */
065@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.TooManyStaticImports" })
066public class DisplaySecretMonitor
067        extends AbstractMonitor<V1Secret, V1SecretList, VmChannel> {
068
069    private int passwordValidity = 10;
070    private final List<PendingGet> pendingGets
071        = Collections.synchronizedList(new LinkedList<>());
072    private final ChannelDictionary<String, VmChannel, ?> channelDictionary;
073
074    /**
075     * Instantiates a new display secrets monitor.
076     *
077     * @param componentChannel the component channel
078     * @param channelDictionary the channel dictionary
079     */
080    public DisplaySecretMonitor(Channel componentChannel,
081            ChannelDictionary<String, VmChannel, ?> channelDictionary) {
082        super(componentChannel, V1Secret.class, V1SecretList.class);
083        this.channelDictionary = channelDictionary;
084        context(K8sV1SecretStub.CONTEXT);
085        ListOptions options = new ListOptions();
086        options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
087            + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET);
088        options(options);
089    }
090
091    /**
092     * On configuration update.
093     *
094     * @param event the event
095     */
096    @Handler
097    @Override
098    public void onConfigurationUpdate(ConfigurationUpdate event) {
099        super.onConfigurationUpdate(event);
100        event.structured(componentPath()).ifPresent(c -> {
101            try {
102                if (c.containsKey("passwordValidity")) {
103                    passwordValidity = Integer
104                        .parseInt((String) c.get("passwordValidity"));
105                }
106            } catch (ClassCastException e) {
107                logger.config("Malformed configuration: " + e.getMessage());
108            }
109        });
110    }
111
112    @Override
113    protected void prepareMonitoring() throws IOException, ApiException {
114        client(new K8sClient());
115    }
116
117    @Override
118    protected void handleChange(K8sClient client, Response<V1Secret> change) {
119        String vmName = change.object.getMetadata().getLabels()
120            .get("app.kubernetes.io/instance");
121        if (vmName == null) {
122            return;
123        }
124        var channel = channelDictionary.channel(vmName).orElse(null);
125        if (channel == null || channel.vmDefinition() == null) {
126            return;
127        }
128
129        try {
130            patchPod(client, change);
131        } catch (ApiException e) {
132            logger.log(Level.WARNING, e,
133                () -> "Cannot patch pod annotations: " + e.getMessage());
134        }
135    }
136
137    private void patchPod(K8sClient client, Response<V1Secret> change)
138            throws ApiException {
139        // Force update for pod
140        ListOptions listOpts = new ListOptions();
141        listOpts.setLabelSelector(
142            "app.kubernetes.io/managed-by=" + VM_OP_NAME + ","
143                + "app.kubernetes.io/name=" + APP_NAME + ","
144                + "app.kubernetes.io/instance=" + change.object.getMetadata()
145                    .getLabels().get("app.kubernetes.io/instance"));
146        // Get pod, selected by label
147        var pods = K8sV1PodStub.list(client, namespace(), listOpts);
148
149        // If the VM is being created, the pod may not exist yet.
150        if (pods.isEmpty()) {
151            return;
152        }
153        var pod = pods.iterator().next();
154
155        // Patch pod annotation
156        PatchOptions patchOpts = new PatchOptions();
157        patchOpts.setFieldManager("kubernetes-java-kubectl-apply");
158        pod.patch(V1Patch.PATCH_FORMAT_JSON_PATCH,
159            new V1Patch("[{\"op\": \"replace\", \"path\": "
160                + "\"/metadata/annotations/vmrunner.jdrupes.org~1dpVersion\", "
161                + "\"value\": \""
162                + change.object.getMetadata().getResourceVersion()
163                + "\"}]"),
164            patchOpts);
165    }
166
167    /**
168     * On get display secrets.
169     *
170     * @param event the event
171     * @param channel the channel
172     * @throws ApiException the api exception
173     */
174    @Handler
175    @SuppressWarnings("PMD.StringInstantiation")
176    public void onGetDisplaySecrets(GetDisplayPassword event, VmChannel channel)
177            throws ApiException {
178        ListOptions options = new ListOptions();
179        options.setLabelSelector("app.kubernetes.io/name=" + APP_NAME + ","
180            + "app.kubernetes.io/component=" + COMP_DISPLAY_SECRET + ","
181            + "app.kubernetes.io/instance="
182            + event.vmDefinition().metadata().getName());
183        var stubs = K8sV1SecretStub.list(client(),
184            event.vmDefinition().metadata().getNamespace(), options);
185        if (stubs.isEmpty()) {
186            return;
187        }
188        var stub = stubs.iterator().next();
189
190        // Check validity
191        var model = stub.model().get();
192        @SuppressWarnings("PMD.StringInstantiation")
193        var expiry = Optional.ofNullable(model.getData()
194            .get(DATA_PASSWORD_EXPIRY)).map(b -> new String(b)).orElse(null);
195        if (model.getData().get(DATA_DISPLAY_PASSWORD) != null
196            && stillValid(expiry)) {
197            event.setResult(
198                new String(model.getData().get(DATA_DISPLAY_PASSWORD)));
199            return;
200        }
201        updatePassword(stub, event);
202    }
203
204    @SuppressWarnings("PMD.StringInstantiation")
205    private void updatePassword(K8sV1SecretStub stub, GetDisplayPassword event)
206            throws ApiException {
207        SecureRandom random = null;
208        try {
209            random = SecureRandom.getInstanceStrong();
210        } catch (NoSuchAlgorithmException e) { // NOPMD
211            // "Every implementation of the Java platform is required
212            // to support at least one strong SecureRandom implementation."
213        }
214        byte[] bytes = new byte[16];
215        random.nextBytes(bytes);
216        var password = Base64.encode(bytes);
217        var model = stub.model().get();
218        model.setStringData(Map.of(DATA_DISPLAY_PASSWORD, password,
219            DATA_PASSWORD_EXPIRY,
220            Long.toString(Instant.now().getEpochSecond() + passwordValidity)));
221        event.setResult(password);
222
223        // Prepare wait for confirmation (by VM status change)
224        var pending = new PendingGet(event,
225            event.vmDefinition().displayPasswordSerial().orElse(0L) + 1,
226            new CompletionLock(event, 1500));
227        pendingGets.add(pending);
228        Event.onCompletion(event, e -> {
229            pendingGets.remove(pending);
230        });
231
232        // Update, will (eventually) trigger confirmation
233        stub.update(model).getObject();
234    }
235
236    private boolean stillValid(String expiry) {
237        if (expiry == null || "never".equals(expiry)) {
238            return true;
239        }
240        @SuppressWarnings({ "PMD.CloseResource", "resource" })
241        var scanner = new Scanner(expiry);
242        if (!scanner.hasNextLong()) {
243            return false;
244        }
245        long expTime = scanner.nextLong();
246        return expTime > Instant.now().getEpochSecond() + passwordValidity;
247    }
248
249    /**
250     * On vm def changed.
251     *
252     * @param event the event
253     * @param channel the channel
254     */
255    @Handler
256    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
257    public void onVmDefChanged(VmDefChanged event, Channel channel) {
258        synchronized (pendingGets) {
259            String vmName = event.vmDefinition().metadata().getName();
260            for (var pending : pendingGets) {
261                if (pending.event.vmDefinition().metadata().getName()
262                    .equals(vmName)
263                    && event.vmDefinition().displayPasswordSerial()
264                        .map(s -> s >= pending.expectedSerial).orElse(false)) {
265                    pending.lock.remove();
266                    // pending will be removed from pendingGest by
267                    // waiting thread, see updatePassword
268                    continue;
269                }
270            }
271        }
272    }
273
274    /**
275     * The Class PendingGet.
276     */
277    @SuppressWarnings("PMD.DataClass")
278    private static class PendingGet {
279        public final GetDisplayPassword event;
280        public final long expectedSerial;
281        public final CompletionLock lock;
282
283        /**
284         * Instantiates a new pending get.
285         *
286         * @param event the event
287         * @param expectedSerial the expected serial
288         */
289        public PendingGet(GetDisplayPassword event, long expectedSerial,
290                CompletionLock lock) {
291            super();
292            this.event = event;
293            this.expectedSerial = expectedSerial;
294            this.lock = lock;
295        }
296    }
297}