Skip to content

Commit 408a40e

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 60d8744 commit 408a40e

File tree

13 files changed

+299
-71
lines changed

13 files changed

+299
-71
lines changed

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
@@ -346,9 +346,14 @@ def flush():
346346
return repos
347347

348348

349-
class AptKeys(GpgFactBase):
349+
class AptKeys(GpgKeyrings):
350350
"""
351-
Returns information on GPG keys apt has in its keychain:
351+
Returns information on GPG keys available to APT.
352+
353+
This fact reuses the GpgKeyrings infrastructure to search APT's modern keyring
354+
directories instead of using the deprecated apt-key command. It provides
355+
compatibility with the old AptKeys interface while leveraging the modern
356+
GPG infrastructure.
352357
353358
.. code:: python
354359
@@ -360,14 +365,26 @@ class AptKeys(GpgFactBase):
360365
}
361366
"""
362367

363-
# This requires both apt-key *and* apt-key itself requires gpg
364368
@override
365-
def command(self) -> str:
366-
return "! command -v gpg || apt-key list --with-colons"
369+
def command(self, directories=None) -> str:
370+
# Default to APT-specific directories if none specified
371+
if directories is None:
372+
directories = ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
373+
374+
return super().command(directories)
367375

368376
@override
369-
def requires_command(self) -> str:
370-
return "apt-key"
377+
def process(self, output):
378+
# Get the full keyring structure from parent
379+
keyrings_data = super().process(output)
380+
381+
# Flatten to match traditional AptKeys format (just key_id -> key_details)
382+
flattened_keys = {}
383+
for keyring_path, keyring_info in keyrings_data.items():
384+
if "keys" in keyring_info:
385+
flattened_keys.update(keyring_info["keys"])
386+
387+
return flattened_keys
371388

372389

373390
class AptSimulationDict(TypedDict):

src/pyinfra/operations/apt.py

Lines changed: 171 additions & 48 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 timedelta
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,76 +47,197 @@ def _simulate_then_perform(command: str):
4547
yield noninteractive_apt(command)
4648

4749

