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