Skip to content

Commit 082c748

Browse files
romamoclaude
andcommitted
feat: treaty 1.0.0rc13; --yes previews are dry runs, reports declared
rc13 lands treaty #197 and #184. --yes is Flag(confirm=True) on operations approve, migrate, deploy, rollback, restart and data migrate, so a run without it is treaty's dry run: would_update and meta.dry_run, where it answered noop. dashboard build, services inspect and observe are safe again: they only read the fleet and declare the reports they regenerate under {project_root}/tmp/, with the resource kind directories as project markers, so their answers drop effect and observe's retryable INCOMPLETE no longer draws the audit warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5292b3b commit 082c748

7 files changed

Lines changed: 83 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,13 @@ Notable changes to Cloudfall. The format follows
8484
`observe`, `secrets render`, `import render`, `import render-api`,
8585
`deploy`, `rollback`, `restart` and `data migrate` (treaty #183 lets
8686
their camelCase keys through). Each keeps its `data` keys, `status`
87-
included, and adds `effect`. `deploy`, `rollback`, `restart` and
88-
`data migrate` still change nothing without `--yes`; their plan answers
89-
`effect: noop` and writes the result keys as `null`, and the result
87+
included; the ones that write add `effect`. `deploy`, `rollback`,
88+
`restart` and `data migrate` still change nothing without `--yes`: that
89+
run is treaty's dry run (`effect: would_update`, `meta.dry_run: true`,
90+
treaty #197). The plan writes the result keys as `null`, and the result
9091
writes the plan keys (`wouldRun`, `instruction`) as `null`, since treaty
91-
answers one object per command. `observe` exits 91 `INCOMPLETE` (was 1)
92+
answers one object per command. `observe` is read-only (it declares the
93+
snapshots it writes under the project) and exits 91 `INCOMPLETE` (was 1)
9294
with the run in `data` when a server gave no snapshot. Engine steps,
9395
sops and the Render API go through treaty: `doctor` checks sops, and
9496
`import render-api` honours `--proxy` and marks its answer as content
@@ -109,8 +111,9 @@ Notable changes to Cloudfall. The format follows
109111
`operator approve` and `migrate`. Ansible runs through treaty in the
110112
project directory, so `--timeout` and Ctrl-C stop it, and `doctor` checks
111113
ansible-playbook. `operations approve` and `migrate` still change nothing
112-
without `--yes`; that preview answers `effect: noop` with
113-
`data.status` `pending` or `plan`. New exit codes, each with the result
114+
without `--yes`; that preview is treaty's dry run, `effect: would_update`
115+
and `meta.dry_run: true`, with `data.status` `pending` or `plan`. New exit
116+
codes, each with the result
114117
in `data`: 87 `ENGINE_STEP_FAILED` (an inventory render or playbook
115118
failed, was 1), 88 `UNHEALTHY` (was 1), 89 `CHECK_FAILED` when check mode
116119
fails in `propose` (was 1), 90 `NOT_VERIFIED` when an approved run fails
@@ -122,9 +125,10 @@ Notable changes to Cloudfall. The format follows
122125
- **Breaking:** six more `cloudfall` commands run on treaty: `init`,
123126
`add ssh-key`, `add server-type`, `add server`, `dashboard build` and
124127
`services inspect`. Their answer is the treaty envelope described below,
125-
with the same `data` keys plus `effect` (`created`, or `updated` for a
126-
dashboard written over an older one), which treaty requires of every
127-
command that writes. Failures: 6 `CONFLICT` for a resource that already
128+
with the same `data` keys, plus `effect: created` on `init` and `add`,
129+
which treaty requires of a command that changes the project.
130+
`dashboard build` and `services inspect` stay read-only and declare the
131+
reports they regenerate under `tmp/`. Failures: 6 `CONFLICT` for a resource that already
128132
exists (was 2), 7 `PERMISSION_DENIED` for a read-only project or path
129133
(was 1 or 2), 4 `PRECONDITION` for a non-empty `init` directory (was 2),
130134
5 `NOT_FOUND` for a missing key file, 2 `ARG_ERROR` for a bad id, name or
@@ -151,7 +155,7 @@ Notable changes to Cloudfall. The format follows
151155
is refused, since treaty's `--output` names a file. Relative paths still
152156
resolve against the project, without changing the working directory
153157
- `cloudfall-engine` runs Ansible and git through treaty's `ctx.run`
154-
(treaty 1.0.0rc12), so `--timeout` and Ctrl-C stop the whole process
158+
(treaty 1.0.0rc13), so `--timeout` and Ctrl-C stop the whole process
155159
group, and secrets are redacted from the play log. The play log streams
156160
to stderr as plain text, as before, terminal or not; `--quiet` silences
157161
it. A failed playbook carries the last 4096 characters of the log in

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ dependencies = [
2020
"ansible-core>=2.21,<2.22",
2121
"jsonschema[format]>=4.23,<5",
2222
"pyyaml>=6,<7",
23-
"treaty==1.0.0rc12",
23+
"treaty==1.0.0rc13",
2424
]
2525

