44
55from __future__ import annotations
66
7+ import re
78from datetime import timedelta
89from urllib .parse import urlparse
910
1011from pyinfra import host
11- from pyinfra .api import OperationError , operation
12+ from pyinfra .api import operation
13+ from pyinfra .api .exceptions import OperationError
1214from pyinfra .facts .apt import (
1315 AptKeys ,
1416 AptSources ,
2022from pyinfra .facts .files import File
2123from pyinfra .facts .gpg import GpgKey
2224from pyinfra .facts .server import Date
25+ from pyinfra .operations import files , gpg
2326
24- from . import files
2527from .util .packaging import ensure_packages
2628
2729APT_UPDATE_FILENAME = "/var/lib/apt/periodic/update-success-stamp"
@@ -45,76 +47,143 @@ def _simulate_then_perform(command: str):
4547 yield noninteractive_apt (command )
4648
4749
48- @operation ()
49- def key (src : str | None = None , keyserver : str | None = None , keyid : str | list [str ] | None = None ):
50+ def _sanitize_apt_keyring_name (name : str ) -> str :
5051 """
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
56-
57- keyserver/id:
58- These must be provided together.
52+ Produce a filesystem-friendly name from an URL host/basename or a local filename.
53+ """
54+ name = name .strip ().lower ()
55+ name = re .sub (r"[^\w.-]+" , "_" , name )
56+ name = re .sub (r"_+" , "_" , name ).strip ("_." )
57+ return name or "apt-keyring"
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:
6359
64- https://wiki.debian.org/DebianRepository/UseThirdParty
60+ def _derive_dest_from_src_and_keyids (
61+ src : str | None , keyids : list [str ] | None , dest : str | None
62+ ) -> str :
63+ """
64+ Compute a stable destination path in /etc/apt/keyrings/.
65+ Priority:
66+ 1) explicit dest if provided
67+ 2) from src (URL host + basename, or local basename)
68+ 3) from keyids (joined)
69+ 4) fallback "apt-keyring.gpg"
70+ """
71+ if dest :
72+ # Ensure it ends with .gpg and is absolute under /etc/apt/keyrings
73+ if not dest .endswith (".gpg" ):
74+ dest += ".gpg"
75+ if not dest .startswith ("/" ):
76+ dest = f"/etc/apt/keyrings/{ dest } "
77+ return dest
78+
79+ base = None
80+ if src :
81+ parsed = urlparse (src )
82+ if parsed .scheme and parsed .netloc :
83+ host_name = _sanitize_apt_keyring_name (parsed .netloc .replace (":" , "_" ))
84+ bn = _sanitize_apt_keyring_name (
85+ (parsed .path .rsplit ("/" , 1 )[- 1 ] or "key" ).replace (".asc" , "" ).replace (".gpg" , "" )
86+ )
87+ base = f"{ host_name } -{ bn } "
88+ else :
89+ bn = _sanitize_apt_keyring_name (
90+ src .rsplit ("/" , 1 )[- 1 ].replace (".asc" , "" ).replace (".gpg" , "" )
91+ )
92+ base = bn or "key"
93+ elif keyids :
94+ base = "keyserver-" + _sanitize_apt_keyring_name ("-" .join (keyids ))
95+ else :
96+ base = "apt-keyring"
6597
66- **Examples:**
98+ return f"/etc/apt/keyrings/ { base } .gpg"
6799
68- .. code:: python
69100
70- # Note: If using URL, wget is assumed to be installed.
101+ @operation ()
102+ def key (
103+ src : str | None = None ,
104+ keyserver : str | None = None ,
105+ keyid : str | list [str ] | None = None ,
106+ dest : str | None = None ,
107+ ):
108+ """
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=...`.
112+
113+ Args:
114+ src: filename or URL to a key (ASCII .asc or binary .gpg)
115+ keyserver: keyserver URL for fetching keys by ID
116+ keyid: key ID or list of key IDs (required with keyserver)
117+ dest: optional keyring filename/path ('.gpg' will be enforced, defaults under /etc/apt/keyrings)
118+
119+ 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.
123+
124+ Examples:
71125 apt.key(
72- name="Add the Docker apt gpg key",
73- src="https://download.docker.com/linux/ubuntu/gpg",
126+ name="Add Docker apt GPG key",
127+ src="https://download.docker.com/linux/debian/gpg",
128+ dest="docker.gpg",
74129 )
75130
76131 apt.key(
77132 name="Install VirtualBox key",
78133 src="https://www.virtualbox.org/download/oracle_vbox_2016.asc",
134+ dest="oracle-virtualbox.gpg",
135+ )
136+
137+ apt.key(
138+ name="Fetch keys from keyserver",
139+ keyserver="hkps://keyserver.ubuntu.com",
140+ keyid=["0xD88E42B4", "0x7EA0A9C3"],
141+ dest="vendor-archive.gpg",
79142 )
80143 """
81144
145+ # Gather currently installed keys (across trusted.gpg.d/, keyrings/, etc.)
82146 existing_keys = host .get_fact (AptKeys )
83147
148+ # Check idempotency for src branch
84149 if src :
85- key_data = host .get_fact (GpgKey , src = src )
86- if key_data :
87- keyid = list (key_data .keys ())
88-
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!
92- if urlparse (src ).scheme :
93- yield "(wget -O - {0} || curl -sSLf {0}) | apt-key add -" .format (src )
94- else :
95- yield "apt-key add {0}" .format (src )
96- else :
97- host .noop ("All keys from {0} are already available in the apt keychain" .format (src ))
150+ key_data = host .get_fact (GpgKey , src = src ) # Parses the key(s) from src to extract key IDs
151+ keyids_from_src = list (key_data .keys ()) if key_data else []
152+
153+ # If we don't know the IDs (eg. unreachable URL), we cannot determine idempotency -> try to install.
154+ # 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 ):
156+ host .noop (f"All keys from { src } are already available in the apt keychain" )
157+ return
98158
99- if keyserver :
159+ dest_path = _derive_dest_from_src_and_keyids (src , keyids_from_src or None , dest )
160+
161+ # Check idempotency for keyserver branch
162+ elif keyserver :
100163 if not keyid :
101164 raise OperationError ("`keyid` must be provided with `keyserver`" )
102165
103166 if isinstance (keyid , str ):
104167 keyid = [keyid ]
105168
106169 needed_keys = sorted (set (keyid ) - set (existing_keys .keys ()))
107- if needed_keys :
108- yield "apt-key adv --keyserver {0} --recv-keys {1}" .format (
109- keyserver ,
110- " " .join (needed_keys ),
111- )
112- else :
113- host .noop (
114- "Keys {0} are already available in the apt keychain" .format (
115- ", " .join (keyid ),
116- ),
117- )
170+ if not needed_keys :
171+ host .noop (f"Keys { ', ' .join (keyid )} are already available in the apt keychain" )
172+ return
173+
174+ dest_path = _derive_dest_from_src_and_keyids (None , needed_keys , dest )
175+ # Only install the needed keys
176+ keyid = needed_keys
177+
178+ # Use the generic GPG operation to install the key
179+ yield from gpg .key ._inner (
180+ src = src ,
181+ dest = dest_path ,
182+ keyserver = keyserver ,
183+ keyid = keyid ,
184+ dearmor = True ,
185+ mode = "0644" ,
186+ )
118187
119188
120189@operation ()
0 commit comments