Skip to content

Commit 302e100

Browse files
committed
operations/facts: modernize apt.key to replace deprecated apt-key command
- Update AptKeys fact to use GpgKeyrings instead of deprecated apt-key command - Maintain backward compatibility by flattening keyring data to match old format - Search APT-specific directories: /etc/apt/trusted.gpg.d, /etc/apt/keyrings, /usr/share/keyrings - Add comprehensive test coverage for modernized apt.key operations - Support both legacy and modern APT key management workflows
1 parent 6e4fd07 commit 302e100

12 files changed

Lines changed: 302 additions & 65 deletions

File tree

src/pyinfra/facts/apt.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from pyinfra.api import FactBase
1010

11-
from .gpg import GpgFactBase
11+
from .gpg import GpgKeyrings
1212

1313

1414
@dataclass
@@ -370,9 +370,14 @@ def flush():
370370
return repos
371371

372372

373-
class AptKeys(GpgFactBase):
373+
class AptKeys(GpgKeyrings):
374374
"""
375-
Returns information on GPG keys apt has in its keychain:
375+
Returns information on GPG keys available to APT.
376+
377+
This fact reuses the GpgKeyrings infrastructure to search APT's modern keyring
378+
directories instead of using the deprecated apt-key command. It provides
379+
compatibility with the old AptKeys interface while leveraging the modern
380+
GPG infrastructure.
376381
377382
.. code:: python
378383
@@ -384,14 +389,26 @@ class AptKeys(GpgFactBase):
384389
}
385390
"""
386391

387-
# This requires both apt-key *and* apt-key itself requires gpg
388392
@override
389-
def command(self) -> str:
390-
return "! command -v gpg || apt-key list --with-colons"
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)
391399

392400
@override
393-
def requires_command(self) -> str:
394-
return "apt-key"
401+
def process(self, output):
402+
# Get the full keyring structure from parent
403+
keyrings_data = super().process(output)
404+
405+
# Flatten to match traditional AptKeys format (just key_id -> key_details)
406+
flattened_keys = {}
407+
for keyring_path, keyring_info in keyrings_data.items():
408+
if "keys" in keyring_info:
409+
flattened_keys.update(keyring_info["keys"])
410+
411+
return flattened_keys
395412

396413

397414
class AptSimulationDict(TypedDict):

src/pyinfra/operations/apt.py

Lines changed: 175 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44

55
from __future__ import annotations
66

7+
import re
78
from datetime import datetime, timedelta, timezone
89
from urllib.parse import urlparse
910

1011
from pyinfra import host
11-
from pyinfra.api import OperationError, operation
12+
from pyinfra.api import operation
13+
from pyinfra.api.exceptions import OperationError
1214
from pyinfra.facts.apt import (
1315
AptKeys,
1416
AptSources,
@@ -18,10 +20,10 @@
1820
)
1921
from pyinfra.facts.deb import DebPackage, DebPackages
2022
from pyinfra.facts.files import File
21-
from pyinfra.facts.gpg import GpgKey
23+
from pyinfra.facts.gpg import GpgKey, GpgKeyrings
2224
from pyinfra.facts.server import Date
25+
from pyinfra.operations import files, gpg
2326

24-
from . import files
2527
from .util.packaging import ensure_packages
2628

2729
APT_UPDATE_FILENAME = "/var/lib/apt/periodic/update-success-stamp"
@@ -45,22 +47,111 @@ def _simulate_then_perform(command: str):
4547
yield noninteractive_apt(command)
4648

4749

