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

- `deploy` without `--yes` shows the component's release command as `release_command` (its command, the working directory resolved under the new release directory, `timeout_seconds` and the release server), or `null` for a component without one, and says in `would_run` that it runs once on that server before any switch; the `rollback` plan of a component with a release command shows `release_command_skipped: true`, since a rollback never runs it (#120)
16 changes: 15 additions & 1 deletion docs/specs/005-release-command.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# S-005: Release command

status: approved
status: built

## Problem

Expand Down Expand Up @@ -129,3 +129,17 @@ Touches `config/schemas/v1/component.schema.json`,
- cloudfall-dev/cloudfall#120: S-005-8, S-005-9

## Verification

No criterion has run against a live Debian host yet: the role's release command tasks ran under `ansible-playbook` on the controller with a stand-in for `systemd-run`, and `deploy.yml` ran against two local hosts with a recording role. The real `systemd-run` (exit code, the `Finished with result: timeout` line, `EnvironmentFile=` quoting) and `current`, unit and restart state on real servers are unexercised.

- S-005-1: `test_s005_1_a_passing_release_command_records_its_run` and `test_s005_1_stderr_is_part_of_the_recorded_output`, the stand-in command created its table and the recorded run had the command, `exitCode: 0` and the combined output tail
- S-005-2: `test_s005_2_the_release_command_runs_between_materialize_and_the_switch` (task order in the role) and `test_s005_2_the_command_runs_in_its_working_directory_as_the_user` (working directory, `--uid`/`--gid` of the application user)
- S-005-3: `test_s005_3_the_command_loads_the_unit_environment_file`, the `EnvironmentFile=` line equals the unit template's
- S-005-4: `test_s005_4_a_failing_release_command_fails_the_play_naming_its_exit`, `test_s005_4_a_unit_that_never_starts_names_systemd_run_s_reason` and `test_s005_4_a_failed_release_server_stops_every_other_server`, the message named the command and exit code 1 and no other server ran
- S-005-5: `test_s005_5_a_timed_out_release_command_fails_naming_the_timeout` and `test_s005_5_the_timeout_is_the_unit_runtime_limit` (`RuntimeMaxSec=`)
- S-005-6: `test_s005_6_the_release_server_deploys_first_and_runs_the_command_once` with `deployment.servers: [h2, h1]`, plus `test_s005_6_only_the_release_server_runs_the_command`, `test_s005_6_a_server_whose_release_server_has_not_passed_does_not_switch`, `test_s005_6_every_receipt_names_the_release_server`, `test_s005_6_the_release_server_is_the_first_deployment_server` and `test_s005_6_a_release_renders_with_its_release_server`
- S-005-7: `test_s005_7_the_output_tail_keeps_the_last_2000_characters` and `test_s005_7_a_receipt_refuses_an_output_tail_over_2000_characters`
- S-005-8: `test_s005_8_the_deploy_plan_shows_the_release_command`, `test_s005_8_the_plan_resolves_a_declared_working_directory_and_timeout` and `test_s005_8_a_component_without_release_shows_a_null_release_command`, `deploy` without `--yes` exited 0 with `release_command` (`working_directory` resolved to `<installRoot>/releases/<release>`, `timeout_seconds: 300` by default, `server: h2`) or `null`; the CLI pointed at an engine whose playbooks only leave a marker left none and rendered no inventory, while the same engine with `--yes` did leave one (`test_s005_8_the_marker_engine_does_record_a_playbook_run`)
- S-005-9: `test_s005_9_the_rollback_plan_skips_the_release_command` showed `release_command_skipped: true`, `test_s005_9_a_rollback_without_release_skips_nothing` showed `null`, and `test_s005_9_the_rollback_run_includes_no_release_command_task` found no release command task in `rollback.yml` or what it includes
- S-005-10: `test_s005_10_a_component_without_release_runs_no_new_task`, `test_s005_10_a_receipt_without_release_has_no_release_command`, `test_s005_10_a_component_without_release_deploys_in_inventory_order` and `test_s005_10_a_component_without_release_serializes_as_before`
- S-005-11: `test_s005_11_config_validate_refuses_a_malformed_release`, `test_s005_11_config_validate_accepts_a_declared_release` and `test_s005_11_release_values_are_refused_outside_their_bounds`
5 changes: 4 additions & 1 deletion engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,10 @@ new release directory (or its `workingDirectory`), with the component's
rules, and is stopped after `timeoutSeconds`. A failure or a timeout fails the
deploy before any server's `current` moves; a success is recorded under
`releaseCommand` in every release receipt of that deploy. The command must be
safe while the old release still serves traffic. Rollback activates an already-retained
safe while the old release still serves traffic. `deploy` without `--yes` shows
it as `release_command` (command, resolved working directory, timeout and
server), `null` for a component without one. Rollback never runs the release
command, and its plan says so with `release_command_skipped: true`. Rollback activates an already-retained
release without re-transferring an artifact and requires the declared health
check to pass; it deliberately does not auto-restore on failure, because a
failed rollback is an incident, not a deployment.
Expand Down
43 changes: 35 additions & 8 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,12 @@
RiskLevel,
load_catalog,
)
from cloudfall.commands import CLI_COMMANDS, snake_case, snake_case_keys
from cloudfall.commands import (
CLI_COMMANDS,
KEY_PROPERTIES,
snake_case,
snake_case_keys,
)
from cloudfall.dashboard import RefreshInterval, build_dashboard
from cloudfall.dashboard_server import (
DashboardHTTPServer,
Expand Down Expand Up @@ -393,6 +398,34 @@ def __init__(self, body: Mapping[str, object]) -> None:
self.body = dict(body)


_FIXED_SHAPES = {
snake_case(key): fields for key, fields in KEY_PROPERTIES.items()
}


def _key_schema(name: str, kind: str, *, optional: bool) -> dict[str, object]:
"""Return the schema of one data key, by its snake_case name."""
schema: dict[str, object] = {"type": [kind, "null"] if optional else kind}
properties = _FIXED_SHAPES.get(name)
if properties is None and kind in {"array", "object"}:
# The domain writes each array in the order it means: a ranking,
# a timeline, the order checks ran in, and a stored record keeps
# its own. treaty would sort them, nested ones included.
schema["x-ordered"] = True
if properties is not None:
# A fixed shape: treaty writes a missing one as null, not as {}, and
# orders only its own arrays, which carry x-ordered themselves.
schema["properties"] = {
snake_case(field): _key_schema(
snake_case(field), field_kind, optional=False
)
for field, field_kind in properties.items()
}
schema["required"] = [snake_case(field) for field in properties]
schema["additionalProperties"] = False
return schema


def _shape_keys(command: str) -> tuple[tuple[str, str, bool], ...]:
"""Return each data key of the command's output: name, JSON type, optional."""
contract = next(
Expand Down Expand Up @@ -423,13 +456,7 @@ def _shape_keys(command: str) -> tuple[tuple[str, str, bool], ...]:
def _payload_schema(cls: type[Payload]) -> dict[str, object]:
properties: dict[str, object] = {}
for name, kind, optional in _shape_keys(cls.command):
schema: dict[str, object] = {"type": [kind, "null"] if optional else kind}
if kind in {"array", "object"}:
# The domain writes each array in the order it means: a ranking,
# a timeline, the order checks ran in, and a stored record keeps
# its own. treaty would sort them, nested ones included.
schema["x-ordered"] = True
properties[name] = schema
properties[name] = _key_schema(name, kind, optional=optional)
if cls.writes:
properties["effect"] = {"type": "string"}
return {
Expand Down
23 changes: 19 additions & 4 deletions sdk/src/cloudfall/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class OutputKey:
("completed", "count", "exitCode", "openProposals", "resources", "tasks"),
"integer",
),
"healthy": "boolean",
**dict.fromkeys(("healthy", "releaseCommandSkipped"), "boolean"),
**dict.fromkeys(
(
"added", "answers", "components", "decisions", "declaredKeys",
Expand All @@ -102,13 +102,28 @@ class OutputKey:
**dict.fromkeys(
(
"ansible", "byKind", "byRisk", "dashboard", "decision", "error",
"inventory", "operation", "proposal", "query", "summary",
"inventory", "operation", "proposal", "query", "releaseCommand",
"summary",
),
"object",
),
}
"""The JSON type of each output key, by name; a shape overrides one as ``name:type``."""

KEY_PROPERTIES: dict[str, dict[str, str]] = {
"releaseCommand": {
"command": "array",
"workingDirectory": "string",
"timeoutSeconds": "integer",
"server": "string",
},
}
"""The named properties of an object key that has a fixed shape, by key name.

Such an object is written as ``null`` when the command has none, where an
open object would be written as ``{}``.
"""


@dataclass(frozen=True, slots=True)
class OutputShape:
Expand Down Expand Up @@ -465,7 +480,7 @@ def _plan(subject: str, *extra: str) -> OutputShape:
"activate one built release behind its health check",
gate=_YES,
output=(
_plan("component", "release"),
_plan("component", "release", "releaseCommand"),
_shape(*_EXECUTED, "release", "receipt", when="with `--yes`"),
),
),
Expand All @@ -476,7 +491,7 @@ def _plan(subject: str, *extra: str) -> OutputShape:
"switch one component back to an existing release",
gate=_YES,
output=(
_plan("component", "release"),
_plan("component", "release", "releaseCommandSkipped?"),
_shape(*_EXECUTED, "release", when="with `--yes`"),
),
),
Expand Down
104 changes: 92 additions & 12 deletions sdk/src/cloudfall/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,25 @@
import sys
from dataclasses import dataclass
from enum import StrEnum
from pathlib import PurePosixPath
from typing import TYPE_CHECKING, Protocol, cast

from jsonschema.exceptions import ValidationError

from cloudfall.domain import AbsolutePath
from cloudfall.inventory import PlatformInventory
from cloudfall.validation import SchemaCatalog, validate_config

if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from pathlib import Path

from cloudfall.domain import ReleaseId, ResourceId
from cloudfall.domain import (
CommandArgv,
ReleaseCommandTimeout,
ReleaseId,
ResourceId,
)
from cloudfall.inventory import (
ComponentInventory,
DomainInventory,
Expand Down Expand Up @@ -176,6 +183,54 @@ def as_dict(self) -> dict[str, object]:
return result


@dataclass(frozen=True, slots=True)
class ReleaseCommandPlan:
"""The release command a deploy would run, resolved as the engine runs it.

The working directory is the absolute path on the release server: the
new release directory, or the declared ``workingDirectory`` under it.
"""

server: ResourceId
command: CommandArgv
working_directory: AbsolutePath
timeout_seconds: ReleaseCommandTimeout

@classmethod
def for_release(
cls, component: ComponentInventory, release: ReleaseId
) -> ReleaseCommandPlan | None:
"""Resolve a component's release command, ``None`` when it has none."""
declared = component.release
if declared is None:
return None
directory = PurePosixPath(
component.install_root.value, "releases", release.value
)
if declared.working_directory is not None:
directory /= declared.working_directory.value
return cls(
server=component.release_server_id,
command=declared.command,
working_directory=AbsolutePath(str(directory)),
timeout_seconds=declared.timeout_seconds,
)

@property
def described(self) -> str:
"""The command as one line, for a plan's ``wouldRun``."""
return " ".join(self.command.value)

def as_dict(self) -> dict[str, object]:
"""Serialize the plan's ``releaseCommand``."""
return {
"command": list(self.command.value),
"workingDirectory": self.working_directory.value,
"timeoutSeconds": self.timeout_seconds.value,
"server": self.server.value,
}


class PreviewSubject(StrEnum):
"""Kind of declared resource a previewed action targets."""

Expand All @@ -197,6 +252,10 @@ class LifecyclePreview:
release: ReleaseId | None
servers: tuple[ResourceId, ...]
effect: str
release_command: ReleaseCommandPlan | None = None
"""The release command a deploy runs; set only on a deploy's plan."""
release_command_skipped: bool = False
"""A declared release command this action does not run (a rollback)."""

def as_dict(self) -> dict[str, object]:
"""Serialize the preview for agent consumers."""
Expand All @@ -209,6 +268,10 @@ def as_dict(self) -> dict[str, object]:
}
if self.release is not None:
result["release"] = self.release.value
if self.release_command is not None:
result["releaseCommand"] = self.release_command.as_dict()
if self.release_command_skipped:
result["releaseCommandSkipped"] = True
return result


Expand Down Expand Up @@ -557,17 +620,26 @@ def preview_deploy(
verify_artifact(
artifact_directory, component_id, release, context.schema_directory
)
release_command = ReleaseCommandPlan.for_release(component, release)
effect = (
f"deploy release {release.value} of component {component_id} to "
f"{_server_list(component.server_ids)} behind its health check, "
"rolling back automatically if the check fails"
)
if release_command is not None:
effect += (
f"; first run its release command {release_command.described} "
f"once on {release_command.server}, before any server switches "
"to the release"
)
return LifecyclePreview(
action="deploy",
subject=PreviewSubject.COMPONENT,
subject_id=component_id,
release=release,
servers=component.server_ids,
effect=(
f"deploy release {release.value} of component {component_id} to "
f"{_server_list(component.server_ids)} behind its health check, "
"rolling back automatically if the check fails"
),
effect=effect,
release_command=release_command,
)


Expand All @@ -577,20 +649,28 @@ def preview_rollback(
"""Validate a rollback without touching any server.

Whether the release is still retained is known only to the servers; the
rollback itself fails if it is not.
rollback itself fails if it is not. A rollback never runs the release
command: a downgrade stays a human decision.
"""
component = _component(context, component_id)
effect = (
f"switch component {component_id} on "
f"{_server_list(component.server_ids)} back to retained release "
f"{release.value} and restart it behind its health check"
)
if component.release is not None:
effect += (
"; its release command "
f"{' '.join(component.release.command.value)} is not run"
)
return LifecyclePreview(
action="rollback",
subject=PreviewSubject.COMPONENT,
subject_id=component_id,
release=release,
servers=component.server_ids,
effect=(
f"switch component {component_id} on "
f"{_server_list(component.server_ids)} back to retained release "
f"{release.value} and restart it behind its health check"
),
effect=effect,
release_command_skipped=component.release is not None,
)


Expand Down
Loading
Loading