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.runner.qemu;
020
021import java.io.IOException;
022import java.math.BigInteger;
023import java.nio.charset.StandardCharsets;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.nio.file.attribute.PosixFilePermission;
027import java.time.Instant;
028import java.util.HashMap;
029import java.util.Map;
030import java.util.Set;
031import java.util.UUID;
032import java.util.logging.Level;
033import java.util.logging.Logger;
034import static org.jdrupes.vmoperator.common.Constants.APP_NAME;
035import org.jdrupes.vmoperator.common.Convertions;
036import org.jdrupes.vmoperator.util.Dto;
037import org.jdrupes.vmoperator.util.FsdUtils;
038
039/**
040 * The configuration information from the configuration file.
041 */
042public class Configuration implements Dto {
043    private static final String CI_INSTANCE_ID = "instance-id";
044
045    protected final Logger logger = Logger.getLogger(getClass().getName());
046
047    /** Configuration timestamp. */
048    public Instant asOf;
049
050    /** The data dir. */
051    public Path dataDir;
052
053    /** The runtime dir. */
054    public Path runtimeDir;
055
056    /** The template. */
057    public String template;
058
059    /** The update template. */
060    public boolean updateTemplate;
061
062    /** The swtpm socket. */
063    public Path swtpmSocket;
064
065    /** The monitor socket. */
066    public Path monitorSocket;
067
068    /** The firmware rom. */
069    public Path firmwareRom;
070
071    /** The firmware vars. */
072    public Path firmwareVars;
073
074    /** The display password. */
075    public boolean hasDisplayPassword;
076
077    /** Optional cloud-init data. */
078    public CloudInit cloudInit;
079
080    /** If guest shutdown changes CRD .vm.state to "Stopped". */
081    public boolean guestShutdownStops;
082
083    /** Increments of the reset counter trigger a reset of the VM. */
084    public Integer resetCounter;
085
086    /** The vm. */
087    @SuppressWarnings("PMD.ShortVariable")
088    public Vm vm;
089
090    /**
091     * Subsection "cloud-init".
092     */
093    public static class CloudInit implements Dto {
094
095        /** The meta data. */
096        public Map<String, Object> metaData;
097
098        /** The user data. */
099        public Map<String, Object> userData;
100
101        /** The network config. */
102        public Map<String, Object> networkConfig;
103    }
104
105    /**
106     * Subsection "vm".
107     */
108    @SuppressWarnings({ "PMD.ShortClassName", "PMD.TooManyFields",
109        "PMD.DataClass", "PMD.AvoidDuplicateLiterals" })
110    public static class Vm implements Dto {
111
112        /** The name. */
113        public String name;
114
115        /** The uuid. */
116        public String uuid;
117
118        /** The use tpm. */
119        public boolean useTpm;
120
121        /** The boot menu. */
122        public boolean bootMenu;
123
124        /** The firmware. */
125        public String firmware = "uefi";
126
127        /** The maximum ram. */
128        public BigInteger maximumRam;
129
130        /** The current ram. */
131        public BigInteger currentRam;
132
133        /** The cpu model. */
134        public String cpuModel = "host";
135
136        /** The maximum cpus. */
137        public int maximumCpus = 1;
138
139        /** The current cpus. */
140        public int currentCpus = 1;
141
142        /** The cpu sockets. */
143        public int sockets;
144
145        /** The dies per socket. */
146        public int diesPerSocket;
147
148        /** The cores per die. */
149        public int coresPerDie;
150
151        /** The threads per core. */
152        public int threadsPerCore;
153
154        /** The accelerator. */
155        public String accelerator = "kvm";
156
157        /** The rtc base. */
158        public String rtcBase = "utc";
159
160        /** The rtc clock. */
161        public String rtcClock = "rt";
162
163        /** The powerdown timeout. */
164        public int powerdownTimeout = 900;
165
166        /** The network. */
167        public Network[] network = { new Network() };
168
169        /** The drives. */
170        public Drive[] drives = new Drive[0];
171
172        /** The display. */
173        public Display display;
174
175        /**
176         * Convert value from JSON parser.
177         *
178         * @param value the new maximum ram
179         */
180        public void setMaximumRam(String value) {
181            maximumRam = Convertions.parseMemory(value);
182        }
183
184        /**
185         * Convert value from JSON parser.
186         *
187         * @param value the new current ram
188         */
189        public void setCurrentRam(String value) {
190            currentRam = Convertions.parseMemory(value);
191        }
192    }
193
194    /**
195     * Subsection "network".
196     */
197    @SuppressWarnings("PMD.DataClass")
198    public static class Network implements Dto {
199
200        /** The type. */
201        public String type = "tap";
202
203        /** The bridge. */
204        public String bridge;
205
206        /** The device. */
207        public String device = "virtio-net";
208
209        /** The mac. */
210        public String mac;
211
212        /** The net. */
213        public String net;
214    }
215
216    /**
217     * Subsection "drive".
218     */
219    @SuppressWarnings("PMD.DataClass")
220    public static class Drive implements Dto {
221
222        /** The type. */
223        public String type;
224
225        /** The bootindex. */
226        public Integer bootindex;
227
228        /** The device. */
229        public String device;
230
231        /** The file. */
232        public String file;
233
234        /** The resource. */
235        public String resource;
236    }
237
238    /**
239     * The Class Display.
240     */
241    public static class Display implements Dto {
242
243        /** The number of outputs. */
244        public int outputs = 1;
245
246        /** The logged in user. */
247        public String loggedInUser;
248
249        /** The spice. */
250        public Spice spice;
251    }
252
253    /**
254     * Subsection "spice".
255     */
256    @SuppressWarnings("PMD.DataClass")
257    public static class Spice implements Dto {
258
259        /** The port. */
260        public int port = 5900;
261
262        /** The ticket. */
263        public String ticket;
264
265        /** The streaming video. */
266        public String streamingVideo;
267
268        /** The usb redirects. */
269        public int usbRedirects = 2;
270    }
271
272    /**
273     * Check configuration.
274     *
275     * @return true, if successful
276     */
277    public boolean check() {
278        if (vm == null || vm.name == null) {
279            logger.severe(() -> "Configuration is missing mandatory entries.");
280            return false;
281        }
282        if (!checkRuntimeDir() || !checkDataDir() || !checkUuid()) {
283            return false;
284        }
285
286        // Adjust max cpus if necessary
287        if (vm.currentCpus > vm.maximumCpus) {
288            vm.maximumCpus = vm.currentCpus;
289        }
290
291        checkDrives();
292        checkCloudInit();
293
294        return true;
295    }
296
297    private void checkDrives() {
298        for (Drive drive : vm.drives) {
299            if (drive.file != null || drive.device != null
300                || "ide-cd".equals(drive.type)) {
301                continue;
302            }
303            if (drive.resource == null) {
304                logger.severe(
305                    () -> "Drive configuration is missing its resource.");
306
307            }
308            if (Files.isRegularFile(Path.of(drive.resource))) {
309                drive.file = drive.resource;
310            } else {
311                drive.device = drive.resource;
312            }
313        }
314    }
315
316    private boolean checkRuntimeDir() {
317        // Runtime directory (sockets etc.)
318        if (runtimeDir == null) {
319            var appDir = FsdUtils.runtimeDir(APP_NAME.replace("-", ""));
320            if (!Files.exists(appDir) && appDir.toFile().mkdirs()) {
321                try {
322                    // When appDir is derived from XDG_RUNTIME_DIR
323                    // the latter should already have these permissions,
324                    // but let's be on the safe side.
325                    Files.setPosixFilePermissions(appDir,
326                        Set.of(PosixFilePermission.OWNER_READ,
327                            PosixFilePermission.OWNER_WRITE,
328                            PosixFilePermission.OWNER_EXECUTE));
329                } catch (IOException e) {
330                    logger.warning(() -> String.format(
331                        "Cannot set permissions rwx------ on \"%s\".",
332                        runtimeDir));
333                }
334            }
335            runtimeDir = FsdUtils.runtimeDir(APP_NAME.replace("-", ""))
336                .resolve(vm.name);
337            runtimeDir.toFile().mkdir();
338            swtpmSocket = runtimeDir.resolve("swtpm-sock");
339            monitorSocket = runtimeDir.resolve("monitor.sock");
340        }
341        if (!Files.isDirectory(runtimeDir) || !Files.isWritable(runtimeDir)) {
342            logger.severe(() -> String.format(
343                "Configured runtime directory \"%s\""
344                    + " does not exist or isn't writable.",
345                runtimeDir));
346            return false;
347        }
348        return true;
349    }
350
351    private boolean checkDataDir() {
352        // Data directory
353        if (dataDir == null) {
354            dataDir
355                = FsdUtils.dataHome(APP_NAME.replace("-", "")).resolve(vm.name);
356        }
357        if (!Files.exists(dataDir)) {
358            dataDir.toFile().mkdirs();
359        }
360        if (!Files.isDirectory(dataDir) || !Files.isWritable(dataDir)) {
361            logger.severe(() -> String.format(
362                "Configured data directory \"%s\""
363                    + " does not exist or isn't writable.",
364                dataDir));
365            return false;
366        }
367        return true;
368    }
369
370    private boolean checkUuid() {
371        // Explicitly configured uuid takes precedence.
372        if (vm.uuid != null) {
373            return true;
374        }
375
376        // Try to read stored uuid.
377        Path uuidPath = dataDir.resolve("uuid.txt");
378        if (Files.isReadable(uuidPath)) {
379            try {
380                var stored
381                    = Files.lines(uuidPath, StandardCharsets.UTF_8).findFirst();
382                if (stored.isPresent()) {
383                    vm.uuid = stored.get();
384                    return true;
385                }
386            } catch (IOException e) {
387                logger.log(Level.WARNING, e,
388                    () -> "Stored uuid cannot be read: " + e.getMessage());
389            }
390        }
391
392        // Generate new uuid
393        vm.uuid = UUID.randomUUID().toString();
394        try {
395            Files.writeString(uuidPath, vm.uuid + "\n");
396        } catch (IOException e) {
397            logger.log(Level.WARNING, e,
398                () -> "Cannot store uuid: " + e.getMessage());
399        }
400
401        return true;
402    }
403
404    private void checkCloudInit() {
405        if (cloudInit == null) {
406            return;
407        }
408
409        // Provide default for instance-id
410        if (cloudInit.metaData == null) {
411            cloudInit.metaData = new HashMap<>();
412        }
413        if (!cloudInit.metaData.containsKey(CI_INSTANCE_ID)) {
414            cloudInit.metaData.put(CI_INSTANCE_ID, "v" + asOf.getEpochSecond());
415        }
416    }
417}