From 5ecf00035af2db60497f428c1b08064ace369a33 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 21:55:04 +0000 Subject: [PATCH] feat: the first boot asks the fully qualified domain name A new interactive hook, 31fqdn, after the root password and before the application hooks: the box is prefilled with the name the machine has (what pct create --hostname set; a dotted name as it is). The first label becomes the hostname, set the way 09hostname sets it: the rename is now lib/hostname.sh, sourced by both hooks. /etc/hosts gets the entry that makes hostname -f answer the name, and the instance description records instance.hostname and instance.fqdn, plus tls.acme.domains with the name when it declares no domain yet; tls.acme.enabled is never touched. A name without a domain is kept as the hostname alone after a notice that no certificate can be requested without one; an empty answer keeps what the machine has. FQDN preseeds it (instance.fqdn renders it, so a described machine is not asked); FQDN=SKIP asks nothing. keel has no writer for the description, so libinithooks/fqdn.py writes it as confconsole's keelcli.py does: PyYAML, a copy beside the file, keel spec validate --no-secret-files when keel is installed, then moved into place. The reader accepts tls.acme.agree_tos, as keel does. --- COVERAGE.md | 33 ++ README.rst | 35 +- bin/fqdn.py | 172 +++++++++ debian/changelog | 38 ++ firstboot.d/09hostname | 38 +- firstboot.d/31fqdn | 67 ++++ lib/hostname.sh | 55 +++ libinithooks/declarative.py | 6 +- libinithooks/fqdn.py | 285 +++++++++++++++ pyproject.toml | 2 + tests/test-fqdn.bats | 263 ++++++++++++++ tests/test-hostname.bats | 178 +++++++++ tests/test_declarative_validate.py | 12 + tests/test_dialog_brand.py | 1 + tests/test_fqdn.py | 561 +++++++++++++++++++++++++++++ tests/test_fqdn_cli.py | 382 ++++++++++++++++++++ 16 files changed, 2101 insertions(+), 27 deletions(-) create mode 100755 bin/fqdn.py create mode 100755 firstboot.d/31fqdn create mode 100644 lib/hostname.sh create mode 100644 libinithooks/fqdn.py create mode 100644 tests/test-fqdn.bats create mode 100644 tests/test-hostname.bats create mode 100644 tests/test_fqdn.py create mode 100644 tests/test_fqdn_cli.py diff --git a/COVERAGE.md b/COVERAGE.md index 05f161f..6b6f867 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -4,6 +4,39 @@ Measured on 2026-09-24 against upstream master (33c43b8), following the project decision 0003 (90 percent floor per repository, 95 percent for every file our changes touch). +## Branch feat/first-boot-fqdn: shell 99.62, Python 99 (2026-10-02) + +The first boot asks the fully qualified domain name (31fqdn). Shell: +`lib/hostname.sh` 9/9 (the rename 09hostname has always done, as a +function that replaces the name as a whole name or a first label, through +perl), `firstboot.d/09hostname` 8/8, measured for the first time, and +`firstboot.d/31fqdn` 21/21, 100 percent each, from +`tests/test-hostname.bats` (13 tests) and `tests/test-fqdn.bats` (16 +tests): the rename over scratch copies of the files, the name inside other +words left alone, a colon and a dot matched literally, the same name again +touching nothing, bash's own HOSTNAME, the screen asked with the name the +machine has, the description recorded before the rename and the hosts +entry after it, a hostname without a domain, an empty answer, FQDN +preseeded and SKIP, a failing screen, record or hosts entry, an answer the +screen did not shape, and the real `bin/fqdn.py` with FQDN preseeded +writing a new instance.yaml, one that is there, leaving one that already +declares the name alone, and refusing a name that is not a domain. 301 +bats in all. The file list of the rename is a `readarray` here document +because kcov marks the lines of a multi-line array assignment as not run. + +Python: `libinithooks/fqdn.py` (152 statements, 50 branches) 100 percent +and `bin/fqdn.py` (97 statements, 34 branches) 100 percent, from +`tests/test_fqdn.py` (66 tests: the checks on a typed name, the split, the +declared names, the hostname that goes with a name, the prefill, the +/etc/hosts entry, the updated description and the one left equal, the +path, the writer with keel stubbed on PATH accepting and refusing, without +keel, and keeping the file's mode) and `tests/test_fqdn_cli.py` (32 tests +on the fake dialog: the screen, the notice without a domain, Back, ESC, a +preseeded name, --record, --hosts and the usage errors). +`test_dialog_brand.py` checks `bin/fqdn.py` as well; the reader accepts +`tls.acme.agree_tos` (two tests in `test_declarative_validate.py`). 515 +passed (3 skipped without `KEEL_SRC`); total 99. + ## Branch fix/password-once-and-updates-record: shell 99.58, Python 99 (2026-10-02) `firstboot.d/95secupdates` is measured for the first time: 49 of 50 diff --git a/README.rst b/README.rst index 9999990..4326b2f 100644 --- a/README.rst +++ b/README.rst @@ -525,8 +525,10 @@ Notes: variables read by 01ipconfig; static IPv6 addresses cannot be written to /etc/network/interfaces yet and are refused. - - tls.acme is accepted and validated, but no certificate is requested - yet; use confconsole for that. + - tls.acme is accepted and validated (enabled, challenge, domains, + agree_tos, as the instance tooling reads them), but no certificate is + requested at first boot; keel spec apply --system and confconsole's + Let's Encrypt screen request it. The description can be validated before it is used:: @@ -576,6 +578,7 @@ Common to all appliances:: 15regen-sslcert DH_BITS [ 1024 | 2048 | 4096 ] 29preseed INITFENCE [ SKIP ] 30rootpass* ROOT_PASS + 31fqdn FQDN [ SKIP | the name ] 75keel-role database.server.role of the instance description 80keel-cloud HUB_APIKEY [ SKIP | the key ] 85secalerts SEC_ALERTS [ SKIP ] @@ -585,6 +588,34 @@ Common to all appliances:: Notes on the Keel hooks: + - 31fqdn asks the machine's fully qualified domain name, + blog.example.org, prefilled with the name the machine has (what + "pct create --hostname" set; a dotted name is offered as it is). + The instance description (/etc/keel/instance.yaml, or the file + 00declarative read) records instance.hostname and instance.fqdn, + plus tls.acme.domains: [the name] when it declares no domain yet, + so confconsole's Let's Encrypt screen offers it; tls.acme.enabled + is never touched, a hostname declared beside the unchanged name is + kept, and a description the answer does not change is not + rewritten. Then the first label becomes the hostname, set the way + 09hostname sets it (lib/hostname.sh: the old name replaced as a + whole name or a first label, never inside another word), and + /etc/hosts gets the entry that makes "hostname -f" answer the name, + rewriting the line a container manager wrote for the host where it + stands. The description comes first so that a step that fails + leaves a description saying what the machine should be. A name + without a domain is kept as the hostname alone, after a notice that + no certificate can be requested without a domain; an empty answer + keeps what the machine has. Lower case labels of letters, digits + and dashes, as a domain name is written; anything else is refused + and asked again. FQDN preseeds the answer (instance.fqdn of the + description renders it, so a described machine is not asked); + FQDN=SKIP asks nothing and changes nothing. keel-init asks again, + prefilled with the fqdn the description declares. The description + is written as confconsole writes it: a copy beside the file, + checked by "keel spec validate --no-secret-files" when keel is + installed, then moved into place; keel has no writer of its own. + - 75keel-role and 80keel-cloud (handbook decision 0020) run confconsole's first boot screens, /usr/lib/confconsole/keelfirstboot.py, and do nothing without confconsole. 75keel-role asks this node's diff --git a/bin/fqdn.py b/bin/fqdn.py new file mode 100755 index 0000000..9d63cf8 --- /dev/null +++ b/bin/fqdn.py @@ -0,0 +1,172 @@ +#!/usr/bin/python3 +# Copyright (c) 2026 Keel Linux maintainers +"""Ask the machine's fully qualified domain name, and record it + +Run by firstboot.d/31fqdn, three times: to ask, to record the answer in +the instance description, and, once the machine is renamed, to write the +/etc/hosts entry. + +Options: + --fqdn= the name; if not provided, will ask interactively, + prefilled with --current (or the fqdn the instance + description declares) + --current= the name the machine has, as `hostname` answers + --record write the name given with --hostname and --fqdn into + the instance description, and ask nothing; a + description the name does not change is left alone + --hosts write the name given with --hostname and --fqdn into + the hosts file, and ask nothing + --hostname= with --record or --hosts: the hostname + +Asked or preseeded, the answer is printed as two lines for the hook: +HOSTNAME= and FQDN=. The +hostname is the first label of the name, or the one the description +declares beside that very name. Nothing is printed when the machine +keeps its name. + +Environment: + INITHOOKS_DECL the instance description (default: the one + 00declarative reads, else /etc/keel/instance.yaml) + INITHOOKS_HOSTS the hosts file (default: /etc/hosts) +""" + +import getopt +import os +import signal +import sys +from typing import NoReturn + +from libinithooks import declarative, fqdn + +HOSTS = "/etc/hosts" +HOSTS_VAR = "INITHOOKS_HOSTS" + +TITLE = "Domain name" +TEXT = ( + "The name this machine is reached by, with its domain, for example" + " blog.example.org. It becomes the hostname, the name /etc/hosts" + " answers for this machine, and the domain a TLS certificate is" + " requested for in confconsole.\n\n" + "Leave the field empty to keep the name the machine has.\n\n" + "Fully qualified domain name:" +) +NO_DOMAIN = ( + "{name} has no domain, so it is kept as the hostname only. No" + " certificate can be requested without a domain.\n\n" + "Continue with the hostname alone, or go back and add the domain?" +) + + +def fatal(msg: object) -> NoReturn: + print(f"Error: {msg}", file=sys.stderr) + sys.exit(1) + + +def usage(msg: str | getopt.GetoptError = "") -> NoReturn: + if msg: + print(f"Error: {msg}", file=sys.stderr) + print(f"Syntax: {sys.argv[0]} [options]", file=sys.stderr) + print(__doc__, file=sys.stderr) + sys.exit(1) + + +def hosts_path() -> str: + return os.environ.get(HOSTS_VAR, HOSTS) + + +def load_description() -> tuple[str, dict]: + path = fqdn.spec_path() + try: + return path, fqdn.load(path) + except declarative.DeclarativeError as e: + fatal(e) + + +def ask(current: str, document: dict) -> str: + """The name the operator confirmed, or "" to keep the machine's""" + from libinithooks.dialog_wrapper import Dialog + + d = Dialog("Keel Linux - First boot configuration") + init = fqdn.prefill(current, document) + while True: + _, typed = d.inputbox(TITLE, TEXT, init, "Apply", "") + name, problem = fqdn.normalize(typed) + if problem: + d.error(problem) + init = typed + continue + if not name or "." in name: + return name + if d.yesno(TITLE, NO_DOMAIN.format(name=name), "Continue", "Back"): + return name + init = name + + +def record(hostname: str, name: str) -> None: + """The description with the name in it, unless it holds it already""" + path, document = load_description() + after = fqdn.updated(document, hostname, name) + if after == document: + print(f"fqdn: {path} already declares the name, not written", + file=sys.stderr) + return + try: + fqdn.write_spec(path, after) + except fqdn.FqdnError as e: + fatal(e) + + +def hosts(hostname: str, name: str) -> None: + try: + fqdn.write_hosts(hosts_path(), hostname, name) + except fqdn.FqdnError as e: + fatal(e) + + +def main(): + signal.signal(signal.SIGINT, signal.SIG_IGN) + try: + l_opts = ["help", "fqdn=", "current=", "record", "hosts", + "hostname="] + opts, args = getopt.gnu_getopt(sys.argv[1:], "h", l_opts) + except getopt.GetoptError as e: + usage(e) + + if args: + usage() + + preseeded = current = hostname = "" + action = "" + for opt, val in opts: + if opt in ("-h", "--help"): + usage() + elif opt == "--fqdn": + preseeded = val + elif opt == "--current": + current = val + elif opt == "--hostname": + hostname = val + else: # --record or --hosts, the writes + action = opt + + if action: + if not hostname: + usage(f"{action} needs --hostname") + (record if action == "--record" else hosts)(hostname, preseeded) + return + + _, document = load_description() + if preseeded: + name, problem = fqdn.normalize(preseeded) + if problem: + fatal(problem) + else: + name = ask(current, document) + if not name: + return + print(f"HOSTNAME={fqdn.hostname_for(name, document)}") + print(f"FQDN={fqdn.split(name)[1]}") + + +if __name__ == "__main__": + main() diff --git a/debian/changelog b/debian/changelog index 2c0dfd0..07ebcd8 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,41 @@ +inithooks (2.3.6+keel19) trixie; urgency=medium + + * The first boot asks the machine's fully qualified domain name, in a + new hook, 31fqdn, after the root password and before the application + hooks. The box is prefilled with the name the machine has (what pct + create --hostname set; a dotted name as it is). The instance + description records instance.hostname and instance.fqdn, plus + tls.acme.domains with the name when it declares no domain yet, so + confconsole's Let's Encrypt screen is driven by the description; + tls.acme.enabled is never touched, a hostname declared beside the + unchanged name is kept, and a description the answer does not change + is not rewritten. Then the first label becomes the hostname, set the + way 09hostname sets it (the rename is now lib/hostname.sh, which + both hooks source), and /etc/hosts gets the entry that makes + hostname -f answer the name, rewriting the line pct wrote for the + host where it stands. The description first, so that a step that + fails leaves one saying what the machine should be. A name without a + domain is kept as the hostname alone, after a notice that no + certificate can be requested without a domain; an empty answer keeps + what the machine has. Lower case labels of letters, digits and + dashes; anything else is refused and asked again. FQDN preseeds it + (instance.fqdn renders it, so a described machine is not asked), + FQDN=SKIP asks nothing. + * The description is written as confconsole writes it (keelcli.py): + PyYAML, a copy beside the file, keel spec validate --no-secret-files + when keel is installed, then moved into place, with the mode the + file had. keel has no writer of its own. + * The rename replaces the old name only as a whole name or as the + first label of a dotted name, matched literally. 09hostname's sed + took it as a pattern: a machine called web was rewritten inside + every SSH key comment, postfix setting and word of /etc/hosts that + contained it, and a colon in the name broke the command. + * The reader accepts tls.acme.agree_tos, which the instance tooling + accepts, and refuses enabled or agree_tos that are not booleans. + * 09hostname reads INITHOOKS_DEFAULT like the other Keel hooks. + + -- Marcos Mendez Fri, 02 Oct 2026 21:00:00 +0000 + inithooks (2.3.6+keel18) trixie; urgency=medium * 95secupdates reads its updates from diff --git a/firstboot.d/09hostname b/firstboot.d/09hostname index df88ea4..8a40b75 100755 --- a/firstboot.d/09hostname +++ b/firstboot.d/09hostname @@ -1,32 +1,22 @@ #!/bin/bash -e # set hostname +# HOSTNAME: the name (if none specified, nothing is done) +# +# The rename itself is lib/hostname.sh, shared with 31fqdn. -. /etc/default/inithooks +INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" +# shellcheck source=default/inithooks +source "$INITHOOKS_DEFAULT" +# shellcheck source=lib/hostname.sh +source "$INITHOOKS_PATH/lib/hostname.sh" -[ -e $INITHOOKS_CONF ] && . $INITHOOKS_CONF +if [[ -e "$INITHOOKS_CONF" ]]; then + # shellcheck source=/dev/null + source "$INITHOOKS_CONF" +fi -[ -z "$HOSTNAME" ] && exit 0 +[[ -z "$HOSTNAME" ]] && exit 0 -old=$(hostname) - -for file in \ - /etc/exim4/update-exim4.conf.conf \ - /etc/printcap \ - /etc/hostname \ - /etc/hosts \ - /etc/network/interfaces \ - /etc/ssh/ssh_host_rsa_key.pub \ - /etc/ssh/ssh_host_dsa_key.pub \ - /etc/ssh/ssh_host_ecdsa_key.pub \ - /etc/ssh/ssh_host_ed25519_key.pub \ - /etc/mailname \ - /etc/postfix/main.cf \ - /etc/motd \ - /etc/ssmtp/ssmtp.conf -do - [ -f $file ] && sed -i -e "s:$old:$HOSTNAME:g" $file -done - -hostname $HOSTNAME +hostname_set "$HOSTNAME" exit 0 diff --git a/firstboot.d/31fqdn b/firstboot.d/31fqdn new file mode 100755 index 0000000..69a4737 --- /dev/null +++ b/firstboot.d/31fqdn @@ -0,0 +1,67 @@ +#!/bin/bash -e +# Ask the machine's fully qualified domain name, blog.example.org, prefilled +# with the name the machine has (what `pct create --hostname` set). The +# answer is recorded in the instance description as instance.hostname and +# instance.fqdn, with tls.acme.domains when the description declares none, +# so confconsole's Let's Encrypt screen offers it; then the machine is +# renamed the way 09hostname renames it (lib/hostname.sh), and /etc/hosts +# gets the entry that makes `hostname -f` answer the name. A name without a +# domain is kept as the hostname alone, and the screen says that no +# certificate can be requested without a domain. An empty answer keeps what +# the machine has. +# +# The description first: it is what the machine is asked to be, and what +# keel spec apply --system converges, so a rename that fails after it +# leaves a machine whose description says what it should be, not one +# renamed with no record of it. The /etc/hosts entry comes after the +# rename, which replaces the old name wherever it stands. An answer that +# changes nothing writes nothing. +# +# FQDN: SKIP, the name (if none specified, will be interactive). The +# instance description renders instance.fqdn as FQDN (00declarative), so a +# described machine is not asked. +# +# Position: an interactive hook is numbered 30 or above (run waits for the +# boot to finish from 30 on, so the screen is not drawn over); after +# 30rootpass, which stays the first screen, and before the application's +# hooks and 85secalerts. + +INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}" +# shellcheck source=default/inithooks +source "$INITHOOKS_DEFAULT" +# shellcheck source=lib/hostname.sh +source "$INITHOOKS_PATH/lib/hostname.sh" + +if [[ -e "$INITHOOKS_CONF" ]]; then + # shellcheck source=/dev/null + source "$INITHOOKS_CONF" +fi + +[[ "${FQDN^^}" == "SKIP" ]] && exit 0 + +# the screen prints HOSTNAME= and FQDN= once the operator has answered, +# and nothing when the machine keeps its name +answer=$("$INITHOOKS_PATH/bin/fqdn.py" --fqdn="$FQDN" --current="$(hostname)") +[[ -n "$answer" ]] || exit 0 + +new_hostname= +new_fqdn= +while IFS='=' read -r key value; do + case $key in + HOSTNAME) new_hostname=$value ;; + FQDN) new_fqdn=$value ;; + *) echo "31fqdn: unexpected answer from fqdn.py: $key" >&2; exit 1 ;; + esac +done <<< "$answer" +if [[ -z "$new_hostname" ]]; then + echo "31fqdn: fqdn.py answered without a hostname" >&2 + exit 1 +fi + +"$INITHOOKS_PATH/bin/fqdn.py" --record --hostname="$new_hostname" \ + --fqdn="$new_fqdn" + +hostname_set "$new_hostname" + +"$INITHOOKS_PATH/bin/fqdn.py" --hosts --hostname="$new_hostname" \ + --fqdn="$new_fqdn" diff --git a/lib/hostname.sh b/lib/hostname.sh new file mode 100644 index 0000000..f55db9c --- /dev/null +++ b/lib/hostname.sh @@ -0,0 +1,55 @@ +# Renaming the machine, as firstboot.d/09hostname has always done it: the +# old name is replaced with the new one in every file of HOSTNAME_FILES +# that exists, and the kernel's name is set. firstboot.d/31fqdn renames +# the machine the same way once the operator has answered its domain +# name, so the rename lives here and both hooks source it. +# +# The old name is replaced only where it stands as a whole name, or as +# the first label of a dotted name (`blog`, `blog.example.org`), never +# inside another word (`weblog`, `backup-blog`, `www.blog`), and it is +# matched literally: 09hostname's sed took it as a pattern, so a short +# name such as `web` was rewritten inside every SSH public key comment, +# postfix setting and word of /etc/hosts that contained it, and a colon +# in the name broke the command. perl does the match (perl-base is +# essential on Debian, and lib/tagid.sh uses it too); the two names reach +# it through the environment, never through the pattern. +# +# HOSTNAME_ROOT prefixes every file, for a test that works on scratch +# copies; on a machine it is empty. + +HOSTNAME_ROOT="${HOSTNAME_ROOT:-}" + +readarray -t HOSTNAME_FILES <<'FILES' +/etc/exim4/update-exim4.conf.conf +/etc/printcap +/etc/hostname +/etc/hosts +/etc/network/interfaces +/etc/ssh/ssh_host_rsa_key.pub +/etc/ssh/ssh_host_dsa_key.pub +/etc/ssh/ssh_host_ecdsa_key.pub +/etc/ssh/ssh_host_ed25519_key.pub +/etc/mailname +/etc/postfix/main.cf +/etc/motd +/etc/ssmtp/ssmtp.conf +FILES + +# hostname_set NEW +# Replaces the name the machine has (hostname) with NEW in the files, as a +# whole name or a first label, then sets the kernel's name to NEW. The +# same name twice touches no file. +hostname_set() { + local new=$1 old file + old=$(hostname) + if [[ "$old" != "$new" ]]; then + for file in "${HOSTNAME_FILES[@]}"; do + if [[ -f "$HOSTNAME_ROOT$file" ]]; then + HOSTNAME_OLD=$old HOSTNAME_NEW=$new perl -pi -e \ + 's/(? list[str]: return errors + ([error] if error else []) for key in acme: - if key not in ("enabled", "challenge", "domains"): + if key not in ("enabled", "challenge", "domains", "agree_tos"): errors.append(f"tls.acme.{key}: unknown key") + for key in ("enabled", "agree_tos"): + value = acme.get(key) + if value is not None and not isinstance(value, bool): + errors.append(f"tls.acme.{key}: must be true or false") if acme.get("challenge") not in (None, "http-01", "dns-01"): errors.append("tls.acme.challenge: must be http-01 or dns-01") for domain in acme.get("domains") or []: diff --git a/libinithooks/fqdn.py b/libinithooks/fqdn.py new file mode 100644 index 0000000..1a441c0 --- /dev/null +++ b/libinithooks/fqdn.py @@ -0,0 +1,285 @@ +# Copyright (c) 2026 Keel Linux maintainers +"""The machine's fully qualified domain name, asked at first boot + +What firstboot.d/31fqdn and bin/fqdn.py need and do not show: the checks +on a typed name, the /etc/hosts entry that makes `hostname -f` answer the +name, and the instance description with the name recorded in it. + +The description is written the way confconsole writes it (keelcli.py), +since keel has no writer of its own: the new document goes to a file +beside the old one, `keel spec validate --no-secret-files` is asked about +that file when keel is installed, and only then does it replace the old +one, so a description that would not load never replaces one that does. +PyYAML writes it, as confconsole does, so comments do not survive; the +keys keep their order, and the file keeps its mode. A description the +answer does not change is not written at all. +""" + +import os +import re +import shutil +import stat +import subprocess +import tempfile +from collections.abc import Callable, Mapping +from typing import Any + +import yaml + +from libinithooks import declarative + +# RFC 1123 labels: letters, digits and dashes, not at either end +LABEL_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") +MAX_LENGTH = 253 +# the address of the entry, Debian's for a name the network does not fix, +# unless the file already gives the host an address of its own +LOOPBACK = "127.0.1.1" +HOSTS_MODE = 0o644 +# a new description: it holds references to secrets, and root reads it +SPEC_MODE = 0o600 +STAGED = ".fqdn-new" +KEEL = "keel" +VALIDATE = ("spec", "validate", "--no-secret-files", "--spec") +INVALID = ( + '"{typed}" is not a domain name. Use labels of letters, digits and' + " dashes separated by dots, as in blog.example.org." +) + + +class FqdnError(Exception): + pass + + +def normalize(typed: str) -> tuple[str, str | None]: + """The name as the files will hold it, or what is wrong with it + + Returns (name, None) for a domain name or a single label, ("", None) + for nothing typed, and ("", message) for anything else. Whitespace + and one trailing dot (the root, as DNS writes an absolute name) are + dropped and the name is lower cased: DNS compares names without + case, and the files hold one spelling. + """ + name = typed.strip() + if name.endswith("."): + name = name[:-1] + name = name.lower() + if not name: + return "", None + labels = name.split(".") + if len(name) > MAX_LENGTH or not all(LABEL_RE.match(l) for l in labels): + return "", INVALID.format(typed=typed.strip()) + return name, None + + +def split(name: str) -> tuple[str, str]: + """(hostname, fqdn): the first label, and the name when it has a domain + + A single label is a hostname without a domain: ("blog", ""). + """ + hostname, dot, _ = name.partition(".") + return hostname, name if dot else "" + + +def declared(document: Mapping[str, Any]) -> tuple[str, str]: + """(hostname, fqdn) as the description declares them, "" for none""" + instance = document.get("instance") + if not isinstance(instance, Mapping): + return "", "" + return (str(instance.get("hostname") or ""), + str(instance.get("fqdn") or "")) + + +def prefill(current: str, document: Mapping[str, Any]) -> str: + """What the box is prefilled with: the declared fqdn, else the name + the machine has, a dotted one as it is""" + return declared(document)[1] or current + + +def hostname_for(name: str, document: Mapping[str, Any]) -> str: + """The hostname that goes with NAME: the first label, unless NAME is + the fqdn the description declares beside a hostname of its own, which + is then kept (09hostname set it from the same description)""" + hostname, fqdn = declared(document) + if name and name == fqdn and hostname: + return hostname + return split(name)[0] + + +def hosts_with_name(text: str, hostname: str, fqdn: str) -> str: + """/etc/hosts TEXT with the entry for HOSTNAME, FQDN first when given + + A line is the host's when its names are all the host's, as a whole + name or as the first label of a dotted name: the `127.0.1.1 blog` + line 09hostname leaves, and the `192.0.2.10 blog.example.org blog` + line a container manager writes for a static address. The first such + line is rewritten where it stands, keeping its address, since a + resolver answers from the first line that carries a name and a line + left before the entry would keep `hostname -f` on the old name; the + others go. A line at LOOPBACK that names the host beside other names + is rewritten too, since that address is the entry's own. A line that + names the host beside another name elsewhere (`127.0.0.1 localhost + blog`) is kept, as keel apply keeps it. Every other line, comments and + blanks included, stays as it was; without a line to rewrite the entry + is appended, at LOOPBACK. + """ + names = [fqdn, hostname] if fqdn else [hostname] + lines = text.splitlines() + address = next((line.split()[0] for line in lines + if _superseded(line, hostname)), LOOPBACK) + entry = " ".join([address, *names]) + kept: list[str] = [] + written = False + for line in lines: + if not _superseded(line, hostname): + kept.append(line) + continue + if not written: + kept.append(entry) + written = True + if not written: + kept.append(entry) + return "".join(f"{line}\n" for line in kept) + + +def _superseded(line: str, hostname: str) -> bool: + fields = line.split() + if line.strip().startswith("#") or len(fields) < 2: + return False + hosts = [one.split(".")[0].lower() == hostname.lower() + for one in fields[1:]] + if not any(hosts): + return False + return fields[0] == LOOPBACK or all(hosts) + + +def updated(document: Mapping[str, Any], hostname: str, fqdn: str) -> dict: + """A new description with the name recorded; the given one is not + changed, and one the answer does not change comes back equal + + instance.fqdn is set (removed when the name has no domain), and + instance.hostname when it is absent or the answer is not the name + the description declares, so a hostname declared beside an unchanged + fqdn is kept. tls.acme.domains is set to [fqdn] only when the + description declares no domain yet; tls.acme.enabled is never + touched, and a tls section of another shape is left alone. + """ + after = dict(document) + instance = dict(document.get("instance") or {}) if isinstance( + document.get("instance"), Mapping) else {} + before_hostname, before_fqdn = declared(document) + changed = (fqdn or hostname) != (before_fqdn or before_hostname) + if changed or not before_hostname: + instance["hostname"] = hostname + if fqdn: + instance["fqdn"] = fqdn + else: + instance.pop("fqdn", None) + after["instance"] = instance + if not fqdn: + return after + + tls = document.get("tls") + if tls is None: + tls = {} + if not isinstance(tls, Mapping): + return after + acme = tls.get("acme") + if acme is None: + acme = {} + if not isinstance(acme, Mapping): + return after + domains = acme.get("domains") + if domains is None: + domains = [] + if not isinstance(domains, list): + return after + if not domains: + after["tls"] = {**tls, "acme": {**acme, "domains": [fqdn]}} + return after + + +def spec_path( + env: Mapping[str, str] | None = None, + exists: Callable[[str], bool] = os.path.exists, +) -> str: + """The description 31fqdn records into: the one 00declarative read + (INITHOOKS_DECL, else the first of its paths that exists), or + /etc/keel/instance.yaml for a machine that has none yet""" + found, _ = declarative.resolve_path(env, exists) + return found or declarative.DECL_PATHS[0] + + +def load(path: str) -> dict: + """The description at PATH, or the smallest one when there is none""" + if not os.path.lexists(path): + return {"version": declarative.SCHEMA_VERSION} + return declarative.load(path) + + +def write_spec(path: str, document: Mapping[str, Any]) -> None: + """Write DOCUMENT to PATH, validated by keel when keel is installed + + Staged beside PATH, checked, given the mode PATH has (SPEC_MODE for + a new file), then moved into place; a document keel refuses, or a + file that cannot be written, raises FqdnError and leaves PATH as it + was. + """ + text = yaml.safe_dump(dict(document), sort_keys=False, + default_flow_style=False) + staged = _write_beside(path, text, STAGED) + keel = shutil.which(KEEL) + if keel is not None: + proc = subprocess.run([keel, *VALIDATE, staged], capture_output=True, + text=True, check=False) + if proc.returncode != 0: + os.unlink(staged) + raise FqdnError( + f"{path} was NOT changed: keel spec validate exited" + f" {proc.returncode}\n{(proc.stdout + proc.stderr).strip()}" + ) + os.chmod(staged, _mode(path, SPEC_MODE)) + _replace(staged, path) + + +def write_hosts(path: str, hostname: str, fqdn: str) -> None: + """Write the entry for HOSTNAME and FQDN into the hosts file at PATH""" + text = "" + if os.path.exists(path): + try: + with open(path) as fob: + text = fob.read() + except OSError as error: + raise FqdnError(f"{path}: {error.strerror}") + staged = _write_beside(path, hosts_with_name(text, hostname, fqdn), + ".fqdn-tmp") + os.chmod(staged, _mode(path, HOSTS_MODE)) + _replace(staged, path) + + +def _mode(path: str, default: int) -> int: + """The permission bits of PATH, DEFAULT when there is no file""" + try: + return stat.S_IMODE(os.stat(path).st_mode) + except OSError: + return default + + +def _write_beside(path: str, text: str, suffix: str) -> str: + """TEXT in a new file beside PATH (mkstemp, 0600); its path""" + directory = os.path.dirname(path) or "." + try: + descriptor, staged = tempfile.mkstemp( + prefix=os.path.basename(path) + ".", suffix=suffix, dir=directory) + except OSError as error: + raise FqdnError(f"{path}: {error.strerror}") + with os.fdopen(descriptor, "w") as fob: + fob.write(text) + return staged + + +def _replace(staged: str, path: str) -> None: + try: + os.replace(staged, path) + except OSError as error: + os.unlink(staged) + raise FqdnError(f"{path}: {error.strerror}") diff --git a/pyproject.toml b/pyproject.toml index 4896952..e34f652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,10 @@ branch = true include = [ "libinithooks/declarative.py", "libinithooks/dialog_wrapper.py", + "libinithooks/fqdn.py", "libinithooks/init_lock.py", "bin/declarative.py", + "bin/fqdn.py", ] # The reusable workflow runs coverage with --source=libinithooks,bin, and # coverage.py ignores "include" when a source is given, so the inherited diff --git a/tests/test-fqdn.bats b/tests/test-fqdn.bats new file mode 100644 index 0000000..f537ee7 --- /dev/null +++ b/tests/test-fqdn.bats @@ -0,0 +1,263 @@ +#!/usr/bin/env bats +# Tests for firstboot.d/31fqdn: the first boot asks the machine's fully +# qualified domain name, prefilled with the name the machine has, or takes +# a preseeded FQDN; what is answered renames the machine (lib/hostname.sh, +# as 09hostname does) and is recorded in /etc/hosts and the instance +# description by bin/fqdn.py --record. +# +# The description is recorded first, then the machine is renamed, then the +# /etc/hosts entry is written: a step that fails leaves the description +# saying what the machine should be, never a renamed machine with no +# record of it. +# +# In most tests bin/fqdn.py is a stub under INITHOOKS_PATH that records its +# arguments and answers what a test scripts; hostname is a stub too. The +# last tests run the real bin/fqdn.py against a scratch instance.yaml and a +# scratch hosts file, with FQDN preseeded so no screen is drawn. + +bats_require_minimum_version 1.5.0 + +load helpers + +REPO=$BATS_TEST_DIRNAME/.. + +setup() { + setup_stubs + stub hostname 'if [[ $# -eq 0 ]]; then echo blog; fi' + + export HOSTNAME_ROOT=$BATS_TEST_TMPDIR/root + mkdir -p "$HOSTNAME_ROOT/etc" + echo blog > "$HOSTNAME_ROOT/etc/hostname" + printf '127.0.0.1\tlocalhost\n127.0.1.1\tblog\n' \ + > "$HOSTNAME_ROOT/etc/hosts" + + export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks + mkdir -p "$INITHOOKS_PATH/bin" + ln -s "$REPO/lib" "$INITHOOKS_PATH/lib" + # the stub prints ANSWER when asked, and exits ASK_STATUS + cat > "$INITHOOKS_PATH/bin/fqdn.py" <> '$STUBS/fqdn.py.calls' +if [[ " \$* " != *" --record "* ]] && [[ " \$* " != *" --hosts "* ]]; then + printf '%s' "\${ANSWER-}" +fi +exit "\${ASK_STATUS:-0}" +EOF + chmod +x "$INITHOOKS_PATH/bin/fqdn.py" + + export INITHOOKS_CONF=$BATS_TEST_TMPDIR/inithooks.conf + export INITHOOKS_DEFAULT=$BATS_TEST_TMPDIR/default-inithooks + { + echo "INITHOOKS_PATH=$INITHOOKS_PATH" + echo "INITHOOKS_CONF=$INITHOOKS_CONF" + } > "$INITHOOKS_DEFAULT" + unset ANSWER ASK_STATUS FQDN +} + +@test "the screen is asked with the name the machine has" { + export ANSWER=$'HOSTNAME=blog\nFQDN=blog.example.org\n' + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(calls fqdn.py | head -1)" = "--fqdn= --current=blog" ] +} + +@test "the answer is recorded, renames the machine and gets its hosts entry" { + export ANSWER=$'HOSTNAME=web\nFQDN=web.example.org\n' + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = web ] + [ "$(calls hostname | tail -1)" = web ] + [ "$(calls fqdn.py | sed -n 2p)" = "--record --hostname=web --fqdn=web.example.org" ] + [ "$(calls fqdn.py | sed -n 3p)" = "--hosts --hostname=web --fqdn=web.example.org" ] +} + +@test "the description first, then the rename, then the hosts entry" { + # the description is what the machine is asked to be, so it is written + # before anything is changed; the rename replaces the old name wherever + # it stands, so the entry is written after it + export ANSWER=$'HOSTNAME=web\nFQDN=web.example.org\n' + stub hostname 'if [[ $# -eq 0 ]]; then echo blog; else + echo renamed >> "'"$STUBS"'/order"; fi' + cat > "$INITHOOKS_PATH/bin/fqdn.py" <> '$STUBS/order' ;; + *" --hosts "*) echo hosts >> '$STUBS/order' ;; + *) printf '%s' "\$ANSWER" ;; +esac +EOF + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(cat "$STUBS/order")" = "$(printf 'recorded\nrenamed\nhosts')" ] +} + +@test "a hostname without a domain is recorded with an empty FQDN" { + export ANSWER=$'HOSTNAME=web\nFQDN=\n' + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(calls hostname | tail -1)" = web ] + [ "$(calls fqdn.py | sed -n 2p)" = "--record --hostname=web --fqdn=" ] +} + +@test "an empty answer changes nothing" { + export ANSWER= + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(calls hostname)" = "" ] + [ "$(calls fqdn.py | wc -l)" -eq 1 ] + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = blog ] +} + +@test "a preseeded FQDN reaches the screen, which asks nothing" { + echo "export FQDN=blog.example.org" > "$INITHOOKS_CONF" + export ANSWER=$'HOSTNAME=blog\nFQDN=blog.example.org\n' + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(calls fqdn.py | head -1)" = "--fqdn=blog.example.org --current=blog" ] +} + +@test "a preseeded SKIP asks nothing and changes nothing" { + echo "export FQDN=skip" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ -z "$(calls fqdn.py)" ] + [ -z "$(calls hostname)" ] +} + +@test "a screen that fails is reported by its status, for run to log" { + export ASK_STATUS=1 + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 1 ] + [ -z "$(calls hostname)" ] +} + +@test "a record that fails stops the hook before the machine is renamed" { + export ANSWER=$'HOSTNAME=web\nFQDN=web.example.org\n' + cat > "$INITHOOKS_PATH/bin/fqdn.py" < "$INITHOOKS_PATH/bin/fqdn.py" < "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = web ] + [ "$(calls hostname | tail -1)" = web ] + [ "$(cat "$HOSTNAME_ROOT/etc/hosts")" = "$(printf '127.0.0.1\tlocalhost\n127.0.1.1 web.example.org web')" ] + [ "$(cat "$INITHOOKS_DECL")" = "$(printf 'version: 1\ninstance:\n hostname: web\n fqdn: web.example.org\ntls:\n acme:\n domains:\n - web.example.org')" ] +} + +@test "a preseeded FQDN is recorded into the instance.yaml that is there" { + real_fqdn_py + printf 'version: 1\napp:\n email: admin@example.org\ntls:\n acme:\n enabled: true\n domains:\n - www.example.org\n' \ + > "$INITHOOKS_DECL" + echo "export FQDN=web.example.org" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [ "$(cat "$INITHOOKS_DECL")" = "$(printf 'version: 1\napp:\n email: admin@example.org\ntls:\n acme:\n enabled: true\n domains:\n - www.example.org\ninstance:\n hostname: web\n fqdn: web.example.org')" ] +} + +@test "a preseeded FQDN the description already declares writes it nowhere" { + real_fqdn_py + printf '# the operator wrote this\nversion: 1\ninstance:\n hostname: wp\n fqdn: web.example.org\ntls:\n acme:\n domains: [web.example.org]\n' \ + > "$INITHOOKS_DECL" + echo "export FQDN=web.example.org" > "$INITHOOKS_CONF" + + run --separate-stderr "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 0 ] + [[ "$stderr" == *"already declares the name"* ]] + [ "$(head -1 "$INITHOOKS_DECL")" = "# the operator wrote this" ] + # the declared hostname, which 09hostname set, is the one kept + [ "$(calls hostname | tail -1)" = wp ] + [ "$(cat "$HOSTNAME_ROOT/etc/hosts")" = "$(printf '127.0.0.1\tlocalhost\n127.0.1.1 web.example.org wp')" ] +} + +@test "a preseeded FQDN that is not a domain name fails the hook and changes nothing" { + real_fqdn_py + echo "export FQDN=web_1.example.org" > "$INITHOOKS_CONF" + + run --separate-stderr "$REPO/firstboot.d/31fqdn" + + [ "$status" -eq 1 ] + [[ "$stderr" == *"web_1.example.org"* ]] + [ -z "$(calls hostname)" ] + [ ! -e "$INITHOOKS_DECL" ] +} diff --git a/tests/test-hostname.bats b/tests/test-hostname.bats new file mode 100644 index 0000000..47e1018 --- /dev/null +++ b/tests/test-hostname.bats @@ -0,0 +1,178 @@ +#!/usr/bin/env bats +# Tests for lib/hostname.sh and firstboot.d/09hostname: renaming the +# machine. The hook has always replaced the old name with the new one in +# the files that carry it and set the kernel's name; the rename is now a +# function in lib/hostname.sh, so that firstboot.d/31fqdn renames the +# machine the same way instead of carrying a copy. +# +# hostname is a stub: it answers the name the machine has and records the +# name it is given. The files are scratch copies under HOSTNAME_ROOT. + +bats_require_minimum_version 1.5.0 + +load helpers + +REPO=$BATS_TEST_DIRNAME/.. + +setup() { + setup_stubs + stub hostname 'if [[ $# -eq 0 ]]; then echo blog; fi' + + export HOSTNAME_ROOT=$BATS_TEST_TMPDIR/root + mkdir -p "$HOSTNAME_ROOT/etc/postfix" + echo blog > "$HOSTNAME_ROOT/etc/hostname" + printf '127.0.0.1\tlocalhost\n127.0.1.1\tblog\n' \ + > "$HOSTNAME_ROOT/etc/hosts" + echo blog > "$HOSTNAME_ROOT/etc/mailname" + printf 'myhostname = blog\nmydestination = blog, localhost\n' \ + > "$HOSTNAME_ROOT/etc/postfix/main.cf" + + export INITHOOKS_CONF=$BATS_TEST_TMPDIR/inithooks.conf + export INITHOOKS_DEFAULT=$BATS_TEST_TMPDIR/default-inithooks + export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks + mkdir -p "$INITHOOKS_PATH" + ln -s "$REPO/lib" "$INITHOOKS_PATH/lib" + { + echo "INITHOOKS_PATH=$INITHOOKS_PATH" + echo "INITHOOKS_CONF=$INITHOOKS_CONF" + } > "$INITHOOKS_DEFAULT" +} + +@test "hostname_set replaces the old name in every file that carries it" { + source "$REPO/lib/hostname.sh" + + hostname_set web + + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = web ] + [ "$(cat "$HOSTNAME_ROOT/etc/hosts")" = "$(printf '127.0.0.1\tlocalhost\n127.0.1.1\tweb')" ] + [ "$(cat "$HOSTNAME_ROOT/etc/mailname")" = web ] + [ "$(cat "$HOSTNAME_ROOT/etc/postfix/main.cf")" = "$(printf 'myhostname = web\nmydestination = web, localhost')" ] +} + +@test "the name is replaced only as a whole name or a first label" { + # 09hostname's sed took the name as a pattern and matched it inside + # any word: a machine called web rewrote every SSH key comment, + # postfix setting and name that contained it + source "$REPO/lib/hostname.sh" + stub hostname 'if [[ $# -eq 0 ]]; then echo web; fi' + printf 'web\n' > "$HOSTNAME_ROOT/etc/hostname" + printf '127.0.1.1 web.example.org web\n2001:db8::10 webmail backup-web www.web web-2\nssh-ed25519 AAAA root@web\n' \ + > "$HOSTNAME_ROOT/etc/hosts" + printf 'myhostname = web\nmydestination = web, webmail, localhost\n' \ + > "$HOSTNAME_ROOT/etc/postfix/main.cf" + + hostname_set blog + + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = blog ] + [ "$(sed -n 1p "$HOSTNAME_ROOT/etc/hosts")" = "127.0.1.1 blog.example.org blog" ] + [ "$(sed -n 2p "$HOSTNAME_ROOT/etc/hosts")" = "2001:db8::10 webmail backup-web www.web web-2" ] + [ "$(sed -n 3p "$HOSTNAME_ROOT/etc/hosts")" = "ssh-ed25519 AAAA root@blog" ] + [ "$(cat "$HOSTNAME_ROOT/etc/postfix/main.cf")" = "$(printf 'myhostname = blog\nmydestination = blog, webmail, localhost')" ] +} + +@test "two names on one line are both replaced" { + source "$REPO/lib/hostname.sh" + printf '127.0.1.1 blog blog\n' > "$HOSTNAME_ROOT/etc/hosts" + + hostname_set web + + [ "$(cat "$HOSTNAME_ROOT/etc/hosts")" = "127.0.1.1 web web" ] +} + +@test "the name is matched literally, a colon or a dot included" { + source "$REPO/lib/hostname.sh" + stub hostname 'if [[ $# -eq 0 ]]; then echo "a:b.example.org"; fi' + printf 'a:b.example.org axb.example.org\n' > "$HOSTNAME_ROOT/etc/hosts" + + hostname_set web + + [ "$(cat "$HOSTNAME_ROOT/etc/hosts")" = "web axb.example.org" ] +} + +@test "a dotted name is replaced whole, as pct create --hostname set it" { + source "$REPO/lib/hostname.sh" + stub hostname 'if [[ $# -eq 0 ]]; then echo blog.example.org; fi' + printf 'blog.example.org\n' > "$HOSTNAME_ROOT/etc/hostname" + printf '127.0.1.1 blog.example.org blog\n' > "$HOSTNAME_ROOT/etc/hosts" + + hostname_set blog + + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = blog ] + [ "$(cat "$HOSTNAME_ROOT/etc/hosts")" = "127.0.1.1 blog blog" ] +} + +@test "the same name again touches no file" { + source "$REPO/lib/hostname.sh" + touch -d '2020-01-01' "$HOSTNAME_ROOT/etc/hosts" + + hostname_set blog + + [ "$(calls hostname | tail -1)" = blog ] + [ "$(stat -c %Y "$HOSTNAME_ROOT/etc/hosts")" = "$(date -d '2020-01-01' +%s)" ] +} + +@test "hostname_set sets the kernel's name last" { + source "$REPO/lib/hostname.sh" + + hostname_set web + + [ "$(calls hostname | tail -1)" = web ] +} + +@test "a file the machine does not have is skipped" { + source "$REPO/lib/hostname.sh" + rm "$HOSTNAME_ROOT/etc/mailname" + + hostname_set web + + [ ! -e "$HOSTNAME_ROOT/etc/mailname" ] + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = web ] +} + +@test "the files are the ones 09hostname has always rewritten" { + source "$REPO/lib/hostname.sh" + + [ "${#HOSTNAME_FILES[@]}" -eq 13 ] + [[ " ${HOSTNAME_FILES[*]} " == *" /etc/hostname "* ]] + [[ " ${HOSTNAME_FILES[*]} " == *" /etc/hosts "* ]] + [[ " ${HOSTNAME_FILES[*]} " == *" /etc/ssh/ssh_host_ed25519_key.pub "* ]] +} + +@test "the root defaults to the machine's own files" { + unset HOSTNAME_ROOT + source "$REPO/lib/hostname.sh" + + [ -z "$HOSTNAME_ROOT" ] +} + +@test "09hostname renames the machine to the preseeded HOSTNAME" { + echo "export HOSTNAME=web" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/09hostname" + + [ "$status" -eq 0 ] + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = web ] + [ "$(calls hostname | tail -1)" = web ] +} + +@test "09hostname without a preseed renames the machine to bash's HOSTNAME" { + # bash sets HOSTNAME itself, to the name of the machine it runs on, so + # without a conf file the hook has always renamed the machine to the + # name it already has; here the stub answers another name, so the + # rename shows + run "$REPO/firstboot.d/09hostname" + + [ "$status" -eq 0 ] + [ "$(cat "$HOSTNAME_ROOT/etc/hostname")" = "$HOSTNAME" ] + [ "$(calls hostname | tail -1)" = "$HOSTNAME" ] +} + +@test "09hostname exits without a name" { + # HOSTNAME empty, as a conf file can make it + echo "export HOSTNAME=" > "$INITHOOKS_CONF" + + run "$REPO/firstboot.d/09hostname" + + [ "$status" -eq 0 ] + [ -z "$(calls hostname)" ] +} diff --git a/tests/test_declarative_validate.py b/tests/test_declarative_validate.py index 2fc8349..2bea2a1 100644 --- a/tests/test_declarative_validate.py +++ b/tests/test_declarative_validate.py @@ -328,11 +328,23 @@ def test_accepts_a_full_acme_section(self): text = self.acme( " enabled: true\n" " challenge: http-01\n" + " agree_tos: true\n" " domains:\n" " - blog.example.org\n" ) self.assertEqual(errors(text), []) + def test_enabled_and_agree_tos_must_be_true_or_false(self): + # the instance tooling accepts both as booleans (keel docs/spec.md) + messages( + self.acme(" enabled: yes please\n"), + "tls.acme.enabled: must be true or false", + ) + messages( + self.acme(" agree_tos: 1\n"), + "tls.acme.agree_tos: must be true or false", + ) + class TestPreseed(unittest.TestCase): def test_rejects_key_that_is_not_a_variable_name(self): diff --git a/tests/test_dialog_brand.py b/tests/test_dialog_brand.py index 166ffb0..544b9f0 100644 --- a/tests/test_dialog_brand.py +++ b/tests/test_dialog_brand.py @@ -22,6 +22,7 @@ ROOT = dirname(dirname(abspath(__file__))) SHOWN = [ + "bin/fqdn.py", "bin/reboot-ask.py", "bin/secalerts.py", "bin/secupdates-ask.py", diff --git a/tests/test_fqdn.py b/tests/test_fqdn.py new file mode 100644 index 0000000..2c4d877 --- /dev/null +++ b/tests/test_fqdn.py @@ -0,0 +1,561 @@ +"""libinithooks.fqdn: the fully qualified domain name of the machine + +What the first boot hook 31fqdn records once the operator has answered: +the hostname and the name /etc/hosts answers for it, and the instance +description (instance.hostname, instance.fqdn, tls.acme.domains when the +description has none). The checks on a typed name are here too, so the +screen and a preseeded FQDN refuse the same things. + +keel exposes no writer for the description, so the one here follows +confconsole's (keelcli.py): the new document is written beside the old +one, handed to `keel spec validate --no-secret-files` when keel is +installed, and moved into place only then. keel is a stub on PATH in +these tests; nothing touches the live system. +""" + +import os +import stat +import tempfile +import unittest +from os.path import abspath, dirname, exists, join + +import yaml + +from helpers import declarative +from libinithooks import fqdn + +REPO = dirname(dirname(abspath(__file__))) + +DEBIAN_HOSTS = ( + "127.0.0.1\tlocalhost\n" + "127.0.1.1\tblog\n" + "\n" + "# The following lines are desirable for IPv6 capable hosts\n" + "::1 localhost ip6-localhost ip6-loopback\n" + "ff02::1 ip6-allnodes\n" + "ff02::2 ip6-allrouters\n" +) + + +class TestNormalize(unittest.TestCase): + def test_a_domain_name_is_accepted_as_typed(self): + self.assertEqual(fqdn.normalize("blog.example.org"), + ("blog.example.org", None)) + + def test_whitespace_and_one_trailing_dot_are_dropped(self): + self.assertEqual(fqdn.normalize(" blog.example.org. \n"), + ("blog.example.org", None)) + + def test_only_one_trailing_dot_is_the_root(self): + self.assertEqual(fqdn.normalize("blog.example.org..")[0], "") + + def test_the_name_is_lower_cased(self): + # DNS names are compared without case; the files hold one spelling + self.assertEqual(fqdn.normalize("Blog.Example.ORG"), + ("blog.example.org", None)) + + def test_a_single_label_is_a_valid_name(self): + self.assertEqual(fqdn.normalize("blog"), ("blog", None)) + + def test_digits_and_dashes_inside_a_label_are_accepted(self): + self.assertEqual(fqdn.normalize("web-2.example.org"), + ("web-2.example.org", None)) + + def test_an_empty_answer_is_empty_and_not_a_problem(self): + # the hook keeps what the machine has + self.assertEqual(fqdn.normalize(" "), ("", None)) + + def test_a_label_may_not_start_or_end_with_a_dash(self): + for typed in ("-blog.example.org", "blog-.example.org"): + with self.subTest(typed=typed): + name, problem = fqdn.normalize(typed) + self.assertEqual(name, "") + self.assertIn(typed, problem) + + def test_an_empty_label_is_refused(self): + for typed in ("blog..example.org", ".example.org", "..."): + with self.subTest(typed=typed): + self.assertEqual(fqdn.normalize(typed)[0], "") + + def test_a_scheme_a_path_a_port_or_a_space_is_refused(self): + for typed in ("http://blog.example.org", "blog.example.org/wp", + "blog.example.org:443", "blog example.org", + "blog_1.example.org", "blög.example.org"): + with self.subTest(typed=typed): + name, problem = fqdn.normalize(typed) + self.assertEqual(name, "") + self.assertIn("blog.example.org", problem) + + def test_a_label_longer_than_63_characters_is_refused(self): + self.assertEqual(fqdn.normalize("a" * 64 + ".example.org")[0], "") + self.assertEqual(fqdn.normalize("a" * 63 + ".example.org")[1], None) + + def test_a_name_longer_than_253_characters_is_refused(self): + label = "a" * 63 + fits = ".".join([label, label, label, "a" * 61]) + self.assertEqual(len(fits), 253) + self.assertEqual(fqdn.normalize(fits)[1], None) + self.assertEqual(fqdn.normalize(fits + "b")[0], "") + + +class TestSplit(unittest.TestCase): + def test_the_hostname_is_the_first_label(self): + self.assertEqual(fqdn.split("blog.example.org"), + ("blog", "blog.example.org")) + + def test_a_single_label_is_a_hostname_without_a_domain(self): + self.assertEqual(fqdn.split("blog"), ("blog", "")) + + +class TestDeclared(unittest.TestCase): + def test_the_names_the_description_declares(self): + self.assertEqual(fqdn.declared({"instance": { + "hostname": "blog", "fqdn": "blog.example.org"}}), + ("blog", "blog.example.org")) + + def test_none_when_it_declares_nothing(self): + for document in ({}, {"instance": None}, {"instance": "x"}, + {"instance": {}}): + self.assertEqual(fqdn.declared(document), ("", "")) + + +class TestHostnameFor(unittest.TestCase): + DOCUMENT = {"instance": {"hostname": "wp", "fqdn": "blog.example.org"}} + + def test_the_first_label(self): + self.assertEqual(fqdn.hostname_for("blog.example.org", {}), "blog") + self.assertEqual(fqdn.hostname_for("blog", {}), "blog") + + def test_the_declared_hostname_beside_the_unchanged_fqdn(self): + # 09hostname set it from the same description + self.assertEqual(fqdn.hostname_for("blog.example.org", self.DOCUMENT), + "wp") + + def test_another_name_is_a_change_and_takes_its_first_label(self): + self.assertEqual(fqdn.hostname_for("shop.example.org", self.DOCUMENT), + "shop") + + def test_without_a_declared_hostname_the_first_label(self): + document = {"instance": {"fqdn": "blog.example.org"}} + self.assertEqual(fqdn.hostname_for("blog.example.org", document), + "blog") + + +class TestPrefill(unittest.TestCase): + def test_the_current_hostname_when_the_description_names_none(self): + self.assertEqual(fqdn.prefill("blog", {"version": 1}), "blog") + + def test_a_dotted_hostname_is_used_as_it_is(self): + # what `pct create --hostname blog.example.org` set + self.assertEqual(fqdn.prefill("blog.example.org", {}), + "blog.example.org") + + def test_the_declared_fqdn_comes_first(self): + # keel-init asks again on a machine whose description answers + document = {"instance": {"hostname": "blog", + "fqdn": "blog.example.org"}} + self.assertEqual(fqdn.prefill("blog", document), "blog.example.org") + + def test_a_description_whose_instance_is_not_a_mapping(self): + self.assertEqual(fqdn.prefill("blog", {"instance": "x"}), "blog") + + +class TestHostsWithName(unittest.TestCase): + def test_the_short_entry_09hostname_leaves_is_replaced_in_place(self): + text = fqdn.hosts_with_name(DEBIAN_HOSTS, "blog", "blog.example.org") + + self.assertEqual(text, DEBIAN_HOSTS.replace( + "127.0.1.1\tblog\n", "127.0.1.1 blog.example.org blog\n")) + + def test_an_entry_at_the_address_naming_the_host_is_replaced(self): + before = DEBIAN_HOSTS.replace( + "127.0.1.1\tblog\n", "127.0.1.1 old.example.org blog\n") + + text = fqdn.hosts_with_name(before, "blog", "blog.example.org") + + self.assertIn("127.0.1.1 blog.example.org blog\n", text) + self.assertNotIn("old.example.org", text) + + def test_a_file_without_an_entry_gets_one_appended(self): + before = "127.0.0.1\tlocalhost\n" + + text = fqdn.hosts_with_name(before, "blog", "blog.example.org") + + self.assertEqual(text, before + "127.0.1.1 blog.example.org blog\n") + + def test_an_empty_file_gets_the_entry(self): + self.assertEqual(fqdn.hosts_with_name("", "blog", "blog.example.org"), + "127.0.1.1 blog.example.org blog\n") + + def test_the_container_manager_s_line_is_rewritten_in_place(self): + # pct writes ` name.domain name` for a static address, and + # the rename of 31fqdn has already put the new hostname in it; a + # second line at 127.0.1.1 would come after it and hostname -f + # would keep answering the old domain + before = ("127.0.0.1 localhost\n192.0.2.10 blog.old.example blog\n" + "::1 localhost ip6-localhost\n") + + text = fqdn.hosts_with_name(before, "blog", "blog.example.org") + + self.assertEqual(text, "127.0.0.1 localhost\n" + "192.0.2.10 blog.example.org blog\n" + "::1 localhost ip6-localhost\n") + + def test_a_line_of_another_host_with_a_domain_is_kept(self): + before = "192.0.2.10 old.example.org old\n" + + text = fqdn.hosts_with_name(before, "blog", "blog.example.org") + + self.assertEqual(text, before + "127.0.1.1 blog.example.org blog\n") + + def test_the_name_is_matched_without_case(self): + text = fqdn.hosts_with_name("127.0.1.1 Blog\n", "blog", + "blog.example.org") + + self.assertEqual(text, "127.0.1.1 blog.example.org blog\n") + + def test_a_line_naming_the_host_beside_another_name_is_kept(self): + # not this hook's to rewrite: keel apply says the same + before = "127.0.0.1\tlocalhost blog\n" + + text = fqdn.hosts_with_name(before, "blog", "blog.example.org") + + self.assertEqual( + text, before + "127.0.1.1 blog.example.org blog\n") + + def test_another_host_s_entry_and_the_comments_are_kept(self): + before = ("# hosts\n127.0.1.1 other\n" + "2001:db8:1::20 db.example.org db\n") + + text = fqdn.hosts_with_name(before, "blog", "blog.example.org") + + self.assertEqual(text, before + "127.0.1.1 blog.example.org blog\n") + + def test_a_last_line_without_a_newline_is_still_one_line(self): + text = fqdn.hosts_with_name("127.0.0.1 localhost", "blog", + "blog.example.org") + + self.assertEqual(text, "127.0.0.1 localhost\n" + "127.0.1.1 blog.example.org blog\n") + + def test_two_lines_naming_the_host_become_one_entry(self): + # a short line 09hostname left and an entry an operator wrote + before = ("127.0.1.1 blog\n127.0.0.1 localhost\n" + "127.0.1.1 blog.example.org blog\n") + + text = fqdn.hosts_with_name(before, "blog", "blog.example.org") + + self.assertEqual(text, "127.0.1.1 blog.example.org blog\n" + "127.0.0.1 localhost\n") + + def test_writing_the_same_name_twice_changes_nothing(self): + once = fqdn.hosts_with_name(DEBIAN_HOSTS, "blog", "blog.example.org") + + self.assertEqual( + fqdn.hosts_with_name(once, "blog", "blog.example.org"), once) + + def test_without_a_domain_the_entry_names_the_host_alone(self): + before = DEBIAN_HOSTS.replace( + "127.0.1.1\tblog\n", "127.0.1.1 blog.example.org blog\n") + + text = fqdn.hosts_with_name(before, "blog", "") + + self.assertIn("127.0.1.1 blog\n", text) + self.assertNotIn("blog.example.org", text) + + +class TestUpdated(unittest.TestCase): + def test_a_new_description_gets_version_instance_and_domains(self): + self.assertEqual( + fqdn.updated({"version": 1}, "blog", "blog.example.org"), + {"version": 1, + "instance": {"hostname": "blog", "fqdn": "blog.example.org"}, + "tls": {"acme": {"domains": ["blog.example.org"]}}}) + + def test_the_rest_of_the_description_is_kept_in_order(self): + document = {"version": 1, "instance": {"hostname": "old"}, + "app": {"email": "admin@example.org"}, + "database": {"server": {"engine": "mariadb"}}} + + after = fqdn.updated(document, "blog", "blog.example.org") + + self.assertEqual(list(after), ["version", "instance", "app", + "database", "tls"]) + self.assertEqual(after["app"], document["app"]) + self.assertEqual(after["database"], document["database"]) + + def test_the_document_given_is_not_changed(self): + document = {"version": 1, "instance": {"hostname": "old"}} + + fqdn.updated(document, "blog", "blog.example.org") + + self.assertEqual(document, {"version": 1, + "instance": {"hostname": "old"}}) + + def test_declared_domains_are_kept(self): + document = {"version": 1, "tls": {"acme": { + "enabled": True, "domains": ["www.example.org"]}}} + + after = fqdn.updated(document, "blog", "blog.example.org") + + self.assertEqual(after["tls"]["acme"]["domains"], ["www.example.org"]) + + def test_an_empty_domains_list_is_filled(self): + document = {"version": 1, "tls": {"acme": {"enabled": False, + "domains": []}}} + + after = fqdn.updated(document, "blog", "blog.example.org") + + self.assertEqual(after["tls"]["acme"], + {"enabled": False, "domains": ["blog.example.org"]}) + + def test_enabled_is_never_touched(self): + for acme in ({}, {"enabled": True}, {"enabled": False}): + with self.subTest(acme=acme): + after = fqdn.updated({"version": 1, "tls": {"acme": acme}}, + "blog", "blog.example.org") + self.assertEqual(after["tls"]["acme"].get("enabled"), + acme.get("enabled")) + + def test_a_hostname_without_a_domain_declares_no_fqdn_or_domain(self): + document = {"version": 1, "instance": {"hostname": "old", + "fqdn": "old.example.org"}} + + after = fqdn.updated(document, "blog", "") + + self.assertEqual(after, {"version": 1, + "instance": {"hostname": "blog"}}) + + def test_the_unchanged_name_leaves_the_description_equal(self): + # the preseed of a described machine: nothing to write + document = {"version": 1, + "instance": {"hostname": "wp", "fqdn": "blog.example.org"}, + "tls": {"acme": {"domains": ["blog.example.org"]}}} + + after = fqdn.updated(document, "wp", "blog.example.org") + + self.assertEqual(after, document) + + def test_a_declared_hostname_is_kept_beside_the_unchanged_fqdn(self): + document = {"version": 1, + "instance": {"hostname": "wp", "fqdn": "blog.example.org"}} + + after = fqdn.updated(document, "blog", "blog.example.org") + + self.assertEqual(after["instance"]["hostname"], "wp") + + def test_a_missing_hostname_is_set_beside_the_unchanged_fqdn(self): + document = {"version": 1, "instance": {"fqdn": "blog.example.org"}} + + after = fqdn.updated(document, "blog", "blog.example.org") + + self.assertEqual(after["instance"], + {"fqdn": "blog.example.org", "hostname": "blog"}) + + def test_a_changed_name_sets_the_hostname(self): + document = {"version": 1, + "instance": {"hostname": "wp", "fqdn": "blog.example.org"}} + + after = fqdn.updated(document, "shop", "shop.example.org") + + self.assertEqual(after["instance"], + {"hostname": "shop", "fqdn": "shop.example.org"}) + + def test_a_changed_bare_label_sets_the_hostname(self): + document = {"version": 1, "instance": {"hostname": "wp"}} + + self.assertEqual(fqdn.updated(document, "web", "")["instance"], + {"hostname": "web"}) + self.assertEqual(fqdn.updated(document, "wp", ""), document) + + def test_a_tls_section_of_another_shape_is_left_alone(self): + for tls in ("x", {"acme": "yes"}, {"acme": {"domains": "a"}}): + with self.subTest(tls=tls): + after = fqdn.updated({"version": 1, "tls": tls}, "blog", + "blog.example.org") + self.assertEqual(after["tls"], tls) + + +class TestSpecPath(unittest.TestCase): + def test_inithooks_decl_names_the_file(self): + self.assertEqual(fqdn.spec_path({"INITHOOKS_DECL": "/x/i.yaml"}), + "/x/i.yaml") + + def test_the_description_00declarative_read(self): + self.assertEqual(fqdn.spec_path({}, exists=lambda p: True), + declarative.DECL_PATHS[0]) + self.assertEqual( + fqdn.spec_path({}, exists=lambda p: p == declarative.DECL_DEFAULT), + declarative.DECL_DEFAULT) + + def test_the_default_when_there_is_none_yet(self): + self.assertEqual(fqdn.spec_path({}, exists=lambda p: False), + "/etc/keel/instance.yaml") + + +class FileCase(unittest.TestCase): + def setUp(self): + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + self.dir = scratch.name + self.spec = join(self.dir, "instance.yaml") + self.hosts = join(self.dir, "hosts") + self.bin = join(self.dir, "bin") + os.mkdir(self.bin) + self.path = os.environ.get("PATH", "") + + def tearDown(self): + os.environ["PATH"] = self.path + + def keel(self, body="exit 0"): + """A keel on PATH that records its arguments and runs BODY""" + self.calls = join(self.dir, "keel.calls") + with open(join(self.bin, "keel"), "w") as fob: + fob.write(f"#!/bin/bash\necho \"$*\" >> '{self.calls}'\n{body}\n") + os.chmod(join(self.bin, "keel"), 0o755) + os.environ["PATH"] = self.bin + os.pathsep + self.path + + def no_keel(self): + os.environ["PATH"] = self.bin + + def read(self, path): + with open(path) as fob: + return fob.read() + + +class TestLoad(FileCase): + def test_a_description_that_is_not_there_yet_starts_at_version_1(self): + self.assertEqual(fqdn.load(self.spec), {"version": 1}) + + def test_the_description_on_disk(self): + with open(self.spec, "w") as fob: + fob.write("version: 1\napp:\n email: a@example.org\n") + + self.assertEqual(fqdn.load(self.spec), + {"version": 1, "app": {"email": "a@example.org"}}) + + def test_one_that_does_not_read_is_the_reader_s_error(self): + with open(self.spec, "w") as fob: + fob.write("version: [1\n") + + with self.assertRaises(declarative.DeclarativeError): + fqdn.load(self.spec) + + +class TestWriteSpec(FileCase): + DOCUMENT = {"version": 1, + "instance": {"hostname": "blog", "fqdn": "blog.example.org"}, + "tls": {"acme": {"domains": ["blog.example.org"]}}} + + def test_the_document_is_written_root_only_keys_in_order(self): + self.no_keel() + + fqdn.write_spec(self.spec, self.DOCUMENT) + + self.assertEqual( + self.read(self.spec), + "version: 1\n" + "instance:\n hostname: blog\n fqdn: blog.example.org\n" + "tls:\n acme:\n domains:\n - blog.example.org\n") + self.assertEqual(stat.S_IMODE(os.stat(self.spec).st_mode), 0o600) + self.assertEqual(sorted(os.listdir(self.dir)), ["bin", "instance.yaml"]) + + def test_a_description_keeps_the_mode_it_had(self): + self.no_keel() + with open(self.spec, "w") as fob: + fob.write("version: 1\n") + os.chmod(self.spec, 0o644) + + fqdn.write_spec(self.spec, self.DOCUMENT) + + self.assertEqual(stat.S_IMODE(os.stat(self.spec).st_mode), 0o644) + + def test_what_is_written_reads_back_as_the_same_document(self): + self.no_keel() + document = {**self.DOCUMENT, "network": {"managed_by": "host"}, + "database": {"server": {"listen": ["::1", "127.0.0.1"]}}} + + fqdn.write_spec(self.spec, document) + + self.assertEqual(declarative.load(self.spec), document) + + def test_keel_validates_the_staged_file_before_it_is_moved_in(self): + self.keel("[[ -e \"${@: -1}\" ]] && echo staged-exists >> '" + + join(self.dir, "keel.calls") + "'") + + fqdn.write_spec(self.spec, self.DOCUMENT) + + calls = self.read(self.calls).splitlines() + staged = calls[0].split()[-1] + self.assertEqual(calls[0], "spec validate --no-secret-files --spec " + + staged) + self.assertEqual(calls[1], "staged-exists") + self.assertNotEqual(staged, self.spec) + self.assertTrue(staged.startswith(self.spec + ".")) + self.assertFalse(exists(staged)) + self.assertEqual(yaml.safe_load(self.read(self.spec)), self.DOCUMENT) + + def test_a_document_keel_refuses_leaves_the_file_as_it_was(self): + with open(self.spec, "w") as fob: + fob.write("version: 1\n") + self.keel("echo 'Error: tls.acme.domains: domain is invalid' >&2\n" + "exit 3") + + with self.assertRaises(fqdn.FqdnError) as refused: + fqdn.write_spec(self.spec, self.DOCUMENT) + + self.assertIn("was NOT changed", str(refused.exception)) + self.assertIn("tls.acme.domains: domain is invalid", + str(refused.exception)) + self.assertEqual(self.read(self.spec), "version: 1\n") + self.assertEqual( + [f for f in os.listdir(self.dir) if f.startswith("instance.yaml")], + ["instance.yaml"]) + + def test_a_directory_that_cannot_be_written_is_an_error(self): + self.no_keel() + + with self.assertRaises(fqdn.FqdnError) as refused: + fqdn.write_spec(join(self.dir, "missing", "instance.yaml"), + self.DOCUMENT) + + self.assertIn("missing", str(refused.exception)) + + def test_a_file_that_cannot_be_replaced_is_an_error(self): + self.no_keel() + os.mkdir(self.spec) + + with self.assertRaises(fqdn.FqdnError): + fqdn.write_spec(self.spec, self.DOCUMENT) + + self.assertEqual(sorted(os.listdir(self.dir)), ["bin", "instance.yaml"]) + + +class TestWriteHosts(FileCase): + def test_the_entry_is_written_and_the_file_stays_world_readable(self): + with open(self.hosts, "w") as fob: + fob.write(DEBIAN_HOSTS) + os.chmod(self.hosts, 0o644) + + fqdn.write_hosts(self.hosts, "blog", "blog.example.org") + + self.assertEqual(self.read(self.hosts), fqdn.hosts_with_name( + DEBIAN_HOSTS, "blog", "blog.example.org")) + self.assertEqual(stat.S_IMODE(os.stat(self.hosts).st_mode), 0o644) + self.assertEqual(sorted(os.listdir(self.dir)), ["bin", "hosts"]) + + def test_a_missing_file_is_made_with_the_entry(self): + fqdn.write_hosts(self.hosts, "blog", "blog.example.org") + + self.assertEqual(self.read(self.hosts), + "127.0.1.1 blog.example.org blog\n") + self.assertEqual(stat.S_IMODE(os.stat(self.hosts).st_mode), 0o644) + + def test_a_file_that_cannot_be_written_is_an_error(self): + with self.assertRaises(fqdn.FqdnError): + fqdn.write_hosts(join(self.dir, "missing", "hosts"), "blog", + "blog.example.org") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fqdn_cli.py b/tests/test_fqdn_cli.py new file mode 100644 index 0000000..09b45f4 --- /dev/null +++ b/tests/test_fqdn_cli.py @@ -0,0 +1,382 @@ +"""bin/fqdn.py (run by firstboot.d/31fqdn) with the dialogs faked + +The screen asks the fully qualified domain name, prefilled with the name +the machine has, and prints HOSTNAME= and FQDN= for the hook; with --fqdn +(a preseeded FQDN) it asks nothing. With --record it writes what the hook +applied into /etc/hosts and the instance description. Every path is a +scratch file and keel is not on PATH, so nothing touches the live system. +""" + +import importlib.util +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from os.path import abspath, dirname, join +from unittest import mock + +import yaml + +from fake_dialog import ESC, OK, FakeConsole, load_wrapper + +dw = load_wrapper() + +FQDN_PY = join(dirname(dirname(abspath(__file__))), "bin", "fqdn.py") + + +def load_fqdn(): + spec = importlib.util.spec_from_file_location("fqdn_cli", FQDN_PY) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class FqdnCase(unittest.TestCase): + def setUp(self): + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + self.dir = scratch.name + self.spec = join(self.dir, "instance.yaml") + self.hosts = join(self.dir, "hosts") + with open(self.hosts, "w") as fob: + fob.write("127.0.0.1\tlocalhost\n127.0.1.1\tblog\n") + + def environ(self): + return {"INITHOOKS_DECL": self.spec, "INITHOOKS_HOSTS": self.hosts, + "PATH": join(self.dir, "nowhere")} + + def run_fqdn(self, *answers, argv=()): + """Run fqdn.py ARGV with the dialogs answering ANSWERS; returns + (exit status, stdout, stderr, the console)""" + cli = load_fqdn() + console = FakeConsole(*answers) + out, err = io.StringIO(), io.StringIO() + status = 0 + with ( + mock.patch.object(dw.dialog, "Dialog", return_value=console), + mock.patch.object(cli.signal, "signal"), + mock.patch.object(cli.sys, "argv", ["fqdn.py", *argv]), + mock.patch.dict(cli.os.environ, self.environ(), clear=True), + redirect_stdout(out), + redirect_stderr(err), + ): + try: + cli.main() + except SystemExit as stopped: + status = stopped.code + return status, out.getvalue(), err.getvalue(), console + + def read_spec(self): + with open(self.spec) as fob: + return yaml.safe_load(fob) + + def read_hosts(self): + with open(self.hosts) as fob: + return fob.read() + + +class TestAsk(FqdnCase): + def test_the_typed_name_is_printed_for_the_hook(self): + status, out, _, console = self.run_fqdn( + (OK, "blog.example.org"), argv=("--current=blog",)) + + self.assertEqual(status, 0) + self.assertEqual(out, "HOSTNAME=blog\nFQDN=blog.example.org\n") + self.assertEqual(console.widgets(), ["inputbox"]) + + def test_the_box_is_prefilled_with_the_current_hostname(self): + _, _, _, console = self.run_fqdn((OK, "blog.example.org"), + argv=("--current=blog",)) + + self.assertEqual(console.calls[0][3]["init"], "blog") + self.assertEqual(console.calls[0][3]["title"], "Domain name") + self.assertIn("blog.example.org", console.calls[0][1]) + self.assertEqual(console.calls[0][3]["ok_label"], "Apply") + self.assertTrue(console.calls[0][3]["no_cancel"]) + + def test_a_dotted_hostname_is_the_prefill_as_it_is(self): + _, out, _, console = self.run_fqdn( + (OK, "blog.example.org"), argv=("--current=blog.example.org",)) + + self.assertEqual(console.calls[0][3]["init"], "blog.example.org") + self.assertEqual(out, "HOSTNAME=blog\nFQDN=blog.example.org\n") + + def test_the_declared_fqdn_is_the_prefill_under_keel_init(self): + with open(self.spec, "w") as fob: + yaml.safe_dump({"version": 1, "instance": { + "hostname": "blog", "fqdn": "blog.example.org"}}, fob) + + _, _, _, console = self.run_fqdn((OK, "blog.example.org"), + argv=("--current=blog",)) + + self.assertEqual(console.calls[0][3]["init"], "blog.example.org") + + def test_an_empty_answer_keeps_what_the_machine_has(self): + status, out, _, console = self.run_fqdn((OK, " "), + argv=("--current=blog",)) + + self.assertEqual((status, out), (0, "")) + self.assertEqual(console.widgets(), ["inputbox"]) + + def test_a_name_that_is_not_a_domain_is_refused_and_asked_again(self): + _, out, _, console = self.run_fqdn( + (OK, "http://blog.example.org"), OK, (OK, "blog.example.org"), + argv=("--current=blog",)) + + self.assertEqual(console.widgets(), ["inputbox", "msgbox", "inputbox"]) + self.assertEqual(console.calls[1][3]["title"], "Error") + self.assertIn("http://blog.example.org", console.calls[1][1]) + # what was typed stays in the box to be corrected + self.assertEqual(console.calls[2][3]["init"], + "http://blog.example.org") + self.assertEqual(out, "HOSTNAME=blog\nFQDN=blog.example.org\n") + + def test_a_single_label_is_kept_as_the_hostname_after_a_notice(self): + status, out, _, console = self.run_fqdn( + (OK, "blog"), OK, argv=("--current=blog",)) + + self.assertEqual(status, 0) + self.assertEqual(console.widgets(), ["inputbox", "yesno"]) + notice = console.calls[1] + self.assertEqual(notice[3]["title"], "Domain name") + self.assertIn("no certificate", notice[1].lower()) + self.assertEqual((notice[3]["yes_label"], notice[3]["no_label"]), + ("Continue", "Back")) + self.assertEqual(out, "HOSTNAME=blog\nFQDN=\n") + + def test_back_from_the_notice_asks_again_with_the_label(self): + _, out, _, console = self.run_fqdn( + (OK, "blog"), "cancel", (OK, "blog.example.org"), + argv=("--current=web",)) + + self.assertEqual(console.widgets(), ["inputbox", "yesno", "inputbox"]) + self.assertEqual(console.calls[2][3]["init"], "blog") + self.assertEqual(out, "HOSTNAME=blog\nFQDN=blog.example.org\n") + + def test_escape_in_the_box_asks_again(self): + _, out, _, console = self.run_fqdn( + (ESC, ""), (OK, "blog.example.org"), argv=("--current=blog",)) + + self.assertEqual(console.widgets(), ["inputbox", "inputbox"]) + self.assertEqual(out, "HOSTNAME=blog\nFQDN=blog.example.org\n") + + def test_the_name_is_normalised_before_it_is_printed(self): + _, out, _, _ = self.run_fqdn((OK, " Blog.Example.org. "), + argv=("--current=blog",)) + + self.assertEqual(out, "HOSTNAME=blog\nFQDN=blog.example.org\n") + + def test_a_description_that_does_not_read_is_fatal(self): + with open(self.spec, "w") as fob: + fob.write("version: [1\n") + + status, out, err, console = self.run_fqdn(argv=("--current=blog",)) + + self.assertEqual((status, out), (1, "")) + self.assertIn("not valid YAML", err) + self.assertEqual(console.calls, []) + + +class TestPreseeded(FqdnCase): + def test_a_preseeded_fqdn_asks_nothing(self): + status, out, _, console = self.run_fqdn( + argv=("--fqdn=Blog.example.org", "--current=web")) + + self.assertEqual((status, out), (0, "HOSTNAME=blog\n" + "FQDN=blog.example.org\n")) + self.assertEqual(console.calls, []) + + def test_a_preseeded_single_label_is_the_hostname_alone(self): + status, out, _, _ = self.run_fqdn(argv=("--fqdn=blog",)) + + self.assertEqual((status, out), (0, "HOSTNAME=blog\nFQDN=\n")) + + def test_the_declared_hostname_goes_with_the_declared_fqdn(self): + # a described machine: 00declarative rendered both, 09hostname set + # the hostname, and this hook changes neither + with open(self.spec, "w") as fob: + yaml.safe_dump({"version": 1, "instance": { + "hostname": "wp", "fqdn": "blog.example.org"}}, fob) + + _, out, _, _ = self.run_fqdn(argv=("--fqdn=blog.example.org",)) + + self.assertEqual(out, "HOSTNAME=wp\nFQDN=blog.example.org\n") + + def test_a_preseeded_name_with_a_description_that_does_not_read(self): + with open(self.spec, "w") as fob: + fob.write("version: [1\n") + + status, _, err, _ = self.run_fqdn(argv=("--fqdn=blog.example.org",)) + + self.assertEqual(status, 1) + self.assertIn("not valid YAML", err) + + def test_a_preseeded_name_that_is_not_a_domain_is_fatal(self): + status, out, err, console = self.run_fqdn( + argv=("--fqdn=blog_1.example.org",)) + + self.assertEqual((status, out), (1, "")) + self.assertIn("blog_1.example.org", err) + self.assertEqual(console.calls, []) + + +class TestRecord(FqdnCase): + def test_the_description_is_written(self): + status, out, err, console = self.run_fqdn( + argv=("--record", "--hostname=blog", "--fqdn=blog.example.org")) + + self.assertEqual((status, out, err), (0, "", "")) + self.assertEqual(console.calls, []) + self.assertEqual(self.read_spec(), { + "version": 1, + "instance": {"hostname": "blog", "fqdn": "blog.example.org"}, + "tls": {"acme": {"domains": ["blog.example.org"]}}}) + # the hosts file is the --hosts step's, after the rename + self.assertEqual(self.read_hosts(), + "127.0.0.1\tlocalhost\n127.0.1.1\tblog\n") + + def test_the_rest_of_the_description_is_preserved(self): + with open(self.spec, "w") as fob: + yaml.safe_dump({"version": 1, "instance": {"hostname": "old"}, + "app": {"email": "a@example.org"}, + "tls": {"acme": {"enabled": True, + "domains": ["www.example.org"]}}}, + fob) + + self.run_fqdn(argv=("--record", "--hostname=blog", + "--fqdn=blog.example.org")) + + self.assertEqual(self.read_spec(), { + "version": 1, + "instance": {"hostname": "blog", "fqdn": "blog.example.org"}, + "app": {"email": "a@example.org"}, + "tls": {"acme": {"enabled": True, "domains": ["www.example.org"]}}}) + + def test_a_description_that_declares_the_name_is_not_written(self): + with open(self.spec, "w") as fob: + fob.write("# kept as the operator wrote it\n" + "version: 1\ninstance:\n hostname: wp\n" + " fqdn: blog.example.org\n" + "tls:\n acme:\n domains: [blog.example.org]\n") + + status, _, err, _ = self.run_fqdn( + argv=("--record", "--hostname=wp", "--fqdn=blog.example.org")) + + self.assertEqual(status, 0) + self.assertIn("already declares the name", err) + with open(self.spec) as fob: + self.assertTrue(fob.read().startswith("# kept")) + + def test_a_hostname_alone_is_recorded_without_a_domain(self): + # the options in the hook's order, --record last + self.run_fqdn(argv=("--hostname=blog", "--fqdn=", "--record")) + + self.assertEqual(self.read_spec(), + {"version": 1, "instance": {"hostname": "blog"}}) + + def test_a_description_that_cannot_be_written_is_fatal(self): + self.spec = join(self.dir, "missing", "instance.yaml") + + status, _, err, _ = self.run_fqdn( + argv=("--record", "--hostname=blog", "--fqdn=blog.example.org")) + + self.assertEqual(status, 1) + self.assertIn(self.spec, err) + + def test_a_description_that_does_not_read_is_fatal(self): + with open(self.spec, "w") as fob: + fob.write("version: [1\n") + + status, _, err, _ = self.run_fqdn( + argv=("--record", "--hostname=blog", "--fqdn=blog.example.org")) + + self.assertEqual(status, 1) + self.assertIn("not valid YAML", err) + + def test_record_needs_the_hostname(self): + status, _, err, _ = self.run_fqdn(argv=("--record",)) + + self.assertEqual(status, 1) + self.assertIn("--record needs --hostname", err) + + +class TestHosts(FqdnCase): + def test_the_entry_is_written(self): + status, out, err, console = self.run_fqdn( + argv=("--hosts", "--hostname=blog", "--fqdn=blog.example.org")) + + self.assertEqual((status, out, err), (0, "", "")) + self.assertEqual(console.calls, []) + self.assertEqual(self.read_hosts(), "127.0.0.1\tlocalhost\n" + "127.0.1.1 blog.example.org blog\n") + self.assertFalse(os.path.exists(self.spec)) + + def test_a_hostname_alone(self): + self.run_fqdn(argv=("--hosts", "--hostname=web", "--fqdn=")) + + self.assertEqual(self.read_hosts(), "127.0.0.1\tlocalhost\n" + "127.0.1.1\tblog\n127.0.1.1 web\n") + + def test_hosts_that_cannot_be_written_is_fatal(self): + os.remove(self.hosts) + os.mkdir(self.hosts) + + status, _, err, _ = self.run_fqdn( + argv=("--hosts", "--hostname=blog", "--fqdn=blog.example.org")) + + self.assertEqual(status, 1) + self.assertIn(self.hosts, err) + + def test_hosts_needs_the_hostname(self): + status, _, err, _ = self.run_fqdn(argv=("--hosts",)) + + self.assertEqual(status, 1) + self.assertIn("--hosts needs --hostname", err) + + def test_the_default_paths_are_the_machine_s(self): + cli = load_fqdn() + with mock.patch.dict(cli.os.environ, {}, clear=True): + self.assertEqual(cli.hosts_path(), "/etc/hosts") + self.assertEqual(cli.fqdn.spec_path(cli.os.environ, + exists=lambda p: False), + "/etc/keel/instance.yaml") + + +class TestUsage(FqdnCase): + def test_an_unknown_option_is_a_usage_error(self): + status, _, err, _ = self.run_fqdn(argv=("--nonsense",)) + + self.assertEqual(status, 1) + self.assertIn("Syntax", err) + + def test_help(self): + status, _, err, _ = self.run_fqdn(argv=("--help",)) + + self.assertEqual(status, 1) + self.assertIn("--record", err) + + def test_an_argument_is_a_usage_error(self): + status, _, err, _ = self.run_fqdn(argv=("blog.example.org",)) + + self.assertEqual(status, 1) + self.assertIn("Syntax", err) + + def test_the_script_is_its_own_entry_point(self): + import runpy + + err = io.StringIO() + with ( + mock.patch.object(sys, "argv", ["fqdn.py", "--help"]), + redirect_stderr(err), + self.assertRaises(SystemExit) as stopped, + ): + runpy.run_path(FQDN_PY, run_name="__main__") + + self.assertEqual(stopped.exception.code, 1) + self.assertIn("Syntax", err.getvalue()) + + +if __name__ == "__main__": + unittest.main()