feat: initial commit

This commit is contained in:
d9pouces
2026-08-28 16:52:07 +02:00
parent 3e5b65659b
commit 48ec887808
20 changed files with 1911 additions and 2 deletions
+124
View File
@@ -0,0 +1,124 @@
import base64
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.apt import AptModule
SOURCE_CONTENT = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apt": {
"sources": [
{
"name": "interdiode",
"uris": ["http://example.invalid/debian"],
"suites": ["stable"],
"signed-by": "/etc/apt/keyrings/interdiode.asc",
}
],
"keys": [
{
"name": "interdiode",
"content": base64.b64encode(b"fake-key-bytes").decode("ascii"),
}
],
}
},
}
def make_config() -> Configuration:
return Configuration.model_validate(SOURCE_CONTENT)
def test_is_relevant_false_without_src(tmp_path) -> None:
context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
assert AptModule().is_relevant(context) is False # nosec B101
def test_is_relevant_false_without_apt_dir(tmp_path) -> None:
src = tmp_path / "src"
src.mkdir()
context = Context(config=make_config(), dst=tmp_path / "dst", src=src)
assert AptModule().is_relevant(context) is False # nosec B101
def test_is_relevant_true_with_apt_dir(tmp_path) -> None:
src = tmp_path / "src"
(src / "etc/apt").mkdir(parents=True)
context = Context(config=make_config(), dst=tmp_path / "dst", src=src)
assert AptModule().is_relevant(context) is True # nosec B101
def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None:
src = tmp_path / "src"
apt_dir = src / "etc/apt"
sources_list_d = apt_dir / "sources.list.d"
sources_list_d.mkdir(parents=True)
(apt_dir / "sources.list").write_text(
"# already disabled\ndeb http://example.invalid/legacy stable main\n\n",
encoding="utf-8",
)
(sources_list_d / "extra.list").write_text(
"deb http://example.invalid/extra stable main\n", encoding="utf-8"
)
(sources_list_d / "docker.sources").write_text(
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: yes\n"
"\n"
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
"\n\n",
encoding="utf-8",
)
(sources_list_d / "ignored.txt").write_text("not a source file\n", encoding="utf-8")
dst = tmp_path / "dst"
context = Context(config=make_config(), dst=dst, src=src)
AptModule().apply(context)
disabled_top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
assert (
disabled_top_level
== "# already disabled\n# deb http://example.invalid/legacy stable main\n\n"
) # nosec B101
disabled_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(
encoding="utf-8"
) # nosec B101
assert disabled_list == "# deb http://example.invalid/extra stable main\n" # nosec B101
disabled_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
encoding="utf-8"
) # nosec B101
assert disabled_sources == ( # nosec B101
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: no"
"\n\n"
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\nEnabled: no\n"
)
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists() # nosec B101
keyring = (dst / "etc/apt/keyrings/interdiode.asc").read_bytes()
assert keyring == b"fake-key-bytes" # nosec B101
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8"
)
assert configured_source == ( # nosec B101
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
"Signed-By: /etc/apt/keyrings/interdiode.asc\nEnabled: yes\n"
)
def test_apply_without_existing_top_level_sources_list(tmp_path) -> None:
src = tmp_path / "src"
(src / "etc/apt").mkdir(parents=True)
dst = tmp_path / "dst"
context = Context(config=make_config(), dst=dst, src=src)
AptModule().apply(context)
assert not (dst / "etc/apt/sources.list").exists() # nosec B101
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() # nosec B101
+119
View File
@@ -0,0 +1,119 @@
import base64
import subprocess
from unittest.mock import MagicMock
import pytest
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.certificates import CertificatesModule
CERT_CONTENT = base64.b64encode(b"fake-cert-bytes").decode("ascii")
def make_config(with_certificate: bool) -> Configuration:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
}
if with_certificate:
data["configurations"] = {
"certificates": [{"name": "new-root-ca", "content": CERT_CONTENT}]
}
return Configuration.model_validate(data)
def test_is_relevant_true_when_certificates_configured(tmp_path) -> None:
context = Context(config=make_config(True), dst=tmp_path / "dst", src=None)
assert CertificatesModule().is_relevant(context) is True # nosec B101
def test_is_relevant_false_when_no_certificates(tmp_path) -> None:
context = Context(config=make_config(False), dst=tmp_path / "dst", src=None)
assert CertificatesModule().is_relevant(context) is False # nosec B101
def _mock_success(monkeypatch, stderr: str = "") -> MagicMock:
mock_run = MagicMock(
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=stderr
)
)
monkeypatch.setattr("id_configurator.modules.certificates.subprocess.run", mock_run)
return mock_run
def test_apply_writes_certificate_and_calls_update_ca_certificates(
tmp_path, monkeypatch
) -> None:
mock_run = _mock_success(monkeypatch)
dst = tmp_path / "dst"
context = Context(config=make_config(True), dst=dst, src=None)
CertificatesModule().apply(context)
written = dst / "usr/local/share/ca-certificates/new-root-ca.crt"
assert written.read_bytes() == b"fake-cert-bytes" # nosec B101
mock_run.assert_called_once()
args = mock_run.call_args.args[0]
assert args[0] == "update-ca-certificates" # nosec B101
assert "--certsconf" not in args # nosec B101
assert "--certsdir" not in args # nosec B101
def test_apply_preserves_existing_local_certificates_from_src(
tmp_path, monkeypatch
) -> None:
mock_run = _mock_success(monkeypatch, stderr="some warning")
src = tmp_path / "src"
local_certs_dir = src / "usr/local/share/ca-certificates"
local_certs_dir.mkdir(parents=True)
(local_certs_dir / "existing.crt").write_bytes(b"existing-bytes")
(local_certs_dir / "new-root-ca.crt").write_bytes(
b"stale-bytes-should-be-overwritten"
)
(src / "etc/ca-certificates.conf").parent.mkdir(parents=True)
(src / "etc/ca-certificates.conf").write_text("existing.crt\n", encoding="utf-8")
(src / "usr/share/ca-certificates").mkdir(parents=True)
dst = tmp_path / "dst"
context = Context(config=make_config(True), dst=dst, src=src)
CertificatesModule().apply(context)
local_dst = dst / "usr/local/share/ca-certificates"
assert (local_dst / "existing.crt").read_bytes() == b"existing-bytes" # nosec B101
assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" # nosec B101
args = mock_run.call_args.args[0]
assert "--certsconf" in args # nosec B101
assert str(src / "etc/ca-certificates.conf") in args # nosec B101
assert "--certsdir" in args # nosec B101
assert str(src / "usr/share/ca-certificates") in args # nosec B101
def test_update_ca_certificates_raises_when_binary_missing(
tmp_path, monkeypatch
) -> None:
monkeypatch.setattr(
"id_configurator.modules.certificates.subprocess.run",
MagicMock(side_effect=FileNotFoundError()),
)
context = Context(config=make_config(True), dst=tmp_path / "dst", src=None)
with pytest.raises(RuntimeError, match="update-ca-certificates is not available"):
CertificatesModule().apply(context)
def test_update_ca_certificates_raises_on_failure(tmp_path, monkeypatch) -> None:
error = subprocess.CalledProcessError(
returncode=1, cmd=["update-ca-certificates"], stderr="boom"
)
monkeypatch.setattr(
"id_configurator.modules.certificates.subprocess.run",
MagicMock(side_effect=error),
)
context = Context(config=make_config(True), dst=tmp_path / "dst", src=None)
with pytest.raises(RuntimeError, match="update-ca-certificates failed"):
CertificatesModule().apply(context) # nosec B101
+93
View File
@@ -0,0 +1,93 @@
import base64
import json
import subprocess
from unittest.mock import MagicMock
from typer.testing import CliRunner
from id_configurator import cli
runner = CliRunner()
MINIMAL_CONFIG = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
}
CONFIG_WITH_CERT = {
**MINIMAL_CONFIG,
"configurations": {
"certificates": [
{"name": "my-ca", "content": base64.b64encode(b"fake").decode("ascii")}
]
},
}
def write_config(tmp_path, data) -> str:
path = tmp_path / "config.json"
path.write_text(json.dumps(data), encoding="utf-8")
return str(path)
def test_validate_valid_config(tmp_path) -> None:
config_path = write_config(tmp_path, MINIMAL_CONFIG)
result = runner.invoke(cli.app, ["validate", "--config", config_path])
assert result.exit_code == 0 # nosec B101
assert "is valid" in result.stdout # nosec B101
def test_validate_without_config_fails() -> None:
result = runner.invoke(cli.app, ["validate"])
assert result.exit_code != 0 # nosec B101
assert "either --config or --config-env" in result.output # nosec B101
def test_list_modules() -> None:
result = runner.invoke(cli.app, ["list-modules"])
assert result.exit_code == 0 # nosec B101
assert set(result.stdout.splitlines()) == {"apt", "certificates"} # nosec B101
def test_apply_skips_irrelevant_modules(tmp_path, caplog) -> None:
config_path = write_config(tmp_path, MINIMAL_CONFIG)
dst = tmp_path / "dst"
with caplog.at_level("DEBUG", logger="id_configurator"):
result = runner.invoke(
cli.app, ["apply", "--config", config_path, "--dst", str(dst), "-v"]
)
assert result.exit_code == 0 # nosec B101
assert "skipping module apt" in caplog.text # nosec B101
assert "skipping module certificates" in caplog.text # nosec B101
def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None:
mock_run = MagicMock(
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=""
)
)
monkeypatch.setattr("id_configurator.modules.certificates.subprocess.run", mock_run)
config_path = write_config(tmp_path, CONFIG_WITH_CERT)
dst = tmp_path / "dst"
result = runner.invoke(
cli.app, ["apply", "--config", config_path, "--dst", str(dst)]
)
assert result.exit_code == 0 # nosec B101
assert (dst / "usr/local/share/ca-certificates/my-ca.crt").read_bytes() == b"fake" # nosec B101
mock_run.assert_called_once()
def test_main_default_name_calls_app(monkeypatch) -> None:
mock_app = MagicMock()
monkeypatch.setattr(cli, "app", mock_app)
cli.main()
mock_app.assert_called_once()
def test_main_other_name_does_not_call_app(monkeypatch) -> None:
mock_app = MagicMock()
monkeypatch.setattr(cli, "app", mock_app)
cli.main("id_configurator.__main__")
mock_app.assert_not_called()
+163
View File
@@ -0,0 +1,163 @@
import base64
import json
import pytest
from id_configurator.config import (
AptSource,
Configuration,
load_configuration,
parse_configuration,
)
FULL_CONFIG = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"description": "a test configuration",
"configurations": {
"certificates": [{"name": "my-ca", "content": "ZmFrZQ=="}],
"apt": {
"sources": [
{
"name": "interdiode",
"types": ["deb"],
"uris": ["http://example.invalid/debian"],
"suites": ["stable"],
"components": ["main", "contrib"],
"signed-by": "/etc/apt/keyrings/interdiode.asc",
}
],
"keys": [{"name": "interdiode", "content": "ZmFrZQ=="}],
},
"yum": {
"repositories": [
{
"name": "interdiode",
"index-url": "http://example.invalid/yum",
"gpg-url": "http://example.invalid/gpg",
}
]
},
"python": {
"repositories": [
{"name": "interdiode", "index-url": "http://example.invalid/simple"}
]
},
},
}
def test_parse_configuration_from_json() -> None:
configuration = parse_configuration(json.dumps(FULL_CONFIG))
assert isinstance(configuration, Configuration) # nosec B101
assert configuration.organisation == "InterDiode" # nosec B101
assert configuration.configurations.certificates[0].name == "my-ca" # nosec B101
assert (
configuration.configurations.yum.repositories[0].index_url
== "http://example.invalid/yum"
) # nosec B101
assert (
configuration.configurations.yum.repositories[0].gpg_url
== "http://example.invalid/gpg"
) # nosec B101
assert (
configuration.configurations.python.repositories[0].index_url
== "http://example.invalid/simple"
) # nosec B101
assert (
configuration.configurations.apt.sources[0].signed_by
== "/etc/apt/keyrings/interdiode.asc"
) # nosec B101
assert configuration.configurations.apt.keys[0].name == "interdiode" # nosec B101
def test_parse_configuration_from_yaml() -> None:
raw = """
uuid: 12345678-1234-5678-1234-567812345678
organisation: InterDiode
"""
configuration = parse_configuration(raw)
assert configuration.organisation == "InterDiode" # nosec B101
assert configuration.description == "" # nosec B101
assert configuration.configurations.certificates == [] # nosec B101
def test_load_configuration_from_file(tmp_path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(FULL_CONFIG), encoding="utf-8")
configuration = load_configuration(config_file, None)
assert configuration.organisation == "InterDiode" # nosec B101
def test_load_configuration_from_env() -> None:
encoded = base64.b64encode(json.dumps(FULL_CONFIG).encode("utf-8")).decode("ascii")
configuration = load_configuration(None, encoded)
assert configuration.organisation == "InterDiode" # nosec B101
def test_load_configuration_without_source_raises() -> None:
with pytest.raises(ValueError, match="either --config or --config-env"):
load_configuration(None, None)
def test_apt_source_to_deb822_minimal() -> None:
source = AptSource(
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
)
assert (
source.to_deb822()
== "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\nEnabled: yes\n"
) # nosec B101
def test_apt_source_to_deb822_full() -> None:
source = AptSource(
name="interdiode",
types=["deb", "deb-src"],
uris=["http://example.invalid/debian"],
suites=["stable"],
components=["main", "contrib"],
architectures=["amd64"],
languages=["en"],
targets=["deb"],
enabled=False,
trusted=True,
**{
"signed-by": "/etc/apt/keyrings/interdiode.asc",
"by-hash": True,
"allow-insecure": False,
"allow-weak": False,
"allow-downgrade-to-insecure": False,
"check-valid-until": True,
"valid-until-min": "10",
"valid-until-max": "1w",
"check-date": True,
"date-max-future": "1d",
},
pdiffs=True,
description="InterDiode APT repository",
)
rendered = source.to_deb822()
assert rendered.splitlines() == [ # nosec B101
"Types: deb deb-src",
"URIs: http://example.invalid/debian",
"Suites: stable",
"Components: main contrib",
"Architectures: amd64",
"Languages: en",
"Targets: deb",
"Signed-By: /etc/apt/keyrings/interdiode.asc",
"Trusted: yes",
"PDiffs: yes",
"By-Hash: yes",
"Allow-Insecure: no",
"Allow-Weak: no",
"Allow-Downgrade-To-Insecure: no",
"Check-Valid-Until: yes",
"Valid-Until-Min: 10",
"Valid-Until-Max: 1w",
"Check-Date: yes",
"Date-Max-Future: 1d",
"Description: InterDiode APT repository",
"Enabled: no",
]
+85
View File
@@ -0,0 +1,85 @@
import pytest
from id_configurator.config import Configuration
from id_configurator.context import Context
CONFIG = Configuration.model_validate(
{"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"}
)
def make_context(tmp_path, with_src: bool = True) -> Context:
dst = tmp_path / "dst"
dst.mkdir()
src = None
if with_src:
src = tmp_path / "src"
src.mkdir()
return Context(config=CONFIG, dst=dst, src=src)
def test_dst_path_within_destination(tmp_path) -> None:
context = make_context(tmp_path)
resolved = context.dst_path("etc/foo.conf")
assert resolved == context.dst / "etc" / "foo.conf" # nosec B101
def test_dst_path_rejects_escape(tmp_path) -> None:
context = make_context(tmp_path)
with pytest.raises(ValueError, match="refusing to write outside"):
context.dst_path("../escape.conf")
def test_src_path_without_src(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.src_path("etc/foo.conf") is None # nosec B101
def test_src_path_with_src(tmp_path) -> None:
context = make_context(tmp_path)
resolved = context.src_path("etc/foo.conf")
assert resolved == context.src / "etc" / "foo.conf" # nosec B101
def test_write_file_creates_parents_and_content(tmp_path) -> None:
context = make_context(tmp_path)
path = context.write_file("etc/nested/foo.conf", b"hello")
assert path.read_bytes() == b"hello" # nosec B101
assert path.stat().st_mode & 0o777 == 0o644 # nosec B101
def test_read_src_file_without_src(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.read_src_file("etc/foo.conf") is None # nosec B101
def test_read_src_file_missing(tmp_path) -> None:
context = make_context(tmp_path)
assert context.read_src_file("etc/missing.conf") is None # nosec B101
def test_read_src_file_present(tmp_path) -> None:
context = make_context(tmp_path)
(context.src / "foo.conf").write_bytes(b"existing")
assert context.read_src_file("foo.conf") == b"existing" # nosec B101
def test_iter_src_dir_without_src(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.iter_src_dir("some/dir") == [] # nosec B101
def test_iter_src_dir_missing_dir(tmp_path) -> None:
context = make_context(tmp_path)
assert context.iter_src_dir("missing-dir") == [] # nosec B101
def test_iter_src_dir_lists_only_files(tmp_path) -> None:
context = make_context(tmp_path)
target = context.src / "certs"
target.mkdir()
(target / "a.crt").write_bytes(b"a")
(target / "b.crt").write_bytes(b"b")
(target / "subdir").mkdir()
result = context.iter_src_dir("certs")
assert [entry.name for entry in result] == ["a.crt", "b.crt"] # nosec B101
+19
View File
@@ -0,0 +1,19 @@
from id_configurator.modules import base as base_module
from id_configurator.modules.base import Module, all_modules, register
def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
monkeypatch.setattr(base_module, "_REGISTRY", [])
class DummyModule(Module):
name = "dummy"
def is_relevant(self, context) -> bool:
return True
def apply(self, context) -> None:
pass
register(DummyModule)
assert all_modules() == [DummyModule] # nosec B101
+20
View File
@@ -0,0 +1,20 @@
import importlib
import sys
from unittest.mock import MagicMock
def test_package_exposes_app_and_main() -> None:
import id_configurator
assert id_configurator.main is id_configurator.cli.main # nosec B101
assert id_configurator.app is id_configurator.cli.app # nosec B101
def test_dunder_main_module_does_not_launch_app_when_imported(monkeypatch) -> None:
mock_app = MagicMock()
monkeypatch.setattr("id_configurator.cli.app", mock_app)
sys.modules.pop("id_configurator.__main__", None)
importlib.import_module("id_configurator.__main__")
mock_app.assert_not_called()