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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,36 @@ 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:** 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`,
`operations decisions`, `operator list`, `operator show`, `audit` and
`services status`. `cloudfall manifest` lists them; every other command,
root `--help` and `--version` stay on argparse for now. Their answer is a
treaty envelope (`ok`, `data`, `error`, `meta`, `warnings`): the verdict
moves from the top-level `status` to `data.status`, and the other `data`
keys are unchanged. `inventory show` writes `data.ansible` as `{}` for a
project. Failures are an envelope on stdout with their own exit codes:
79 `PROJECT_INVALID`, 80 `CONFIG_INVALID` (resources or evidence files),
85 `INVENTORY_UNREADABLE`, 86 `RECORD_INVALID`, 5 `NOT_FOUND` for an
undeclared operation or a missing proposal, 2 `ARG_ERROR` for bad input;
the old snake_case code is in `error.context.code`. `audit` exits 83
`DRIFT` (was 1) or 84 `UNKNOWN` (was 3) with the report in `data`; until
treaty #181 is fixed, that report's lists come back sorted rather than in
the order the servers and checks were declared. Pick
the format with `--format json`: `--output` after one of these commands
is refused, since treaty's `--output` names a file. Relative paths still
resolve against the project, without changing the working directory
- `cloudfall-engine` runs Ansible and git through treaty's `ctx.run`
(treaty 1.0.0rc10), so `--timeout` and Ctrl-C stop the whole process
group, and secrets are redacted from the play log. The play log streams
to stderr at a terminal; elsewhere, such as under an agent or in CI, it
shows only with `-v`, one JSON line per log line. A failed playbook
carries the last 4096 characters of the log in `error.context.output`.
`playbook run` reports `meta.dry_run: true` under `--dry-run`. A git step
that runs past its 600-second limit now exits 10 `TIMEOUT`, not 81 with
`artifact_git_timeout`. `cloudfall-engine doctor` checks for
ansible-playbook 2.21 and git 2.24
- Every JSON document has the same six top-level keys: `ok` (true exactly
when the exit code is 0), `status` (the command's verdict, such as `ok`,
`plan`, `drift` or `unhealthy`), `data` (the command's payload, which
Expand Down Expand Up @@ -111,6 +141,11 @@ Notable changes to Cloudfall. The format follows

### Fixed

- `migrate --build` and the `cloudfall-mcp` `build_artifact` tool read the
release from the engine's treaty envelope, under `data`. Since the engine
moved to treaty every build step failed with "artifact builder returned no
release id". `build_artifact` now returns the engine's `data` keys
(`git_ref`, `archive_sha256`, ...) beside `status`
- The time baseline accepts a host whose clock another daemon keeps
(ntp, ntpsec, chrony). `timedatectl set-ntp` drives systemd-timesyncd
only, so on such hosts check mode reported "Would enable network time
Expand Down
9 changes: 6 additions & 3 deletions engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ uv run cloudfall-engine playbook run engine/ansible/playbooks/proxy.yml \
```

`--dry-run` (Ansible's check mode), `--diff`, `--syntax-check`, `--limit`,
`--tags`, and `--extra-vars` pass through to `ansible-playbook`. Ansible's play
log streams to stderr as it runs; stdout carries one JSON document when the run
ends. A project uses this instead of
`--tags`, and `--extra-vars` pass through to `ansible-playbook`. At a terminal,
Ansible's play log streams to stderr as it runs; elsewhere, such as under an
agent or in CI, pass `-v` to see it. stdout carries one JSON document when the
run ends, and a failed run keeps the end of the log in `error.context.output`.
`cloudfall-engine doctor` checks that ansible-playbook and git are installed.
A project uses this instead of
setting `ANSIBLE_CONFIG` against a checkout; the `task` targets in this
repository still call `ansible-playbook` directly because they run from the
source tree.
Expand Down
61 changes: 35 additions & 26 deletions engine/src/cloudfall_engine/artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import json
import re
import shutil
import subprocess
import tarfile
import tempfile
from dataclasses import dataclass
Expand All @@ -22,18 +21,25 @@
from jsonschema.exceptions import ValidationError

if TYPE_CHECKING:
from collections.abc import Callable, Sequence

from cloudfall.inventory import ComponentInventory, PlatformInventory
from treaty import Completed

Runner = Callable[[Sequence[str]], Completed]
"""Runs one git argv to completion without raising on a non-zero exit"""

_ARTIFACT_SCHEMA = "artifact.schema.json"
GIT_TIMEOUT_SECONDS = 600
GIT_MINIMUM_VERSION = "2.24.0"
"""The first git with ``--end-of-options``, which ref resolution passes"""
_SHORT_COMMIT_LENGTH = 7
GIT_REF_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/@^~-]{0,199}")
_ERROR_COMPONENT_MISSING = "artifact_component_missing"
_ERROR_SUBDIRECTORY_MISSING = "artifact_subdirectory_missing"
_ERROR_REF_INVALID = "artifact_ref_invalid"
_ERROR_GIT_MISSING = "artifact_git_missing"
_ERROR_GIT_FAILED = "artifact_git_failed"
_ERROR_GIT_TIMEOUT = "artifact_git_timeout"