2626
[project.urls]

‎sdk/src/cloudfall/app.py‎

Lines changed: 54 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
Flag,
3636
Out,
3737
ParseError,
38+
SideEffect,
3839
Subprocess,
3940
Timeout,
4041
)
@@ -89,6 +90,7 @@
8990
LinuxUser,
9091
ReleaseId,
9192
ResourceId,
93+
ResourceKind,
9294
TcpPort,
9395
)
9496
from cloudfall.importer import (
@@ -207,6 +209,8 @@
207209
)
208210
# 79 and 80 mean what they mean on cloudfall-engine; 81 and 82 stay the
209211
# engine's (artifact build, playbook run) for the commands that wrap it.
212+
PROJECT_MARKERS = tuple(kind.directory for kind in ResourceKind)
213+
"""A project holds at least one resource kind directory, such as ``servers``."""
210214
GIT_TIMEOUT_SECONDS = 30
211215
"""How long one git step of ``init`` may take."""
212216
_NOT_WRITABLE = frozenset({errno.EACCES, errno.EPERM, errno.EROFS})
@@ -1275,23 +1279,20 @@ def __post_init__(self) -> None:
12751279
_inside_project(self.output_dir, "output-dir")
12761280

12771281

1278-
@dataclass(frozen=True, slots=True)
1279-
class DashboardBuilt:
1280-
"""The static dashboard written, and the fleet health it shows."""
1282+
class DashboardPayload(Payload):
1283+
"""``dashboard build``: the pages written, and the fleet health they show."""
12811284

1282-
effect: str
1283-
status: str
1284-
health: str
1285-
tasks: int
1286-
dashboard: dict[str, str]
1285+
command = "dashboard build"
12871286

12881287

