Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/117-domains-apply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- `cloudfall domains apply <domain> [--issue-certificate] [--yes]` converges one Domain: `domains.yml` runs on its proxy server alone and renders only it, so no baseline, firewall or service task runs. Without `--yes` it exits 0 with a plan. `--issue-certificate` refuses a Domain with `tls.mode: disabled`, and first checks on the controller that the primary name and every alias resolve to the proxy server (`PRECONDITION`, `domains_dns_unverified`, naming each failing name); a `proxied` Domain skips the check (`dnsCheck: "skipped-proxied"`). With `--yes` it answers the receipt path under `--deployments` (#117)
68 changes: 68 additions & 0 deletions engine/tests/test_domains_certificate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""``domains apply --issue-certificate``: the role's side (spec S-004).

Real issuance needs Let's Encrypt and a public name, so the ``when``
conditions of the certificate tasks are evaluated with Ansible's own
templating, and the receipt tasks run for real twice on localhost.
"""

from __future__ import annotations

from pathlib import Path

from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar, trust_as_template
from test_domains_one_domain import RENDERED, _run, _sha256

ROLES = Path(__file__).parents[2] / "engine" / "ansible" / "roles"
ROLE_TASKS = ROLES / "cloudfall_nginx_site" / "tasks" / "main.yml"


def _when(name: str, variables: dict[str, object]) -> bool:
"""Return whether the named task of the role runs under ``variables``."""
tasks = DataLoader().load_from_file(str(ROLE_TASKS))
assert isinstance(tasks, list)
(task,) = (task for task in tasks if task["name"] == name)
conditions = task["when"]
templar = Templar(loader=DataLoader(), variables=variables)
return all(
templar.evaluate_conditional(trust_as_template(condition))
for condition in (conditions if isinstance(conditions, list) else [conditions])
)


def _variables(*, certificate_exists: bool) -> dict[str, object]:
return {
"ansible_check_mode": False,
"cloudfall_nginx_site_issue_certificate": True,
"cloudfall_nginx_site_domain": {"tls": {"mode": "required"}},
"cloudfall_nginx_site_certificate": {"stat": {"exists": certificate_exists}},
}


# proves: S-004-9 (issuance runs on a fresh Domain, and the timer is enabled)
def test_s004_9_a_fresh_domain_issues_and_enables_the_renewal_timer() -> None:
fresh = _variables(certificate_exists=False)

assert _when("Issue the declared certificate", fresh)
assert _when("Enable the certificate renewal timer", fresh)


def test_s004_10_a_second_run_skips_issuance() -> None:
issued = _variables(certificate_exists=True)

assert not _when("Issue the declared certificate", issued)
assert _when("Enable the certificate renewal timer", issued)


def test_s004_10_a_second_run_records_the_vhost_it_rendered_before(
tmp_path: Path,
) -> None:
"""The second render writes what the first left, so the hashes agree."""
vhost = tmp_path / "crm.example.test.conf"

first = _run(tmp_path, vhost)
second = _run(tmp_path, vhost)

assert first["previousConfigurationSha256"] is None
assert second["previousConfigurationSha256"] == _sha256(RENDERED)
assert second["configurationSha256"] == second["previousConfigurationSha256"]
17 changes: 12 additions & 5 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,18 @@ uv run cloudfall restart --project config/examples crm-backend --yes
uv run cloudfall health --project config/examples crm-backend
```

`deploy`, `rollback`, `restart`, and `data copy` change servers only with
`--yes`. Without it they run every controller-side check (declared component,
verified artifact, declared database, source URL file) and print a `plan`
naming the target servers, exit `0`, so a request can be reviewed before it
runs.
`deploy`, `rollback`, `restart`, `data copy`, and `domains apply` change
servers only with `--yes`. Without it they run every controller-side check
(declared component, verified artifact, declared database, source URL file,
DNS for a certificate) and print a `plan` naming the target servers, exit `0`,
so a request can be reviewed before it runs.

`domains apply <domain>` runs `domains.yml` on the Domain's proxy server
alone and renders only that Domain, so no baseline, firewall or service task
runs; it is safe on a server whose baseline another project owns. With
`--issue-certificate` it first checks that the Domain's names resolve to the
proxy server (a `proxied` Domain skips the check), then issues the certificate
when none exists yet.

`deploy` refuses to run when the built artifact is missing, misidentified, or
fails digest verification, and refuses to report success if the engine wrote
Expand Down
94 changes: 94 additions & 0 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,16 @@
ERROR_EXECUTION_FAILED,
STEP_TIMEOUT_SECONDS,
DeployOptions,
DomainApply,
DomainsRun,
EngineContext,
ExecutionStep,
LifecycleError,
LifecyclePreview,
StepRun,
StepRunner,
SystemNameResolver,
apply_domain,
backup_service,
build_release_artifact,
converge_domains,
Expand All @@ -182,6 +185,7 @@
migrate_data,
preview_data_migration,
preview_deploy,
preview_domain_apply,
preview_restart,
preview_rollback,
restart,
Expand Down Expand Up @@ -4052,6 +4056,96 @@ def data_copy(
return DataMigratePayload({**body, "effect": "updated"})


domains = app.group(
"domains", description="Converge declared Domains on their proxy servers"
)


@dataclass(frozen=True, slots=True, kw_only=True)
class DomainApplyArgs(EngineArgs):
"""Arguments of ``domains apply``."""

domain: ResourceId = Arg(description="Domain to apply, by its metadata.id")
issue_certificate: bool = Flag(
default=False,
description=(
"Check on the controller that every name of the Domain resolves to "
"its proxy server, then issue its certificate when none exists yet"
),
)
deployments: Path = Flag(
default=Path("tmp/deployments"),
description="Deployment receipt directory (default: tmp/deployments)",
)
yes: bool = Flag(
default=False,
confirm=True,
description=(
"Change the proxy server; without it the command validates the "
"request and only shows what it would do"
),
)

def __post_init__(self) -> None:
"""Keep relative paths inside the project."""
EngineArgs.__post_init__(self)
_inside_project(self.deployments, "deployments")


class DomainApplyPayload(Payload):
"""``domains apply``: the plan without --yes, the receipt with it."""

command = "domains apply"
writes = True


@domains.command(
"apply",
description=(
"Render one Domain's virtual host on its proxy server alone, issuing "
"its certificate with --issue-certificate; without --yes it shows the plan"
),
danger_level="mutating",
exit_codes=_LIFECYCLE_EXIT_CODES,
timeout=None,
has_network_io=True,
subprocess=Subprocess("ansible-playbook"),
required_tools={"ansible-playbook": "2.21.0"},
supports_raw_payload=True,
examples=[
("Show the plan", "cloudfall domains apply crm-site"),
(
"Apply it and issue its certificate",
"cloudfall domains apply crm-site --issue-certificate --yes",
),
],
)
def domains_apply(
args: DomainApplyArgs, ctx: Ctx, fleet: Fleet
) -> DomainApplyPayload:
"""Converge the Domain on its proxy server alone, or show the plan."""
context = args.context(fleet, ctx)
request = DomainApply(
domain_id=args.domain,
issue_certificate=args.issue_certificate,
receipt_directory=fleet.path(args.deployments),
)
try:
if not args.yes:
preview = preview_domain_apply(context, request, SystemNameResolver())
return DomainApplyPayload(
{
**preview.as_dict(),
"instruction": _PLAN_INSTRUCTION,
"effect": "would_update",
}
)
applied = apply_domain(context, request, SystemNameResolver())
except LifecycleError as error:
raise _lifecycle_failed(error) from error
return DomainApplyPayload({**applied.as_dict(), "effect": "updated"})


# mcp serve: the fleet check before serving, and the tools beside the commands


Expand Down
27 changes: 24 additions & 3 deletions sdk/src/cloudfall/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ class OutputKey:
**dict.fromkeys(
(
"action", "application", "component", "database", "detail",
"directory", "environmentFile", "health", "instruction", "output",
"pass", "path", "project", "receipt", "release", "report",
"service", "sha256", "status", "step", "verification", "wouldRun",
"directory", "dnsCheck", "domain", "environmentFile", "health",
"instruction", "output", "pass", "path", "project", "receipt",
"release", "report", "service", "sha256", "status", "step",
"verification", "wouldRun",
),
"string",
),
Expand Down Expand Up @@ -523,6 +524,26 @@ def _plan(subject: str, *extra: str) -> OutputShape:
),
),
),
CommandContract(
"cloudfall",
"domains apply",
CommandEffect.SERVERS,
"render one Domain's virtual host, and its certificate on request, on "
"its proxy server alone; no baseline, firewall or service task runs",
gate=_YES,
output=(
_plan("domain", "dnsCheck?"),
_shape(
"status",
"action",
"domain",
"servers",
"dnsCheck?",
"receipt",
when="with `--yes`; `dnsCheck` only with `--issue-certificate`",
),
),
),
CommandContract(
"cloudfall",
"migrate",
Expand Down
Loading
Loading