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 ,
1820)
1921from pyinfra .facts .deb import DebPackage , DebPackages
2022from pyinfra .facts .files import File
21- from pyinfra .facts .gpg import GpgKey
23+ from pyinfra .facts .gpg import GpgKey , GpgKeyrings
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,197 @@ 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 :
51+ """
52+ Produce a filesystem-friendly name from an URL host/basename or a local filename.
5053 """
51- Add apt gpg keys with ``apt-key``.
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"
5258
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
5659
57- keyserver/id:
58- These must be provided together.
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"
5997
60- .. warning::
61- ``apt-key`` is deprecated in Debian, it is recommended NOT to use this
62- operation and instead follow the instructions here:
98+ return f"/etc/apt/keyrings/{ base } .gpg"
6399
64- https://wiki.debian.org/DebianRepository/UseThirdParty
65100
66- **Examples:**
101+ def _get_apt_keys_comprehensive () -> dict [str , str ]:
102+ """
103+ Get all GPG keys available in APT directories using the GpgKeyrings fact.
104+ This provides more comprehensive coverage than AptKeys fact.
105+ Falls back gracefully if GpgKeyrings data is not available.
67106
68- .. code:: python
107+ Returns:
108+ dict: Key ID -> keyring file path mapping
109+ """
110+ try :
111+ apt_directories = ["/etc/apt/trusted.gpg.d" , "/etc/apt/keyrings" , "/usr/share/keyrings" ]
112+ keyrings_info = host .get_fact (GpgKeyrings , directories = apt_directories )
113+
114+ all_keys = {}
115+ for keyring_path , keyring_data in keyrings_info .items ():
116+ keys = keyring_data .get ("keys" , {})
117+ for key_id in keys .keys ():
118+ all_keys [key_id ] = keyring_path
119+
120+ return all_keys
121+ except (KeyError , AttributeError ):
122+ # Fallback to empty dict if GpgKeyrings fact is not available (e.g., in tests)
123+ return {}
124+
125+
126+ @operation ()
127+ def key (
128+ src : str | None = None ,
129+ keyserver : str | None = None ,
130+ keyid : str | list [str ] | None = None ,
131+ dest : str | None = None ,
132+ present : bool = True ,
133+ ):
134+ """
135+ Add or remove apt GPG keys using modern keyring management.
136+
137+ This operation manages GPG keys for APT repos without using the deprecated apt-key command.
138+ Keys are stored in /etc/apt/keyrings/ and can be referenced in source lists via signed-by=.
139+
140+ Args:
141+ src: filename or URL to a key (ASCII .asc or binary .gpg)
142+ keyserver: keyserver URL for fetching keys by ID
143+ keyid: key ID or list of key IDs (required with keyserver, optional for removal)
144+ dest: optional keyring path ('.gpg' will be enforced, defaults under /etc/apt/keyrings)
145+ present: whether the key should be present (True) or absent (False)
146+
147+ Behavior:
148+ - Installation: Idempotent via AptKeys - if key IDs are already present, nothing changes
149+ - Removal: Uses GpgKeyrings fact to find and remove keys from APT directories
150+ - If src is ASCII (.asc), it will be dearmored; if binary (.gpg), it's copied as-is
151+ - Keyserver flow uses temporary GNUPGHOME, then exports to destination keyring
152+
153+ Examples:
154+ apt.key(
155+ name="Add Docker apt GPG key",
156+ src="https://download.docker.com/linux/debian/gpg",
157+ dest="docker.gpg",
158+ )
69159
70- # Note: If using URL, wget is assumed to be installed.
71160 apt.key(
72- name="Add the Docker apt gpg key",
73- src="https://download.docker.com/linux/ubuntu/gpg",
161+ name="Remove specific keyring file",
162+ dest="old-vendor.gpg",
163+ present=False,
74164 )
75165
76166 apt.key(
77- name="Install VirtualBox key",
78- src="https://www.virtualbox.org/download/oracle_vbox_2016.asc",
167+ name="Remove key by ID from all APT keyrings",
168+ keyid="0xCOMPROMISED123",
169+ present=False,
170+ )
171+
172+ apt.key(
173+ name="Fetch keys from keyserver",
174+ keyserver="hkps://keyserver.ubuntu.com",
175+ keyid=["0xD88E42B4", "0x7EA0A9C3"],
176+ dest="vendor-archive.gpg",
79177 )
80178 """
81179
180+ # Handle removal operations using the GPG infrastructure
181+ if present is False :
182+ # Use the GPG operation for removal, but restrict to APT directories
183+ apt_working_dirs = ["/etc/apt/trusted.gpg.d" , "/etc/apt/keyrings" , "/usr/share/keyrings" ]
184+ yield from gpg .key ._inner (
185+ dest = dest ,
186+ keyid = keyid ,
187+ present = False ,
188+ working_dirs = apt_working_dirs ,
189+ )
190+ return
191+
192+ # Installation logic (existing code)
193+ # Get comprehensive view of all keys in APT directories
194+ existing_keys_comprehensive = _get_apt_keys_comprehensive ()
195+ # Also get the legacy AptKeys fact for compatibility
82196 existing_keys = host .get_fact (AptKeys )
83197
198+ # Combine both sources of key information for complete coverage
199+ all_available_keys = set (existing_keys_comprehensive .keys ()) | set (existing_keys .keys ())
200+
201+ # Check idempotency for src branch
84202 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 ))
203+ key_data = host .get_fact (GpgKey , src = src ) # Parses the key(s) from src to extract key IDs
204+ keyids_from_src = list (key_data .keys ()) if key_data else []
205+
206+ # If we don't know the IDs (eg. unreachable URL), we cannot determine idempotency
207+ # -> try to install.
208+ # Otherwise, skip if all key IDs are already present.
209+ if keyids_from_src and all (kid in all_available_keys for kid in keyids_from_src ):
210+ host .noop (f"All keys from { src } are already available in the apt keychain" )
211+ return
212+
213+ dest_path = _derive_dest_from_src_and_keyids (src , keyids_from_src or None , dest )
98214
99- if keyserver :
215+ # Check idempotency for keyserver branch
216+ elif keyserver :
100217 if not keyid :
101218 raise OperationError ("`keyid` must be provided with `keyserver`" )
102219
103220 if isinstance (keyid , str ):
104221 keyid = [keyid ]
105222
106- 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- )
223+ needed_keys = sorted (set (keyid ) - all_available_keys )
224+ if not needed_keys :
225+ host .noop (f"Keys { ', ' .join (keyid )} are already available in the apt keychain" )
226+ return
227+
228+ dest_path = _derive_dest_from_src_and_keyids (None , needed_keys , dest )
229+ # Only install the needed keys
230+ keyid = needed_keys
231+
232+ # Use the generic GPG operation to install the key
233+ yield from gpg .key ._inner (
234+ src = src ,
235+ dest = dest_path ,
236+ keyserver = keyserver ,
237+ keyid = keyid ,
238+ dearmor = True ,
239+ mode = "0644" ,
240+ )
118241
119242
120243@operation ()
0 commit comments