50+
def _sanitize_apt_keyring_name(name: str) -> str:
51+
"""
52+
Produce a filesystem-friendly name from an URL host/basename or a local filename.
53+
"""
54+
name = name.strip().lower()
55+
name = re.sub(r"[^\w.-]+", "_", name)
56+
name = re.sub(r"_+", "_", name).strip("_.")
57+
return name or "apt-keyring"
58+
59+
60+
def _derive_dest_from_src_and_keyids(
61+
src: str | None, keyids: list[str] | None, dest: str | None
62+
) -> str:
63+
"""
64+
Compute a stable destination path in /etc/apt/keyrings/.
65+
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"
70+
"""
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+
79+
base = None
80+
if src:
81+
parsed = urlparse(src)
82+
if parsed.scheme and parsed.netloc:
83+
host_name = _sanitize_apt_keyring_name(parsed.netloc.replace(":", "_"))
84+
bn = _sanitize_apt_keyring_name(
85+
(parsed.path.rsplit("/", 1)[-1] or "key").replace(".asc", "").replace(".gpg", "")
86+
)
87+
base = f"{host_name}-{bn}"
88+
else:
89+
bn = _sanitize_apt_keyring_name(
90+
src.rsplit("/", 1)[-1].replace(".asc", "").replace(".gpg", "")
91+
)
92+
base = bn or "key"
93+
elif keyids:
94+
base = "keyserver-" + _sanitize_apt_keyring_name("-".join(keyids))
95+
else:
96+
base = "apt-keyring"
97+
98+
return f"/etc/apt/keyrings/{base}.gpg"
99+
100+
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+
48126
@operation()
49-
def key(src: str | None = None, keyserver: str | None = None, keyid: str | list[str] | None = None):
127+
def key(
128+
src: str | None = None,
129+
keyserver: str | None = None,
130+
keyid: str | list[str] | None = None,
131+
dest: str | None = None,
132+
present: bool = True,
133+
):
50134
"""
51-
Add apt gpg keys with ``apt-key``.
135+
Add or remove apt GPG keys using modern keyring management.
52136
53-
+ src: filename or URL
54-
+ keyserver: URL of keyserver to fetch key from
55-
+ keyid: key ID or list of key IDs when using keyserver
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=.
56139
57-
keyserver/id:
58-
These must be provided together.
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)
59146
60-
.. warning::
61-
``apt-key`` is deprecated in Debian, it is recommended NOT to use this
62-
operation and instead follow the instructions here:
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
63152
153+
.. warning::
154+
``apt-key`` is deprecated in Debian. This operation follows modern keyring management:
64155
https://wiki.debian.org/DebianRepository/UseThirdParty
65156
66157
**Examples:**
@@ -69,53 +160,94 @@ def key(src: str | None = None, keyserver: str | None = None, keyid: str | list[
69160
70161
from pyinfra.operations import apt
71162
# Note: If using URL, wget is assumed to be installed.
163+
164+
apt.key(
165+
name="Add Docker apt GPG key",
166+
src="https://download.docker.com/linux/debian/gpg",
167+
dest="docker.gpg",
168+
)
169+
72170
apt.key(
73-
name="Add the Docker apt gpg key",
74-
src="https://download.docker.com/linux/ubuntu/gpg",
171+
name="Remove specific keyring file",
172+
dest="old-vendor.gpg",
173+
present=False,
174+
)
175+
176+
apt.key(
177+
name="Remove key by ID from all APT keyrings",
178+
keyid="0xCOMPROMISED123",
179+
present=False,
75180
)
76181
77182
apt.key(
78-
name="Install VirtualBox key",
79-
src="https://www.virtualbox.org/download/oracle_vbox_2016.asc",
183+
name="Fetch keys from keyserver",
184+
keyserver="hkps://keyserver.ubuntu.com",
185+
keyid=["0xD88E42B4", "0x7EA0A9C3"],
186+
dest="vendor-archive.gpg",
80187
)
81188
"""
82189

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"]
194+
yield from gpg.key._inner(
195+
dest=dest,
196+
keyid=keyid,
197+
present=False,
198+
working_dirs=apt_working_dirs,
199+
)
200+
return
201+
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
83206
existing_keys = host.get_fact(AptKeys)
84207

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
85212
if src:
86-
key_data = host.get_fact(GpgKey, src=src)
87-
if key_data:
88-
keyid = list(key_data.keys())
89-
90-
if not keyid or not all(kid in existing_keys for kid in keyid):
91-
# If URL, wget the key to stdout and pipe into apt-key, because the "adv"
92-
# apt-key passes to gpg which doesn't always support https!
93-
if urlparse(src).scheme:
94-
yield "(wget -O - {0} || curl -sSLf {0}) | apt-key add -".format(src)
95-
else:
96-
yield "apt-key add {0}".format(src)
97-
else:
98-
host.noop("All keys from {0} are already available in the apt keychain".format(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
99222

100-
if keyserver:
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:
101227
if not keyid:
102228
raise OperationError("`keyid` must be provided with `keyserver`")
103229

104230
if isinstance(keyid, str):
105231
keyid = [keyid]
106232

107-
needed_keys = sorted(set(keyid) - set(existing_keys.keys()))
108-
if needed_keys:
109-
yield "apt-key adv --keyserver {0} --recv-keys {1}".format(
110-
keyserver,
111-
" ".join(needed_keys),
112-
)
113-
else:
114-
host.noop(
115-
"Keys {0} are already available in the apt keychain".format(
116-
", ".join(keyid),
117-
),
118-
)
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
241+
242+
# Use the generic GPG operation to install the key
243+
yield from gpg.key._inner(
244+
src=src,
245+
dest=dest_path,
246+
keyserver=keyserver,
247+
keyid=keyid,
248+
dearmor=True,
249+
mode="0644",
250+
)
119251

120252

121253
@operation()

tests/facts/apt.AptKeys/keys.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
{
2-
"command": "! command -v gpg || apt-key list --with-colons",
3-
"requires_command": "apt-key",
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",
3+
"requires_command": "gpg",
44
"output": [
5+
"KEYRING:/etc/apt/trusted.gpg.d/ubuntu-keyring.gpg",
56
"tru:t:1:1601454628:0:3:1:5",
67
"pub:-:4096:1:3B4FE6ACC0B21F32:1336770936:::-:::scSC::::::23::0:",
78
"fpr:::::::::790BC7277767219C42C86F933B4FE6ACC0B21F32:",
89
"uid:-::::1336770936::B7A02867A0C1D32B594B36C00E20C8C57E397748::Ubuntu Archive Automatic Signing Key (2012) <ftpmaster@ubuntu.com>::::::::::0:",
10+
"KEYRING:/etc/apt/trusted.gpg.d/ubuntu-keyring2.gpg",
911
"tru:t:1:1601454628:0:3:1:5:",
1012
"pub:-:4096:1:D94AA3F0EFE21092:1336774248:::-:::scSC::::::23::0:",
1113
"fpr:::::::::843938DF228D22F7B3742BC0D94AA3F0EFE21092:",
1214
"uid:-::::1336774248::77355A0B96082B2694009775B6490C605BD16B6F::Ubuntu CD Image Automatic Signing Key (2012) <cdimage@ubuntu.com>::::::::::0:",
15+
"KEYRING:/etc/apt/trusted.gpg.d/ubuntu-keyring3.gpg",
1316
"tru:t:1:1601454628:0:3:1:5",
1417
"pub:-:4096:1:871920D1991BC93C:1537196506:::-:::scSC::::::23::0:",
1518
"fpr:::::::::F6ECB3762474EDA9D21B7022871920D1991BC93C:",

tests/operations/apt.key/add.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,20 @@
66
"src=mykey": {
77
"abc": {}
88
}
9+
},
10+
"files.Directory": {
11+
"path=/etc/apt/keyrings": null
12+
},
13+
"files.File": {
14+
"path=/etc/apt/keyrings/mykey.gpg": null
915
}
1016
},
1117
"commands": [
12-
"apt-key add mykey"
18+
"mkdir -p /etc/apt/keyrings",
19+
"chmod 755 /etc/apt/keyrings",
20+
"if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"mykey\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/mykey.gpg\" \"mykey\"; else cp \"mykey\" \"/etc/apt/keyrings/mykey.gpg\"; fi",
21+
"mkdir -p /etc/apt/keyrings",
22+
"touch /etc/apt/keyrings/mykey.gpg",
23+
"chmod 644 /etc/apt/keyrings/mykey.gpg"
1324
]
1425
}

tests/operations/apt.key/add_exists.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
"src=mykey": {
99
"abc": {}
1010
}
11+
},
12+
"files.Directory": {
13+
"path=/etc/apt/keyrings": null
1114
}
1215
},
1316
"commands": [],

0 commit comments

Comments
 (0)