diff --git a/.gitignore b/.gitignore index 4d66fc5..2ed6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ result results /servers/exp* .envrc +.venv +.nixos-test-history diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..994440a --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1 @@ +disable=SC2016 diff --git a/containers/cs306/image.nix b/containers/cs306/image.nix deleted file mode 100644 index 2aa7047..0000000 --- a/containers/cs306/image.nix +++ /dev/null @@ -1,60 +0,0 @@ -{ - pkgs, - cmdArgs ? [], -}: - -rec { - # Use a Nix-locked Ubuntu image for caching, not for reproducibility. - # Forget about reproducibility, it's not possible with Docker. - # See https://nixos.org/manual/nixpkgs/stable/#ssec-pkgs-dockerTools-fetchFromRegistry. - ubuntuImage = pkgs.dockerTools.pullImage { - imageName = "ubuntu"; - imageDigest = "sha256:bcc511d82482900604524a8e8d64bf4c53b2461868dac55f4d04d660e61983cb"; - finalImageName = "ubuntu"; - finalImageTag = "latest"; - sha256 = "sha256-qjUOR8QKzJVwMkUyn9ZuHLnYPA0PKupKnThuijo4LdM="; - os = "linux"; - arch = "x86_64"; - }; - - # See https://nixos.org/manual/nixpkgs/stable/#ssec-pkgs-dockerTools-buildLayeredImage. - imageFile = pkgs.dockerTools.buildLayeredImage { - name = "experimental-discord-bot-image"; - tag = "latest"; - fromImage = ubuntuImage; - enableFakechroot = true; - - contents = pkgs.writeShellScriptBin "experimental-discord-bot" '' - echo "Checking for Internet connectivity..." - wget --spider http://google.com - - echo "Initializing the package manager..." - apt update - apt install -y --no-install-recommends \ - ca-certificates \ - curl \ - bash \ - git - - echo "Installing Go..." - apt install -y golang - - echo "Downloading Discord bot..." - git clone https://libdb.so/arikawa - cd arikawa/0-examples/commands-hybrid - - echo "Ensuring that the contents are proper..." - ls - - echo "Building the bot..." - go build -v - - echo "Running the bot..." - exec ./commands-hybrid - ''; - - config = { - Entrypoint = [ "/bin/experimental-discord-bot" ] ++ cmdArgs; - }; - }; -} diff --git a/containers/cs306/test.nix b/containers/cs306/test.nix deleted file mode 100644 index 5cb67f4..0000000 --- a/containers/cs306/test.nix +++ /dev/null @@ -1,36 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - podmanServiceName = containerName: "podman-" + containerName; -in - -{ - virtualisation.oci-containers = - let - inherit (import ./image.nix { inherit pkgs; }) imageFile; - in - { - backend = "podman"; - containers = { - "experimental-discord-bot" = { - autoStart = true; - environment = { - TEST_ENV1 = "test1"; - TEST_ENV2 = "test2"; - }; - ports = [ - "127.0.0.1:48765:80" - ]; - image = "experimental-discord-bot-image:latest"; - - inherit imageFile; - }; - }; - }; - - systemd.services.${podmanServiceName "experimental-discord-bot"} = with lib; { - serviceConfig.RestartSec = mkForce "5s"; - startLimitBurst = mkForce 3; - startLimitIntervalSec = mkForce (5 * 60); # 5 minutes - }; -} diff --git a/nix/sources.json b/nix/sources.json index 1671022..564269a 100644 --- a/nix/sources.json +++ b/nix/sources.json @@ -1,4 +1,17 @@ { + "NixVirt": { + "branch": "master", + "description": "LibVirt domain management for Nix", + "homepage": null, + "owner": "AshleyYakeley", + "repo": "NixVirt", + "rev": "f6bc308804cc3c35457ed012c031b121af1578a8", + "sha256": "19j9rcsbvg9qmrxaqmswd6mxy9qr983hs1f7p827kqm07bpmdwh2", + "type": "tarball", + "url": "https://github.com/AshleyYakeley/NixVirt/archive/f6bc308804cc3c35457ed012c031b121af1578a8.tar.gz", + "url_template": "https://github.com///archive/.tar.gz", + "version": "master" + }, "acm-nixie": { "branch": "main", "description": "acmCSUF's version of the nixie bot.", @@ -109,6 +122,18 @@ "url": "https://github.com/EthanThatOneKid/discord_conversation_summary_bot/archive/3771e8cabc7f7992b81f21dea53f7bd338a9aa07.tar.gz", "url_template": "https://github.com///archive/.tar.gz" }, + "flake-compat": { + "branch": "master", + "description": null, + "homepage": null, + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "sha256": "0m9grvfsbwmvgwaxvdzv6cmyvjnlww004gfxjvcl806ndqaxzy4j", + "type": "tarball", + "url": "https://github.com/edolstra/flake-compat/archive/0f9255e01c2351cc7d116c072cb317785dd33b33.tar.gz", + "url_template": "https://github.com///archive/.tar.gz" + }, "fullyhacks-qrms": { "branch": "main", "description": null, diff --git a/scripts/lib/init b/scripts/lib/init index b9b0e28..6ab2598 100644 --- a/scripts/lib/init +++ b/scripts/lib/init @@ -105,3 +105,49 @@ lib::scripts_path() { printf -v tailPath "/%s" "$@" echo -n "$(lib::root_path)/scripts$tailPath" } + +# log prints a message with a timestamp prefix. +lib::log() { + printf -v prefix "[%(%H:%M:%S)T] " + echo "${prefix}${*}" >&2 +} + +# logf prints a formatted message with a timestamp prefix. +# shellcheck disable=SC2059 +# +# Usage: lib::logf +lib::logf() { + printf -v prefix "[%(%H:%M:%S)T] " + printf "${prefix}${1}\n" "${@:2}" >&2 +} + +# fatal prints a message and exits with status 1. +lib::fatal() { + lib::log "$@" + exit 1 +} + +# mktmpname takes in a file path and returns a temporary file path pointing to +# the same directory. Note that the temporary file is created by this function. +# +# Usage: lib::mktmpname +lib::mktmpname() { + local file="$1" + local dir + dir="$(dirname "$file")" + mktemp "$dir/.$(basename "$file").XXXXXXXX" +} + +# write_file writes the given content to the given file path. +# It differs from regularly piping to a file in that the function will try to +# create a temporary file for writing before moving it to the target file. +# This is to prevent partial writes in case of an error. +# +# Usage: command | lib::write_file +lib::write_file() { + local file="$1" + local tmpFile + tmpFile="$(lib::mktmpname "$file")" + cat > "$tmpFile" + mv "$tmpFile" "$file" +} diff --git a/servers/cs306/services.nix b/servers/cs306/services.nix index a2e5563..4183060 100644 --- a/servers/cs306/services.nix +++ b/servers/cs306/services.nix @@ -131,23 +131,4 @@ in enable = true; config = builtins.readFile ; }; - - services.sshwifty = { - enable = true; - config = { - Servers = [ - { - ListenInterface = "127.0.0.1"; - ListenPort = 38274; - } - ]; - Presets = [ - # { - # Title = "GitHub"; - # Type = "SSH"; - # } - ]; - OnlyAllowPresetRemotes = true; - }; - }; } diff --git a/servers/cs306/user-vms.nix b/servers/cs306/user-vms.nix new file mode 100644 index 0000000..d1ef16b --- /dev/null +++ b/servers/cs306/user-vms.nix @@ -0,0 +1,55 @@ +{ config, lib, pkgs, ... }: + +let + inherit (import { inherit pkgs; }) + ips; +in + +{ + imports = [ + + ]; + + acm.user-vms = { + enable = true; + users = builtins.fromJSON (builtins.readFile ); + # Pin all CPU usages to the 4 host cores. + cpuPinning = [4 5 6 7]; + poolDirectory = "/var/lib/acm-vm"; + }; + + services.diamondburned.caddy.sites."https://vps.acmcsuf.com" = '' + root * ${pkgs.writeTextDir "vps.json" (builtins.toJSON config.acm.user-vms.usersData)} + rewrite * /vps.json + file_server + ''; + + services.sshwifty = { + enable = true; + config = { + Servers = [ + { + ListenInterface = "127.0.0.1"; + ListenPort = 38274; + } + ]; + Presets = map + (offset: + let + ip = ips.ipFromOffset offset; + in + { + Title = "SSH to ${ip}"; + Type = "SSH"; + Host = "${ip}:22"; + } + ) + (ips.range); + OnlyAllowPresetRemotes = true; + }; + }; + + services.diamondburned.caddy.sites."http://ssh.acmcsuf.com" = '' + reverse_proxy * localhost:38274 + ''; +} diff --git a/shell.nix b/shell.nix index 6b91fa3..5e775e5 100644 --- a/shell.nix +++ b/shell.nix @@ -7,19 +7,23 @@ in pkgs.mkShell { name = "acm-aws-shell"; buildInputs = with pkgs; [ + cloud-init terraform awscli2 - # rnix-lsp nix-update jq niv git git-crypt openssl - yamllint gomod2nix expect + + # editor tools. + yamllint shellcheck + nodePackages.bash-language-server + # rnix-lsp ]; shellHook = '' diff --git a/user-machines/secrets/user-vms.json b/user-machines/secrets/user-vms.json new file mode 100644 index 0000000..fedb13a Binary files /dev/null and b/user-machines/secrets/user-vms.json differ diff --git a/user-machines/users-validate b/user-machines/users-validate new file mode 100755 index 0000000..410635b --- /dev/null +++ b/user-machines/users-validate @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +. "$(dirname "$0")/../scripts/lib/init" + +USERS_FILE="$(dirname "$0")/secrets/user-vms.json" +USERS_ID_REGEX='^[a-zA-Z0-9\-_.]{1,32}$' + +main() { + lib::require_installed uuidgen jq + + # Format the JSON file. + users_jq '.' | lib::write_file "$USERS_FILE" + + # Guarantee that there are no null IDs. + readarray -t nullUsers < <(users_jq -c '.[] | select(.id == null)') + if (( ${#nullUsers[@]} > 0 )); then + lib::log "Null IDs found in $USERS_FILE for the following users:" + for user in "${nullUsers[@]}"; do + lib::log " $user" + done + return 1 + fi + + # Guarantee that all user IDs are unique. + readarray -t duplicateIDs < <(users_jq -r '.[].id' | sort | uniq -d) + if (( ${#duplicateIDs[@]} > 0 )); then + lib::log "Duplicate user IDs found in $USERS_FILE for the following users:" + for user in "${duplicateIDs[@]}"; do + lib::log " - $user" + done + return 1 + fi + + # Validate all user IDs. We ask jq to return one JSON string per line in + # case the IDs have a new line. Ideally this should be never, but... + readarray -t userIDs < <(users_jq '.[].id') + for id in "${userIDs[@]}"; do + # Trim the quotes manually then compare that result with the parsed JSON + # string. None of our characters should require special escaping, so + # manually trimming should just work. + id="${id%\"}" + id="${id#\"}" + if [[ $id != $(jq -r -n --arg id "$id" '$id') ]]; then + lib::logf "Invalid user ID %q found in %s." "$id" "$USERS_FILE" + return 1 + fi + + # Actually pass the ID through regex. + if [[ ! $id =~ $USERS_ID_REGEX ]]; then + lib::logf "Invalid user ID %q found in %s." "$id" "$USERS_FILE" + return 1 + fi + done + + # Guarantee that all users have a unique UUID. + readarray -t idsWithNoUUID < <(users_jq -r '.[] | select(.uuid == null) | .id') + for id in "${idsWithNoUUID[@]}"; do + uuid=$(uuidgen) + dupe=$(users_jq --arg uuid "$uuid" -r '.[] | select(.uuid == $uuid) | .id') + if [[ $dupe != "" ]]; then + lib::fatal "uuidgen returned colliding UUID $uuid, please retry." + fi + + lib::logf "Assigning user with ID %q UUID %s." "$id" "$uuid" + users_jq \ + --arg id "$id" \ + --arg uuid "$uuid" \ + '(.[] | select(.id == $id)).uuid = $uuid' | lib::write_file "$USERS_FILE" + done +} + +users_jq() { + jq "$@" "$USERS_FILE" +} + +if lib::is_main; then + main "$@" +fi diff --git a/user-machines/vm/config.nix b/user-machines/vm/config.nix new file mode 100644 index 0000000..f39e004 --- /dev/null +++ b/user-machines/vm/config.nix @@ -0,0 +1,63 @@ +{ pkgs }: + +with pkgs.lib; +with builtins; + +let + div = a: b: floor a / b; + mod = a: b: a - (b * (div a b)); + + hostOrderToIP = hostOrder: concatStringsSep "." [ + # (toString (hostOrder / 256 / 256 / 256)) + # (toString (hostOrder / 256 / 256 % 256)) + # (toString (hostOrder / 256 % 256)) + # (toString (hostOrder % 256)) + (toString (div (div (div hostOrder 256) 256) 256)) + (toString (mod (div (div hostOrder 256) 256) 256)) + (toString (mod (div hostOrder 256) 256)) + (toString (mod hostOrder 256)) + ]; + + mkCloudInitImage = pkgs.callPackage ./ubuntu-cloud-init.nix { }; +in + +{ + lib = { + inherit + hostOrderToIP + mkCloudInitImage; + }; + + ips = rec { + start = { + ip = "192.168.168.2"; + order = 3232278530; + }; + end = { + ip = "192.168.175.254"; + order = 3232280574; + }; + # count is the number of IPs between the start and end IPs, inclusive. + count = end.order - start.order + 1; + # range is a list of all the order numbers between the start and end IPs. + range = pkgs.lib.range start.order end.order; + # ipFromOffset returns the IP address that is offset away from the start IP. + ipFromOffset = offset: hostOrderToIP (start.order + offset); + }; + + ubuntu = rec { + # NOTE: DO NOT REMOVE ITEMS IN THE IMAGE LIST. YOU MUST ONLY APPEND TO IT. + # libvirt requires all images to be present for its backing store, so it is not safe to delete + # existing images when updating the list. + images = [ + (pkgs.fetchurl { + url = "https://isos.acmcsuf.com/ubuntu/2024-05-02/noble-server-cloudimg-amd64.img"; + sha256 = "32a9d30d18803da72f5936cf2b7b9efcb4d0bb63c67933f17e3bdfd1751de3f3"; + passthru.format = "qcow2"; + }) + ]; + + # Use the last image in the list as the default image. + image = last images; + }; +} diff --git a/user-machines/vm/default.nix b/user-machines/vm/default.nix new file mode 100644 index 0000000..cae3cd2 --- /dev/null +++ b/user-machines/vm/default.nix @@ -0,0 +1,320 @@ +{ config, lib, pkgs, ... }: + +with lib; +with builtins; + +# NOTE: DO NOT CHANGE THE UUIDS IN THIS FILE! +# YOU WILL BREAK EVERYTHING! + +let + sources = import { inherit pkgs; }; + + nixvirt = (import sources.flake-compat { + src = sources.NixVirt; + }).defaultNix; + virtlib = nixvirt.lib; + + self = config.acm.user-vms; + userIsDeleted = user: user ? "deleted" && user.deleted; + + inherit (import ./config.nix { inherit pkgs; }) + lib + ips + ubuntu; + + # utility value that signifies that a case is _impossible_. + _impossible_ = throw "This should be _impossible_"; +in + +{ + imports = [ + nixvirt.nixosModules.default + ]; + + options.acm = { + user-vms = { + enable = mkEnableOption "Enable the service for managing VMs for ACM members"; + + poolDirectory = mkOption { + type = types.str; + default = "/var/lib/acm-vm"; + description = "The directory to store VM disk images in."; + }; + + virtConnection = mkOption { + type = types.str; + default = "qemu:///system"; + description = "The libvirt connection URI to use."; + }; + + cpuPinning = mkOption { + type = types.nullOr (types.listOf types.int); + default = null; + # default = [4 5 6 7]; + description = "The CPU cores to pin VMs to."; + }; + + users = mkOption { + type = types.listOf (types.submodule { + options = { + id = mkOption { + type = types.str; + description = "The username of the user."; + }; + name = mkOption { + type = types.str; + description = "The full name of the user."; + }; + email = mkOption { + type = types.listOf types.email; + default = []; + description = "The email addresses of the user."; + }; + discord = mkOption { + type = types.nullOr types.str; + default = null; + description = "The Discord username of the user."; + }; + default_password = mkOption { + type = types.str; + description = "The default password for the user."; + }; + uuid = mkOption { + type = types.str; + description = "The UUID of the user."; + }; + }; + }); + description = '' + List of users to create VMs for. + ''; + }; + + usersInfo = mkOption { + readOnly = true; + type = types.listOf (types.submodule { + options = { + id = mkOption { + type = types.str; + description = "The username of the user."; + }; + ip = mkOption { + type = types.str; + description = "The IP address of the user's VM."; + }; + }; + }); + description = '' + Public information about the users. + ''; + }; + }; + }; + + # TODO: tainted: high-privileges + + config = mkIf self.enable ({ + systemd.tmpfiles.rules = + let + # Create GC roots to all known backing store images. This prevents them from being garbage + # collected by the Nix garbage collector. + imageRoot = pkgs.linkFarm "acm-vm-image-root" (map (image: { + name = image.outputHash; + path = image; + }) ubuntu.images); + in + [ + "d ${self.poolDirectory} 0700 root root -" + "L+ ${self.poolDirectory}/.image-root - - - - ${imageRoot}" + ]; + + acm.user-vms.usersInfo = imap0 (i: user: { + id = user.id; + ip = ips.ipFromOffset i; + }) self.users; + + virtualisation.libvirt.enable = true; + + virtualisation.libvirt.connections.${self.virtConnection} = { + pools = [ + { + active = true; + definition = virtlib.pool.writeXML { + uuid = "d988b1ac-1732-4185-809b-d3b30bc1eef3"; + name = "acm-vm-pool"; + type = "dir"; + target.path = self.poolDirectory; + }; + volumes = (map (user: { + present = !userIsDeleted user; + definition = virtlib.volume.writeXML { + name = "${user.uuid}.qcow2"; + capacity = { count = 4; unit = "GiB"; }; + allocation = { count = 256; unit = "MiB"; }; + target = { + format.type = "qcow2"; + permissions.mode = "0700"; + }; + }; + }) self.users); + } + ]; + + networks = [ + { + active = true; + definition = virtlib.network.writeXML { + uuid = "b3ce2af6-af93-4b4f-b0d6-576b975e84b6"; + name = "acm-lan"; + forward = { + mode = "nat"; + nat = { + ipv6 = false; + # address = { + # start = ips.start.ip; + # end = ips.end.ip; + # }; + # port = {}; + }; + }; + bridge = { name = "virbr0"; }; + ipv6 = false; + ip = { + # subnet: 192.168.170.0/21 + # this holds about 2045 addresses (https://www.colocationamerica.com/ip-calculator) + address = "192.168.168.1"; + netmask = "255.255.248.0"; + # dhcp.range = { + # start = ips.start.ip; + # end = ips.end.ip; + # }; + }; + }; + } + ]; + + domains = imap0 (i: user: { + active = true; + definition = virtlib.domain.writeXML (let + base = virtlib.domain.templates.linux { + name = "acm-vm-${user.id}"; + uuid = user.uuid; + memory = { count = 512; unit = "MiB"; }; + storage_vol = { + # Replaced by final.devices.disk[0]. + }; + virtio_drive = true; + virtio_video = false; + }; + + final = base // { + os = { + type = "hvm"; + arch = "x86_64"; + machine = "q35"; + smbios = { + mode = "sysinfo"; + }; + # Set each devices.disk[]'s boot order instead. + # boot = []; + }; + sysinfo = { + type = "smbios"; + system.serial = "ds=nocloud"; + }; + vcpu = { + placement = "static"; + count = 1; + }; + cputune = { + vcpupin = + if self.cpuPinning == null + then [ ] + else [ + # Limit the VM to the last 4 cores.This prevents the VM from + # overloading the host. + { + vcpu = 0; + cpuset = concatStringsSep "," (map (toString) self.cpuPinning); + } + ]; + }; + devices = base.devices // { + disk = with lib; [ + { + type = "volume"; + device = "disk"; + driver = { + name = "qemu"; + type = "qcow2"; + cache = "none"; + discard = "unmap"; + }; + source = { + pool = "acm-vm-pool"; + volume = "${user.uuid}.qcow2"; + }; + target = { + dev = "vda"; + bus = "virtio"; + }; + backingStore = { + type = "file"; + format.type = ubuntu.image.format; + source.file = "${ubuntu.image}"; + }; + } + { + type = "file"; + device = "disk"; + driver = { + name = "qemu"; + type = "raw"; + }; + source.file = "${lib.mkCloudInitImage { + inherit user; + network-config = { + version = 2; + ethernets.enp1s0 = { + addresses = [ "${ips.ipFromOffset i}/21" ]; + gateway4 = "192.168.168.1"; + dhcp4 = false; + dhcp6 = false; + nameservers.addresses = [ "1.1.1.1" "8.8.8.8" ]; + }; + }; + }}"; + target = { + dev = "vdb"; + bus = "virtio"; + }; + readonly = true; + } + ]; + interface = { + type = "network"; + model.type = "virtio"; + source.network = "acm-lan"; + # source.bridge = "virbr0"; + }; + serial = { + type = "pty"; + target = { + port = 0; + }; + }; + console = { + type = "pty"; + target = { + type = "serial"; + port = 0; + }; + }; + }; + }; + in + final); + }) self.users; + }; + }); +} diff --git a/user-machines/vm/test-vm.nix b/user-machines/vm/test-vm.nix new file mode 100644 index 0000000..ca417ae --- /dev/null +++ b/user-machines/vm/test-vm.nix @@ -0,0 +1,70 @@ +# Usages: +# nix-build ./user-machines/vm/test-vm.nix +# $(nix-build -A driverInteractive ./user-machines/vm/test-vm.nix)/bin/nixos-test-driver + +let + pkgs = import ; +in + +pkgs.testers.runNixOSTest { + name = "user-machines-test"; + + nodes.machine = { config, pkgs, ... }: { + imports = [ + ./. + ]; + + networking.firewall.enable = false; + + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + + # services.xserver = { + # enable = true; + # displayManager = { + # lightdm.enable = true; + # autoLogin = { + # enable = true; + # user = "alice"; + # }; + # }; + # desktopManager = { + # lxqt.enable = true; + # }; + # }; + + # users.users.alice = { + # isNormalUser = true; + # extraGroups = [ "wheel" ]; + # initialPassword = "password"; + # }; + # security.sudo.wheelNeedsPassword = false; + + # programs.virt-manager.enable = true; + + acm.user-vms = { + enable = true; + users = [ + { + id = "alice"; + name = "Alice"; + email = ["alice@example.com"]; + discord = "@alice"; + default_password = "password"; + uuid = "f2d0c3a3-5c4b-4b0d-8e4a-9f3c4d1f8d6e"; + } + ]; + }; + + environment.systemPackages = with pkgs; [ + # zellij + tmux + ]; + + system.stateVersion = builtins.trace (builtins.toJSON config.acm.user-vms.usersInfo) "22.05"; + }; + + testScript = { nodes, ... }: '' + # TODO: Add tests here + ''; +} diff --git a/user-machines/vm/ubuntu-cloud-init.nix b/user-machines/vm/ubuntu-cloud-init.nix new file mode 100644 index 0000000..6e6c823 --- /dev/null +++ b/user-machines/vm/ubuntu-cloud-init.nix @@ -0,0 +1,106 @@ +{ pkgs }: + +{ + user, + user-data ? {}, + meta-data ? {}, + network-config ? {}, +}: + +let + extras = { + inherit + user-data + meta-data + network-config; + }; +in + +with pkgs.lib; +with builtins; + +# https://cloudinit.readthedocs.io/en/latest/reference/examples.html#including-users-and-groups +let + lib = pkgs.lib; + + # Add the repo's admin public key. + # publicKeys = [ + # (builtins.readFile ./secrets/ssh/id_ed25519.pub) + # ]; + + hostname = "acm-vm-${user.id}"; + + user-data = writeYAML "user-data.yml" ("#cloud-config\n" + (toJSON ( + let + base = { + users = [ + ( + { + name = user.id; + sudo = "ALL=(ALL) NOPASSWD:ALL"; + shell = "/bin/bash"; + lock_passwd = false; + plain_text_passwd = user.default_password; + } // + (lib.optionalAttrs (user ? "ssh_public_key" && user.ssh_public_key != null) { + ssh_authorized_keys = user.ssh_keys; + }) + ) + ]; + ssh_pwauth = true; + ssh_deletekeys = true; + package_update = true; + packages = [ + "htop" + "curl" + "git" + ]; + runcmd = [ + # Permanently disable cloud-init after first boot. + # This permits the user to change anything they want afterwards. + "touch /etc/cloud/cloud-init.disabled" + ]; + }; + in + base // (extras.user-data) + ))); + + meta-data = writeYAML "meta-data.yml" (toJSON ({ + instance-id = hostname; + local-hostname = hostname; + } // extras.meta-data)); + + network-config = writeYAML "network-config.yml" (toJSON ({ + } // extras.network-config)); + + writeYAML = name: json: pkgs.runCommandLocal name { + nativeBuildInputs = with pkgs; [ + yq-go + ]; + JSON_FILE = pkgs.writeText "${name}.yml" json; + } '' + yq -P -oy "$JSON_FILE" > $out + ''; + + # Refer to the following link for more information: + # https://canonical-subiquity.readthedocs-hosted.com/en/latest/howto/autoinstall-quickstart.html#create-an-iso-to-use-as-a-cloud-init-data-source + image = pkgs.runCommand "${user.id}-cloud-init.iso" { + nativeBuildInputs = with pkgs; [ + cloud-init + cloud-utils + ]; + passthru = { + inherit + user-data + meta-data + network-config; + }; + } '' + # TODO:Fix validation + # cloud-init schema -c "$USERDATA" --annotate + + cloud-localds -N ${network-config} $out ${user-data} ${meta-data} + ''; +in + +image