001/*
002 * VM-Operator
003 * Copyright (C) 2025 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.common;
020
021import com.fasterxml.jackson.databind.ObjectMapper;
022import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
023import com.google.gson.Gson;
024import com.google.gson.JsonObject;
025import io.kubernetes.client.openapi.JSON;
026import io.kubernetes.client.openapi.models.V1Condition;
027import java.time.Instant;
028import java.util.Collection;
029import java.util.Collections;
030import java.util.EnumSet;
031import java.util.HashMap;
032import java.util.HashSet;
033import java.util.List;
034import java.util.Map;
035import java.util.Objects;
036import java.util.Optional;
037import java.util.Set;
038import java.util.function.Function;
039import java.util.logging.Logger;
040import java.util.stream.Collectors;
041import org.jdrupes.vmoperator.common.Constants.Status;
042import org.jdrupes.vmoperator.common.Constants.Status.Condition;
043import org.jdrupes.vmoperator.common.Constants.Status.Condition.Reason;
044import org.jdrupes.vmoperator.util.DataPath;
045
046/**
047 * Represents a VM definition.
048 */
049@SuppressWarnings({ "PMD.DataClass", "PMD.TooManyMethods" })
050public class VmDefinition extends K8sDynamicModel {
051
052    @SuppressWarnings({ "unused" })
053    private static final Logger logger
054        = Logger.getLogger(VmDefinition.class.getName());
055    @SuppressWarnings("PMD.FieldNamingConventions")
056    private static final Gson gson = new JSON().getGson();
057    @SuppressWarnings("PMD.FieldNamingConventions")
058    private static final ObjectMapper objectMapper
059        = new ObjectMapper().registerModule(new JavaTimeModule());
060
061    private final Model model;
062    private VmExtraData extraData;
063
064    /**
065     * The VM state from the VM definition.
066     */
067    public enum RequestedVmState {
068        STOPPED, RUNNING
069    }
070
071    /**
072     * Permissions for accessing and manipulating the VM.
073     */
074    public enum Permission {
075        START("start"), STOP("stop"), RESET("reset"),
076        ACCESS_CONSOLE("accessConsole"), TAKE_CONSOLE("takeConsole");
077
078        @SuppressWarnings("PMD.UseConcurrentHashMap")
079        private static Map<String, Permission> reprs = new HashMap<>();
080
081        static {
082            for (var value : EnumSet.allOf(Permission.class)) {
083                reprs.put(value.repr, value);
084            }
085        }
086
087        private final String repr;
088
089        Permission(String repr) {
090            this.repr = repr;
091        }
092
093        /**
094         * Create permission from representation in CRD.
095         *
096         * @param value the value
097         * @return the permission
098         */
099        @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
100        public static Set<Permission> parse(String value) {
101            if ("*".equals(value)) {
102                return EnumSet.allOf(Permission.class);
103            }
104            return Set.of(reprs.get(value));
105        }
106
107        /**
108         * To string.
109         *
110         * @return the string
111         */
112        @Override
113        public String toString() {
114            return repr;
115        }
116    }
117
118    /**
119     * Permissions granted to a user or role.
120     *
121     * @param user the user
122     * @param role the role
123     * @param may the may
124     */
125    public record Grant(String user, String role, Set<Permission> may) {
126
127        /**
128         * To string.
129         *
130         * @return the string
131         */
132        @Override
133        public String toString() {
134            StringBuilder builder = new StringBuilder();
135            if (user != null) {
136                builder.append("User ").append(user);
137            } else {
138                builder.append("Role ").append(role);
139            }
140            builder.append(" may=").append(may).append(']');
141            return builder.toString();
142        }
143    }
144
145    /**
146     * The assignment information.
147     *
148     * @param pool the pool
149     * @param user the user
150     * @param lastUsed the last used
151     */
152    public record Assignment(String pool, String user, Instant lastUsed) {
153    }
154
155    /**
156     * Instantiates a new vm definition.
157     *
158     * @param delegate the delegate
159     * @param json the json
160     */
161    public VmDefinition(Gson delegate, JsonObject json) {
162        super(delegate, json);
163        model = gson.fromJson(json, Model.class);
164    }
165
166    /**
167     * Gets the spec.
168     *
169     * @return the spec
170     */
171    public Map<String, Object> spec() {
172        return model.getSpec();
173    }
174
175    /**
176     * Get a value from the spec using {@link DataPath#get}.
177     *
178     * @param <T> the generic type
179     * @param selectors the selectors
180     * @return the value, if found
181     */
182    public <T> Optional<T> fromSpec(Object... selectors) {
183        return DataPath.get(spec(), selectors);
184    }
185
186    /**
187     * The pools that this VM belongs to.
188     *
189     * @return the list
190     */
191    public List<String> pools() {
192        return this.<List<String>> fromSpec("pools")
193            .orElse(Collections.emptyList());
194    }
195
196    /**
197     * Get a value from the `spec().get("vm")` using {@link DataPath#get}.
198     *
199     * @param <T> the generic type
200     * @param selectors the selectors
201     * @return the value, if found
202     */
203    public <T> Optional<T> fromVm(Object... selectors) {
204        return DataPath.get(spec(), "vm")
205            .flatMap(vm -> DataPath.get(vm, selectors));
206    }
207
208    /**
209     * Gets the status.
210     *
211     * @return the status
212     */
213    public Map<String, Object> status() {
214        return model.getStatus();
215    }
216
217    /**
218     * Get a value from the status using {@link DataPath#get}.
219     *
220     * @param <T> the generic type
221     * @param selectors the selectors
222     * @return the value, if found
223     */
224    public <T> Optional<T> fromStatus(Object... selectors) {
225        return DataPath.get(status(), selectors);
226    }
227
228    /**
229     * The assignment information.
230     *
231     * @return the optional
232     */
233    public Optional<Assignment> assignment() {
234        return this.<Map<String, Object>> fromStatus(Status.ASSIGNMENT)
235            .filter(m -> !m.isEmpty()).map(a -> new Assignment(
236                a.get("pool").toString(), a.get("user").toString(),
237                Instant.parse(a.get("lastUsed").toString())));
238    }
239
240    /**
241     * Return a condition from the status.
242     *
243     * @param name the condition's name
244     * @return the status, if the condition is defined
245     */
246    public Optional<V1Condition> condition(String name) {
247        return this.<List<Map<String, Object>>> fromStatus("conditions")
248            .orElse(Collections.emptyList()).stream()
249            .filter(cond -> DataPath.get(cond, "type")
250                .map(name::equals).orElse(false))
251            .findFirst()
252            .map(cond -> objectMapper.convertValue(cond, V1Condition.class));
253    }
254
255    /**
256     * Return a condition's status.
257     *
258     * @param name the condition's name
259     * @return the status, if the condition is defined
260     */
261    public Optional<Boolean> conditionStatus(String name) {
262        return this.<List<Map<String, Object>>> fromStatus("conditions")
263            .orElse(Collections.emptyList()).stream()
264            .filter(cond -> DataPath.get(cond, "type")
265                .map(name::equals).orElse(false))
266            .findFirst().map(cond -> DataPath.get(cond, "status")
267                .map("True"::equals).orElse(false));
268    }
269
270    /**
271     * Return true if the console is in use.
272     *
273     * @return true, if successful
274     */
275    public boolean consoleConnected() {
276        return conditionStatus("ConsoleConnected").orElse(false);
277    }
278
279    /**
280     * Return the last known console user.
281     *
282     * @return the optional
283     */
284    public Optional<String> consoleUser() {
285        return this.<String> fromStatus(Status.CONSOLE_USER);
286    }
287
288    /**
289     * Set extra data (unknown to kubernetes).
290     * @return the VM definition
291     */
292    /* default */ VmDefinition extra(VmExtraData extraData) {
293        this.extraData = extraData;
294        return this;
295    }
296
297    /**
298     * Return the extra data.
299     *
300     * @return the data
301     */
302    public VmExtraData extra() {
303        return extraData;
304    }
305
306    /**
307     * Returns the definition's name.
308     *
309     * @return the string
310     */
311    public String name() {
312        return metadata().getName();
313    }
314
315    /**
316     * Returns the definition's namespace.
317     *
318     * @return the string
319     */
320    public String namespace() {
321        return metadata().getNamespace();
322    }
323
324    /**
325     * Return the requested VM state.
326     *
327     * @return the string
328     */
329    public RequestedVmState vmState() {
330        return fromVm("state")
331            .map(s -> "Running".equals(s) ? RequestedVmState.RUNNING
332                : RequestedVmState.STOPPED)
333            .orElse(RequestedVmState.STOPPED);
334    }
335
336    /**
337     * Collect all permissions for the given user with the given roles.
338     * If permission "takeConsole" is granted, the result will also
339     * contain "accessConsole" to simplify checks.
340     *
341     * @param user the user
342     * @param roles the roles
343     * @return the sets the
344     */
345    public Set<Permission> permissionsFor(String user,
346            Collection<String> roles) {
347        var result = this.<List<Map<String, Object>>> fromSpec("permissions")
348            .orElse(Collections.emptyList()).stream()
349            .filter(p -> DataPath.get(p, "user").map(u -> u.equals(user))
350                .orElse(false)
351                || DataPath.get(p, "role").map(roles::contains).orElse(false))
352            .map(p -> DataPath.<List<String>> get(p, "may")
353                .orElse(Collections.emptyList()).stream())
354            .flatMap(Function.identity())
355            .map(Permission::parse).map(Set::stream)
356            .flatMap(Function.identity())
357            .collect(Collectors.toCollection(HashSet::new));
358
359        // Take console implies access console, simplify checks
360        if (result.contains(Permission.TAKE_CONSOLE)) {
361            result.add(Permission.ACCESS_CONSOLE);
362        }
363        return result;
364    }
365
366    /**
367     * Check if the console is accessible. Always returns `true` if 
368     * the VM is running and the permissions allow taking over the
369     * console. Else, returns `true` if
370     * 
371     *   * the permissions allow access to the console and
372     * 
373     *   * the VM is running and
374     * 
375     *   * the console is currently unused or used by the given user and
376     *     
377     *   * if user login is requested, the given user is logged in.
378     *
379     * @param user the user
380     * @param permissions the permissions
381     * @return true, if successful
382     */
383    @SuppressWarnings("PMD.SimplifyBooleanReturns")
384    public boolean consoleAccessible(String user, Set<Permission> permissions) {
385        // Basic checks
386        if (!conditionStatus(Condition.RUNNING).orElse(false)) {
387            return false;
388        }
389        if (permissions.contains(Permission.TAKE_CONSOLE)) {
390            return true;
391        }
392        if (!permissions.contains(Permission.ACCESS_CONSOLE)) {
393            return false;
394        }
395
396        // If the console is in use by another user, deny access
397        if (conditionStatus(Condition.CONSOLE_CONNECTED).orElse(false)
398            && !consoleUser().map(cu -> cu.equals(user)).orElse(false)) {
399            return false;
400        }
401
402        // If no login is requested, allow access, else check if user matches
403        if (condition(Condition.USER_LOGGED_IN).map(V1Condition::getReason)
404            .map(r -> Reason.NOT_REQUESTED.equals(r)).orElse(false)) {
405            return true;
406        }
407        return user.equals(status().get(Status.LOGGED_IN_USER));
408    }
409
410    /**
411     * Get the display password serial.
412     *
413     * @return the optional
414     */
415    public Optional<Long> displayPasswordSerial() {
416        return this.<Number> fromStatus(Status.DISPLAY_PASSWORD_SERIAL)
417            .map(Number::longValue);
418    }
419
420    /**
421     * Hash code.
422     *
423     * @return the int
424     */
425    @Override
426    public int hashCode() {
427        return Objects.hash(metadata().getNamespace(), metadata().getName());
428    }
429
430    /**
431     * Equals.
432     *
433     * @param obj the obj
434     * @return true, if successful
435     */
436    @Override
437    public boolean equals(Object obj) {
438        if (this == obj) {
439            return true;
440        }
441        if (obj == null) {
442            return false;
443        }
444        if (getClass() != obj.getClass()) {
445            return false;
446        }
447        VmDefinition other = (VmDefinition) obj;
448        return Objects.equals(metadata().getNamespace(),
449            other.metadata().getNamespace())
450            && Objects.equals(metadata().getName(), other.metadata().getName());
451    }
452
453    /**
454     * The Class Model.
455     */
456    public static class Model {
457
458        private Map<String, Object> spec;
459        private Map<String, Object> status;
460
461        /**
462         * Gets the spec.
463         *
464         * @return the spec
465         */
466        public Map<String, Object> getSpec() {
467            return spec;
468        }
469
470        /**
471         * Sets the spec.
472         *
473         * @param spec the spec to set
474         */
475        public void setSpec(Map<String, Object> spec) {
476            this.spec = spec;
477        }
478
479        /**
480         * Gets the status.
481         *
482         * @return the status
483         */
484        public Map<String, Object> getStatus() {
485            return status;
486        }
487
488        /**
489         * Sets the status.
490         *
491         * @param status the status to set
492         */
493        public void setStatus(Map<String, Object> status) {
494            this.status = status;
495        }
496
497    }
498
499}