feat: update version to 2026.9.7 in pyproject.toml

Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
d9pouces
2026-09-09 22:20:16 +02:00
parent 380e976f76
commit 4409c38579
21 changed files with 252 additions and 72 deletions
+23 -6
View File
@@ -27,7 +27,16 @@ def _yes_no(value: bool) -> str:
class AptSource(BaseModel): class AptSource(BaseModel):
"""A single APT source, in the DEB822 format (see sources.list(5)).""" """A single APT source, in the DEB822 format (see sources.list(5)).
Two ways to tell APT which key to trust this source with:
- `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key.
The `apt` module dearmors it into a dedicated keyring and writes that
keyring's path as the rendered `Signed-By` field.
- `signed_by_file`: the *path* to a keyring already expected to exist on
the target system, written to the rendered `Signed-By` field verbatim.
If both are set, `signed_by` takes precedence.
"""
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
@@ -42,6 +51,7 @@ class AptSource(BaseModel):
enabled: bool = True enabled: bool = True
trusted: bool | None = None trusted: bool | None = None
signed_by: str | None = Field(default=None, alias="signed-by") signed_by: str | None = Field(default=None, alias="signed-by")
signed_by_file: str | None = Field(default=None, alias="signed-by-file")
pdiffs: bool | None = None pdiffs: bool | None = None
by_hash: bool | None = Field(default=None, alias="by-hash") by_hash: bool | None = Field(default=None, alias="by-hash")
allow_insecure: bool | None = Field(default=None, alias="allow-insecure") allow_insecure: bool | None = Field(default=None, alias="allow-insecure")
@@ -56,8 +66,15 @@ class AptSource(BaseModel):
date_max_future: str | None = Field(default=None, alias="date-max-future") date_max_future: str | None = Field(default=None, alias="date-max-future")
description: str | None = None description: str | None = None
def to_deb822(self) -> str: def to_deb822(self, signed_by_path: str | None = None) -> str:
"""Render this source as a DEB822 stanza (as used in /etc/apt/sources.list.d/*.sources).""" """Render this source as a DEB822 stanza (as used in
/etc/apt/sources.list.d/*.sources).
`signed_by_path` is the value actually written as the `Signed-By`
field, if given - resolved by the caller (the `apt` module) from
`self.signed_by`/`self.signed_by_file`, neither of which is ever
rendered here directly.
"""
fields = [ fields = [
("Types", " ".join(self.types)), ("Types", " ".join(self.types)),
("URIs", " ".join(self.uris)), ("URIs", " ".join(self.uris)),
@@ -71,8 +88,8 @@ class AptSource(BaseModel):
fields.append(("Languages", " ".join(self.languages))) fields.append(("Languages", " ".join(self.languages)))
if self.targets: if self.targets:
fields.append(("Targets", " ".join(self.targets))) fields.append(("Targets", " ".join(self.targets)))
if self.signed_by: if signed_by_path:
fields.append(("Signed-By", self.signed_by)) fields.append(("Signed-By", signed_by_path))
if self.trusted is not None: if self.trusted is not None:
fields.append(("Trusted", _yes_no(self.trusted))) fields.append(("Trusted", _yes_no(self.trusted)))
if self.pdiffs is not None: if self.pdiffs is not None:
@@ -108,12 +125,12 @@ class AptSource(BaseModel):
class AptConfig(BaseModel): class AptConfig(BaseModel):
sources: list[AptSource] = Field(default_factory=list) sources: list[AptSource] = Field(default_factory=list)
keys: list[NamedContent] = Field(default_factory=list)
class ApkRepository(BaseModel): class ApkRepository(BaseModel):
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community.""" """An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community."""
name: str
url: str url: str
key: str | None = None key: str | None = None
+1 -1
View File
@@ -71,7 +71,7 @@ class ApkModule(Module):
lines = [] lines = []
for repository in context.config.configurations.apk.repositories: for repository in context.config.configurations.apk.repositories:
lines.append(repository.url) lines.append(repository.url)
logger.info("added apk repository %s", repository.url) logger.info("added apk repository %s", repository.name)
if repository.key: if repository.key:
filename = _key_filename(repository.key.encode("utf-8")) filename = _key_filename(repository.key.encode("utf-8"))
context.write_file( context.write_file(
+51 -11
View File
@@ -5,13 +5,29 @@ one-line `.list` files or DEB822 `.sources` files, whether at the top level
or under `sources.list.d/`) is copied into `<dst>/etc/apt`, disabled so it no or under `sources.list.d/`) is copied into `<dst>/etc/apt`, disabled so it no
longer takes effect, and the sources declared in the configuration are added longer takes effect, and the sources declared in the configuration are added
as new, enabled `.sources` files. as new, enabled `.sources` files.
Each source can be trusted two ways: `signed_by` holds the ASCII-armored
content of a GPG public key, which is converted with `gpg --dearmor` into a
binary keyring dedicated to this one source, written to
`/etc/apt/keyrings/<name>.gpg` - it is that path, not the key's own content,
that ends up in the rendered `.sources` file's `Signed-By` field.
`signed_by_file` instead names a keyring already expected to exist on the
target system, used verbatim as `Signed-By` (no file is written for it); if
both are given, `signed_by` takes precedence. `gpg` is only used here for
the armored-to-binary format conversion; the actual signature verification
of the repository itself is left to APT when it fetches the repository's
Release/InRelease file.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import shutil
import subprocess
import tempfile
from pathlib import Path from pathlib import Path
from id_configurator.config import AptSource
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.base import Module, register from id_configurator.modules.base import Module, register
@@ -43,6 +59,28 @@ def _disable_deb822_content(content: str) -> str:
return "\n\n".join(disabled_stanzas) + "\n" return "\n\n".join(disabled_stanzas) + "\n"
def _dearmor_gpg_key(name: str, content: bytes) -> bytes:
"""Convert an ASCII-armored GPG public key into apt's binary keyring format."""
with tempfile.TemporaryDirectory() as gnupg_home:
path = shutil.which("gpg")
if path is not None:
result = subprocess.run(
[path, "--homedir", gnupg_home, "--batch", "--dearmor"],
input=content,
capture_output=True,
check=False,
)
else:
raise RuntimeError(
"gpg is not available; the GNUPG package must be installed in the environment running id-configurator"
)
if result.returncode != 0:
raise RuntimeError(
f"invalid GPG key for apt source {name}: {result.stderr.decode('utf-8', 'replace')}"
)
return result.stdout
@register @register
class AptModule(Module): class AptModule(Module):
name = "apt" name = "apt"
@@ -59,7 +97,6 @@ class AptModule(Module):
def apply(self, context: Context) -> None: def apply(self, context: Context) -> None:
self._copy_existing_disabled(context) self._copy_existing_disabled(context)
self._write_configured_keys(context)
self._write_configured_sources(context) self._write_configured_sources(context)
@staticmethod @staticmethod
@@ -82,19 +119,22 @@ class AptModule(Module):
) )
logger.info("disabled existing apt source %s", entry.name) logger.info("disabled existing apt source %s", entry.name)
@staticmethod def _resolve_signed_by(self, context: Context, source: AptSource) -> str | None:
def _write_configured_keys(context: Context) -> None: if source.signed_by:
for key in context.config.configurations.apt.keys: keyring = _dearmor_gpg_key(source.name, source.signed_by.encode("utf-8"))
context.write_file( relative = f"{_KEYRINGS_DIR}/{source.name}.gpg"
f"{_KEYRINGS_DIR}/{key.name}.asc", key.content.encode("utf-8") context.write_file(relative, keyring)
) logger.info("installed apt keyring for source %s", source.name)
logger.info("installed apt keyring %s", key.name) return f"/{relative}"
if source.signed_by_file:
return source.signed_by_file
return None
@staticmethod def _write_configured_sources(self, context: Context) -> None:
def _write_configured_sources(context: Context) -> None:
for source in context.config.configurations.apt.sources: for source in context.config.configurations.apt.sources:
signed_by_path = self._resolve_signed_by(context, source)
context.write_file( context.write_file(
f"{_SOURCES_LIST_D}/{source.name}.sources", f"{_SOURCES_LIST_D}/{source.name}.sources",
source.to_deb822().encode("utf-8"), source.to_deb822(signed_by_path).encode("utf-8"),
) )
logger.info("added apt source %s", source.name) logger.info("added apt source %s", source.name)
+13 -21
View File
@@ -123,7 +123,18 @@ options:
description: DEB822 "Trusted" field. description: DEB822 "Trusted" field.
signed_by: signed_by:
type: str type: str
description: DEB822 "Signed-By" field. description:
- ASCII-armored content of a GPG public key to trust this
source with. The `apt` module dearmors it into a dedicated
keyring and writes that keyring's path as the rendered
Signed-By field. Takes precedence over signed_by_file if
both are set.
signed_by_file:
type: str
description:
- Path to a keyring already expected to exist on the target
system, used verbatim as the rendered Signed-By field.
Ignored if signed_by is also set.
pdiffs: pdiffs:
type: bool type: bool
description: DEB822 "PDiffs" field. description: DEB822 "PDiffs" field.
@@ -157,20 +168,6 @@ options:
description: description:
type: str type: str
description: DEB822 "Description" field. description: DEB822 "Description" field.
keys:
type: list
elements: dict
default: []
description: GPG keys trusted for the sources above (NamedContent).
suboptions:
name:
type: str
required: true
description: A short, unique name for this entry.
content:
type: str
required: true
description: PEM-encoded/ASCII-armored key content.
apk: apk:
type: dict type: dict
default: {} default: {}
@@ -483,6 +480,7 @@ ARGUMENT_SPEC = {
"enabled": {"type": "bool", "default": True}, "enabled": {"type": "bool", "default": True},
"trusted": {"type": "bool"}, "trusted": {"type": "bool"},
"signed_by": {"type": "str"}, "signed_by": {"type": "str"},
"signed_by_file": {"type": "str"},
"pdiffs": {"type": "bool"}, "pdiffs": {"type": "bool"},
"by_hash": {"type": "bool"}, "by_hash": {"type": "bool"},
"allow_insecure": {"type": "bool"}, "allow_insecure": {"type": "bool"},
@@ -496,12 +494,6 @@ ARGUMENT_SPEC = {
"description": {"type": "str"}, "description": {"type": "str"},
}, },
}, },
"keys": {
"type": "list",
"elements": "dict",
"default": [],
"options": dict(_NAMED_CONTENT_OPTIONS),
},
}, },
}, },
"apk": { "apk": {
+2 -2
View File
@@ -8,10 +8,10 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: repositories:
- url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ - name: demo
url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/
key: | key: |
{{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }} {{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }}
yum: yum:
+2 -2
View File
@@ -13,8 +13,8 @@ configurations:
uris: uris:
- https://host.docker.internal:{{ 'APT_REPO_PORT' | env }}/ - https://host.docker.internal:{{ 'APT_REPO_PORT' | env }}/
suites: ["./"] suites: ["./"]
trusted: true signed-by: |
keys: [] {{ 'dockerfiles/repos/apt-signing-key.pub.asc' | file | indent(8, first=true) }}
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
+2
View File
@@ -1,5 +1,7 @@
FROM debian:trixie-slim AS target FROM debian:trixie-slim AS target
FROM interdiode/id-configurator:latest AS configuration FROM interdiode/id-configurator:latest AS configuration
RUN apt-get update && apt-get install -y --no-install-recommends gnupg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=target / /src/ COPY --from=target / /src/
COPY --from=generated-config Apt.yaml /config.yaml COPY --from=generated-config Apt.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
+15 -2
View File
@@ -1,12 +1,18 @@
# Minimal flat-format APT repository serving one hand-built .deb package over # Minimal flat-format APT repository serving one hand-built .deb package over
# TLS, used by tox-docker to exercise the `apt` module integration test # TLS, used by tox-docker to exercise the `apt` module integration test
# (tests/configs/Apt.yaml.tmpl, tests/dockerfiles/Apt.Dockerfile). # (tests/configs/Apt.yaml.j2, tests/dockerfiles/Apt.Dockerfile). Its
# InRelease file is genuinely signed with the dedicated key pair
# apt-signing-key.key.asc / apt-signing-key.pub.asc, whose public half is
# what tests/configs/Apt.yaml.j2 embeds as the configured source's
# `signed-by`: the `apt` module dearmors it into a keyring, and APT itself
# verifies the InRelease signature against that keyring on `apt-get update`
# - no `Trusted: yes` bypass involved.
# #
# Built by tox-docker, whose build context is this directory (not the repo # Built by tox-docker, whose build context is this directory (not the repo
# root) - every COPY source below must live under tests/dockerfiles/repos/. # root) - every COPY source below must live under tests/dockerfiles/repos/.
FROM debian:trixie-slim AS packagebuild FROM debian:trixie-slim AS packagebuild
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends dpkg-dev \ && apt-get install -y --no-install-recommends dpkg-dev apt-utils gnupg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN set -eux; \ RUN set -eux; \
pkg=idconfiguratortestpkg; \ pkg=idconfiguratortestpkg; \
@@ -18,6 +24,13 @@ RUN set -eux; \
WORKDIR /srv WORKDIR /srv
RUN dpkg-scanpackages --multiversion . > Packages \ RUN dpkg-scanpackages --multiversion . > Packages \
&& gzip -9c Packages > Packages.gz && gzip -9c Packages > Packages.gz
COPY apt-signing-key.key.asc /tmp/apt-signing-key.key.asc
RUN gnupg_home="$(mktemp -d)" \
&& gpg --homedir "$gnupg_home" --batch --import /tmp/apt-signing-key.key.asc \
&& key_id="$(gpg --homedir "$gnupg_home" --list-secret-keys --with-colons | awk -F: '/^sec/{print $5; exit}')" \
&& apt-ftparchive release . > Release \
&& gpg --homedir "$gnupg_home" --batch --yes --pinentry-mode loopback --default-key "$key_id" --clearsign -o InRelease Release \
&& rm -rf "$gnupg_home" /tmp/apt-signing-key.key.asc
FROM nginx:alpine FROM nginx:alpine
COPY --from=packagebuild /srv /srv COPY --from=packagebuild /srv /srv
+2 -1
View File
@@ -14,10 +14,11 @@ CONFIG_DATA = {
"apk": { "apk": {
"repositories": [ "repositories": [
{ {
"name": "main",
"url": "https://example.invalid/v3.19/main", "url": "https://example.invalid/v3.19/main",
"key": KEY_BYTES, "key": KEY_BYTES,
}, },
{"url": "https://example.invalid/v3.19/community"}, {"name": "community", "url": "https://example.invalid/v3.19/community"},
] ]
} }
}, },
+140 -12
View File
@@ -1,7 +1,15 @@
import subprocess
from unittest.mock import MagicMock
import pytest
from id_configurator.config import Configuration from id_configurator.config import Configuration
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.apt import AptModule from id_configurator.modules.apt import AptModule
KEY_CONTENT = "fake-armored-gpg-key"
KEYRING_BYTES = b"fake-dearmored-keyring-bytes"
SOURCE_CONTENT = { SOURCE_CONTENT = {
"uuid": "12345678-1234-5678-1234-567812345678", "uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode", "organisation": "InterDiode",
@@ -12,13 +20,7 @@ SOURCE_CONTENT = {
"name": "interdiode", "name": "interdiode",
"uris": ["http://example.invalid/debian"], "uris": ["http://example.invalid/debian"],
"suites": ["stable"], "suites": ["stable"],
"signed-by": "/etc/apt/keyrings/interdiode.asc", "signed-by": KEY_CONTENT,
}
],
"keys": [
{
"name": "interdiode",
"content": "fake-key-bytes",
} }
], ],
} }
@@ -30,6 +32,36 @@ def make_config() -> Configuration:
return Configuration.model_validate(SOURCE_CONTENT) return Configuration.model_validate(SOURCE_CONTENT)
def make_config_with(source_extra: dict) -> Configuration:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apt": {
"sources": [
{
"name": "interdiode",
"uris": ["http://example.invalid/debian"],
"suites": ["stable"],
**source_extra,
}
],
}
},
}
return Configuration.model_validate(data)
def _mock_gpg_dearmor_success(monkeypatch, output: bytes = KEYRING_BYTES) -> MagicMock:
mock_run = MagicMock(
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout=output, stderr=b""
)
)
monkeypatch.setattr("id_configurator.modules.apt.subprocess.run", mock_run)
return mock_run
def test_is_relevant_false_without_src(tmp_path) -> None: def test_is_relevant_false_without_src(tmp_path) -> None:
context = Context(config=make_config(), dst=tmp_path / "dst", src=None) context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
assert AptModule().is_relevant(context) is False assert AptModule().is_relevant(context) is False
@@ -49,7 +81,10 @@ def test_is_relevant_true_with_apt_dir(tmp_path) -> None:
assert AptModule().is_relevant(context) is True assert AptModule().is_relevant(context) is True
def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None: def test_apply_disables_existing_sources_and_adds_configured_ones(
tmp_path, monkeypatch
) -> None:
mock_run = _mock_gpg_dearmor_success(monkeypatch)
src = tmp_path / "src" src = tmp_path / "src"
apt_dir = src / "etc/apt" apt_dir = src / "etc/apt"
sources_list_d = apt_dir / "sources.list.d" sources_list_d = apt_dir / "sources.list.d"
@@ -98,19 +133,22 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists() assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
keyring = (dst / "etc/apt/keyrings/interdiode.asc").read_bytes() keyring = (dst / "etc/apt/keyrings/interdiode.gpg").read_bytes()
assert keyring == b"fake-key-bytes" assert keyring == KEYRING_BYTES
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["input"] == KEY_CONTENT.encode("utf-8")
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8" encoding="utf-8"
) )
assert configured_source == ( assert configured_source == (
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n" "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
"Signed-By: /etc/apt/keyrings/interdiode.asc\nEnabled: yes\n" "Signed-By: /etc/apt/keyrings/interdiode.gpg\nEnabled: yes\n"
) )
def test_apply_without_existing_top_level_sources_list(tmp_path) -> None: def test_apply_without_existing_top_level_sources_list(tmp_path, monkeypatch) -> None:
_mock_gpg_dearmor_success(monkeypatch)
src = tmp_path / "src" src = tmp_path / "src"
(src / "etc/apt").mkdir(parents=True) (src / "etc/apt").mkdir(parents=True)
dst = tmp_path / "dst" dst = tmp_path / "dst"
@@ -120,3 +158,93 @@ def test_apply_without_existing_top_level_sources_list(tmp_path) -> None:
assert not (dst / "etc/apt/sources.list").exists() assert not (dst / "etc/apt/sources.list").exists()
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
def test_apply_uses_signed_by_file_when_no_key_content(tmp_path, monkeypatch) -> None:
mock_run = _mock_gpg_dearmor_success(monkeypatch)
dst = tmp_path / "dst"
context = Context(
config=make_config_with({"signed-by-file": "/etc/apt/keyrings/existing.gpg"}),
dst=dst,
src=None,
)
AptModule().apply(context)
mock_run.assert_not_called()
assert not (dst / "etc/apt/keyrings").exists()
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8"
)
assert configured_source == (
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
"Signed-By: /etc/apt/keyrings/existing.gpg\nEnabled: yes\n"
)
def test_apply_signed_by_takes_precedence_over_signed_by_file(
tmp_path, monkeypatch
) -> None:
mock_run = _mock_gpg_dearmor_success(monkeypatch)
dst = tmp_path / "dst"
context = Context(
config=make_config_with(
{
"signed-by": KEY_CONTENT,
"signed-by-file": "/etc/apt/keyrings/existing.gpg",
}
),
dst=dst,
src=None,
)
AptModule().apply(context)
mock_run.assert_called_once()
assert (dst / "etc/apt/keyrings/interdiode.gpg").read_bytes() == KEYRING_BYTES
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8"
)
assert "Signed-By: /etc/apt/keyrings/interdiode.gpg\n" in configured_source
def test_apply_without_signed_by_writes_no_keyring_or_field(
tmp_path, monkeypatch
) -> None:
mock_run = _mock_gpg_dearmor_success(monkeypatch)
dst = tmp_path / "dst"
context = Context(config=make_config_with({}), dst=dst, src=None)
AptModule().apply(context)
mock_run.assert_not_called()
assert not (dst / "etc/apt/keyrings").exists()
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8"
)
assert configured_source == (
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\nEnabled: yes\n"
)
def test_resolve_signed_by_raises_when_gpg_not_on_path(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("id_configurator.modules.apt.shutil.which", lambda _: None)
dst = tmp_path / "dst"
context = Context(config=make_config(), dst=dst, src=None)
with pytest.raises(RuntimeError, match="gpg is not available"):
AptModule().apply(context)
def test_resolve_signed_by_raises_on_invalid_key(tmp_path, monkeypatch) -> None:
mock_run = MagicMock(
return_value=subprocess.CompletedProcess(
args=[], returncode=2, stdout=b"", stderr=b"gpg: no valid data found"
)
)
monkeypatch.setattr("id_configurator.modules.apt.subprocess.run", mock_run)
dst = tmp_path / "dst"
context = Context(config=make_config(), dst=dst, src=None)
with pytest.raises(RuntimeError, match="invalid GPG key for apt source interdiode"):
AptModule().apply(context)
+1 -4
View File
@@ -34,7 +34,6 @@ FULL_CONFIG = {
"signed-by": "/etc/apt/keyrings/interdiode.asc", "signed-by": "/etc/apt/keyrings/interdiode.asc",
} }
], ],
"keys": [{"name": "interdiode", "content": "ZmFrZQ=="}],
}, },
"yum": { "yum": {
"repositories": [ "repositories": [
@@ -76,7 +75,6 @@ def test_parse_configuration_from_json() -> None:
configuration.configurations.apt.sources[0].signed_by configuration.configurations.apt.sources[0].signed_by
== "/etc/apt/keyrings/interdiode.asc" == "/etc/apt/keyrings/interdiode.asc"
) )
assert configuration.configurations.apt.keys[0].name == "interdiode"
assert configuration.configurations.yum.keys[0].name == "interdiode" assert configuration.configurations.yum.keys[0].name == "interdiode"
@@ -191,7 +189,6 @@ def test_apt_source_to_deb822_full() -> None:
enabled=False, enabled=False,
trusted=True, trusted=True,
**{ **{
"signed-by": "/etc/apt/keyrings/interdiode.asc",
"by-hash": True, "by-hash": True,
"allow-insecure": False, "allow-insecure": False,
"allow-weak": False, "allow-weak": False,
@@ -205,7 +202,7 @@ def test_apt_source_to_deb822_full() -> None:
pdiffs=True, pdiffs=True,
description="InterDiode APT repository", description="InterDiode APT repository",
) )
rendered = source.to_deb822() rendered = source.to_deb822(signed_by_path="/etc/apt/keyrings/interdiode.asc")
assert rendered.splitlines() == [ assert rendered.splitlines() == [
"Types: deb deb-src", "Types: deb deb-src",
"URIs: http://example.invalid/debian", "URIs: http://example.invalid/debian",