Skip to content

Commit 1f73888

Browse files
committed
facts+operations/gpg: add GpgKeyrings fact and update operations for key management
1 parent 8d3ea39 commit 1f73888

File tree

9 files changed

+424
-48
lines changed

9 files changed

+424
-48
lines changed

src/pyinfra/facts/gpg.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from typing_extensions import override
66

7+
from pyinfra import host
8+
79
from pyinfra.api import FactBase
810

911

@@ -148,3 +150,58 @@ def command(self, keyring=None):
148150
return ("gpg --list-secret-keys --with-colons --keyring {0} --no-default-keyring").format(
149151
keyring,
150152
)
153+
154+
155+
class GpgKeyrings(FactBase):
156+
"""
157+
Returns information on all GPG keyrings found in specified directories.
158+
159+
.. code:: python
160+
161+
{
162+
"/etc/apt/keyrings/docker.gpg": {
163+
"format": "gpg",
164+
"keys": {...} # Same format as GpgKeys fact
165+
}
166+
}
167+
"""
168+
169+
@override
170+
def requires_command(self, *args, **kwargs) -> str:
171+
return "gpg"
172+
173+
@override
174+
def command(self, directories):
175+
if isinstance(directories, str):
176+
directories = [directories]
177+
178+
search_locations = " ".join(f'"{d}"' for d in directories)
179+
return f"find {search_locations} -type f \\( -name '*.gpg' -o -name '*.asc' -o -name '*.kbx' \\) 2>/dev/null"
180+
181+
@override
182+
def process(self, output):
183+
keyrings = {}
184+
185+
for line in output:
186+
line = line.strip()
187+
if not line:
188+
continue
189+
190+
# get format from file extension
191+
keyring_format = line.split(".")[-1].lower()
192+
193+
# Use different facts based on file type
194+
if keyring_format == 'asc':
195+
# For .asc files (ASCII armored keys), use GpgKey fact
196+
# This is a single key file, not a keyring, but it keeps consistent usage
197+
keys = host.get_fact(GpgKey, src=line)
198+
else:
199+
# For .gpg/.kbx files (keyrings), use GpgKeys fact
200+
keys = host.get_fact(GpgKeys, keyring=line)
201+
202+
keyrings[line] = {
203+
"format": keyring_format,
204+
"keys": keys
205+
}
206+
207+
return keyrings

src/pyinfra/operations/gpg.py

Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from pyinfra import host
99
from pyinfra.api import OperationError, operation
10+
from pyinfra.facts.gpg import GpgKeyrings
1011

1112
from . import files
1213

