From faa4b16358ebd8607499538dbea02bfba5930bd9 Mon Sep 17 00:00:00 2001 From: Michael Vogel Date: Fri, 11 Aug 2023 10:42:57 +0200 Subject: [PATCH] podman: add module This module is a continuation of #2630 by MaeIsBad. It also adds a module `virtualisation.oci-containers` that is equivalent to the one in NixOS. Basically it allows a simple toggle to activate oci-container services and commands. We also support Podman on mac. Note, Podman requires a VM on mac, which has to be started before any Podman commands can be executed. Users might sometimes require VMs that use different architectures than the default VM started by Podman. Thus, they get the option to define the VM(s) that will be initialized and started by podman. Since Podman has to start a machine, it's best to do it using launchd. The configuration of the machines requires a JSON, generated from an attrset in Home Manager, which is where Python script comes into play to take care of diff-ing the `podman machine list` to CRUD them. PR #4331 Co-authored-by: MaeIsBad <26093674+MaeIsBad@users.noreply.github.com> --- modules/misc/news.nix | 10 + modules/modules.nix | 3 + modules/virtualisation/containers.nix | 76 +++++ modules/virtualisation/oci-containers.nix | 28 ++ .../virtualisation/podman/podmactl/README.md | 30 ++ .../podman/podmactl/default.nix | 28 ++ .../podman/podmactl/podmactl.py | 317 ++++++++++++++++++ .../virtualisation/podman/podmactl/shell.nix | 5 + .../podman/podmactl/test_podmactl.py | 232 +++++++++++++ modules/virtualisation/podman/podman.nix | 274 +++++++++++++++ tests/default.nix | 2 + .../oci-containers/basic-config.nix | 52 +++ .../virtualisation/oci-containers/default.nix | 1 + .../virtualisation/podman/basic-config.nix | 52 +++ .../modules/virtualisation/podman/default.nix | 4 + .../virtualisation/podman/docker-alias.nix | 14 + 16 files changed, 1128 insertions(+) create mode 100644 modules/virtualisation/containers.nix create mode 100644 modules/virtualisation/oci-containers.nix create mode 100644 modules/virtualisation/podman/podmactl/README.md create mode 100644 modules/virtualisation/podman/podmactl/default.nix create mode 100644 modules/virtualisation/podman/podmactl/podmactl.py create mode 100644 modules/virtualisation/podman/podmactl/shell.nix create mode 100644 modules/virtualisation/podman/podmactl/test_podmactl.py create mode 100644 modules/virtualisation/podman/podman.nix create mode 100644 tests/modules/virtualisation/oci-containers/basic-config.nix create mode 100644 tests/modules/virtualisation/oci-containers/default.nix create mode 100644 tests/modules/virtualisation/podman/basic-config.nix create mode 100644 tests/modules/virtualisation/podman/default.nix create mode 100644 tests/modules/virtualisation/podman/docker-alias.nix 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" + ''; +}