This commit is contained in:
David Boyd 2026-02-03 19:16:39 -06:00 committed by GitHub
commit a9b58e20f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 305 additions and 16 deletions

View file

@ -0,0 +1,2 @@
minor_changes:
- deb822_repository - The signed_by parameter now supports multiple keys. Input changed from string to list of strings. (https://github.com/ansible/ansible/issues/85700)

View file

@ -113,11 +113,14 @@ options:
type: bool
signed_by:
description:
- Either a URL to a GPG key, absolute path to a keyring file, one or
more fingerprints of keys either in the C(trusted.gpg) keyring or in
the keyrings in the C(trusted.gpg.d/) directory, or an ASCII armored
GPG public key block.
type: str
- A string or list of strings that can be one of the below.
- a URL to a GPG key
- the absolute path to a keyring file
- one or more fingerprints of keys either in the C(trusted.gpg) keyring or in
the keyrings in the C(trusted.gpg.d/) directory
- An ASCII armored GPG public key block.
type: list
elements: str
suites:
description:
- >-
@ -216,6 +219,18 @@ EXAMPLES = """
components: stable
architectures: amd64
signed_by: https://download.example.com/linux/ubuntu/gpg
- name: Add repo using multiple keys
deb822_repository:
name: example
types: deb
uris: https://download.example.com/linux/ubuntu
suites: '{{ ansible_distribution_release }}'
components: stable
architectures: amd64
signed_by:
- https://download.example.com/linux/ubuntu/gpg
- https://download.example-two.com/linux/ubuntu/gpg
"""
RETURN = """
@ -342,10 +357,164 @@ def is_armored(b_data):
return b'-----BEGIN PGP PUBLIC KEY BLOCK-----' in b_data
PGP_BLOCK_ONLY_RE = re.compile(
r"""
\A
-----BEGIN\ PGP\ PUBLIC\ KEY\ BLOCK-----\n
(?:[A-Za-z0-9+/= ]*\n)* # base64 and armor headers
-----END\ PGP\ PUBLIC\ KEY\ BLOCK-----\n?
\Z
""",
re.VERBOSE,
)
def is_only_one_armored_block(value: str) -> bool:
return bool(PGP_BLOCK_ONLY_RE.match(value))
def contains_exactly_one_public_key(module, value: str) -> bool:
rc, stdout, stderr = module.run_command(
["gpg", "--batch", "--with-colons", "--show-keys"],
data=value,
)
if rc != 0:
return False
pubs = [l for l in stdout.splitlines() if l.startswith("pub:")]
return len(pubs) == 1
def is_strict_single_inline_pgp_key(module, value: str) -> bool:
value = value.strip("\n")
return (
is_only_one_armored_block(value)
and contains_exactly_one_public_key(module, value)
)
# GPG fingerprints are 40 hex characters, optionally with spaces
FINGERPRINT_RE = re.compile(r'^[A-Fa-f0-9]{4}(\s?[A-Fa-f0-9]{4}){9}$')
def is_fingerprint(value: str) -> bool:
"""
Check if a value is a GPG key fingerprint.
Fingerprints are 40 hex characters, optionally separated by spaces.
Examples:
- "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"
- "A1B2 C3D4 E5F6 A1B2 C3D4 E5F6 A1B2 C3D4 E5F6 A1B2"
"""
return bool(FINGERPRINT_RE.match(value.strip()))
def dearmor_key(module, b_data):
"""
Convert ASCII-armored key to binary format using gpg --dearmor.
Returns binary key data.
"""
rc, stdout, stderr = module.run_command(
['gpg', '--batch', '--yes', '--dearmor'],
data=b_data,
binary_data=True,
encoding=None,
)
if rc != 0:
raise RuntimeError(f'Failed to dearmor key: {to_native(stderr)}')
# stdout should be bytes with encoding=None
return stdout
def fetch_key_data(module, key):
"""
Fetch key data from URL, file path, or return raw PGP data.
Returns bytes.
"""
# Check if it's a file path
if os.path.isfile(key):
with open(key, 'rb') as f:
return f.read()
# Check if it's a URL
parts = generic_urlparse(urlparse(key))
if parts.scheme:
try:
r = open_url(key, http_agent=get_user_agent())
return r.read()
except Exception as exc:
raise RuntimeError('Could not fetch signed_by key.') from exc
# Must be raw PGP data
return to_bytes(key)
def write_signed_by_key(module, v, slug):
"""
Process signing keys from a list.
v: list of keys (URLs, file paths, fingerprints, or raw key data)
Returns: (changed, signed_by_filename, signed_by_data, fingerprints)
"""
changed = False
# Separate fingerprints from other keys (URLs, files, PGP blocks)
fingerprints = []
other_keys = []
for key in v:
key = key.strip()
if is_fingerprint(key):
fingerprints.append(key)
else:
other_keys.append(key)
# If only fingerprints, return them directly (no file needed)
if not other_keys:
return changed, None, None, fingerprints
# Handle single inline PGP key ONLY if there are no fingerprints
if len(other_keys) == 1 and not fingerprints and is_only_one_armored_block(other_keys[0]):
return changed, None, other_keys[0], []
# For multiple keys, or when mixing with fingerprints, combine into a single file
if len(other_keys) > 1 or fingerprints:
all_key_data = []
for key in other_keys:
key_data = fetch_key_data(module, key)
# Convert armored keys to binary for consistent format
if is_armored(key_data):
key_data = dearmor_key(module, key_data)
all_key_data.append(key_data)
combined_data = b''.join(all_key_data)
tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir)
with os.fdopen(tmpfd, 'wb') as f:
f.write(combined_data)
ext = 'gpg' # Keys are always saved as binary .gpg file
filename = make_signed_by_filename(slug, ext)
src_chksum = module.sha256(tmpfile)
dest_chksum = module.sha256(filename)
if src_chksum != dest_chksum:
changed |= ensure_keyrings_dir(module)
if not module.check_mode:
module.atomic_move(tmpfile, filename)
changed = True
changed |= module.set_mode_if_different(filename, S_IRWU_RG_RO, False)
return changed, filename, None, fingerprints
else:
# Single key (URL or file path) with no fingerprints
key_changed, signed_by_filename = process_single_key(module, other_keys[0], slug)
return key_changed, signed_by_filename, None, []
def process_single_key(module, v, slug):
changed = False
if os.path.isfile(v):
return changed, v, None
return changed, v
b_data = None
@ -358,11 +527,11 @@ def write_signed_by_key(module, v, slug):
else:
b_data = r.read()
else:
# Not a file, nor a URL, just pass it through
return changed, None, v
# Not a file, nor a URL, must be a fingerprint, just pass it through
return changed, v
if not b_data:
return changed, v, None
return changed, v
tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir)
with os.fdopen(tmpfd, 'wb') as f:
@ -382,7 +551,7 @@ def write_signed_by_key(module, v, slug):
changed |= module.set_mode_if_different(filename, S_IRWU_RG_RO, False)
return changed, filename, None
return changed, filename
def install_python_debian(module, deb_pkg_name):
@ -462,7 +631,8 @@ def main():
'type': 'bool',
},
'signed_by': {
'type': 'str',
'type': 'list',
'elements': 'str',
},
'suites': {
'elements': 'str',
@ -616,12 +786,26 @@ def main():
value = format_bool(value)
elif isinstance(value, int):
value = to_native(value)
elif key == 'signed_by':
key_changed, signed_by_filename, signed_by_data, fingerprints = write_signed_by_key(module, value, slug)
changed |= key_changed
# Build the Signed-By value
if signed_by_data:
# Single inline PGP key (no fingerprints in this case)
value = signed_by_data
else:
# Combine keyring file path with fingerprints
signed_by_parts = []
if signed_by_filename:
signed_by_parts.append(signed_by_filename)
signed_by_parts.extend(fingerprints)
value = ' '.join(signed_by_parts) if signed_by_parts else None
if value is None:
continue
elif is_sequence(value):
value = format_list(value)
elif key == 'signed_by':
key_changed, signed_by_filename, signed_by_data = write_signed_by_key(module, value, slug)
value = signed_by_filename or signed_by_data
changed |= key_changed
if value.count('\n') > 0:
value = format_multiline(value)

View file

@ -146,7 +146,7 @@
- remove_repos_1 is changed
- remove_stats.results|map(attribute='stat')|selectattr('exists') == []
- name: Add repo with signed_by
- name: Add repo with signed_by single key block
deb822_repository:
name: ansible-test
types: deb
@ -181,6 +181,95 @@
signed_by: https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg
register: signed_by_url
- name: Add repo with signed_by containing multiple keys - URLs and fingerprints
deb822_repository:
name: ansible-test
types: deb
uris: https://deb.debian.org
suites: stable
components:
- main
- contrib
- non-free
signed_by:
- https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg
- https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg
- ffffDDDDeeee44449999nnnn2222ddddvvvvzzzz
register: signed_by_multiple_keys
- name: Add repo with signed_by containing multiple keys - url, filepath, fingerprint
deb822_repository:
name: ansible-test
types: deb
uris: https://deb.debian.org
suites: stable
components:
- main
- contrib
- non-free
signed_by:
- https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg
- /etc/apt/keyrings/ansible-test.gpg
- ffff DDDD eeee 4444 9999 nnnn 2222 dddd vvvv zzzz
register: signed_by_multiple_keys_two
- name: Add repo with signed_by list of key blocks
deb822_repository:
name: ansible-test
types: deb
uris: https://deb.debian.org
suites: stable
components:
- main
- contrib
- non-free
signed_by:
- |
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEYCQjIxYJKwYBBAHaRw8BAQdAD/P5Nvvnvk66SxBBHDbhRml9ORg1WV5CvzKY
CuMfoIS0BmFiY2RlZoiQBBMWCgA4FiEErCIG1VhKWMWo2yfAREZd5NfO31cFAmAk
IyMCGyMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AACgkQREZd5NfO31fbOwD6ArzS
dM0Dkd5h2Ujy1b6KcAaVW9FOa5UNfJ9FFBtjLQEBAJ7UyWD3dZzhvlaAwunsk7DG
3bHcln8DMpIJVXht78sL
=IE0r
-----END PGP PUBLIC KEY BLOCK-----
- |
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEYCQjIxYJKwYBBAHaRw8BAQdAD/P5Nvvnvk66SxBBHDbhRml9ORg1WV5CvzKY
CuMfoIS0BmFiY2RlZoiQBBMWCgA4FiEErCIG1VhKWMWo2yfAREZd5NfO31cFAmAk
IyMCGyMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AACgkQREZd5NfO31fbOwD6ArzS
dM0Dkd5h2Ujy1b6KcAaVW9FOa5UNfJ9FFBtjLQEBAJ7UyWD3dZzhvlaAwunsk7DG
3bHcln8DMpIJVXht78sL
=IE0r
-----END PGP PUBLIC KEY BLOCK-----
register: signed_by_multiple_key_blocks
- name: Add repo with signed_by as list with keyblock and url
deb822_repository:
name: ansible-test
types: deb
uris: https://deb.debian.org
suites: stable
components:
- main
- contrib
- non-free
signed_by:
- |
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEYCQjIxYJKwYBBAHaRw8BAQdAD/P5Nvvnvk66SxBBHDbhRml9ORg1WV5CvzKY
CuMfoIS0BmFiY2RlZoiQBBMWCgA4FiEErCIG1VhKWMWo2yfAREZd5NfO31cFAmAk
IyMCGyMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AACgkQREZd5NfO31fbOwD6ArzS
dM0Dkd5h2Ujy1b6KcAaVW9FOa5UNfJ9FFBtjLQEBAJ7UyWD3dZzhvlaAwunsk7DG
3bHcln8DMpIJVXht78sL
=IE0r
-----END PGP PUBLIC KEY BLOCK-----
- https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg
register: signed_by_multiple_keys_block_and_url
- assert:
that:
- signed_by_inline.key_filename is none
@ -189,6 +278,13 @@
- signed_by_url.key_filename == '/etc/apt/keyrings/ansible-test.gpg'
- >
'BEGIN' not in signed_by_url.repo
- signed_by_multiple_keys is changed
- signed_by_multiple_keys.key_filename == '/etc/apt/keyrings/ansible-test.gpg'
- signed_by_multiple_keys.repo|trim == signed_by_multiple_keys_expected
- signed_by_multiple_keys_two.key_filename == '/etc/apt/keyrings/ansible-test.gpg'
- signed_by_multiple_keys_two.repo|trim == signed_by_multiple_keys_expected
- signed_by_multiple_key_blocks.key_filename == '/etc/apt/keyrings/ansible-test.gpg'
- signed_by_multiple_keys_block_and_url.key_filename == '/etc/apt/keyrings/ansible-test.gpg'
vars:
signed_by_inline_expected: |-
Components: main contrib non-free
@ -206,6 +302,13 @@
Suites: stable
Types: deb
URIs: https://deb.debian.org
signed_by_multiple_keys_expected: |-
Components: main contrib non-free
X-Repolib-Name: ansible-test
Signed-By: /etc/apt/keyrings/ansible-test.gpg
Suites: stable
Types: deb
URIs: https://deb.debian.org
- name: remove ansible-test repo
deb822_repository: