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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 68 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions examples/northstar-commerce.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"service_owner": "commerce",
"release_version": "2.6.0"
}
75 changes: 62 additions & 13 deletions src/base_cli_demo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("|"),
Expand Down Expand Up @@ -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."""

Expand All @@ -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":
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
115 changes: 115 additions & 0 deletions src/base_cli_demo/profile.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading