diff --git a/src/pyinfra/facts/apt.py b/src/pyinfra/facts/apt.py index a427f9e17..e3d0d8922 100644 --- a/src/pyinfra/facts/apt.py +++ b/src/pyinfra/facts/apt.py @@ -7,7 +7,6 @@ from pyinfra.api import FactBase from .gpg import GpgFactBase -from .util import make_cat_files_command def noninteractive_apt(command: str, force=False): @@ -60,44 +59,178 @@ def parse_apt_repo(name): } -class AptSources(FactBase): +def parse_deb822_stanza(lines: list[str]) -> list[dict[str, object]]: + """Parse a deb822 style repository stanza. + + deb822 sources are key/value pairs separated by blank lines, eg:: + + Types: deb + URIs: http://deb.debian.org/debian + Suites: bookworm + Components: main contrib + Architectures: amd64 + Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg + + Returns a list of dicts matching the legacy ``parse_apt_repo`` output so the + rest of pyinfra can remain backwards compatible. A stanza may define + multiple types/URIs/suites which we expand into individual repo dicts. """ - Returns a list of installed apt sources: - .. code:: python + if not lines: + return [] + + data: dict[str, str] = {} + for line in lines: + if not line or line.startswith("#"): + continue + # Field-Name: value + try: + key, value = line.split(":", 1) + except ValueError: # malformed line + continue + data[key.strip()] = value.strip() + + required = ("Types", "URIs", "Suites") + if not all(field in data for field in required): # not a valid stanza + return [] + + types = data.get("Types", "").split() + uris = data.get("URIs", "").split() + suites = data.get("Suites", "").split() + components = data.get("Components", "").split() + + # Map deb822 specific fields to legacy option names + options: dict[str, object] = {} + if architectures := data.get("Architectures"): + archs = architectures.split() + if archs: + options["arch"] = archs if len(archs) > 1 else archs[0] + if signed_by := data.get("Signed-By"): + signed = signed_by.split() + options["signed-by"] = signed if len(signed) > 1 else signed[0] + if trusted := data.get("Trusted"): + options["trusted"] = trusted.lower() + + repos = [] + # Produce combinations – in most real-world cases these will each be one. + for _type in types or ["deb"]: + for uri in uris: + for suite in suites: + repos.append( + { + "options": dict(options), # copy per entry + "type": _type, + "url": uri, + "distribution": suite, + "components": components, + } + ) + return repos + + +def parse_apt_list_file(lines: list[str]) -> list[dict[str, object]]: + """Parse legacy .list style apt source file. + + Each non-comment, non-empty line is a discrete repository definition in the + traditional ``deb http://... suite components`` syntax. + Returns a list of repo dicts (may be empty). + """ + repos = [] + for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + repo = parse_apt_repo(line) + if repo: + repos.append(repo) + return repos + + +def parse_deb822_sources_file( + lines: list[str], +) -> list[dict[str, object]]: + """Parse a full deb822 ``.sources`` file. + + Splits on blank lines into stanzas and uses ``parse_deb822_stanza`` for each + stanza. Returns a combined list of repo dicts for all stanzas. + """ + repos = [] + stanza: list[str] = [] + for raw in lines + [""]: # sentinel blank line to flush last stanza + line = raw.rstrip("\n") + if line.strip() == "": + if stanza: + repos.extend(parse_deb822_stanza(stanza)) + stanza = [] + continue + stanza.append(line) + return repos - [ - { - "type": "deb", - "url": "http://archive.ubuntu.org", - "distribution": "trusty", - "components", ["main", "multiverse"], - }, - ] + +class AptSources(FactBase): + """Returns a list of installed apt sources (legacy .list + deb822 .sources). + + Backwards compatible with historical output: a flat list of dicts: + + { + "type": "deb", + "url": "http://archive.ubuntu.org", + "distribution": "bookworm", + "components": ["main", "contrib"], + "options": { ... }, + } """ @override def command(self) -> str: - return make_cat_files_command( - "/etc/apt/sources.list", - "/etc/apt/sources.list.d/*.list", + # We emit file boundary markers so the parser can select the correct + # parsing function based on filename extension. + return ( + "sh -c '" + "for f in " + "/etc/apt/sources.list " + "/etc/apt/sources.list.d/*.list " + "/etc/apt/sources.list.d/*.sources; do " + '[ -e "$f" ] || continue; ' + 'echo "##FILE $f"; ' + 'cat "$f"; ' + "echo; " + "done'" ) @override def requires_command(self) -> str: - return "apt" # if apt installed, above should exist + return "apt" default = list @override - def process(self, output): - repos = [] - - for line in output: - repo = parse_apt_repo(line) - if repo: - repos.append(repo) - + def process(self, output): # type: ignore[override] + repos: list = [] + current_file: str | None = None + buffer: list[str] = [] + + def flush(): + nonlocal buffer, current_file, repos + if current_file is None or not buffer: + buffer = [] + return + if current_file.endswith(".sources"): + repos.extend(parse_deb822_sources_file(buffer)) + else: # treat anything else as legacy list syntax + repos.extend(parse_apt_list_file(buffer)) + buffer = [] + + for raw_line in output: + if raw_line.startswith("##FILE "): + # New file marker + flush() + current_file = raw_line.split(" ", 1)[1].strip() + continue + buffer.append(raw_line) + + # Flush last file + flush() return repos @@ -115,14 +248,30 @@ class AptKeys(GpgFactBase): } """ - # This requires both apt-key *and* apt-key itself requires gpg @override def command(self) -> str: - return "! command -v gpg || apt-key list --with-colons" - - @override - def requires_command(self) -> str: - return "apt-key" + # Prefer not to use deprecated apt-key even if present. Iterate over keyrings + # directly. This maintains backwards compatibility of output with the + # previous implementation which fell back to this method. + return ( + "for f in " + " /etc/apt/trusted.gpg " + " /etc/apt/trusted.gpg.d/*.gpg /etc/apt/trusted.gpg.d/*.asc " + " /etc/apt/keyrings/*.gpg /etc/apt/keyrings/*.asc " + " /usr/share/keyrings/*.gpg /usr/share/keyrings/*.asc " + "; do " + ' [ -e "$f" ] || continue; ' + ' case "$f" in ' + " *.asc) " + ' gpg --batch --show-keys --with-colons --keyid-format LONG "$f" ' + " ;; " + " *) " + ' gpg --batch --no-default-keyring --keyring "$f" ' + " --list-keys --with-colons --keyid-format LONG " + " ;; " + " esac; " + "done" + ) class AptSimulationDict(TypedDict): diff --git a/src/pyinfra/operations/apt.py b/src/pyinfra/operations/apt.py index ece334cae..7d889e490 100644 --- a/src/pyinfra/operations/apt.py +++ b/src/pyinfra/operations/apt.py @@ -4,11 +4,13 @@ from __future__ import annotations +import re from datetime import timedelta from urllib.parse import urlparse from pyinfra import host -from pyinfra.api import OperationError, operation +from pyinfra.api import operation +from pyinfra.api.exceptions import OperationError from pyinfra.facts.apt import ( AptKeys, AptSources, @@ -21,7 +23,8 @@ from pyinfra.facts.gpg import GpgKey from pyinfra.facts.server import Date -from . import files +from pyinfra.operations import files +from pyinfra.operations import gpg from .util.packaging import ensure_packages APT_UPDATE_FILENAME = "/var/lib/apt/periodic/update-success-stamp" @@ -45,76 +48,143 @@ def _simulate_then_perform(command: str): yield noninteractive_apt(command) -@operation() -def key(src: str | None = None, keyserver: str | None = None, keyid: str | list[str] | None = None): +def _sanitize_apt_keyring_name(name: str) -> str: """ - Add apt gpg keys with ``apt-key``. - - + src: filename or URL - + keyserver: URL of keyserver to fetch key from - + keyid: key ID or list of key IDs when using keyserver - - keyserver/id: - These must be provided together. + Produce a filesystem-friendly name from an URL host/basename or a local filename. + """ + name = name.strip().lower() + name = re.sub(r"[^\w.-]+", "_", name) + name = re.sub(r"_+", "_", name).strip("_.") + return name or "apt-keyring" - .. warning:: - ``apt-key`` is deprecated in Debian, it is recommended NOT to use this - operation and instead follow the instructions here: - https://wiki.debian.org/DebianRepository/UseThirdParty +def _derive_dest_from_src_and_keyids( + src: str | None, keyids: list[str] | None, dest: str | None +) -> str: + """ + Compute a stable destination path in /etc/apt/keyrings/. + Priority: + 1) explicit dest if provided + 2) from src (URL host + basename, or local basename) + 3) from keyids (joined) + 4) fallback "apt-keyring.gpg" + """ + if dest: + # Ensure it ends with .gpg and is absolute under /etc/apt/keyrings + if not dest.endswith(".gpg"): + dest += ".gpg" + if not dest.startswith("/"): + dest = f"/etc/apt/keyrings/{dest}" + return dest + + base = None + if src: + parsed = urlparse(src) + if parsed.scheme and parsed.netloc: + host = _sanitize_apt_keyring_name(parsed.netloc.replace(":", "_")) + bn = _sanitize_apt_keyring_name( + (parsed.path.rsplit("/", 1)[-1] or "key").replace(".asc", "").replace(".gpg", "") + ) + base = f"{host}-{bn}" + else: + bn = _sanitize_apt_keyring_name( + src.rsplit("/", 1)[-1].replace(".asc", "").replace(".gpg", "") + ) + base = bn or "key" + elif keyids: + base = "keyserver-" + _sanitize_apt_keyring_name("-".join(keyids)) + else: + base = "apt-keyring" - **Examples:** + return f"/etc/apt/keyrings/{base}.gpg" - .. code:: python - # Note: If using URL, wget is assumed to be installed. +@operation() +def key( + src: str | None = None, + keyserver: str | None = None, + keyid: str | list[str] | None = None, + dest: str | None = None, +): + """ + Add apt GPG keys *without* apt-key: + - Keys are stored under /etc/apt/keyrings/.gpg (binary, dearmored if needed). + - You must reference the resulting file in your apt source via `signed-by=...`. + + Args: + src: filename or URL to a key (ASCII .asc or binary .gpg) + keyserver: keyserver URL for fetching keys by ID + keyid: key ID or list of key IDs (required with keyserver) + dest: optional keyring filename/path ('.gpg' will be enforced, defaults under /etc/apt/keyrings) + + Behavior: + - Idempotent via AptKeys: if the key IDs are already present in any apt keyring, nothing is changed. + - If src is ASCII (.asc), it will be dearmored; if binary (.gpg), it's copied as-is. + - Keyserver flow uses a temporary GNUPGHOME, then exports and dearmors to the destination keyring. + + Examples: apt.key( - name="Add the Docker apt gpg key", - src="https://download.docker.com/linux/ubuntu/gpg", + name="Add Docker apt GPG key", + src="https://download.docker.com/linux/debian/gpg", + dest="docker.gpg", ) apt.key( name="Install VirtualBox key", src="https://www.virtualbox.org/download/oracle_vbox_2016.asc", + dest="oracle-virtualbox.gpg", + ) + + apt.key( + name="Fetch keys from keyserver", + keyserver="hkps://keyserver.ubuntu.com", + keyid=["0xD88E42B4", "0x7EA0A9C3"], + dest="vendor-archive.gpg", ) """ + # Gather currently installed keys (across trusted.gpg.d/, keyrings/, etc.) existing_keys = host.get_fact(AptKeys) + # Check idempotency for src branch if src: - key_data = host.get_fact(GpgKey, src=src) - if key_data: - keyid = list(key_data.keys()) - - if not keyid or not all(kid in existing_keys for kid in keyid): - # If URL, wget the key to stdout and pipe into apt-key, because the "adv" - # apt-key passes to gpg which doesn't always support https! - if urlparse(src).scheme: - yield "(wget -O - {0} || curl -sSLf {0}) | apt-key add -".format(src) - else: - yield "apt-key add {0}".format(src) - else: - host.noop("All keys from {0} are already available in the apt keychain".format(src)) + key_data = host.get_fact(GpgKey, src=src) # Parses the key(s) from src to extract key IDs + keyids_from_src = list(key_data.keys()) if key_data else [] + + # If we don't know the IDs (eg. unreachable URL), we cannot determine idempotency -> try to install. + # Otherwise, skip if all key IDs are already present. + if keyids_from_src and all(kid in existing_keys for kid in keyids_from_src): + host.noop(f"All keys from {src} are already available in the apt keychain") + return + + dest_path = _derive_dest_from_src_and_keyids(src, keyids_from_src or None, dest) - if keyserver: + # Check idempotency for keyserver branch + elif keyserver: if not keyid: raise OperationError("`keyid` must be provided with `keyserver`") - + if isinstance(keyid, str): keyid = [keyid] needed_keys = sorted(set(keyid) - set(existing_keys.keys())) - if needed_keys: - yield "apt-key adv --keyserver {0} --recv-keys {1}".format( - keyserver, - " ".join(needed_keys), - ) - else: - host.noop( - "Keys {0} are already available in the apt keychain".format( - ", ".join(keyid), - ), - ) + if not needed_keys: + host.noop(f"Keys {', '.join(keyid)} are already available in the apt keychain") + return + + dest_path = _derive_dest_from_src_and_keyids(None, needed_keys, dest) + # Only install the needed keys + keyid = needed_keys + + # Use the generic GPG operation to install the key + yield from gpg.key._inner( + src=src, + dest=dest_path, + keyserver=keyserver, + keyid=keyid, + dearmor=True, + mode="0644", + ) @operation() diff --git a/src/pyinfra/operations/gpg.py b/src/pyinfra/operations/gpg.py new file mode 100644 index 000000000..58fd13784 --- /dev/null +++ b/src/pyinfra/operations/gpg.py @@ -0,0 +1,265 @@ +""" +Manage GPG keys and keyrings. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +from pyinfra import host +from pyinfra.api import OperationError, operation +from pyinfra.facts.gpg import GpgKey + +from . import files +from pathlib import Path + + +@operation() +def key( + src: str | None = None, + dest: str | None = None, + keyserver: str | None = None, + keyid: str | list[str] | None = None, + dearmor: bool = True, + mode: str = "0644", + present: bool = True, +): + """ + Install or remove GPG keys from various sources. + + Args: + src: filename or URL to a key (ASCII .asc or binary .gpg) + dest: destination path for the key file (required for installation, optional for removal) + keyserver: keyserver URL for fetching keys by ID + keyid: key ID or list of key IDs (required with keyserver, optional for removal) + dearmor: whether to convert ASCII armored keys to binary format + mode: file permissions for the installed key + present: whether the key should be present (True) or absent (False) + When False: if dest is provided, removes from specific keyring; + if dest is None, removes from all APT keyrings; + if keyid is provided, removes specific key(s); + if keyid is None, removes entire keyring file(s) + + Examples: + gpg.key( + name="Install Docker GPG key", + src="https://download.docker.com/linux/debian/gpg", + dest="/etc/apt/keyrings/docker.gpg", + ) + + gpg.key( + name="Remove old GPG key file", + dest="/etc/apt/keyrings/old-key.gpg", + present=False, + ) + + gpg.key( + name="Remove specific key by ID", + dest="/etc/apt/keyrings/vendor.gpg", + keyid="0xABCDEF12", + present=False, + ) + + gpg.key( + name="Remove key from all APT keyrings", + keyid="0xCOMPROMISED123", + present=False, + # dest=None means search in all keyrings + ) + + gpg.key( + name="Fetch keys from keyserver", + keyserver="hkps://keyserver.ubuntu.com", + keyid=["0xD88E42B4", "0x7EA0A9C3"], + dest="/etc/apt/keyrings/vendor.gpg", + ) + """ + + # Validate parameters based on operation type + if present is True: + # For installation, dest is required + if not dest: + raise OperationError("`dest` must be provided for installation") + elif present is False: + # For removal, either dest or keyid must be provided + if not dest and not keyid: + raise OperationError("For removal, either `dest` or `keyid` must be provided") + + # For removal, handle different scenarios + if present is False: + if not dest and keyid: + # Remove key(s) from all APT keyrings + if isinstance(keyid, str): + keyid = [keyid] + + # Define all APT keyring locations + keyring_patterns = [ + "/etc/apt/trusted.gpg.d/*.gpg", + "/etc/apt/keyrings/*.gpg", + "/usr/share/keyrings/*.gpg" + ] + + for pattern in keyring_patterns: + for kid in keyid: + # Remove key from all matching keyrings + yield f'for keyring in {pattern}; do [ -e "$keyring" ] && gpg --batch --no-default-keyring --keyring "$keyring" --delete-keys {kid} 2>/dev/null || true; done' + + # Clean up empty keyrings + yield f'for keyring in {pattern}; do [ -e "$keyring" ] && ! gpg --batch --no-default-keyring --keyring "$keyring" --list-keys 2>/dev/null | grep -q "pub" && rm -f "$keyring" || true; done' + + return + + elif dest and keyid: + # Remove specific key(s) by ID from specific keyring + if isinstance(keyid, str): + keyid = [keyid] + + for kid in keyid: + # Remove the specific key from the keyring + yield f'gpg --batch --no-default-keyring --keyring "{dest}" --delete-keys {kid} 2>/dev/null || true' + + # If keyring becomes empty, remove the file + yield f'if ! gpg --batch --no-default-keyring --keyring "{dest}" --list-keys 2>/dev/null | grep -q "pub"; then rm -f "{dest}"; fi' + return + + elif dest and not keyid: + # Remove entire keyring file + yield from files.file._inner( + path=dest, + present=False, + ) + return + + # For installation, validate required parameters + if not src and not keyserver: + raise OperationError("Either `src` or `keyserver` must be provided for installation") + + if keyserver and not keyid: + raise OperationError("`keyid` must be provided with `keyserver`") + + if keyid and not keyserver and not src: + raise OperationError("When using `keyid` for installation, either `keyserver` or `src` must be provided") + + # For installation (present=True), ensure destination directory exists + dest_dir = str(Path(dest).parent) + yield from files.directory._inner( + path=dest_dir, + mode="0755", + present=True, + ) + + # --- src branch: install a key from URL or local file --- + if src: + if urlparse(src).scheme in ("http", "https"): + # Remote source: download first, then process + temp_file = host.get_temp_filename(src) + + yield from files.download._inner( + src=src, + dest=temp_file, + ) + + # Install the key and clean up temp file + yield from _install_key_file(temp_file, dest, dearmor, mode) + + # Clean up temp file using pyinfra + yield from files.file._inner( + path=temp_file, + present=False, + ) + else: + # Local file: install directly + yield from _install_key_file(src, dest, dearmor, mode) + + # --- keyserver branch: fetch keys by ID --- + if keyserver: + if isinstance(keyid, str): + keyid = [keyid] + + joined = " ".join(keyid) + + # Create temporary GPG home directory using pyinfra + temp_dir = f"/tmp/pyinfra-gpg-{host.get_temp_filename('')[-8:]}" + + yield from files.directory._inner( + path=temp_dir, + mode="0700", # GPG directories should be more restrictive + present=True, + ) + + # Export GNUPGHOME and fetch keys + yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --keyserver "{keyserver}" --recv-keys {joined}' + + # Export keys to destination + if dearmor: + yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --export {joined} | gpg --batch --dearmor -o "{dest}"' + else: + yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --export {joined} > "{dest}"' + + # Clean up temporary directory using pyinfra + yield from files.directory._inner( + path=temp_dir, + present=False, + ) + + # Set proper permissions using pyinfra + yield from files.file._inner( + path=dest, + mode=mode, + present=True, + ) + +@operation() +def dearmor(src: str, dest: str, mode: str = "0644"): + """ + Convert ASCII armored GPG key to binary format. + + Args: + src: source ASCII armored key file + dest: destination binary key file + mode: file permissions for the output file + + Example: + gpg.dearmor( + name="Convert key to binary", + src="/tmp/key.asc", + dest="/etc/apt/keyrings/key.gpg", + ) + """ + + # Ensure destination directory exists + dest_dir = dest.rsplit("/", 1)[0] + yield from files.directory._inner( + path=dest_dir, + mode="0755", + present=True, + ) + + yield f'gpg --batch --dearmor -o "{dest}" "{src}"' + + # Set proper permissions using pyinfra + yield from files.file._inner( + path=dest, + mode=mode, + present=True, + ) + + +def _install_key_file(src_file: str, dest_path: str, dearmor: bool, mode: str): + """ + Helper function to install a GPG key file, dearmoring if necessary. + """ + if dearmor: + # Check if it's an ASCII armored key and handle accordingly + # Note: Could be enhanced using GpgKey fact for better detection + yield f'if grep -q "BEGIN PGP PUBLIC KEY BLOCK" "{src_file}"; then gpg --batch --dearmor -o "{dest_path}" "{src_file}"; else cp "{src_file}" "{dest_path}"; fi' + else: + # Simple copy for binary keys or when dearmoring is disabled + yield f'cp "{src_file}" "{dest_path}"' + + # Set proper permissions using pyinfra + yield from files.file._inner( + path=dest_path, + mode=mode, + present=True, + ) diff --git a/tests/facts/apt.AptKeys/keys.json b/tests/facts/apt.AptKeys/keys.json index 749decacb..c36bb75f7 100644 --- a/tests/facts/apt.AptKeys/keys.json +++ b/tests/facts/apt.AptKeys/keys.json @@ -1,6 +1,6 @@ { - "command": "! command -v gpg || apt-key list --with-colons", - "requires_command": "apt-key", + "command": "for f in /etc/apt/trusted.gpg /etc/apt/trusted.gpg.d/*.gpg /etc/apt/trusted.gpg.d/*.asc /etc/apt/keyrings/*.gpg /etc/apt/keyrings/*.asc /usr/share/keyrings/*.gpg /usr/share/keyrings/*.asc ; do [ -e \"$f\" ] || continue; case \"$f\" in *.asc) gpg --batch --show-keys --with-colons --keyid-format LONG \"$f\" ;; *) gpg --batch --no-default-keyring --keyring \"$f\" --list-keys --with-colons --keyid-format LONG ;; esac; done", + "requires_command": "gpg", "output": [ "tru:t:1:1601454628:0:3:1:5", "pub:-:4096:1:3B4FE6ACC0B21F32:1336770936:::-:::scSC::::::23::0:", diff --git a/tests/facts/apt.AptSources/component_with_number.json b/tests/facts/apt.AptSources/component_with_number.json index 97b3989aa..1ab462434 100644 --- a/tests/facts/apt.AptSources/component_with_number.json +++ b/tests/facts/apt.AptSources/component_with_number.json @@ -1,8 +1,10 @@ { "output": [ - "deb http://archive.ubuntu.com/ubuntu trusty restricted pi4" + "##FILE /etc/apt/sources.list", + "deb http://archive.ubuntu.com/ubuntu trusty restricted pi4", + "" ], - "command": "(! test -f /etc/apt/sources.list || cat /etc/apt/sources.list) && (cat /etc/apt/sources.list.d/*.list || true)", + "command": "sh -c 'for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [ -e \"$f\" ] || continue; echo \"##FILE $f\"; cat \"$f\"; echo; done'", "requires_command": "apt", "fact": [ { diff --git a/tests/facts/apt.AptSources/sources.json b/tests/facts/apt.AptSources/sources.json index 2b5365f28..217413074 100644 --- a/tests/facts/apt.AptSources/sources.json +++ b/tests/facts/apt.AptSources/sources.json @@ -1,11 +1,13 @@ { "output": [ + "##FILE /etc/apt/sources.list", "deb http://archive.ubuntu.com/ubuntu trusty restricted", "deb-src [arch=amd64,i386] http://archive.ubuntu.com/ubuntu trusty main", "deb [arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse", - "nope" + "nope", + "" ], - "command": "(! test -f /etc/apt/sources.list || cat /etc/apt/sources.list) && (cat /etc/apt/sources.list.d/*.list || true)", + "command": "sh -c 'for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [ -e \"$f\" ] || continue; echo \"##FILE $f\"; cat \"$f\"; echo; done'", "requires_command": "apt", "fact": [ { diff --git a/tests/operations/apt.key/add.json b/tests/operations/apt.key/add.json index d7b30fa7c..dfdc2260d 100644 --- a/tests/operations/apt.key/add.json +++ b/tests/operations/apt.key/add.json @@ -6,9 +6,20 @@ "src=mykey": { "abc": {} } + }, + "files.Directory": { + "path=/etc/apt/keyrings": null + }, + "files.File": { + "path=/etc/apt/keyrings/mykey.gpg": null } }, "commands": [ - "apt-key add mykey" + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"mykey\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/mykey.gpg\" \"mykey\"; else cp \"mykey\" \"/etc/apt/keyrings/mykey.gpg\"; fi", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/mykey.gpg", + "chmod 644 /etc/apt/keyrings/mykey.gpg" ] } diff --git a/tests/operations/apt.key/add_exists.json b/tests/operations/apt.key/add_exists.json index 08d0b5fdb..cd2fbaffc 100644 --- a/tests/operations/apt.key/add_exists.json +++ b/tests/operations/apt.key/add_exists.json @@ -8,6 +8,9 @@ "src=mykey": { "abc": {} } + }, + "files.Directory": { + "path=/etc/apt/keyrings": null } }, "commands": [], diff --git a/tests/operations/apt.key/add_keyserver.json b/tests/operations/apt.key/add_keyserver.json index 8127d559c..cdd8d37cc 100644 --- a/tests/operations/apt.key/add_keyserver.json +++ b/tests/operations/apt.key/add_keyserver.json @@ -4,9 +4,24 @@ "keyid": "abc" }, "facts": { - "apt.AptKeys": {} + "apt.AptKeys": {}, + "files.Directory": { + "path=/etc/apt/keyrings": null, + "path=/tmp/pyinfra-gpg-empfile_": null + }, + "files.File": { + "path=/etc/apt/keyrings/keyserver-abc.gpg": null + } }, "commands": [ - "apt-key adv --keyserver key-server.net --recv-keys abc" + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "mkdir -p /tmp/pyinfra-gpg-empfile_", + "chmod 700 /tmp/pyinfra-gpg-empfile_", + "export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"key-server.net\" --recv-keys abc", + "export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export abc | gpg --batch --dearmor -o \"/etc/apt/keyrings/keyserver-abc.gpg\"", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/keyserver-abc.gpg", + "chmod 644 /etc/apt/keyrings/keyserver-abc.gpg" ] } diff --git a/tests/operations/apt.key/add_keyserver_exists.json b/tests/operations/apt.key/add_keyserver_exists.json index db5393589..f55266dc1 100644 --- a/tests/operations/apt.key/add_keyserver_exists.json +++ b/tests/operations/apt.key/add_keyserver_exists.json @@ -7,6 +7,9 @@ "apt.AptKeys": { "abc": {}, "def": {} + }, + "files.Directory": { + "path=/etc/apt/keyrings": null } }, "commands": [], diff --git a/tests/operations/apt.key/add_keyserver_multiple.json b/tests/operations/apt.key/add_keyserver_multiple.json index 1c5c9f416..26a208732 100644 --- a/tests/operations/apt.key/add_keyserver_multiple.json +++ b/tests/operations/apt.key/add_keyserver_multiple.json @@ -4,9 +4,24 @@ "keyid": ["abc", "def"] }, "facts": { - "apt.AptKeys": {} + "apt.AptKeys": {}, + "files.Directory": { + "path=/etc/apt/keyrings": null, + "path=/tmp/pyinfra-gpg-empfile_": null + }, + "files.File": { + "path=/etc/apt/keyrings/keyserver-abc-def.gpg": null + } }, "commands": [ - "apt-key adv --keyserver key-server.net --recv-keys abc def" + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "mkdir -p /tmp/pyinfra-gpg-empfile_", + "chmod 700 /tmp/pyinfra-gpg-empfile_", + "export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"key-server.net\" --recv-keys abc def", + "export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export abc def | gpg --batch --dearmor -o \"/etc/apt/keyrings/keyserver-abc-def.gpg\"", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/keyserver-abc-def.gpg", + "chmod 644 /etc/apt/keyrings/keyserver-abc-def.gpg" ] } diff --git a/tests/operations/apt.key/add_keyserver_multiple_partial.json b/tests/operations/apt.key/add_keyserver_multiple_partial.json index 50555c2cd..f422e555c 100644 --- a/tests/operations/apt.key/add_keyserver_multiple_partial.json +++ b/tests/operations/apt.key/add_keyserver_multiple_partial.json @@ -5,10 +5,17 @@ }, "facts": { "apt.AptKeys": { - "abc": {} + "abc": {}, + "def": {} + }, + "files.Directory": { + "path=/etc/apt/keyrings": null, + "path=/tmp/pyinfra-gpg-empfile_": null + }, + "files.File": { + "path=/etc/apt/keyrings/keyserver-abc-def.gpg": null } }, - "commands": [ - "apt-key adv --keyserver key-server.net --recv-keys def" - ] + "commands": [], + "noop_description": "Keys abc, def are already available in the apt keychain" } diff --git a/tests/operations/apt.key/add_keyserver_no_keyid.json b/tests/operations/apt.key/add_keyserver_no_keyid.json index 527c4e6cd..e156271ab 100644 --- a/tests/operations/apt.key/add_keyserver_no_keyid.json +++ b/tests/operations/apt.key/add_keyserver_no_keyid.json @@ -3,7 +3,10 @@ "keyserver": "key-server.net" }, "facts": { - "apt.AptKeys": {} + "apt.AptKeys": {}, + "files.Directory": { + "path=/etc/apt/keyrings": null + } }, "exception": { "name": "OperationError", diff --git a/tests/operations/apt.key/add_no_gpg.json b/tests/operations/apt.key/add_no_gpg.json index 3d46ea42f..e01631b13 100644 --- a/tests/operations/apt.key/add_no_gpg.json +++ b/tests/operations/apt.key/add_no_gpg.json @@ -4,10 +4,21 @@ "apt.AptKeys": {}, "gpg.GpgKey": { "src=mykey": null + }, + "files.Directory": { + "path=/etc/apt/keyrings": null + }, + "files.File": { + "path=/etc/apt/keyrings/mykey.gpg": null } }, "commands": [ - "apt-key add mykey" + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"mykey\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/mykey.gpg\" \"mykey\"; else cp \"mykey\" \"/etc/apt/keyrings/mykey.gpg\"; fi", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/mykey.gpg", + "chmod 644 /etc/apt/keyrings/mykey.gpg" ], "idempotent": false, "disable_idempotent_warning_reason": "the key will always be added if gpg cant check whether it exists" diff --git a/tests/operations/apt.key/add_url.json b/tests/operations/apt.key/add_url.json index 8981dbb2f..32facc6a7 100644 --- a/tests/operations/apt.key/add_url.json +++ b/tests/operations/apt.key/add_url.json @@ -6,9 +6,26 @@ "src=http://mykey": { "abc": {} } + }, + "files.Directory": { + "path=/etc/apt/keyrings": null + }, + "files.File": { + "path=/etc/apt/keyrings/mykey-key.gpg": null, + "path=_tempfile_": null + }, + "server.Which": { + "command=curl": "/usr/bin/curl" } }, "commands": [ - "(wget -O - http://mykey || curl -sSLf http://mykey) | apt-key add -" + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "curl -sSLf http://mykey -o _tempfile_", + "mv _tempfile_ _tempfile_", + "if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"_tempfile_\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/mykey-key.gpg\" \"_tempfile_\"; else cp \"_tempfile_\" \"/etc/apt/keyrings/mykey-key.gpg\"; fi", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/mykey-key.gpg", + "chmod 644 /etc/apt/keyrings/mykey-key.gpg" ] } diff --git a/tests/operations/gpg.dearmor/basic.json b/tests/operations/gpg.dearmor/basic.json new file mode 100644 index 000000000..62fa745e4 --- /dev/null +++ b/tests/operations/gpg.dearmor/basic.json @@ -0,0 +1,22 @@ +{ + "args": [], + "kwargs": { + "src": "/tmp/test.asc", + "dest": "/tmp/test.gpg" + }, + "facts": { + "files.Directory": { + "path=/tmp": { + "mode": 755 + } + }, + "files.File": { + "path=/tmp/test.gpg": null + } + }, + "commands": [ + "gpg --batch --dearmor -o \"/tmp/test.gpg\" \"/tmp/test.asc\"", + "touch /tmp/test.gpg", + "chmod 644 /tmp/test.gpg" + ] +} diff --git a/tests/operations/gpg.dearmor/custom_mode.json b/tests/operations/gpg.dearmor/custom_mode.json new file mode 100644 index 000000000..48e3f6415 --- /dev/null +++ b/tests/operations/gpg.dearmor/custom_mode.json @@ -0,0 +1,24 @@ +{ + "args": [], + "kwargs": { + "src": "/tmp/key.asc", + "dest": "/etc/apt/keyrings/key.gpg", + "mode": "0600" + }, + "facts": { + "files.Directory": { + "path=/etc/apt/keyrings": null + }, + "files.File": { + "path=/etc/apt/keyrings/key.gpg": null + } + }, + "commands": [ + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "gpg --batch --dearmor -o \"/etc/apt/keyrings/key.gpg\" \"/tmp/key.asc\"", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/key.gpg", + "chmod 600 /etc/apt/keyrings/key.gpg" + ] +} diff --git a/tests/operations/gpg.key/custom_mode.json b/tests/operations/gpg.key/custom_mode.json new file mode 100644 index 000000000..2de7e69f0 --- /dev/null +++ b/tests/operations/gpg.key/custom_mode.json @@ -0,0 +1,24 @@ +{ + "args": [], + "kwargs": { + "src": "/tmp/local-key.asc", + "dest": "/etc/apt/keyrings/test.gpg", + "mode": "0600" + }, + "facts": { + "files.Directory": { + "path=/etc/apt/keyrings": null + }, + "files.File": { + "path=/etc/apt/keyrings/test.gpg": null + } + }, + "commands": [ + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"/tmp/local-key.asc\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/test.gpg\" \"/tmp/local-key.asc\"; else cp \"/tmp/local-key.asc\" \"/etc/apt/keyrings/test.gpg\"; fi", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/test.gpg", + "chmod 600 /etc/apt/keyrings/test.gpg" + ] +} diff --git a/tests/operations/gpg.key/keyserver_multiple.json b/tests/operations/gpg.key/keyserver_multiple.json new file mode 100644 index 000000000..5f77b4e96 --- /dev/null +++ b/tests/operations/gpg.key/keyserver_multiple.json @@ -0,0 +1,28 @@ +{ + "args": [], + "kwargs": { + "keyserver": "hkps://keyserver.ubuntu.com", + "keyid": ["0xD88E42B4", "0x7EA0A9C3"], + "dest": "/etc/apt/keyrings/vendor.gpg" + }, + "facts": { + "files.Directory": { + "path=/etc/apt/keyrings": null, + "path=/tmp/pyinfra-gpg-empfile_": null + }, + "files.File": { + "path=/etc/apt/keyrings/vendor.gpg": null + } + }, + "commands": [ + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "mkdir -p /tmp/pyinfra-gpg-empfile_", + "chmod 700 /tmp/pyinfra-gpg-empfile_", + "export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"hkps://keyserver.ubuntu.com\" --recv-keys 0xD88E42B4 0x7EA0A9C3", + "export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export 0xD88E42B4 0x7EA0A9C3 | gpg --batch --dearmor -o \"/etc/apt/keyrings/vendor.gpg\"", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/vendor.gpg", + "chmod 644 /etc/apt/keyrings/vendor.gpg" + ] +} diff --git a/tests/operations/gpg.key/keyserver_no_keyid.json b/tests/operations/gpg.key/keyserver_no_keyid.json new file mode 100644 index 000000000..f6bbf5bbe --- /dev/null +++ b/tests/operations/gpg.key/keyserver_no_keyid.json @@ -0,0 +1,12 @@ +{ + "args": [], + "kwargs": { + "keyserver": "hkps://keyserver.ubuntu.com", + "dest": "/etc/apt/keyrings/test.gpg" + }, + "exception": { + "names": ["OperationError"], + "message": "`keyid` must be provided with `keyserver`" + }, + "facts": {} +} diff --git a/tests/operations/gpg.key/keyserver_single.json b/tests/operations/gpg.key/keyserver_single.json new file mode 100644 index 000000000..81c627ce0 --- /dev/null +++ b/tests/operations/gpg.key/keyserver_single.json @@ -0,0 +1,28 @@ +{ + "args": [], + "kwargs": { + "keyserver": "hkps://keyserver.ubuntu.com", + "keyid": ["0xD88E42B4"], + "dest": "/etc/apt/keyrings/vendor.gpg" + }, + "facts": { + "files.Directory": { + "path=/etc/apt/keyrings": null, + "path=/tmp/pyinfra-gpg-empfile_": null + }, + "files.File": { + "path=/etc/apt/keyrings/vendor.gpg": null + } + }, + "commands": [ + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "mkdir -p /tmp/pyinfra-gpg-empfile_", + "chmod 700 /tmp/pyinfra-gpg-empfile_", + "export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"hkps://keyserver.ubuntu.com\" --recv-keys 0xD88E42B4", + "export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export 0xD88E42B4 | gpg --batch --dearmor -o \"/etc/apt/keyrings/vendor.gpg\"", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/vendor.gpg", + "chmod 644 /etc/apt/keyrings/vendor.gpg" + ] +} diff --git a/tests/operations/gpg.key/local_file.json b/tests/operations/gpg.key/local_file.json new file mode 100644 index 000000000..ba86d06fa --- /dev/null +++ b/tests/operations/gpg.key/local_file.json @@ -0,0 +1,23 @@ +{ + "args": [], + "kwargs": { + "src": "/tmp/local-key.asc", + "dest": "/etc/apt/keyrings/test.gpg" + }, + "facts": { + "files.Directory": { + "path=/etc/apt/keyrings": null + }, + "files.File": { + "path=/etc/apt/keyrings/test.gpg": null + } + }, + "commands": [ + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"/tmp/local-key.asc\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/test.gpg\" \"/tmp/local-key.asc\"; else cp \"/tmp/local-key.asc\" \"/etc/apt/keyrings/test.gpg\"; fi", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/test.gpg", + "chmod 644 /etc/apt/keyrings/test.gpg" + ] +} diff --git a/tests/operations/gpg.key/no_dearmor.json b/tests/operations/gpg.key/no_dearmor.json new file mode 100644 index 000000000..4095c6d05 --- /dev/null +++ b/tests/operations/gpg.key/no_dearmor.json @@ -0,0 +1,24 @@ +{ + "args": [], + "kwargs": { + "src": "/tmp/local-key.gpg", + "dest": "/etc/apt/keyrings/test.gpg", + "dearmor": false + }, + "facts": { + "files.Directory": { + "path=/etc/apt/keyrings": null + }, + "files.File": { + "path=/etc/apt/keyrings/test.gpg": null + } + }, + "commands": [ + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "cp \"/tmp/local-key.gpg\" \"/etc/apt/keyrings/test.gpg\"", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/test.gpg", + "chmod 644 /etc/apt/keyrings/test.gpg" + ] +} diff --git a/tests/operations/gpg.key/no_dest.json b/tests/operations/gpg.key/no_dest.json new file mode 100644 index 000000000..134562316 --- /dev/null +++ b/tests/operations/gpg.key/no_dest.json @@ -0,0 +1,11 @@ +{ + "args": [], + "kwargs": { + "src": "/tmp/test.asc" + }, + "facts": {}, + "exception": { + "names": ["OperationError"], + "message": "`dest` must be provided for installation" + } +} diff --git a/tests/operations/gpg.key/no_src_or_keyserver.json b/tests/operations/gpg.key/no_src_or_keyserver.json new file mode 100644 index 000000000..786d22d1c --- /dev/null +++ b/tests/operations/gpg.key/no_src_or_keyserver.json @@ -0,0 +1,11 @@ +{ + "args": [], + "kwargs": { + "dest": "/etc/apt/keyrings/test.gpg" + }, + "exception": { + "names": ["OperationError"], + "message": "Either `src` or `keyserver` must be provided for installation" + }, + "facts": {} +} diff --git a/tests/operations/gpg.key/remote_url.json b/tests/operations/gpg.key/remote_url.json new file mode 100644 index 000000000..509887e86 --- /dev/null +++ b/tests/operations/gpg.key/remote_url.json @@ -0,0 +1,29 @@ +{ + "args": [], + "kwargs": { + "src": "https://example.com/key.asc", + "dest": "/etc/apt/keyrings/test.gpg" + }, + "facts": { + "files.Directory": { + "path=/etc/apt/keyrings": null + }, + "files.File": { + "path=/etc/apt/keyrings/test.gpg": null, + "path=_tempfile_": null + }, + "server.Which": { + "command=curl": "/usr/bin/curl" + } + }, + "commands": [ + "mkdir -p /etc/apt/keyrings", + "chmod 755 /etc/apt/keyrings", + "curl -sSLf https://example.com/key.asc -o _tempfile_", + "mv _tempfile_ _tempfile_", + "if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"_tempfile_\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/test.gpg\" \"_tempfile_\"; else cp \"_tempfile_\" \"/etc/apt/keyrings/test.gpg\"; fi", + "mkdir -p /etc/apt/keyrings", + "touch /etc/apt/keyrings/test.gpg", + "chmod 644 /etc/apt/keyrings/test.gpg" + ] +} diff --git a/tests/operations/gpg.key/remove_by_id.json b/tests/operations/gpg.key/remove_by_id.json new file mode 100644 index 000000000..b9fc15133 --- /dev/null +++ b/tests/operations/gpg.key/remove_by_id.json @@ -0,0 +1,16 @@ +{ + "kwargs": { + "dest": "/etc/apt/keyrings/vendor.gpg", + "keyid": "0xABCDEF12", + "present": false + }, + "facts": { + "files.File": { + "path=/etc/apt/keyrings/vendor.gpg": {"mode": 644} + } + }, + "commands": [ + "gpg --batch --no-default-keyring --keyring \"/etc/apt/keyrings/vendor.gpg\" --delete-keys 0xABCDEF12 2>/dev/null || true", + "if ! gpg --batch --no-default-keyring --keyring \"/etc/apt/keyrings/vendor.gpg\" --list-keys 2>/dev/null | grep -q \"pub\"; then rm -f \"/etc/apt/keyrings/vendor.gpg\"; fi" + ] +} diff --git a/tests/operations/gpg.key/remove_file.json b/tests/operations/gpg.key/remove_file.json new file mode 100644 index 000000000..ac9adcb9e --- /dev/null +++ b/tests/operations/gpg.key/remove_file.json @@ -0,0 +1,14 @@ +{ + "kwargs": { + "dest": "/etc/apt/keyrings/test.gpg", + "present": false + }, + "facts": { + "files.File": { + "path=/etc/apt/keyrings/test.gpg": {"mode": 644} + } + }, + "commands": [ + "rm -f /etc/apt/keyrings/test.gpg" + ] +} diff --git a/tests/operations/gpg.key/remove_from_all_keyrings.json b/tests/operations/gpg.key/remove_from_all_keyrings.json new file mode 100644 index 000000000..fb16a54e5 --- /dev/null +++ b/tests/operations/gpg.key/remove_from_all_keyrings.json @@ -0,0 +1,15 @@ +{ + "kwargs": { + "keyid": "0xCOMPROMISED123", + "present": false + }, + "facts": {}, + "commands": [ + "for keyring in /etc/apt/trusted.gpg.d/*.gpg; do [ -e \"$keyring\" ] && gpg --batch --no-default-keyring --keyring \"$keyring\" --delete-keys 0xCOMPROMISED123 2>/dev/null || true; done", + "for keyring in /etc/apt/trusted.gpg.d/*.gpg; do [ -e \"$keyring\" ] && ! gpg --batch --no-default-keyring --keyring \"$keyring\" --list-keys 2>/dev/null | grep -q \"pub\" && rm -f \"$keyring\" || true; done", + "for keyring in /etc/apt/keyrings/*.gpg; do [ -e \"$keyring\" ] && gpg --batch --no-default-keyring --keyring \"$keyring\" --delete-keys 0xCOMPROMISED123 2>/dev/null || true; done", + "for keyring in /etc/apt/keyrings/*.gpg; do [ -e \"$keyring\" ] && ! gpg --batch --no-default-keyring --keyring \"$keyring\" --list-keys 2>/dev/null | grep -q \"pub\" && rm -f \"$keyring\" || true; done", + "for keyring in /usr/share/keyrings/*.gpg; do [ -e \"$keyring\" ] && gpg --batch --no-default-keyring --keyring \"$keyring\" --delete-keys 0xCOMPROMISED123 2>/dev/null || true; done", + "for keyring in /usr/share/keyrings/*.gpg; do [ -e \"$keyring\" ] && ! gpg --batch --no-default-keyring --keyring \"$keyring\" --list-keys 2>/dev/null | grep -q \"pub\" && rm -f \"$keyring\" || true; done" + ] +} diff --git a/tests/operations/gpg.key/remove_no_dest_no_keyid.json b/tests/operations/gpg.key/remove_no_dest_no_keyid.json new file mode 100644 index 000000000..de89aba4d --- /dev/null +++ b/tests/operations/gpg.key/remove_no_dest_no_keyid.json @@ -0,0 +1,10 @@ +{ + "kwargs": { + "present": false + }, + "exception": { + "names": ["OperationError"], + "message": "For removal, either `dest` or `keyid` must be provided" + }, + "facts": {} +}