Skip to content

Commit c7cc626

Browse files
Add cloudfall release approve and its approval record (S-008-2, S-008-4, S-008-11, S-008-12) (#172)
* Add cloudfall release approve and its approval record A person approves one built release of one component at a terminal by typing the release id back, and the command writes one ReleaseApproval record under decisions/releases/ naming the archive SHA-256, git commit, servers, approver and expiry. It checks the component, refuses an automatic application and verifies the artifact before asking, and --yes never answers it. It deploys nothing; deploy reads the record in issue 163 (spec S-008). Fixes #160 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse a control character in the approver and report a same-second approval An approver with a newline, an escape sequence or a bidi override is refused by the model (release_approver_invalid) and by the schema, since a later plan shows it. A second approval of the same release by the same person in the same second has the same id; it now exits PRECONDITION release_approval_exists naming the recorded file instead of an unhandled FileExistsError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: shipmill-romamo[bot] <338557544+shipmill-romamo[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 835d7ce commit c7cc626

9 files changed

Lines changed: 997 additions & 2 deletions

File tree

‎changelog.d/160-release-approve.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Added
2+
3+
- `cloudfall release approve <component> --release <release>` records a person's approval of one built release as a `ReleaseApproval` (new `release-approval.schema.json`) under `decisions/releases/`: the application, component, release, archive SHA-256, git commit, servers, approver, `approvedAt`, `expiresAt` (`--valid-for` hours, 1 to 168, default 24) and `via: terminal`. It verifies the artifact first, refuses a component of an `automatic` application with `release_approval_not_required`, and writes the record only once a person typed the release id at a terminal; `--yes` never answers it, and it is no MCP tool. It deploys nothing, and `deploy` does not read the record yet (#160)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://cloudfall.internal/schemas/v1/release-approval.schema.json",
4+
"title": "Cloudfall release approval record",
5+
"description": "A person's approval of one release of one component, written by cloudfall release approve. It names the archive digest and servers it approves and holds no secret.",
6+
"type": "object",
7+
"properties": {
8+
"apiVersion": {
9+
"$ref": "common.schema.json#/$defs/apiVersion"
10+
},
11+
"kind": {
12+
"const": "ReleaseApproval"
13+
},
14+
"metadata": {
15+
"$ref": "common.schema.json#/$defs/metadata"
16+
},
17+
"spec": {
18+
"type": "object",
19+
"properties": {
20+
"application": {
21+
"$ref": "common.schema.json#/$defs/resourceId"
22+
},
23+
"component": {
24+
"$ref": "common.schema.json#/$defs/resourceId"
25+
},
26+
"release": {
27+
"type": "string",
28+
"pattern": "^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{7,40}$"
29+
},
30+
"artifactSha256": {
31+
"description": "The archive's SHA-256 as verified against its artifact metadata when approved",
32+
"$ref": "common.schema.json#/$defs/sha256"
33+
},
34+
"gitCommit": {
35+
"type": "string",
36+
"pattern": "^[0-9a-f]{40}$"
37+
},
38+
"servers": {
39+
"description": "The component's declared servers when approved",
40+
"type": "array",
41+
"items": {
42+
"$ref": "common.schema.json#/$defs/resourceId"
43+
},
44+
"minItems": 1,
45+
"uniqueItems": true
46+
},
47+
"approver": {
48+
"type": "string",
49+
"minLength": 1,
50+
"maxLength": 200,
51+
"pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f]+$"
52+
},
53+
"approvedAt": {
54+
"type": "string",
55+
"format": "date-time"
56+
},
57+
"expiresAt": {
58+
"type": "string",
59+
"format": "date-time"
60+
},
61+
"via": {
62+
"description": "How the approval arrived: terminal when a person typed the release id at a terminal",
63+
"enum": [
64+
"terminal"
65+
]
66+
}
67+
},
68+
"required": [
69+
"application",
70+
"component",
71+
"release",
72+
"artifactSha256",
73+
"gitCommit",
74+
"servers",
75+
"approver",
76+
"approvedAt",
77+
"expiresAt",
78+
"via"
79+
],
80+
"additionalProperties": false
81+
}
82+
},
83+
"required": [
84+
"apiVersion",
85+
"kind",
86+
"metadata",
87+
"spec"
88+
],
89+
"additionalProperties": false
90+
}

‎sdk/src/cloudfall/agent_server.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,8 @@ def operation_tool_description(operation: Operation) -> str:
303303
)
304304
"""The commands an agent runs on a project.
305305
306-
``init``, the loops and ``operator approve`` are not tools: a person approves.
306+
``init``, the loops, ``operator approve`` and ``release approve`` are not
307+
tools: a person approves.
307308
"""
308309

309310
FLEET_COMMANDS = frozenset(

‎sdk/src/cloudfall/app.py‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
# handler's Iterator and a provided MCP tool's Mapping must exist at runtime.
2626
from collections.abc import Iterator, Mapping
2727
from dataclasses import dataclass, replace
28+
from datetime import UTC, datetime
2829
from functools import partial
2930
from http import HTTPStatus
3031
from importlib.metadata import version
@@ -244,6 +245,18 @@
244245
resolve_project_directory,
245246
)
246247
from cloudfall.recording import Recording, RecordingError
248+
from cloudfall.release_approval import (
249+
APPROVALS_DIRECTORY,
250+
VALID_FOR_DEFAULT,
251+
VALID_FOR_MAXIMUM,
252+
VALID_FOR_MINIMUM,
253+
Approver,
254+
ReleaseApprovalError,
255+
ValidityHours,
256+
approve_release,
257+
release_candidate,
258+
write_approval,
259+
)
247260
from cloudfall.render_api import (
248261
ERROR_API_UNREACHABLE,
249262
RENDER_API_URL,
@@ -4066,6 +4079,151 @@ def restart_command(args: ComponentArgs, ctx: Ctx, fleet: Fleet) -> RestartPaylo
40664079
return RestartPayload({**result.as_dict(), "effect": "updated"})
40674080

40684081

4082+
# release approve: a person's record that one release may be deployed (S-008)
4083+
4084+
4085+
release_group = app.group(
4086+
"release", description="Record a person's approval of one built release"
4087+
)
4088+
4089+
4090+
@dataclass(frozen=True, slots=True, kw_only=True)
4091+
class ReleaseApproveArgs(ProjectArgs):
4092+
"""Arguments of ``release approve``."""
4093+
4094+
component: ResourceId = Arg(description="Component whose release is approved")
4095+
release: ReleaseId = Flag(
4096+
description="Release id produced by cloudfall-engine artifact build"
4097+
)
4098+
artifacts: Path = Flag(
4099+
default=Path("tmp/artifacts"),
4100+
description="Artifact directory (default: tmp/artifacts)",
4101+
)
4102+
approvals: Path = Flag(
4103+
default=Path(APPROVALS_DIRECTORY),
4104+
description=(
4105+
f"Directory the approval record is written to (default: "
4106+
f"{APPROVALS_DIRECTORY})"
4107+
),
4108+
)
4109+
valid_for: int = Flag(
4110+
default=VALID_FOR_DEFAULT,
4111+
description=(
4112+
f"Hours the approval counts for, {VALID_FOR_MINIMUM} to "
4113+
f"{VALID_FOR_MAXIMUM} (default: {VALID_FOR_DEFAULT})"
4114+
),
4115+
)
4116+
approver: str | None = Flag(
4117+
default=None,
4118+
description="Who is approving (default: the USER environment variable)",
4119+
)
4120+
4121+
def __post_init__(self) -> None:
4122+
"""Keep relative paths inside the project; check the validity."""
4123+
_inside_project(self.artifacts, "artifacts")
4124+
_inside_project(self.approvals, "approvals")
4125+
self.validity()
4126+
4127+
def validity(self) -> ValidityHours:
4128+
"""Return how long the approval counts, refusing an out-of-range count."""
4129+
try:
4130+
return ValidityHours(self.valid_for)
4131+
except ReleaseApprovalError as error:
4132+
message = f"--valid-for: {error.detail}"
4133+
raise ParseError(message, context={"flag": "valid-for"}) from error
4134+
4135+
4136+
@dataclass(frozen=True, slots=True)
4137+
class ReleaseApproved:
4138+
"""The approval record a person wrote, and where it is kept."""
4139+
4140+
effect: str
4141+
status: str
4142+
path: str
4143+
approval: dict[str, object] = Out(ordered=True)
4144+
4145+
4146+
@release_group.command(
4147+
"approve",
4148+
# A person approves; no agent tool stands in for it. As with decisions
4149+
# approve, they type the release id at a terminal, which no flag answers,
4150+
# --yes included. requires_person keeps it off MCP too; mcp=False says so
4151+
# here as well.
4152+
mcp=False,
4153+
requires_person=True,
4154+
description=(
4155+
"Approve one built release of one component for deploy; a person types "
4156+
"the release id at a terminal, and nothing is deployed"
4157+
),
4158+
danger_level="mutating",
4159+
exit_codes=[
4160+
"PROJECT_INVALID",
4161+
"CONFIG_INVALID",
4162+
"PRECONDITION",
4163+
"PERMISSION_DENIED",
4164+
],
4165+
supports_raw_payload=True,
4166+
examples=[
4167+
(
4168+
"Approve a release, typing its id at a terminal",
4169+
"cloudfall release approve crm-backend --release 20260101T000000Z-abcdef0",
4170+
),
4171+
],
4172+
)
4173+
def release_approve(
4174+
args: ReleaseApproveArgs, ctx: Ctx, fleet: Fleet
4175+
) -> ReleaseApproved:
4176+
"""Write the approval record once a person typed the release id."""
4177+
valid_for = args.validity()
4178+
try:
4179+
approver = Approver(
4180+
args.approver if args.approver is not None else ctx.env.get("USER", "")
4181+
)
4182+
candidate = release_candidate(
4183+
fleet.inventory,
4184+
args.component,
4185+
args.release,
4186+
fleet.path(args.artifacts),
4187+
args.schemas,
4188+
)
4189+
except ReleaseApprovalError as error:
4190+
raise Exit.PRECONDITION(error.detail, context={"code": error.code}) from error
4191+
except LifecycleError as error:
4192+
raise _lifecycle_failed(error) from error
4193+
until = (datetime.now(UTC) + valid_for.duration).strftime("%Y-%m-%dT%H:%M:%SZ")
4194+
# Off a terminal this ends with PERSON_REQUIRED, and a mistyped id with
4195+
# ATTESTATION_MISMATCH, before anything is written: --yes never approves.
4196+
attestation = ctx.attest(
4197+
f"{candidate.describe()}. The approval counts for {valid_for.value} "
4198+
f"hours, until about {until}. Type the release id to approve "
4199+
f"{candidate.release.value}",
4200+
expected=candidate.release.value,
4201+
)
4202+
approval = approve_release(
4203+
candidate,
4204+
approver,
4205+
ApprovalChannel(attestation.channel),
4206+
valid_for,
4207+
attestation.at,
4208+
)
4209+
try:
4210+
path = write_approval(
4211+
approval, fleet.path(args.approvals), SchemaCatalog(args.schemas)
4212+
)
4213+
except ReleaseApprovalError as error:
4214+
raise Exit.PRECONDITION(error.detail, context={"code": error.code}) from error
4215+
except OSError as error:
4216+
if error.errno not in _NOT_WRITABLE:
4217+
raise
4218+
raise _not_writable(error) from error
4219+
return ReleaseApproved(
4220+
effect="created",
4221+
status="ok",
4222+
path=str(path),
4223+
approval=approval.as_document(),
4224+
)
4225+
4226+
40694227
data = app.group("data", description="Migrate data into declared services")
40704228

40714229

‎sdk/src/cloudfall/commands.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,15 @@ def _plan(subject: str, *extra: str) -> OutputShape:
511511
gate=_YES,
512512
output=(_plan("component"), _shape(*_EXECUTED, when="with `--yes`")),
513513
),
514+
CommandContract(
515+
"cloudfall",
516+
"release approve",
517+
CommandEffect.PROJECT,
518+
"record a person's approval of one built release under "
519+
"`decisions/releases/`; a person types the release id at a terminal, "
520+
"which no flag answers (`--yes` included), and nothing is deployed",
521+
output=(_shape("status", "path", "approval:object"),),
522+
),
514523
CommandContract(
515524
"cloudfall",
516525
"data copy",

‎sdk/src/cloudfall/lifecycle.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ class VerifiedArtifact:
179179
archive_path: Path
180180
metadata_path: Path
181181
sha256: str
182+
git_commit: str
183+
"""The commit the metadata says the archive was built from."""
182184

183185

184186
@dataclass(frozen=True, slots=True)
@@ -441,6 +443,8 @@ def verify_artifact(
441443
archive_path=archive_path,
442444
metadata_path=metadata_path,
443445
sha256=actual_digest,
446+
# The schema above requires it as 40 lowercase hex characters.
447+
git_commit=cast("str", spec["gitCommit"]),
444448
)
445449

446450

@@ -1514,7 +1518,13 @@ def _component(
15141518
context: EngineContext, component_id: ResourceId
15151519
) -> ComponentInventory:
15161520
state = validate_config(context.project_directory, context.schema_directory)
1517-
inventory = PlatformInventory.from_state(state)
1521+
return find_component(PlatformInventory.from_state(state), component_id)
1522+
1523+
1524+
def find_component(
1525+
inventory: PlatformInventory, component_id: ResourceId
1526+
) -> ComponentInventory:
1527+
"""Return the declared component, or fail as every lifecycle command does."""
15181528
component = next(
15191529
(
15201530
candidate

0 commit comments

Comments
 (0)