From 7a46c8b8ded42528e9b0ff2a2fbefeb06d86ed1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 25 Mar 2025 17:44:22 +0100 Subject: [PATCH 1/7] skip sshd-based tests on macOS for now --- pkgs/clan-cli/tests/getpwnam-preload.c | 49 ++++++++++++++++++++++++-- pkgs/clan-cli/tests/test_ssh_remote.py | 11 ++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/pkgs/clan-cli/tests/getpwnam-preload.c b/pkgs/clan-cli/tests/getpwnam-preload.c index 828007ed1..d671116ed 100644 --- a/pkgs/clan-cli/tests/getpwnam-preload.c +++ b/pkgs/clan-cli/tests/getpwnam-preload.c @@ -6,12 +6,44 @@ #include #include +#ifdef __APPLE__ +#include +#include +#endif + +#ifdef __APPLE__ +struct dyld_interpose { + const void *replacement; + const void *replacee; +}; +#define WRAPPER(ret, name) static ret _fakeroot_wrapper_##name +#define WRAPPER_DEF(name) \ + __attribute__(( \ + used)) static struct dyld_interpose _fakeroot_interpose_##name \ + __attribute__((section("__DATA,__interpose"))) = { \ + &_fakeroot_wrapper_##name, &name}; +#else +#define WRAPPER(ret, name) ret name +#define WRAPPER_DEF(name) +#endif + typedef struct passwd *(*getpwnam_type)(const char *name); -struct passwd *getpwnam(const char *name) { +WRAPPER(struct passwd *, getpwnam)(const char *name) { struct passwd *pw; - getpwnam_type orig_getpwnam; - orig_getpwnam = (getpwnam_type)dlsym(RTLD_NEXT, "getpwnam"); +#ifdef __APPLE__ +#define orig_getpwnam(name) getpwnam(name) +#else + static getpwnam_type orig_getpwnam = NULL; + + if (!orig_getpwnam) { + orig_getpwnam = (getpwnam_type)dlsym(RTLD_NEXT, "getpwnam"); + if (!orig_getpwnam) { + fprintf(stderr, "dlsym error: %s\n", dlerror()); + exit(1); + } + } +#endif pw = orig_getpwnam(name); if (pw) { @@ -21,6 +53,17 @@ struct passwd *getpwnam(const char *name) { exit(1); } pw->pw_shell = strdup(shell); + fprintf(stderr, "getpwnam: %s -> %s\n", name, pw->pw_shell); } return pw; } +WRAPPER_DEF(getpwnam) + +#ifdef __APPLE__ +// sandbox_init(3) doesn't work in nix build sandbox +WRAPPER(int, sandbox_init)(const char *profile, uint64_t flags, void *handle) { + return 0; +} +WRAPPER_DEF(sandbox_init) +#else +#endif diff --git a/pkgs/clan-cli/tests/test_ssh_remote.py b/pkgs/clan-cli/tests/test_ssh_remote.py index 22d2c546f..18d9b4c20 100644 --- a/pkgs/clan-cli/tests/test_ssh_remote.py +++ b/pkgs/clan-cli/tests/test_ssh_remote.py @@ -1,4 +1,5 @@ import contextlib +import sys from collections.abc import Generator from typing import Any, NamedTuple @@ -127,6 +128,10 @@ def test_parse_ssh_options() -> None: assert host.ssh_options["StrictHostKeyChecking"] == "yes" +is_darwin = sys.platform == "darwin" + + +@pytest.mark.skipif(is_darwin, reason="preload doesn't work on darwin") def test_run(hosts: list[Host], runtime: AsyncRuntime) -> None: for host in hosts: proc = runtime.async_run( @@ -135,6 +140,7 @@ def test_run(hosts: list[Host], runtime: AsyncRuntime) -> None: assert proc.wait().result.stdout == "hello\n" +@pytest.mark.skipif(is_darwin, reason="preload doesn't work on darwin") def test_run_environment(hosts: list[Host], runtime: AsyncRuntime) -> None: for host in hosts: proc = runtime.async_run( @@ -157,6 +163,7 @@ def test_run_environment(hosts: list[Host], runtime: AsyncRuntime) -> None: assert "env_var=true" in p2.wait().result.stdout +@pytest.mark.skipif(is_darwin, reason="preload doesn't work on darwin") def test_run_no_shell(hosts: list[Host], runtime: AsyncRuntime) -> None: for host in hosts: proc = runtime.async_run( @@ -165,6 +172,7 @@ def test_run_no_shell(hosts: list[Host], runtime: AsyncRuntime) -> None: assert proc.wait().result.stdout == "hello\n" +@pytest.mark.skipif(is_darwin, reason="preload doesn't work on darwin") def test_run_function(hosts: list[Host], runtime: AsyncRuntime) -> None: def some_func(h: Host) -> bool: p = h.run(["echo", "hello"]) @@ -175,6 +183,7 @@ def test_run_function(hosts: list[Host], runtime: AsyncRuntime) -> None: assert proc.wait().result +@pytest.mark.skipif(is_darwin, reason="preload doesn't work on darwin") def test_timeout(hosts: list[Host], runtime: AsyncRuntime) -> None: for host in hosts: proc = runtime.async_run( @@ -184,6 +193,7 @@ def test_timeout(hosts: list[Host], runtime: AsyncRuntime) -> None: assert isinstance(error, ClanCmdTimeoutError) +@pytest.mark.skipif(is_darwin, reason="preload doesn't work on darwin") def test_run_exception(hosts: list[Host], runtime: AsyncRuntime) -> None: for host in hosts: proc = runtime.async_run( @@ -203,6 +213,7 @@ def test_run_exception(hosts: list[Host], runtime: AsyncRuntime) -> None: raise AssertionError(msg) +@pytest.mark.skipif(is_darwin, reason="preload doesn't work on darwin") def test_run_function_exception(hosts: list[Host], runtime: AsyncRuntime) -> None: def some_func(h: Host) -> CmdOut: return h.run_local(["exit 1"], RunOpts(shell=True)) From c20c0d4ea825980c13608b6cacbe29eb2833c327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 25 Mar 2025 17:44:43 +0100 Subject: [PATCH 2/7] enable python tests without core on macOS --- pkgs/clan-cli/default.nix | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-cli/default.nix b/pkgs/clan-cli/default.nix index 0d31b9bd3..a3214cb2d 100644 --- a/pkgs/clan-cli/default.nix +++ b/pkgs/clan-cli/default.nix @@ -51,7 +51,7 @@ let testDependencies = testRuntimeDependencies ++ [ gnupg stdenv.cc # Compiler used for certain native extensions - (pythonRuntime.withPackages (ps: (pyTestDeps ps) ++ (pyDeps ps))) + (pythonRuntime.withPackages pyTestDeps) ]; source = runCommand "clan-cli-source" { } '' @@ -127,7 +127,7 @@ pythonRuntime.pkgs.buildPythonApplication { # Define and expose the tests and checks to run in CI passthru.tests = (lib.mapAttrs' (n: lib.nameValuePair "clan-dep-${n}") testRuntimeDependenciesMap) - // lib.optionalAttrs (!stdenv.isDarwin) { + // { # disabled on macOS until we fix all remaining issues clan-pytest-without-core = runCommand "clan-pytest-without-core" @@ -159,6 +159,8 @@ pythonRuntime.pkgs.buildPythonApplication { python -m pytest -m "not impure and not with_core" -n $jobs ./tests touch $out ''; + } + // lib.optionalAttrs (!stdenv.isDarwin) { clan-pytest-with-core = runCommand "clan-pytest-with-core" { From fb70e715cf7595b52947676bbeb975104a02b2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 25 Mar 2025 17:58:17 +0100 Subject: [PATCH 3/7] use pre-generate gpg key for tests this is a bit faster. --- pkgs/clan-cli/tests/test_secrets_cli.py | 45 +++++-------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/pkgs/clan-cli/tests/test_secrets_cli.py b/pkgs/clan-cli/tests/test_secrets_cli.py index b9cafb210..6fefe6153 100644 --- a/pkgs/clan-cli/tests/test_secrets_cli.py +++ b/pkgs/clan-cli/tests/test_secrets_cli.py @@ -1,9 +1,8 @@ -import functools import json import logging import os import re -import subprocess +import shutil from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path @@ -13,6 +12,7 @@ import pytest from age_keys import assert_secrets_file_recipients from clan_cli.errors import ClanError from fixtures_flakes import FlakeForTest +from gpg_keys import GpgKey from helpers import cli from stdout import CaptureOutput @@ -444,45 +444,16 @@ def use_gpg_key(key: str, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: @pytest.fixture def gpg_key( - tmp_path: Path, + temp_dir: Path, monkeypatch: pytest.MonkeyPatch, + test_root: Path, ) -> str: - gpg_home = tmp_path / "gnupghome" - gpg_home.mkdir(mode=0o700) - - gpg_environ = os.environ.copy() - gpg_environ["GNUPGHOME"] = str(gpg_home) - run = functools.partial( - subprocess.run, - encoding="utf-8", - check=True, - env=gpg_environ, - ) - key_parameters = "\n".join( - ( - "%no-protection", - "%transient-key", - "Key-Type: rsa", - "Key-Usage: cert encrypt", - "Name-Real: Foo Bar", - "Name-Comment: Test user", - "Name-Email: test@clan.lol", - "%commit", - ) - ) - run(["gpg", "--batch", "--quiet", "--generate-key"], input=key_parameters) - details = run(["gpg", "--list-keys", "--with-colons"], capture_output=True) - fingerprint = None - for line in details.stdout.strip().split(os.linesep): - if not line.startswith("fpr"): - continue - fingerprint = line.split(":")[9] - break - assert fingerprint is not None, "Could not generate test GPG key" - log.info(f"Created GPG key under {gpg_home}") + gpg_home = temp_dir / "gnupghome" + shutil.copytree(test_root / "data" / "gnupg-home", gpg_home) monkeypatch.setenv("GNUPGHOME", str(gpg_home)) - return fingerprint + + return "9A9B2741C8062D3D3DF1302D8B049E262A5CA255" def test_secrets( From 3c7991137acb14a356e18ca80aabe6737ff581e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 25 Mar 2025 18:06:06 +0100 Subject: [PATCH 4/7] move git_repo fixture to its own file for consistency --- pkgs/clan-cli/tests/conftest.py | 20 +------------------- pkgs/clan-cli/tests/git_repo.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 19 deletions(-) create mode 100644 pkgs/clan-cli/tests/git_repo.py diff --git a/pkgs/clan-cli/tests/conftest.py b/pkgs/clan-cli/tests/conftest.py index 947b4fbc2..6ec659933 100644 --- a/pkgs/clan-cli/tests/conftest.py +++ b/pkgs/clan-cli/tests/conftest.py @@ -1,14 +1,11 @@ -import subprocess -from pathlib import Path - import pytest from clan_cli.custom_logger import setup_logging -from clan_cli.nix import nix_shell pytest_plugins = [ "temporary_dir", "root", "age_keys", + "gpg_keys", "sshd", "command", "ports", @@ -28,18 +25,3 @@ def pytest_sessionstart(session: pytest.Session) -> None: print(f"Session config: {session.config}") setup_logging(level="DEBUG") - - -# fixture for git_repo -@pytest.fixture -def git_repo(tmp_path: Path) -> Path: - # initialize a git repository - cmd = nix_shell(["nixpkgs#git"], ["git", "init"]) - subprocess.run(cmd, cwd=tmp_path, check=True) - # set user.name and user.email - cmd = nix_shell(["nixpkgs#git"], ["git", "config", "user.name", "test"]) - subprocess.run(cmd, cwd=tmp_path, check=True) - cmd = nix_shell(["nixpkgs#git"], ["git", "config", "user.email", "test@test.test"]) - subprocess.run(cmd, cwd=tmp_path, check=True) - # return the path to the git repository - return tmp_path diff --git a/pkgs/clan-cli/tests/git_repo.py b/pkgs/clan-cli/tests/git_repo.py new file mode 100644 index 000000000..ed8f94495 --- /dev/null +++ b/pkgs/clan-cli/tests/git_repo.py @@ -0,0 +1,20 @@ +import subprocess +from pathlib import Path + +import pytest +from clan_cli.nix import nix_shell + + +# fixture for git_repo +@pytest.fixture +def git_repo(temp_dir: Path) -> Path: + # initialize a git repository + cmd = nix_shell(["nixpkgs#git"], ["git", "init"]) + subprocess.run(cmd, cwd=temp_dir, check=True) + # set user.name and user.email + cmd = nix_shell(["nixpkgs#git"], ["git", "config", "user.name", "test"]) + subprocess.run(cmd, cwd=temp_dir, check=True) + cmd = nix_shell(["nixpkgs#git"], ["git", "config", "user.email", "test@test.test"]) + subprocess.run(cmd, cwd=temp_dir, check=True) + # return the path to the git repository + return temp_dir From 3a78dd6ded0c97b39097d3dedea5bce3808d2707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 25 Mar 2025 18:09:04 +0100 Subject: [PATCH 5/7] fix gpg key fixture on macOS macOS has length limitations for unix sockets, which are violated by the default length of temporary directories. --- pkgs/clan-cli/tests/conftest.py | 1 + pkgs/clan-cli/tests/gpg_keys.py | 25 +++++++++++++++++++ pkgs/clan-cli/tests/test_secrets_cli.py | 32 +++++++------------------ 3 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 pkgs/clan-cli/tests/gpg_keys.py diff --git a/pkgs/clan-cli/tests/conftest.py b/pkgs/clan-cli/tests/conftest.py index 6ec659933..6cff106a9 100644 --- a/pkgs/clan-cli/tests/conftest.py +++ b/pkgs/clan-cli/tests/conftest.py @@ -6,6 +6,7 @@ pytest_plugins = [ "root", "age_keys", "gpg_keys", + "git_repo", "sshd", "command", "ports", diff --git a/pkgs/clan-cli/tests/gpg_keys.py b/pkgs/clan-cli/tests/gpg_keys.py new file mode 100644 index 000000000..b4390646d --- /dev/null +++ b/pkgs/clan-cli/tests/gpg_keys.py @@ -0,0 +1,25 @@ +import shutil +from dataclasses import dataclass +from pathlib import Path + +import pytest + + +@dataclass +class GpgKey: + fingerprint: str + gpg_home: Path + + +@pytest.fixture +def gpg_key( + temp_dir: Path, + monkeypatch: pytest.MonkeyPatch, + test_root: Path, +) -> GpgKey: + gpg_home = temp_dir / "gnupghome" + + shutil.copytree(test_root / "data" / "gnupg-home", gpg_home) + monkeypatch.setenv("GNUPGHOME", str(gpg_home)) + + return GpgKey("9A9B2741C8062D3D3DF1302D8B049E262A5CA255", gpg_home) diff --git a/pkgs/clan-cli/tests/test_secrets_cli.py b/pkgs/clan-cli/tests/test_secrets_cli.py index 6fefe6153..ec2229b5c 100644 --- a/pkgs/clan-cli/tests/test_secrets_cli.py +++ b/pkgs/clan-cli/tests/test_secrets_cli.py @@ -2,10 +2,8 @@ import json import logging import os import re -import shutil from collections.abc import Iterator from contextlib import contextmanager -from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -426,12 +424,12 @@ def use_age_key(key: str, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: @contextmanager -def use_gpg_key(key: str, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: +def use_gpg_key(key: GpgKey, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: old_key_file = os.environ.get("SOPS_AGE_KEY_FILE") old_key = os.environ.get("SOPS_AGE_KEY") monkeypatch.delenv("SOPS_AGE_KEY_FILE", raising=False) monkeypatch.delenv("SOPS_AGE_KEY", raising=False) - monkeypatch.setenv("SOPS_PGP_FP", key) + monkeypatch.setenv("SOPS_PGP_FP", key.fingerprint) try: yield finally: @@ -442,25 +440,11 @@ def use_gpg_key(key: str, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.setenv("SOPS_AGE_KEY", old_key) -@pytest.fixture -def gpg_key( - temp_dir: Path, - monkeypatch: pytest.MonkeyPatch, - test_root: Path, -) -> str: - gpg_home = temp_dir / "gnupghome" - - shutil.copytree(test_root / "data" / "gnupg-home", gpg_home) - monkeypatch.setenv("GNUPGHOME", str(gpg_home)) - - return "9A9B2741C8062D3D3DF1302D8B049E262A5CA255" - - def test_secrets( test_flake: FlakeForTest, capture_output: CaptureOutput, monkeypatch: pytest.MonkeyPatch, - gpg_key: str, + gpg_key: GpgKey, age_keys: list["KeyPair"], ) -> None: with capture_output as output: @@ -687,7 +671,7 @@ def test_secrets( "--flake", str(test_flake.path), "--pgp-key", - gpg_key, + gpg_key.fingerprint, "user2", ] ) @@ -754,7 +738,7 @@ def test_secrets_key_generate_gpg( test_flake: FlakeForTest, capture_output: CaptureOutput, monkeypatch: pytest.MonkeyPatch, - gpg_key: str, + gpg_key: GpgKey, ) -> None: with use_gpg_key(gpg_key, monkeypatch): # Make sure clan secrets key generate recognizes @@ -776,7 +760,7 @@ def test_secrets_key_generate_gpg( cli.run(["secrets", "key", "show", "--flake", str(test_flake.path)]) key = json.loads(output.out) assert key["type"] == "pgp" - assert key["publickey"] == gpg_key + assert key["publickey"] == gpg_key.fingerprint # Add testuser with the key that was (not) generated for the clan: cli.run( @@ -787,7 +771,7 @@ def test_secrets_key_generate_gpg( "--flake", str(test_flake.path), "--pgp-key", - gpg_key, + gpg_key.fingerprint, "testuser", ] ) @@ -804,7 +788,7 @@ def test_secrets_key_generate_gpg( ) key = json.loads(output.out) assert key["type"] == "pgp" - assert key["publickey"] == gpg_key + assert key["publickey"] == gpg_key.fingerprint monkeypatch.setenv("SOPS_NIX_SECRET", "secret-value") cli.run(["secrets", "set", "--flake", str(test_flake.path), "secret-name"]) From d3a1b29c6b5a7cce66488e42c8e855478f2caddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 25 Mar 2025 18:09:56 +0100 Subject: [PATCH 6/7] make gnupg a dependency of sops if anything uses a gnupg key, we need the gnupg binary. Sucks a bit, but at least it makes it work everywhere. --- pkgs/clan-cli/clan_cli/secrets/import_sops.py | 2 +- pkgs/clan-cli/clan_cli/secrets/sops.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/secrets/import_sops.py b/pkgs/clan-cli/clan_cli/secrets/import_sops.py index ccf3b90eb..de07fd07e 100644 --- a/pkgs/clan-cli/clan_cli/secrets/import_sops.py +++ b/pkgs/clan-cli/clan_cli/secrets/import_sops.py @@ -30,7 +30,7 @@ def import_sops(args: argparse.Namespace) -> None: if args.input_type: cmd += ["--input-type", args.input_type] cmd += ["--output-type", "json", "--decrypt", args.sops_file] - cmd = nix_shell(["nixpkgs#sops"], cmd) + cmd = nix_shell(["nixpkgs#sops", "nixpkgs#gnupg"], cmd) res = run(cmd, RunOpts(error_msg=f"Could not import sops file {file}")) secrets = json.loads(res.stdout) diff --git a/pkgs/clan-cli/clan_cli/secrets/sops.py b/pkgs/clan-cli/clan_cli/secrets/sops.py index b678c2d19..3f533962c 100644 --- a/pkgs/clan-cli/clan_cli/secrets/sops.py +++ b/pkgs/clan-cli/clan_cli/secrets/sops.py @@ -233,7 +233,7 @@ def sops_run( raise ClanError(msg) sops_cmd.append(str(secret_path)) - cmd = nix_shell(["nixpkgs#sops"], sops_cmd) + cmd = nix_shell(["nixpkgs#sops", "nixpkgs#gnupg"], sops_cmd) opts = ( dataclasses.replace(run_opts, env=environ) if run_opts From 9fcf3edab3ca8aaf898b32382ae67abaf1d2c04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 25 Mar 2025 18:23:05 +0100 Subject: [PATCH 7/7] add missing lock around "flake" "lock" --- pkgs/clan-cli/tests/fixtures_flakes.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/pkgs/clan-cli/tests/fixtures_flakes.py b/pkgs/clan-cli/tests/fixtures_flakes.py index 5d3944b9c..aba71fff0 100644 --- a/pkgs/clan-cli/tests/fixtures_flakes.py +++ b/pkgs/clan-cli/tests/fixtures_flakes.py @@ -294,18 +294,19 @@ def create_flake( if tmp_store := nix_test_store(): nix_options += ["--store", str(tmp_store)] - sp.run( - [ - "nix", - "flake", - "lock", - flake, - "--extra-experimental-features", - "nix-command flakes", - *nix_options, - ], - check=True, - ) + with locked_open(Path(lock_nix), "w"): + sp.run( + [ + "nix", + "flake", + "lock", + flake, + "--extra-experimental-features", + "nix-command flakes", + *nix_options, + ], + check=True, + ) if "/tmp" not in str(os.environ.get("HOME")): log.warning(