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/160-release-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- `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)
90 changes: 90 additions & 0 deletions config/schemas/v1/release-approval.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cloudfall.internal/schemas/v1/release-approval.schema.json",
"title": "Cloudfall release approval record",
"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.",
"type": "object",
"properties": {
"apiVersion": {
"$ref": "common.schema.json#/$defs/apiVersion"
},
"kind": {
"const": "ReleaseApproval"
},
"metadata": {
"$ref": "common.schema.json#/$defs/metadata"
},
"spec": {
"type": "object",
"properties": {
"application": {
"$ref": "common.schema.json#/$defs/resourceId"
},
"component": {
"$ref": "common.schema.json#/$defs/resourceId"
},
"release": {
"type": "string",
"pattern": "^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{7,40}$"
},
"artifactSha256": {
"description": "The archive's SHA-256 as verified against its artifact metadata when approved",
"$ref": "common.schema.json#/$defs/sha256"
},
"gitCommit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$"
},
"servers": {
"description": "The component's declared servers when approved",
"type": "array",
"items": {
"$ref": "common.schema.json#/$defs/resourceId"
},
"minItems": 1,
"uniqueItems": true
},
"approver": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f]+$"
},
"approvedAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"via": {
"description": "How the approval arrived: terminal when a person typed the release id at a terminal",
"enum": [
"terminal"
]
}
},
"required": [
"application",
"component",
"release",
"artifactSha256",
"gitCommit",
"servers",
"approver",
"approvedAt",
"expiresAt",
"via"
],
"additionalProperties": false
}
},
"required": [
"apiVersion",
"kind",
"metadata",
"spec"
],
"additionalProperties": false
}
3 changes: 2 additions & 1 deletion sdk/src/cloudfall/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ def operation_tool_description(operation: Operation) -> str:
)
"""The commands an agent runs on a project.

``init``, the loops and ``operator approve`` are not tools: a person approves.
``init``, the loops, ``operator approve`` and ``release approve`` are not
tools: a person approves.
"""

FLEET_COMMANDS = frozenset(
Expand Down
158 changes: 158 additions & 0 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
# handler's Iterator and a provided MCP tool's Mapping must exist at runtime.
from collections.abc import Iterator, Mapping
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from functools import partial
from http import HTTPStatus
from importlib.metadata import version
Expand Down Expand Up @@ -245,6 +246,18 @@
resolve_project_directory,
)
from cloudfall.recording import Recording, RecordingError
from cloudfall.release_approval import (
APPROVALS_DIRECTORY,
VALID_FOR_DEFAULT,
VALID_FOR_MAXIMUM,
VALID_FOR_MINIMUM,
Approver,
ReleaseApprovalError,
ValidityHours,
approve_release,
release_candidate,
write_approval,
)
from cloudfall.render_api import (
ERROR_API_UNREACHABLE,
RENDER_API_URL,
Expand Down Expand Up @@ -4031,6 +4044,151 @@ def restart_command(args: ComponentArgs, ctx: Ctx, fleet: Fleet) -> RestartPaylo
return RestartPayload({**result.as_dict(), "effect": "updated"})


# release approve: a person's record that one release may be deployed (S-008)


release_group = app.group(
"release", description="Record a person's approval of one built release"
)


@dataclass(frozen=True, slots=True, kw_only=True)
class ReleaseApproveArgs(ProjectArgs):
"""Arguments of ``release approve``."""

component: ResourceId = Arg(description="Component whose release is approved")
release: ReleaseId = Flag(
description="Release id produced by cloudfall-engine artifact build"
)
artifacts: Path = Flag(
default=Path("tmp/artifacts"),
description="Artifact directory (default: tmp/artifacts)",
)
approvals: Path = Flag(
default=Path(APPROVALS_DIRECTORY),
description=(
f"Directory the approval record is written to (default: "
f"{APPROVALS_DIRECTORY})"
),
)
valid_for: int = Flag(
default=VALID_FOR_DEFAULT,
description=(
f"Hours the approval counts for, {VALID_FOR_MINIMUM} to "
f"{VALID_FOR_MAXIMUM} (default: {VALID_FOR_DEFAULT})"
),
)
approver: str | None = Flag(
default=None,
description="Who is approving (default: the USER environment variable)",
)

def __post_init__(self) -> None:
"""Keep relative paths inside the project; check the validity."""
_inside_project(self.artifacts, "artifacts")
_inside_project(self.approvals, "approvals")
self.validity()

def validity(self) -> ValidityHours:
"""Return how long the approval counts, refusing an out-of-range count."""
try:
return ValidityHours(self.valid_for)
except ReleaseApprovalError as error:
message = f"--valid-for: {error.detail}"
raise ParseError(message, context={"flag": "valid-for"}) from error


@dataclass(frozen=True, slots=True)
class ReleaseApproved:
"""The approval record a person wrote, and where it is kept."""

effect: str
status: str
path: str
approval: dict[str, object] = Out(ordered=True)


@release_group.command(
"approve",
# A person approves; no agent tool stands in for it. As with decisions
# approve, they type the release id at a terminal, which no flag answers,
# --yes included. requires_person keeps it off MCP too; mcp=False says so
# here as well.
mcp=False,
requires_person=True,
description=(
"Approve one built release of one component for deploy; a person types "
"the release id at a terminal, and nothing is deployed"
),
danger_level="mutating",
exit_codes=[
"PROJECT_INVALID",
"CONFIG_INVALID",
"PRECONDITION",
"PERMISSION_DENIED",
],
supports_raw_payload=True,
examples=[
(
"Approve a release, typing its id at a terminal",
"cloudfall release approve crm-backend --release 20260101T000000Z-abcdef0",
),
],
)
def release_approve(
args: ReleaseApproveArgs, ctx: Ctx, fleet: Fleet
) -> ReleaseApproved:
"""Write the approval record once a person typed the release id."""
valid_for = args.validity()
try:
approver = Approver(
args.approver if args.approver is not None else ctx.env.get("USER", "")
)
candidate = release_candidate(
fleet.inventory,
args.component,
args.release,
fleet.path(args.artifacts),
args.schemas,
)
except ReleaseApprovalError as error:
raise Exit.PRECONDITION(error.detail, context={"code": error.code}) from error
except LifecycleError as error:
raise _lifecycle_failed(error) from error
until = (datetime.now(UTC) + valid_for.duration).strftime("%Y-%m-%dT%H:%M:%SZ")
# Off a terminal this ends with PERSON_REQUIRED, and a mistyped id with
# ATTESTATION_MISMATCH, before anything is written: --yes never approves.
attestation = ctx.attest(
f"{candidate.describe()}. The approval counts for {valid_for.value} "
f"hours, until about {until}. Type the release id to approve "
f"{candidate.release.value}",
expected=candidate.release.value,
)
approval = approve_release(
candidate,
approver,
ApprovalChannel(attestation.channel),
valid_for,
attestation.at,
)
try:
path = write_approval(
approval, fleet.path(args.approvals), SchemaCatalog(args.schemas)
)
except ReleaseApprovalError as error:
raise Exit.PRECONDITION(error.detail, context={"code": error.code}) from error
except OSError as error:
if error.errno not in _NOT_WRITABLE:
raise
raise _not_writable(error) from error
return ReleaseApproved(
effect="created",
status="ok",
path=str(path),
approval=approval.as_document(),
)


data = app.group("data", description="Migrate data into declared services")


Expand Down
9 changes: 9 additions & 0 deletions sdk/src/cloudfall/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,15 @@ def _plan(subject: str, *extra: str) -> OutputShape:
gate=_YES,
output=(_plan("component"), _shape(*_EXECUTED, when="with `--yes`")),
),
CommandContract(
"cloudfall",
"release approve",
CommandEffect.PROJECT,
"record a person's approval of one built release under "
"`decisions/releases/`; a person types the release id at a terminal, "
"which no flag answers (`--yes` included), and nothing is deployed",
output=(_shape("status", "path", "approval:object"),),
),
CommandContract(
"cloudfall",
"data copy",
Expand Down
12 changes: 11 additions & 1 deletion sdk/src/cloudfall/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ class VerifiedArtifact:
archive_path: Path
metadata_path: Path
sha256: str
git_commit: str
"""The commit the metadata says the archive was built from."""


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -421,6 +423,8 @@ def verify_artifact(
archive_path=archive_path,
metadata_path=metadata_path,
sha256=actual_digest,
# The schema above requires it as 40 lowercase hex characters.
git_commit=cast("str", spec["gitCommit"]),
)


Expand Down Expand Up @@ -1481,7 +1485,13 @@ def _component(
context: EngineContext, component_id: ResourceId
) -> ComponentInventory:
state = validate_config(context.project_directory, context.schema_directory)
inventory = PlatformInventory.from_state(state)
return find_component(PlatformInventory.from_state(state), component_id)


def find_component(
inventory: PlatformInventory, component_id: ResourceId
) -> ComponentInventory:
"""Return the declared component, or fail as every lifecycle command does."""
component = next(
(
candidate
Expand Down
Loading
Loading