Skip to content

Commit 5d70a6b

Browse files
committed
Manage deb822 format and apt-key removal in trixie
1 parent c557b99 commit 5d70a6b

File tree

2 files changed

+253
-43
lines changed

2 files changed

+253
-43
lines changed

pyinfra/facts/apt.py

Lines changed: 128 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,75 @@ def parse_apt_repo(name):
6060
}
6161

6262

63+
def parse_deb822_stanza(lines: list[str]):
64+
"""Parse a deb822 style repository stanza.
65+
66+
deb822 sources are key/value pairs separated by blank lines, eg::
67+
68+
Types: deb
69+
URIs: http://deb.debian.org/debian
70+
Suites: bookworm
71+
Components: main contrib
72+
Architectures: amd64
73+
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
74+
75+
Returns a list of dicts matching the legacy ``parse_apt_repo`` output so the
76+
rest of pyinfra can remain backwards compatible. A stanza may define
77+
multiple types/URIs/suites which we expand into individual repo dicts.
78+
"""
79+
80+
if not lines:
81+
return []
82+
83+
data: dict[str, str] = {}
84+
for line in lines:
85+
if not line or line.startswith("#"):
86+
continue
87+
# Field-Name: value
88+
try:
89+
key, value = line.split(":", 1)
90+
except ValueError: # malformed line
91+
continue
92+
data[key.strip()] = value.strip()
93+
94+
required = ("Types", "URIs", "Suites")
95+
if not all(field in data for field in required): # not a valid stanza
96+
return []
97+
98+
types = data.get("Types", "").split()
99+
uris = data.get("URIs", "").split()
100+
suites = data.get("Suites", "").split()
101+
components = data.get("Components", "").split()
102+
103+
# Map deb822 specific fields to legacy option names
104+
options: dict[str, object] = {}
105+
if architectures := data.get("Architectures"):
106+
archs = architectures.split()
107+
if archs:
108+
options["arch"] = archs if len(archs) > 1 else archs[0]
109+
if signed_by := data.get("Signed-By"):
110+
signed = signed_by.split()
111+
options["signed-by"] = signed if len(signed) > 1 else signed[0]
112+
if trusted := data.get("Trusted"):
113+
options["trusted"] = trusted.lower()
114+
115+
repos = []
116+
# Produce combinations – in most real-world cases these will each be one.
117+
for _type in types or ["deb"]:
118+
for uri in uris:
119+
for suite in suites:
120+
repos.append(
121+
{
122+
"options": dict(options), # copy per entry
123+
"type": _type,
124+
"url": uri,
125+
"distribution": suite,
126+
"components": components,
127+
}
128+
)
129+
return repos
130+
131+
63132
class AptSources(FactBase):
64133
"""
65134
Returns a list of installed apt sources:
@@ -78,9 +147,11 @@ class AptSources(FactBase):
78147

79148
@override
80149
def command(self) -> str:
150+
# Include deb822 style .sources files in addition to legacy .list files.
81151
return make_cat_files_command(
82152
"/etc/apt/sources.list",
83153
"/etc/apt/sources.list.d/*.list",
154+
"/etc/apt/sources.list.d/*.sources",
84155
)
85156

86157
@override
@@ -93,11 +164,45 @@ def requires_command(self) -> str:
93164
def process(self, output):
94165
repos = []
95166

96-
for line in output:
167+
deb822_buffer: list[str] = []
168+
inside_deb822 = False
169+
170+
def flush_deb822():
171+
nonlocal deb822_buffer, inside_deb822, repos
172+
if deb822_buffer:
173+
repos.extend(parse_deb822_stanza(deb822_buffer))
174+
deb822_buffer = []
175+
inside_deb822 = False
176+
177+
for raw_line in output:
178+
line = raw_line.strip()
179+
180+
# Blank line -> possible end of deb822 stanza
181+
if line == "":
182+
flush_deb822()
183+
continue
184+
185+
# Heuristic: deb822 stanzas use Key: Value capitalization, while
186+
# legacy sources begin with 'deb' or 'deb-src'. Detect stanza start.
187+
if re.match(r"^[A-Z][A-Za-z-]+:\s+", line):
188+
# Starting or continuing a deb822 stanza
189+
inside_deb822 = True
190+
deb822_buffer.append(line)
191+
continue
192+
193+
# If we were processing a stanza and hit a non deb822 line, flush it
194+
if inside_deb822:
195+
flush_deb822()
196+
197+
# Legacy single-line entry
97198
repo = parse_apt_repo(line)
98199
if repo:
99200
repos.append(repo)
100201

202+
# Flush any remaining deb822 stanza at EOF
203+
if inside_deb822:
204+
flush_deb822()
205+
101206
return repos
102207

103208

@@ -115,14 +220,30 @@ class AptKeys(GpgFactBase):
115220
}
116221
"""
117222

