Recipes for the certbot bootstrap + renewal scripts.
These build the check/run payloads of the certbot script resources a
TLS-terminating host declares:
- certbot_standalone_issue_core — issues the first certificate via
certbot certonly --standalone, stopping nginx first to free port 80; the
recipe_debian_certbot.ts / recipe_alpine_certbot.ts wrappers
({debian,alpine}_certbot_standalone_issue) pass the service-manager stop
command, the one step that differs by distro. - certbot_webroot_switch — re-issues once via
--webroot to pin the
unattended renewal authenticator to webroot (so renewal works while nginx
holds port 80), then runs the deploy hook it is given, so the server picks up
the re-issued certificate.
Both take extra_domains for the names beside the primary one a cert covers
(the www. alias a site redirects), matching the nginx site recipe's
redirect_domains.
Both scripts that lead to a request to the CA — the first issuance and the
webroot switch — run a DNS preflight before they change anything: each name the
certificate covers must resolve, on the target, only to expected_addresses (by
default the target's DEPLOY_HOST). A name that doesn't resolve, or resolves
anywhere else, refuses the script with the reason on stderr, so a host whose DNS
isn't pointed at it yet doesn't spend Let's Encrypt's failed-validation limit. The
issuance stops nginx inside the same run, after the preflight passes, so a
refusal never leaves nginx stopped, and the stop runs only once the names the
request covers have passed. The preflight reads the target's resolver, not the CA's:
see CertbotDnsPreflightOptions.expected_addresses for what that can't know.
Renewal scheduling also differs by distro: Debian's certbot package installs
certbot.timer automatically; Alpine has none, so recipe_alpine_certbot.ts
supplies alpine_certbot_renew_cron.
Neither schedule reloads anything after a renewal, so a server keeps serving the
old certificate until it restarts. certbot_deploy_hook_core builds the file that
closes that gap: an executable in CERTBOT_DEPLOY_HOOKS_DIR, which certbot runs
after every certificate it renews, whatever started the renewal. The distro
wrappers (debian_certbot_reload_nginx_hook, alpine_certbot_reload_nginx_hook)
pass the reload command.
The scripts embed references to DEPLOY_DOMAIN / DEPLOY_CERTBOT_EMAIL
(env_ref) that stay inert in the plan and are resolved in zap's Rust parent
at apply time. The domain/email options default to those references so the
typical caller passes nothing. Every value is one single-quoted word of the
command (single_quote), so a value — a reference's or a literal — can't change
what the command runs.
Recipes are imported at config eval time inside zap's sandboxed config eval
(fgro eval), which has no network, no writes, and no environment, and where
the clock and randomness throw. This module is therefore a set of pure functions: each only
assembles strings from its arguments — no I/O, no clock, no randomness, and no
import beyond types, env_ref, single_quote and validate_run_parts_name,
themselves pure. The output is deterministic for given inputs, so it
stays a reliable content-addressed plan artifact.
The caller spreads each script result into a script resource, e.g.
zap.script({id: 'ssl-certificate', name: '...', ...debian_certbot_standalone_issue(), depends_on: [...]}),
and a deploy hook into a file, e.g.
zap.file({id: 'certbot-deploy-hook', name: '...', ...hook, depends_on: ['certbot']}) with
const hook = debian_certbot_reload_nginx_hook(), which the webroot switch also takes
(certbot_webroot_switch({deploy_hook: hook}), its script depending on the hook's file).