class ArtifactBuildError(RuntimeError):
Expand Down Expand Up @@ -83,28 +89,43 @@ def as_dict(self) -> dict[str, object]:
}


def build_artifact(
def build_artifact( # noqa: PLR0913 - the build inputs plus its git runner.
inventory: PlatformInventory,
component_id: str,
git_ref: str,
output_directory: Path,
schema_directory: Path,
*,
run: Runner,
) -> BuiltArtifact:
"""Clone the component repository at a ref and package a release."""
"""Clone the component repository at a ref and package a release.

``run`` starts git: the CLI passes ``ctx.run``, so treaty owns its time
limit, locale, and cancellation.
"""
component = _component(inventory, component_id)
_validate_ref(git_ref)
built_at = datetime.now(tz=UTC)
built_at_text = built_at.strftime("%Y-%m-%dT%H:%M:%SZ")
with tempfile.TemporaryDirectory(prefix="cloudfall-artifact-") as workdir:
checkout = Path(workdir) / "source"
_run_git("clone", "--quiet", component.repository.url.value, str(checkout))
_run_git(
run, "clone", "--quiet", component.repository.url.value, str(checkout)
)
# A fresh clone has local branches only for the default branch, and
# bare branch names trigger git's remote-branch DWIM, which conflicts
# with --detach. Resolving the ref to a commit first supports
# branches, tags, and commits uniformly.
commit = _resolve_ref_commit(checkout, git_ref)
commit = _resolve_ref_commit(run, checkout, git_ref)
_run_git(
"-C", str(checkout), "checkout", "--quiet", "--detach", commit, "--"
run,
"-C",
str(checkout),
"checkout",
"--quiet",
"--detach",
commit,
"--",
)
source_root = checkout
if component.repository.subdirectory is not None:
Expand Down Expand Up @@ -197,11 +218,12 @@ def _validate_ref(git_ref: str) -> None:
raise ArtifactBuildError(_ERROR_REF_INVALID, detail)


def _resolve_ref_commit(checkout: Path, git_ref: str) -> str:
def _resolve_ref_commit(run: Runner, checkout: Path, git_ref: str) -> str:
candidates = (git_ref, f"origin/{git_ref}")
for candidate in candidates:
try:
return _run_git(
run,
"-C",
str(checkout),
"rev-parse",
Expand All @@ -217,30 +239,17 @@ def _resolve_ref_commit(checkout: Path, git_ref: str) -> str:
raise ArtifactBuildError(_ERROR_GIT_FAILED, detail)


def _run_git(*arguments: str) -> str:
def _run_git(run: Runner, *arguments: str) -> str:
binary = shutil.which("git")
if binary is None:
detail = "git is not installed on the build host"
raise ArtifactBuildError(_ERROR_GIT_MISSING, detail)
try:
completed = subprocess.run( # noqa: S603 - fixed binary, validated args.
[binary, *arguments],
capture_output=True,
check=True,
text=True,
timeout=GIT_TIMEOUT_SECONDS,
)
except subprocess.CalledProcessError as error:
detail = (
f"git {' '.join(arguments[:2])} failed: {error.stderr.strip()}"
)
raise ArtifactBuildError(_ERROR_GIT_FAILED, detail) from error
except subprocess.TimeoutExpired as error:
completed = run((binary, *arguments))
if completed.returncode != 0:
detail = (
f"git {' '.join(arguments[:2])} exceeded "
f"{GIT_TIMEOUT_SECONDS} seconds"
f"git {' '.join(arguments[:2])} failed: {completed.stderr.strip()}"
)
raise ArtifactBuildError(_ERROR_GIT_TIMEOUT, detail) from error
raise ArtifactBuildError(_ERROR_GIT_FAILED, detail)
return completed.stdout


Expand Down
45 changes: 35 additions & 10 deletions engine/src/cloudfall_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

Built on treaty: every run answers one JSON envelope on stdout, a failure
carries a declared exit code, and ``cloudfall-engine manifest`` describes
each command. Ansible's play log streams to stderr.
each command. Ansible and git run through ``ctx.run``; Ansible's play log
streams to stderr on a terminal or under ``-v``.
"""

import json
import os
from dataclasses import dataclass
from functools import partial
from importlib.metadata import version
from pathlib import Path
from typing import TYPE_CHECKING, Self
Expand All @@ -21,20 +23,22 @@
)
from cloudfall.resources import default_engine_directory, default_schema_directory
from cloudfall.validation import ConfigValidationError, validate_config
from treaty import App, Arg, Ctx, Exit, Flag, Out, ParseError
from treaty import App, Arg, Ctx, Exit, Flag, Out, ParseError, Subprocess, Timeout

from cloudfall_engine.ansible_inventory import render_ansible_inventory
from cloudfall_engine.artifact import (
GIT_MINIMUM_VERSION,
GIT_REF_PATTERN,
GIT_TIMEOUT_SECONDS,
ArtifactBuildError,
build_artifact,
)
from cloudfall_engine.playbook import (
ANSIBLE_MINIMUM_VERSION,
PlaybookError,
PlaybookRun,
bundled_playbooks,
execute_playbook,
playbook_command,
resolve_playbook,
)

Expand Down Expand Up @@ -83,7 +87,10 @@
description="ansible-playbook exited non-zero; hosts may be partly converged",
retryable=False,
side_effects="partial",
suggestion="read the Ansible log on stderr, fix the failing task, and run again",
suggestion=(
"read the end of the Ansible log in error.context.output, or run "
"again with -v to stream all of it; fix the failing task, and run again"
),
)


Expand Down Expand Up @@ -225,6 +232,8 @@ class BuiltRelease:
danger_level="mutating",
exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "ARTIFACT_BUILD_FAILED"],
timeout=3 * GIT_TIMEOUT_SECONDS,
subprocess=Subprocess("git"),
required_tools={"git": GIT_MINIMUM_VERSION},
supports_raw_payload=True,
examples=[
(
Expand All @@ -233,7 +242,7 @@ class BuiltRelease:
),
],
)
def build(args: BuildArgs, _ctx: Ctx, project: Project) -> BuiltRelease:
def build(args: BuildArgs, ctx: Ctx, project: Project) -> BuiltRelease:
"""Clone the component at ``--ref`` and package it under the project."""
try:
built = build_artifact(
Expand All @@ -242,6 +251,9 @@ def build(args: BuildArgs, _ctx: Ctx, project: Project) -> BuiltRelease:
args.ref,
project.path(args.output_dir),
args.schemas,
run=partial(
ctx.run, timeout=Timeout(GIT_TIMEOUT_SECONDS), check=False
),
)
except ArtifactBuildError as error:
raise Exit.ARTIFACT_BUILD_FAILED(
Expand Down Expand Up @@ -380,11 +392,14 @@ class Played:
"run",
description=(
"Run one bundled or project playbook against the inventory; "
"the Ansible log streams to stderr"
"the Ansible log streams to stderr at a terminal or under -v"
),
danger_level="mutating",
exit_codes=["PRECONDITION", "PLAYBOOK_FAILED"],
timeout=None,
subprocess=Subprocess("ansible-playbook"),
required_tools={"ansible-playbook": ANSIBLE_MINIMUM_VERSION},
external=False,
supports_raw_payload=True,
examples=[
(
Expand All @@ -398,20 +413,30 @@ class Played:
),
],
)
def run_playbook(args: RunArgs, _ctx: Ctx) -> Played:
def run_playbook(args: RunArgs, ctx: Ctx) -> Played:
"""Run ansible-playbook in the foreground and report how it ended."""
run = _playbook_run(args)
try:
returncode = execute_playbook(run, args.engine)
command = playbook_command(run, args.engine)
except PlaybookError as error:
raise Exit.PRECONDITION(
error.detail, context={"code": error.code}
) from error
if returncode != 0:
# Stdout belongs to the envelope, so treaty streams Ansible's play log to
# stderr line by line, redacted, and keeps its tail for a failure.
completed = ctx.run(
command.argv, env=command.environment, check=False, stream=True
)
if completed.returncode != 0:
returncode = completed.returncode
message = f"the playbook failed: ansible-playbook exited {returncode}"
raise Exit.PLAYBOOK_FAILED(
message,
context={"returncode": returncode, "playbook": str(run.playbook)},
context={
"returncode": returncode,
"playbook": str(run.playbook),
"output": completed.stdout,
},
)
return Played(_effect(args), run.playbook, run.inventory_file)

Expand Down
18 changes: 2 additions & 16 deletions engine/src/cloudfall_engine/playbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@

import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

ANSIBLE_MINIMUM_VERSION = "2.21.0"
"""The ansible-core release the bundled roles are written against"""
_PLAYBOOK_SUFFIX = ".yml"
_ERROR_PLAYBOOK_MISSING = "playbook_missing"
_ERROR_INVENTORY_MISSING = "inventory_missing"
Expand Down Expand Up @@ -163,17 +163,3 @@ def playbook_command(run: PlaybookRun, engine_directory: Path) -> PlaybookComman
),
}
return PlaybookCommand(argv=tuple(argv), environment=environment)


def execute_playbook(run: PlaybookRun, engine_directory: Path) -> int:
"""Run the playbook in the foreground and return its exit code."""
command = playbook_command(run, engine_directory)
# Stdout belongs to the caller's result document, so Ansible's play log
# streams to stderr, live, for the person watching the run.
completed = subprocess.run( # noqa: S603 - fixed binary, validated args.
command.argv,
env={**os.environ, **command.environment},
stdout=sys.__stderr__,
check=False,
)
return completed.returncode
Loading
Loading