diff --git a/modules/misc/news.nix b/modules/misc/news.nix index d8b9f4d9f..f40a48f13 100644 --- a/modules/misc/news.nix +++ b/modules/misc/news.nix @@ -1348,6 +1348,16 @@ in A new module is available: 'programs.gradle'. ''; } + + { + time = "2023-12-23T08:45:52+00:00"; + message = '' + Three new modules are available: + 'virtualisation.containers', + 'virtualisation.oci-containers', + 'virtualisation.podman'. + ''; + } ]; }; } diff --git a/modules/modules.nix b/modules/modules.nix index 8f1b98a02..0d7fade19 100644 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -368,6 +368,9 @@ let ./systemd.nix ./targets/darwin ./targets/generic-linux.nix + ./virtualisation/containers.nix + ./virtualisation/oci-containers.nix + ./virtualisation/podman/podman.nix ./xresources.nix ./xsession.nix ./misc/nix.nix diff --git a/modules/virtualisation/containers.nix b/modules/virtualisation/containers.nix new file mode 100644 index 000000000..ac74767de --- /dev/null +++ b/modules/virtualisation/containers.nix @@ -0,0 +1,76 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.virtualisation.containers; + + inherit (lib) mkOption types; + + toml = pkgs.formats.toml { }; +in { + meta.maintainers = [ lib.maintainers.michaelCTS ]; + + options.virtualisation.containers = { + enable = lib.mkEnableOption "the common containers configuration module"; + + ociSeccompBpfHook.enable = lib.mkEnableOption "the OCI seccomp BPF hook"; + + registries = { + search = mkOption { + type = types.listOf types.str; + default = [ "docker.io" "quay.io" ]; + description = '' + List of repositories to search. + ''; + }; + + insecure = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + List of insecure repositories. + ''; + }; + + block = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + List of blocked repositories. + ''; + }; + }; + + policy = mkOption { + type = types.attrs; + default = { }; + example = lib.literalExpression '' + { + default = [ { type = "insecureAcceptAnything"; } ]; + transports = { + docker-daemon = { + "" = [ { type = "insecureAcceptAnything"; } ]; + }; + }; + } + ''; + description = '' + Signature verification policy file. + If this option is empty the default policy file from + `skopeo` will be used. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + xdg.configFile."containers/registries.conf".source = + toml.generate "registries.conf" { + registries = lib.mapAttrs (n: v: { registries = v; }) cfg.registries; + }; + + xdg.configFile."containers/policy.json".source = if cfg.policy != { } then + pkgs.writeText "policy.json" (builtins.toJSON cfg.policy) + else + "${pkgs.skopeo.src}/default-policy.json"; + }; + +} diff --git a/modules/virtualisation/oci-containers.nix b/modules/virtualisation/oci-containers.nix new file mode 100644 index 000000000..fda69e26c --- /dev/null +++ b/modules/virtualisation/oci-containers.nix @@ -0,0 +1,28 @@ +# Equivalent of +# https://github.com/NixOS/nixpkgs/blob/nixos-unstable/nixos/modules/virtualisation/oci-containers.nix +{ config, lib, pkgs, ... }: + +let + cfg = config.virtualisation.oci-containers; + + inherit (lib) mkDefault mkIf mkMerge mkOption types; + + defaultBackend = "podman"; +in { + meta.maintainers = [ pkgs.lib.maintainers.michaelCTS ]; + + options.virtualisation.oci-containers = { + enable = lib.mkEnableOption + "a convenience option to enable containers in platform-agnostic manner"; + + backend = mkOption { + type = types.enum [ "podman" ]; + default = defaultBackend; + description = "Which service to use as a backend for containers."; + }; + }; + + config = mkIf (cfg.enable && cfg.backend == "podman") { + virtualisation.podman.enable = true; + }; +} diff --git a/modules/virtualisation/podman/podmactl/README.md b/modules/virtualisation/podman/podmactl/README.md new file mode 100644 index 000000000..979c9504b --- /dev/null +++ b/modules/virtualisation/podman/podmactl/README.md @@ -0,0 +1,30 @@ +# podmactl + +`podmactl` is a script to manage the podman machines declared in Home +Manager. + +## How it works + +`main()` is a (hopefully) straight-forward method to read, but the gist of it is: + +1. The declared machines and their configuration are passed in. +2. Existing machines and their configuration are listed. +3. A diff is made from the declared machines and existing machines. +4. New machines are added. +5. Existing machines are updated. +6. Old machines are removed. +7. The machine declared as `active` is started (if necessary). + +## Developing + +Enter a devshell with `nix-shell`. + +Make your changes and then run + +``` +# Code autoformatting +black . + +# Unittests +python -m unittest +``` diff --git a/modules/virtualisation/podman/podmactl/default.nix b/modules/virtualisation/podman/podmactl/default.nix new file mode 100644 index 000000000..670645f71 --- /dev/null +++ b/modules/virtualisation/podman/podmactl/default.nix @@ -0,0 +1,28 @@ +{ pkgs ? (import { }), }: + +pkgs.stdenv.mkDerivation { + name = "podmactl"; + src = ./.; + + buildInputs = [ pkgs.python311 ]; + doCheck = true; + checkPhase = '' + runHook preCheck + ( + cd $src + black --check . + python -m unittest + ) + runHook postCheck + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin + cp podmactl.py $out/bin/podmactl + chmod +x $out/bin/podmactl + + runHook postInstall + ''; +} diff --git a/modules/virtualisation/podman/podmactl/podmactl.py b/modules/virtualisation/podman/podmactl/podmactl.py new file mode 100644 index 000000000..8c8af81f3 --- /dev/null +++ b/modules/virtualisation/podman/podmactl/podmactl.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3.11 +import argparse +import json +import logging +import re +import shlex +import subprocess +import sys +from argparse import ArgumentParser + +from dataclasses import asdict, dataclass, field, fields +from functools import reduce +from operator import concat +from typing import Dict, Generic, Iterable, List, Optional, TypeVar + +DEFAULT_MACHINE = "podman-machine-default" +T = TypeVar("T") +logger = logging.getLogger("podman-launchd") +logger_commander = logger.getChild("commander") + +CAMEL_REGEX = re.compile(r"([A-Z]+)") +UNDERSCORE_REGEX = re.compile(r"^_") + + +@dataclass(frozen=True) +class Machine: + # Resource config for CLI + cpus: int + disk_size: int + memory: int + + # Metadata about the machine + name: str + active: bool = field(compare=False, default=False) + qemu_binary: Optional[str] = None + """A path to a custom QEMU command to be used when starting the machine with a specific arch""" + + # Optional CLI parameters + image_path: Optional[str] = None + """A local path to a custom QEMU image""" + + @classmethod + def from_dict(cls, a_dict: dict) -> "Machine": + return Machine( + **{ + snake_key: value + for key, value in a_dict.items() + if (snake_key := camel2snake(key).lower()) in MACHINE_FIELDS + } + ) + + +MACHINE_FIELDS = [field.name for field in fields(Machine)] + + +@dataclass +class Diff(Generic[T]): + new: List[T] = field(default_factory=list) + modified: List[T] = field(default_factory=list) + same: List[T] = field(default_factory=list) + removed: List[T] = field(default_factory=list) + + +class PodmanMachineCommander: + MACHINE_CLI_ARGS = ("cpus", "disk_size", "memory") + + def __init__(self, command: str = None): + self.command = command or "podman" + + def _call(self, *args: str, **kwargs) -> str: + """Call podman machine""" + + args_ = [self.command, "machine"] + list(args) + logger_commander.debug("Executing %s", shlex.join(args_)) + + # no subprocess.run here as streaming is necessary + stdout_lines = [] + with subprocess.Popen( + args_, + # Capture both streams in stdout + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + **kwargs, + ) as process: + # Collect stdout+stderr and steam if requested + for line in process.stdout: + line_str = line.decode().rstrip() + stdout_lines.append(line_str) + logger_commander.debug(line_str) + + stdout = "\n".join(stdout_lines) + # Check if the command failed + if (return_code := process.returncode) != 0: + print(stdout, file=sys.stderr) + raise subprocess.CalledProcessError(return_code, args_, stdout) + + return stdout + + def _call_json(self, *args: str, **kwargs) -> dict: + """Call podman requesting JSON output and interpret it as such""" + return json.loads(self._call(*args, "--format", "json", **kwargs)) + + @classmethod + def make_cli_args( + cls, machine: Machine, selected_args: Iterable[str] = MACHINE_CLI_ARGS + ): + """ + Converts dict from list of key-value pair + to list of ["--key1", value1, "--key2", value2, ... ] + """ + machine_dict = asdict(machine) + return reduce( + concat, + [ + [("--" + key.replace("_", "-")), str(value)] + for key in selected_args + if (value := machine_dict.get(key)) + ], + [], + ) + + def get_active_machine_name(self) -> str: + """Name of the machine that is currently running""" + output_json = self._call_json("info") + return output_json.get("Host", {}).get("CurrentMachine") + + def list(self) -> Dict[str, Machine]: + """Get all machines known to podman""" + machine_jsons = self._call_json("list") + if not isinstance(machine_jsons, list): + raise ValueError("Unexpected output from command", machine_jsons) + + # `podman machine list` has different units for disk_size, memory, etc. + # `podman machine inspect` has the information we need + inspected_jsons = self.inspect( + *[listed_machine["Name"] for listed_machine in machine_jsons] + ) + return { + machine.name: machine + for inspected_json in inspected_jsons + if ( + machine := Machine.from_dict( + {"name": inspected_json["Name"], **inspected_json["Resources"]} + ) + ) + } + + def inspect(self, *machine_names: str): + """Get information about a machine from podman""" + # The podman machine interface is really confusing + # inspect only returns JSON and other commands require --format json + return json.loads(self._call("inspect", *machine_names)) + + def add(self, machine: Machine): + """ + Let podman create a machine's config and initialize it + Also downloads the image of the machine + """ + self._call( + "init", + *self.make_cli_args(machine, self.MACHINE_CLI_ARGS + ("image_path",)), + machine.name, + ) + + def update(self, machine: Machine): + """Update a machine's configuration and write it to disk""" + self._call("set", *self.make_cli_args(machine), machine.name) + + # Set the custom QEMU path in the machine's config + # This is necessary for running machines with a specific architecture + if machine.qemu_binary: + inspection = self.inspect(machine.name)[0] + config_path = inspection.get("ConfigPath", {}).get("Path", {}) + + with open(config_path) as config_file: + config = json.load(config_file) + + if not (cmd_line := config.get("CmdLine")): + logger.error( + "Cannot find CmdLine in config of %s at", machine.name, config_path + ) + cmd_line[0] = machine.qemu_binary + + with open(config_path, mode="w") as config_file: + json.dump(config, config_file) + + def remove(self, machine: Machine): + """Kills and removes the machine""" + self._call("rm", "--force", machine.name) + + def start(self, machine_name: str): + """Start up a machine""" + self._call("start", machine_name) + + def stop(self, machine_name: str): + """Stop a running machine""" + self._call("stop", machine_name) + + +def main( + requested_machines: Dict[str, Machine], + podman_command: str, +): + """ + :param requested_machines: Which machines should exist on the host + :param podman_command: The path to or the podman command itself + """ + podman_command = podman_command or "podman" + commander = PodmanMachineCommander(podman_command) + active_machines = [ + name for name, machine in requested_machines.items() if machine.active + ] + if len(active_machines) != 1: + raise ValueError("Exactly one machine in the configuration should be active") + requested_active = active_machines[0] + + old_machines = commander.list() + # Find machines to add, update, delete + diffs = diff_machines(requested_machines, old_machines) + + # Init new machines + for new_machine in diffs.new: + logger.info("Adding machine: %s. This may take some time...", new_machine.name) + commander.add(new_machine) + # Init the default machine if it's not + + # Delete old ones + for removed_machine in diffs.removed: + logger.info("Removing machine: %s", removed_machine.name) + commander.remove(removed_machine) + + # Update configuration of qemuBinary if necessary + for mod_machine in diffs.modified: + logger.info("Updating machine: %s", mod_machine.name) + commander.update(mod_machine) + + # Start the requested machine if it isn't already running + active_machine = commander.get_active_machine_name() + if active_machine != requested_active: + if active_machine: + logger.info("Stopping machine: %s", active_machine) + commander.stop(active_machine) + logger.info("Starting: %s", requested_active) + commander.start(requested_active) + + logger.info("%s is active and podman is ready to be used") + + +def camel2snake(camel: str) -> str: + """ + Converts camelCase to snake_case + """ + snake = CAMEL_REGEX.sub(r"_\1", camel).lower() + # if snake starts with _ remove it + return UNDERSCORE_REGEX.sub("", snake) + + +def diff_machines( + requested_machines: Dict[str, Machine], old_machines: Dict[str, Machine] +) -> Diff[Machine]: + diff: Diff[Machine] = Diff() + requested_names = requested_machines.keys() + old_names = old_machines.keys() + requested_items = requested_machines.items() + old_items = old_machines.items() + diff.new = [requested_machines[name] for name in (requested_names - old_names)] + diff.removed = [old_machines[name] for name in (old_names - requested_names)] + diff.same = list(dict(old_items & requested_items).values()) + + # Find modified machines = same key, different Machine + diff.modified = [ + requested_machines[key] + for key in (requested_names & old_names) + if requested_machines[key] != old_machines[key] + ] + + return diff + + +def MachineDict(json_path: str) -> dict: + try: + with open(json_path) as json_file: + loaded_json = json.load(json_file) + return { + name: Machine.from_dict({"name": name, **machine}) + for name, machine in loaded_json.items() + } + except json.JSONDecodeError as decode_error: + raise argparse.ArgumentTypeError() from decode_error + except Exception as exc: + raise argparse.ArgumentTypeError() from exc + + +if __name__ == "__main__": + parser = ArgumentParser( + "podman-launchd", description="CRUDs pod machines and starts one" + ) + + parser.add_argument( + "machines", + help="Path to JSON configuration of machines that should be on this host", + type=MachineDict, + ) + parser.add_argument( + "-p", "--podman", help="Name or path of the podman command to use" + ) + parser.add_argument( + "--verbose", help="Activate verbose logging", action="store_true" + ) + cmd_args = parser.parse_args() + logging.basicConfig(level=logging.DEBUG if cmd_args.verbose else logging.INFO) + + try: + main(cmd_args.machines, cmd_args.podman) + except: + logger.exception("Couldn't complete command") + exit(1) diff --git a/modules/virtualisation/podman/podmactl/shell.nix b/modules/virtualisation/podman/podmactl/shell.nix new file mode 100644 index 000000000..b2f99d925 --- /dev/null +++ b/modules/virtualisation/podman/podmactl/shell.nix @@ -0,0 +1,5 @@ +{ pkgs ? import { } }: + +pkgs.mkShell { + buildInputs = [ (pkgs.python311.withPackages (ps: with ps; [ black ])) ]; +} diff --git a/modules/virtualisation/podman/podmactl/test_podmactl.py b/modules/virtualisation/podman/podmactl/test_podmactl.py new file mode 100644 index 000000000..383bf55b7 --- /dev/null +++ b/modules/virtualisation/podman/podmactl/test_podmactl.py @@ -0,0 +1,232 @@ +import argparse +import json +import tempfile +import unittest + +from podmactl import ( + Machine, + MachineDict, + diff_machines, + PodmanMachineCommander, +) + + +class MachineTestCase(unittest.TestCase): + def test_from_list_dict(self): + """Ensure that dicts from `podman machine list` can create a machine object""" + self.assertEqual( + Machine( + cpus=2, disk_size=100, memory=2048, name="indie-machine", active=True + ), + Machine.from_dict( + dict( + CPUs=2, + DiskSize=100, + Memory=2048, + Name="indie-machine", + ) + ), + ) + + def test_from_extra_dict(self): + self.assertEqual( + Machine( + cpus=2, disk_size=100, memory=2048, name="indie-machine", active=True + ), + Machine.from_dict( + dict( + cpus=2, + disk_size=100, + memory=2048, + name="indie-machine", + active=True, + new=True, + dont_exist="something", + something_else="ladidah", + ) + ), + ) + + def test_from_bad_dict(self): + """Will pass the wrong number of args to the __init__""" + self.assertRaises( + TypeError, + Machine.from_dict, + dict( + cpus=2, + ), + ) + + def test_make_cli_args(self): + args = PodmanMachineCommander.make_cli_args( + Machine(cpus=2, disk_size=50, memory=4096, name="manjaro", active=False) + ) + self.assertEqual( + args, + [ + "--cpus", + "2", + "--disk-size", + "50", + "--memory", + "4096", + ], + ) + + def test_make_optional_cli_args(self): + machine = Machine( + cpus=2, + disk_size=50, + memory=4096, + name="manjaro", + active=False, + image_path="somewhere.qcow2.xz", + ) + self.assertEqual( + PodmanMachineCommander.make_cli_args(machine), + [ + "--cpus", + "2", + "--disk-size", + "50", + "--memory", + "4096", + ], + ) + self.assertEqual( + PodmanMachineCommander.make_cli_args( + machine, PodmanMachineCommander.MACHINE_CLI_ARGS + ("image_path",) + ), + [ + "--cpus", + "2", + "--disk-size", + "50", + "--memory", + "4096", + "--image-path", + "somewhere.qcow2.xz", + ], + ) + + +class MachineDictTestCase(unittest.TestCase): + def test_load(self): + machine_json = { + "cpus": 2, + "disk_size": 100, + "memory": 2048, + "active": False, + } + machines_json = {"default": machine_json} + with tempfile.NamedTemporaryFile(mode="w") as fp: + json.dump(machines_json, fp) + fp.seek(0) + machines = MachineDict(fp.name) + self.assertDictEqual( + machines, + { + "default": Machine( + cpus=2, + disk_size=100, + memory=2048, + name="default", + active=False, + ) + }, + ) + + def test_bad_machine_load(self): + with self.assertRaises(argparse.ArgumentTypeError): + with tempfile.NamedTemporaryFile(mode="w") as fp: + json.dump({"default": {}}, fp) + fp.seek(0) + MachineDict(fp.name) + + def test_bad_json_load(self): + with self.assertRaises(argparse.ArgumentTypeError): + with tempfile.NamedTemporaryFile(mode="w") as fp: + fp.write("this is definitely not a json") + fp.seek(0) + MachineDict(fp.name) + + +class DiffTestCase(unittest.TestCase): + def test_new_machines(self): + diff = diff_machines( + { + "new": Machine( + cpus=1, disk_size=100, memory=1024, name="new", active=True + ), + "old": Machine( + cpus=1, disk_size=100, memory=1024, name="old", active=False + ), + }, + { + "old": Machine( + cpus=1, disk_size=100, memory=1024, name="old", active=False + ), + }, + ) + self.assertListEqual( + diff.new, + [Machine(cpus=1, disk_size=100, memory=1024, name="new", active=False)], + ) + self.assertListEqual( + diff.same, + [Machine(cpus=1, disk_size=100, memory=1024, name="old", active=False)], + ) + self.assertListEqual(diff.removed, []) + self.assertListEqual(diff.modified, []) + + def test_update_machine(self): + diff = diff_machines( + { + "changed": Machine( + cpus=1, disk_size=100, memory=1024, name="changed", active=True + ), + }, + { + "changed": Machine( + cpus=2, disk_size=100, memory=2048, name="changed", active=False + ), + }, + ) + self.assertListEqual(diff.new, []) + self.assertListEqual(diff.same, []) + self.assertListEqual(diff.removed, []) + self.assertListEqual( + diff.modified, + [Machine(cpus=1, disk_size=100, memory=1024, name="changed", active=False)], + ) + + def test_remove_machine(self): + diff = diff_machines( + { + "same": Machine( + cpus=1, disk_size=100, memory=1024, name="same", active=True + ), + }, + { + "same": Machine( + cpus=1, disk_size=100, memory=1024, name="same", active=False + ), + "removed": Machine( + cpus=1, disk_size=100, memory=1024, name="removed", active=True + ), + }, + ) + self.assertListEqual(diff.new, []) + self.assertListEqual( + diff.same, + [Machine(cpus=1, disk_size=100, memory=1024, name="same", active=False)], + ) + self.assertListEqual( + diff.removed, + [Machine(cpus=1, disk_size=100, memory=1024, name="removed", active=False)], + ) + self.assertListEqual(diff.modified, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/modules/virtualisation/podman/podman.nix b/modules/virtualisation/podman/podman.nix new file mode 100644 index 000000000..64fce3f25 --- /dev/null +++ b/modules/virtualisation/podman/podman.nix @@ -0,0 +1,274 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.virtualisation.podman; + toml = pkgs.formats.toml { }; + json = pkgs.formats.json { }; + + inherit (lib) mkDefault mkIf mkMerge mkOption types; + + podmanPackage = (pkgs.podman.override { inherit (cfg) extraPackages; }); + + # Provides a fake "docker" binary mapping to podman + dockerAlias = pkgs.runCommandNoCC + "${podmanPackage.pname}-docker-alias-${podmanPackage.version}" { + outputs = [ "out" "man" ]; + inherit (podmanPackage) meta; + } '' + mkdir -p $out/bin + ln -s ${podmanPackage}/bin/podman $out/bin/docker + + mkdir -p $man/share/man/man1 + for f in ${podmanPackage.man}/share/man/man1/*; do + basename=$(basename $f | sed s/podman/docker/g) + ln -s $f $man/share/man/man1/$basename + done + ''; + + podmactl = pkgs.callPackage ./podmactl { }; + + machineOpts = { + # Options here are loaded into python. For simplicity, please use + # snake_case. + options = { + active = mkOption { + type = types.bool; + default = false; + description = '' + This machine should be started. Only one machine can be active at a time + ''; + }; + + qemu_binary = mkOption { + type = types.nullOr types.str; + default = null; + example = "''${pkgs.qemu}/bin/qemu-system-x86_64"; + description = '' + Use this to start VM with the qemu appropriate for your architecture. + ''; + }; + + # Options passed to Podman machine. + # See https://docs.podman.io/en/latest/markdown/podman-machine.1.html + cpus = mkOption { + type = types.ints.positive; + default = 1; + description = "The number of CPUs to assign to the VM."; + }; + + disk_size = mkOption { + type = types.ints.positive; + default = 100; + description = "Size of disk in gigabytes. Can only be increased"; + }; + + image_path = mkOption { + type = types.nullOr types.str; + default = null; + example = lib.literalExpression '' + builtins.fetchurl "https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/38.20230819.3.0/x86_64/fedora-coreos-38.20230819.3.0-qemu.x86_64.qcow2.xz"''; + description = '' + Image to be used when starting the VM + Can be a local path or a URL to an image. + Alternatives can be found at . + ''; + }; + + memory = mkOption { + type = types.ints.positive; + default = 2048; + description = "RAM in MB to be assigned to the machine"; + }; + }; + }; + +in { + meta.maintainers = [ pkgs.lib.maintainers.michaelCTS ]; + + options.virtualisation.podman = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + This option enables Podman, a daemonless container engine for + developing, managing, and running OCI Containers on your Linux System. + + It is a drop-in replacement for the {command}`docker` command. + ''; + }; + + enableDockerSocket = mkOption { + type = types.bool; + default = false; + description = '' + Make the Podman socket available in place of the Docker socket, so + Docker tools can find the Podman socket. + + Podman implements the Docker API. + ''; + }; + + enableDockerAlias = mkOption { + type = types.bool; + default = false; + description = '' + Create an alias mapping {command}`docker` to {command}`podman`. + ''; + }; + + extraPackages = mkOption { + type = with types; listOf package; + default = [ ]; + example = lib.literalExpression "[ pkgs.gvisor ]"; + description = '' + Extra packages to be installed in the Podman wrapper. + ''; + }; + + finalPackage = lib.mkOption { + type = types.package; + internal = true; + readOnly = true; + default = podmanPackage; + description = '' + The final Podman package (including extra packages). + ''; + }; + + defaultNetwork.extraPlugins = lib.mkOption { + type = types.listOf json.type; + default = [ ]; + description = '' + Extra CNI plugin configurations to add to Podman's default network. + ''; + }; + + machines = lib.mkOption { + type = types.attrsOf (types.submodule machineOpts); + # One and only one machine may be active at any given time + apply = machines: + assert ((lib.lists.count (machine: machine.active) + (lib.attrsets.attrValues machines)) == 1); + machines; + default = { + podman-machine-default = { + active = true; + cpus = 2; + disk_size = 100; + memory = 2048; + }; + }; + example = lib.literalExpression '' + { + intel-x86 = { + cpus = 2; + disk_size = 200; + memory = 4096; + image_path = "fedora-coreos-38.20230806.3.0-qemu.x86_64.qcow2.xz"; + qemu_binary = "${pkgs.qemu}/bin/qemu-system-x86_64"; + }; + } + ''; + description = '' + Virtual machine descriptions when Podman is run in on non-Linux systems. + ''; + }; + + }; + + config = mkIf cfg.enable (mkMerge [ + { + home.packages = [ cfg.finalPackage ] + ++ lib.optional cfg.enableDockerAlias dockerAlias; + + virtualisation.containers = { + enable = true; # Enable common /etc/containers configuration + }; + } + + (mkIf pkgs.stdenv.hostPlatform.isLinux (mkMerge [ + { + systemd.user = { + services.podman = { + Unit = { + Description = "Podman API Service"; + Requires = "podman.socket"; + After = "podman.socket"; + Documentation = "man:podman-system-service(1)"; + StartLimitIntervalSec = 0; + }; + + Service = { + Type = "exec"; + KillMode = "process"; + Environment = ''LOGGING=" --log-level=info"''; + ExecStart = [ + "${cfg.finalPackage}/bin/podman" + "$LOGGING" + "system" + "service" + ]; + }; + + Install = { WantedBy = [ "default.target" ]; }; + }; + + sockets.podman = { + Unit = { + Description = "Podman API Socket"; + Documentation = "man:podman-system-service(1)"; + }; + + Socket = { + ListenStream = "%t/podman/podman.sock"; + SocketMode = 660; + }; + + Install.WantedBy = [ "sockets.target" ]; + }; + + }; + } + + (mkIf cfg.enableDockerSocket { + home.sessionVariables."DOCKER_HOST" = + "unix:///$XDG_RUNTIME_DIR/podman/podman.sock"; + }) + ])) + + (mkIf pkgs.stdenv.isDarwin (mkMerge [ + { + home.packages = [ + pkgs.qemu # To manage machines + pkgs.openssh # To ssh into the machines + ]; + } + + { + home.extraActivationPath = [ + pkgs.qemu # To manage machines. + pkgs.openssh # To ssh into the machines. + ]; + + # CRUD the requested podman machines when activating the profile + home.activation.podman-machine = + lib.hm.dag.entryAfter [ "writeBoundary" ] + (lib.strings.concatStringsSep " " [ + "$DRY_RUN_CMD" + "${podmactl}/bin/podmactl" + "--podman" + "${cfg.finalPackage}/bin/podman" + "$VERBOSE_ARG" + "${json.generate "podman-machines.json" cfg.machines}" + ]); + } + + # Socket is actually only available after the launchd agent has + # successfully completed and the machine has been started. + (mkIf cfg.enableDockerSocket { + home.sessionVariables."DOCKER_HOST" = + "unix:///Users/$USER/.local/share/containers/podman/machine/qemu/podman.sock"; + }) + ])) + ]); +} diff --git a/tests/default.nix b/tests/default.nix index cebcf6c78..734e15fbf 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -159,6 +159,7 @@ import nmt { ./modules/programs/zplug ./modules/programs/zsh ./modules/services/syncthing/common + ./modules/virtualisation/podman ./modules/xresources ] ++ lib.optionals isDarwin [ ./modules/launchd @@ -263,6 +264,7 @@ import nmt { ./modules/services/wlsunset ./modules/services/xsettingsd ./modules/systemd + ./modules/virtualisation/oci-containers ./modules/targets-linux ]); } diff --git a/tests/modules/virtualisation/oci-containers/basic-config.nix b/tests/modules/virtualisation/oci-containers/basic-config.nix new file mode 100644 index 000000000..ec998f0f6 --- /dev/null +++ b/tests/modules/virtualisation/oci-containers/basic-config.nix @@ -0,0 +1,52 @@ +{ config, lib, pkgs, ... }: + +lib.mkIf config.test.enableBig { + virtualisation.oci-containers.enable = true; + + nmt.script = lib.mkIf pkgs.stdenv.isLinux '' + servicePath=home-files/.config/systemd/user + + assertFileExists $servicePath/podman.service $servicePath/podman.socket + + podmanServiceNormalized="$(normalizeStorePaths "$servicePath/podman.service")" + assertFileContent $podmanServiceNormalized \ + ${ + builtins.toFile "podman.service-expected" '' + [Install] + WantedBy=default.target + + [Service] + Environment=LOGGING=" --log-level=info" + ExecStart=/nix/store/00000000000000000000000000000000-podman/bin/podman + ExecStart=$LOGGING + ExecStart=system + ExecStart=service + KillMode=process + Type=exec + + [Unit] + After=podman.socket + Description=Podman API Service + Documentation=man:podman-system-service(1) + Requires=podman.socket + StartLimitIntervalSec=0 + '' + } + + assertFileContent $servicePath/podman.socket \ + ${ + builtins.toFile "podman.socket-expected" '' + [Install] + WantedBy=sockets.target + + [Socket] + ListenStream=%t/podman/podman.sock + SocketMode=660 + + [Unit] + Description=Podman API Socket + Documentation=man:podman-system-service(1) + '' + } + ''; +} diff --git a/tests/modules/virtualisation/oci-containers/default.nix b/tests/modules/virtualisation/oci-containers/default.nix new file mode 100644 index 000000000..68ce9d9aa --- /dev/null +++ b/tests/modules/virtualisation/oci-containers/default.nix @@ -0,0 +1 @@ +{ oci-containers-basic-config = ./basic-config.nix; } diff --git a/tests/modules/virtualisation/podman/basic-config.nix b/tests/modules/virtualisation/podman/basic-config.nix new file mode 100644 index 000000000..5b3355095 --- /dev/null +++ b/tests/modules/virtualisation/podman/basic-config.nix @@ -0,0 +1,52 @@ +{ config, pkgs, lib, ... }: + +lib.mkIf config.test.enableBig { + virtualisation.podman.enable = true; + + nmt.script = lib.mkIf pkgs.stdenv.isLinux '' + servicePath=home-files/.config/systemd/user + + assertFileExists $servicePath/podman.service $servicePath/podman.socket + + podmanServiceNormalized="$(normalizeStorePaths "$servicePath/podman.service")" + assertFileContent $podmanServiceNormalized \ + ${ + builtins.toFile "podman.service-expected" '' + [Install] + WantedBy=default.target + + [Service] + Environment=LOGGING=" --log-level=info" + ExecStart=/nix/store/00000000000000000000000000000000-podman/bin/podman + ExecStart=$LOGGING + ExecStart=system + ExecStart=service + KillMode=process + Type=exec + + [Unit] + After=podman.socket + Description=Podman API Service + Documentation=man:podman-system-service(1) + Requires=podman.socket + StartLimitIntervalSec=0 + '' + } + + assertFileContent $servicePath/podman.socket \ + ${ + builtins.toFile "podman.socket-expected" '' + [Install] + WantedBy=sockets.target + + [Socket] + ListenStream=%t/podman/podman.sock + SocketMode=660 + + [Unit] + Description=Podman API Socket + Documentation=man:podman-system-service(1) + '' + } + ''; +} diff --git a/tests/modules/virtualisation/podman/default.nix b/tests/modules/virtualisation/podman/default.nix new file mode 100644 index 000000000..6a738591e --- /dev/null +++ b/tests/modules/virtualisation/podman/default.nix @@ -0,0 +1,4 @@ +{ + podman-basic-config = ./basic-config.nix; + podman-docker-alias = ./docker-alias.nix; +} diff --git a/tests/modules/virtualisation/podman/docker-alias.nix b/tests/modules/virtualisation/podman/docker-alias.nix new file mode 100644 index 000000000..4f5356608 --- /dev/null +++ b/tests/modules/virtualisation/podman/docker-alias.nix @@ -0,0 +1,14 @@ +{ config, pkgs, lib, ... }: + +lib.mkIf config.test.enableBig { + virtualisation.podman = { + enable = true; + enableDockerAlias = true; + enableDockerSocket = true; + }; + + nmt.script = '' + assertFileIsExecutable home-path/bin/docker + assertFileContains home-path/etc/profile.d/hm-session-vars.sh "DOCKER_HOST" + ''; +}