mirror of
https://github.com/ansible-collections/community.crypto.git
synced 2026-04-16 05:41:00 +00:00
ACME: add dns-persist-01 support (#997)
* Add dns-persist-01 DNS TXT record filters. * Refactor parsing and joining CAA issue-values out. * Add basic tests. * Fix bug and add integration tests for filters. * Add dns-persist-01 support to ACME modules. * Add changelog fragment.
This commit is contained in:
4
changelogs/fragments/997-acme-dns-persist-01.yml
Normal file
4
changelogs/fragments/997-acme-dns-persist-01.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
minor_changes:
|
||||
- acme_* modules - support ``dns-persist-01`` challenge type according to
|
||||
`acme-dns-persist draft 01 <https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html>`__
|
||||
(https://github.com/ansible-collections/community.crypto/pull/997).
|
||||
177
plugins/filter/acme_dns_persist_record.py
Normal file
177
plugins/filter/acme_dns_persist_record.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# Copyright (c) 2026, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
name: acme_dns_persist_record
|
||||
short_description: Craft a DNS record for ACME C(dns-persist-01) challenges
|
||||
author: Felix Fontein (@felixfontein)
|
||||
version_added: 3.2.0
|
||||
description:
|
||||
- Craft the content for a ACME C(dns-persist-01) DNS TXT record V(_validation-persist.<domain>).
|
||||
- This filter conforms to the L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html).
|
||||
Note that the supported draft version can change at any time,
|
||||
and changes will only be considered breaking once the draft reached RFC status.
|
||||
options:
|
||||
_input:
|
||||
description:
|
||||
- The issuer domain name.
|
||||
type: string
|
||||
required: true
|
||||
account_uri:
|
||||
description:
|
||||
- The ACME account URI.
|
||||
type: string
|
||||
required: true
|
||||
policy:
|
||||
description:
|
||||
- The validation scope.
|
||||
type: string
|
||||
choices:
|
||||
wildcard:
|
||||
- If this value is present, the CA MAY consider this validation sufficient for issuing certificates
|
||||
for the validated FQDN, for specific subdomains of the validated FQDN
|
||||
(as covered by wildcard scope or specific subdomain validation rules),
|
||||
and for wildcard certificates (for example V(*.example.com)). See
|
||||
L(Section 5, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html#wildcard-certificate-validation)
|
||||
and L(Section 6, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html#subdomain-certificate-validation)
|
||||
of the L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html).
|
||||
persist_until:
|
||||
description:
|
||||
- Until when the record is valid.
|
||||
- Can be specified as a UNIX time stamp (integer), as a Python datetime object,
|
||||
or as a relative time or absolute timestamp specified as a string.
|
||||
- Times specified as strings will always be interpreted as UTC.
|
||||
Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
|
||||
+ C([w | d | h | m | s]) (for example V(+32w1d2h)).
|
||||
type: any
|
||||
seealso:
|
||||
- module: community.crypto.acme_certificate
|
||||
- module: community.crypto.acme_certificate_order_create
|
||||
- module: community.crypto.acme_certificate_order_validate
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
---
|
||||
- name: Create _validation-persist.<domain> TXT record contents
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{
|
||||
'letsencrypt.org' | community.crypto.acme_dns_persist_record(
|
||||
account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234',
|
||||
policy='wildcard',
|
||||
persist_until='+1w',
|
||||
)
|
||||
}}
|
||||
|
||||
- name: Create _validation-persist.<domain> TXT record for example.com
|
||||
community.dns.hetzner_dns_record_set:
|
||||
prefix: _validation-persist
|
||||
zone_name: example.com
|
||||
value:
|
||||
- >-
|
||||
{{
|
||||
'letsencrypt.org' | community.crypto.acme_dns_persist_record(
|
||||
account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/4321',
|
||||
persist_until='20190331202428Z',
|
||||
)
|
||||
}}
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
_value:
|
||||
description:
|
||||
- The content for the V(_validation-persist.<domain>) TXT record.
|
||||
type: string
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import typing as t
|
||||
from collections.abc import Callable
|
||||
|
||||
from ansible.errors import AnsibleFilterError
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils._caa import (
|
||||
join_issue_value,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
|
||||
OpenSSLObjectError,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils._time import (
|
||||
get_epoch_seconds,
|
||||
get_now_datetime,
|
||||
get_relative_time_option,
|
||||
)
|
||||
|
||||
|
||||
def acme_dns_persist_record(
|
||||
domain_issuer_name: t.Any,
|
||||
*,
|
||||
account_uri: t.Any,
|
||||
policy: t.Any | None = None,
|
||||
persist_until: t.Any | None = None,
|
||||
) -> str:
|
||||
if not isinstance(domain_issuer_name, str):
|
||||
raise AnsibleFilterError(
|
||||
"The input for the community.crypto.acme_dns_persist_record filter"
|
||||
f" must be a string; got {type(domain_issuer_name)} instead"
|
||||
)
|
||||
if not isinstance(account_uri, str):
|
||||
raise AnsibleFilterError(
|
||||
"The account_uri parameter for the community.crypto.acme_dns_persist_record filter"
|
||||
f" must be a string; got {type(account_uri)} instead"
|
||||
)
|
||||
valid_policies = ("wildcard",)
|
||||
if policy is not None and policy not in valid_policies:
|
||||
choices = ", ".join(f'"{vp}"' for vp in valid_policies)
|
||||
raise AnsibleFilterError(
|
||||
"The policy parameter for the community.crypto.acme_dns_persist_record filter"
|
||||
f" must be one of {choices}; got {policy!r} instead"
|
||||
)
|
||||
if persist_until is not None:
|
||||
if isinstance(persist_until, str):
|
||||
try:
|
||||
persist_until = get_relative_time_option(
|
||||
persist_until,
|
||||
input_name="persist_until",
|
||||
with_timezone=True,
|
||||
now=get_now_datetime(with_timezone=True),
|
||||
)
|
||||
except OpenSSLObjectError as exc:
|
||||
raise AnsibleFilterError(
|
||||
"Error parsing persist_until parameter for the community.crypto.acme_dns_persist_record filter:"
|
||||
f" {exc}"
|
||||
) from None
|
||||
if isinstance(persist_until, int) and not isinstance(persist_until, bool):
|
||||
pass
|
||||
elif isinstance(persist_until, datetime.datetime):
|
||||
persist_until = int(get_epoch_seconds(persist_until))
|
||||
else:
|
||||
raise AnsibleFilterError(
|
||||
"The persist_until parameter for the community.crypto.acme_dns_persist_record filter"
|
||||
f" must be an integer, a string, or a datetime object; got {type(persist_until)} instead"
|
||||
)
|
||||
|
||||
parts = [("accounturi", account_uri)]
|
||||
if policy is not None:
|
||||
parts.append(("policy", policy))
|
||||
if persist_until is not None:
|
||||
parts.append(("persistUntil", str(persist_until)))
|
||||
try:
|
||||
return join_issue_value(domain_issuer_name, parts)
|
||||
except ValueError as exc:
|
||||
raise AnsibleFilterError(
|
||||
"Error composing result for the community.crypto.acme_dns_persist_record filter:"
|
||||
f" {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
class FilterModule:
|
||||
"""Ansible jinja2 filters"""
|
||||
|
||||
def filters(self) -> dict[str, Callable]:
|
||||
return {
|
||||
"acme_dns_persist_record": acme_dns_persist_record,
|
||||
}
|
||||
163
plugins/filter/acme_dns_persist_record_parse.py
Normal file
163
plugins/filter/acme_dns_persist_record_parse.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# Copyright (c) 2026, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
name: acme_dns_persist_record_parse
|
||||
short_description: Parse a DNS record for ACME C(dns-persist-01) challenges
|
||||
author: Felix Fontein (@felixfontein)
|
||||
version_added: 3.2.0
|
||||
description:
|
||||
- Parse the content for a ACME C(dns-persist-01) DNS TXT record V(_validation-persist.<domain>).
|
||||
- This filter conforms to the L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html).
|
||||
Note that the supported draft version can change at any time,
|
||||
and changes will only be considered breaking once the draft reached RFC status.
|
||||
options:
|
||||
_input:
|
||||
description:
|
||||
- The DNS TXT record entry.
|
||||
type: string
|
||||
required: true
|
||||
seealso:
|
||||
- module: community.crypto.acme_certificate
|
||||
- module: community.crypto.acme_certificate_order_create
|
||||
- module: community.crypto.acme_certificate_order_validate
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
---
|
||||
- name: Create _validation-persist.<domain> TXT record contents
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ record | community.crypto.acme_dns_persist_record_parse }}"
|
||||
var:
|
||||
record: >-
|
||||
letsencrypt.org;
|
||||
accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234;
|
||||
policy=wildcard;
|
||||
persistUntil=1774813004
|
||||
|
||||
- name: Create _validation-persist.<domain> TXT record for example.com
|
||||
community.dns.hetzner_dns_record_set:
|
||||
prefix: _validation-persist
|
||||
zone_name: example.com
|
||||
value:
|
||||
- >-
|
||||
{{
|
||||
'letsencrypt.org' | community.crypto.acme_dns_persist_record(
|
||||
account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/4321',
|
||||
persist_until='20190331202428Z',
|
||||
)
|
||||
}}
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
_value:
|
||||
description:
|
||||
- The content for the V(_validation-persist.<domain>) TXT record.
|
||||
type: dictionary
|
||||
contains:
|
||||
issuer_domain_name:
|
||||
type: string
|
||||
description:
|
||||
- The issuer domain name.
|
||||
sample: letsencrypt.org
|
||||
account_uri:
|
||||
type: string
|
||||
description:
|
||||
- The ACME account URI.
|
||||
policy:
|
||||
description:
|
||||
- The validation scope.
|
||||
- Is V(null) if not present.
|
||||
type: string
|
||||
persist_until:
|
||||
description:
|
||||
- Until when the record is valid.
|
||||
- This is a UNIX timestamp, that is the number of seconds since January 1st, 1970, in UTC.
|
||||
- Is V(null) if V(persistUntil) is not present.
|
||||
type: string
|
||||
persist_until_str:
|
||||
description:
|
||||
- A ASN.1 string representation of RV(_value.persist_until).
|
||||
- Is V(null) if V(persistUntil) is not present.
|
||||
type: string
|
||||
"""
|
||||
|
||||
import typing as t
|
||||
from collections.abc import Callable
|
||||
|
||||
from ansible.errors import AnsibleFilterError
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils._caa import (
|
||||
parse_issue_value,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils._time import (
|
||||
from_epoch_seconds,
|
||||
)
|
||||
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
|
||||
|
||||
|
||||
def acme_dns_persist_record_parse(
|
||||
record_value: t.Any,
|
||||
) -> dict[str, t.Any]:
|
||||
if not isinstance(record_value, str):
|
||||
raise AnsibleFilterError(
|
||||
"The input for the community.crypto.acme_dns_persist_record_parse filter"
|
||||
f" must be a string; got {type(record_value)} instead"
|
||||
)
|
||||
try:
|
||||
domain_name, pairs = parse_issue_value(record_value)
|
||||
except ValueError as exc:
|
||||
raise AnsibleFilterError(
|
||||
"community.crypto.acme_dns_persist_record_parse filter could not parse"
|
||||
f" value: {exc}"
|
||||
) from exc
|
||||
values = dict(pairs)
|
||||
if domain_name is None:
|
||||
raise AnsibleFilterError(
|
||||
"community.crypto.acme_dns_persist_record_parse filter: domain name not present"
|
||||
)
|
||||
try:
|
||||
account_uri = values.pop("accounturi")
|
||||
except KeyError:
|
||||
raise AnsibleFilterError(
|
||||
"community.crypto.acme_dns_persist_record_parse filter: cannot find account URI"
|
||||
) from None
|
||||
policy = values.pop("policy", None)
|
||||
if policy is not None:
|
||||
policy = policy.lower()
|
||||
# TODO unknown policy
|
||||
persist_until_v = values.pop("persistUntil", None)
|
||||
persist_until: int | None = None
|
||||
persist_until_str: str | None = None
|
||||
if persist_until_v is not None:
|
||||
try:
|
||||
persist_until = int(persist_until_v)
|
||||
except ValueError as exc:
|
||||
raise AnsibleFilterError(
|
||||
f"community.crypto.acme_dns_persist_record_parse filter: error when parsing persistUntil: {exc}"
|
||||
) from None
|
||||
persist_until_str = from_epoch_seconds(
|
||||
persist_until, with_timezone=True
|
||||
).strftime(TIMESTAMP_FORMAT)
|
||||
result: dict[str, t.Any] = {
|
||||
"issuer_domain_name": domain_name,
|
||||
"account_uri": account_uri,
|
||||
"policy": policy,
|
||||
"persist_until": persist_until,
|
||||
"persist_until_str": persist_until_str,
|
||||
}
|
||||
# TODO values not empty
|
||||
return result
|
||||
|
||||
|
||||
class FilterModule:
|
||||
"""Ansible jinja2 filters"""
|
||||
|
||||
def filters(self) -> dict[str, Callable]:
|
||||
return {
|
||||
"acme_dns_persist_record_parse": acme_dns_persist_record_parse,
|
||||
}
|
||||
@@ -26,6 +26,9 @@ from ansible_collections.community.crypto.plugins.module_utils._acme.errors impo
|
||||
from ansible_collections.community.crypto.plugins.module_utils._acme.utils import (
|
||||
nopad_b64,
|
||||
)
|
||||
from ansible_collections.community.crypto.plugins.module_utils._caa import (
|
||||
_check_domain_name,
|
||||
)
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from ansible.module_utils.basic import AnsibleModule # pragma: no cover
|
||||
@@ -104,6 +107,52 @@ class Challenge:
|
||||
def get_validation_data(
|
||||
self, *, client: ACMEClient, identifier_type: str, identifier: str
|
||||
) -> dict[str, t.Any] | None:
|
||||
if self.type == "dns-persist-01":
|
||||
# https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html#section-3.1
|
||||
account_uri = self.data.get("accounturi")
|
||||
issuer_domain_names = self.data.get("issuer-domain-names")
|
||||
if account_uri is None:
|
||||
# In version 00 of the draft, accounturi isn't present.
|
||||
# Since that's what Pebble currently implements,
|
||||
# let's fake the value if we have it.
|
||||
# (https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-00.html#section-6)
|
||||
account_uri = client.account_uri
|
||||
if (
|
||||
not isinstance(account_uri, str)
|
||||
or not isinstance(issuer_domain_names, list)
|
||||
or not all(isinstance(idn, str) for idn in issuer_domain_names)
|
||||
):
|
||||
return None
|
||||
if client.account_uri is not None and account_uri != client.account_uri:
|
||||
# While the RFC doesn't demand this, I think it's a bad sign if the account URIs disagree.
|
||||
# Better err on the side of caution...
|
||||
client.module.warn(
|
||||
f"The dns-persist-01 challenge for DNS:{identifier} has account URI {account_uri!r},"
|
||||
f" while the client is has account URI {client.account_uri}. Ignoring malformed challenge."
|
||||
)
|
||||
return None
|
||||
if not (1 <= len(issuer_domain_names) <= 10):
|
||||
client.module.warn(
|
||||
f"The dns-persist-01 challenge for DNS:{identifier} has {len(issuer_domain_names)}"
|
||||
" issuer domain names, which is not in [1, 10]. Ignoring malformed challenge."
|
||||
)
|
||||
return None
|
||||
for idn in issuer_domain_names:
|
||||
try:
|
||||
_check_domain_name(idn)
|
||||
if idn != idn.lower() or len(idn) > 253:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
client.module.warn(
|
||||
f"The dns-persist-01 challenge for DNS:{identifier} has an invalid"
|
||||
f" issuer domain name {idn!r}. Ignoring malformed challenge."
|
||||
)
|
||||
return None
|
||||
return {
|
||||
"account_uri": account_uri,
|
||||
"issuer_domain_names": issuer_domain_names,
|
||||
}
|
||||
|
||||
if self.token is None:
|
||||
return None
|
||||
|
||||
|
||||
98
plugins/module_utils/_caa.py
Normal file
98
plugins/module_utils/_caa.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Copyright (c) 2020, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
|
||||
# Do not use this from other collections or standalone plugins/modules!
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_VALUE_RE = re.compile("^[\x21-\x3a\x3c-\x7e]*$")
|
||||
_LABEL_RE = re.compile("^[0-9a-zA-Z][0-9a-zA-Z-]*$")
|
||||
|
||||
|
||||
def _check_value(value: str) -> None:
|
||||
if not _VALUE_RE.match(value):
|
||||
raise ValueError(f"Invalid value {value!r}")
|
||||
|
||||
|
||||
def _check_label(label: str, what: str) -> None:
|
||||
if not _LABEL_RE.match(label):
|
||||
raise ValueError(f"Invalid {what} {label!r}")
|
||||
|
||||
|
||||
def _check_domain_name(value: str) -> None:
|
||||
for p in value.split("."):
|
||||
_check_label(p, "label")
|
||||
|
||||
|
||||
def parse_issue_value(
|
||||
value: str, *, check_for_duplicates: bool = True, strict: bool = True
|
||||
) -> tuple[str | None, list[tuple[str, str]]]:
|
||||
"""
|
||||
Given a CAA issue property, parses it according to the syntax defined in RFC 8659.
|
||||
|
||||
More precisely, see https://www.rfc-editor.org/rfc/rfc8659.html#section-4.2.
|
||||
|
||||
If ``check_for_duplicates == True``, duplicate tags are reported as an error.
|
||||
If ``strict == True``, invalid characters are reported as an error.
|
||||
"""
|
||||
parts = [v.strip(" \t") for v in value.split(";")]
|
||||
if len(parts) > 1 and not parts[-1]:
|
||||
del parts[-1]
|
||||
domain_name = parts[0] or None
|
||||
if domain_name is not None and strict:
|
||||
_check_domain_name(domain_name)
|
||||
pairs = []
|
||||
previous_tags: set[str] = set()
|
||||
for part in parts[1:]:
|
||||
pieces = part.split("=", 1)
|
||||
if len(pieces) != 2:
|
||||
raise ValueError(f"{part!r} is not of the form tag=value")
|
||||
tag, value = pieces[0].rstrip(" \t"), pieces[1].lstrip(" \t")
|
||||
if strict:
|
||||
_check_label(tag, "tag")
|
||||
_check_value(value)
|
||||
pairs.append((tag, value))
|
||||
if check_for_duplicates:
|
||||
if tag in previous_tags:
|
||||
raise ValueError(f"Tag {tag!r} appears multiple times")
|
||||
previous_tags.add(tag)
|
||||
return domain_name, pairs
|
||||
|
||||
|
||||
def join_issue_value(
|
||||
domain_name: str | None,
|
||||
pairs: list[tuple[str, str]],
|
||||
*,
|
||||
check_for_duplicates: bool = True,
|
||||
strict: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Given a domain name and a list of tag-value pairs, joins them according
|
||||
to the syntax defined in RFC 8659.
|
||||
|
||||
More precisely, see https://www.rfc-editor.org/rfc/rfc8659.html#section-4.2.
|
||||
|
||||
If ``check_for_duplicates == True``, duplicate tags are reported as an error.
|
||||
If ``strict == True``, invalid characters are reported as an error.
|
||||
"""
|
||||
if domain_name is not None and strict:
|
||||
_check_domain_name(domain_name)
|
||||
parts = [domain_name or ""]
|
||||
previous_tags: set[str] = set()
|
||||
for tag, value in pairs:
|
||||
if strict:
|
||||
_check_label(tag, "tag")
|
||||
_check_value(value)
|
||||
parts.append(f"{tag}={value}")
|
||||
if check_for_duplicates:
|
||||
if tag in previous_tags:
|
||||
raise ValueError(f"Tag {tag!r} appears multiple times")
|
||||
previous_tags.add(tag)
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
__all__ = ("parse_issue_value",)
|
||||
@@ -12,12 +12,13 @@ short_description: Create SSL/TLS certificates with the ACME protocol
|
||||
description:
|
||||
- Create and renew SSL/TLS certificates with a CA supporting the L(ACME protocol,https://tools.ietf.org/html/rfc8555), such
|
||||
as L(Let's Encrypt,https://letsencrypt.org/).
|
||||
The current implementation supports the V(http-01), V(dns-01), V(dns-account-01) and V(tls-alpn-01) challenges.
|
||||
The current implementation supports the V(http-01), V(dns-01), V(dns-account-01), V(dns-persist-01), and V(tls-alpn-01) challenges.
|
||||
- To use this module, it has to be executed twice. Either as two different tasks in the same run or during two runs. Note
|
||||
that the output of the first run needs to be recorded and passed to the second run as the module argument O(data).
|
||||
- Between these two tasks you have to fulfill the required steps for the chosen challenge by whatever means necessary. For
|
||||
V(http-01) that means creating the necessary challenge file on the destination webserver. For V(dns-01) and V(dns-account-01)
|
||||
the necessary DNS records have to be created. For V(tls-alpn-01) the necessary certificate has to be created and served.
|
||||
- Between these two tasks you have to fulfill the required steps for the chosen challenge by whatever means necessary.
|
||||
For V(http-01) that means creating the necessary challenge file on the destination webserver.
|
||||
For V(dns-01), V(dns-account-01), and V(dns-persist-01) the necessary DNS records have to be created.
|
||||
For V(tls-alpn-01) the necessary certificate has to be created and served.
|
||||
It is I(not) the responsibility of this module to perform these steps.
|
||||
- For details on how to fulfill these challenges, you might have to read through L(the main ACME specification,https://tools.ietf.org/html/rfc8555#section-8)
|
||||
and the L(TLS-ALPN-01 specification,https://www.rfc-editor.org/rfc/rfc8737.html#section-3). Also, consider the examples
|
||||
@@ -27,6 +28,10 @@ description:
|
||||
L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02).
|
||||
Note that the supported draft version can change at any time,
|
||||
and changes will only be considered breaking once the draft reached RFC status.
|
||||
- The module supports the V(dns-persist-01) challenge type according to
|
||||
L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html).
|
||||
Note that the supported draft version can change at any time,
|
||||
and changes will only be considered breaking once the draft reached RFC status.
|
||||
notes:
|
||||
- At least one of O(dest) and O(fullchain_dest) must be specified.
|
||||
- This module includes basic account management functionality. If you want to have more control over your ACME account,
|
||||
@@ -119,13 +124,14 @@ options:
|
||||
- If set to V(no challenge), no challenge will be used. This is necessary for some private CAs which use External Account
|
||||
Binding and other means of validating certificate assurance. For example, an account could be allowed to issue certificates
|
||||
for C(foo.example.com) without any further validation for a certain period of time.
|
||||
- Support for V(dns-account-01) has been added in community.crypto 3.2.0.
|
||||
- Support for V(dns-account-01) and V(dns-persist-01) has been added in community.crypto 3.2.0.
|
||||
type: str
|
||||
default: http-01
|
||||
choices:
|
||||
- http-01
|
||||
- dns-01
|
||||
- dns-account-01
|
||||
- dns-persist-01
|
||||
- tls-alpn-01
|
||||
- no challenge
|
||||
csr:
|
||||
@@ -473,14 +479,14 @@ challenge_data:
|
||||
description:
|
||||
- Data for every challenge type.
|
||||
- The keys in this dictionary are the challenge types. C(challenge-type) is a placeholder used in the documentation.
|
||||
Possible keys are V(http-01), V(dns-01), V(dns-account-01), and V(tls-alpn-01).
|
||||
Possible keys are V(http-01), V(dns-01), V(dns-account-01), V(dns-persist-01), and V(tls-alpn-01).
|
||||
- Note that the keys are not valid Jinja2 identifiers.
|
||||
returned: changed
|
||||
type: dict
|
||||
contains:
|
||||
resource:
|
||||
description: The challenge resource that must be created for validation.
|
||||
returned: changed
|
||||
returned: changed and challenge is not V(dns-persist-01)
|
||||
type: str
|
||||
sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA
|
||||
resource_original:
|
||||
@@ -497,7 +503,7 @@ challenge_data:
|
||||
binary blob which has to be put into the acmeValidation x509 extension; see U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3)
|
||||
for details. To do this, you might need the P(ansible.builtin.b64decode#filter) Jinja filter to extract
|
||||
the binary blob from this return value.
|
||||
returned: changed
|
||||
returned: changed and challenge is not V(dns-persist-01)
|
||||
type: str
|
||||
sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA
|
||||
record:
|
||||
@@ -505,6 +511,20 @@ challenge_data:
|
||||
returned: changed and challenge is V(dns-01) or V(dns-account-01)
|
||||
type: str
|
||||
sample: _acme-challenge.example.com
|
||||
account_uri:
|
||||
description:
|
||||
- The account URI that must be mentioned in the DNS TXT record.
|
||||
returned: changed and challenge is V(dns-persist-01)
|
||||
type: str
|
||||
sample: https://ca.example/acct/123
|
||||
issuer_domain_names:
|
||||
description:
|
||||
- One of the issuer domain names must be mentioned in the DNS TXT record.
|
||||
returned: changed and challenge is V(dns-persist-01)
|
||||
type: list
|
||||
elements: str
|
||||
sample:
|
||||
- letsencrypt.org
|
||||
challenge_data_dns:
|
||||
description:
|
||||
- List of TXT values per DNS record, in case challenge is V(dns-01) or V(dns-account-01).
|
||||
@@ -987,6 +1007,7 @@ def main() -> t.NoReturn:
|
||||
"http-01",
|
||||
"dns-01",
|
||||
"dns-account-01",
|
||||
"dns-persist-01",
|
||||
"tls-alpn-01",
|
||||
NO_CHALLENGE,
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ description:
|
||||
Authority such as L(Let's Encrypt,https://letsencrypt.org/).
|
||||
This module does not support ACME v1, the original version of the ACME protocol
|
||||
before standardization.
|
||||
- The current implementation supports the V(http-01), V(dns-01), V(dns-account-01),
|
||||
- The current implementation supports the V(http-01), V(dns-01), V(dns-account-01), V(dns-persist-01),
|
||||
and V(tls-alpn-01) challenges.
|
||||
- This module needs to be used in conjunction with the
|
||||
M(community.crypto.acme_certificate_order_validate) and.
|
||||
@@ -29,9 +29,9 @@ description:
|
||||
- Between the call of this module and M(community.crypto.acme_certificate_order_finalize),
|
||||
you have to fulfill the required steps for the chosen challenge by whatever means necessary.
|
||||
For V(http-01) that means creating the necessary challenge file on the destination webserver.
|
||||
For V(dns-01) and V(dns-account-01) the necessary DNS records have to be created. For V(tls-alpn-01) the necessary
|
||||
certificate has to be created and served. It is I(not) the responsibility of this module to
|
||||
perform these steps.
|
||||
For V(dns-01), V(dns-account-01), and V(dns-persist-01) the necessary DNS records have to be created.
|
||||
For V(tls-alpn-01) the necessary certificate has to be created and served.
|
||||
It is I(not) the responsibility of this module to perform these steps.
|
||||
- For details on how to fulfill these challenges, you might have to read through
|
||||
L(the main ACME specification,https://tools.ietf.org/html/rfc8555#section-8)
|
||||
and the L(TLS-ALPN-01 specification,https://www.rfc-editor.org/rfc/rfc8737.html#section-3).
|
||||
@@ -42,6 +42,10 @@ description:
|
||||
L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02).
|
||||
Note that the supported draft version can change at any time,
|
||||
and changes will only be considered breaking once the draft reached RFC status.
|
||||
- The module supports the V(dns-persist-01) challenge type according to
|
||||
L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html).
|
||||
Note that the supported draft version can change at any time,
|
||||
and changes will only be considered breaking once the draft reached RFC status.
|
||||
seealso:
|
||||
- module: community.crypto.acme_certificate_order_validate
|
||||
description: Validate pending authorizations of an ACME order.
|
||||
@@ -307,6 +311,7 @@ challenge_data:
|
||||
resource:
|
||||
description:
|
||||
- Always contains the string V(_acme-challenge).
|
||||
returned: success
|
||||
type: str
|
||||
sample: _acme-challenge
|
||||
resource_value:
|
||||
@@ -332,6 +337,7 @@ challenge_data:
|
||||
resource:
|
||||
description:
|
||||
- Always ends with the string V(._acme-challenge).
|
||||
returned: success
|
||||
type: str
|
||||
sample: _ujmmovf2vn55tgye._acme-challenge
|
||||
resource_value:
|
||||
@@ -345,6 +351,29 @@ challenge_data:
|
||||
returned: success
|
||||
type: str
|
||||
sample: _ujmmovf2vn55tgye._acme-challenge.example.com
|
||||
dns-persist-01:
|
||||
description:
|
||||
- Information for V(dns-persist-01) authorization.
|
||||
- A DNS TXT record needs to be created with the record name V(_validation-persist.<domain>).
|
||||
See the P(community.crypto.acme_dns_persist_record#filter) for how to create the record's content.
|
||||
returned: if the identifier supports V(dns-persist-01) authorization
|
||||
version_added: 3.2.0
|
||||
type: dict
|
||||
contains:
|
||||
account_uri:
|
||||
description:
|
||||
- The account URI that must be mentioned in the DNS TXT record.
|
||||
returned: success
|
||||
type: str
|
||||
sample: https://ca.example/acct/123
|
||||
issuer_domain_names:
|
||||
description:
|
||||
- One of the issuer domain names must be mentioned in the DNS TXT record.
|
||||
returned: success
|
||||
type: list
|
||||
elements: str
|
||||
sample:
|
||||
- letsencrypt.org
|
||||
tls-alpn-01:
|
||||
description:
|
||||
- Information for V(tls-alpn-01) authorization.
|
||||
|
||||
@@ -24,6 +24,10 @@ description:
|
||||
L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02).
|
||||
Note that the supported draft version can change at any time,
|
||||
and changes will only be considered breaking once the draft reached RFC status.
|
||||
- The module supports the V(dns-persist-01) challenge type according to
|
||||
L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html).
|
||||
Note that the supported draft version can change at any time,
|
||||
and changes will only be considered breaking once the draft reached RFC status.
|
||||
seealso:
|
||||
- module: community.crypto.acme_certificate_order_create
|
||||
description: Create an ACME order.
|
||||
@@ -260,13 +264,14 @@ authorizations_by_identifier:
|
||||
type:
|
||||
description:
|
||||
- The type of challenge encoded in the object.
|
||||
- Support for V(dns-account-01) has been added in community.crypto 3.2.0.
|
||||
- Support for V(dns-account-01) and V(dns-persist-01) has been added in community.crypto 3.2.0.
|
||||
type: str
|
||||
returned: always
|
||||
choices:
|
||||
- http-01
|
||||
- dns-01
|
||||
- dns-account-01
|
||||
- dns-persist-01
|
||||
- tls-alpn-01
|
||||
url:
|
||||
description:
|
||||
|
||||
@@ -65,12 +65,13 @@ options:
|
||||
- In case of authorization reuse, or in case of CAs which use External Account Binding
|
||||
and other means of validating certificate assurance, it might not be necessary
|
||||
to provide this option.
|
||||
- Support for V(dns-account-01) has been added in community.crypto 3.2.0.
|
||||
- Support for V(dns-account-01) and V(dns-persist-01) has been added in community.crypto 3.2.0.
|
||||
type: str
|
||||
choices:
|
||||
- http-01
|
||||
- dns-01
|
||||
- dns-account-01
|
||||
- dns-persist-01
|
||||
- tls-alpn-01
|
||||
order_uri:
|
||||
description:
|
||||
@@ -250,7 +251,13 @@ def main() -> t.NoReturn:
|
||||
order_uri={"type": "str", "required": True},
|
||||
challenge={
|
||||
"type": "str",
|
||||
"choices": ["http-01", "dns-01", "dns-account-01", "tls-alpn-01"],
|
||||
"choices": [
|
||||
"http-01",
|
||||
"dns-01",
|
||||
"dns-account-01",
|
||||
"dns-persist-01",
|
||||
"tls-alpn-01",
|
||||
],
|
||||
},
|
||||
deactivate_authzs={"type": "bool", "default": True},
|
||||
)
|
||||
|
||||
@@ -387,6 +387,66 @@
|
||||
ansible.builtin.set_fact:
|
||||
cert_9_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_9_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
- when: ansible_version.full is version('2.21', '>=')
|
||||
vars:
|
||||
validation_record_domains:
|
||||
- name: example.com
|
||||
wildcard: true
|
||||
- name: example.org
|
||||
persist_until: "+5m"
|
||||
- name: t1.example.com
|
||||
issuer_domain_name: pebble.letsencrypt.org
|
||||
block:
|
||||
- name: Obtain ACME account
|
||||
community.crypto.acme_account_info:
|
||||
acme_directory: "{{ acme_directory_url }}"
|
||||
acme_version: 2
|
||||
validate_certs: false
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
register: account_info
|
||||
- name: Create dns-persist-01 DNS entries
|
||||
ansible.builtin.uri:
|
||||
url: "http://{{ acme_host }}:5000/dns/_validation-persist.{{ item.name }}"
|
||||
method: PUT
|
||||
body_format: json
|
||||
body:
|
||||
- >-
|
||||
{{
|
||||
issuer_domain_name
|
||||
| community.crypto.acme_dns_persist_record(
|
||||
account_uri=account_info.account_uri,
|
||||
policy='wildcard' if item.wildcard | default(false) else none,
|
||||
persist_until=item.persist_until | default(none),
|
||||
)
|
||||
}}
|
||||
loop: "{{ validation_record_domains }}"
|
||||
- name: Obtain cert 10
|
||||
ansible.builtin.include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
certgen_title: Certificate 10
|
||||
certificate_name: cert-10
|
||||
key_type: ec256
|
||||
subject_alt_name: "DNS:*.example.com,DNS:example.org,DNS:t1.example.com"
|
||||
subject_alt_name_critical: false
|
||||
account_key: account-ec256
|
||||
challenge: dns-persist-01
|
||||
modify_account: true
|
||||
deactivate_authzs: false
|
||||
force: false
|
||||
remaining_days: 1
|
||||
terms_agreed: true
|
||||
account_email: "example@example.org"
|
||||
use_csr_content: true
|
||||
- name: Store obtain results for cert 10
|
||||
ansible.builtin.set_fact:
|
||||
cert_10_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_10_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
- name: Remove dns-persist-01 DNS entries
|
||||
ansible.builtin.uri:
|
||||
url: "http://{{ acme_host }}:5000/dns/_validation-persist.{{ item.name }}"
|
||||
method: DELETE
|
||||
loop: "{{ validation_record_domains }}"
|
||||
|
||||
## DISSECT CERTIFICATES #######################################################################
|
||||
# Make sure certificates are valid. Root certificate for Pebble equals the chain certificate.
|
||||
@@ -429,6 +489,11 @@
|
||||
ignore_errors: true
|
||||
register: cert_9_valid
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
- name: Verifying cert 10
|
||||
ansible.builtin.command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-10-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-10-chain.pem" "{{ remote_tmp_dir }}/cert-10.pem"'
|
||||
ignore_errors: true
|
||||
register: cert_10_valid
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
|
||||
# Dump certificate info
|
||||
- name: Dumping cert 1
|
||||
@@ -461,6 +526,10 @@
|
||||
ansible.builtin.command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-9.pem" -noout -text'
|
||||
register: cert_9_text
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
- name: Dumping cert 10
|
||||
ansible.builtin.command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-10.pem" -noout -text'
|
||||
register: cert_10_text
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
|
||||
# Dump certificate info
|
||||
- name: Dumping cert 1
|
||||
@@ -502,6 +571,11 @@
|
||||
path: "{{ remote_tmp_dir }}/cert-9.pem"
|
||||
register: cert_9_info
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
- name: Dumping cert 10
|
||||
community.crypto.x509_certificate_info:
|
||||
path: "{{ remote_tmp_dir }}/cert-10.pem"
|
||||
register: cert_10_info
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
|
||||
## GET ACCOUNT ORDERS #########################################################################
|
||||
- name: Don't retrieve orders
|
||||
|
||||
@@ -173,6 +173,19 @@
|
||||
- "'DNS:example.org' in cert_9_text.stdout"
|
||||
- "'DNS:t1.example.com' in cert_9_text.stdout"
|
||||
|
||||
- when: ansible_version.full is version('2.21', '>=')
|
||||
block:
|
||||
- name: Check that certificate 10 is valid
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- cert_10_valid is not failed
|
||||
- name: Check that certificate 10 contains correct SANs
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'DNS:*.example.com' in cert_10_text.stdout"
|
||||
- "'DNS:example.org' in cert_10_text.stdout"
|
||||
- "'DNS:t1.example.com' in cert_10_text.stdout"
|
||||
|
||||
- name: Validate that orders were not retrieved
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
ansible.builtin.debug:
|
||||
var: order_1
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check order"
|
||||
- name: "({{ select_crypto_backend }}) Check order (1/2)"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- order_1 is changed
|
||||
@@ -80,6 +80,24 @@
|
||||
- order_1.challenge_data_dns['_acme-challenge.' ~ domain_name] | length == 1
|
||||
- order_1.account_uri == account.account_uri
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check order (2/2)"
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'dns-account-01' in order_1.challenge_data[0].challenges"
|
||||
- "'dns-persist-01' in order_1.challenge_data[0].challenges"
|
||||
- order_1.challenge_data[0].challenges['dns-account-01'].record.endswith('._acme-challenge.' ~ domain_name)
|
||||
- order_1.challenge_data[0].challenges['dns-account-01'].resource.endswith('._acme-challenge')
|
||||
- order_1.challenge_data[0].challenges['dns-account-01'].resource_value is string
|
||||
- "'record' not in order_1.challenge_data[0].challenges['dns-persist-01']"
|
||||
- "'resource' not in order_1.challenge_data[0].challenges['dns-persist-01']"
|
||||
- "'resource_value' not in order_1.challenge_data[0].challenges['dns-persist-01']"
|
||||
- order_1.challenge_data[0].challenges['dns-persist-01'].account_uri == order_1.account_uri
|
||||
- order_1.challenge_data[0].challenges['dns-persist-01'].issuer_domain_names | length == 2
|
||||
- order_1.challenge_data[0].challenges['dns-persist-01'].issuer_domain_names == ["pebble.letsencrypt.org", "ca.example.com"]
|
||||
- order_1.challenge_data_dns_account | length == 1
|
||||
- order_1.challenge_data_dns_account.values() | first | length == 1
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Get order information"
|
||||
community.crypto.acme_certificate_order_info:
|
||||
acme_directory: "{{ acme_directory_url }}"
|
||||
@@ -94,7 +112,7 @@
|
||||
ansible.builtin.debug:
|
||||
var: order_info_1
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check order information"
|
||||
- name: "({{ select_crypto_backend }}) Check order information (1/2)"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- order_info_1 is not changed
|
||||
@@ -119,6 +137,13 @@
|
||||
- order_info_1.order_uri == order_1.order_uri
|
||||
- order_info_1.account_uri == account.account_uri
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check order information (2/2)"
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (order_info_1.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | first).status == 'pending'
|
||||
- (order_info_1.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | first).status == 'pending'
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Create HTTP challenges"
|
||||
ansible.builtin.uri:
|
||||
url: "http://{{ acme_host }}:5000/http/{{ item.identifier }}/{{ item.challenges['http-01'].resource[('.well-known/acme-challenge/' | length) :] }}"
|
||||
@@ -161,7 +186,7 @@
|
||||
ansible.builtin.debug:
|
||||
var: order_info_2
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check order information"
|
||||
- name: "({{ select_crypto_backend }}) Check order information (1/2)"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- order_info_2 is not changed
|
||||
@@ -186,6 +211,13 @@
|
||||
- order_info_2.order_uri == order_1.order_uri
|
||||
- order_info_2.account_uri == account.account_uri
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check order information (2/2)"
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (order_info_2.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | map(attribute='status') | first | default('not there')) in ['pending', 'not there']
|
||||
- (order_info_2.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | map(attribute='status') | first | default('not there')) in ['pending', 'not there']
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Let the challenge be validated (idempotent)"
|
||||
community.crypto.acme_certificate_order_validate:
|
||||
acme_directory: "{{ acme_directory_url }}"
|
||||
@@ -411,7 +443,7 @@
|
||||
- replacement_order_1.account_uri == account.account_uri
|
||||
- replacement_order_1.order_uri not in [order_1.order_uri]
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 1 information"
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 1 information (1/2)"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- order_info_5 is not changed
|
||||
@@ -436,6 +468,13 @@
|
||||
- order_info_5.order_uri == replacement_order_1.order_uri
|
||||
- order_info_5.account_uri == account.account_uri
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 1 information (2/2)"
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (order_info_5.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | first).status == 'pending'
|
||||
- (order_info_5.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | first).status == 'pending'
|
||||
|
||||
# Right now Pebble does not reject duplicate replacement orders...
|
||||
|
||||
- when: false # TODO get Pebble improved
|
||||
@@ -509,7 +548,7 @@
|
||||
- >-
|
||||
('Stop passing `replaces=' ~ cert_info.cert_id ~ '` due to error 409 urn:ietf:params:acme:error:alreadyReplaced when creating ACME order') in replacement_order_3.warnings
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 3 information"
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 3 information (1/2)"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- order_info_6 is not changed
|
||||
@@ -534,6 +573,13 @@
|
||||
- order_info_6.order_uri == replacement_order_3.order_uri
|
||||
- order_info_6.account_uri == account.account_uri
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 3 information (2/2)"
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (order_info_6.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | first).status == 'pending'
|
||||
- (order_info_6.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | first).status == 'pending'
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Deactivate authzs for replacement order 3"
|
||||
community.crypto.acme_certificate_deactivate_authz:
|
||||
acme_directory: "{{ acme_directory_url }}"
|
||||
@@ -647,7 +693,7 @@
|
||||
order_uri: "{{ replacement_order_5.order_uri }}"
|
||||
register: order_info_7
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 5"
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 5 (1/2)"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- replacement_order_5 is changed
|
||||
@@ -670,7 +716,25 @@
|
||||
- >-
|
||||
('Stop passing `replaces=' ~ cert_info.cert_id ~ '` due to error 409 urn:ietf:params:acme:error:malformed when creating ACME order') in replacement_order_5.warnings
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 5 information"
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 5 (2/2)"
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'dns-account-01' in replacement_order_5.challenge_data[0].challenges"
|
||||
- "'dns-persist-01' in replacement_order_5.challenge_data[0].challenges"
|
||||
- replacement_order_5.challenge_data[0].challenges['dns-account-01'].record.endswith('._acme-challenge.' ~ domain_name)
|
||||
- replacement_order_5.challenge_data[0].challenges['dns-account-01'].resource.endswith('._acme-challenge')
|
||||
- replacement_order_5.challenge_data[0].challenges['dns-account-01'].resource_value is string
|
||||
- "'record' not in replacement_order_5.challenge_data[0].challenges['dns-persist-01']"
|
||||
- "'resource' not in replacement_order_5.challenge_data[0].challenges['dns-persist-01']"
|
||||
- "'resource_value' not in replacement_order_5.challenge_data[0].challenges['dns-persist-01']"
|
||||
- replacement_order_5.challenge_data[0].challenges['dns-persist-01'].account_uri == replacement_order_5.account_uri
|
||||
- replacement_order_5.challenge_data[0].challenges['dns-persist-01'].issuer_domain_names | length == 2
|
||||
- replacement_order_5.challenge_data[0].challenges['dns-persist-01'].issuer_domain_names == ["pebble.letsencrypt.org", "ca.example.com"]
|
||||
- replacement_order_5.challenge_data_dns_account | length == 1
|
||||
- replacement_order_5.challenge_data_dns_account.values() | first | length == 1
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 5 information (1/2)"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- order_info_7 is not changed
|
||||
@@ -695,6 +759,13 @@
|
||||
- order_info_7.order_uri == replacement_order_5.order_uri
|
||||
- order_info_7.account_uri == account.account_uri
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Check replacement order 5 information (2/2)"
|
||||
when: ansible_version.full is version('2.21', '>=')
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (order_info_7.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | first).status == 'pending'
|
||||
- (order_info_7.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | first).status == 'pending'
|
||||
|
||||
- name: "({{ select_crypto_backend }}) Deactivate authzs for replacement order 5"
|
||||
community.crypto.acme_certificate_deactivate_authz:
|
||||
acme_directory: "{{ acme_directory_url }}"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copyright (c) Ansible Project
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
azp/generic/1
|
||||
azp/posix/1
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
# Copyright (c) 2026 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
- name: Basic tests
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
'letsencrypt.org' | community.crypto.acme_dns_persist_record(
|
||||
account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234',
|
||||
)
|
||||
== "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234"
|
||||
- >-
|
||||
'letsencrypt.org' | community.crypto.acme_dns_persist_record(
|
||||
account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234',
|
||||
persist_until=1234,
|
||||
)
|
||||
== "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; persistUntil=1234"
|
||||
- >-
|
||||
'letsencrypt.org' | community.crypto.acme_dns_persist_record(
|
||||
account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234',
|
||||
policy='wildcard',
|
||||
)
|
||||
== "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; policy=wildcard"
|
||||
- >-
|
||||
'letsencrypt.org' | community.crypto.acme_dns_persist_record(
|
||||
account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234',
|
||||
policy='wildcard',
|
||||
persist_until=1234,
|
||||
)
|
||||
== "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; policy=wildcard; persistUntil=1234"
|
||||
|
||||
- name: Test error (1/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ 42 | community.crypto.acme_dns_persist_record(account_uri='') }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (1/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"The input for the community.crypto.acme_dns_persist_record filter must be a string; got <class 'int'> instead" in result.msg
|
||||
|
||||
- name: Test error (2/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri=42) }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (2/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"The account_uri parameter for the community.crypto.acme_dns_persist_record filter must be a string; got <class 'int'> instead" in result.msg
|
||||
|
||||
- name: Test error (3/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri='', policy=42) }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (3/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
'The policy parameter for the community.crypto.acme_dns_persist_record filter must be one of "wildcard"; got 42 instead' in result.msg
|
||||
|
||||
- name: Test error (4/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri='') }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (4/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"Error composing result for the community.crypto.acme_dns_persist_record filter: Invalid label ''" in result.msg
|
||||
|
||||
- name: Test error (5/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri='', persist_until=true) }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (5/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"The persist_until parameter for the community.crypto.acme_dns_persist_record filter must be an integer, a string, or a datetime object; got <class 'bool'> instead" in result.msg
|
||||
|
||||
- name: Test error (6/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri='', persist_until='foo bar') }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (6/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
'Error parsing persist_until parameter for the community.crypto.acme_dns_persist_record filter: The time spec "foo bar" for persist_until is invalid' in result.msg
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copyright (c) Ansible Project
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
azp/generic/1
|
||||
azp/posix/1
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
# Copyright (c) 2026 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
- name: Basic tests
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234"
|
||||
| community.crypto.acme_dns_persist_record_parse
|
||||
== {
|
||||
"account_uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1234",
|
||||
"issuer_domain_name": "letsencrypt.org",
|
||||
"persist_until": None,
|
||||
"persist_until_str": None,
|
||||
"policy": None,
|
||||
}
|
||||
- >-
|
||||
"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; policy=wildcard"
|
||||
| community.crypto.acme_dns_persist_record_parse
|
||||
== {
|
||||
"account_uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1234",
|
||||
"issuer_domain_name": "letsencrypt.org",
|
||||
"persist_until": None,
|
||||
"persist_until_str": None,
|
||||
"policy": "wildcard",
|
||||
}
|
||||
- >-
|
||||
"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; persistUntil=1234"
|
||||
| community.crypto.acme_dns_persist_record_parse
|
||||
== {
|
||||
"account_uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1234",
|
||||
"issuer_domain_name": "letsencrypt.org",
|
||||
"persist_until": 1234,
|
||||
"persist_until_str": "19700101002034Z",
|
||||
"policy": None,
|
||||
}
|
||||
- >-
|
||||
"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; policy=wildcard; persistUntil=1234"
|
||||
| community.crypto.acme_dns_persist_record_parse
|
||||
== {
|
||||
"account_uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1234",
|
||||
"issuer_domain_name": "letsencrypt.org",
|
||||
"persist_until": 1234,
|
||||
"persist_until_str": "19700101002034Z",
|
||||
"policy": "wildcard",
|
||||
}
|
||||
|
||||
- name: Test error (1/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ 42 | community.crypto.acme_dns_persist_record_parse }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (1/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"The input for the community.crypto.acme_dns_persist_record_parse filter must be a string; got <class 'int'> instead" in result.msg
|
||||
|
||||
- name: Test error (2/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ ';' | community.crypto.acme_dns_persist_record_parse }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (2/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"community.crypto.acme_dns_persist_record_parse filter: domain name not present" in result.msg
|
||||
|
||||
- name: Test error (3/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ 'lets.encrypt; foo=bar' | community.crypto.acme_dns_persist_record_parse }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (3/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"community.crypto.acme_dns_persist_record_parse filter: cannot find account URI" in result.msg
|
||||
|
||||
- name: Test error (4/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ 'lets.encrypt; accounturi=foo; persistUntil=a' | community.crypto.acme_dns_persist_record_parse }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (4/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"community.crypto.acme_dns_persist_record_parse filter: error when parsing persistUntil: invalid literal for int() with base 10: 'a'" in result.msg
|
||||
|
||||
- name: Test error (1/N)
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ 'a; b' | community.crypto.acme_dns_persist_record_parse }}"
|
||||
ignore_errors: true
|
||||
register: result
|
||||
|
||||
- name: Check error (1/N)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- result is failed
|
||||
- >-
|
||||
"community.crypto.acme_dns_persist_record_parse filter could not parse value: 'b' is not of the form tag=value" in result.msg
|
||||
180
tests/unit/plugins/module_utils/test__caa.py
Normal file
180
tests/unit/plugins/module_utils/test__caa.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# Copyright (c) Ansible Project
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
|
||||
import pytest
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils._caa import (
|
||||
_check_domain_name,
|
||||
_check_label,
|
||||
_check_value,
|
||||
join_issue_value,
|
||||
parse_issue_value,
|
||||
)
|
||||
|
||||
TEST_CHECK_VALUE: list[tuple[str, str | None]] = [
|
||||
("", None),
|
||||
("a", None),
|
||||
(
|
||||
"!\"#$%&'()*+,-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
|
||||
None,
|
||||
),
|
||||
("a=b", None),
|
||||
("\x1f", "Invalid value '\\x1f'"),
|
||||
(" ", "Invalid value ' '"),
|
||||
(";", "Invalid value ';'"),
|
||||
("\x7f", "Invalid value '\\x7f'"),
|
||||
("a b", "Invalid value 'a b'"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, error", TEST_CHECK_VALUE)
|
||||
def test_check_value(
|
||||
value: str,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
if error is None:
|
||||
_check_value(value)
|
||||
else:
|
||||
with pytest.raises(ValueError, match=f"^{re.escape(error)}$"):
|
||||
_check_value(value)
|
||||
|
||||
|
||||
TEST_CHECK_LABEL: list[tuple[str, str | None]] = [
|
||||
("", "Invalid value ''"),
|
||||
("a", None),
|
||||
("0", None),
|
||||
("a-", None),
|
||||
("a=", "Invalid value 'a='"),
|
||||
("-a", "Invalid value '-a'"),
|
||||
(" ", "Invalid value ' '"),
|
||||
("\t", "Invalid value '\\t'"),
|
||||
("a0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-", None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, error", TEST_CHECK_LABEL)
|
||||
def test_check_label(
|
||||
value: str,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
if error is None:
|
||||
_check_label(value, "value")
|
||||
else:
|
||||
with pytest.raises(ValueError, match=f"^{re.escape(error)}$"):
|
||||
_check_label(value, "value")
|
||||
|
||||
|
||||
TEST_CHECK_DOMAIN_NAME: list[tuple[str, str | None]] = [
|
||||
("", "Invalid label ''"),
|
||||
("a.", "Invalid label ''"),
|
||||
(".a", "Invalid label ''"),
|
||||
("a.-", "Invalid label '-'"),
|
||||
("a.b", None),
|
||||
("a.b.c.d.e.f.g.h.i.j.k.l", None),
|
||||
("letsencrypt.org", None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, error", TEST_CHECK_DOMAIN_NAME)
|
||||
def test_check_domain_name(
|
||||
value: str,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
if error is None:
|
||||
_check_domain_name(value)
|
||||
else:
|
||||
with pytest.raises(ValueError, match=f"^{re.escape(error)}$"):
|
||||
_check_domain_name(value)
|
||||
|
||||
|
||||
TEST_PARSE_ISSUE_VALUE: list[
|
||||
tuple[str, dict[str, t.Any], str | None, list[tuple[str, str]]]
|
||||
] = [
|
||||
("", {}, None, []),
|
||||
(";", {}, None, []),
|
||||
("a=b", {"strict": False}, "a=b", []),
|
||||
("; a=b", {"strict": False}, None, [("a", "b")]),
|
||||
("a; a=b", {"strict": False}, "a", [("a", "b")]),
|
||||
("a; a=b; c-d=e", {"strict": False}, "a", [("a", "b"), ("c-d", "e")]),
|
||||
("ca1.example.net", {}, "ca1.example.net", []),
|
||||
("ca1.example.net; account=230123", {}, "ca1.example.net", [("account", "230123")]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, kwargs, expected_domain_name, expected_pairs", TEST_PARSE_ISSUE_VALUE
|
||||
)
|
||||
def test_parse_issue_value(
|
||||
value: str,
|
||||
kwargs: dict[str, t.Any],
|
||||
expected_domain_name: str | None,
|
||||
expected_pairs: list[tuple[str, str]],
|
||||
) -> None:
|
||||
assert parse_issue_value(value, **kwargs) == (expected_domain_name, expected_pairs)
|
||||
|
||||
|
||||
TEST_PARSE_ISSUE_VALUE_FAIL: list[tuple[str, dict[str, t.Any], str]] = [
|
||||
("a=b", {}, "Invalid label 'a=b'"),
|
||||
("a; a.b=b", {}, "Invalid tag 'a.b'"),
|
||||
("a; a=b; a=c", {}, "Tag 'a' appears multiple times"),
|
||||
("%%%%%", {}, "Invalid label '%%%%%'"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, kwargs, expected_error", TEST_PARSE_ISSUE_VALUE_FAIL)
|
||||
def test_parse_issue_value_fail(
|
||||
value: str, kwargs: dict[str, t.Any], expected_error: str
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match=f"^{re.escape(expected_error)}$"):
|
||||
parse_issue_value(value, **kwargs)
|
||||
|
||||
|
||||
TEST_JOIN_ISSUE_VALUE: list[
|
||||
tuple[str | None, list[tuple[str, str]], dict[str, t.Any], str]
|
||||
] = [
|
||||
(None, [], {}, ""),
|
||||
("a", [], {}, "a"),
|
||||
(None, [("a", "b")], {}, "; a=b"),
|
||||
("a", [("a", "b")], {}, "a; a=b"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain_name, pairs, kwargs, expected_result", TEST_JOIN_ISSUE_VALUE
|
||||
)
|
||||
def test_join_issue_value(
|
||||
domain_name: str | None,
|
||||
pairs: list[tuple[str, str]],
|
||||
kwargs: dict[str, t.Any],
|
||||
expected_result: str,
|
||||
) -> None:
|
||||
assert join_issue_value(domain_name, pairs, **kwargs) == expected_result
|
||||
|
||||
|
||||
TEST_JOIN_ISSUE_VALUE_FAIL: list[
|
||||
tuple[str | None, list[tuple[str, str]], dict[str, t.Any], str]
|
||||
] = [
|
||||
("", [], {}, "Invalid label ''"),
|
||||
(None, [("", "")], {}, "Invalid tag ''"),
|
||||
(None, [("a", " ")], {}, "Invalid value ' '"),
|
||||
(None, [("a", "a"), ("a", "b")], {}, "Tag 'a' appears multiple times"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain_name, pairs, kwargs, expected_error", TEST_JOIN_ISSUE_VALUE_FAIL
|
||||
)
|
||||
def test_join_issue_value_fail(
|
||||
domain_name: str | None,
|
||||
pairs: list[tuple[str, str]],
|
||||
kwargs: dict[str, t.Any],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match=f"^{re.escape(expected_error)}$"):
|
||||
join_issue_value(domain_name, pairs, **kwargs)
|
||||
Reference in New Issue
Block a user