Skip to content

Commit 88b85a8

Browse files
committed
fix: correct apt.key removal, idempotency and test fixtures
- Rename _sanitize_apt_keyring_name → _sanitize_keyring_part (cleaner name) - Remove dead 'dest' param from _derive_dest_from_src_and_keyids - Expose APT_KEYRING_DIRS constant at module level - Convert apt.key docstring to pyinfra '+' format - Fix present=False + keyid without dest: delegate directly to gpg.key._inner(working_dirs=APT_KEYRING_DIRS) instead of resolving a wrong default dest - Fix AptKeys.command() signature: directories=None with APT defaults as fallback, keeping LSP-compatible interface - Add collision comment in AptKeys.process() (last keyring wins on same ID) - Remove dead apt.AptKeys/gpg.GpgKey facts from test fixtures (apt.key delegates to gpg.key._inner, never calls those facts directly) - Fix _tempfile_ path placeholder in keyserver fixtures - Rewrite add_exists/add_keyserver_exists as true idempotent fixtures (file/keys already present → commands: []) - Delete add_no_gpg and add_keyserver_multiple_partial (untestable or superseded) - Add remove_file, remove_file_missing, remove_by_keyid fixtures
1 parent 302e100 commit 88b85a8

15 files changed

Lines changed: 160 additions & 217 deletions

src/pyinfra/facts/apt.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -382,28 +382,31 @@ class AptKeys(GpgKeyrings):
382382
.. code:: python
383383
384384
{
385-
"KEY-ID": {
385+
"3B4FE6ACC0B21F32": {
386+
"validity": "-",
386387
"length": 4096,
387-
"uid": "Oxygem <hello@oxygem.com>"
388+
"subkeys": {},
389+
"fingerprint": "790BC7277767219C42C86F933B4FE6ACC0B21F32",
390+
"uid_hash": "B7A02867A0C1D32B594B36C00E20C8C57E397748",
391+
"uid": "Ubuntu Archive Automatic Signing Key (2012) <ftpmaster@ubuntu.com>"
388392
},
389393
}
390394
"""
391395

392396
@override
393-
def command(self, directories=None) -> str:
394-
# Default to APT-specific directories if none specified
395-
if directories is None:
396-
directories = ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
397-
398-
return super().command(directories)
397+
def command(self, directories: list[str] | None = None) -> str:
398+
return super().command(
399+
directories or ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
400+
)
399401

400402
@override
401403
def process(self, output):
402404
# Get the full keyring structure from parent
403405
keyrings_data = super().process(output)
404406

405-
# Flatten to match traditional AptKeys format (just key_id -> key_details)
406-
flattened_keys = {}
407+
# Flatten to match the traditional AptKeys format: {key_id: key_details}
408+
# Note: if the same key ID appears in multiple keyring files, the last one wins.
409+
flattened_keys: dict = {}
407410
for keyring_path, keyring_info in keyrings_data.items():
408411
if "keys" in keyring_info:
409412
flattened_keys.update(keyring_info["keys"])

src/pyinfra/operations/apt.py

Lines changed: 47 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,21 @@
1010

1111
from pyinfra import host
1212
from pyinfra.api import operation
13-
from pyinfra.api.exceptions import OperationError
1413
from pyinfra.facts.apt import (
15-
AptKeys,
1614
AptSources,
1715
SimulateOperationWillChange,
1816
noninteractive_apt,
1917
parse_apt_repo,
2018
)
2119
from pyinfra.facts.deb import DebPackage, DebPackages
2220
from pyinfra.facts.files import File
23-
from pyinfra.facts.gpg import GpgKey, GpgKeyrings
2421
from pyinfra.facts.server import Date
2522
from pyinfra.operations import files, gpg
2623

2724
from .util.packaging import ensure_packages
2825

2926
APT_UPDATE_FILENAME = "/var/lib/apt/periodic/update-success-stamp"
27+
APT_KEYRING_DIRS = ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
3028

3129

3230
def _simulate_then_perform(command: str):
@@ -47,82 +45,47 @@ def _simulate_then_perform(command: str):
4745
yield noninteractive_apt(command)
4846

4947

50-
def _sanitize_apt_keyring_name(name: str) -> str:
48+
def _sanitize_keyring_part(name: str) -> str:
5149
"""
52-
Produce a filesystem-friendly name from an URL host/basename or a local filename.
50+
Produce a filesystem-friendly segment from a URL host, basename, or key ID.
5351
"""
5452
name = name.strip().lower()
5553
name = re.sub(r"[^\w.-]+", "_", name)
5654
name = re.sub(r"_+", "_", name).strip("_.")
5755
return name or "apt-keyring"
5856

5957

60-
def _derive_dest_from_src_and_keyids(
61-
src: str | None, keyids: list[str] | None, dest: str | None
62-
) -> str:
58+
def _derive_dest_from_src_and_keyids(src: str | None, keyids: list[str] | None) -> str:
6359
"""
64-
Compute a stable destination path in /etc/apt/keyrings/.
60+
Compute a stable destination path in /etc/apt/keyrings/ from a source or key IDs.
61+
6562
Priority:
66-
1) explicit dest if provided
67-
2) from src (URL host + basename, or local basename)
68-
3) from keyids (joined)
69-
4) fallback "apt-keyring.gpg"
63+
1) from src (URL domain + basename, or local basename)
64+
2) from keyids (joined)
65+
3) fallback "apt-keyring.gpg"
7066
"""
71-
if dest:
72-
# Ensure it ends with .gpg and is absolute under /etc/apt/keyrings
73-
if not dest.endswith(".gpg"):
74-
dest += ".gpg"
75-
if not dest.startswith("/"):
76-
dest = f"/etc/apt/keyrings/{dest}"
77-
return dest
78-
7967
base = None
8068
if src:
8169
parsed = urlparse(src)
8270
if parsed.scheme and parsed.netloc:
83-
host_name = _sanitize_apt_keyring_name(parsed.netloc.replace(":", "_"))
84-
bn = _sanitize_apt_keyring_name(
71+
domain_part = _sanitize_keyring_part(parsed.netloc.replace(":", "_"))
72+
bn = _sanitize_keyring_part(
8573
(parsed.path.rsplit("/", 1)[-1] or "key").replace(".asc", "").replace(".gpg", "")
8674
)
87-
base = f"{host_name}-{bn}"
75+
base = f"{domain_part}-{bn}"
8876
else:
89-
bn = _sanitize_apt_keyring_name(
77+
bn = _sanitize_keyring_part(
9078
src.rsplit("/", 1)[-1].replace(".asc", "").replace(".gpg", "")
9179
)
9280
base = bn or "key"
9381
elif keyids:
94-
base = "keyserver-" + _sanitize_apt_keyring_name("-".join(keyids))
82+
base = "keyserver-" + _sanitize_keyring_part("-".join(keyids))
9583
else:
9684
base = "apt-keyring"
9785

9886
return f"/etc/apt/keyrings/{base}.gpg"
9987

10088

101-
def _get_apt_keys_comprehensive() -> dict[str, str]:
102-
"""
103-
Get all GPG keys available in APT directories using the GpgKeyrings fact.
104-
This provides more comprehensive coverage than AptKeys fact.
105-
Falls back gracefully if GpgKeyrings data is not available.
106-
107-
Returns:
108-
dict: Key ID -> keyring file path mapping
109-
"""
110-
try:
111-
apt_directories = ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
112-
keyrings_info = host.get_fact(GpgKeyrings, directories=apt_directories)
113-
114-
all_keys = {}
115-
for keyring_path, keyring_data in keyrings_info.items():
116-
keys = keyring_data.get("keys", {})
117-
for key_id in keys.keys():
118-
all_keys[key_id] = keyring_path
119-
120-
return all_keys
121-
except (KeyError, AttributeError):
122-
# Fallback to empty dict if GpgKeyrings fact is not available (e.g., in tests)
123-
return {}
124-
125-
12689
@operation()
12790
def key(
12891
src: str | None = None,
@@ -132,26 +95,27 @@ def key(
13295
present: bool = True,
13396
):
13497
"""
135-
Add or remove apt GPG keys using modern keyring management.
98+
Add or remove APT GPG keys using modern keyring management (no ``apt-key``).
13699
137-
This operation manages GPG keys for APT repos without using the deprecated apt-key command.
138-
Keys are stored in /etc/apt/keyrings/ and can be referenced in source lists via signed-by=.
100+
Keys are written to ``/etc/apt/keyrings/`` and can be referenced in source entries
101+
via ``signed-by=``. The destination filename is derived automatically from the source
102+
URL or key IDs when not specified explicitly.
139103
140-
Args:
141-
src: filename or URL to a key (ASCII .asc or binary .gpg)
142-
keyserver: keyserver URL for fetching keys by ID
143-
keyid: key ID or list of key IDs (required with keyserver, optional for removal)
144-
dest: optional keyring path ('.gpg' will be enforced, defaults under /etc/apt/keyrings)
145-
present: whether the key should be present (True) or absent (False)
104+
+ src: filename or URL to a key (``.asc`` ASCII-armored or binary ``.gpg``)
105+
+ keyserver: keyserver URL for fetching keys by ID
106+
+ keyid: key ID or list of key IDs (required with ``keyserver``, optional for removal)
107+
+ dest: destination filename or absolute path — ``.gpg`` extension is enforced;
108+
relative names are resolved under ``/etc/apt/keyrings/``
109+
+ present: whether the key should be present (default: ``True``) or removed
146110
147-
Behavior:
148-
- Installation: Idempotent via AptKeys - if key IDs are already present, nothing changes
149-
- Removal: Uses GpgKeyrings fact to find and remove keys from APT directories
150-
- If src is ASCII (.asc), it will be dearmored; if binary (.gpg), it's copied as-is
151-
- Keyserver flow uses temporary GNUPGHOME, then exports to destination keyring
111+
.. note::
112+
ASCII-armored keys (``.asc``) are automatically dearmored on installation.
113+
Keyserver fetches use a temporary ``GNUPGHOME`` and export binary keyrings.
114+
Removal without ``keyid`` deletes the whole keyring file; with ``keyid`` it
115+
removes individual keys and prunes empty files.
152116
153117
.. warning::
154-
``apt-key`` is deprecated in Debian. This operation follows modern keyring management:
118+
``apt-key`` is deprecated in Debian. This operation follows the modern approach:
155119
https://wiki.debian.org/DebianRepository/UseThirdParty
156120
157121
**Examples:**
@@ -187,64 +151,34 @@ def key(
187151
)
188152
"""
189153

190-
# Handle removal operations using the GPG infrastructure
191-
if present is False:
192-
# Use the GPG operation for removal, but restrict to APT directories
193-
apt_working_dirs = ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
154+
# Special case: remove by key ID without explicit destination → search all APT keyring dirs
155+
if not present and keyid and not dest and not src and not keyserver:
194156
yield from gpg.key._inner(
195-
dest=dest,
196157
keyid=keyid,
197158
present=False,
198-
working_dirs=apt_working_dirs,
159+
working_dirs=APT_KEYRING_DIRS,
199160
)
200161
return
201162

202-
# Installation logic (existing code)
203-
# Get comprehensive view of all keys in APT directories
204-
existing_keys_comprehensive = _get_apt_keys_comprehensive()
205-
# Also get the legacy AptKeys fact for compatibility
206-
existing_keys = host.get_fact(AptKeys)
207-
208-
# Combine both sources of key information for complete coverage
209-
all_available_keys = set(existing_keys_comprehensive.keys()) | set(existing_keys.keys())
210-
211-
# Check idempotency for src branch
212-
if src:
213-
key_data = host.get_fact(GpgKey, src=src) # Parses the key(s) from src to extract key IDs
214-
keyids_from_src = list(key_data.keys()) if key_data else []
215-
216-
# If we don't know the IDs (eg. unreachable URL), we cannot determine idempotency
217-
# -> try to install.
218-
# Otherwise, skip if all key IDs are already present.
219-
if keyids_from_src and all(kid in all_available_keys for kid in keyids_from_src):
220-
host.noop(f"All keys from {src} are already available in the apt keychain")
221-
return
222-
223-
dest_path = _derive_dest_from_src_and_keyids(src, keyids_from_src or None, dest)
224-
225-
# Check idempotency for keyserver branch
226-
elif keyserver:
227-
if not keyid:
228-
raise OperationError("`keyid` must be provided with `keyserver`")
229-
230-
if isinstance(keyid, str):
231-
keyid = [keyid]
232-
233-
needed_keys = sorted(set(keyid) - all_available_keys)
234-
if not needed_keys:
235-
host.noop(f"Keys {', '.join(keyid)} are already available in the apt keychain")
236-
return
237-
238-
dest_path = _derive_dest_from_src_and_keyids(None, needed_keys, dest)
239-
# Only install the needed keys
240-
keyid = needed_keys
163+
# Resolve destination path under /etc/apt/keyrings/
164+
if dest and not dest.startswith("/"):
165+
dest = f"/etc/apt/keyrings/{dest}"
166+
elif not dest:
167+
if src:
168+
dest = _derive_dest_from_src_and_keyids(src, None)
169+
elif keyserver and keyid:
170+
keyid_list = [keyid] if isinstance(keyid, str) else keyid
171+
dest = _derive_dest_from_src_and_keyids(None, keyid_list)
172+
else:
173+
dest = "/etc/apt/keyrings/apt-key.gpg"
241174

242-
# Use the generic GPG operation to install the key
175+
# Delegate everything to gpg.key with APT-specific defaults
243176
yield from gpg.key._inner(
244177
src=src,
245-
dest=dest_path,
178+
dest=dest,
246179
keyserver=keyserver,
247180
keyid=keyid,
181+
present=present,
248182
dearmor=True,
249183
mode="0644",
250184
)

tests/facts/apt.AptKeys/keys.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"command": "for keyring in $(find \"/etc/apt/trusted.gpg.d\" \"/etc/apt/keyrings\" \"/usr/share/keyrings\" -type f \\( -name '*.gpg' -o -name '*.asc' -o -name '*.kbx' \\) 2>/dev/null); do echo \"KEYRING:$keyring\"; if [[ \"$keyring\" == *.asc ]]; then gpg --with-colons \"$keyring\" 2>/dev/null || true; else gpg --list-keys --with-colons --keyring \"$keyring\" --no-default-keyring 2>/dev/null || true; fi; done",
2+
"command": "find \"/etc/apt/trusted.gpg.d\" \"/etc/apt/keyrings\" \"/usr/share/keyrings\" -type f \\( -name '*.gpg' -o -name '*.kbx' \\) 2>/dev/null -exec sh -c 'echo \"KEYRING:$1\"; gpg --list-keys --with-colons --keyring \"$1\" --no-default-keyring 2>/dev/null || true' _ {} \\; ; find \"/etc/apt/trusted.gpg.d\" \"/etc/apt/keyrings\" \"/usr/share/keyrings\" -type f -name '*.asc' 2>/dev/null -exec sh -c 'echo \"KEYRING:$1\"; gpg --with-colons \"$1\" 2>/dev/null || true' _ {} \\;",
33
"requires_command": "gpg",
44
"output": [
55
"KEYRING:/etc/apt/trusted.gpg.d/ubuntu-keyring.gpg",

tests/operations/apt.key/add.json

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
{
22
"args": ["mykey"],
33
"facts": {
4-
"apt.AptKeys": {},
5-
"gpg.GpgKey": {
6-
"src=mykey": {
7-
"abc": {}
8-
}
9-
},
104
"files.Directory": {
115
"path=/etc/apt/keyrings": null
126
},
Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
11
{
22
"args": ["mykey"],
33
"facts": {
4-
"apt.AptKeys": {
5-
"abc": {}
6-
},
7-
"gpg.GpgKey": {
8-
"src=mykey": {
9-
"abc": {}
10-
}
11-
},
12-
"files.Directory": {
13-
"path=/etc/apt/keyrings": null
4+
"files.File": {
5+
"path=/etc/apt/keyrings/mykey.gpg": {"mode": 644, "user": "root", "group": "root"}
146
}
157
},
168
"commands": [],
17-
"noop_description": "All keys from mykey are already available in the apt keychain"
9+
"noop_description": "GPG keyring /etc/apt/keyrings/mykey.gpg already exists"
1810
}

tests/operations/apt.key/add_keyserver.json

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
"keyid": "abc"
55
},
66
"facts": {
7-
"apt.AptKeys": {},
87
"files.Directory": {
98
"path=/etc/apt/keyrings": null,
10-
"path=/tmp/pyinfra-gpg-empfile_": null
9+
"path=_tempfile_": null
1110
},
1211
"files.File": {
1312
"path=/etc/apt/keyrings/keyserver-abc.gpg": null
@@ -16,10 +15,10 @@
1615
"commands": [
1716
"mkdir -p /etc/apt/keyrings",
1817
"chmod 755 /etc/apt/keyrings",
19-
"mkdir -p /tmp/pyinfra-gpg-empfile_",
20-
"chmod 700 /tmp/pyinfra-gpg-empfile_",
21-
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"key-server.net\" --recv-keys abc",
22-
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export abc > \"/etc/apt/keyrings/keyserver-abc.gpg\"",
18+
"mkdir -p _tempfile_",
19+
"chmod 700 _tempfile_",
20+
"export GNUPGHOME=\"_tempfile_\" && gpg --batch --keyserver \"key-server.net\" --recv-keys abc",
21+
"export GNUPGHOME=\"_tempfile_\" && gpg --batch --export abc > \"/etc/apt/keyrings/keyserver-abc.gpg\"",
2322
"mkdir -p /etc/apt/keyrings",
2423
"touch /etc/apt/keyrings/keyserver-abc.gpg",
2524
"chmod 644 /etc/apt/keyrings/keyserver-abc.gpg"

tests/operations/apt.key/add_keyserver_exists.json

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,35 @@
44
"keyid": ["abc", "def"]
55
},
66
"facts": {
7-
"apt.AptKeys": {
8-
"abc": {},
9-
"def": {}
7+
"gpg.GpgKeyrings": {
8+
"directories=['/etc/apt/keyrings']": {
9+
"/etc/apt/keyrings/keyserver-abc-def.gpg": {
10+
"format": "gpg",
11+
"keys": {
12+
"ABC": {
13+
"validity": "-",
14+
"length": 4096,
15+
"subkeys": {},
16+
"fingerprint": "ABCABC1234567890ABCABC1234567890ABCABC12",
17+
"uid_hash": "AAABBBCCC",
18+
"uid": "Test Vendor A <a@vendor.com>"
19+
},
20+
"DEF": {
21+
"validity": "-",
22+
"length": 4096,
23+
"subkeys": {},
24+
"fingerprint": "DEFDEF1234567890DEFDEF1234567890DEFDEF12",
25+
"uid_hash": "DDDEEEFFF",
26+
"uid": "Test Vendor B <b@vendor.com>"
27+
}
28+
}
29+
}
30+
}
1031
},
11-
"files.Directory": {
12-
"path=/etc/apt/keyrings": null
32+
"files.File": {
33+
"path=/etc/apt/keyrings/keyserver-abc-def.gpg": {"mode": 644, "user": "root", "group": "root"}
1334
}
1435
},
1536
"commands": [],
16-
"noop_description": "Keys abc, def are already available in the apt keychain"
37+
"noop_description": "GPG keys ['abc', 'def'] already exist in /etc/apt/keyrings/keyserver-abc-def.gpg"
1738
}

0 commit comments

Comments
 (0)