1010
1111from pyinfra import host
1212from pyinfra .api import operation
13- from pyinfra .api .exceptions import OperationError
1413from pyinfra .facts .apt import (
15- AptKeys ,
1614 AptSources ,
1715 SimulateOperationWillChange ,
1816 noninteractive_apt ,
1917 parse_apt_repo ,
2018)
2119from pyinfra .facts .deb import DebPackage , DebPackages
2220from pyinfra .facts .files import File
23- from pyinfra .facts .gpg import GpgKey , GpgKeyrings
2421from pyinfra .facts .server import Date
2522from pyinfra .operations import files , gpg
2623
2724from .util .packaging import ensure_packages
2825
2926APT_UPDATE_FILENAME = "/var/lib/apt/periodic/update-success-stamp"
27+ APT_KEYRING_DIRS = ["/etc/apt/trusted.gpg.d" , "/etc/apt/keyrings" , "/usr/share/keyrings" ]
3028
3129
3230def _simulate_then_perform (command : str ):
@@ -47,82 +45,47 @@ def _simulate_then_perform(command: str):
4745 yield noninteractive_apt (command )
4846
4947
50- def _sanitize_apt_keyring_name (name : str ) -> str :
48+ def _sanitize_keyring_part (name : str ) -> str :
5149 """
52- Produce a filesystem-friendly name from an URL host/ basename or a local filename .
50+ Produce a filesystem-friendly segment from a URL host, basename, or key ID .
5351 """
5452 name = name .strip ().lower ()
5553 name = re .sub (r"[^\w.-]+" , "_" , name )
5654 name = re .sub (r"_+" , "_" , name ).strip ("_." )
5755 return name or "apt-keyring"
5856
5957
60- def _derive_dest_from_src_and_keyids (
61- src : str | None , keyids : list [str ] | None , dest : str | None
62- ) -> str :
58+ def _derive_dest_from_src_and_keyids (src : str | None , keyids : list [str ] | None ) -> str :
6359 """
64- Compute a stable destination path in /etc/apt/keyrings/.
60+ Compute a stable destination path in /etc/apt/keyrings/ from a source or key IDs.
61+
6562 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"
63+ 1) from src (URL domain + basename, or local basename)
64+ 2) from keyids (joined)
65+ 3) fallback "apt-keyring.gpg"
7066 """
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-
7967 base = None
8068 if src :
8169 parsed = urlparse (src )
8270 if parsed .scheme and parsed .netloc :
83- host_name = _sanitize_apt_keyring_name (parsed .netloc .replace (":" , "_" ))
84- bn = _sanitize_apt_keyring_name (
71+ domain_part = _sanitize_keyring_part (parsed .netloc .replace (":" , "_" ))
72+ bn = _sanitize_keyring_part (
8573 (parsed .path .rsplit ("/" , 1 )[- 1 ] or "key" ).replace (".asc" , "" ).replace (".gpg" , "" )
8674 )
87- base = f"{ host_name } -{ bn } "
75+ base = f"{ domain_part } -{ bn } "
8876 else :
89- bn = _sanitize_apt_keyring_name (
77+ bn = _sanitize_keyring_part (
9078 src .rsplit ("/" , 1 )[- 1 ].replace (".asc" , "" ).replace (".gpg" , "" )
9179 )
9280 base = bn or "key"
9381 elif keyids :
94- base = "keyserver-" + _sanitize_apt_keyring_name ("-" .join (keyids ))
82+ base = "keyserver-" + _sanitize_keyring_part ("-" .join (keyids ))
9583 else :
9684 base = "apt-keyring"
9785
9886 return f"/etc/apt/keyrings/{ base } .gpg"
9987
10088
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.
106-
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-
12689@operation ()
12790def key (
12891 src : str | None = None ,
@@ -132,26 +95,27 @@ def key(
13295 present : bool = True ,
13396):
13497 """
135- Add or remove apt GPG keys using modern keyring management.
98+ Add or remove APT GPG keys using modern keyring management (no ``apt-key``) .
13699
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=.
100+ Keys are written to ``/etc/apt/keyrings/`` and can be referenced in source entries
101+ via ``signed-by=``. The destination filename is derived automatically from the source
102+ URL or key IDs when not specified explicitly.
139103
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)
104+ + src: filename or URL to a key (``.asc`` ASCII-armored or binary ``.gpg``)
105+ + keyserver: keyserver URL for fetching keys by ID
106+ + keyid: key ID or list of key IDs (required with ``keyserver``, optional for removal)
107+ + dest: destination filename or absolute path — ``.gpg`` extension is enforced;
108+ relative names are resolved under `` /etc/apt/keyrings/``
109+ + present: whether the key should be present (default: `` True`` ) or removed
146110
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
111+ .. note: :
112+ ASCII-armored keys (``.asc``) are automatically dearmored on installation.
113+ Keyserver fetches use a temporary ``GNUPGHOME`` and export binary keyrings.
114+ Removal without ``keyid`` deletes the whole keyring file; with ``keyid`` it
115+ removes individual keys and prunes empty files.
152116
153117 .. warning::
154- ``apt-key`` is deprecated in Debian. This operation follows modern keyring management :
118+ ``apt-key`` is deprecated in Debian. This operation follows the modern approach :
155119 https://wiki.debian.org/DebianRepository/UseThirdParty
156120
157121 **Examples:**
@@ -187,64 +151,34 @@ def key(
187151 )
188152 """
189153
190- # Handle removal operations using the GPG infrastructure
191- if present is False :
192- # Use the GPG operation for removal, but restrict to APT directories
193- apt_working_dirs = ["/etc/apt/trusted.gpg.d" , "/etc/apt/keyrings" , "/usr/share/keyrings" ]
154+ # Special case: remove by key ID without explicit destination → search all APT keyring dirs
155+ if not present and keyid and not dest and not src and not keyserver :
194156 yield from gpg .key ._inner (
195- dest = dest ,
196157 keyid = keyid ,
197158 present = False ,
198- working_dirs = apt_working_dirs ,
159+ working_dirs = APT_KEYRING_DIRS ,
199160 )
200161 return
201162
202- # Installation logic (existing code)
203- # Get comprehensive view of all keys in APT directories
204- existing_keys_comprehensive = _get_apt_keys_comprehensive ()
205- # Also get the legacy AptKeys fact for compatibility
206- existing_keys = host .get_fact (AptKeys )
207-
208- # Combine both sources of key information for complete coverage
209- all_available_keys = set (existing_keys_comprehensive .keys ()) | set (existing_keys .keys ())
210-
211- # Check idempotency for src branch
212- if src :
213- key_data = host .get_fact (GpgKey , src = src ) # Parses the key(s) from src to extract key IDs
214- keyids_from_src = list (key_data .keys ()) if key_data else []
215-
216- # If we don't know the IDs (eg. unreachable URL), we cannot determine idempotency
217- # -> try to install.
218- # Otherwise, skip if all key IDs are already present.
219- if keyids_from_src and all (kid in all_available_keys for kid in keyids_from_src ):
220- host .noop (f"All keys from { src } are already available in the apt keychain" )
221- return
222-
223- dest_path = _derive_dest_from_src_and_keyids (src , keyids_from_src or None , dest )
224-
225- # Check idempotency for keyserver branch
226- elif keyserver :
227- if not keyid :
228- raise OperationError ("`keyid` must be provided with `keyserver`" )
229-
230- if isinstance (keyid , str ):
231- keyid = [keyid ]
232-
233- needed_keys = sorted (set (keyid ) - all_available_keys )
234- if not needed_keys :
235- host .noop (f"Keys { ', ' .join (keyid )} are already available in the apt keychain" )
236- return
237-
238- dest_path = _derive_dest_from_src_and_keyids (None , needed_keys , dest )
239- # Only install the needed keys
240- keyid = needed_keys
163+ # Resolve destination path under /etc/apt/keyrings/
164+ if dest and not dest .startswith ("/" ):
165+ dest = f"/etc/apt/keyrings/{ dest } "
166+ elif not dest :
167+ if src :
168+ dest = _derive_dest_from_src_and_keyids (src , None )
169+ elif keyserver and keyid :
170+ keyid_list = [keyid ] if isinstance (keyid , str ) else keyid
171+ dest = _derive_dest_from_src_and_keyids (None , keyid_list )
172+ else :
173+ dest = "/etc/apt/keyrings/apt-key.gpg"
241174
242- # Use the generic GPG operation to install the key
175+ # Delegate everything to gpg.key with APT-specific defaults
243176 yield from gpg .key ._inner (
244177 src = src ,
245- dest = dest_path ,
178+ dest = dest ,
246179 keyserver = keyserver ,
247180 keyid = keyid ,
181+ present = present ,
248182 dearmor = True ,
249183 mode = "0644" ,
250184 )
0 commit comments