@@ -85,58 +86,73 @@ def key(
8586
# For removal, handle different scenarios
8687
if present is False:
8788
if not dest and keyid:
88-
# Remove key(s) from all APT keyrings
89+
# Remove key(s) from all keyrings found in APT directories
8990
if isinstance(keyid, str):
9091
keyid = [keyid]
9192

92-
# Define all APT keyring locations
93-
# Not sure this is the best way to do this
94-
# Cannot find a more generic way to get gpg keyring locations
95-
keyring_patterns = [
96-
"/etc/apt/trusted.gpg.d/*.gpg",
97-
"/etc/apt/keyrings/*.gpg",
98-
"/usr/share/keyrings/*.gpg",
99-
]
100-
101-
for pattern in keyring_patterns:
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)
96+
97+
for keyring_path, keyring_data in keyrings_info.items():
98+
# Get the keys from the GpgKeyrings fact data
99+
keys_in_keyring = keyring_data.get("keys", {})
100+
101+
# Check if any of the target keys exist in this keyring
102+
keys_to_remove = []
102103
for kid in keyid:
103-
# Remove key from all matching keyrings
104-
yield (
105-
f'for keyring in {pattern}; do [ -e "$keyring" ] && '
106-
f'gpg --batch --no-default-keyring --keyring "$keyring" '
107-
f"--delete-keys {kid} 2>/dev/null || true; done"
104+
# Handle different key ID formats (short, long, with/without 0x prefix)
105+
clean_key = kid.replace("0x", "").replace("0X", "").upper()
106+
107+
# Check for exact match or if the key ID is a suffix/prefix of any key in the keyring
108+
for existing_key_id in keys_in_keyring.keys():
109+
if (clean_key == existing_key_id.upper() or
110+
existing_key_id.upper().endswith(clean_key) or
111+
existing_key_id.upper().startswith(clean_key)):
112+
keys_to_remove.append(existing_key_id)
113+
114+
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
117+
yield from files.file._inner(
118+
path=keyring_path,
119+
present=False,
108120
)
109121

110-
# Clean up empty keyrings
111-
yield (
112-
f'for keyring in {pattern}; do [ -e "$keyring" ] && '
113-
f'! gpg --batch --no-default-keyring --keyring "$keyring" '
114-
f'--list-keys 2>/dev/null | grep -q "pub" && rm -f "$keyring" || true; done'
115-
)
116-
117122
return
118123

119124
elif dest and keyid:
120-
# For APT keyring files, use a simpler approach:
121-
# Check if keys exist in file, and if so, remove the entire file
122-
# This is appropriate for most APT use cases
125+
# Remove specific key(s) from a specific keyring file
123126
if isinstance(keyid, str):
124127
keyid = [keyid]
125128

126-
# Build a condition to check if any of the keys exist in the file
127-
key_checks = []
128-
for kid in keyid:
129-
# Strip 0x prefix if present and handle both short and long key formats
130-
clean_key = kid.replace("0x", "").replace("0X", "")
131-
key_checks.append(
132-
f'gpg --batch --no-default-keyring --keyring "{dest}" '
133-
f'--list-keys 2>/dev/null | grep -qi "{clean_key}"'
134-
)
135-
136-
condition = " || ".join(key_checks)
137-
138-
# If any of the keys exist in the file, remove the entire file
139-
yield (f'if [ -f "{dest}" ] && ({condition}); then ' f'rm -f "{dest}"; fi')
129+
# Check if the destination keyring exists and contains the target keys
130+
keyrings_info = host.get_fact(GpgKeyrings, directories=[str(PurePosixPath(dest).parent)])
131+
132+
if dest in keyrings_info:
133+
keyring_data = keyrings_info[dest]
134+
keys_in_keyring = keyring_data.get("keys", {})
135+
136+
# Check if any of the target keys exist in this keyring
137+
keys_found = False
138+
for kid in keyid:
139+
clean_key = kid.replace("0x", "").replace("0X", "").upper()
140+
for existing_key_id in keys_in_keyring.keys():
141+
# Check for exact match, suffix (short key ID), or prefix match
142+
if (clean_key == existing_key_id.upper() or
143+
existing_key_id.upper().endswith(clean_key) or
144+
existing_key_id.upper().startswith(clean_key)):
145+
keys_found = True
146+
break
147+
if keys_found:
148+
break
149+
150+
if keys_found:
151+
# Remove the entire keyring file - safest approach for APT
152+
yield from files.file._inner(
153+
path=dest,
154+
present=False,
155+
)
140156
return
141157

142158
elif dest and not keyid:
@@ -147,6 +163,9 @@ def key(
147163
)
148164
return
149165

166+
else:
167+
raise OperationError("Invalid parameters for removal operation")
168+
150169
# For installation, validate required parameters
151170
if not src and not keyserver:
152171
raise OperationError("Either `src` or `keyserver` must be provided for installation")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"command": "find \"/etc/apt/trusted.gpg.d\" -type f \\( -name '*.gpg' -o -name '*.asc' -o -name '*.kbx' \\) 2>/dev/null",
3+
"requires_command": "gpg",
4+
"arg": ["/etc/apt/trusted.gpg.d"],
5+
"output": [
6+
"/etc/apt/trusted.gpg.d/debian-archive-bookworm-automatic.asc",
7+
"/etc/apt/trusted.gpg.d/debian-archive-bookworm-stable.asc"
8+
],
9+
"facts": {
10+
"gpg.GpgKey": {
11+
"src=/etc/apt/trusted.gpg.d/debian-archive-bookworm-automatic.asc": {
12+
"B7C5D7D6350947F8": {
13+
"validity": "-",
14+
"length": 4096,
15+
"subkeys": {
16+
"6ED0E7B82643E131": {
17+
"validity": "-",
18+
"length": 4096
19+
}
20+
},
21+
"uid_hash": "",
22+
"uid": "Debian Archive Automatic Signing Key (12/bookworm) <[email protected]>"
23+
}
24+
},
25+
"src=/etc/apt/trusted.gpg.d/debian-archive-bookworm-stable.asc": {
26+
"F8D2585B8783D481": {
27+
"validity": "-",
28+
"length": 255,
29+
"subkeys": {},
30+
"uid_hash": "",
31+
"uid": "Debian Stable Release Key (12/bookworm) <[email protected]>"
32+
}
33+
}
34+
}
35+
},
36+
"fact": {
37+
"/etc/apt/trusted.gpg.d/debian-archive-bookworm-automatic.asc": {
38+
"format": "asc",
39+
"keys": {
40+
"B7C5D7D6350947F8": {
41+
"validity": "-",
42+
"length": 4096,
43+
"subkeys": {
44+
"6ED0E7B82643E131": {
45+
"validity": "-",
46+
"length": 4096
47+
}
48+
},
49+
"uid_hash": "",
50+
"uid": "Debian Archive Automatic Signing Key (12/bookworm) <[email protected]>"
51+
}
52+
}
53+
},
54+
"/etc/apt/trusted.gpg.d/debian-archive-bookworm-stable.asc": {
55+
"format": "asc",
56+
"keys": {
57+
"F8D2585B8783D481": {
58+
"validity": "-",
59+
"length": 255,
60+
"subkeys": {},
61+
"uid_hash": "",
62+
"uid": "Debian Stable Release Key (12/bookworm) <[email protected]>"
63+
}
64+
}
65+
}
66+
}
67+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"command": "find \"/custom/keyring/path\" -type f \\( -name '*.gpg' -o -name '*.asc' -o -name '*.kbx' \\) 2>/dev/null",
3+
"requires_command": "gpg",
4+
"arg": ["/custom/keyring/path"],
5+
"output": [
6+
"/custom/keyring/path/custom.asc"
7+
],
8+
"facts": {
9+
"gpg.GpgKey": {
10+
"src=/custom/keyring/path/custom.asc": {
11+
"9A2B3C4D5E6F7890": {
12+
"validity": "-",
13+
"length": 4096,
14+
"subkeys": {},
15+
"uid_hash": "",
16+
"uid": "Custom Key <[email protected]>"
17+
}
18+
}
19+
}
20+
},
21+
"fact": {
22+
"/custom/keyring/path/custom.asc": {
23+
"format": "asc",
24+
"keys": {
25+
"9A2B3C4D5E6F7890": {
26+
"validity": "-",
27+
"length": 4096,
28+
"subkeys": {},
29+
"uid_hash": "",
30+
"uid": "Custom Key <[email protected]>"
31+
}
32+
}
33+
}
34+
}
35+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"command": "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",
3+
"requires_command": "gpg",
4+
"arg": [["/etc/apt/trusted.gpg.d", "/etc/apt/keyrings", "/usr/share/keyrings"]],
5+
"output": [
6+
"/etc/apt/keyrings/docker.gpg",
7+
"/etc/apt/trusted.gpg.d/ubuntu-keyring.asc"
8+
],
9+
"facts": {
10+
"gpg.GpgKeys": {
11+
"keyring=/etc/apt/keyrings/docker.gpg": {
12+
"ABCD1234EFGH5678": {
13+
"validity": "-",
14+
"length": 4096,
15+
"subkeys": {},
16+
"fingerprint": "ABCD1234EFGH5678IJKL9012MNOP3456QRST7890",
17+
"uid_hash": "ABC123DEF456",
18+
"uid": "Docker Release (CE deb) <[email protected]>"
19+
}
20+
}
21+
},
22+
"gpg.GpgKey": {
23+
"src=/etc/apt/trusted.gpg.d/ubuntu-keyring.asc": {
24+
"1234ABCD5678EFGH": {
25+
"validity": "-",
26+
"length": 4096,
27+
"subkeys": {},
28+
"uid_hash": "",
29+
"uid": "Ubuntu Archive Signing Key <[email protected]>"
30+
}
31+
}
32+
}
33+
},
34+
"fact": {
35+
"/etc/apt/keyrings/docker.gpg": {
36+
"format": "gpg",
37+
"keys": {
38+
"ABCD1234EFGH5678": {
39+
"validity": "-",
40+
"length": 4096,
41+
"subkeys": {},
42+
"fingerprint": "ABCD1234EFGH5678IJKL9012MNOP3456QRST7890",
43+
"uid_hash": "ABC123DEF456",
44+
"uid": "Docker Release (CE deb) <[email protected]>"
45+
}
46+
}
47+
},
48+
"/etc/apt/trusted.gpg.d/ubuntu-keyring.asc": {
49+
"format": "asc",
50+
"keys": {
51+
"1234ABCD5678EFGH": {
52+
"validity": "-",
53+
"length": 4096,
54+
"subkeys": {},
55+
"uid_hash": "",
56+
"uid": "Ubuntu Archive Signing Key <[email protected]>"
57+
}
58+
}
59+
}
60+
}
61+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"command": "find \"/empty/directory\" -type f \\( -name '*.gpg' -o -name '*.asc' -o -name '*.kbx' \\) 2>/dev/null",
3+
"requires_command": "gpg",
4+
"arg": ["/empty/directory"],
5+
"output": [],
6+
"fact": {}
7+
}

0 commit comments

Comments
 (0)