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
50 changes: 46 additions & 4 deletions docs/site-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,49 @@ subscription: "real-subscription-id"
resourceGroup: real-resource-group
```

> **Security**: Only base files (`sites/`) can specify `inherits`. Overlays cannot inject inheritance.
> **Security**: Only base files (in trusted site directories) can specify `inherits`. Overlays in `sites.local/` cannot inject inheritance.

## Extra trusted site directories

In addition to the workspace's `sites/` directory, Site Ops can search
one or more extra trusted directories for site files. Files in these
directories are treated exactly like files in `sites/`: they are
discoverable by `siteops sites`, they can declare `inherits`, and they
serve as valid base files for the inheritance chain.

Use cases include:

- **CI / end-to-end tests**: keep test-only sites out of `workspaces/*/sites/`
(production config) and inject them only when the test workflow runs.
- **Cross-repo site libraries**: pull shared sites from another repository
checked out alongside the workspace.
- **Blueprint catalogs**: keep opinionated site templates in a central
location, pointed at from multiple workspaces.

Provide extra directories via the CLI or environment variable:

```bash
# Repeatable flag
siteops -w workspace --extra-sites-dir ./tests/e2e/sites sites

# Environment variable (os.pathsep-separated: ';' on Windows, ':' on Unix)
SITEOPS_EXTRA_SITES_DIRS=/path/to/lib-sites siteops -w workspace sites
```

When both are provided, the CLI flag wins and an INFO log records that
the env var was ignored.

**Merge order (full)**:

```
inherits target → sites/ → <extra dirs, in listed order> → sites.local/
```

Extras cannot collide with the workspace's own `sites/` or `sites.local/`
directories; the orchestrator rejects both at construction time.
Registering `sites.local/` as trusted is specifically refused because it
would let overlays inject inheritance and break the overlay security
invariant.

## Site inheritance

Expand Down Expand Up @@ -227,8 +269,8 @@ parameters:

### Merge order with inheritance

`inherits target` → `sites/` → `sites.local/`
`inherits target` → `sites/` → `<extra trusted dirs>` → `sites.local/`

Inherited values are overridden by child site values. Nested objects (labels, parameters, properties) merge recursively.
Inherited values are overridden by child site values. Nested objects (labels, parameters, properties) merge recursively. See [Extra trusted site directories](#extra-trusted-site-directories) for how extra dirs participate in the chain.

> **Security**: Only base files (`sites/`) can specify `inherits`. Overlays cannot inject inheritance.
> **Security**: Only base files (in trusted site directories) can specify `inherits`. Overlays in `sites.local/` cannot inject inheritance, even when extra trusted dirs are configured.
68 changes: 64 additions & 4 deletions siteops/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import argparse
import logging
import os
import sys
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -204,6 +205,40 @@ def cmd_sites(args: argparse.Namespace, orchestrator: Orchestrator) -> int:
return 0


_EXTRA_SITES_DIRS_ENV = "SITEOPS_EXTRA_SITES_DIRS"


def _resolve_extra_sites_dirs(cli_dirs: list[Path] | None) -> list[Path]:
"""Resolve extra trusted site dirs from CLI flag and/or env var.

Precedence: `--extra-sites-dir` wins over `SITEOPS_EXTRA_SITES_DIRS`.
When both are provided, an INFO log records that the env var was ignored.

The env var is parsed using `os.pathsep` (`;` on Windows, `:` on
Unix) to match platform conventions for `PATH`-style variables. Empty
segments are skipped so trailing separators are tolerated.

Args:
cli_dirs: Directories supplied via the `--extra-sites-dir` flag,
or `None` if the flag was not used.

Returns:
List of paths to pass to `Orchestrator`. Empty list when neither
source provides a value.
"""
env_raw = os.environ.get(_EXTRA_SITES_DIRS_ENV, "")
env_dirs = [Path(p) for p in env_raw.split(os.pathsep) if p]

if cli_dirs:
if env_dirs:
logging.getLogger("siteops.cli").info(
"Ignoring %s (--extra-sites-dir takes precedence).",
_EXTRA_SITES_DIRS_ENV,
)
return list(cli_dirs)
return env_dirs


def main() -> None:
"""Main entry point for the Site Ops CLI."""
parser = argparse.ArgumentParser(
Expand All @@ -227,6 +262,24 @@ def main() -> None:
default=Path.cwd(),
help="Workspace directory (default: current directory)",
)
parser.add_argument(
"--extra-sites-dir",
dest="extra_sites_dirs",
action="append",
type=Path,
default=None,
metavar="DIR",
help=(
"Additional trusted directory to search for site YAML files "
"(repeatable). Treated with the same trust level as the "
"workspace's sites/ directory: files may declare 'inherits'. "
"Searched after sites/ and before sites.local/. "
"Alternatively set SITEOPS_EXTRA_SITES_DIRS to a list of "
"directories using the platform path separator "
"(';' on Windows, ':' on Unix). When both are provided, "
"--extra-sites-dir wins."
),
)

subparsers = parser.add_subparsers(dest="command", required=True)

Expand Down Expand Up @@ -308,10 +361,17 @@ def main() -> None:
print(f"Error: Workspace directory not found: {args.workspace}", file=sys.stderr)
sys.exit(1)

orchestrator = Orchestrator(
workspace=args.workspace,
dry_run=getattr(args, "dry_run", False),
)
extra_sites_dirs = _resolve_extra_sites_dirs(args.extra_sites_dirs)

try:
orchestrator = Orchestrator(
workspace=args.workspace,
dry_run=getattr(args, "dry_run", False),
extra_trusted_sites_dirs=extra_sites_dirs,
)
except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

commands = {
"deploy": cmd_deploy,
Expand Down
Loading
Loading