From 0ca35089a3b342412ab09b6397e54fc0bca7a7df Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 22:00:35 +0000 Subject: [PATCH] feat: the Let's Encrypt screen is driven by the instance description The domain boxes are prefilled from /etc/keel/instance.yaml: tls.acme.domains when it declares any, else instance.fqdn (the name the first boot records, Keel-Linux/inithooks#36), else from dehydrated's domains file as before, example.com on a fresh machine. Once the operator confirms the domains and the certificate is issued, the description gets tls.acme.domains and tls.acme.enabled: true, written as every Instance screen writes it (keelcli: staged, keel spec validate --no-secret-files, moved into place; a refusal is shown and the file left as it was). A request that fails writes nothing to it. dehydrated keeps reading its own domains file, written as before; HTTP-01 and DNS-01 are unchanged. The screen's text was eleven rows, too tall for a 24 row console with the five boxes under it; a test now holds it to the room that leaves. --- COVERAGE.md | 22 ++ debian/changelog | 20 + docs/Lets_encrypt.rst | 25 ++ keelcli.py | 92 +++++ plugins.d/Lets_Encrypt/get_certificate.py | 85 +++- tests/conftest.py | 18 + tests/test_lets_encrypt.py | 457 ++++++++++++++++++++++ 7 files changed, 702 insertions(+), 17 deletions(-) create mode 100644 tests/test_lets_encrypt.py diff --git a/COVERAGE.md b/COVERAGE.md index 3c1484e..5de8721 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -10,6 +10,28 @@ previous tip a2d9f70 (2025-10-02) was 50 commits behind and lacked the IPv6-aware rewrite of `ifutil.py` (upstream pull request #109), which is the file the static IPv6 work starts from. +## Branch feat/lets-encrypt-from-the-spec: 100 percent kept (2026-10-02) + +The Let's Encrypt screen reads and writes the instance description. +`keelcli.py` gains `acme_domains`, `bare_domains`, `recordable_domains`, +`with_acme` and `save_acme` (414 statements, 84 branches, 100 percent) and +stays in the measured set. `tests/test_lets_encrypt.py` (35 tests) loads +`plugins.d/Lets_Encrypt/get_certificate.py` the way confconsole does, with +the fake console, requests, dehydrated-wrapper and keel replaced: the +domains a description offers, the writer accepting and refusing (keel +refusing, keel absent, a file that cannot be staged or replaced), the +prefill (declared domains, the fqdn, more than five, the file and its +alias, a fresh machine, a description that does not read), the screen +recording an issued certificate and writing nothing on a failed request, a +refusal shown with keel's words, a wildcard left out of the description +and said so (alone, recording nothing), a cancelled form, and the form +text held to an 80x24 console with the five boxes under it. 864 passed, 7 +skipped, +total 100. Measured on its own (`--source=plugins.d/Lets_Encrypt`), the +screen is at 61 percent: every line this branch adds runs, what is left +is the DNS-01 flow and the alias handling the tests do not reach, so the +file does not join the gate yet. + ## Baseline: 0 percent measured There is no `tests/` directory, no pytest or unittest suite and no diff --git a/debian/changelog b/debian/changelog index 99df9fb..1452fdb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,23 @@ +confconsole (2.2.3+keel14) trixie; urgency=low + + * The Let's Encrypt screen is driven by the instance description. Its + domain boxes are prefilled from tls.acme.domains, else instance.fqdn + (the name the first boot now records, inithooks 31fqdn), else from + dehydrated's domains file as before, example.com on a fresh machine. + Once the operator confirms the domains and the certificate is issued, + /etc/keel/instance.yaml gets tls.acme.domains and tls.acme.enabled: + true, written as every Instance screen writes it (keelcli: staged, + keel spec validate, moved into place; a refusal is shown and the file + left as it was). A request that fails writes nothing to it. dehydrated + keeps reading its own domains file, written as before; HTTP-01 and + DNS-01 are unchanged. A wildcard is left out of the description and + said so, since keel's domain validation has no field it fits yet. + * The screen's text fits an 80x24 console with the five boxes under it: + it was eleven rows, and a test now holds it to the room a 24 row + console leaves. + + -- Marcos Mendez Fri, 02 Oct 2026 21:30:00 +0000 + confconsole (2.2.3+keel13) trixie; urgency=low * The first boot role screen does not run keel inspect on a machine diff --git a/docs/Lets_encrypt.rst b/docs/Lets_encrypt.rst index 4ed3073..11b3a1a 100644 --- a/docs/Lets_encrypt.rst +++ b/docs/Lets_encrypt.rst @@ -76,6 +76,31 @@ to do so will cause the Let's Encrypt challenges to fail (so you won't get a certificate). Repeated failures may cause your server to be blocked (for up to a week - perhaps longer) from further attempts. +The instance description +------------------------ + +The domain boxes are prefilled from the instance description, +``/etc/keel/instance.yaml`` (or ``$KEEL_SPEC``): ``tls.acme.domains`` +when it declares any, else ``instance.fqdn``, the name the first boot +recorded (inithooks ``31fqdn``), else the first line of +``/etc/dehydrated/confconsole.domains.txt`` as before, ``example.com`` on +a fresh machine. A description that does not read counts as none. + +Once you have confirmed the domains and the certificate was issued, the +description gets ``tls.acme.domains`` (the domains as confirmed, without +blanks and without an alias) and ``tls.acme.enabled: true``, so ``keel +spec apply --system`` and ``keel diff`` know the certificate this machine +asked for; ``challenge`` and ``agree_tos`` are left as they were. It is +written as every Instance screen writes it (``keelcli``): a copy beside +the file, ``keel spec validate --no-secret-files`` on it, then moved into +place, and a description keel refuses, or a machine without keel, is said +on the screen and the file is left as it was. A request that fails writes +nothing to the description. A wildcard (DNS-01) is left out of the +description and said so on the screen: keel's domain validation has no +field a wildcard fits yet, and with nothing but wildcards confirmed the +description is not written. dehydrated keeps reading +``confconsole.domains.txt``, which is written as before. + Getting a certificate - Behind the scenes ----------------------------------------- diff --git a/keelcli.py b/keelcli.py index 071e318..0819351 100644 --- a/keelcli.py +++ b/keelcli.py @@ -588,6 +588,98 @@ def promote_text(result: Result) -> str: ) +# --- the Let's Encrypt screen ---------------------------------------------- +# +# The screen is driven by the description: its boxes are prefilled with +# tls.acme.domains, else instance.fqdn (what the first boot recorded), and +# the domains the operator confirmed are written back, with acme turned on, +# once the certificate was issued. Nothing here opens a dialog or touches +# dehydrated's files. + +ACME_UNCHANGED = ( + "The certificate was issued, but {path} was NOT changed: the next" + " keel spec apply --system will not know about it.\n\n{problem}" +) +# keel's domain validation takes labels of letters, digits and dashes +# (keel.spec.fields.LABEL_RE), so a wildcard has no field it fits yet +ACME_WILDCARDS = ( + "The certificate was issued. {wildcards}: a wildcard is not recorded" + " in {path}, which has no field it fits yet, so keel spec apply" + " --system will not know about it. Recorded: {recorded}." +) +ACME_NOTHING_RECORDED = ( + "The certificate was issued, but nothing was recorded in {path}:" + " {wildcards} is a wildcard, and the description has no field it fits" + " yet, so keel spec apply --system will not know about it." +) + + +def acme_domains(document: dict) -> list[str]: + """The domains the description offers: tls.acme.domains when it + declares any, else instance.fqdn, else none""" + tls = document.get("tls") + acme = tls.get("acme") if isinstance(tls, dict) else None + domains = acme.get("domains") if isinstance(acme, dict) else None + if isinstance(domains, list) and domains: + return [str(domain) for domain in domains] + instance = document.get("instance") + fqdn = instance.get("fqdn") if isinstance(instance, dict) else None + return [str(fqdn)] if fqdn else [] + + +def bare_domains(values: list[str]) -> list[str]: + """The domains of the boxes, without blanks and without the alias + dehydrated's file carries after `>`""" + found = [value.split(">", 1)[0].strip() for value in values] + return [domain for domain in found if domain] + + +def recordable_domains(values: list[str]) -> tuple[list[str], list[str]]: + """The domains of the boxes the description can hold, and the + wildcards it cannot: (recorded, wildcards)""" + domains = bare_domains(values) + return ([one for one in domains if "*" not in one], + [one for one in domains if "*" in one]) + + +def with_acme(document: dict, domains: list[str]) -> dict: + """A new description declaring `domains` with acme enabled; the + rest of tls.acme (challenge, agree_tos) is kept. A copy, never an + edit in place, as with_server.""" + tls = document.get("tls") + tls = dict(tls) if isinstance(tls, dict) else {} + acme = tls.get("acme") + acme = dict(acme) if isinstance(acme, dict) else {} + acme["domains"] = list(domains) + acme["enabled"] = True + return {**document, "tls": {**tls, "acme": acme}} + + +def save_acme(path: str, document: dict, domains: list[str]) -> str: + """Write tls.acme.domains and tls.acme.enabled: true; the problem or "" + + Staged and validated before anything replaces the description, as + every Instance screen does it. + """ + staged, problem = stage_spec(with_acme(document, domains), path) + if problem: + return problem + try: + result = call(["spec", "validate", "--no-secret-files", "--spec", + staged]) + except KeelNotInstalled as error: + discard_spec(staged) + return str(error) + if result.code != OK: + discard_spec(staged) + return invalid_text(path, result) + problem = commit_spec(staged, path) + if problem: + discard_spec(staged) + return f"{path} was NOT changed.\n\n{problem}" + return "" + + # --- what a replica needs, and the credential both ends hold ------------- # # The primary's screen hands the operator what each replica's screen will diff --git a/plugins.d/Lets_Encrypt/get_certificate.py b/plugins.d/Lets_Encrypt/get_certificate.py index 9b4ed5d..178d3ee 100755 --- a/plugins.d/Lets_Encrypt/get_certificate.py +++ b/plugins.d/Lets_Encrypt/get_certificate.py @@ -1,4 +1,14 @@ -"""Get Let's Encrypt SSl cert""" +"""Get Let's Encrypt SSl cert + +The domain boxes are prefilled from the instance description +(/etc/keel/instance.yaml): tls.acme.domains when it declares any, else +instance.fqdn, the name the first boot recorded; else from dehydrated's +domains file as before, example.com on a fresh machine. Once the operator +has confirmed the domains and the certificate was issued, the description +gets tls.acme.domains and tls.acme.enabled: true, through keelcli as every +Instance screen writes it; a request that fails writes nothing to it. +dehydrated keeps reading its own domains file, which is written as before. +""" import requests import subprocess @@ -9,20 +19,21 @@ from json import JSONDecodeError from glob import glob +import keelcli + LE_INFO_URL = "https://acme-v02.api.letsencrypt.org/directory" TITLE = "Certificate Creation Wizard" +SPEC_TITLE = "Instance description" -DESC = """Please enter domain(s) to generate certificate for. - -To generate a single certificate for up to five domains (including subdomains), -enter each domain into a box, one domain per box. Empty boxes will be ignored. +# Fits an 80x24 console with the five boxes under it (tests/test_lets_encrypt) +DESC = """Enter the domain(s) the certificate is for, one per box; empty boxes +are ignored. One certificate covers up to five domains. -Wildcard domains are supported, but only when using DNS-01 challenge. Alias -will be auto generated, so should not be entered here. - -For wildcards and for multiple certificates, please consult the docs: -https://github.com/Keel-Linux/confconsole/blob/master/docs/Lets_encrypt.rst +The boxes come from /etc/keel/instance.yaml (tls.acme.domains, else +instance.fqdn); what you confirm is written back there once the +certificate is issued. A wildcard needs the DNS-01 challenge, and its +alias is generated: do not type it. More in docs/Lets_encrypt.rst. """ dehydrated_conf = "/etc/dehydrated" @@ -106,13 +117,24 @@ def gen_alias(line: str) -> str: return line.split(" ")[0].replace("*", "star").replace(".", "_") +def spec_domains() -> list[str]: + """The domains the instance description offers, none when it has + none or does not read (the file then decides, as before)""" + document, problem = keelcli.load_description(keelcli.spec_path()) + if problem: + return [] + return keelcli.acme_domains(document) + + def load_domains() -> tuple[list[str], str | None]: """Loads domain conf, writes default config if non-existent. Expects "/etc/dehydrated" to exist - returns a tuple of list(domains) and alias""" + returns a tuple of list(domains) and alias. The domains are the + instance description's when it declares any (spec_domains), else the + file's; dehydrated's file is made or backed up either way.""" if not isfile(domain_path): copyfile(d_dom_example, domain_path) - return [example_domain, "", "", "", ""], None + domains, alias = [example_domain], None else: backup_domain_path = ".".join([domain_path, "bak"]) copyfile(domain_path, backup_domain_path) @@ -131,11 +153,37 @@ def load_domains() -> tuple[list[str], str | None]: break if alias: alias = alias.strip() - while len(domains) > 5: - domains.pop() - while len(domains) < 5: - domains.append("") - return domains, alias + domains = spec_domains() or domains + while len(domains) > 5: + domains.pop() + while len(domains) < 5: + domains.append("") + return domains, alias + + +def record_domains(values: list[str]) -> str: + """Write the confirmed domains into the description, acme enabled; + what to tell the operator, or "" when there is nothing to say + + A wildcard (DNS-01) is left out, since keel's validation has no field + it fits yet, and said so; with nothing else confirmed the description + is not written at all. + """ + path = keelcli.spec_path() + domains, wildcards = keelcli.recordable_domains(values) + if not domains: + return keelcli.ACME_NOTHING_RECORDED.format( + path=path, wildcards=", ".join(wildcards)) + document, problem = keelcli.load_description(path) + if not problem: + problem = keelcli.save_acme(path, document, domains) + if problem: + return keelcli.ACME_UNCHANGED.format(path=path, problem=problem) + if wildcards: + return keelcli.ACME_WILDCARDS.format( + path=path, wildcards=", ".join(wildcards), + recorded=", ".join(domains)) + return "" def save_domains(domains: list[str], alias: str | None = None) -> None: @@ -440,6 +488,9 @@ def run() -> None: text=True, ) if proc.returncode == 0: + problem = record_domains(values) + if problem: + console.msgbox(SPEC_TITLE, problem, autosize=True) break else: console.msgbox("Error!", proc.stderr) diff --git a/tests/conftest.py b/tests/conftest.py index 4c152a8..f0453d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -89,8 +89,26 @@ def __init__(self, *args, **kwargs): sys.modules["systemd.journal"] = journal +def _install_requests_stub(): + """The Let's Encrypt screen imports requests (python3-requests) to + read the terms of service; the tests replace its `get`, so a host + without the package gets a module with that one name, which refuses + to be called for real.""" + try: + importlib.import_module("requests") + except ModuleNotFoundError: + stub = types.ModuleType("requests") + + def get(url, **kwargs): + raise AssertionError(f"requests.get({url!r}) must be replaced") + + stub.get = get + sys.modules["requests"] = stub + + _install_netinfo_stub() _install_dialog_stubs() +_install_requests_stub() @pytest.fixture diff --git a/tests/test_lets_encrypt.py b/tests/test_lets_encrypt.py new file mode 100644 index 0000000..946d2dc --- /dev/null +++ b/tests/test_lets_encrypt.py @@ -0,0 +1,457 @@ +"""The Let's Encrypt screen is driven by the instance description. + +Its domain boxes are prefilled from tls.acme.domains, else instance.fqdn +(the name the first boot recorded, Keel-Linux/inithooks#36), else from +dehydrated's domains file as before, example.com on a fresh machine. When +the operator confirms the domains and the certificate is issued, the +description gets tls.acme.domains and tls.acme.enabled: true, written as +every Instance screen writes it (keelcli: staged, validated by keel, +moved into place); a request that fails writes nothing to it. dehydrated +still reads its domains file, which is kept as it was. + +The plugin is loaded the way confconsole loads it, with the scripted fake +console of conftest; requests, dehydrated-wrapper and keel are replaced, +and every path is under tmp_path. +""" + +import subprocess +from pathlib import Path + +import pytest +import yaml + +import keelbanner +import keelcli +import keelfit +import plugin +from conftest import FakeConsole + +ROOT = Path(__file__).resolve().parent.parent +SCREEN = ROOT / "plugins.d" / "Lets_Encrypt" / "get_certificate.py" +SHARE = ROOT / "share" / "letsencrypt" +TOS = "https://letsencrypt.example/terms" +FQDN = "blog.example.org" +FIVE = [FQDN, "", "", "", ""] +EXAMPLE = ["example.com", "", "", "", ""] + + +class FakeResponse: + def json(self): + return {"meta": {"termsOfService": TOS}} + + +@pytest.fixture +def spec(tmp_path, monkeypatch): + """A description KEEL_SPEC points at; call it to write one""" + path = tmp_path / "instance.yaml" + monkeypatch.setenv("KEEL_SPEC", str(path)) + + def _write(document): + path.write_text(yaml.safe_dump(document, sort_keys=False)) + return path + + _write.path = path + return _write + + +@pytest.fixture +def screen(tmp_path, monkeypatch): + """The plugin with dehydrated's files under tmp_path, Let's Encrypt + answering its terms, and dehydrated-wrapper a recorded stand-in + exiting `state["status"]`""" + loaded = plugin.Plugin(str(SCREEN)) + module = loaded.module + etc = tmp_path / "dehydrated" + etc.mkdir() + monkeypatch.setattr(module, "dehydrated_conf", str(etc)) + monkeypatch.setattr(module, "domain_path", + str(etc / "confconsole.domains.txt")) + monkeypatch.setattr(module, "d_conf_path", str(etc / "confconsole.config")) + monkeypatch.setattr(module, "d_conf_example", + str(SHARE / "dehydrated-confconsole.config")) + monkeypatch.setattr(module, "d_dom_example", + str(SHARE / "dehydrated-confconsole.domains")) + monkeypatch.setattr(module.requests, "get", lambda url: FakeResponse()) + state = {"status": 0, "runs": [], "etc": etc, "module": module} + + def run(argv, **kwargs): + state["runs"].append(list(argv)) + return subprocess.CompletedProcess(argv, state["status"], "", + "challenge failed") + + monkeypatch.setattr(module.subprocess, "run", run) + return state + + +def read(path): + return yaml.safe_load(Path(path).read_text()) + + +def domains_file(state): + return (state["etc"] / "confconsole.domains.txt").read_text() + + +def console(values, more_forms=()): + """A console answering Yes to the terms, http-01, Yes to the DNS + warning, VALUES in the domain boxes and Yes to overwriting""" + return FakeConsole(forms=[("ok", list(values)), *more_forms], + yesno=["ok", "ok", "ok", "ok"], + menus=[("ok", "http-01")]) + + +def run_screen(state, fake): + state["module"].console = fake + state["module"].run() + return fake + + +class TestWhatTheDescriptionSays: + """keelcli: the domains a description offers, and the one written""" + + def test_declared_acme_domains_come_first(self): + document = {"instance": {"fqdn": FQDN}, + "tls": {"acme": {"domains": ["www.example.org", FQDN]}}} + + assert keelcli.acme_domains(document) == ["www.example.org", FQDN] + + def test_the_fqdn_when_no_domain_is_declared(self): + for tls in (None, {}, {"acme": {}}, {"acme": {"domains": []}}, + {"acme": {"enabled": False}}): + document = {"instance": {"fqdn": FQDN}} + if tls is not None: + document["tls"] = tls + assert keelcli.acme_domains(document) == [FQDN], tls + + def test_nothing_when_the_description_names_no_domain(self): + for document in ({}, {"version": 1}, {"instance": {"hostname": "b"}}, + {"instance": "x"}, {"tls": "x"}, {"tls": {"acme": 1}}, + {"tls": {"acme": {"domains": "not a list"}}}): + assert keelcli.acme_domains(document) == [], document + + def test_the_domains_are_strings(self): + document = {"tls": {"acme": {"domains": [FQDN, 1]}}} + + assert keelcli.acme_domains(document) == [FQDN, "1"] + + def test_with_acme_declares_the_domains_and_turns_acme_on(self): + document = {"version": 1, "instance": {"fqdn": FQDN}} + + after = keelcli.with_acme(document, [FQDN]) + + assert after == {"version": 1, "instance": {"fqdn": FQDN}, + "tls": {"acme": {"domains": [FQDN], "enabled": True}}} + assert document == {"version": 1, "instance": {"fqdn": FQDN}} + + def test_with_acme_keeps_the_rest_of_the_section(self): + document = {"version": 1, "tls": {"acme": { + "enabled": False, "challenge": "http-01", "agree_tos": True, + "domains": ["old.example.org"]}}} + + after = keelcli.with_acme(document, [FQDN, "www.example.org"]) + + assert after["tls"]["acme"] == { + "enabled": True, "challenge": "http-01", "agree_tos": True, + "domains": [FQDN, "www.example.org"]} + + def test_with_acme_replaces_a_section_of_another_shape(self): + after = keelcli.with_acme({"tls": {"acme": "yes"}}, [FQDN]) + + assert after["tls"] == {"acme": {"domains": [FQDN], "enabled": True}} + + def test_bare_domains_drop_blanks_aliases_and_spaces(self): + values = [" blog.example.org ", "", "www.example.org > alias", " "] + + assert keelcli.bare_domains(values) == [FQDN, "www.example.org"] + + def test_a_wildcard_is_not_recordable(self): + # keel.spec.fields.LABEL_RE takes no `*`, so keel spec validate + # would refuse the description and nothing would be recorded + values = ["*.example.org > star", "example.org", "", "", ""] + + assert keelcli.recordable_domains(values) == ( + ["example.org"], ["*.example.org"]) + + def test_the_wildcard_notices_name_the_file(self): + assert "instance.yaml" in keelcli.ACME_WILDCARDS.format( + path="/etc/keel/instance.yaml", wildcards="*.x", recorded="x") + assert "nothing was recorded" in keelcli.ACME_NOTHING_RECORDED + + +class TestSaveAcme: + def test_the_description_is_validated_then_written(self, keel, spec): + spec({"version": 1, "instance": {"hostname": "blog"}}) + + problem = keelcli.save_acme(str(spec.path), read(spec.path), [FQDN]) + + assert problem == "" + assert read(spec.path) == { + "version": 1, "instance": {"hostname": "blog"}, + "tls": {"acme": {"domains": [FQDN], "enabled": True}}} + assert keel["calls"][0][:3] == ["spec", "validate", + "--no-secret-files"] + assert keel["calls"][0][4] != str(spec.path) + + def test_a_description_keel_refuses_is_not_written(self, keel, spec): + spec({"version": 1}) + keel["answers"] = [(3, "", "Error: tls.acme.domains: bad")] + + problem = keelcli.save_acme(str(spec.path), read(spec.path), [FQDN]) + + assert "NOT changed" in problem + assert "tls.acme.domains: bad" in problem + assert read(spec.path) == {"version": 1} + assert sorted(spec.path.parent.iterdir()) == [spec.path] + + def test_without_keel_nothing_is_written(self, keel, spec): + spec({"version": 1}) + keel["answers"] = [keelcli.KeelNotInstalled(keelcli.NOT_INSTALLED)] + + problem = keelcli.save_acme(str(spec.path), read(spec.path), [FQDN]) + + assert problem == keelcli.NOT_INSTALLED + assert read(spec.path) == {"version": 1} + assert sorted(spec.path.parent.iterdir()) == [spec.path] + + def test_a_file_that_cannot_be_staged_is_said(self, keel, spec): + path = spec.path.parent / "missing" / "instance.yaml" + + problem = keelcli.save_acme(str(path), {"version": 1}, [FQDN]) + + assert "missing" in problem + assert keel["calls"] == [] + + def test_a_file_that_cannot_be_replaced_is_said(self, keel, spec): + spec.path.mkdir() + + problem = keelcli.save_acme(str(spec.path), {"version": 1}, [FQDN]) + + assert "NOT changed" in problem + assert sorted(spec.path.parent.iterdir()) == [spec.path] + + +class TestThePrefill: + """load_domains: the description first, dehydrated's file after it""" + + def test_declared_domains_fill_the_boxes(self, screen, spec): + spec({"version": 1, "tls": {"acme": { + "domains": ["www.example.org", FQDN]}}}) + + domains, alias = screen["module"].load_domains() + + assert domains == ["www.example.org", FQDN, "", "", ""] + assert alias is None + + def test_the_fqdn_fills_the_first_box(self, screen, spec): + spec({"version": 1, "instance": {"hostname": "blog", "fqdn": FQDN}}) + + domains, _ = screen["module"].load_domains() + + assert domains == FIVE + + def test_more_than_five_declared_domains_fill_five_boxes(self, screen, + spec): + many = [f"d{n}.example.org" for n in range(7)] + spec({"version": 1, "tls": {"acme": {"domains": many}}}) + + domains, _ = screen["module"].load_domains() + + assert domains == many[:5] + + def test_dehydrated_s_file_is_still_made_from_the_example(self, screen, + spec): + # dehydrated reads it: the file stays, whatever filled the boxes + spec({"version": 1, "instance": {"fqdn": FQDN}}) + + screen["module"].load_domains() + + assert domains_file(screen) == ( + SHARE / "dehydrated-confconsole.domains").read_text() + + def test_without_a_declared_name_the_file_fills_the_boxes(self, screen, + spec): + spec({"version": 1, "instance": {"hostname": "blog"}}) + (screen["etc"] / "confconsole.domains.txt").write_text( + "# mine\nshop.example.org www.shop.example.org\n") + + domains, alias = screen["module"].load_domains() + + assert domains == ["shop.example.org", "www.shop.example.org", + "", "", ""] + assert alias is None + assert (screen["etc"] / "confconsole.domains.txt.bak").exists() + + def test_the_file_s_alias_is_kept_beside_declared_domains(self, screen, + spec): + spec({"version": 1, "tls": {"acme": {"domains": [FQDN]}}}) + (screen["etc"] / "confconsole.domains.txt").write_text( + "*.example.org > star_example_org\n") + + domains, alias = screen["module"].load_domains() + + assert domains == FIVE + assert alias == "star_example_org" + + def test_a_fresh_machine_without_a_description_shows_example_com( + self, screen, spec + ): + domains, _ = screen["module"].load_domains() + + assert domains == EXAMPLE + assert not spec.path.exists() + + def test_a_description_that_does_not_read_is_no_description( + self, screen, spec + ): + spec.path.write_text("version: [1") + + domains, _ = screen["module"].load_domains() + + assert domains == EXAMPLE + + +class TestTheScreen: + """run(): what happens once the operator confirms the domains""" + + def test_an_issued_certificate_is_recorded_in_the_description( + self, screen, spec, keel + ): + spec({"version": 1, "instance": {"hostname": "blog", "fqdn": FQDN}, + "app": {"email": "admin@example.org"}}) + + fake = run_screen(screen, console([FQDN, "www.example.org", "", "", + ""])) + + assert read(spec.path) == { + "version": 1, "instance": {"hostname": "blog", "fqdn": FQDN}, + "app": {"email": "admin@example.org"}, + "tls": {"acme": {"domains": [FQDN, "www.example.org"], + "enabled": True}}} + assert domains_file(screen).splitlines()[3] == ( + f"{FQDN} www.example.org ") + assert screen["runs"][0][1:] == [ + str(SCREEN.parent / "dehydrated-wrapper"), "--register", + "--log-info", "--challenge", "http-01"] + assert [call for call in fake.calls if call[0] == "msgbox"] == [] + + def test_the_boxes_the_operator_saw_came_from_the_description( + self, screen, spec, keel + ): + spec({"version": 1, "instance": {"fqdn": FQDN}}) + + fake = run_screen(screen, console(FIVE)) + + form = [call for call in fake.calls if call[0] == "form"][0] + assert [field[3] for field in form[2]] == FIVE + assert [field[0] for field in form[2]] == [ + "Domain 1", "Domain 2", "Domain 3", "Domain 4", "Domain 5"] + + def test_a_request_that_fails_writes_nothing_to_the_description( + self, screen, spec, keel + ): + spec({"version": 1, "instance": {"fqdn": FQDN}}) + screen["status"] = 1 + + fake = run_screen(screen, console(FIVE, more_forms=[("cancel", FIVE)])) + + assert read(spec.path) == {"version": 1, "instance": {"fqdn": FQDN}} + assert keel["calls"] == [] + assert ("msgbox", "Error!", "challenge failed") in fake.calls + # dehydrated's file was written, as before: it is what it reads + assert domains_file(screen).splitlines()[3] == f"{FQDN} " + + def test_a_description_keel_refuses_is_said_and_left_as_it_was( + self, screen, spec, keel + ): + spec({"version": 1}) + keel["answers"] = [(3, "", "Error: tls.acme.domains: bad")] + + fake = run_screen(screen, console(FIVE)) + + assert read(spec.path) == {"version": 1} + boxes = [call for call in fake.calls if call[0] == "msgbox"] + assert boxes[0][1] == "Instance description" + assert "NOT changed" in boxes[0][2] + assert "tls.acme.domains: bad" in boxes[0][2] + assert "certificate" in boxes[0][2] + + def test_a_machine_without_a_description_gets_one(self, screen, spec, + keel): + run_screen(screen, console(FIVE)) + + assert read(spec.path) == { + "version": 1, + "tls": {"acme": {"domains": [FQDN], "enabled": True}}} + + def test_aliases_and_blanks_are_not_domains(self, screen, spec, keel): + spec({"version": 1}) + + run_screen(screen, console([FQDN, "", "www.example.org > www", "", + ""])) + + assert read(spec.path)["tls"]["acme"]["domains"] == [ + FQDN, "www.example.org"] + + def test_a_description_that_does_not_read_is_said_not_written( + self, screen, spec, keel + ): + spec.path.write_text("version: [1") + + fake = run_screen(screen, console(FIVE)) + + assert spec.path.read_text() == "version: [1" + boxes = [call for call in fake.calls if call[0] == "msgbox"] + assert "not valid YAML" in boxes[0][2] + + def test_a_wildcard_is_left_out_and_said(self, screen, spec, keel): + # the DNS-01 flow confirms the boxes the same way; what reaches + # the description is decided here + spec({"version": 1}) + + notice = screen["module"].record_domains( + ["*.example.org > star", "example.org", "", "", ""]) + + assert read(spec.path) == { + "version": 1, + "tls": {"acme": {"domains": ["example.org"], "enabled": True}}} + assert "*.example.org" in notice + assert "not recorded" in notice + assert "Recorded: example.org" in notice + + def test_a_wildcard_alone_records_nothing_and_says_so( + self, screen, spec, keel + ): + spec({"version": 1}) + + notice = screen["module"].record_domains( + ["*.example.org", "", "", "", ""]) + + assert read(spec.path) == {"version": 1} + assert keel["calls"] == [] + assert "nothing was recorded" in notice + assert "*.example.org" in notice + + def test_a_cancelled_form_writes_nothing(self, screen, spec, keel): + spec({"version": 1}) + + run_screen(screen, FakeConsole(forms=[("cancel", FIVE)], + yesno=["ok", "ok"], + menus=[("ok", "http-01")])) + + assert read(spec.path) == {"version": 1} + assert screen["runs"] == [] + + +class TestTheText: + def test_the_form_fits_an_80_by_24_console(self, screen, monkeypatch): + # five boxes under the text, the buttons and the frame below them + rows, cols = keelbanner.available(24, 80) + inside = cols - keelfit.TEXT_CHROME + text = screen["module"].DESC + for line in text.splitlines(): + assert len(line) <= inside, line + boxes = 5 + assert (keelbanner.text_rows(text + "\n ", inside) + boxes + 1 + + keelbanner.BOX_CHROME) <= rows + + def test_the_text_says_where_the_boxes_come_from(self, screen): + assert "instance.yaml" in screen["module"].DESC