diff --git a/ansible/tasks/files b/ansible/tasks/files new file mode 120000 index 000000000..feb122881 --- /dev/null +++ b/ansible/tasks/files @@ -0,0 +1 @@ +../files \ No newline at end of file diff --git a/ansible/tests/conftest.py b/ansible/tests/conftest.py new file mode 100644 index 000000000..c993ff4cc --- /dev/null +++ b/ansible/tests/conftest.py @@ -0,0 +1,12 @@ +def pytest_addoption(parser): + parser.addoption( + "--ansible-dir", + action="store", + help="Directory containing Ansible playbooks and roles", + ) + + parser.addoption( + "--docker-image", + action="store", + help="Docker image and tag to use for testing", + ) diff --git a/ansible/tests/nginx.yaml b/ansible/tests/nginx.yaml new file mode 100644 index 000000000..720e79679 --- /dev/null +++ b/ansible/tests/nginx.yaml @@ -0,0 +1,14 @@ +--- +- hosts: localhost + tasks: + - name: Install dependencies + apt: + pkg: + - build-essential + update_cache: yes + - import_tasks: ../tasks/setup-nginx.yml + - name: Start Nginx service + service: + name: nginx + state: started + enabled: yes diff --git a/ansible/tests/test_nginx.py b/ansible/tests/test_nginx.py new file mode 100644 index 000000000..cff5c0d69 --- /dev/null +++ b/ansible/tests/test_nginx.py @@ -0,0 +1,61 @@ +import pytest +import subprocess +import testinfra +from rich.console import Console + +console = Console() + + +@pytest.fixture(scope="session") +def host(request): + ansible_dir = request.config.getoption("--ansible-dir") + docker_id = ( + subprocess.check_output( + [ + "docker", + "run", + "--privileged", + "--cap-add", + "SYS_ADMIN", + "--security-opt", + "seccomp=unconfined", + "--cgroup-parent=docker.slice", + "--cgroupns", + "private", + "-v", + f"{ansible_dir}/:/ansible/", + "-d", + "ubuntu-cloudimg-with-tools:0.1", + ] + ) + .decode() + .strip() + ) + yield testinfra.get_host("docker://" + docker_id) + subprocess.check_call(["docker", "rm", "-f", docker_id], stdout=subprocess.DEVNULL) + + +@pytest.fixture(scope="session", autouse=True) +def run_ansible(host): + cmd = [ + "ANSIBLE_HOST_KEY_CHECKING=False", + "ansible-playbook", + "--connection=local", + "-i", + "localhost,", + "--extra-vars", + "@/ansible/vars.yml", + "/ansible/tests/nginx.yaml", + ] + result = host.run(" ".join(cmd)) + if result.failed: + console.log(result.stdout) + console.log(result.stderr) + raise pytest.fail( + "Ansible playbook nginx.yaml failed with return code {}".format(result.rc) + ) + + +def test_nginx_service(host): + assert host.service("nginx.service").is_valid + assert host.service("nginx.service").is_running diff --git a/flake.lock b/flake.lock index 08aa16261..81a3b92f9 100644 --- a/flake.lock +++ b/flake.lock @@ -217,6 +217,7 @@ "nixpkgs-go124": "nixpkgs-go124", "nixpkgs": "nixpkgs", "rust-overlay": "rust-overlay", + "system-manager": "system-manager", "treefmt-nix": "treefmt-nix" } }, @@ -240,6 +241,26 @@ "type": "github" } }, + "system-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1754135474, + "narHash": "sha256-tZ8SXR80gcy8lqa1DD6/CNH9oQ1kOFw/cJihcn5/1M0=", + "owner": "numtide", + "repo": "system-manager", + "rev": "7865b4a207e46afa5c2e264de550730f8e281176", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "system-manager", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/flake.nix b/flake.nix index cb51bf57e..78b2b383f 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,10 @@ url = "github:cachix/git-hooks.nix"; inputs.nixpkgs.follows = "nixpkgs"; }; + system-manager = { + url = "github:numtide/system-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = @@ -54,6 +58,7 @@ nix/nixpkgs.nix nix/packages nix/overlays + nix/systemModules ]; }); } diff --git a/nix/packages/ansible-test.nix b/nix/packages/ansible-test.nix new file mode 100644 index 000000000..c6d997b71 --- /dev/null +++ b/nix/packages/ansible-test.nix @@ -0,0 +1,90 @@ +{ + pkgs, + lib, +}: +let + ubuntu-cloudimg = + let + cloudImg = builtins.fetchurl { + url = "http://cloud-images-archive.ubuntu.com/releases/noble/release-20250430/ubuntu-24.04-server-cloudimg-amd64-root.tar.xz"; + sha256 = "sha256:0rfi3qqs0sqarixfic7pzjpx7d4vldv2d98c5zjv7b90mirznvf9"; + }; + in + pkgs.runCommand "ubuntu-cloudimg" { nativeBuildInputs = [ pkgs.xz ]; } '' + mkdir -p $out + tar --exclude='dev/*' \ + --exclude='etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service' \ + --exclude='etc/systemd/system/multi-user.target.wants/systemd-resolved.service' \ + --exclude='usr/lib/systemd/system/tpm-udev.service' \ + --exclude='usr/lib/systemd/system/systemd-remount-fs.service' \ + --exclude='usr/lib/systemd/system/systemd-resolved.service' \ + --exclude='var/lib/apt/lists/*' \ + -xJf ${cloudImg} -C $out + rm $out/bin $out/lib $out/lib64 $out/sbin + mkdir -p $out/run/systemd && echo 'docker' > $out/run/systemd/container + mkdir $out/var/lib/apt/lists/partial + ''; + + dockerImageUbuntu = pkgs.dockerTools.buildImage { + name = "ubuntu-cloudimg"; + tag = "0.1"; + created = "now"; + extraCommands = '' + ln -s usr/bin + ln -s usr/lib + ln -s usr/lib64 + ln -s usr/sbin + ''; + copyToRoot = pkgs.buildEnv { + name = "image-root"; + pathsToLink = [ "/" ]; + paths = [ ubuntu-cloudimg ]; + }; + config.Cmd = [ "/lib/systemd/systemd" ]; + }; + + dockerImageUbuntuWithTools = + let + tools = [ pkgs.ansible ]; + in + pkgs.dockerTools.buildLayeredImage { + name = "ubuntu-cloudimg-with-tools"; + tag = "0.1"; + created = "now"; + maxLayers = 30; + fromImage = dockerImageUbuntu; + config = { + Env = [ + "PATH=${lib.makeBinPath tools}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ]; + Cmd = [ "/lib/systemd/systemd" ]; + }; + }; +in +pkgs.writeShellApplication { + name = "ansible-test"; + runtimeInputs = with pkgs; [ + (python3.withPackages ( + ps: with ps; [ + requests + pytest + pytest-testinfra + rich + ] + )) + ]; + text = '' + echo "Running Ansible tests..." + export DOCKER_IMAGE=${dockerImageUbuntuWithTools.imageName}:${dockerImageUbuntuWithTools.imageTag} + if ! docker image inspect $DOCKER_IMAGE > /dev/null; then + echo "Loading Docker image..." + docker load < ${dockerImageUbuntuWithTools} + fi + ANSIBLE_DIR=${../../ansible} + pytest -p no:cacheprovider -s -v "$@" $ANSIBLE_DIR/tests --ansible-dir=$ANSIBLE_DIR --docker-image=$DOCKER_IMAGE + ''; + meta = with pkgs.lib; { + description = "Ansible test runner"; + platforms = platforms.linux; + }; +} diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 342763961..980b3935e 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -31,8 +31,10 @@ packages = ( { build-test-ami = pkgs.callPackage ./build-test-ami.nix { }; + ansible-test = pkgs.callPackage ./ansible-test.nix { }; cleanup-ami = pkgs.callPackage ./cleanup-ami.nix { }; dbmate-tool = pkgs.callPackage ./dbmate-tool.nix { inherit (self.supabase) defaults; }; + docker-image-ubuntu = pkgs.callPackage ./docker-ubuntu.nix { }; docs = pkgs.callPackage ./docs.nix { }; supabase-groonga = pkgs.callPackage ./groonga { }; local-infra-bootstrap = pkgs.callPackage ./local-infra-bootstrap.nix { }; diff --git a/nix/packages/docker-ubuntu.nix b/nix/packages/docker-ubuntu.nix new file mode 100644 index 000000000..838f68f68 --- /dev/null +++ b/nix/packages/docker-ubuntu.nix @@ -0,0 +1,48 @@ +{ + runCommand, + dockerTools, + xz, + buildEnv, +}: +let + ubuntu-cloudimg = + let + cloudImg = builtins.fetchurl { + url = "http://cloud-images-archive.ubuntu.com/releases/noble/release-20250430/ubuntu-24.04-server-cloudimg-amd64-root.tar.xz"; + sha256 = "sha256:0rfi3qqs0sqarixfic7pzjpx7d4vldv2d98c5zjv7b90mirznvf9"; + }; + in + runCommand "ubuntu-cloudimg" { nativeBuildInputs = [ xz ]; } '' + mkdir -p $out + tar --exclude='dev/*' \ + --exclude='etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service' \ + --exclude='etc/systemd/system/multi-user.target.wants/systemd-resolved.service' \ + --exclude='usr/lib/systemd/system/tpm-udev.service' \ + --exclude='usr/lib/systemd/system/systemd-remount-fs.service' \ + --exclude='usr/lib/systemd/system/systemd-resolved.service' \ + --exclude='usr/lib/systemd/system/proc-sys-fs-binfmt_misc.automount' \ + --exclude='usr/lib/systemd/system/sys-kernel-*' \ + --exclude='var/lib/apt/lists/*' \ + -xJf ${cloudImg} -C $out + rm $out/bin $out/lib $out/lib64 $out/sbin + mkdir -p $out/run/systemd && echo 'docker' > $out/run/systemd/container + mkdir $out/var/lib/apt/lists/partial + ''; +in +dockerTools.buildImage { + name = "ubuntu-cloudimg"; + tag = "24.04"; + created = "now"; + extraCommands = '' + ln -s usr/bin + ln -s usr/lib + ln -s usr/lib64 + ln -s usr/sbin + ''; + copyToRoot = buildEnv { + name = "image-root"; + pathsToLink = [ "/" ]; + paths = [ ubuntu-cloudimg ]; + }; + config.Cmd = [ "/lib/systemd/systemd" ]; +} diff --git a/nix/systemModules/default.nix b/nix/systemModules/default.nix new file mode 100644 index 000000000..14b459255 --- /dev/null +++ b/nix/systemModules/default.nix @@ -0,0 +1,9 @@ +{ + ... +}: +{ + imports = [ ./tests ]; + flake = { + systemModules = { }; + }; +} diff --git a/nix/systemModules/tests/conftest.py b/nix/systemModules/tests/conftest.py new file mode 100644 index 000000000..581c2dea1 --- /dev/null +++ b/nix/systemModules/tests/conftest.py @@ -0,0 +1,109 @@ +import pytest +import shutil +import subprocess +import testinfra +import time +from rich.console import Console + +console = Console() + + +def pytest_addoption(parser): + parser.addoption( + "--image-name", + action="store", + help="Docker image and tag to use for testing", + ) + parser.addoption( + "--image-path", + action="store", + help="Compressed Docker image to load for testing", + ) + + parser.addoption( + "--force-docker", + action="store_true", + help="Force using Docker instead of Podman for testing", + ) + + +@pytest.fixture(scope="session") +def host(request): + force_docker = request.config.getoption("--force-docker") + image_name = request.config.getoption("--image-name") + image_path = request.config.getoption("--image-path") + if not force_docker and shutil.which("podman"): + console.log("Using Podman for testing") + with console.status("Loading image..."): + subprocess.check_output(["podman", "load", "-q", "-i", image_path]) + podman_id = ( + subprocess.check_output( + [ + "podman", + "run", + "--cap-add", + "SYS_ADMIN", + "-d", + image_name, + ] + ) + .decode() + .strip() + ) + yield testinfra.get_host("podman://" + podman_id) + with console.status("Cleaning up..."): + subprocess.check_call( + ["podman", "rm", "-f", podman_id], stdout=subprocess.DEVNULL + ) + else: + console.log("Using Docker for testing") + with console.status("Loading image..."): + subprocess.check_output(["docker", "load", "-q", "-i", image_path]) + docker_id = ( + subprocess.check_output( + [ + "docker", + "run", + "--privileged", + "--cap-add", + "SYS_ADMIN", + "--security-opt", + "seccomp=unconfined", + "--cgroup-parent=docker.slice", + "--cgroupns", + "private", + "-d", + image_name, + ] + ) + .decode() + .strip() + ) + yield testinfra.get_host("docker://" + docker_id) + with console.status("Cleaning up..."): + subprocess.check_call( + ["docker", "rm", "-f", docker_id], stdout=subprocess.DEVNULL + ) + + +def wait_for_target(host, target, timeout=60): + start_time = time.time() + while time.time() - start_time < timeout: + result = host.run(f"systemctl is-active {target}") + if result.rc == 0: + return True + time.sleep(0.2) + return False + + +@pytest.fixture(scope="session", autouse=True) +def activate_system_manager(host): + with console.status("Waiting systemd to be ready..."): + assert wait_for_target(host, "multi-user.target") + result = host.run("activate") + console.log(result.stdout) + console.log(result.stderr) + if result.failed: + raise pytest.fail( + "System manager activation failed with return code {}".format(result.rc) + ) diff --git a/nix/systemModules/tests/default.nix b/nix/systemModules/tests/default.nix new file mode 100644 index 000000000..cb489b714 --- /dev/null +++ b/nix/systemModules/tests/default.nix @@ -0,0 +1,70 @@ +{ self, ... }: +{ + perSystem = + { + lib, + pkgs, + self', + ... + }: + { + packages = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isLinux) { + check-system-manager = + let + lib = pkgs.lib; + systemManagerConfig = self.inputs.system-manager.lib.makeSystemConfig { + modules = [ + ({ + services.nginx.enable = true; + nixpkgs.hostPlatform = pkgs.system; + }) + ]; + }; + + dockerImageUbuntuWithTools = + let + tools = [ systemManagerConfig ]; + in + pkgs.dockerTools.buildLayeredImage { + name = "ubuntu-cloudimg-with-tools"; + tag = "0.2"; + created = "now"; + maxLayers = 30; + fromImage = self'.packages.docker-image-ubuntu; + compressor = "zstd"; + config = { + Env = [ + "PATH=${lib.makeBinPath tools}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ]; + Cmd = [ "/lib/systemd/systemd" ]; + }; + }; + in + pkgs.writeShellApplication { + name = "system-manager-test"; + passthru = { + inherit systemManagerConfig dockerImageUbuntuWithTools; + }; + runtimeInputs = with pkgs; [ + (python3.withPackages ( + ps: with ps; [ + requests + pytest + pytest-testinfra + rich + ] + )) + ]; + text = '' + export DOCKER_IMAGE=${dockerImageUbuntuWithTools.imageName}:${dockerImageUbuntuWithTools.imageTag} + TEST_DIR=${./.} + pytest -p no:cacheprovider -s -v "$@" $TEST_DIR --image-name=$DOCKER_IMAGE --image-path=${dockerImageUbuntuWithTools} + ''; + meta = with pkgs.lib; { + description = "Test deployment with system-manager"; + platforms = platforms.linux; + }; + }; + }; + }; +} diff --git a/nix/systemModules/tests/test_nginx.py b/nix/systemModules/tests/test_nginx.py new file mode 100644 index 000000000..047d07956 --- /dev/null +++ b/nix/systemModules/tests/test_nginx.py @@ -0,0 +1,3 @@ +def test_nginx_service(host): + assert host.service("nginx.service").is_valid + assert host.service("nginx.service").is_running