12891288
@dashboard.command(
12901289
"build",
12911290
description="Build a static read-only operations dashboard",
1292-
danger_level="mutating",
1291+
danger_level="safe",
12931292
timeout=120,
12941293
supports_raw_payload=True,
1294+
project_root=PROJECT_MARKERS,
1295+
filesystem_side_effects=[SideEffect("{project_root}/tmp/dashboard/", "cache")],
12951296
exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PERMISSION_DENIED"],
12961297
examples=[
12971298
(
@@ -1302,23 +1303,22 @@ class DashboardBuilt:
13021303
)
13031304
def dashboard_build(
13041305
args: DashboardBuildArgs, _ctx: Ctx, fleet: Fleet
1305-
) -> DashboardBuilt:
1306+
) -> DashboardPayload:
13061307
"""Write the dashboard pages for the fleet's current evidence."""
13071308
view = args.operations_view(fleet)
1308-
output = fleet.path(args.output_dir)
1309-
effect = "updated" if output.exists() else "created"
13101309
try:
1311-
artifacts = build_dashboard(view, output)
1310+
artifacts = build_dashboard(view, fleet.path(args.output_dir))
13121311
except OSError as error:
13131312
if error.errno not in _NOT_WRITABLE:
13141313
raise
13151314
raise _not_writable(error) from error
1316-
return DashboardBuilt(
1317-
effect=effect,
1318-
status="ok",
1319-
health=view.health.value,
1320-
tasks=len(view.tasks),
1321-
dashboard=artifacts.as_dict(),
1315+
return DashboardPayload(
1316+
{
1317+
"status": "ok",
1318+
"health": view.health.value,
1319+
"tasks": len(view.tasks),
1320+
"dashboard": artifacts.as_dict(),
1321+
}
13221322
)
13231323

13241324

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

13381338

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

1343-
effect: str
1344-
status: str
1345-
observations: list[str] = Out(ordered=True)
1342+
command = "services inspect"
13461343

13471344

13481345
@services.command(
13491346
"inspect",
13501347
description="Collect DNS, TLS, origin, and public route evidence",
1351-
danger_level="mutating",
1348+
danger_level="safe",
13521349
# DNS, TLS, and HTTP probes of every declared domain, one after another.
13531350
timeout=600,
1351+
project_root=PROJECT_MARKERS,
1352+
filesystem_side_effects=[
1353+
SideEffect("{project_root}/tmp/observed-services/", "cache")
1354+
],
13541355
exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PERMISSION_DENIED"],
13551356
examples=[("Probe every declared domain", "cloudfall services inspect")],
13561357
)
13571358
def services_inspect(
13581359
args: ServicesInspectArgs, _ctx: Ctx, fleet: Fleet
1359-
) -> ServicesInspected:
1360+
) -> InspectedPayload:
13601361
"""Probe each declared domain and write one observation per domain."""
13611362
try:
13621363
paths = inspect_domains(
@@ -1369,10 +1370,8 @@ def services_inspect(
13691370
if error.errno not in _NOT_WRITABLE:
13701371
raise
13711372
raise _not_writable(error) from error
1372-
return ServicesInspected(
1373-
effect="created",
1374-
status="ok",
1375-
observations=[str(path) for path in paths],
1373+
return InspectedPayload(
1374+
{"status": "ok", "observations": [str(path) for path in paths]}
13761375
)
13771376

13781377

@@ -1699,6 +1698,7 @@ class ApproveDecisionArgs(DecisionsArgs):
16991698
)
17001699
yes: bool = Flag(
17011700
default=False,
1701+
confirm=True,
17021702
description=(
17031703
"Change the servers; without it the command shows the recorded "
17041704
"proposal and runs nothing"
@@ -1805,10 +1805,9 @@ def operations_approve(args: ApproveDecisionArgs, ctx: Ctx) -> Decided:
18051805
raise Exit.NOT_FOUND(error.detail, context={"code": error.code}) from error
18061806
raise _record_invalid(error) from error
18071807
if not args.yes:
1808-
# Nothing ran. treaty reads would_* only from a dry run it switched,
1809-
# and --yes is the inverse of its --dry-run (treaty #197).
1808+
# Without --yes the run is treaty's dry run (Flag(confirm=True)).
18101809
return Decided(
1811-
effect="noop",
1810+
effect="would_update",
18121811
status="pending",
18131812
decision=decision.as_document(),
18141813
next=[
@@ -2030,6 +2029,7 @@ class MigrateArgs(EngineArgs):
20302029
)
20312030
yes: bool = Flag(
20322031
default=False,
2032+
confirm=True,
20332033
description=(
20342034
"Change the servers; without it the command prints the plan and "
20352035
"runs nothing"
@@ -2150,8 +2150,8 @@ def migrate(args: MigrateArgs, ctx: Ctx, fleet: Fleet) -> Migration:
21502150
) from error
21512151
status = str(result["status"])
21522152
migration = Migration(
2153-
# A plan changes nothing: noop, as without --yes in `operations approve`.
2154-
effect="noop" if status == "plan" else "updated",
2153+
# A plan is the dry run --yes confirms.
2154+
effect="would_update" if status == "plan" else "updated",
21552155
status=status,
21562156
steps=list(cast("list[dict[str, object]]", result["steps"])),
21572157
completed=int(cast("int", result["completed"])),
@@ -2259,7 +2259,7 @@ def why(args: WhyArgs, _ctx: Ctx) -> WhyPayload:
22592259
"Some servers produced no snapshot or Ansible failed; data says which"
22602260
),
22612261
retryable=True,
2262-
# The hosts are only read; a retry rewrites the snapshots it wrote.
2262+
# observe only reads the hosts; a retry rewrites the snapshots it wrote.
22632263
side_effects="none",
22642264
suggestion="read data.missing and data.detail, fix the hosts, then observe again",
22652265
)
@@ -2290,7 +2290,6 @@ class ObservedPayload(Payload):
22902290
"""``observe``: which servers produced a snapshot."""
22912291

22922292
command = "observe"
2293-
writes = True
22942293

22952294

22962295
def _playbook_runner(
@@ -2311,7 +2310,14 @@ def run(argv: Sequence[str], environment: Mapping[str, str]) -> PlaybookRun:
23112310
"Collect read-only server snapshots from the fleet; servers without one "
23122311
"exit non-zero with the run in data"
23132312
),
2314-
danger_level="mutating",
2313+
# It only reads the hosts; its snapshots and the inventory overlay are
2314+
# regenerated under the project on every run.
2315+
danger_level="safe",
2316+
project_root=PROJECT_MARKERS,
2317+
filesystem_side_effects=[
2318+
SideEffect("{project_root}/tmp/observed/", "cache"),
2319+
SideEffect("{project_root}/tmp/cloudfall/", "cache"),
2320+
],
23152321
exit_codes=[
23162322
"PROJECT_INVALID",
23172323
"CONFIG_INVALID",
@@ -2334,7 +2340,6 @@ def observe(args: ObserveArgs, ctx: Ctx, fleet: Fleet) -> ObservedPayload:
23342340
else (fleet.directory / "tmp/ansible-inventory.json",)
23352341
)
23362342
output = fleet.path(args.output_dir)
2337-
effect = "updated" if output.exists() else "created"
23382343
try:
23392344
request = ObservationRequest(
23402345
inventory_sources=sources,
@@ -2353,7 +2358,7 @@ def observe(args: ObserveArgs, ctx: Ctx, fleet: Fleet) -> ObservedPayload:
23532358
)
23542359
except ObserveError as error:
23552360
raise Exit.PRECONDITION(error.detail, context={"code": error.code}) from error
2356-
payload = ObservedPayload({**result.as_dict(), "effect": effect})
2361+
payload = ObservedPayload(result.as_dict())
23572362
if not result.complete:
23582363
message = f"{len(result.missing)} server(s) produced no snapshot"
23592364
raise Exit.INCOMPLETE(message, data=payload)
@@ -2657,6 +2662,7 @@ class ComponentArgs(EngineArgs):
26572662
component: ResourceId = Arg(description="Component to act on")
26582663
yes: bool = Flag(
26592664
default=False,
2665+
confirm=True,
26602666
description=(
26612667
"Change the servers; without it the command validates the request "
26622668
"and only shows what it would do"
@@ -2718,6 +2724,7 @@ class DataMigrateArgs(EngineArgs):
27182724
)
27192725
yes: bool = Flag(
27202726
default=False,
2727+
confirm=True,
27212728
description=(
27222729
"Change the servers; without it the command validates the request "
27232730
"and only shows what it would do"
@@ -2759,9 +2766,12 @@ class DataMigratePayload(Payload):
27592766

27602767

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

27662776

27672777
_LIFECYCLE_EXIT_CODES = [

‎sdk/tests/test_decision.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,8 @@ def test_the_cli_shows_the_proposal_before_it_runs_anything(
626626
payload = json.loads(capsys.readouterr().out)
627627
assert exit_code == 0
628628
assert payload["data"]["status"] == "pending"
629-
assert payload["data"]["effect"] == "noop"
629+
assert payload["data"]["effect"] == "would_update"
630+
assert payload["meta"]["dry_run"] is True
630631
assert payload["data"]["decision"]["spec"]["status"] == "proposed"
631632
assert store.load(proposed.decision_id).status is DecisionStatus.PROPOSED
632633

‎sdk/tests/test_lifecycle.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ def test_cli_server_changes_show_a_plan_without_yes(
252252

253253
assert exit_code == 0
254254
assert payload["data"]["status"] == "plan"
255-
assert payload["data"]["effect"] == "noop"
255+
assert payload["data"]["effect"] == "would_update"
256+
assert payload["meta"]["dry_run"] is True
256257
assert payload["data"]["action"] == action
257258
assert payload["data"]["component"] == "crm-backend"
258259
assert payload["data"]["servers"]
@@ -281,7 +282,8 @@ def test_cli_deploy_plan_verifies_the_artifact(
281282

282283
assert exit_code == 0
283284
assert payload["data"]["status"] == "plan"
284-
assert payload["data"]["effect"] == "noop"
285+
assert payload["data"]["effect"] == "would_update"
286+
assert payload["meta"]["dry_run"] is True
285287
assert payload["data"]["release"] == RELEASE
286288
would_run = str(payload["data"]["wouldRun"])
287289
assert f"release {RELEASE} of component crm-backend" in would_run
@@ -323,7 +325,8 @@ def test_cli_data_migration_plan_checks_the_source_url_file(
323325
exit_code, payload = _cli_lifecycle(argv, tmp_path, capsys)
324326
assert exit_code == 0
325327
assert payload["data"]["status"] == "plan"
326-
assert payload["data"]["effect"] == "noop"
328+
assert payload["data"]["effect"] == "would_update"
329+
assert payload["meta"]["dry_run"] is True
327330
assert payload["data"]["service"] == "postgresql-main"
328331
assert "u:p@" not in json.dumps(payload)
329332

‎sdk/tests/test_migrate.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ def test_cli_migrate_previews_without_executing(
222222
payload = json.loads(captured.out)
223223
assert exit_code == 0
224224
assert payload["data"]["status"] == "plan"
225-
assert payload["data"]["effect"] == "noop"
225+
assert payload["data"]["effect"] == "would_update"
226+
assert payload["meta"]["dry_run"] is True
226227
assert payload["data"]["next"] == "baseline"
227228

228229

‎uv.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)