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
24 changes: 14 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,13 @@ Notable changes to Cloudfall. The format follows
`observe`, `secrets render`, `import render`, `import render-api`,
`deploy`, `rollback`, `restart` and `data migrate` (treaty #183 lets
their camelCase keys through). Each keeps its `data` keys, `status`
included, and adds `effect`. `deploy`, `rollback`, `restart` and
`data migrate` still change nothing without `--yes`; their plan answers
`effect: noop` and writes the result keys as `null`, and the result
included; the ones that write add `effect`. `deploy`, `rollback`,
`restart` and `data migrate` still change nothing without `--yes`: that
run is treaty's dry run (`effect: would_update`, `meta.dry_run: true`,
treaty #197). The plan writes the result keys as `null`, and the result
writes the plan keys (`wouldRun`, `instruction`) as `null`, since treaty
answers one object per command. `observe` exits 91 `INCOMPLETE` (was 1)
answers one object per command. `observe` is read-only (it declares the
snapshots it writes under the project) and exits 91 `INCOMPLETE` (was 1)
with the run in `data` when a server gave no snapshot. Engine steps,
sops and the Render API go through treaty: `doctor` checks sops, and
`import render-api` honours `--proxy` and marks its answer as content
Expand All @@ -109,8 +111,9 @@ Notable changes to Cloudfall. The format follows
`operator approve` and `migrate`. Ansible runs through treaty in the
project directory, so `--timeout` and Ctrl-C stop it, and `doctor` checks
ansible-playbook. `operations approve` and `migrate` still change nothing
without `--yes`; that preview answers `effect: noop` with
`data.status` `pending` or `plan`. New exit codes, each with the result
without `--yes`; that preview is treaty's dry run, `effect: would_update`
and `meta.dry_run: true`, with `data.status` `pending` or `plan`. New exit
codes, each with the result
in `data`: 87 `ENGINE_STEP_FAILED` (an inventory render or playbook
failed, was 1), 88 `UNHEALTHY` (was 1), 89 `CHECK_FAILED` when check mode
fails in `propose` (was 1), 90 `NOT_VERIFIED` when an approved run fails
Expand All @@ -122,9 +125,10 @@ Notable changes to Cloudfall. The format follows
- **Breaking:** six more `cloudfall` commands run on treaty: `init`,
`add ssh-key`, `add server-type`, `add server`, `dashboard build` and
`services inspect`. Their answer is the treaty envelope described below,
with the same `data` keys plus `effect` (`created`, or `updated` for a
dashboard written over an older one), which treaty requires of every
command that writes. Failures: 6 `CONFLICT` for a resource that already
with the same `data` keys, plus `effect: created` on `init` and `add`,
which treaty requires of a command that changes the project.
`dashboard build` and `services inspect` stay read-only and declare the
reports they regenerate under `tmp/`. Failures: 6 `CONFLICT` for a resource that already
exists (was 2), 7 `PERMISSION_DENIED` for a read-only project or path
(was 1 or 2), 4 `PRECONDITION` for a non-empty `init` directory (was 2),
5 `NOT_FOUND` for a missing key file, 2 `ARG_ERROR` for a bad id, name or
Expand All @@ -151,7 +155,7 @@ Notable changes to Cloudfall. The format follows
is refused, since treaty's `--output` names a file. Relative paths still
resolve against the project, without changing the working directory
- `cloudfall-engine` runs Ansible and git through treaty's `ctx.run`
(treaty 1.0.0rc12), so `--timeout` and Ctrl-C stop the whole process
(treaty 1.0.0rc13), so `--timeout` and Ctrl-C stop the whole process
group, and secrets are redacted from the play log. The play log streams
to stderr as plain text, as before, terminal or not; `--quiet` silences
it. A failed playbook carries the last 4096 characters of the log in
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ dependencies = [
"ansible-core>=2.21,<2.22",
"jsonschema[format]>=4.23,<5",
"pyyaml>=6,<7",
"treaty==1.0.0rc12",
"treaty==1.0.0rc13",
]

