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:
Felix Fontein
2026-04-01 19:46:59 +02:00
committed by GitHub
parent 4c5962788d
commit f3b43185bf
17 changed files with 1148 additions and 22 deletions

View 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).

View 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,
}

View 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,
}

View File

@@ -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

View 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",)

View File

@@ -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,
],

View File

@@ -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.

View File

@@ -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:

View File

@@ -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},
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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 }}"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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)