44
55from  __future__ import  annotations 
66
7+ import  re 
78from  datetime  import  timedelta 
89from  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 } \" $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 } 
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 } \" { 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 )}  )
118207
119208
120209@operation () 
0 commit comments