[project.urls]
Expand Down
98 changes: 54 additions & 44 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
Flag,
Out,
ParseError,
SideEffect,
Subprocess,
Timeout,
)
Expand Down Expand Up @@ -89,6 +90,7 @@
LinuxUser,
ReleaseId,
ResourceId,
ResourceKind,
TcpPort,
)
from cloudfall.importer import (
Expand Down Expand Up @@ -207,6 +209,8 @@
)
# 79 and 80 mean what they mean on cloudfall-engine; 81 and 82 stay the
# engine's (artifact build, playbook run) for the commands that wrap it.
PROJECT_MARKERS = tuple(kind.directory for kind in ResourceKind)
"""A project holds at least one resource kind directory, such as ``servers``."""
GIT_TIMEOUT_SECONDS = 30
"""How long one git step of ``init`` may take."""
_NOT_WRITABLE = frozenset({errno.EACCES, errno.EPERM, errno.EROFS})
Expand Down Expand Up @@ -1275,23 +1279,20 @@ def __post_init__(self) -> None:
_inside_project(self.output_dir, "output-dir")


@dataclass(frozen=True, slots=True)
class DashboardBuilt:
"""The static dashboard written, and the fleet health it shows."""
class DashboardPayload(Payload):
"""``dashboard build``: the pages written, and the fleet health they show."""

effect: str
status: str
health: str
tasks: int
dashboard: dict[str, str]
command = "dashboard build"


