|
25 | 25 | # handler's Iterator and a provided MCP tool's Mapping must exist at runtime. |
26 | 26 | from collections.abc import Iterator, Mapping |
27 | 27 | from dataclasses import dataclass, replace |
| 28 | +from datetime import UTC, datetime |
28 | 29 | from functools import partial |
29 | 30 | from http import HTTPStatus |
30 | 31 | from importlib.metadata import version |
|
244 | 245 | resolve_project_directory, |
245 | 246 | ) |
246 | 247 | 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 | +) |
247 | 260 | from cloudfall.render_api import ( |
248 | 261 | ERROR_API_UNREACHABLE, |
249 | 262 | RENDER_API_URL, |
@@ -4066,6 +4079,151 @@ def restart_command(args: ComponentArgs, ctx: Ctx, fleet: Fleet) -> RestartPaylo |
4066 | 4079 | return RestartPayload({**result.as_dict(), "effect": "updated"}) |
4067 | 4080 |
|
4068 | 4081 |
|
| 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 | + |
4069 | 4227 | data = app.group("data", description="Migrate data into declared services") |
4070 | 4228 |
|
4071 | 4229 |
|
|
0 commit comments