Skip to content
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,76 @@ before and never look for an `environments/` directory.

---

### Profile Composition (`extends`)

A profile can also factor out shared configuration into one or more parent
profiles instead of duplicating it, using a top-level `extends` field:

```yaml
# profiles/analytics-prod/profile.yaml
apiVersion: cds/v1alpha1
kind: Profile
metadata:
name: analytics-prod
environment: production
extends:
- analytics-base # a bare name resolves to profiles/analytics-base/profile.yaml
spec:
modules:
- id: postgres
config:
storage:
size: 20Gi
```

`extends` accepts a non-empty list of parent references, each either a bare
profile name (resolved under the profiles root) or a path relative to the
child profile's directory (e.g. `../shared/profile.yaml`). Parents are
resolved and merged left-to-right — later parents win over earlier ones —
and then the child profile's own document is merged on top of all parents.
Composition uses the exact same deep-merge/module-merge-by-id engine as
environment overlays: mappings merge recursively, `spec.modules` entries
merge by stable `id`, and any other array is replaced wholesale rather than
concatenated. Parent profiles may themselves use `extends` (chains are
resolved transitively).

`extends` is not a CLI flag — it's read directly from `profile.yaml`, so
every profile-consuming command resolves it automatically, with no new
syntax to learn:

```bash
cds validate analytics-prod
cds plan analytics-prod
cds up analytics-prod
```

A profile can extend more than one parent, which is merged in the order
listed (later entries win over earlier ones):

```yaml
extends:
- networking-base
- observability-base
```

`extends` and `--environment` compose together: parents are merged first,
then the child, then the selected environment overlay is applied on top of
that fully-composed result — so a shared base profile and environment
promotion can both be used without duplicating configuration in either
dimension.

A malformed `extends` chain fails validation/planning before anything else
runs, with a dedicated diagnostic code:

|Code|Meaning|
|---|---|
|E103|`extends` is missing, not a list, empty, or contains a non-string/empty entry|
|E104|A parent reference resolves outside the profiles root|
|E105|A referenced parent profile does not exist|
|E106|A cycle was detected in the `extends` chain|

---

## ⚙️ CLI

|Command|Description|
Expand Down
58 changes: 56 additions & 2 deletions cli/getter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from urllib.request import Request, urlopen

from .loader import load_yaml_file, resolve_module_dir
from .overlay import _derive_profiles_root, _resolve_extends_ref, resolve_extends
from .planner import MaxNestingDepthExceeded, apply_defaults, substitute_string

# The upstream repository `cds get` downloads from when no `--remote` is
Expand Down Expand Up @@ -233,15 +234,67 @@ def _resolve_source_profile_path(source_repo: Path, profile: str) -> Path:
)


def _collect_extends_profile_dirs(
source_repo: Path, profile_path: Path, _visited: frozenset[Path] = frozenset()
) -> set[Path]:
"""
Returns every parent profile directory reachable via `extends`, so
`cds get` copies parent profile.yaml files (and, via
_collect_asset_roots's module walk, their modules) too instead of
silently omitting anything only declared in a parent profile. Reuses
cli.overlay's own extends-ref resolution rather than re-implementing it.

`_visited` tracks resolved profile paths already seen along the current
extends chain so a cyclic `extends` graph raises a clean GetError
instead of recursing until Python's recursion limit is hit; this
mirrors cli.overlay._compose_extends's own cycle-detection stack.
"""
resolved_profile_path = profile_path.resolve()
if resolved_profile_path in _visited:
raise GetError(f"Cycle detected in extends chain at {profile_path}")
_visited = _visited | {resolved_profile_path}

doc, _diagnostics = load_yaml_file(profile_path)
if doc is None:
raise GetError(f"Could not load source profile {profile_path}")

extends = doc.get("extends")
if not extends:
return set()

profile_dir = profile_path.parent.resolve()
profiles_root = _derive_profiles_root(profile_dir)
if profiles_root is None:
raise GetError(f'Could not derive a profiles root for extends in {profile_path}')

dirs: set[Path] = set()
for ref in extends:
parent_path = _resolve_extends_ref(ref, profile_dir, profiles_root)
if not parent_path.is_file():
raise GetError(f'Could not resolve extends parent "{ref}" for {profile_path}')
_require_within_repo(parent_path, source_repo, f'extends parent "{ref}"')
dirs.add(parent_path.parent)
dirs.update(_collect_extends_profile_dirs(source_repo, parent_path, _visited))

return dirs


def _collect_asset_roots(source_repo: Path, profile_path: Path) -> list[Path]:
profile_dir = profile_path.parent
asset_roots: set[Path] = {
profile_dir if profile_path.name == "profile.yaml" else profile_path
}
profile_doc, profile_diags = load_yaml_file(profile_path)
if profile_diags or profile_doc is None:

# Resolve through `extends` so modules/config contributed only by a
# parent profile (not redeclared in the child) are still collected;
# cli.overlay is the single source of truth for extends semantics (see
# cli.planner/cli.validator, which route through it the same way).
profile_doc, _provenance, diagnostics = resolve_extends(str(profile_path))
if profile_doc is None or any(d.level == "error" for d in diagnostics):
raise GetError(f"Could not load source profile {profile_path}")

asset_roots.update(_collect_extends_profile_dirs(source_repo, profile_path))

spec = profile_doc.get("spec")
modules = spec.get("modules", []) if isinstance(spec, dict) else []
if not isinstance(modules, list):
Expand All @@ -266,6 +319,7 @@ def _collect_asset_roots(source_repo: Path, profile_path: Path) -> list[Path]:
return sorted(asset_roots)



def _collect_module_runtime_assets(
source_repo: Path,
module_dir: Path,
Expand Down
11 changes: 6 additions & 5 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@
load_policy_from_env,
verify_images,
)
from .loader import load_yaml_file
from .overlay import resolve_profile
from .overlay import resolve_extends, resolve_profile
from .planner import build_plan
from .preflight import preflight_passed, run_preflight
from .renderer import render_compose
Expand Down Expand Up @@ -469,12 +468,14 @@ def _collect_profile_env_vars(
identifiers like database/user names, so callers can fill in friendlier defaults
for them instead of a placeholder.
"""
# Always resolve via cli.overlay so a profile's `extends` chain (and, if
# selected, --environment overlay) is applied; environment=None still
# resolves extends via resolve_extends() (lighter than resolve_profile(),
# which also runs full validate_loaded_profile()).
if environment is not None:
from .overlay import resolve_profile

profile, _, diags = resolve_profile(profile_path, environment)
else:
profile, diags = load_yaml_file(Path(profile_path))
profile, _, diags = resolve_extends(profile_path)
if profile is None:
error_messages = [d.format() for d in diags if d.level == "error"]
raise ValueError("Could not load profile: " + "; ".join(error_messages or ["unknown error"]))
Expand Down
Loading