118-
# This requires both apt-key *and* apt-key itself requires gpg
119223
@override
120224
def command(self) -> str:
121-
return "! command -v gpg || apt-key list --with-colons"
122-
123-
@override
124-
def requires_command(self) -> str:
125-
return "apt-key"
225+
# Prefer not to use deprecated apt-key even if present. Iterate over keyrings
226+
# directly. This maintains backwards compatibility of output with the
227+
# previous implementation which fell back to this method.
228+
return (
229+
"for f in "
230+
" /etc/apt/trusted.gpg "
231+
" /etc/apt/trusted.gpg.d/*.gpg /etc/apt/trusted.gpg.d/*.asc "
232+
" /etc/apt/keyrings/*.gpg /etc/apt/keyrings/*.asc "
233+
" /usr/share/keyrings/*.gpg /usr/share/keyrings/*.asc "
234+
"; do "
235+
" [ -e \"$f\" ] || continue; "
236+
" case \"$f\" in "
237+
" *.asc) "
238+
" gpg --batch --show-keys --with-colons --keyid-format LONG \"$f\" "
239+
" ;; "
240+
" *) "
241+
" gpg --batch --no-default-keyring --keyring \"$f\" "
242+
" --list-keys --with-colons --keyid-format LONG "
243+
" ;; "
244+
" esac; "
245+
"done"
246+
)
126247

127248

128249
class AptSimulationDict(TypedDict):

pyinfra/operations/apt.py

Lines changed: 125 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
import re
78
from datetime import timedelta
89
from urllib.parse import urlparse
910

@@ -45,57 +46,139 @@ def _simulate_then_perform(command: str):
4546
yield noninteractive_apt(command)
4647

4748

48-
@operation()
49-
def key(src: str | None = None, keyserver: str | None = None, keyid: str | list[str] | None = None):
49+
def _sanitize_apt_keyring_name(name: str) -> str:
5050
"""
51-
Add apt gpg keys with ``apt-key``.
52-
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
51+
Produce a filesystem-friendly name from an URL host/basename or a local filename.
52+
"""
53+
name = name.strip().lower()
54+
name = re.sub(r"[^\w.-]+", "_", name)
55+
name = re.sub(r"_+", "_", name).strip("_.")
56+
return name or "apt-keyring"
5657

57-
keyserver/id:
58-
These must be provided together.
5958

60-
.. warning::
61-
``apt-key`` is deprecated in Debian, it is recommended NOT to use this
62-
operation and instead follow the instructions here:
59+
def _derive_dest_from_src_and_keyids(src: str | None, keyids: list[str] | None, dest: str | None) -> str:
60+
"""
61+
Compute a stable destination path in /etc/apt/keyrings/.
62+
Priority:
63+
1) explicit dest if provided
64+
2) from src (URL host + basename, or local basename)
65+
3) from keyids (joined)
66+
4) fallback "apt-keyring.gpg"
67+
"""
68+
if dest:
69+
# Ensure it ends with .gpg and is absolute under /etc/apt/keyrings
70+
if not dest.endswith(".gpg"):
71+
dest += ".gpg"
72+
if not dest.startswith("/"):
73+
dest = f"/etc/apt/keyrings/{dest}"
74+
return dest
75+
76+
base = None
77+
if src:
78+
parsed = urlparse(src)
79+
if parsed.scheme and parsed.netloc:
80+
host = _sanitize_apt_keyring_name(parsed.netloc.replace(":", "_"))
81+
bn = _sanitize_apt_keyring_name((parsed.path.rsplit("/", 1)[-1] or "key").replace(".asc", "").replace(".gpg", ""))
82+
base = f"{host}-{bn}"
83+
else:
84+
bn = _sanitize_apt_keyring_name(src.rsplit("/", 1)[-1].replace(".asc", "").replace(".gpg", ""))
85+
base = bn or "key"
86+
elif keyids:
87+
base = "keyserver-" + _sanitize_apt_keyring_name("-".join(keyids))
88+
else:
89+
base = "apt-keyring"
6390

64-
https://wiki.debian.org/DebianRepository/UseThirdParty
91+
return f"/etc/apt/keyrings/{base}.gpg"
6592

66-
**Examples:**
6793

68-
.. code:: python
69-
70-
# Note: If using URL, wget is assumed to be installed.
94+
@operation()
95+
def key(
96+
src: str | None = None,
97+
keyserver: str | None = None,
98+
keyid: str | list[str] | None = None,
99+
dest: str | None = None,
100+
):
101+
"""
102+
Add apt GPG keys *without* apt-key:
103+
- Keys are stored under /etc/apt/keyrings/<name>.gpg (binary, dearmored if needed).
104+
- You must reference the resulting file in your apt source via `signed-by=...`.
105+
106+
Args:
107+
src: filename or URL to a key (ASCII .asc or binary .gpg)
108+
keyserver: keyserver URL for fetching keys by ID
109+
keyid: key ID or list of key IDs (required with keyserver)
110+
dest: optional keyring filename/path ('.gpg' will be enforced, defaults under /etc/apt/keyrings)
111+
112+
Behavior:
113+
- Idempotent via AptKeys: if the key IDs are already present in any apt keyring, nothing is changed.
114+
- If src is ASCII (.asc), it will be dearmored; if binary (.gpg), it's copied as-is.
115+
- Keyserver flow uses a temporary GNUPGHOME, then exports and dearmors to the destination keyring.
116+
117+
Examples:
71118
apt.key(
72-
name="Add the Docker apt gpg key",
73-
src="https://download.docker.com/linux/ubuntu/gpg",
119+
name="Add Docker apt GPG key",
120+
src="https://download.docker.com/linux/debian/gpg",
121+
dest="docker.gpg",
74122
)
75123
76124
apt.key(
77125
name="Install VirtualBox key",
78126
src="https://www.virtualbox.org/download/oracle_vbox_2016.asc",
127+
dest="oracle-virtualbox.gpg",
128+
)
129+
130+
apt.key(
131+
name="Fetch keys from keyserver",
132+
keyserver="hkps://keyserver.ubuntu.com",
133+
keyid=["0xD88E42B4", "0x7EA0A9C3"],
134+
dest="vendor-archive.gpg",
79135
)
80136
"""
81137