48-
@operation()
49-
def key(src: str | None = None, keyserver: str | None = None, keyid: str | list[str] | None = None):
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.
5053
"""
51-
Add apt gpg keys with ``apt-key``.
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"
5258

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
5659

57-
keyserver/id:
58-
These must be provided together.
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"
5997

60-
.. warning::
61-
``apt-key`` is deprecated in Debian, it is recommended NOT to use this
62-
operation and instead follow the instructions here:
98+
return f"/etc/apt/keyrings/{base}.gpg"
6399

64-
https://wiki.debian.org/DebianRepository/UseThirdParty
65100

66-
**Examples:**
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.
67106
68-
.. code:: python
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+
126+
@operation()
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+
):
134+
"""
135+
Add or remove apt GPG keys using modern keyring management.
136+
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=.
139+
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)
146+
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
152+
153+
Examples:
154+
apt.key(
155+
name="Add Docker apt GPG key",
156+
src="https://download.docker.com/linux/debian/gpg",
157+
dest="docker.gpg",
158+
)
69159
70-
# Note: If using URL, wget is assumed to be installed.
71160
apt.key(
72-
name="Add the Docker apt gpg key",
73-
src="https://download.docker.com/linux/ubuntu/gpg",
161+
name="Remove specific keyring file",
162+
dest="old-vendor.gpg",
163+
present=False,
74164
)
75165
76166
apt.key(
77-
name="Install VirtualBox key",
78-
src="https://www.virtualbox.org/download/oracle_vbox_2016.asc",
167+
name="Remove key by ID from all APT keyrings",
168+
keyid="0xCOMPROMISED123",
169+
present=False,
170+
)
171+
172+
apt.key(
173+
name="Fetch keys from keyserver",
174+
keyserver="hkps://keyserver.ubuntu.com",
175+
keyid=["0xD88E42B4", "0x7EA0A9C3"],
176+
dest="vendor-archive.gpg",
79177
)
80178
"""
81179

180+
# Handle removal operations using the GPG infrastructure
181+
if present is False:
182+
# Use the GPG operation for removal, but restrict to APT directories
183+
apt_working_dirs = ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
184+
yield from gpg.key._inner(
185+
dest=dest,
186+
keyid=keyid,
187+
present=False,
188+
working_dirs=apt_working_dirs,
189+
)
190+
return
191+
192+
# Installation logic (existing code)
193+
# Get comprehensive view of all keys in APT directories
194+
existing_keys_comprehensive = _get_apt_keys_comprehensive()
195+
# Also get the legacy AptKeys fact for compatibility
82196
existing_keys = host.get_fact(AptKeys)
83197

198+
# Combine both sources of key information for complete coverage
199+
all_available_keys = set(existing_keys_comprehensive.keys()) | set(existing_keys.keys())
200+
201+
# Check idempotency for src branch
84202
if src:
85-
key_data = host.get_fact(GpgKey, src=src)
86-
if key_data:
87-
keyid = list(key_data.keys())
88-
89-
if not keyid or not all(kid in existing_keys for kid in keyid):
90-
# If URL, wget the key to stdout and pipe into apt-key, because the "adv"
91-
# apt-key passes to gpg which doesn't always support https!
92-
if urlparse(src).scheme:
93-
yield "(wget -O - {0} || curl -sSLf {0}) | apt-key add -".format(src)
94-
else:
95-
yield "apt-key add {0}".format(src)
96-
else:
97-
host.noop("All keys from {0} are already available in the apt keychain".format(src))
203+
key_data = host.get_fact(GpgKey, src=src) # Parses the key(s) from src to extract key IDs
204+
keyids_from_src = list(key_data.keys()) if key_data else []
205+
206+
# If we don't know the IDs (eg. unreachable URL), we cannot determine idempotency
207+
# -> try to install.
208+
# Otherwise, skip if all key IDs are already present.
209+
if keyids_from_src and all(kid in all_available_keys for kid in keyids_from_src):
210+
host.noop(f"All keys from {src} are already available in the apt keychain")
211+
return
212+
213+
dest_path = _derive_dest_from_src_and_keyids(src, keyids_from_src or None, dest)
98214

99-
if keyserver:
215+
# Check idempotency for keyserver branch
216+
elif keyserver:
100217
if not keyid:
101218
raise OperationError("`keyid` must be provided with `keyserver`")
102219

103220
if isinstance(keyid, str):
104221
keyid = [keyid]
105222

106-
needed_keys = sorted(set(keyid) - set(existing_keys.keys()))
107-
if needed_keys:
108-
yield "apt-key adv --keyserver {0} --recv-keys {1}".format(
109-
keyserver,
110-
" ".join(needed_keys),
111-
)
112-
else:
113-
host.noop(
114-
"Keys {0} are already available in the apt keychain".format(
115-
", ".join(keyid),
116-
),
117-
)
223+
needed_keys = sorted(set(keyid) - all_available_keys)
224+
if not needed_keys:
225+
host.noop(f"Keys {', '.join(keyid)} are already available in the apt keychain")
226+
return
227+
228+
dest_path = _derive_dest_from_src_and_keyids(None, needed_keys, dest)
229+
# Only install the needed keys
230+
keyid = needed_keys
231+
232+
# Use the generic GPG operation to install the key
233+
yield from gpg.key._inner(
234+
src=src,
235+
dest=dest_path,
236+
keyserver=keyserver,
237+
keyid=keyid,
238+
dearmor=True,
239+
mode="0644",
240+
)
118241

119242

120243
@operation()

src/pyinfra/operations/gpg.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ def key(
251251

252252
# Export keys to destination - always use direct binary export
253253
# gpg --export produces binary format by default, no dearmoring needed
254-
yield (f'export GNUPGHOME="{temp_dir}" && ' f'gpg --batch --export {joined} > "{dest}"')
254+
yield (f'export GNUPGHOME="{temp_dir}" && gpg --batch --export {joined} > "{dest}"')
255255

256256
# Clean up temporary directory
257257
yield from files.directory._inner(

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) <[email protected]>::::::::::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) <[email protected]>::::::::::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)