From 698ec8dea3e3816592448cfc39149b0c798ab130 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:39:33 +0530 Subject: [PATCH] feat: add consumer configuration policy --- README.md | 22 ++++++ docs/configuration.md | 68 ++++++++++++++++++ examples/northstar-commerce.json | 4 ++ src/base_cli_demo/cli.py | 75 ++++++++++++++++---- src/base_cli_demo/profile.py | 115 +++++++++++++++++++++++++++++++ tests/test_cli.py | 60 ++++++++++++++++ 6 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 docs/configuration.md create mode 100644 examples/northstar-commerce.json create mode 100644 src/base_cli_demo/profile.py diff --git a/README.md b/README.md index d166f25..4dd30ed 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,28 @@ 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. +## Consumer-owned configuration + +Northstar keeps the generic Base-CLI profile and opts into one small +consumer-owned policy: an explicit JSON config can filter services by owner and +set the default release target. The default path has no config file and uses +the generic profile defaults; the configured path is an application adapter, +not a Base repository convention. + +Try the default policy and then the checked-in commerce policy: + +```bash +$ northstar --quiet config show --format json +$ northstar --quiet --config examples/northstar-commerce.json status --format json +$ northstar --quiet --config examples/northstar-commerce.json release plan --format json +``` + +The `config show` command reports each normalized value and whether it came +from the consumer default or the explicit file. Invalid JSON or unsupported +values produce a safe configuration error with exit status 2. See +[`docs/configuration.md`](docs/configuration.md) for the schema and the +profile boundary. + ## Development Install the development extra and run the focused suite: diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..94b626f --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,68 @@ +# Northstar configuration + +Northstar demonstrates a consumer-owned configuration adapter on top of the +generic Base-CLI profile. It deliberately does not discover a manifest or +read machine-local files implicitly. + +## Configuration file + +Pass an explicit JSON object with the lifecycle-owned `--config` option: + +```json +{ + "service_owner": "commerce", + "release_version": "2.6.0" +} +``` + +The checked-in [commerce example](../examples/northstar-commerce.json) uses +this schema. `service_owner` limits service snapshots and release plans to +one fixture owner. `release_version` supplies the default target for release +commands; a command-line `--version` still overrides it. + +The no-file path is also intentional: + +```console +$ northstar --quiet config show --format json +[{"setting":"service_owner","value":null,"source":"consumer-default"},{"setting":"release_version","value":"2.5.0","source":"consumer-default"}] +``` + +The configured path shows the same lifecycle with consumer policy layered in: + +```console +$ northstar --quiet --config examples/northstar-commerce.json config show --format json +[{"setting":"service_owner","value":"commerce","source":"explicit"},{"setting":"release_version","value":"2.6.0","source":"explicit"}] +$ northstar --quiet --config examples/northstar-commerce.json status --format json +[{"service":"orders-api","status":"ready","version":"2.4.0","owner":"commerce"},{"service":"web","status":"degraded","version":"3.1.0","owner":"commerce"}] +``` + +`NorthstarConfig` validates the two consumer settings before command logic +runs. It returns a public Base-CLI `ConfigSnapshot`, so the framework keeps +the consumer values in `Context.config` and the winning source for each field +in `Context.config_provenance`. The framework's own lifecycle configuration +remains separate in `Context.framework_config`. + +Try a safe failure: + +```console +$ northstar --quiet --config /tmp/invalid-northstar.json status +Error: Northstar config file '/tmp/invalid-northstar.json' contains invalid JSON: ... +``` + +The exact parser detail depends on the malformed input, but the command exits +with status 2 and does not print a traceback unless debugging is requested. + +## Public extension boundary + +`src/base_cli_demo/profile.py` contains the consumer adapter: + +- `NorthstarConfig` is the consumer-owned typed model and validator. +- `load_config` is the consumer-owned explicit file policy. +- `northstar_profile()` calls the public `base_cli.CliProfile.generic()` + factory and supplies only that policy. +- `get_config()` is the single typed accessor used by commands. + +Base-CLI still owns lifecycle option parsing, runtime placement, logging, +cleanup, and structured output. Northstar owns the fixture schema, service +owner filter, release target default, JSON serialization, and user-facing +configuration messages. diff --git a/examples/northstar-commerce.json b/examples/northstar-commerce.json new file mode 100644 index 0000000..007259f --- /dev/null +++ b/examples/northstar-commerce.json @@ -0,0 +1,4 @@ +{ + "service_owner": "commerce", + "release_version": "2.6.0" +} diff --git a/src/base_cli_demo/cli.py b/src/base_cli_demo/cli.py index f36acb3..5d51c07 100644 --- a/src/base_cli_demo/cli.py +++ b/src/base_cli_demo/cli.py @@ -10,6 +10,8 @@ import base_cli import click +from .profile import get_config, northstar_profile + SERVICE_NAMES = ("orders-api", "billing-worker", "web") OUTPUT_FORMAT = click.Choice( base_cli.output_format_choices().split("|"), @@ -41,7 +43,9 @@ def _load_services() -> tuple[dict[str, str], ...]: def _services_for_environment( - environment: str, requested_service: str = "all" + environment: str, + requested_service: str = "all", + service_owner: str | None = None, ) -> tuple[dict[str, str], ...]: """Return fixture services selected by the consumer-owned domain policy.""" @@ -50,6 +54,7 @@ def _services_for_environment( for service in _load_services() if service["environment"] == environment and (requested_service == "all" or service["name"] == requested_service) + and (service_owner is None or service["owner"] == service_owner) ) if not services: if requested_service == "all": @@ -112,7 +117,11 @@ 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) + config = get_config(context) + services = _services_for_environment( + context.environment, + service_owner=config.service_owner, + ) context.log.info("status requested for %s", context.environment) records = tuple( { @@ -146,16 +155,21 @@ def release() -> None: @click.option( "--version", "target_version", - default="2.5.0", - show_default=True, - help="Target release version.", + default=None, + help="Override the configured target release version.", ) @_service_option -def plan(service: str, target_version: str, output_format: str) -> None: +def plan(service: str, target_version: str | None, 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) + config = get_config(context) + selected = _services_for_environment( + context.environment, + service, + service_owner=config.service_owner, + ) + target_version = target_version or config.release_version context.log.info("release plan requested for %s", context.environment) records = tuple( { @@ -186,22 +200,30 @@ def plan(service: str, target_version: str, output_format: str) -> None: @click.option( "--version", "target_version", - default="2.5.0", - show_default=True, - help="Target release version.", + default=None, + help="Override the configured 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 + service: str, + target_version: str | None, + 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) + config = get_config(context) + selected = _services_for_environment( + context.environment, + service, + service_owner=config.service_owner, + ) + target_version = target_version or config.release_version action = "would-reconcile" if context.dry_run else "reconciled" context.log.info( "%s %d service(s) in %s", action, len(selected), context.environment @@ -227,10 +249,37 @@ def reconcile( ) +@cli.group() +def config() -> None: + """Inspect the consumer-owned Northstar configuration policy.""" + + +@config.command("show") +@_format_option +def show_config(output_format: str) -> None: + """Show normalized consumer settings and their source layers.""" + + context = base_cli.get_current_context() + records = tuple( + { + "setting": key, + "value": value, + "source": context.config_provenance.get(key, "consumer-default"), + } + for key, value in context.config.items() + ) + _render( + context, + records, + output_format, + (("SETTING", "setting"), ("VALUE", "value"), ("SOURCE", "source")), + ) + + app = base_cli.App( name="northstar", version="0.1.0", - profile=base_cli.CliProfile.generic(), + profile=northstar_profile(), lifecycle_options=base_cli.LifecycleOptions( environment=base_cli.LifecycleOption( "--environment", diff --git a/src/base_cli_demo/profile.py b/src/base_cli_demo/profile.py new file mode 100644 index 0000000..3f3527b --- /dev/null +++ b/src/base_cli_demo/profile.py @@ -0,0 +1,115 @@ +"""Northstar-owned configuration and profile policies.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import base_cli + + +@dataclass(frozen=True) +class NorthstarConfig: + """Validated settings owned by the Northstar consumer.""" + + service_owner: str | None = None + release_version: str = "2.5.0" + + @classmethod + def from_mapping(cls, values: object) -> NorthstarConfig: + """Validate consumer configuration without changing lifecycle policy.""" + + if not isinstance(values, Mapping): + raise base_cli.ConfigurationError( + "Northstar config must contain a JSON object." + ) + + unknown = sorted(set(values) - {"service_owner", "release_version"}) + if unknown: + names = ", ".join(unknown) + raise base_cli.ConfigurationError( + f"Northstar config contains unsupported key(s): {names}." + ) + + service_owner = values.get("service_owner") + if service_owner is not None: + if not isinstance(service_owner, str) or not service_owner.strip(): + raise base_cli.ConfigurationError( + "Northstar config key 'service_owner' must be a non-empty string or null." + ) + service_owner = service_owner.strip() + + release_version = values.get("release_version", cls.release_version) + if not isinstance(release_version, str) or not release_version.strip(): + raise base_cli.ConfigurationError( + "Northstar config key 'release_version' must be a non-empty string." + ) + + return cls( + service_owner=service_owner, + release_version=release_version.strip(), + ) + + def as_mapping(self) -> dict[str, Any]: + """Return normalized consumer settings exposed through Context.""" + + return { + "service_owner": self.service_owner, + "release_version": self.release_version, + } + + +def _load_json(path: Path) -> dict[str, Any]: + try: + contents = path.read_text(encoding="utf-8") + except OSError as exc: + raise base_cli.ConfigurationError( + f"Unable to read Northstar config file '{path}': {exc}" + ) from exc + + try: + payload = json.loads(contents) + except json.JSONDecodeError as exc: + raise base_cli.ConfigurationError( + f"Northstar config file '{path}' contains invalid JSON: {exc.msg}." + ) from exc + + if not isinstance(payload, Mapping): + raise base_cli.ConfigurationError( + f"Northstar config file '{path}' must contain a JSON object." + ) + return dict(payload) + + +def load_config( + _project: base_cli.ProjectInfo | None, + explicit_path: Path | None, +) -> base_cli.ConfigSnapshot: + """Load optional explicit consumer config and preserve field provenance.""" + + raw = _load_json(explicit_path) if explicit_path is not None else {} + config = NorthstarConfig.from_mapping(raw) + provenance = { + key: "explicit" if key in raw else "consumer-default" + for key in config.as_mapping() + } + return base_cli.ConfigSnapshot( + config=config.as_mapping(), + framework=base_cli.FrameworkConfig(), + provenance=provenance, + ) + + +def northstar_profile() -> base_cli.CliProfile: + """Build the explicit generic-lifecycle profile used by Northstar.""" + + return base_cli.CliProfile.generic(load_config=load_config) + + +def get_config(context: base_cli.Context[Any, Any, Any]) -> NorthstarConfig: + """Return validated consumer settings from an active invocation.""" + + return NorthstarConfig.from_mapping(context.config) diff --git a/tests/test_cli.py b/tests/test_cli.py index 6cb419b..671b5d4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -112,3 +112,63 @@ def test_json_lifecycle_envelope_is_available_to_consumers() -> None: assert payload["schema"] == "base-cli.output" assert payload["code"] == "ok" assert '"orders-api"' in payload["details"]["stdout"] + + +def test_default_consumer_config_reports_its_own_provenance() -> None: + with tempfile.TemporaryDirectory() as directory: + result = invoke(["config", "show", "--format", "json"], Path(directory)) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == [ + { + "setting": "service_owner", + "value": None, + "source": "consumer-default", + }, + { + "setting": "release_version", + "value": "2.5.0", + "source": "consumer-default", + }, + ] + + +def test_explicit_consumer_config_filters_and_sets_release_default() -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config_path = root / "northstar.json" + config_path.write_text( + '{"service_owner": "commerce", "release_version": "2.6.0"}', + encoding="utf-8", + ) + result = invoke( + [ + "--config", + str(config_path), + "release", + "plan", + "--format", + "json", + ], + root / "home", + ) + + assert result.exit_code == 0, result.output + records = json.loads(result.stdout) + assert [record["service"] for record in records] == ["orders-api", "web"] + assert {record["target_version"] for record in records} == {"2.6.0"} + + +def test_invalid_consumer_config_is_a_safe_configuration_error() -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config_path = root / "invalid.json" + config_path.write_text('{"service_owner": 42}', encoding="utf-8") + result = invoke( + ["--config", str(config_path), "status"], + root / "home", + ) + + assert result.exit_code == 2 + assert "service_owner" in result.output + assert "Traceback" not in result.output