138+
# Gather currently installed keys (across trusted.gpg.d/, keyrings/, etc.)
82139
existing_keys = host.get_fact(AptKeys)
83140

141+
# --- src branch: install a key from URL or local file ---
84142
if src:
85-
key_data = host.get_fact(GpgKey, src=src)
86-
if key_data:
87-
keyid = list(key_data.keys())
143+
key_data = host.get_fact(GpgKey, src=src) # Parses the key(s) from src to extract key IDs
144+
keyids_from_src = list(key_data.keys()) if key_data else []
145+
146+
# If we don't know the IDs (eg. unreachable URL), we cannot determine idempotency -> try to install.
147+
# Otherwise, skip if all key IDs are already present.
148+
if (not keyids_from_src) or (not all(kid in existing_keys for kid in keyids_from_src)):
149+
dest_path = _derive_dest_from_src_and_keyids(src, keyids_from_src or None, dest)
88150

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!
92151
if urlparse(src).scheme:
93-
yield "(wget -O - {0} || curl -sSLf {0}) | apt-key add -".format(src)
152+
# Remote source: download to a temp file, then install/dearmor accordingly
153+
yield (
154+
"sh -c 'set -e;"
155+
" install -d -m 0755 /etc/apt/keyrings;"
156+
" tmp=$(mktemp);"
157+
f" (wget -qO \"$tmp\" {src} || curl -sSLf -o \"$tmp\" {src});"
158+
" if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"$tmp\"; then"
159+
f" gpg --batch --dearmor -o \"{dest_path}\" \"$tmp\";"
160+
" else"
161+
f" install -m 0644 \"$tmp\" \"{dest_path}\";"
162+
" fi;"
163+
" rm -f \"$tmp\";"
164+
f" chmod 0644 \"{dest_path}\"'"
165+
)
94166
else:
95-
yield "apt-key add {0}".format(src)
167+
# Local file already present on the target
168+
yield (
169+
"sh -c 'set -e;"
170+
" install -d -m 0755 /etc/apt/keyrings;"
171+
f" if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"{src}\"; then"
172+
f" gpg --batch --dearmor -o \"{dest_path}\" \"{src}\";"
173+
" else"
174+
f" install -m 0644 \"{src}\" \"{dest_path}\";"
175+
" fi;"
176+
f" chmod 0644 \"{dest_path}\"'"
177+
)
96178
else:
97-
host.noop("All keys from {0} are already available in the apt keychain".format(src))
179+
host.noop(f"All keys from {src} are already available in the apt keychain")
98180

181+
# --- keyserver branch: fetch one or multiple keys by ID ---
99182
if keyserver:
100183
if not keyid:
101184
raise OperationError("`keyid` must be provided with `keyserver`")
@@ -105,16 +188,22 @@ def key(src: str | None = None, keyserver: str | None = None, keyid: str | list[
105188

106189
needed_keys = sorted(set(keyid) - set(existing_keys.keys()))
107190
if needed_keys:
108-
yield "apt-key adv --keyserver {0} --recv-keys {1}".format(
109-
keyserver,
110-
" ".join(needed_keys),
191+
dest_path = _derive_dest_from_src_and_keyids(None, needed_keys, dest)
192+
joined = " ".join(needed_keys)
193+
# Use a temporary GNUPGHOME so we don't pollute the system/user keyring,
194+
# then export and dearmor to the APT keyring destination.
195+
yield (
196+
"sh -c 'set -e;"
197+
" install -d -m 0755 /etc/apt/keyrings;"
198+
" tmp=$(mktemp -d);"
199+
" export GNUPGHOME=\"$tmp\";"
200+
f" gpg --batch --keyserver \"{keyserver}\" --recv-keys {joined};"
201+
f" gpg --batch --export {joined} | gpg --batch --dearmor -o \"{dest_path}\";"
202+
" rm -rf \"$tmp\";"
203+
f" chmod 0644 \"{dest_path}\"'"
111204
)
112205
else:
113-
host.noop(
114-
"Keys {0} are already available in the apt keychain".format(
115-
", ".join(keyid),
116-
),
117-
)
206+
host.noop(f"Keys {', '.join(keyid)} are already available in the apt keychain")
118207

119208

120209
@operation()

0 commit comments

Comments
 (0)