Skip to content

Commit 2602657

Browse files
committed
operation/apt: update to use .sources file
1 parent aa33064 commit 2602657

File tree

11 files changed

+155
-57
lines changed

11 files changed

+155
-57
lines changed

src/pyinfra/facts/apt.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,11 @@ def flush():
348348

349349
class AptKeys(GpgFactBase):
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 searches in APT's modern keyring directories instead of using
354+
the deprecated apt-key command. It provides compatibility with the old
355+
AptKeys interface while using the modern GPG infrastructure.
352356
353357
.. code:: python
354358
@@ -360,14 +364,23 @@ class AptKeys(GpgFactBase):
360364
}
361365
"""
362366

363-
# This requires both apt-key *and* apt-key itself requires gpg
364367
@override
365368
def command(self) -> str:
366-
return "! command -v gpg || apt-key list --with-colons"
369+
# Use APT's standard keyring directories - this generates a complex command
370+
# that finds and processes all keyrings in APT directories
371+
apt_directories = ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
372+
search_locations = " ".join(f'"{d}"' for d in apt_directories)
373+
374+
# Find all GPG keyring files and process them
375+
return (
376+
f"for keyring in $(find {search_locations} -type f \\( -name '*.gpg' -o -name '*.asc' -o -name '*.kbx' \\) 2>/dev/null); do "
377+
f"gpg --batch --no-default-keyring --keyring \"$keyring\" --list-keys --with-colons 2>/dev/null || true; "
378+
f"done"
379+
)
367380

368381
@override
369382
def requires_command(self) -> str:
370-
return "apt-key"
383+
return "gpg"
371384

372385

373386
class AptSimulationDict(TypedDict):

src/pyinfra/operations/apt.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
)
2121
from pyinfra.facts.deb import DebPackage, DebPackages
2222
from pyinfra.facts.files import File
23-
from pyinfra.facts.gpg import GpgKey
23+
from pyinfra.facts.gpg import GpgKey, GpgKeyrings
2424
from pyinfra.facts.server import Date
2525
from pyinfra.operations import files, gpg
2626

@@ -98,28 +98,57 @@ def _derive_dest_from_src_and_keyids(
9898
return f"/etc/apt/keyrings/{base}.gpg"
9999

100100

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+
101126
@operation()
102127
def key(
103128
src: str | None = None,
104129
keyserver: str | None = None,
105130
keyid: str | list[str] | None = None,
106131
dest: str | None = None,
132+
present: bool = True,
107133
):
108134
"""
109-
Add apt GPG keys *without* apt-key:
110-
- Keys are stored under /etc/apt/keyrings/<name>.gpg (binary, dearmored if needed).
111-
- You must reference the resulting file in your apt source via `signed-by=...`.
135+
Add or remove apt GPG keys using modern keyring management.
136+
137+
This operation manages GPG keys for APT repositories 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=.
112139
113140
Args:
114141
src: filename or URL to a key (ASCII .asc or binary .gpg)
115142
keyserver: keyserver URL for fetching keys by ID
116-
keyid: key ID or list of key IDs (required with keyserver)
143+
keyid: key ID or list of key IDs (required with keyserver, optional for removal)
117144
dest: optional keyring filename/path ('.gpg' will be enforced, defaults under /etc/apt/keyrings)
145+
present: whether the key should be present (True) or absent (False)
118146
119147
Behavior:
120-
- Idempotent via AptKeys: if the key IDs are already present in any apt keyring, nothing is changed.
121-
- If src is ASCII (.asc), it will be dearmored; if binary (.gpg), it's copied as-is.
122-
- Keyserver flow uses a temporary GNUPGHOME, then exports and dearmors to the destination keyring.
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
123152
124153
Examples:
125154
apt.key(
@@ -129,9 +158,15 @@ def key(
129158
)
130159
131160
apt.key(
132-
name="Install VirtualBox key",
133-
src="https://www.virtualbox.org/download/oracle_vbox_2016.asc",
134-
dest="oracle-virtualbox.gpg",
161+
name="Remove specific keyring file",
162+
dest="old-vendor.gpg",
163+
present=False,
164+
)
165+
166+
apt.key(
167+
name="Remove key by ID from all APT keyrings",
168+
keyid="0xCOMPROMISED123",
169+
present=False,
135170
)
136171
137172
apt.key(
@@ -142,8 +177,26 @@ def key(
142177
)
143178
"""
144179

145-
# Gather currently installed keys (across trusted.gpg.d/, keyrings/, etc.)
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
146196
existing_keys = host.get_fact(AptKeys)
197+
198+
# Combine both sources of key information for complete coverage
199+
all_available_keys = set(existing_keys_comprehensive.keys()) | set(existing_keys.keys())
147200

148201
# Check idempotency for src branch
149202
if src:
@@ -152,7 +205,7 @@ def key(
152205

153206
# If we don't know the IDs (eg. unreachable URL), we cannot determine idempotency -> try to install.
154207
# Otherwise, skip if all key IDs are already present.
155-
if keyids_from_src and all(kid in existing_keys for kid in keyids_from_src):
208+
if keyids_from_src and all(kid in all_available_keys for kid in keyids_from_src):
156209
host.noop(f"All keys from {src} are already available in the apt keychain")
157210
return
158211

@@ -166,7 +219,7 @@ def key(
166219
if isinstance(keyid, str):
167220
keyid = [keyid]
168221

169-
needed_keys = sorted(set(keyid) - set(existing_keys.keys()))
222+
needed_keys = sorted(set(keyid) - all_available_keys)
170223
if not needed_keys:
171224
host.noop(f"Keys {', '.join(keyid)} are already available in the apt keychain")
172225
return

src/pyinfra/operations/gpg.py

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def key(
2121
dearmor: bool = True,
2222
mode: str = "0644",
2323
present: bool = True,
24+
working_dirs: list[str] | None = None,
2425
):
2526
"""
2627
Install or remove GPG keys from various sources.
@@ -33,8 +34,9 @@ def key(
3334
dearmor: whether to convert ASCII armored keys to binary format
3435
mode: file permissions for the installed key
3536
present: whether the key should be present (True) or absent (False)
37+
working_dirs: directories to search for existing keyrings (required for removal without dest)
3638
When False: if dest is provided, removes from specific keyring;
37-
if dest is None, removes from all APT keyrings;
39+
if dest is None, removes from keyrings found in working_dirs;
3840
if keyid is provided, removes specific key(s);
3941
if keyid is None, removes entire keyring file(s)
4042
@@ -59,10 +61,10 @@ def key(
5961
)
6062
6163
gpg.key(
62-
name="Remove key from all APT keyrings",
64+
name="Remove key from specific directories",
6365
keyid="0xCOMPROMISED123",
6466
present=False,
65-
# dest=None means search in all keyrings
67+
working_dirs=["/etc/apt/keyrings", "/usr/share/keyrings"],
6668
)
6769
6870
gpg.key(
@@ -79,20 +81,24 @@ def key(
7981
if not dest:
8082
raise OperationError("`dest` must be provided for installation")
8183
elif present is False:
82-
# For removal, either dest or keyid must be provided
83-
if not dest and not keyid:
84-
raise OperationError("For removal, either `dest` or `keyid` must be provided")
84+
# For removal, either dest or (keyid and working_dirs) must be provided
85+
if not dest and not (keyid and working_dirs):
86+
raise OperationError(
87+
"For removal, either `dest` or both `keyid` and `working_dirs` must be provided"
88+
)
8589

8690
# For removal, handle different scenarios
8791
if present is False:
8892
if not dest and keyid:
89-
# Remove key(s) from all keyrings found in APT directories
93+
# Remove key(s) from all keyrings found in specified directories
9094
if isinstance(keyid, str):
9195
keyid = [keyid]
9296

93-
# Use the GpgKeyrings fact to find all keyrings with default APT directories
94-
apt_directories = ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
95-
keyrings_info = host.get_fact(GpgKeyrings, directories=apt_directories)
97+
if not working_dirs:
98+
raise OperationError("`working_dirs` must be provided when removing keys without `dest`")
99+
100+
# Use the GpgKeyrings fact to find all keyrings in specified directories
101+
keyrings_info = host.get_fact(GpgKeyrings, directories=working_dirs)
96102

97103
for keyring_path, keyring_data in keyrings_info.items():
98104
# Get the keys from the GpgKeyrings fact data
@@ -112,8 +118,8 @@ def key(
112118
keys_to_remove.append(existing_key_id)
113119

114120
if keys_to_remove:
115-
# For APT keyrings, remove the entire keyring file if any target keys are found
116-
# This is the safest approach for APT key management
121+
# Remove the entire keyring file if any target keys are found
122+
# This is the safest approach for keyring management
117123
yield from files.file._inner(
118124
path=keyring_path,
119125
present=False,
@@ -148,7 +154,7 @@ def key(
148154
break
149155

150156
if keys_found:
151-
# Remove the entire keyring file - safest approach for APT
157+
# Remove the entire keyring file - safest approach for keyring management
152158
yield from files.file._inner(
153159
path=dest,
154160
present=False,
@@ -234,16 +240,9 @@ def key(
234240
# Export GNUPGHOME and fetch keys
235241
yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --keyserver "{keyserver}" --recv-keys {joined}' # noqa: E501
236242

237-
# Export keys to destination
238-
if dearmor:
239-
# For binary output (dearmored), use --export without --armor
240-
yield (f'export GNUPGHOME="{temp_dir}" && ' f'gpg --batch --export {joined} > "{dest}"')
241-
else:
242-
# For ASCII output, use --export --armor
243-
yield (
244-
f'export GNUPGHOME="{temp_dir}" && '
245-
f'gpg --batch --export --armor {joined} > "{dest}"'
246-
)
243+
# Export keys to destination - always use direct binary export
244+
# gpg --export produces binary format by default, no dearmoring needed
245+
yield (f'export GNUPGHOME="{temp_dir}" && ' f'gpg --batch --export {joined} > "{dest}"')
247246

248247
# Clean up temporary directory
249248
yield from files.directory._inner(

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 f in /etc/apt/trusted.gpg /etc/apt/trusted.gpg.d/*.gpg /etc/apt/trusted.gpg.d/*.asc /etc/apt/keyrings/*.gpg /etc/apt/keyrings/*.asc /usr/share/keyrings/*.gpg /usr/share/keyrings/*.asc ; do [ -e \"$f\" ] || continue; case \"$f\" in *.asc) gpg --batch --show-keys --with-colons --keyid-format LONG \"$f\" ;; *) gpg --batch --no-default-keyring --keyring \"$f\" --list-keys --with-colons --keyid-format LONG ;; esac; done",
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 gpg --batch --no-default-keyring --keyring \"$keyring\" --list-keys --with-colons 2>/dev/null || true; done",
33
"requires_command": "gpg",
44
"output": [
55
"tru:t:1:1601454628:0:3:1:5",

tests/operations/apt.key/add_keyserver.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"mkdir -p /tmp/pyinfra-gpg-empfile_",
2020
"chmod 700 /tmp/pyinfra-gpg-empfile_",
2121
"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 | gpg --batch --dearmor -o \"/etc/apt/keyrings/keyserver-abc.gpg\"",
22+
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export abc > \"/etc/apt/keyrings/keyserver-abc.gpg\"",
2323
"mkdir -p /etc/apt/keyrings",
2424
"touch /etc/apt/keyrings/keyserver-abc.gpg",
2525
"chmod 644 /etc/apt/keyrings/keyserver-abc.gpg"

tests/operations/apt.key/add_keyserver_multiple.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"mkdir -p /tmp/pyinfra-gpg-empfile_",
2020
"chmod 700 /tmp/pyinfra-gpg-empfile_",
2121
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"key-server.net\" --recv-keys abc def",
22-
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export abc def | gpg --batch --dearmor -o \"/etc/apt/keyrings/keyserver-abc-def.gpg\"",
22+
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export abc def > \"/etc/apt/keyrings/keyserver-abc-def.gpg\"",
2323
"mkdir -p /etc/apt/keyrings",
2424
"touch /etc/apt/keyrings/keyserver-abc-def.gpg",
2525
"chmod 644 /etc/apt/keyrings/keyserver-abc-def.gpg"

tests/operations/gpg.key/keyserver_multiple.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"mkdir -p /tmp/pyinfra-gpg-empfile_",
2121
"chmod 700 /tmp/pyinfra-gpg-empfile_",
2222
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"hkps://keyserver.ubuntu.com\" --recv-keys 0xD88E42B4 0x7EA0A9C3",
23-
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export 0xD88E42B4 0x7EA0A9C3 | gpg --batch --dearmor -o \"/etc/apt/keyrings/vendor.gpg\"",
23+
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export 0xD88E42B4 0x7EA0A9C3 > \"/etc/apt/keyrings/vendor.gpg\"",
2424
"mkdir -p /etc/apt/keyrings",
2525
"touch /etc/apt/keyrings/vendor.gpg",
2626
"chmod 644 /etc/apt/keyrings/vendor.gpg"

tests/operations/gpg.key/keyserver_single.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"mkdir -p /tmp/pyinfra-gpg-empfile_",
2121
"chmod 700 /tmp/pyinfra-gpg-empfile_",
2222
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"hkps://keyserver.ubuntu.com\" --recv-keys 0xD88E42B4",
23-
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export 0xD88E42B4 | gpg --batch --dearmor -o \"/etc/apt/keyrings/vendor.gpg\"",
23+
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export 0xD88E42B4 > \"/etc/apt/keyrings/vendor.gpg\"",
2424
"mkdir -p /etc/apt/keyrings",
2525
"touch /etc/apt/keyrings/vendor.gpg",
2626
"chmod 644 /etc/apt/keyrings/vendor.gpg"

tests/operations/gpg.key/remove_by_id.json

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,28 @@
55
"present": false
66
},
77
"facts": {
8+
"gpg.GpgKeyrings": {
9+
"directories=['/etc/apt/keyrings']": {
10+
"/etc/apt/keyrings/vendor.gpg": {
11+
"format": "gpg",
12+
"keys": {
13+
"ABCDEF1234567890": {
14+
"validity": "-",
15+
"length": 4096,
16+
"subkeys": {},
17+
"fingerprint": "ABCDEF1234567890FEDCBA0987654321ABCDEF12",
18+
"uid_hash": "ABC123DEF456",
19+
"uid": "Vendor Key <[email protected]>"
20+
}
21+
}
22+
}
23+
}
24+
},
825
"files.File": {
926
"path=/etc/apt/keyrings/vendor.gpg": {"mode": 644}
1027
}
1128
},
1229
"commands": [
13-
"gpg --batch --no-default-keyring --keyring \"/etc/apt/keyrings/vendor.gpg\" --delete-keys 0xABCDEF12 2>/dev/null || true",
14-
"if ! gpg --batch --no-default-keyring --keyring \"/etc/apt/keyrings/vendor.gpg\" --list-keys 2>/dev/null | grep -q \"pub\"; then rm -f \"/etc/apt/keyrings/vendor.gpg\"; fi"
30+
"rm -f /etc/apt/keyrings/vendor.gpg"
1531
]
1632
}
Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
11
{
22
"kwargs": {
33
"keyid": "0xCOMPROMISED123",
4-
"present": false
4+
"present": false,
5+
"working_dirs": ["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]
6+
},
7+
"facts": {
8+
"gpg.GpgKeyrings": {
9+
"directories=['/etc/apt/trusted.gpg.d', '/etc/apt/keyrings', '/usr/share/keyrings']": {
10+
"/etc/apt/trusted.gpg.d/compromised.gpg": {
11+
"format": "gpg",
12+
"keys": {
13+
"COMPROMISED123567890": {
14+
"validity": "-",
15+
"length": 4096,
16+
"subkeys": {},
17+
"fingerprint": "COMPROMISED123567890FEDCBA0987654321COMPROMISED123",
18+
"uid_hash": "ABC123DEF456",
19+
"uid": "Compromised Key <[email protected]>"
20+
}
21+
}
22+
}
23+
}
24+
},
25+
"files.File": {
26+
"path=/etc/apt/trusted.gpg.d/compromised.gpg": {"mode": 644}
27+
}
528
},
6-
"facts": {},
729
"commands": [
8-
"for keyring in /etc/apt/trusted.gpg.d/*.gpg; do [ -e \"$keyring\" ] && gpg --batch --no-default-keyring --keyring \"$keyring\" --delete-keys 0xCOMPROMISED123 2>/dev/null || true; done",
9-
"for keyring in /etc/apt/trusted.gpg.d/*.gpg; do [ -e \"$keyring\" ] && ! gpg --batch --no-default-keyring --keyring \"$keyring\" --list-keys 2>/dev/null | grep -q \"pub\" && rm -f \"$keyring\" || true; done",
10-
"for keyring in /etc/apt/keyrings/*.gpg; do [ -e \"$keyring\" ] && gpg --batch --no-default-keyring --keyring \"$keyring\" --delete-keys 0xCOMPROMISED123 2>/dev/null || true; done",
11-
"for keyring in /etc/apt/keyrings/*.gpg; do [ -e \"$keyring\" ] && ! gpg --batch --no-default-keyring --keyring \"$keyring\" --list-keys 2>/dev/null | grep -q \"pub\" && rm -f \"$keyring\" || true; done",
12-
"for keyring in /usr/share/keyrings/*.gpg; do [ -e \"$keyring\" ] && gpg --batch --no-default-keyring --keyring \"$keyring\" --delete-keys 0xCOMPROMISED123 2>/dev/null || true; done",
13-
"for keyring in /usr/share/keyrings/*.gpg; do [ -e \"$keyring\" ] && ! gpg --batch --no-default-keyring --keyring \"$keyring\" --list-keys 2>/dev/null | grep -q \"pub\" && rm -f \"$keyring\" || true; done"
30+
"rm -f /etc/apt/trusted.gpg.d/compromised.gpg"
1431
]
1532
}

0 commit comments

Comments
 (0)