@dashboard.command(
"build",
description="Build a static read-only operations dashboard",
danger_level="mutating",
danger_level="safe",
timeout=120,
supports_raw_payload=True,
project_root=PROJECT_MARKERS,
filesystem_side_effects=[SideEffect("{project_root}/tmp/dashboard/", "cache")],
exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PERMISSION_DENIED"],
examples=[
(
Expand All @@ -1302,23 +1303,22 @@ class DashboardBuilt:
)
def dashboard_build(
args: DashboardBuildArgs, _ctx: Ctx, fleet: Fleet
) -> DashboardBuilt:
) -> DashboardPayload:
"""Write the dashboard pages for the fleet's current evidence."""
view = args.operations_view(fleet)
output = fleet.path(args.output_dir)
effect = "updated" if output.exists() else "created"
try:
artifacts = build_dashboard(view, output)
artifacts = build_dashboard(view, fleet.path(args.output_dir))
except OSError as error:
if error.errno not in _NOT_WRITABLE:
raise
raise _not_writable(error) from error
return DashboardBuilt(
effect=effect,
status="ok",
health=view.health.value,
tasks=len(view.tasks),
dashboard=artifacts.as_dict(),
return DashboardPayload(
{
"status": "ok",
"health": view.health.value,
"tasks": len(view.tasks),
"dashboard": artifacts.as_dict(),
}
)


Expand All @@ -1336,27 +1336,28 @@ def __post_init__(self) -> None:
_inside_project(self.output_dir, "output-dir")


@dataclass(frozen=True, slots=True)
class ServicesInspected:
"""The domain observation files written."""
class InspectedPayload(Payload):
"""``services inspect``: the domain observation files written."""

effect: str
status: str
observations: list[str] = Out(ordered=True)
command = "services inspect"


@services.command(
"inspect",
description="Collect DNS, TLS, origin, and public route evidence",
danger_level="mutating",
danger_level="safe",
# DNS, TLS, and HTTP probes of every declared domain, one after another.
timeout=600,
project_root=PROJECT_MARKERS,
filesystem_side_effects=[
SideEffect("{project_root}/tmp/observed-services/", "cache")
],
exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PERMISSION_DENIED"],
examples=[("Probe every declared domain", "cloudfall services inspect")],
)
def services_inspect(
args: ServicesInspectArgs, _ctx: Ctx, fleet: Fleet
) -> ServicesInspected:
) -> InspectedPayload:
"""Probe each declared domain and write one observation per domain."""
try:
paths = inspect_domains(
Expand All @@ -1369,10 +1370,8 @@ def services_inspect(
if error.errno not in _NOT_WRITABLE:
raise
raise _not_writable(error) from error
return ServicesInspected(
effect="created",
status="ok",
observations=[str(path) for path in paths],
return InspectedPayload(
{"status": "ok", "observations": [str(path) for path in paths]}
)


Expand Down Expand Up @@ -1699,6 +1698,7 @@ class ApproveDecisionArgs(DecisionsArgs):
)
yes: bool = Flag(
default=False,
confirm=True,
description=(
"Change the servers; without it the command shows the recorded "
"proposal and runs nothing"
Expand Down Expand Up @@ -1805,10 +1805,9 @@ def operations_approve(args: ApproveDecisionArgs, ctx: Ctx) -> Decided:
raise Exit.NOT_FOUND(error.detail, context={"code": error.code}) from error
raise _record_invalid(error) from error
if not args.yes:
# Nothing ran. treaty reads would_* only from a dry run it switched,
# and --yes is the inverse of its --dry-run (treaty #197).
# Without --yes the run is treaty's dry run (Flag(confirm=True)).
return Decided(
effect="noop",
effect="would_update",
status="pending",
decision=decision.as_document(),
next=[
Expand Down Expand Up @@ -2030,6 +2029,7 @@ class MigrateArgs(EngineArgs):
)
yes: bool = Flag(
default=False,
confirm=True,
description=(
"Change the servers; without it the command prints the plan and "
"runs nothing"
Expand Down Expand Up @@ -2150,8 +2150,8 @@ def migrate(args: MigrateArgs, ctx: Ctx, fleet: Fleet) -> Migration:
) from error
status = str(result["status"])
migration = Migration(
# A plan changes nothing: noop, as without --yes in `operations approve`.
effect="noop" if status == "plan" else "updated",
# A plan is the dry run --yes confirms.
effect="would_update" if status == "plan" else "updated",
status=status,
steps=list(cast("list[dict[str, object]]", result["steps"])),
completed=int(cast("int", result["completed"])),
Expand Down Expand Up @@ -2259,7 +2259,7 @@ def why(args: WhyArgs, _ctx: Ctx) -> WhyPayload:
"Some servers produced no snapshot or Ansible failed; data says which"
),
retryable=True,
# The hosts are only read; a retry rewrites the snapshots it wrote.
# observe only reads the hosts; a retry rewrites the snapshots it wrote.
side_effects="none",
suggestion="read data.missing and data.detail, fix the hosts, then observe again",
)
Expand Down Expand Up @@ -2290,7 +2290,6 @@ class ObservedPayload(Payload):
"""``observe``: which servers produced a snapshot."""

command = "observe"
writes = True


def _playbook_runner(
Expand All @@ -2311,7 +2310,14 @@ def run(argv: Sequence[str], environment: Mapping[str, str]) -> PlaybookRun:
"Collect read-only server snapshots from the fleet; servers without one "
"exit non-zero with the run in data"
),
danger_level="mutating",
# It only reads the hosts; its snapshots and the inventory overlay are
# regenerated under the project on every run.
danger_level="safe",
project_root=PROJECT_MARKERS,
filesystem_side_effects=[
SideEffect("{project_root}/tmp/observed/", "cache"),
SideEffect("{project_root}/tmp/cloudfall/", "cache"),
],
exit_codes=[
"PROJECT_INVALID",
"CONFIG_INVALID",
Expand All @@ -2334,7 +2340,6 @@ def observe(args: ObserveArgs, ctx: Ctx, fleet: Fleet) -> ObservedPayload:
else (fleet.directory / "tmp/ansible-inventory.json",)
)
output = fleet.path(args.output_dir)
effect = "updated" if output.exists() else "created"
try:
request = ObservationRequest(
inventory_sources=sources,
Expand All @@ -2353,7 +2358,7 @@ def observe(args: ObserveArgs, ctx: Ctx, fleet: Fleet) -> ObservedPayload:
)
except ObserveError as error:
raise Exit.PRECONDITION(error.detail, context={"code": error.code}) from error
payload = ObservedPayload({**result.as_dict(), "effect": effect})
payload = ObservedPayload(result.as_dict())
if not result.complete:
message = f"{len(result.missing)} server(s) produced no snapshot"
raise Exit.INCOMPLETE(message, data=payload)
Expand Down Expand Up @@ -2657,6 +2662,7 @@ class ComponentArgs(EngineArgs):
component: ResourceId = Arg(description="Component to act on")
yes: bool = Flag(
default=False,
confirm=True,
description=(
"Change the servers; without it the command validates the request "
"and only shows what it would do"
Expand Down Expand Up @@ -2718,6 +2724,7 @@ class DataMigrateArgs(EngineArgs):
)
yes: bool = Flag(
default=False,
confirm=True,
description=(
"Change the servers; without it the command validates the request "
"and only shows what it would do"
Expand Down Expand Up @@ -2759,9 +2766,12 @@ class DataMigratePayload(Payload):


def _plan(preview: LifecyclePreview) -> dict[str, object]:
# Nothing ran: noop, as treaty reads would_* only from its own dry run
# (treaty #197).
return {**preview.as_dict(), "instruction": _PLAN_INSTRUCTION, "effect": "noop"}
# Without --yes the run is treaty's dry run (Flag(confirm=True)).
return {
**preview.as_dict(),
"instruction": _PLAN_INSTRUCTION,
"effect": "would_update",
}


_LIFECYCLE_EXIT_CODES = [
Expand Down
3 changes: 2 additions & 1 deletion sdk/tests/test_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,8 @@ def test_the_cli_shows_the_proposal_before_it_runs_anything(
payload = json.loads(capsys.readouterr().out)
assert exit_code == 0
assert payload["data"]["status"] == "pending"
assert payload["data"]["effect"] == "noop"
assert payload["data"]["effect"] == "would_update"
assert payload["meta"]["dry_run"] is True
assert payload["data"]["decision"]["spec"]["status"] == "proposed"
assert store.load(proposed.decision_id).status is DecisionStatus.PROPOSED

Expand Down
9 changes: 6 additions & 3 deletions sdk/tests/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ def test_cli_server_changes_show_a_plan_without_yes(

assert exit_code == 0
assert payload["data"]["status"] == "plan"
assert payload["data"]["effect"] == "noop"
assert payload["data"]["effect"] == "would_update"
assert payload["meta"]["dry_run"] is True
assert payload["data"]["action"] == action
assert payload["data"]["component"] == "crm-backend"
assert payload["data"]["servers"]
Expand Down Expand Up @@ -281,7 +282,8 @@ def test_cli_deploy_plan_verifies_the_artifact(

assert exit_code == 0
assert payload["data"]["status"] == "plan"
assert payload["data"]["effect"] == "noop"
assert payload["data"]["effect"] == "would_update"
assert payload["meta"]["dry_run"] is True
assert payload["data"]["release"] == RELEASE
would_run = str(payload["data"]["wouldRun"])
assert f"release {RELEASE} of component crm-backend" in would_run
Expand Down Expand Up @@ -323,7 +325,8 @@ def test_cli_data_migration_plan_checks_the_source_url_file(
exit_code, payload = _cli_lifecycle(argv, tmp_path, capsys)
assert exit_code == 0
assert payload["data"]["status"] == "plan"
assert payload["data"]["effect"] == "noop"
assert payload["data"]["effect"] == "would_update"
assert payload["meta"]["dry_run"] is True
assert payload["data"]["service"] == "postgresql-main"
assert "u:p@" not in json.dumps(payload)

Expand Down
3 changes: 2 additions & 1 deletion sdk/tests/test_migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ def test_cli_migrate_previews_without_executing(
payload = json.loads(captured.out)
assert exit_code == 0
assert payload["data"]["status"] == "plan"
assert payload["data"]["effect"] == "noop"
assert payload["data"]["effect"] == "would_update"
assert payload["meta"]["dry_run"] is True
assert payload["data"]["next"] == "baseline"


Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading