diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc13289..517cc86 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,3 +19,11 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Validate repository baseline run: ./tests/validate.sh + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Install the reference consumer + run: python -m pip install ".[dev]" + - name: Run consumer tests + run: python -m pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index b491220..89db933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,4 @@ and versions are tracked in the repo-root `VERSION` file. ### Added - Initialized the repository with the Base-managed repo baseline. +- Added the Northstar reference consumer with nested status and release commands. diff --git a/README.md b/README.md index c0bc0c2..d166f25 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,90 @@ # base-cli-demo -Reference consumer and learning application for the base-cli Python framework. +Reference consumer and learning application for the `base-cli` Python framework. + +This repository contains Northstar, a small offline operational CLI. It is +designed to show how an application embeds Base-CLI while keeping its own +command tree, domain policy, and local data model. + +Northstar does not require Base, Docker, cloud credentials, or network access +after its dependencies are installed. + +## Quick start + +From a fresh checkout: + +```bash +python3 -m venv .venv +. .venv/bin/activate +python -m pip install . +northstar --help +northstar --quiet status +``` + +The default environment is `dev`. Select another fixture environment with the +framework lifecycle option: + +```bash +northstar --quiet --environment staging status +northstar --quiet --environment dev status --format json +northstar --quiet --environment dev release plan --version 2.5.0 +northstar --quiet --environment dev --dry-run release reconcile --version 2.5.0 --format json +``` + +Base-CLI also provides the optional versioned lifecycle envelope: + +```bash +northstar --quiet --environment dev --json status --format json +``` + +## What this demonstrates + +- `northstar status` reads consumer-owned, deterministic service fixtures. +- `northstar release plan` is a nested command that produces a machine-readable + release plan. +- `northstar release reconcile` uses the Base-CLI dry-run lifecycle boundary and + explicitly reports that the demo performs no external changes. +- `--environment`, `--quiet`, `--debug`, `--config`, `--keep-temp`, and + `--log-file` are lifecycle options supplied by Base-CLI. +- `--format` is a consumer-owned option that delegates rendering to the public + Base-CLI output API. +- The `--json` option wraps command output in Base-CLI's versioned success or + error envelope. + +## Framework boundary + +The application uses only the public `import base_cli` facade. Base-CLI owns the +invocation lifecycle, context, logging, runtime paths, cleanup, and structured +output. Northstar owns the Click command tree, service fixture schema, release +planning policy, and domain-facing messages. + +The generic consumer profile is explicit in `src/base_cli_demo/cli.py`. The demo +does not inherit Base-specific manifest, project, history, or cache conventions. + +## Development + +Install the development extra and run the focused suite: + +```bash +python -m pip install ".[dev]" +python -m pytest +./tests/validate.sh +``` + +The package requires Python 3.10 or newer and pins the supported Base-CLI line +to `>=0.4.3,<0.5`. The repository intentionally keeps demo versioning separate +from framework versioning. + +## Repository shape + +- `src/base_cli_demo/cli.py` contains the consumer-owned Click tree and the + Base-CLI attachment boundary. +- `src/base_cli_demo/fixtures/services.json` contains deterministic local data. +- `tests/test_cli.py` exercises the installed lifecycle through the public + testing helper. +- `pyproject.toml` defines the installable `northstar` console script. +- The generated Base repository files provide the project workflow and release + contract; the demo itself does not require Base at runtime. ## Base diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bb8a725 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68,<77"] +build-backend = "setuptools.build_meta" + +[project] +name = "base-cli-demo" +version = "0.1.0" +description = "Reference consumer and learning application for the base-cli framework" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "base-cli>=0.4.3,<0.5", + "click>=8.1,<9", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8,<9", +] + +[project.scripts] +northstar = "base_cli_demo.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +base_cli_demo = ["fixtures/*.json"] diff --git a/src/base_cli_demo/__init__.py b/src/base_cli_demo/__init__.py new file mode 100644 index 0000000..700228e --- /dev/null +++ b/src/base_cli_demo/__init__.py @@ -0,0 +1,5 @@ +"""A small, realistic consumer application for the base-cli framework.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/base_cli_demo/cli.py b/src/base_cli_demo/cli.py new file mode 100644 index 0000000..f36acb3 --- /dev/null +++ b/src/base_cli_demo/cli.py @@ -0,0 +1,262 @@ +"""Northstar, an offline reference consumer for base-cli.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from importlib.resources import files +from typing import Any + +import base_cli +import click + +SERVICE_NAMES = ("orders-api", "billing-worker", "web") +OUTPUT_FORMAT = click.Choice( + base_cli.output_format_choices().split("|"), + case_sensitive=False, +) + + +def _load_services() -> tuple[dict[str, str], ...]: + """Load and validate the application-owned deterministic fixture.""" + + fixture_path = files("base_cli_demo.fixtures").joinpath("services.json") + payload: Any = json.loads(fixture_path.read_text(encoding="utf-8")) + entries = payload.get("services") if isinstance(payload, Mapping) else None + if not isinstance(entries, list): + raise TypeError("The services fixture must contain a services list.") + + services: list[dict[str, str]] = [] + required_fields = ("name", "environment", "version", "status", "owner") + for entry in entries: + if not isinstance(entry, Mapping): + raise TypeError("Every service fixture entry must be an object.") + record = {field: entry.get(field) for field in required_fields} + if not all(isinstance(value, str) and value for value in record.values()): + raise RuntimeError( + "Every service fixture entry must contain string fields." + ) + services.append({field: str(record[field]) for field in required_fields}) + return tuple(services) + + +def _services_for_environment( + environment: str, requested_service: str = "all" +) -> tuple[dict[str, str], ...]: + """Return fixture services selected by the consumer-owned domain policy.""" + + services = tuple( + service + for service in _load_services() + if service["environment"] == environment + and (requested_service == "all" or service["name"] == requested_service) + ) + if not services: + if requested_service == "all": + raise click.ClickException( + f"No fixture services are defined for environment '{environment}'." + ) + raise click.ClickException( + f"Service '{requested_service}' is not defined for environment '{environment}'." + ) + return services + + +def _render( + context: base_cli.Context[Any, Any, Any], + records: tuple[Mapping[str, Any], ...], + output_format: str, + columns: tuple[tuple[str, str], ...], +) -> None: + """Render records through the framework-owned output boundary.""" + + base_cli.render_records( + records, + requested_format=output_format, + columns=columns, + rich=context.rich, + ) + + +def _service_option(function: Any) -> Any: + return click.option( + "--service", + type=click.Choice(("all",) + SERVICE_NAMES, case_sensitive=False), + default="all", + show_default=True, + help="Limit the command to one service.", + )(function) + + +def _format_option(function: Any) -> Any: + return click.option( + "--format", + "output_format", + type=OUTPUT_FORMAT, + default="text", + show_default=True, + help="Render text, CSV, TSV, YAML, JSON, or NDJSON.", + )(function) + + +@click.group( + name="northstar", help="Explore a production-shaped base-cli consumer offline." +) +def cli() -> None: + """Keep the command tree and domain policy owned by the consumer.""" + + +@cli.command() +@_format_option +def status(output_format: str) -> None: + """Show the local service snapshot for the selected environment.""" + + context = base_cli.get_current_context() + services = _services_for_environment(context.environment) + context.log.info("status requested for %s", context.environment) + records = tuple( + { + "service": service["name"], + "status": service["status"], + "version": service["version"], + "owner": service["owner"], + } + for service in services + ) + _render( + context, + records, + output_format, + ( + ("SERVICE", "service"), + ("STATUS", "status"), + ("VERSION", "version"), + ("OWNER", "owner"), + ), + ) + + +@cli.group() +def release() -> None: + """Plan and reconcile a local release snapshot.""" + + +@release.command("plan") +@_format_option +@click.option( + "--version", + "target_version", + default="2.5.0", + show_default=True, + help="Target release version.", +) +@_service_option +def plan(service: str, target_version: str, output_format: str) -> None: + """Create a deterministic release plan without external changes.""" + + context = base_cli.get_current_context() + selected = _services_for_environment(context.environment, service) + context.log.info("release plan requested for %s", context.environment) + records = tuple( + { + "environment": context.environment, + "service": item["name"], + "current_version": item["version"], + "target_version": target_version, + "action": "update" if item["version"] != target_version else "unchanged", + } + for item in selected + ) + _render( + context, + records, + output_format, + ( + ("ENVIRONMENT", "environment"), + ("SERVICE", "service"), + ("CURRENT", "current_version"), + ("TARGET", "target_version"), + ("ACTION", "action"), + ), + ) + + +@release.command("reconcile") +@_format_option +@click.option( + "--version", + "target_version", + default="2.5.0", + show_default=True, + help="Target release version.", +) +@_service_option +@click.option( + "--approval-token", hidden=True, help="Example sensitive adapter credential." +) +def reconcile( + service: str, target_version: str, output_format: str, approval_token: str | None +) -> None: + """Reconcile a local snapshot, with dry-run controlled by base-cli.""" + + del approval_token + context = base_cli.get_current_context() + selected = _services_for_environment(context.environment, service) + action = "would-reconcile" if context.dry_run else "reconciled" + context.log.info( + "%s %d service(s) in %s", action, len(selected), context.environment + ) + record = { + "environment": context.environment, + "services": len(selected), + "target_version": target_version, + "action": action, + "external_changes": False, + } + _render( + context, + (record,), + output_format, + ( + ("ENVIRONMENT", "environment"), + ("SERVICES", "services"), + ("TARGET", "target_version"), + ("ACTION", "action"), + ("EXTERNAL CHANGES", "external_changes"), + ), + ) + + +app = base_cli.App( + name="northstar", + version="0.1.0", + profile=base_cli.CliProfile.generic(), + lifecycle_options=base_cli.LifecycleOptions( + environment=base_cli.LifecycleOption( + "--environment", + default="dev", + show_default=True, + help="Select the local fixture environment.", + ), + dry_run=base_cli.LifecycleOption( + "--dry-run", + help="Describe reconciliation without applying local changes.", + ), + json=base_cli.LifecycleOption( + "--json", + help="Wrap command output in the versioned base-cli JSON envelope.", + ), + ), +) + +command = app.attach(cli, sensitive_parameters={"approval_token"}) + + +def main() -> int: + """Run Northstar through the production base-cli lifecycle.""" + + return base_cli.run_app(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/base_cli_demo/fixtures/__init__.py b/src/base_cli_demo/fixtures/__init__.py new file mode 100644 index 0000000..4155266 --- /dev/null +++ b/src/base_cli_demo/fixtures/__init__.py @@ -0,0 +1 @@ +"""Deterministic data used by the reference application.""" diff --git a/src/base_cli_demo/fixtures/services.json b/src/base_cli_demo/fixtures/services.json new file mode 100644 index 0000000..8c9c52f --- /dev/null +++ b/src/base_cli_demo/fixtures/services.json @@ -0,0 +1,60 @@ +{ + "services": [ + { + "name": "orders-api", + "environment": "dev", + "version": "2.4.0", + "status": "ready", + "owner": "commerce" + }, + { + "name": "billing-worker", + "environment": "dev", + "version": "1.8.2", + "status": "ready", + "owner": "finance" + }, + { + "name": "web", + "environment": "dev", + "version": "3.1.0", + "status": "degraded", + "owner": "commerce" + }, + { + "name": "orders-api", + "environment": "staging", + "version": "2.3.9", + "status": "ready", + "owner": "commerce" + }, + { + "name": "billing-worker", + "environment": "staging", + "version": "1.8.2", + "status": "ready", + "owner": "finance" + }, + { + "name": "orders-api", + "environment": "prod", + "version": "2.3.8", + "status": "ready", + "owner": "commerce" + }, + { + "name": "billing-worker", + "environment": "prod", + "version": "1.8.1", + "status": "ready", + "owner": "finance" + }, + { + "name": "web", + "environment": "prod", + "version": "3.0.6", + "status": "ready", + "owner": "commerce" + } + ] +} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..6cb419b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import Any + +import base_cli + +from base_cli_demo.cli import command + + +def invoke(args: list[str], home: Path) -> Any: + return base_cli.testing.invoke(command, ["--quiet", *args], home=home) + + +def test_help_exposes_nested_consumer_commands_and_lifecycle_options() -> None: + with tempfile.TemporaryDirectory() as directory: + result = invoke(["--help"], Path(directory)) + + assert result.exit_code == 0, result.output + assert "status" in result.stdout + assert "release" in result.stdout + assert "--environment" in result.stdout + assert "--dry-run" in result.stdout + + +def test_status_reads_the_selected_local_fixture_environment() -> None: + with tempfile.TemporaryDirectory() as directory: + result = invoke( + ["--environment", "dev", "status", "--format", "json"], Path(directory) + ) + + assert result.exit_code == 0, result.output + records = json.loads(result.stdout) + assert [record["service"] for record in records] == [ + "orders-api", + "billing-worker", + "web", + ] + assert records[-1]["status"] == "degraded" + + +def test_release_plan_is_nested_and_machine_readable() -> None: + with tempfile.TemporaryDirectory() as directory: + result = invoke( + [ + "--environment", + "staging", + "release", + "plan", + "--service", + "orders-api", + "--version", + "2.5.0", + "--format", + "json", + ], + Path(directory), + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == [ + { + "environment": "staging", + "service": "orders-api", + "current_version": "2.3.9", + "target_version": "2.5.0", + "action": "update", + } + ] + + +def test_reconcile_dry_run_reports_no_external_changes() -> None: + with tempfile.TemporaryDirectory() as directory: + result = invoke( + [ + "--environment", + "dev", + "--dry-run", + "release", + "reconcile", + "--version", + "2.5.0", + "--format", + "json", + ], + Path(directory), + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == [ + { + "environment": "dev", + "services": 3, + "target_version": "2.5.0", + "action": "would-reconcile", + "external_changes": False, + } + ] + + +def test_json_lifecycle_envelope_is_available_to_consumers() -> None: + with tempfile.TemporaryDirectory() as directory: + result = invoke( + ["--environment", "dev", "--json", "status", "--format", "json"], + Path(directory), + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["schema"] == "base-cli.output" + assert payload["code"] == "ok" + assert '"orders-api"' in payload["details"]["stdout"]