From 30cf044eb6b214fb1d0d5ca97660d4dda90df158 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:42:47 +0530 Subject: [PATCH] feat: demonstrate lifecycle safety contracts --- CHANGELOG.md | 2 + README.md | 2 + docs/lifecycle-safety.md | 80 ++++++++++++++++++++++++++++++++++++++++ src/base_cli_demo/cli.py | 24 ++++++++++++ tests/test_cli.py | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 docs/lifecycle-safety.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d7aaa..d7df141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index cfbf353..3cfe482 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/lifecycle-safety.md b/docs/lifecycle-safety.md new file mode 100644 index 0000000..15a1136 --- /dev/null +++ b/docs/lifecycle-safety.md @@ -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. diff --git a/src/base_cli_demo/cli.py b/src/base_cli_demo/cli.py index 5d51c07..8f625c1 100644 --- a/src/base_cli_demo/cli.py +++ b/src/base_cli_demo/cli.py @@ -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", @@ -235,6 +258,7 @@ def reconcile( "action": action, "external_changes": False, } + _persist_reconciliation(context, record) _render( context, (record,), diff --git a/tests/test_cli.py b/tests/test_cli.py index 671b5d4..ecdba6f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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(