diff --git a/CHANGELOG.md b/CHANGELOG.md index 654008d..b8f00db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,19 @@ Notable changes to Cloudfall. The format follows A missing playbook, inventory, or role directory exits 2 before Ansible starts. `playbook run --check` is now `--dry-run`; Ansible's play log streams to stderr. `cloudfall-engine manifest` describes every command +- **Breaking:** six more `cloudfall` commands run on treaty: `init`, + `add ssh-key`, `add server-type`, `add server`, `dashboard build` and + `services inspect`. Their answer is the treaty envelope described below, + with the same `data` keys plus `effect` (`created`, or `updated` for a + dashboard written over an older one), which treaty requires of every + command that writes. Failures: 6 `CONFLICT` for a resource that already + exists (was 2), 7 `PERMISSION_DENIED` for a read-only project or path + (was 1 or 2), 4 `PRECONDITION` for a non-empty `init` directory (was 2), + 5 `NOT_FOUND` for a missing key file, 2 `ARG_ERROR` for a bad id, name or + address; the old code is in `error.context.code`. `init` runs git through + treaty, so `cloudfall doctor` checks for git 2.24. `observe`, + `secrets render` and the two `import` commands stay on argparse until + treaty can carry their camelCase keys on a command that writes (#183) - **Breaking:** nine read-only `cloudfall` commands run on treaty, the first step of moving the CLI off argparse: `config validate`, `inventory show`, `operations list`, `operations show`, diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 980acb2..d09052c 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -14,13 +14,26 @@ from __future__ import annotations +import errno import os from dataclasses import dataclass +from functools import partial from importlib.metadata import version from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, Self - -from treaty import App, Arg, Ctx, Excludes, Exit, Flag, ParseError +from typing import TYPE_CHECKING, ClassVar, Self, cast + +from treaty import ( + App, + Arg, + Ctx, + Excludes, + Exit, + Flag, + Out, + ParseError, + Subprocess, + Timeout, +) from cloudfall.ansible_api import ( AnsibleReadError, @@ -30,6 +43,19 @@ ) from cloudfall.ansible_reader import FleetRead, read_fleet from cloudfall.audit import AuditStatus, audit_inventory +from cloudfall.authoring import ( + ERROR_KEY_FILE_MISSING, + ERROR_PROJECT_WRITE_FAILED, + ERROR_RESOURCE_EXISTS, + AddResult, + AuthoringError, + ServerOptions, + ServerTypeOptions, + SshKeyOptions, + add_server, + add_server_type, + add_ssh_key, +) from cloudfall.catalog import ( CATALOG_DIRECTORY, ERROR_OPERATION_UNDECLARED, @@ -37,17 +63,30 @@ load_catalog, ) from cloudfall.commands import CLI_COMMANDS +from cloudfall.dashboard import build_dashboard from cloudfall.decision import DECISION_DIRECTORY, DecisionError, DecisionStore -from cloudfall.domain import ResourceId +from cloudfall.domain import ( + ConnectionAddress, + Hostname, + LinuxUser, + ResourceId, + TcpPort, +) from cloudfall.inventory import PlatformInventory from cloudfall.observation import load_observations -from cloudfall.operations import UtcTimestamp, build_operations_view +from cloudfall.operations import FleetOperations, UtcTimestamp, build_operations_view from cloudfall.operator import ERROR_PROPOSAL_MISSING, OperatorError, ProposalStore from cloudfall.project import ( PROJECT_DIRECTORY_VARIABLE, + InitOptions, + ProjectDescription, ProjectError, + ProjectName, + init_project, + initialize_git, is_project, project_path, + resolve_installed_version, resolve_project_directory, ) from cloudfall.resources import default_schema_directory @@ -55,6 +94,9 @@ from cloudfall.service_evidence import ( DeploymentReceiptSet, DomainObservationSet, + EvidenceTimestamp, + SocketDomainNetworkClient, + inspect_domains, load_deployment_receipts, load_domain_observations, ) @@ -83,6 +125,10 @@ ) # 79 and 80 mean what they mean on cloudfall-engine; 81 and 82 stay the # engine's (artifact build, playbook run) for the commands that wrap it. +GIT_TIMEOUT_SECONDS = 30 +"""How long one git step of ``init`` may take.""" +_NOT_WRITABLE = frozenset({errno.EACCES, errno.EPERM, errno.EROFS}) + app.exit_code( "PROJECT_INVALID", 79, @@ -675,8 +721,8 @@ def audit(args: AuditArgs, _ctx: Ctx, fleet: Fleet) -> AuditPayload: @dataclass(frozen=True, slots=True, kw_only=True) -class ServicesStatusArgs(ProjectArgs): - """Arguments of ``services status``.""" +class EvidenceArgs(ProjectArgs): + """Options of a command that reads the evidence a fleet's view is built from.""" observed: Path = Flag( description="Directory containing observed-server JSON snapshots" @@ -696,6 +742,25 @@ def __post_init__(self) -> None: _inside_project(self.service_observed, "service-observed") _inside_project(self.deployments, "deployments") + def operations_view(self, fleet: Fleet) -> FleetOperations: + """Build the fleet's operations view from the evidence on disk.""" + deployments = fleet.path(self.deployments) + domains = fleet.path(self.service_observed) + try: + return build_operations_view( + fleet.inventory, + load_observations(fleet.path(self.observed), self.schemas), + load_deployment_receipts(deployments, self.schemas) + if deployments.is_dir() + else DeploymentReceiptSet.empty(), + load_domain_observations(domains, self.schemas) + if domains.is_dir() + else DomainObservationSet.empty(), + generated_at=UtcTimestamp.now(), + ) + except ConfigValidationError as error: + raise _config_invalid(error) from error + class ServicesPayload(Payload): """``services status``: each public service's lifecycle.""" @@ -715,26 +780,477 @@ class ServicesPayload(Payload): ), ], ) -def services_status( - args: ServicesStatusArgs, _ctx: Ctx, fleet: Fleet -) -> ServicesPayload: +def services_status(args: EvidenceArgs, _ctx: Ctx, fleet: Fleet) -> ServicesPayload: """Answer each declared service's lifecycle from the evidence on disk.""" - deployments = fleet.path(args.deployments) - domains = fleet.path(args.service_observed) + view = args.operations_view(fleet) + return ServicesPayload( + {"status": "ok", "services": [domain.as_dict() for domain in view.domains]} + ) + + +def _not_writable(error: OSError) -> Exception: + """Report a path the command must write as read-only or not ours.""" + message = f"cannot write {error.filename}: {error.strerror}" + return Exit.PERMISSION_DENIED(message, context={"code": "path_not_writable"}) + + +# init + + +@dataclass(frozen=True, slots=True, kw_only=True) +class InitArgs: + """Arguments of ``init``, which runs before any project exists.""" + + directory: Path = Arg( + default=Path(), + description=( + "Project directory to create; must be empty or absent (default: .)" + ), + ) + name: str | None = Flag( + default=None, + description=( + "Package name written to pyproject.toml (default: the directory name)" + ), + ) + description: str | None = Flag( + default=None, + description=( + "One line saying what the project manages, written to the README and " + "pyproject.toml" + ), + ) + + def __post_init__(self) -> None: + """Refuse a name or description the project files cannot hold.""" + try: + self.project_name() + self.project_description() + except ValueError as error: + raise ParseError(str(error)) from error + + def project_name(self) -> ProjectName: + """Return the package name, the directory's by default.""" + if self.name is None: + return ProjectName.from_directory(self.directory) + return ProjectName.from_boundary(self.name) + + def project_description(self) -> ProjectDescription | None: + """Return the one-line description, when one was given.""" + if self.description is None: + return None + return ProjectDescription.from_boundary(self.description) + + +@dataclass(frozen=True, slots=True) +class Scaffolded: + """A new project, the files written into it, and what to run next.""" + + effect: str + status: str + project: dict[str, object] = Out(ordered=True) + files: list[str] = Out(ordered=True) + next: list[str] = Out(ordered=True) + + +@app.command( + "init", + description="Create a new project: fleet, applications, and operations", + danger_level="mutating", + # Two git steps of up to GIT_TIMEOUT_SECONDS each, then local files. + timeout=3 * GIT_TIMEOUT_SECONDS, + exit_codes=["PRECONDITION", "PERMISSION_DENIED"], + subprocess=Subprocess("git"), + required_tools={"git": "2.24.0"}, + examples=[ + ("Create a project in a new directory", "cloudfall init fleet"), + ( + "Name and describe it", + "cloudfall init fleet --name acme-fleet --description 'Acme servers'", + ), + ], +) +def init(args: InitArgs, ctx: Ctx) -> Scaffolded: + """Lay out a new project in an empty or absent directory.""" try: - view = build_operations_view( - fleet.inventory, - load_observations(fleet.path(args.observed), args.schemas), - load_deployment_receipts(deployments, args.schemas) - if deployments.is_dir() - else DeploymentReceiptSet.empty(), - load_domain_observations(domains, args.schemas) - if domains.is_dir() - else DomainObservationSet.empty(), - generated_at=UtcTimestamp.now(), + options = InitOptions( + directory=args.directory, + name=args.project_name(), + version=resolve_installed_version(), + description=args.project_description(), + ) + scaffold = init_project( + options, + initialize_git=partial( + initialize_git, + run=partial(ctx.run, timeout=Timeout(GIT_TIMEOUT_SECONDS), check=False), + ), + ) + except ProjectError 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 + body = scaffold.as_dict() + return Scaffolded( + effect="created", + status="ok", + project=dict(cast("Mapping[str, object]", body["project"])), + files=list(scaffold.files), + next=list(cast("list[str]", body["next"])), + ) + + +# add ssh-key, server-type, server + + +add = app.group("add", description="Write a fleet resource into the project") + + +@dataclass(frozen=True, slots=True) +class ProjectDirectory: + """The project a command writes into, resolved but not yet validated.""" + + path: Path + + @classmethod + def acquire(cls, args: ProjectArgs, _ctx: Ctx) -> Self: + """Resolve the project directory before the handler runs.""" + try: + return cls(resolve_project_directory(args.project, os.environ, Path.cwd())) + except ProjectError as error: + raise Exit.PROJECT_INVALID( + error.detail, context={"code": error.code} + ) from error + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AddArgs(ProjectArgs): + """Options every ``add`` command shares.""" + + description: str | None = Flag( + default=None, description="One line saying what the resource is for" + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AddSshKeyArgs(AddArgs): + """Arguments of ``add ssh-key``.""" + + key_file: Path = Arg( + description="Public key file, such as ~/.ssh/id_ed25519.pub", + # A path to a public key: nothing in it or in its name is secret. + secret=False, + ) + owner: ResourceId = Flag(description="Who the key belongs to") + id: ResourceId | None = Flag( + default=None, description="Resource id (default: the owner)" + ) + environment: ResourceId = Flag( + default=ResourceId.from_boundary("production"), + description="Environment the key is for (default: production)", + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AddServerTypeArgs(AddArgs): + """Arguments of ``add server-type``.""" + + id: ResourceId = Arg( + description="Resource id, such as debian-application" + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AddServerArgs(AddArgs): + """Arguments of ``add server``.""" + + id: ResourceId = Arg( + description="Resource id, such as h1" + ) + address: str = Flag(description="IP address or hostname to connect to") + type: ResourceId = Flag( + default=ResourceId.from_boundary("debian-application"), + description=( + "Server type id; created when missing (default: debian-application)" + ), + ) + environment: ResourceId = Flag( + default=ResourceId.from_boundary("production"), + description="Environment the server belongs to (default: production)", + ) + hostname: str | None = Flag( + default=None, + description=( + "Hostname (default: the address when it is a hostname, else the id)" + ), + ) + ssh_user: str = Flag(default="root", description="SSH user (default: root)") + ssh_port: int = Flag(default=22, description="SSH port (default: 22)") + + def __post_init__(self) -> None: + """Refuse an address, user, port, or hostname a server cannot have.""" + try: + self.options() + except (TypeError, ValueError) as error: + raise ParseError(str(error)) from error + + def options(self) -> ServerOptions: + """Return the server to declare, as typed values.""" + return ServerOptions( + resource_id=self.id, + address=ConnectionAddress.from_boundary(self.address), + server_type=self.type, + environment=self.environment, + ssh_user=LinuxUser.from_boundary(self.ssh_user), + ssh_port=TcpPort.from_boundary(self.ssh_port), + hostname=( + Hostname.from_boundary(self.hostname) + if self.hostname is not None + else None + ), + description=self.description, ) + + +@dataclass(frozen=True, slots=True) +class Added: + """The resource files one ``add`` wrote into the project.""" + + effect: str + status: str + project: str + added: list[dict[str, object]] = Out(ordered=True) + + +def _added(result: AddResult) -> Added: + body = result.as_dict() + return Added( + effect="created", + status="ok", + project=str(result.project), + added=list(cast("list[dict[str, object]]", body["added"])), + ) + + +def _authoring_failed(error: AuthoringError) -> Exception: + context = {"code": error.code} + if error.code == ERROR_RESOURCE_EXISTS: + return Exit.CONFLICT(error.detail, context=context) + if error.code == ERROR_KEY_FILE_MISSING: + return Exit.NOT_FOUND(error.detail, context=context) + if error.code == ERROR_PROJECT_WRITE_FAILED: + return Exit.PERMISSION_DENIED(error.detail, context=context) + return Exit.PRECONDITION(error.detail, context=context) + + +_ADD_EXIT_CODES = [ + "PROJECT_INVALID", + "CONFIG_INVALID", + "CONFLICT", + "PERMISSION_DENIED", + "PRECONDITION", +] + + +@add.command( + "ssh-key", + description="Declare an SSH public key read from a file", + danger_level="mutating", + timeout=30, + supports_raw_payload=True, + exit_codes=[*_ADD_EXIT_CODES, "NOT_FOUND"], + examples=[ + ( + "Declare your own key", + "cloudfall add ssh-key ~/.ssh/id_ed25519.pub --owner alice", + ), + ], +) +def add_ssh_key_command( + args: AddSshKeyArgs, _ctx: Ctx, project: ProjectDirectory +) -> Added: + """Write one SshPublicKey resource into the project.""" + options = SshKeyOptions( + key_path=args.key_file.expanduser(), + owner=args.owner, + environment=args.environment, + resource_id=args.id, + description=args.description, + ) + try: + return _added(add_ssh_key(project.path, options, args.schemas)) + except AuthoringError as error: + raise _authoring_failed(error) from error except ConfigValidationError as error: raise _config_invalid(error) from error - return ServicesPayload( - {"status": "ok", "services": [domain.as_dict() for domain in view.domains]} + + +@add.command( + "server-type", + description="Declare a server type from the bundled Debian 13 baseline", + danger_level="mutating", + timeout=30, + supports_raw_payload=True, + exit_codes=_ADD_EXIT_CODES, + examples=[ + ("Declare the baseline type", "cloudfall add server-type debian-application"), + ], +) +def add_server_type_command( + args: AddServerTypeArgs, _ctx: Ctx, project: ProjectDirectory +) -> Added: + """Write one ServerType resource into the project.""" + options = ServerTypeOptions(resource_id=args.id, description=args.description) + try: + return _added(add_server_type(project.path, options, args.schemas)) + except AuthoringError as error: + raise _authoring_failed(error) from error + except ConfigValidationError as error: + raise _config_invalid(error) from error + + +@add.command( + "server", + description="Declare a server; creates its server type when missing", + danger_level="mutating", + timeout=30, + supports_raw_payload=True, + exit_codes=_ADD_EXIT_CODES, + examples=[ + ("Declare a server by address", "cloudfall add server h1 --address 192.0.2.10"), + ], +) +def add_server_command( + args: AddServerArgs, _ctx: Ctx, project: ProjectDirectory +) -> Added: + """Write one Server resource, and its ServerType when the project lacks it.""" + try: + return _added(add_server(project.path, args.options(), args.schemas)) + except AuthoringError as error: + raise _authoring_failed(error) from error + except ConfigValidationError as error: + raise _config_invalid(error) from error + + +# dashboard build, services inspect + + +dashboard = app.group("dashboard", description="Build operations dashboard artifacts") + + +@dataclass(frozen=True, slots=True, kw_only=True) +class DashboardBuildArgs(EvidenceArgs): + """Arguments of ``dashboard build``.""" + + output_dir: Path = Flag( + default=Path("tmp/dashboard"), + description="Dashboard output directory (default: tmp/dashboard)", + ) + + def __post_init__(self) -> None: + """Keep relative evidence and output paths inside the project.""" + EvidenceArgs.__post_init__(self) + _inside_project(self.output_dir, "output-dir") + + +@dataclass(frozen=True, slots=True) +class DashboardBuilt: + """The static dashboard written, and the fleet health it shows.""" + + effect: str + status: str + health: str + tasks: int + dashboard: dict[str, str] + + +@dashboard.command( + "build", + description="Build a static read-only operations dashboard", + danger_level="mutating", + timeout=120, + supports_raw_payload=True, + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PERMISSION_DENIED"], + examples=[ + ( + "Build the dashboard from fresh snapshots", + "cloudfall dashboard build --observed tmp/observed", + ), + ], +) +def dashboard_build( + args: DashboardBuildArgs, _ctx: Ctx, fleet: Fleet +) -> DashboardBuilt: + """Write the dashboard pages for the fleet's current evidence.""" + view = args.operations_view(fleet) + output = fleet.path(args.output_dir) + effect = "updated" if output.exists() else "created" + try: + artifacts = build_dashboard(view, output) + except OSError as error: + if error.errno not in _NOT_WRITABLE: + raise + raise _not_writable(error) from error + return DashboardBuilt( + effect=effect, + status="ok", + health=view.health.value, + tasks=len(view.tasks), + dashboard=artifacts.as_dict(), + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ServicesInspectArgs(ProjectArgs): + """Arguments of ``services inspect``.""" + + output_dir: Path = Flag( + default=Path("tmp/observed-services"), + description="Service observation directory (default: tmp/observed-services)", + ) + + def __post_init__(self) -> None: + """Keep a relative output path inside the project.""" + _inside_project(self.output_dir, "output-dir") + + +@dataclass(frozen=True, slots=True) +class ServicesInspected: + """The domain observation files written.""" + + effect: str + status: str + observations: list[str] = Out(ordered=True) + + +@services.command( + "inspect", + description="Collect DNS, TLS, origin, and public route evidence", + danger_level="mutating", + # DNS, TLS, and HTTP probes of every declared domain, one after another. + timeout=600, + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PERMISSION_DENIED"], + examples=[("Probe every declared domain", "cloudfall services inspect")], +) +def services_inspect( + args: ServicesInspectArgs, _ctx: Ctx, fleet: Fleet +) -> ServicesInspected: + """Probe each declared domain and write one observation per domain.""" + try: + paths = inspect_domains( + fleet.inventory, + fleet.path(args.output_dir), + SocketDomainNetworkClient(), + observed_at=EvidenceTimestamp.now(), + ) + except OSError as error: + if error.errno not in _NOT_WRITABLE: + raise + raise _not_writable(error) from error + return ServicesInspected( + effect="created", + status="ok", + observations=[str(path) for path in paths], ) diff --git a/sdk/src/cloudfall/authoring.py b/sdk/src/cloudfall/authoring.py index 6dbd5e6..1411054 100644 --- a/sdk/src/cloudfall/authoring.py +++ b/sdk/src/cloudfall/authoring.py @@ -46,10 +46,10 @@ BASELINE_ROOT_MINIMUM_BYTES = 20_000_000_000 """Smallest root filesystem the baseline server type accepts (20 GB).""" -_ERROR_KEY_FILE_MISSING = "ssh_key_file_missing" -_ERROR_KEY_FILE_INVALID = "ssh_key_file_invalid" -_ERROR_RESOURCE_EXISTS = "resource_exists" -_ERROR_PROJECT_WRITE_FAILED = "project_write_failed" +ERROR_KEY_FILE_MISSING = "ssh_key_file_missing" +ERROR_KEY_FILE_INVALID = "ssh_key_file_invalid" +ERROR_RESOURCE_EXISTS = "resource_exists" +ERROR_PROJECT_WRITE_FAILED = "project_write_failed" class AuthoringError(RuntimeError): @@ -135,7 +135,7 @@ def read_public_key(key_path: Path) -> OpenSshPublicKey: """Read one OpenSSH public key from a file such as ``~/.ssh/id_ed25519.pub``.""" if not key_path.is_file(): message = f"SSH public key file does not exist: {key_path}" - raise AuthoringError(_ERROR_KEY_FILE_MISSING, message) + raise AuthoringError(ERROR_KEY_FILE_MISSING, message) lines = [ line for line in key_path.read_text(encoding="utf-8").splitlines() @@ -143,12 +143,12 @@ def read_public_key(key_path: Path) -> OpenSshPublicKey: ] if len(lines) != 1: message = f"SSH public key file must hold exactly one key: {key_path}" - raise AuthoringError(_ERROR_KEY_FILE_INVALID, message) + raise AuthoringError(ERROR_KEY_FILE_INVALID, message) try: return OpenSshPublicKey.from_boundary(lines[0].strip()) except (TypeError, ValueError) as error: message = f"{key_path}: {error}" - raise AuthoringError(_ERROR_KEY_FILE_INVALID, message) from error + raise AuthoringError(ERROR_KEY_FILE_INVALID, message) from error def ssh_key_document( @@ -317,7 +317,7 @@ def _commit( path = _resource_path(project, kind, resource_id) if path.exists(): message = f"{kind.value}/{resource_id} already exists: {path}" - raise AuthoringError(_ERROR_RESOURCE_EXISTS, message) + raise AuthoringError(ERROR_RESOURCE_EXISTS, message) planned.append((kind, resource_id, path, document)) written: list[Path] = [] @@ -339,7 +339,7 @@ def _commit( for path in written: path.unlink() message = f"cannot write {error.filename}: {error.strerror}" - raise AuthoringError(_ERROR_PROJECT_WRITE_FAILED, message) from error + raise AuthoringError(ERROR_PROJECT_WRITE_FAILED, message) from error return AddResult( project=project, added=tuple( diff --git a/sdk/src/cloudfall/cli.py b/sdk/src/cloudfall/cli.py index 9c8aa1e..5687ebc 100644 --- a/sdk/src/cloudfall/cli.py +++ b/sdk/src/cloudfall/cli.py @@ -14,6 +14,7 @@ from argparse import Namespace from collections.abc import Callable, Mapping, Sequence + from cloudfall.domain import ResourceId from cloudfall.operations import FleetOperations from cloudfall.operator import AlertFeed, OperatorProposal @@ -37,17 +38,8 @@ root_parser, schema_version_argument, ) -from cloudfall.authoring import ( - AuthoringError, - ServerOptions, - ServerTypeOptions, - SshKeyOptions, - add_server, - add_server_type, - add_ssh_key, -) from cloudfall.catalog import CATALOG_DIRECTORY, OperationCatalog, load_catalog -from cloudfall.dashboard import RefreshInterval, build_dashboard +from cloudfall.dashboard import RefreshInterval from cloudfall.dashboard_server import ( EvidenceSources, ListenEndpoint, @@ -64,13 +56,6 @@ approve, propose, ) -from cloudfall.domain import ( - ConnectionAddress, - Hostname, - LinuxUser, - ResourceId, - TcpPort, -) from cloudfall.importer import ( ImportTargets, RenderImportError, @@ -134,14 +119,9 @@ ) from cloudfall.project import ( PROJECT_DIRECTORY_VARIABLE, - InitOptions, - ProjectDescription, ProjectError, - ProjectName, - init_project, is_project, project_context, - resolve_installed_version, ) from cloudfall.render_api import ( HttpRenderApiClient, @@ -157,9 +137,6 @@ from cloudfall.service_evidence import ( DeploymentReceiptSet, DomainObservationSet, - EvidenceTimestamp, - SocketDomainNetworkClient, - inspect_domains, load_deployment_receipts, load_domain_observations, ) @@ -1308,9 +1285,7 @@ def _main(argv: Sequence[str] | None) -> int: return _dispatch_from_inventory(arguments, source) with project_context(arguments.project, os.environ) as project_directory: arguments.project_directory = project_directory - without_state = _run_without_validated_fleet( - arguments, project_directory - ) + without_state = _run_without_validated_fleet(arguments) if without_state is not None: return without_state schema_directory = Path(arguments.schemas) @@ -1331,15 +1306,11 @@ def _main(argv: Sequence[str] | None) -> int: _OVERLAY_DIRECTORY = "tmp/cloudfall" -def _run_without_validated_fleet( - arguments: Namespace, project_directory: Path -) -> int | None: +def _run_without_validated_fleet(arguments: Namespace) -> int | None: """Run the commands that read no fleet, or return ``None`` for the rest. - `add` and `import` write the fleet rather than read it. + `import` writes the fleet rather than reads it (`add` runs on treaty). """ - if arguments.command == "add": - return _run_add(arguments, project_directory) if arguments.command == "import": return _run_import_render(arguments) return None @@ -1384,87 +1355,6 @@ def _dispatch_from_inventory(arguments: Namespace, source: InventorySource) -> i return _dispatch(arguments, read.config, schema_directory) -def _run_init(arguments: Namespace) -> int: - directory = Path(arguments.directory) - try: - name = ( - ProjectName.from_boundary(arguments.name) - if arguments.name is not None - else ProjectName.from_directory(directory) - ) - options = InitOptions( - directory=directory, - name=name, - version=resolve_installed_version(), - description=( - ProjectDescription.from_boundary(arguments.description) - if arguments.description is not None - else None - ), - ) - scaffold = init_project(options) - except ValueError as error: - payload = { - "status": "error", - "error": {"code": "invalid_argument", "message": str(error)}, - } - return write_error(payload, 2) - except ProjectError as error: - return write_error(error.as_dict(), 2) - write_result(scaffold.as_dict()) - return 0 - - -def _run_add(arguments: Namespace, project_directory: Path) -> int: - schema_directory = Path(arguments.schemas) - try: - if arguments.add_command == "ssh-key": - key_options = SshKeyOptions( - key_path=Path(arguments.key_file).expanduser(), - owner=ResourceId.from_boundary(arguments.owner), - environment=ResourceId.from_boundary(arguments.environment), - resource_id=( - ResourceId.from_boundary(arguments.id) - if arguments.id is not None - else None - ), - description=arguments.description, - ) - result = add_ssh_key(project_directory, key_options, schema_directory) - elif arguments.add_command == "server-type": - type_options = ServerTypeOptions( - resource_id=ResourceId.from_boundary(arguments.id), - description=arguments.description, - ) - result = add_server_type(project_directory, type_options, schema_directory) - else: - server_options = ServerOptions( - resource_id=ResourceId.from_boundary(arguments.id), - address=ConnectionAddress.from_boundary(arguments.address), - server_type=ResourceId.from_boundary(arguments.type), - environment=ResourceId.from_boundary(arguments.environment), - ssh_user=LinuxUser.from_boundary(arguments.ssh_user), - ssh_port=TcpPort.from_boundary(arguments.ssh_port), - hostname=( - Hostname.from_boundary(arguments.hostname) - if arguments.hostname is not None - else None - ), - description=arguments.description, - ) - result = add_server(project_directory, server_options, schema_directory) - except (TypeError, ValueError) as error: - payload = { - "status": "error", - "error": {"code": "invalid_argument", "message": str(error)}, - } - return write_error(payload, 2) - except AuthoringError as error: - return write_error(error.as_dict(), 2) - write_result(result.as_dict()) - return 0 - - def _run_import_render(arguments: Namespace) -> int: try: targets = ImportTargets( @@ -1495,7 +1385,6 @@ def _dispatch( "backup:verify": _run_backup_verify, "secrets:render": _run_secrets_render, "data:migrate": _run_data_migrate, - "dashboard:build": _run_dashboard_build, "dashboard:serve": _run_dashboard_serve, "deploy": _run_deploy, "health": _run_health, @@ -1505,7 +1394,6 @@ def _dispatch( "operator:run": _run_operator_run, "restart": _run_restart, "rollback": _run_rollback, - "services:inspect": _run_services_inspect, } key = _command_key(arguments) try: @@ -1594,7 +1482,6 @@ def _run_changelog(arguments: Namespace) -> int: _PROJECTLESS_COMMANDS: Mapping[str, Callable[[Namespace], int]] = { - "init": _run_init, "changelog": _run_changelog, } """The commands that must run before a project exists, so none is resolved.""" @@ -1897,40 +1784,6 @@ def _run_operator_approve( return 0 if verified else 1 -def _run_services_inspect( - arguments: Namespace, state: ValidatedConfig, _schema_directory: Path -) -> int: - paths = inspect_domains( - PlatformInventory.from_state(state), - Path(arguments.output_dir), - SocketDomainNetworkClient(), - observed_at=EvidenceTimestamp.now(), - ) - write_result( - { - "status": "ok", - "observations": [str(path) for path in paths], - } - ) - return 0 - - -def _run_dashboard_build( - arguments: Namespace, state: ValidatedConfig, schema_directory: Path -) -> int: - operations = _service_operations(arguments, state, schema_directory) - artifacts = build_dashboard(operations, Path(arguments.output_dir)) - write_result( - { - "status": "ok", - "health": operations.health.value, - "tasks": len(operations.tasks), - "dashboard": artifacts.as_dict(), - } - ) - return 0 - - def _run_dashboard_serve( arguments: Namespace, _state: ValidatedConfig, schema_directory: Path ) -> int: diff --git a/sdk/src/cloudfall/commands.py b/sdk/src/cloudfall/commands.py index b34d830..3bc74b3 100644 --- a/sdk/src/cloudfall/commands.py +++ b/sdk/src/cloudfall/commands.py @@ -114,12 +114,16 @@ def treaty_schema(self) -> dict[str, object]: ``status`` moves into ``data`` beside the other keys, every key is written on every answer (an optional one as an empty object), and a - failed run keeps ``data`` only for a verdict such as drift. + failed run keeps ``data`` only for a verdict such as drift. A command + treaty runs as mutating also writes ``effect`` (REQ-C-003). """ - data_keys = tuple( - OutputKey(key.name, key.stability) - for key in self.keys - if key.name != "error" + data_keys = ( + *( + OutputKey(key.name, key.stability) + for key in self.keys + if key.name != "error" + ), + OutputKey("effect", optional=True), ) data = { **_object_schema(data_keys), @@ -266,6 +270,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.PROJECT, "lay out a new project directory", output=(_shape("status", "project", "files", "next"),), + treaty=True, ), CommandContract( "cloudfall", @@ -280,6 +285,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.PROJECT, "declare an `SshPublicKey` from a public key file", output=_ADDED, + treaty=True, ), CommandContract( "cloudfall", @@ -287,6 +293,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.PROJECT, "declare a `ServerType` from the bundled Debian 13 baseline", output=_ADDED, + treaty=True, ), CommandContract( "cloudfall", @@ -294,6 +301,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.PROJECT, "declare a `Server`, creating its type on first use", output=_ADDED, + treaty=True, ), CommandContract( "cloudfall", @@ -408,6 +416,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "probe DNS, TLS, origin, and public routes into evidence", output=(_shape("status", "observations"),), + treaty=True, ), CommandContract( "cloudfall", @@ -440,6 +449,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "build the static read-only operations dashboard", output=(_shape("status", "health", "tasks", "dashboard"),), + treaty=True, ), CommandContract( "cloudfall", diff --git a/sdk/src/cloudfall/project.py b/sdk/src/cloudfall/project.py index 715e5b1..0f37221 100644 --- a/sdk/src/cloudfall/project.py +++ b/sdk/src/cloudfall/project.py @@ -23,13 +23,13 @@ from enum import Enum from importlib import metadata from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol from cloudfall.commands import EXIT_CODES, CommandEffect, commands_with_effect from cloudfall.domain import ResourceKind if TYPE_CHECKING: - from collections.abc import Callable, Iterator, Mapping + from collections.abc import Callable, Iterator, Mapping, Sequence _PROJECT_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$") _RELEASE_VERSION_PATTERN = re.compile( @@ -359,22 +359,45 @@ def _git_binary() -> str: return git -def _run_git(directory: Path, *arguments: str) -> subprocess.CompletedProcess[str]: +class GitRun(Protocol): + """How one finished git invocation reads: ``ctx.run`` or ``subprocess``.""" + + @property + def returncode(self) -> int: + """The exit code.""" + ... + + @property + def stdout(self) -> str: + """What git printed on stdout.""" + ... + + @property + def stderr(self) -> str: + """What git printed on stderr.""" + ... + + +type GitRunner = Callable[[Sequence[str]], GitRun] +"""Runs one git argv to completion without raising on a non-zero exit.""" + + +def _subprocess_git(argv: Sequence[str]) -> GitRun: try: return subprocess.run( # noqa: S603 - fixed binary, fixed arguments. - [_git_binary(), "-C", str(directory), *arguments], + list(argv), check=False, capture_output=True, text=True, timeout=_GIT_TIMEOUT_SECONDS, ) except subprocess.TimeoutExpired as error: - message = f"git {arguments[0]} in {directory} timed out: {error}" + message = f"git {argv[3]} timed out: {error}" raise ProjectError(_ERROR_GIT_FAILED, message) from error -def _git_or_fail(directory: Path, *arguments: str) -> str: - completed = _run_git(directory, *arguments) +def _git_or_fail(run: GitRunner, directory: Path, *arguments: str) -> str: + completed = run([_git_binary(), "-C", str(directory), *arguments]) if completed.returncode != 0: message = ( f"git {arguments[0]} in {directory} failed with exit code " @@ -384,9 +407,11 @@ def _git_or_fail(directory: Path, *arguments: str) -> str: return completed.stdout -def _initialize_git(directory: Path) -> GitSetup: +def initialize_git(directory: Path, run: GitRunner) -> GitSetup: """Create a repository in the directory unless one already encloses it.""" - enclosing = _run_git(directory, "rev-parse", "--is-inside-work-tree") + enclosing = run( + [_git_binary(), "-C", str(directory), "rev-parse", "--is-inside-work-tree"] + ) if enclosing.returncode == 0 and enclosing.stdout.strip() == "true": return GitSetup.ENCLOSED if enclosing.returncode != _GIT_NOT_A_REPOSITORY_EXIT_CODE: @@ -395,10 +420,14 @@ def _initialize_git(directory: Path) -> GitSetup: f"{enclosing.returncode}: {enclosing.stderr.strip()}" ) raise ProjectError(_ERROR_GIT_FAILED, message) - _git_or_fail(directory, "init", "--quiet") + _git_or_fail(run, directory, "init", "--quiet") return GitSetup.INITIALIZED +def _initialize_git(directory: Path) -> GitSetup: + return initialize_git(directory, _subprocess_git) + + def init_project( options: InitOptions, initialize_git: Callable[[Path], GitSetup] | None = None, diff --git a/sdk/tests/test_arguments.py b/sdk/tests/test_arguments.py index e7a649f..77a6852 100644 --- a/sdk/tests/test_arguments.py +++ b/sdk/tests/test_arguments.py @@ -207,7 +207,7 @@ def test_relative_output_leaving_the_project_is_rejected( observed = tmp_path / "observed" observed.mkdir() - payload = _usage_error( + payload = _treaty_usage_error( [ "dashboard", "build", @@ -221,7 +221,7 @@ def test_relative_output_leaving_the_project_is_rejected( capsys, ) - assert "leaves the project directory" in str(payload["error"]) + assert "escapes its base directory" in json.dumps(payload["error"]) assert not (EXAMPLES.parent / "escape").exists() @@ -248,5 +248,5 @@ def test_absolute_output_outside_the_project_is_allowed( ) assert exit_code == 0 - assert json.loads(capsys.readouterr().out)["status"] == "ok" + assert json.loads(capsys.readouterr().out)["data"]["status"] == "ok" assert (output / "index.html").is_file() diff --git a/sdk/tests/test_authoring.py b/sdk/tests/test_authoring.py index db3a67e..6a0256d 100644 --- a/sdk/tests/test_authoring.py +++ b/sdk/tests/test_authoring.py @@ -203,9 +203,9 @@ def test_cli_add_reports_bad_arguments_as_json( exit_code = main(["add", "server", "H1", "--address", "203.0.113.10", *common]) - payload = json.loads(capsys.readouterr().err) + payload = json.loads(capsys.readouterr().out) assert exit_code == 2 - assert payload["error"]["code"] == "invalid_argument" + assert payload["error"]["code"] == "ARG_ERROR" assert not (project / "servers" / "H1.yaml").exists() @@ -231,10 +231,10 @@ def test_cli_add_into_a_read_only_project_changes_nothing( for directory in (project, *(p for p in project.rglob("*") if p.is_dir())): directory.chmod(0o755) - captured = capsys.readouterr() - assert (code, captured.out) == (2, "") - error = json.loads(captured.err)["error"] - assert error["code"] == "project_write_failed" + error = json.loads(capsys.readouterr().out)["error"] + assert code == 7 + assert error["code"] == "PERMISSION_DENIED" + assert error["context"]["code"] == "project_write_failed" assert "Permission denied" in error["message"] assert _files(project) == before @@ -258,14 +258,13 @@ def test_add_removes_what_it_wrote_when_a_later_file_fails( finally: servers.chmod(0o755) - assert code == 2 - assert json.loads(capsys.readouterr().err)["error"]["code"] == ( - "project_write_failed" - ) + assert code == 7 + error = json.loads(capsys.readouterr().out)["error"] + assert error["context"]["code"] == "project_write_failed" assert _files(project) == before -def test_any_command_reports_a_read_only_path_as_json( +def test_init_reports_a_read_only_path_as_json( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: parent = tmp_path / "read-only" @@ -276,9 +275,9 @@ def test_any_command_reports_a_read_only_path_as_json( finally: parent.chmod(0o755) - captured = capsys.readouterr() - assert (code, captured.out) == (1, "") - document = json.loads(captured.err) + document = json.loads(capsys.readouterr().out) + assert code == 7 assert document["ok"] is False - assert document["error"]["code"] == "path_not_writable" + assert document["error"]["code"] == "PERMISSION_DENIED" + assert document["error"]["context"]["code"] == "path_not_writable" assert str(parent / "project") in document["error"]["message"] diff --git a/sdk/tests/test_importer.py b/sdk/tests/test_importer.py index 9deca85..e225ed8 100644 --- a/sdk/tests/test_importer.py +++ b/sdk/tests/test_importer.py @@ -225,3 +225,31 @@ def test_cli_import_render_emits_structured_output( assert payload["status"] == "ok" assert payload["data"]["components"] == ["acme-api", "acme-worker"] assert payload["data"]["report"].endswith("IMPORT-REPORT.md") + + +def test_an_argparse_command_reports_a_read_only_path_as_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + blueprint = tmp_path / "render.yaml" + blueprint.write_text(BLUEPRINT, encoding="utf-8") + read_only = tmp_path / "read-only" + read_only.mkdir() + read_only.chmod(0o555) + try: + code = main( + [ + *("import", "render", str(blueprint), "--project", str(tmp_path)), + *("--application", "acme", "--server", "h1"), + *("--output-dir", str(read_only / "config")), + *("--env-dir", str(read_only / "env"), "--schemas", str(SCHEMAS)), + ] + ) + finally: + read_only.chmod(0o755) + + captured = capsys.readouterr() + assert (code, captured.out) == (1, "") + document = json.loads(captured.err) + assert document["error"]["code"] == "path_not_writable" + assert document["error"]["exit_code"] == 1 + assert str(read_only) in document["error"]["message"] diff --git a/sdk/tests/test_output.py b/sdk/tests/test_output.py index 7c867cd..652acbc 100644 --- a/sdk/tests/test_output.py +++ b/sdk/tests/test_output.py @@ -439,17 +439,10 @@ def test_help_ends_with_every_exit_code( def test_the_error_carries_the_code_the_process_exits_with( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - read_only = tmp_path / "read-only" - read_only.mkdir() - read_only.chmod(0o555) - try: - cases = [ - (_run(["deploy"], capsys), 2), - (_run(["observe", "--project", str(tmp_path)], capsys), 2), - (_run(["init", str(read_only / "project")], capsys), 1), - ] - finally: - read_only.chmod(0o755) + cases = [ + (_run(["deploy"], capsys), 2), + (_run(["observe", "--project", str(tmp_path)], capsys), 2), + ] for (code, _, err), expected in cases: assert code == expected diff --git a/sdk/tests/test_project.py b/sdk/tests/test_project.py index afa2dee..6cf1c8b 100644 --- a/sdk/tests/test_project.py +++ b/sdk/tests/test_project.py @@ -230,7 +230,7 @@ def test_cli_init_pins_the_version_it_runs( payload = json.loads(capsys.readouterr().out) assert exit_code == 0 - assert payload["status"] == "ok" + assert (payload["data"]["status"], payload["data"]["effect"]) == ("ok", "created") assert payload["data"]["project"]["name"] == "fleet" assert payload["data"]["project"]["version"] == installed assert payload["data"]["project"]["git"] == "initialized" @@ -253,28 +253,28 @@ def test_cli_init_accepts_a_description( exit_code = main(["init", str(tmp_path / "other"), "--description", " "]) - payload = json.loads(capsys.readouterr().err) + payload = json.loads(capsys.readouterr().out) assert exit_code == 2 - assert payload["error"]["code"] == "invalid_argument" + assert payload["error"]["code"] == "ARG_ERROR" def test_cli_init_reports_errors_as_json( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - with pytest.raises(SystemExit) as exit_info: - main(["init", str(tmp_path), "--rev", "abc"]) + exit_code = main(["init", str(tmp_path), "--rev", "abc"]) - payload = json.loads(capsys.readouterr().err) - assert exit_info.value.code == 2 - assert payload["error"]["code"] == "invalid_argument" - assert "--rev" in payload["error"]["message"] + payload = json.loads(capsys.readouterr().out) + assert exit_code == 2 + assert payload["error"]["code"] == "ARG_ERROR" + assert "--rev" in json.dumps(payload["error"]) (tmp_path / "x").write_text("", encoding="utf-8") exit_code = main(["init", str(tmp_path)]) - payload = json.loads(capsys.readouterr().err) - assert exit_code == 2 - assert payload["error"]["code"] == "project_directory_not_empty" + payload = json.loads(capsys.readouterr().out) + assert exit_code == 4 + assert payload["error"]["code"] == "PRECONDITION" + assert payload["error"]["context"]["code"] == "project_directory_not_empty" def test_fresh_project_validates_as_empty(tmp_path: Path) -> None: