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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ and versions are tracked in the repo-root `VERSION` file.
- Initialized the repository with the Base-managed repo baseline.
- Added the Northstar reference consumer with nested status and release commands.
- Added a five-minute scenario-driven learning path with CI-checked command examples.
- Added lifecycle safety examples for dry-run, structured output and errors,
redacted diagnostics, temporary paths, and cleanup.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ boundary explained beside each scenario, see the
Base-CLI output API.
- The `--json` option wraps command output in Base-CLI's versioned success or
error envelope.
- The [lifecycle safety guide](docs/lifecycle-safety.md) shows dry-run safety,
structured errors, redacted diagnostics, temporary paths, and cleanup.

## Framework boundary

Expand Down
80 changes: 80 additions & 0 deletions docs/lifecycle-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Lifecycle safety and automation contracts

Northstar uses a local reconciliation fixture to show the boundaries a
production-shaped command should make explicit. It never calls a cloud API or
changes an external system.

## Dry-run is side-effect free

The regular command persists a local `last-reconciliation.json` state record
under the Base-CLI runtime state directory and uses a managed temporary input
while it runs:

```console
$ northstar --quiet release reconcile --version 2.5.0 --format json
[{"environment":"dev","services":3,"target_version":"2.5.0","action":"reconciled","external_changes":false}]
```

The lifecycle dry-run flag changes the action and skips the local state write:

```console
$ northstar --quiet --dry-run release reconcile --version 2.5.0 --format json
[{"environment":"dev","services":3,"target_version":"2.5.0","action":"would-reconcile","external_changes":false}]
```

Both paths report `external_changes: false` because this repository is an
offline teaching consumer. The tests distinguish the local state file and
assert that dry-run leaves it absent. Base-CLI removes the temporary input
through the consumer cleanup hook after a normal run.

## Human and machine contracts

Human output remains the default. A consumer can select a record format and,
when needed, the versioned lifecycle envelope:

```console
$ northstar --quiet --json release reconcile --dry-run --format json
{"schema":"base-cli.output","code":"ok",...}
```

The envelope's `run_id` is unique per invocation. Its stable fields identify a
successful command, preserve the numeric exit code, and carry the command's
serialized output. The unwrapped `--format json` form is useful when a script
needs only the records.

## Structured errors and exit status

An invalid environment is a user-correctable command failure. In JSON mode it
is represented as a Base-CLI error envelope and retains a non-zero exit code:

```console
$ northstar --quiet --json --environment unknown status
{"schema":"base-cli.error","type":"error","code":"click_error",...}
```

Unexpected application failures remain hidden behind the framework's generic
error boundary unless `--debug` is requested. Consumer validation errors should
use the public `base_cli.ConfigurationError` or Click exception types so they
are safe to show and test.

## Diagnostics and sensitive inputs

Base-CLI owns debug logging, log-file selection, and redaction of the hidden
approval token declared by Northstar:

```console
$ northstar --debug --log-file /tmp/northstar.log release reconcile --approval-token demo-secret
```

The command succeeds, but `demo-secret` is not written to the log. The
recorded invocation contains `[REDACTED]`. A real consumer must mark every
domain-specific secret-bearing option and must not treat log redaction as a
secrets manager.

## What belongs where

Northstar owns the reconciliation record, its local state path, and the
cleanup hook. Base-CLI owns the lifecycle context, dry-run flag, runtime and
temporary directory placement, logging, redaction, structured envelopes,
exit-code boundary, and final cleanup. Keeping those responsibilities visible
is the point of the example.
24 changes: 24 additions & 0 deletions src/base_cli_demo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ def _render(
)


def _persist_reconciliation(
context: base_cli.Context[Any, Any, Any], record: Mapping[str, Any]
) -> None:
"""Persist local demo state and clean its temporary input after the run."""

if context.dry_run:
return

state_path = context.state_dir / "last-reconciliation.json"
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(
json.dumps(dict(record), sort_keys=True) + "\n",
encoding="utf-8",
)

temporary_input = context.temp_dir / "reconciliation-input.json"
temporary_input.write_text(
json.dumps(dict(record), sort_keys=True) + "\n",
encoding="utf-8",
)
context.on_cleanup(lambda: temporary_input.unlink(missing_ok=True))


def _service_option(function: Any) -> Any:
return click.option(
"--service",
Expand Down Expand Up @@ -235,6 +258,7 @@ def reconcile(
"action": action,
"external_changes": False,
}
_persist_reconciliation(context, record)
_render(
context,
(record,),
Expand Down
70 changes: 70 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,76 @@ def test_reconcile_dry_run_reports_no_external_changes() -> None:
]


def test_reconcile_dry_run_does_not_persist_consumer_state() -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
result = invoke(
["--dry-run", "release", "reconcile", "--format", "json"],
root,
)

assert result.exit_code == 0, result.output
assert list(root.rglob("last-reconciliation.json")) == []


def test_reconcile_persists_state_and_cleans_temporary_input() -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
result = invoke(
["release", "reconcile", "--format", "json"],
root,
)

assert result.exit_code == 0, result.output
state_files = list(root.rglob("last-reconciliation.json"))
assert len(state_files) == 1
assert (
json.loads(state_files[0].read_text(encoding="utf-8"))["action"]
== "reconciled"
)
assert list(root.rglob("reconciliation-input.json")) == []


def test_json_error_envelope_preserves_nonzero_exit_status() -> None:
with tempfile.TemporaryDirectory() as directory:
result = invoke(
["--json", "--environment", "unknown", "status"],
Path(directory),
)

assert result.exit_code != 0
payload = json.loads(result.stdout)
assert payload["schema"] == "base-cli.error"
assert payload["type"] == "error"
assert payload["details"]["exit_code"] == result.exit_code


def test_debug_log_file_redacts_sensitive_adapter_argument() -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
log_path = root / "northstar.log"
secret = "demo-secret"
result = base_cli.testing.invoke(
command,
[
"--debug",
"--log-file",
str(log_path),
"release",
"reconcile",
"--approval-token",
secret,
],
home=root / "home",
)

assert result.exit_code == 0, result.output
log_text = log_path.read_text(encoding="utf-8")

assert secret not in log_text
assert "[REDACTED]" in log_text


def test_json_lifecycle_envelope_is_available_to_consumers() -> None:
with tempfile.TemporaryDirectory() as directory:
result = invoke(
Expand Down
Loading