Skip to content

Commit a3ded84

Browse files
committed
operations/gpg: add GPG key management operations and tests
1 parent 4c1c782 commit a3ded84

File tree

12 files changed

+416
-0
lines changed

12 files changed

+416
-0
lines changed

src/pyinfra/operations/gpg.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""
2+
Manage GPG keys and keyrings.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from urllib.parse import urlparse
8+
9+
from pyinfra import host
10+
from pyinfra.api import OperationError, operation
11+
from pyinfra.facts.gpg import GpgKey
12+
13+
from . import files
14+
15+
16+
@operation()
17+
def key(
18+
src: str | None = None,
19+
dest: str | None = None,
20+
keyserver: str | None = None,
21+
keyid: str | list[str] | None = None,
22+
dearmor: bool = True,
23+
mode: str = "0644",
24+
):
25+
"""
26+
Install GPG keys from various sources.
27+
28+
Args:
29+
src: filename or URL to a key (ASCII .asc or binary .gpg)
30+
dest: destination path for the key file (required)
31+
keyserver: keyserver URL for fetching keys by ID
32+
keyid: key ID or list of key IDs (required with keyserver)
33+
dearmor: whether to convert ASCII armored keys to binary format
34+
mode: file permissions for the installed key
35+
36+
Examples:
37+
gpg.key(
38+
name="Install Docker GPG key",
39+
src="https://download.docker.com/linux/debian/gpg",
40+
dest="/etc/apt/keyrings/docker.gpg",
41+
)
42+
43+
gpg.key(
44+
name="Fetch keys from keyserver",
45+
keyserver="hkps://keyserver.ubuntu.com",
46+
keyid=["0xD88E42B4", "0x7EA0A9C3"],
47+
dest="/etc/apt/keyrings/vendor.gpg",
48+
)
49+
"""
50+
51+
if not src and not keyserver:
52+
raise OperationError("Either `src` or `keyserver` must be provided")
53+
54+
if keyserver and not keyid:
55+
raise OperationError("`keyid` must be provided with `keyserver`")
56+
57+
if not dest:
58+
raise OperationError("`dest` must be provided")
59+
60+
# Ensure destination directory exists
61+
dest_dir = dest.rsplit("/", 1)[0]
62+
yield from files.directory._inner(
63+
path=dest_dir,
64+
mode="0755",
65+
present=True,
66+
)
67+
68+
# --- src branch: install a key from URL or local file ---
69+
if src:
70+
if urlparse(src).scheme in ("http", "https"):
71+
# Remote source: download first, then process
72+
temp_file = host.get_temp_filename(src)
73+
74+
yield from files.download._inner(
75+
src=src,
76+
dest=temp_file,
77+
)
78+
79+
# Install the key and clean up temp file
80+
yield from _install_key_file(temp_file, dest, dearmor, mode)
81+
82+
# Clean up temp file using pyinfra
83+
yield from files.file._inner(
84+
path=temp_file,
85+
present=False,
86+
)
87+
else:
88+
# Local file: install directly
89+
yield from _install_key_file(src, dest, dearmor, mode)
90+
91+
# --- keyserver branch: fetch keys by ID ---
92+
if keyserver:
93+
if isinstance(keyid, str):
94+
keyid = [keyid]
95+
96+
joined = " ".join(keyid)
97+
98+
# Create temporary GPG home directory using pyinfra
99+
temp_dir = f"/tmp/pyinfra-gpg-{host.get_temp_filename('')[-8:]}"
100+
101+
yield from files.directory._inner(
102+
path=temp_dir,
103+
mode="0700", # GPG directories should be more restrictive
104+
present=True,
105+
)
106+
107+
# Export GNUPGHOME and fetch keys
108+
yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --keyserver "{keyserver}" --recv-keys {joined}'
109+
110+
# Export keys to destination
111+
if dearmor:
112+
yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --export {joined} | gpg --batch --dearmor -o "{dest}"'
113+
else:
114+
yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --export {joined} > "{dest}"'
115+
116+
# Clean up temporary directory using pyinfra
117+
yield from files.directory._inner(
118+
path=temp_dir,
119+
present=False,
120+
)
121+
122+
# Set proper permissions using pyinfra
123+
yield from files.file._inner(
124+
path=dest,
125+
mode=mode,
126+
present=True,
127+
)
128+
129+
130+
@operation()
131+
def dearmor(src: str, dest: str, mode: str = "0644"):
132+
"""
133+
Convert ASCII armored GPG key to binary format.
134+
135+
Args:
136+
src: source ASCII armored key file
137+
dest: destination binary key file
138+
mode: file permissions for the output file
139+
140+
Example:
141+
gpg.dearmor(
142+
name="Convert key to binary",
143+
src="/tmp/key.asc",
144+
dest="/etc/apt/keyrings/key.gpg",
145+
)
146+
"""
147+
148+
# Ensure destination directory exists
149+
dest_dir = dest.rsplit("/", 1)[0]
150+
yield from files.directory._inner(
151+
path=dest_dir,
152+
mode="0755",
153+
present=True,
154+
)
155+
156+
yield f'gpg --batch --dearmor -o "{dest}" "{src}"'
157+
158+
# Set proper permissions using pyinfra
159+
yield from files.file._inner(
160+
path=dest,
161+
mode=mode,
162+
present=True,
163+
)
164+
165+
166+
def _install_key_file(src_file: str, dest_path: str, dearmor: bool, mode: str):
167+
"""
168+
Helper function to install a GPG key file, dearmoring if necessary.
169+
"""
170+
if dearmor:
171+
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'
172+
else:
173+
yield f'cp "{src_file}" "{dest_path}"'
174+
175+
# Set proper permissions using pyinfra
176+
yield from files.file._inner(
177+
path=dest_path,
178+
mode=mode,
179+
present=True,
180+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/test.asc",
5+
"dest": "/tmp/test.gpg"
6+
},
7+
"facts": {
8+
"files.Directory": {
9+
"path=/tmp": {
10+
"mode": 755
11+
}
12+
},
13+
"files.File": {
14+
"path=/tmp/test.gpg": null
15+
}
16+
},
17+
"commands": [
18+
"gpg --batch --dearmor -o \"/tmp/test.gpg\" \"/tmp/test.asc\"",
19+
"touch /tmp/test.gpg",
20+
"chmod 644 /tmp/test.gpg"
21+
]
22+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/key.asc",
5+
"dest": "/etc/apt/keyrings/key.gpg",
6+
"mode": "0600"
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/etc/apt/keyrings": null
11+
},
12+
"files.File": {
13+
"path=/etc/apt/keyrings/key.gpg": null
14+
}
15+
},
16+
"commands": [
17+
"mkdir -p /etc/apt/keyrings",
18+
"chmod 755 /etc/apt/keyrings",
19+
"gpg --batch --dearmor -o \"/etc/apt/keyrings/key.gpg\" \"/tmp/key.asc\"",
20+
"mkdir -p /etc/apt/keyrings",
21+
"touch /etc/apt/keyrings/key.gpg",
22+
"chmod 600 /etc/apt/keyrings/key.gpg"
23+
]
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/local-key.asc",
5+
"dest": "/etc/apt/keyrings/test.gpg",
6+
"mode": "0600"
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/etc/apt/keyrings": null
11+
},
12+
"files.File": {
13+
"path=/etc/apt/keyrings/test.gpg": null
14+
}
15+
},
16+
"commands": [
17+
"mkdir -p /etc/apt/keyrings",
18+
"chmod 755 /etc/apt/keyrings",
19+
"if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"/tmp/local-key.asc\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/test.gpg\" \"/tmp/local-key.asc\"; else cp \"/tmp/local-key.asc\" \"/etc/apt/keyrings/test.gpg\"; fi",
20+
"mkdir -p /etc/apt/keyrings",
21+
"touch /etc/apt/keyrings/test.gpg",
22+
"chmod 600 /etc/apt/keyrings/test.gpg"
23+
]
24+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"keyserver": "hkps://keyserver.ubuntu.com",
5+
"keyid": ["0xD88E42B4", "0x7EA0A9C3"],
6+
"dest": "/etc/apt/keyrings/vendor.gpg"
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/etc/apt/keyrings": null,
11+
"path=/tmp/pyinfra-gpg-empfile_": null
12+
},
13+
"files.File": {
14+
"path=/etc/apt/keyrings/vendor.gpg": null
15+
}
16+
},
17+
"commands": [
18+
"mkdir -p /etc/apt/keyrings",
19+
"chmod 755 /etc/apt/keyrings",
20+
"mkdir -p /tmp/pyinfra-gpg-empfile_",
21+
"chmod 700 /tmp/pyinfra-gpg-empfile_",
22+
"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\"",
24+
"mkdir -p /etc/apt/keyrings",
25+
"touch /etc/apt/keyrings/vendor.gpg",
26+
"chmod 644 /etc/apt/keyrings/vendor.gpg"
27+
]
28+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"keyserver": "hkps://keyserver.ubuntu.com",
5+
"dest": "/etc/apt/keyrings/test.gpg"
6+
},
7+
"exception": {
8+
"names": ["OperationError"],
9+
"message": "`keyid` must be provided with `keyserver`"
10+
},
11+
"facts": {}
12+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"keyserver": "hkps://keyserver.ubuntu.com",
5+
"keyid": ["0xD88E42B4"],
6+
"dest": "/etc/apt/keyrings/vendor.gpg"
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/etc/apt/keyrings": null,
11+
"path=/tmp/pyinfra-gpg-empfile_": null
12+
},
13+
"files.File": {
14+
"path=/etc/apt/keyrings/vendor.gpg": null
15+
}
16+
},
17+
"commands": [
18+
"mkdir -p /etc/apt/keyrings",
19+
"chmod 755 /etc/apt/keyrings",
20+
"mkdir -p /tmp/pyinfra-gpg-empfile_",
21+
"chmod 700 /tmp/pyinfra-gpg-empfile_",
22+
"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\"",
24+
"mkdir -p /etc/apt/keyrings",
25+
"touch /etc/apt/keyrings/vendor.gpg",
26+
"chmod 644 /etc/apt/keyrings/vendor.gpg"
27+
]
28+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/local-key.asc",
5+
"dest": "/etc/apt/keyrings/test.gpg"
6+
},
7+
"facts": {
8+
"files.Directory": {
9+
"path=/etc/apt/keyrings": null
10+
},
11+
"files.File": {
12+
"path=/etc/apt/keyrings/test.gpg": null
13+
}
14+
},
15+
"commands": [
16+
"mkdir -p /etc/apt/keyrings",
17+
"chmod 755 /etc/apt/keyrings",
18+
"if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"/tmp/local-key.asc\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/test.gpg\" \"/tmp/local-key.asc\"; else cp \"/tmp/local-key.asc\" \"/etc/apt/keyrings/test.gpg\"; fi",
19+
"mkdir -p /etc/apt/keyrings",
20+
"touch /etc/apt/keyrings/test.gpg",
21+
"chmod 644 /etc/apt/keyrings/test.gpg"
22+
]
23+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/local-key.gpg",
5+
"dest": "/etc/apt/keyrings/test.gpg",
6+
"dearmor": false
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/etc/apt/keyrings": null
11+
},
12+
"files.File": {
13+
"path=/etc/apt/keyrings/test.gpg": null
14+
}
15+
},
16+
"commands": [
17+
"mkdir -p /etc/apt/keyrings",
18+
"chmod 755 /etc/apt/keyrings",
19+
"cp \"/tmp/local-key.gpg\" \"/etc/apt/keyrings/test.gpg\"",
20+
"mkdir -p /etc/apt/keyrings",
21+
"touch /etc/apt/keyrings/test.gpg",
22+
"chmod 644 /etc/apt/keyrings/test.gpg"
23+
]
24+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/test.asc"
5+
},
6+
"facts": {},
7+
"exception": {
8+
"names": ["OperationError"],
9+
"message": "`dest` must be provided"
10+
}
11+
}

0 commit comments

Comments
 (0)