Skip to content

Commit d22483b

Browse files
committed
operations/gpg: enhance key management to support removal of GPG keys and keyrings
1 parent 18070e3 commit d22483b

File tree

10 files changed

+160
-17
lines changed

10 files changed

+160
-17
lines changed

pyinfra/operations/gpg.py

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pyinfra.facts.gpg import GpgKey
1212

1313
from . import files
14+
from pathlib import Path
1415

1516

1617
@operation()
@@ -21,17 +22,23 @@ def key(
2122
keyid: str | list[str] | None = None,
2223
dearmor: bool = True,
2324
mode: str = "0644",
25+
present: bool = True,
2426
):
2527
"""
26-
Install GPG keys from various sources.
28+
Install or remove GPG keys from various sources.
2729
2830
Args:
2931
src: filename or URL to a key (ASCII .asc or binary .gpg)
30-
dest: destination path for the key file (required)
32+
dest: destination path for the key file (required for installation, optional for removal)
3133
keyserver: keyserver URL for fetching keys by ID
32-
keyid: key ID or list of key IDs (required with keyserver)
34+
keyid: key ID or list of key IDs (required with keyserver, optional for removal)
3335
dearmor: whether to convert ASCII armored keys to binary format
3436
mode: file permissions for the installed key
37+
present: whether the key should be present (True) or absent (False)
38+
When False: if dest is provided, removes from specific keyring;
39+
if dest is None, removes from all APT keyrings;
40+
if keyid is provided, removes specific key(s);
41+
if keyid is None, removes entire keyring file(s)
3542
3643
Examples:
3744
gpg.key(
@@ -40,6 +47,26 @@ def key(
4047
dest="/etc/apt/keyrings/docker.gpg",
4148
)
4249
50+
gpg.key(
51+
name="Remove old GPG key file",
52+
dest="/etc/apt/keyrings/old-key.gpg",
53+
present=False,
54+
)
55+
56+
gpg.key(
57+
name="Remove specific key by ID",
58+
dest="/etc/apt/keyrings/vendor.gpg",
59+
keyid="0xABCDEF12",
60+
present=False,
61+
)
62+
63+
gpg.key(
64+
name="Remove key from all APT keyrings",
65+
keyid="0xCOMPROMISED123",
66+
present=False,
67+
# dest=None means search in all keyrings
68+
)
69+
4370
gpg.key(
4471
name="Fetch keys from keyserver",
4572
keyserver="hkps://keyserver.ubuntu.com",
@@ -48,17 +75,73 @@ def key(
4875
)
4976
"""
5077

78+
# Validate parameters based on operation type
79+
if present is True:
80+
# For installation, dest is required
81+
if not dest:
82+
raise OperationError("`dest` must be provided for installation")
83+
elif present is False:
84+
# For removal, either dest or keyid must be provided
85+
if not dest and not keyid:
86+
raise OperationError("For removal, either `dest` or `keyid` must be provided")
87+
88+
# For removal, handle different scenarios
89+
if present is False:
90+
if not dest and keyid:
91+
# Remove key(s) from all APT keyrings
92+
if isinstance(keyid, str):
93+
keyid = [keyid]
94+
95+
# Define all APT keyring locations
96+
keyring_patterns = [
97+
"/etc/apt/trusted.gpg.d/*.gpg",
98+
"/etc/apt/keyrings/*.gpg",
99+
"/usr/share/keyrings/*.gpg"
100+
]
101+
102+
for pattern in keyring_patterns:
103+
for kid in keyid:
104+
# Remove key from all matching keyrings
105+
yield f'for keyring in {pattern}; do [ -e "$keyring" ] && gpg --batch --no-default-keyring --keyring "$keyring" --delete-keys {kid} 2>/dev/null || true; done'
106+
107+
# Clean up empty keyrings
108+
yield f'for keyring in {pattern}; do [ -e "$keyring" ] && ! gpg --batch --no-default-keyring --keyring "$keyring" --list-keys 2>/dev/null | grep -q "pub" && rm -f "$keyring" || true; done'
109+
110+
return
111+
112+
elif dest and keyid:
113+
# Remove specific key(s) by ID from specific keyring
114+
if isinstance(keyid, str):
115+
keyid = [keyid]
116+
117+
for kid in keyid:
118+
# Remove the specific key from the keyring
119+
yield f'gpg --batch --no-default-keyring --keyring "{dest}" --delete-keys {kid} 2>/dev/null || true'
120+
121+
# If keyring becomes empty, remove the file
122+
yield f'if ! gpg --batch --no-default-keyring --keyring "{dest}" --list-keys 2>/dev/null | grep -q "pub"; then rm -f "{dest}"; fi'
123+
return
124+
125+
elif dest and not keyid:
126+
# Remove entire keyring file
127+
yield from files.file._inner(
128+
path=dest,
129+
present=False,
130+
)
131+
return
132+
133+
# For installation, validate required parameters
51134
if not src and not keyserver:
52-
raise OperationError("Either `src` or `keyserver` must be provided")
135+
raise OperationError("Either `src` or `keyserver` must be provided for installation")
53136

54137
if keyserver and not keyid:
55138
raise OperationError("`keyid` must be provided with `keyserver`")
56139

57-
if not dest:
58-
raise OperationError("`dest` must be provided")
140+
if keyid and not keyserver and not src:
141+
raise OperationError("When using `keyid` for installation, either `keyserver` or `src` must be provided")
59142

60-
# Ensure destination directory exists
61-
dest_dir = dest.rsplit("/", 1)[0]
143+
# For installation (present=True), ensure destination directory exists
144+
dest_dir = str(Path(dest).parent)
62145
yield from files.directory._inner(
63146
path=dest_dir,
64147
mode="0755",
@@ -126,7 +209,6 @@ def key(
126209
present=True,
127210
)
128211

129-
130212
@operation()
131213
def dearmor(src: str, dest: str, mode: str = "0644"):
132214
"""
@@ -168,8 +250,11 @@ def _install_key_file(src_file: str, dest_path: str, dearmor: bool, mode: str):
168250
Helper function to install a GPG key file, dearmoring if necessary.
169251
"""
170252
if dearmor:
253+
# Check if it's an ASCII armored key and handle accordingly
254+
# Note: Could be enhanced using GpgKey fact for better detection
171255
yield f'if grep -q "BEGIN PGP PUBLIC KEY BLOCK" "{src_file}"; then gpg --batch --dearmor -o "{dest_path}" "{src_file}"; else cp "{src_file}" "{dest_path}"; fi'
172256
else:
257+
# Simple copy for binary keys or when dearmoring is disabled
173258
yield f'cp "{src_file}" "{dest_path}"'
174259

175260
# Set proper permissions using pyinfra

tests/facts/apt.AptKeys/keys.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
2-
"command": "! command -v gpg || apt-key list --with-colons",
3-
"requires_command": "apt-key",
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",
43
"output": [
54
"tru:t:1:1601454628:0:3:1:5",
65
"pub:-:4096:1:3B4FE6ACC0B21F32:1336770936:::-:::scSC::::::23::0:",

tests/facts/apt.AptSources/component_with_number.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
{
22
"output": [
3-
"deb http://archive.ubuntu.com/ubuntu trusty restricted pi4"
3+
"##FILE /etc/apt/sources.list",
4+
"deb http://archive.ubuntu.com/ubuntu trusty restricted pi4",
5+
""
46
],
5-
"command": "(! test -f /etc/apt/sources.list || cat /etc/apt/sources.list) && (cat /etc/apt/sources.list.d/*.list || true)",
7+
"command": "sh -c 'for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [ -e \"$f\" ] || continue; echo \"##FILE $f\"; cat \"$f\"; echo; done'",
68
"requires_command": "apt",
79
"fact": [
810
{

tests/facts/apt.AptSources/sources.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
{
22
"output": [
3+
"##FILE /etc/apt/sources.list",
34
"deb http://archive.ubuntu.com/ubuntu trusty restricted",
45
"deb-src [arch=amd64,i386] http://archive.ubuntu.com/ubuntu trusty main",
56
"deb [arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse",
6-
"nope"
7+
"nope",
8+
""
79
],
8-
"command": "(! test -f /etc/apt/sources.list || cat /etc/apt/sources.list) && (cat /etc/apt/sources.list.d/*.list || true)",
10+
"command": "sh -c 'for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [ -e \"$f\" ] || continue; echo \"##FILE $f\"; cat \"$f\"; echo; done'",
911
"requires_command": "apt",
1012
"fact": [
1113
{

tests/operations/gpg.key/no_dest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
"facts": {},
77
"exception": {
88
"names": ["OperationError"],
9-
"message": "`dest` must be provided"
9+
"message": "`dest` must be provided for installation"
1010
}
1111
}

tests/operations/gpg.key/no_src_or_keyserver.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
},
66
"exception": {
77
"names": ["OperationError"],
8-
"message": "Either `src` or `keyserver` must be provided"
8+
"message": "Either `src` or `keyserver` must be provided for installation"
99
},
1010
"facts": {}
1111
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"kwargs": {
3+
"dest": "/etc/apt/keyrings/vendor.gpg",
4+
"keyid": "0xABCDEF12",
5+
"present": false
6+
},
7+
"facts": {
8+
"files.File": {
9+
"path=/etc/apt/keyrings/vendor.gpg": {"mode": 644}
10+
}
11+
},
12+
"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"
15+
]
16+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"kwargs": {
3+
"dest": "/etc/apt/keyrings/test.gpg",
4+
"present": false
5+
},
6+
"facts": {
7+
"files.File": {
8+
"path=/etc/apt/keyrings/test.gpg": {"mode": 644}
9+
}
10+
},
11+
"commands": [
12+
"rm -f /etc/apt/keyrings/test.gpg"
13+
]
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"kwargs": {
3+
"keyid": "0xCOMPROMISED123",
4+
"present": false
5+
},
6+
"facts": {},
7+
"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"
14+
]
15+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"kwargs": {
3+
"present": false
4+
},
5+
"exception": {
6+
"names": ["OperationError"],
7+
"message": "For removal, either `dest` or `keyid` must be provided"
8+
},
9+
"facts": {}
10+
}

0 commit comments

Comments
 (0)