From c87dff7a14840ed57daea92f03f214fa4a6541d5 Mon Sep 17 00:00:00 2001 From: RonaldHensbergen Date: Fri, 11 Sep 2026 23:22:16 +0200 Subject: [PATCH] feat: add in-memory profile planning entry points (rest of #349) PR #647 only closed the disk-based half of #349 (write a runtime- generated profile to disk first, then plan it via the existing path-based build_plan()). This closes the remaining gap: true in-memory/dict-based planning, so a runtime-assembled profile never needs to touch disk at all, mirroring the split cli/validator.py already has (validate_profile -> validate_loaded_profile). cli/overlay.py: - Extract _resolve_and_merge_extends() as the shared tail of _compose_extends(), used by both the existing file-based path and a new _compose_extends_from_doc() for already-loaded profile dicts with no file of their own on disk. - Extract _resolve_environment_overlay_and_validate() as the shared tail of resolve_profile(), reused by a new resolve_profile_from_profile(). - Add resolve_extends_from_profile()/resolve_profile_from_profile(): in-memory counterparts to resolve_extends()/resolve_profile() that accept a profile dict + anchor directory instead of a profile_path, resolving extends/environment overlays against on-disk parents without requiring the child profile itself to exist on disk. cli/planner.py: - Extract build_plan_from_profile(profile, profile_dir, ...): the body of build_plan() from profile resolution onward, now reusable with an already-resolved profile dict + anchor directory. build_plan() becomes a thin wrapper: resolve profile_path via cli.overlay, then delegate. - Add plan_generated_profile(): one-call convenience composing the new overlay resolvers with build_plan_from_profile(), mirroring build_plan()'s own resolve-then-plan composition for in-memory profiles. All existing overlay/planner tests pass unmodified (behavior-preserving refactor); 22 new tests cover the in-memory entry points directly, including extends/environment-overlay resolution with no profile.yaml on disk, and a disk-vs-memory equivalence check for build_plan_from_profile(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/overlay.py | 163 +++++++++++++++++++++++++++++------ cli/planner.py | 151 ++++++++++++++++++++++++++++++-- tests/test_overlay.py | 184 +++++++++++++++++++++++++++++++++++++++ tests/test_planner.py | 194 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 661 insertions(+), 31 deletions(-) diff --git a/cli/overlay.py b/cli/overlay.py index 6ba48b6c..28b31bd1 100644 --- a/cli/overlay.py +++ b/cli/overlay.py @@ -216,6 +216,52 @@ def _compose_extends( if doc is None: return None, {}, diagnostics + profile_dir = profile_file.parent.resolve() + new_stack = (*stack, resolved_file) + merged, provenance, merge_diagnostics = _resolve_and_merge_extends( + doc, profile_dir, str(profile_file), new_stack + ) + diagnostics += merge_diagnostics + return merged, provenance, diagnostics + + +def _compose_extends_from_doc( + doc: dict[str, Any], + profile_dir: Path, + source_label: str, +) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]: + """ + In-memory counterpart to _compose_extends(): resolves `doc`'s own + `extends` chain the same way, but for an already-loaded document that + has no file of its own on disk (e.g. a profile assembled at runtime and + planned/validated without being written to disk first, per issue #349). + `profile_dir` anchors relative extends refs (and derives the profiles + root) the same way a real profile file's parent directory would; + `source_label` stands in for the file path in diagnostics and merge + provenance. `doc` has no file identity of its own to cycle back to, so + cycle detection (E113) only applies to on-disk *parent* extends chains, + which still recurse through _compose_extends() normally. + """ + return _resolve_and_merge_extends(doc, profile_dir, source_label, ()) + + +def _resolve_and_merge_extends( + doc: dict[str, Any], + profile_dir: Path, + source_label: str, + stack: tuple[Path, ...], +) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]: + """ + Shared tail of _compose_extends()/_compose_extends_from_doc(): resolves + `doc`'s own `extends` list (if any) into merged parent(s) plus `doc` + itself, given the directory `doc` should be treated as living in + (`profile_dir`) and a label to use in diagnostics/provenance in place of + a real file path (`source_label`). `stack` is only used for cycle + detection against on-disk parent recursion; callers with no file + identity of their own to cycle back to (i.e. _compose_extends_from_doc) + pass (). + """ + diagnostics: list[Diagnostic] = [] extends = doc.get("extends") if extends is None: return doc, {}, diagnostics @@ -233,7 +279,6 @@ def _compose_extends( ) return None, {}, diagnostics - profile_dir = profile_file.parent.resolve() profiles_root = _derive_profiles_root(profile_dir) if profiles_root is None: diagnostics.append( @@ -241,7 +286,7 @@ def _compose_extends( level="error", code="E111", message=( - f'Cannot resolve "extends" for {profile_file}: its directory does not ' + f'Cannot resolve "extends" for {source_label}: its directory does not ' 'reside under a "profiles/" root.' ), path="extends", @@ -249,8 +294,6 @@ def _compose_extends( ) return None, {}, diagnostics - new_stack = (*stack, resolved_file) - merged: dict[str, Any] | None = None merged_source: str | None = None provenance: dict[str, str] = {} @@ -280,7 +323,7 @@ def _compose_extends( ) return None, {}, diagnostics - parent_doc, parent_provenance, parent_diagnostics = _compose_extends(parent_path, new_stack) + parent_doc, parent_provenance, parent_diagnostics = _compose_extends(parent_path, stack) diagnostics += parent_diagnostics if parent_doc is None: return None, {}, diagnostics @@ -300,7 +343,7 @@ def _compose_extends( child_without_extends = {k: v for k, v in doc.items() if k != "extends"} merged, merge_diagnostics = _merge_profile_docs( - merged, child_without_extends, merged_source, str(profile_file), provenance + merged, child_without_extends, merged_source, source_label, provenance ) diagnostics += merge_diagnostics if merge_diagnostics: @@ -353,6 +396,23 @@ def resolve_extends( return _compose_extends(Path(profile_path), ()) +def resolve_extends_from_profile( + profile: dict[str, Any], + profile_dir: Path, + source_label: str = "", +) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]: + """ + In-memory counterpart to resolve_extends() (issue #349): resolves only + `profile`'s own `extends` chain against on-disk parent profiles, without + requiring `profile` itself to be loaded from (or written to) disk + first. `profile_dir` only needs to exist and reside under a + "profiles/" root if `profile` actually declares `extends`; it does not + need to contain a profile.yaml of its own. `source_label` stands in + for a real file path in diagnostics and merge provenance. + """ + return _compose_extends_from_doc(profile, profile_dir, source_label) + + def resolve_profile( profile_path: str, environment: str | None = None, @@ -375,13 +435,71 @@ def resolve_profile( if base is None: return None, {}, diagnostics + resolved, out_provenance = _resolve_environment_overlay_and_validate( + base, provenance, profile_file.parent, str(profile_file), environment, profile_file, diagnostics + ) + return resolved, out_provenance, diagnostics + + +def resolve_profile_from_profile( + profile: dict[str, Any], + profile_dir: Path, + environment: str | None = None, + source_label: str = "", +) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]: + """ + In-memory counterpart to resolve_profile() (issue #349): resolves + `profile`'s own `extends` chain and, if `environment` is set, merges + profile_dir/environments/.yaml over the result, then runs + the same validate_loaded_profile() pass -- all without requiring + `profile` to be loaded from (or written to) disk first. `profile_dir` + only needs to exist and reside under a "profiles/" root if `extends` + or `environment` are actually used; it does not need to contain a + profile.yaml of its own. `source_label` stands in for a real file path + in diagnostics and merge provenance. + """ + base, provenance, diagnostics = _compose_extends_from_doc(profile, profile_dir, source_label) + if base is None: + return None, {}, diagnostics + + anchor_file = profile_dir / "profile.yaml" + resolved, out_provenance = _resolve_environment_overlay_and_validate( + base, provenance, profile_dir, source_label, environment, anchor_file, diagnostics + ) + return resolved, out_provenance, diagnostics + + +def _resolve_environment_overlay_and_validate( + base: dict[str, Any], + provenance: dict[str, str], + profile_dir: Path, + source_label: str, + environment: str | None, + anchor_file: Path, + diagnostics: list[Diagnostic], +) -> tuple[dict[str, Any] | None, dict[str, str]]: + """ + Shared tail of resolve_profile()/resolve_profile_from_profile(): applies + an optional environments/.yaml overlay over `base` (merged + the same way `extends` parents are, via _merge_profile_docs) and runs + validate_loaded_profile() against the result (or against `base` directly + when environment is None), appending all diagnostics to `diagnostics` in + place. `anchor_file` is passed through to validate_loaded_profile() + purely to anchor module `source:` resolution via its .parent -- unlike + a resolve_profile() caller's real profile file, it does not need to + exist for resolve_profile_from_profile()'s in-memory case. + + Returns (resolved_profile_or_None, provenance_to_report); the reported + provenance intentionally differs between failure branches to preserve + resolve_profile()'s pre-existing behavior (empty on overlay-resolution + failures, the extends-derived provenance on validation failures). + """ if environment is None: - diagnostics += validate_loaded_profile(base, profile_file) - return (base, provenance, diagnostics) if not any( - d.level == "error" for d in diagnostics - ) else (None, provenance, diagnostics) + diagnostics.extend(validate_loaded_profile(base, anchor_file)) + if any(d.level == "error" for d in diagnostics): + return None, provenance + return base, provenance - profile_dir = profile_file.parent environments_dir = profile_dir / "environments" overlay_file = environments_dir / f"{environment}.yaml" @@ -394,7 +512,7 @@ def resolve_profile( path="environment", ) ) - return None, {}, diagnostics + return None, {} if not overlay_file.is_file(): diagnostics.append( @@ -405,23 +523,20 @@ def resolve_profile( path="environment", ) ) - return None, {}, diagnostics + return None, {} overlay, overlay_diags = load_yaml_file(overlay_file) - diagnostics += overlay_diags + diagnostics.extend(overlay_diags) if overlay is None: - return None, {}, diagnostics - - base_source = str(profile_file) - overlay_source = str(overlay_file) + return None, {} - merged, merge_diagnostics = _merge_profile_docs(base, overlay, base_source, overlay_source, provenance) - diagnostics += merge_diagnostics + merged, merge_diagnostics = _merge_profile_docs(base, overlay, source_label, str(overlay_file), provenance) + diagnostics.extend(merge_diagnostics) if merge_diagnostics: - return None, {}, diagnostics + return None, {} - diagnostics += validate_loaded_profile(merged, profile_file) + diagnostics.extend(validate_loaded_profile(merged, anchor_file)) if any(d.level == "error" for d in diagnostics): - return None, provenance, diagnostics + return None, provenance - return merged, provenance, diagnostics + return merged, provenance diff --git a/cli/planner.py b/cli/planner.py index 9be14b60..90728685 100644 --- a/cli/planner.py +++ b/cli/planner.py @@ -67,6 +67,15 @@ def build_plan( Returns: Tuple of (plan, diagnostics) + + This is a thin, path-based wrapper: it resolves profile_path's + extends/environment overlay chain (see cli.overlay), then delegates + everything else to build_plan_from_profile(), the profile-dict-based + entry point (issue #349). Callers that already have an assembled + profile in memory -- e.g. a runtime-generated profile that has not + (and may never) be written to disk -- can call + cli.overlay.resolve_extends_from_profile()/resolve_profile_from_profile() + plus build_plan_from_profile() directly instead of going through disk. """ diagnostics: list[Diagnostic] = [] @@ -90,6 +99,74 @@ def build_plan( if profile is None: return None, diagnostics + plan, plan_diagnostics = build_plan_from_profile( + profile, + profile_file.parent, + env_file=env_file, + hardened=hardened, + image_source=image_source, + environment=environment, + provenance=provenance, + source_label=str(profile_file), + ) + diagnostics.extend(plan_diagnostics) + return plan, diagnostics + + +def build_plan_from_profile( + profile: dict[str, Any], + profile_dir: Path, + env_file: str | None = None, + hardened: bool = False, + image_source: str | None = None, + environment: str | None = None, + provenance: dict[str, str] | None = None, + source_label: str | None = None, +) -> tuple[dict[str, Any] | None, list[Diagnostic]]: + """ + In-memory/dict-based counterpart to build_plan() (issue #349): builds a + resolved plan directly from an already-loaded/resolved profile dict, + without requiring it to be loaded from (or written to) disk first. This + is everything build_plan() does *after* resolving profile_path's + extends/environment overlay chain; build_plan(profile_path, ...) is a + thin wrapper around this function. + + `profile` is expected to already be fully resolved -- i.e. its own + `extends` chain (if any) and any --environment overlay have already + been merged in, mirroring how build_plan() calls + cli.overlay.resolve_extends()/resolve_profile() before reaching this + point. Callers that need extends/environment support for an in-memory + profile should resolve it first via + cli.overlay.resolve_extends_from_profile()/resolve_profile_from_profile(), + then pass the result here. + + Args: + profile: An already-loaded/resolved profile document (see above). + profile_dir: Directory used to anchor relative module `source:` + fields (see resolve_module_file()), exactly as a real profile + file's parent directory would. Must exist; does not need to + contain a profile.yaml of its own. + env_file: Optional path to .env file for secrets + hardened: See build_plan(). + image_source: See build_plan(). + environment: Optional environment overlay name to record on the + returned plan's "environment" field, purely for output + parity with build_plan() -- resolving an actual overlay must + happen before calling this function (see above). + provenance: Value provenance for the returned plan's "provenance" + field (see build_plan()); defaults to {} when the caller has no + extends/overlay resolution of its own to report. + source_label: Value to record on the returned plan's + "sourceProfile" field in place of a real file path (default: + ""). + + Returns: + Tuple of (plan, diagnostics) + """ + diagnostics: list[Diagnostic] = [] + provenance = provenance if provenance is not None else {} + source_label = source_label or "" + spec = profile.get("spec", {}) secrets, secret_diags = load_profile_secrets(spec.get("secrets"), env_file) diagnostics.extend(secret_diags) @@ -97,10 +174,10 @@ def build_plan( modules = spec.get("modules", []) if not isinstance(modules, list): # Defensive guard: validate_profile() already rejects a non-list - # spec.modules (E010), but build_plan() is a public entry point that - # may be called directly (e.g. by tests/tools) without prior - # validation, so it must not crash with an unhandled TypeError from - # enumerate() on a non-iterable/scalar value. + # spec.modules (E010), but build_plan_from_profile() is a public + # entry point that may be called directly (e.g. by tests/tools) + # without prior validation, so it must not crash with an unhandled + # TypeError from enumerate() on a non-iterable/scalar value. diagnostics.append(Diagnostic( level="error", code="E010", @@ -109,8 +186,6 @@ def build_plan( )) return None, diagnostics - profile_dir = profile_file.parent - loaded_modules: list[dict[str, Any]] = [] module_instances_by_id: dict[str, dict[str, Any]] = {} @@ -245,7 +320,7 @@ def build_plan( "apiVersion": "cds/v1alpha1", "kind": "Plan", "metadata": deepcopy(profile.get("metadata", {})), - "sourceProfile": str(profile_file), + "sourceProfile": source_label, "environment": environment, "provenance": provenance, "runtime": spec.get("runtime", {}), @@ -256,6 +331,68 @@ def build_plan( return plan, diagnostics + +def plan_generated_profile( + profile: dict[str, Any], + profile_dir: Path, + env_file: str | None = None, + environment: str | None = None, + hardened: bool = False, + image_source: str | None = None, + source_label: str | None = None, +) -> tuple[dict[str, Any] | None, list[Diagnostic]]: + """ + One-call convenience wrapper (issue #349) around + cli.overlay.resolve_extends_from_profile()/resolve_profile_from_profile() + + build_plan_from_profile(): resolves `profile`'s own `extends` chain + and, if `environment` is set, its environments/.yaml + overlay, then builds a plan from the result -- all without requiring + `profile` to be loaded from (or written to) disk first. This mirrors + build_plan(profile_path, ...)'s own resolve-then-plan composition, but + for a profile that only exists in memory (e.g. one produced by + cli.main.generate_profile() and never written via + save_generated_profile()). + + `profile_dir` anchors relative module `source:` fields and, if + `profile` declares `extends` or `environment` is set, `extends` parent + references and the environment overlay lookup, the same way a real + profile file's parent directory would. It must exist and, for + extends/environment support, reside under a "profiles/" root; it does + not need to contain a profile.yaml of its own. + + Returns: + Tuple of (plan, diagnostics) + """ + diagnostics: list[Diagnostic] = [] + source_label = source_label or "" + + # Local import: see build_plan()'s equivalent comment. + from .overlay import resolve_extends_from_profile, resolve_profile_from_profile + + if environment is not None: + resolved, provenance, diags = resolve_profile_from_profile( + profile, profile_dir, environment, source_label + ) + else: + resolved, provenance, diags = resolve_extends_from_profile(profile, profile_dir, source_label) + diagnostics.extend(diags) + + if resolved is None: + return None, diagnostics + + plan, plan_diagnostics = build_plan_from_profile( + resolved, + profile_dir, + env_file=env_file, + hardened=hardened, + image_source=image_source, + environment=environment, + provenance=provenance, + source_label=source_label, + ) + diagnostics.extend(plan_diagnostics) + return plan, diagnostics + _CDS_VAR_PATTERN = re.compile(r"\$\{(CDS_[A-Z0-9_]+)\}") def _substitute_config_env_vars( diff --git a/tests/test_overlay.py b/tests/test_overlay.py index e5853e99..61048842 100644 --- a/tests/test_overlay.py +++ b/tests/test_overlay.py @@ -9,7 +9,9 @@ _merge_modules, _merge_value, resolve_extends, + resolve_extends_from_profile, resolve_profile, + resolve_profile_from_profile, ) @@ -728,5 +730,187 @@ def test_merge_profile_docs_does_not_crash_when_overlay_spec_is_null(self): resolve_extends(str(child)) +class InMemoryExtendsCompositionTest(unittest.TestCase): + """ + resolve_extends_from_profile()/resolve_profile_from_profile() (issue + #349): resolving `extends`/`environment` for a profile dict that has no + file of its own on disk yet. + """ + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.root = Path(self._tmp.name) + self.profiles_dir = self.root / "profiles" + self.profiles_dir.mkdir(parents=True) + self.modules_dir = self.root / "modules" / "warehouse" / "postgres" + self.modules_dir.mkdir(parents=True) + + (self.modules_dir / "module.yaml").write_text( + yaml.safe_dump( + { + "apiVersion": "cds/v1alpha1", + "kind": "Module", + "metadata": {"name": "postgres", "category": "warehouse", "version": "0.1.0"}, + "spec": { + "runtime": { + "type": "container", + "service": { + "name": "postgres", + "ports": [{"name": "db", "containerPort": 5432, "protocol": "TCP"}], + }, + }, + "configSchema": {"type": "object", "additionalProperties": True}, + "implementation": {"kind": "docker-compose", "compose": {"services": {}}}, + }, + } + ) + ) + + (self.profiles_dir / "base").mkdir(parents=True) + (self.profiles_dir / "base" / "profile.yaml").write_text( + yaml.safe_dump( + { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "base", "environment": "local"}, + "spec": { + "runtime": {"type": "docker-compose"}, + "modules": [ + { + "id": "db", + "source": "../../modules/warehouse/postgres", + "version": "0.1.0", + "enabled": True, + "config": {"replicas": 1}, + } + ], + }, + } + ) + ) + + # No profile.yaml here: this directory only anchors relative + # extends refs / module source: paths and environment overlay + # lookups for an in-memory profile that lives here conceptually. + self.profile_dir = self.profiles_dir / "generated" + self.profile_dir.mkdir(parents=True) + + def test_resolve_extends_from_profile_with_no_extends_returns_doc_unchanged(self): + profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "spec": {"runtime": {"type": "docker-compose"}, "modules": []}, + } + resolved, provenance, diagnostics = resolve_extends_from_profile(profile, self.profile_dir) + self.assertEqual(resolved, profile) + self.assertEqual(provenance, {}) + self.assertFalse(diagnostics) + + def test_resolve_extends_from_profile_merges_an_on_disk_parent(self): + profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "extends": ["base"], + "spec": {"modules": [{"id": "db", "config": {"replicas": 5}}]}, + } + resolved, provenance, diagnostics = resolve_extends_from_profile( + profile, self.profile_dir, source_label="generated-profile" + ) + self.assertFalse(any(d.level == "error" for d in diagnostics), diagnostics) + self.assertEqual(resolved["spec"]["modules"][0]["config"]["replicas"], 5) + self.assertEqual(provenance["spec.modules[db]"], "generated-profile") + + def test_resolve_extends_from_profile_rejects_a_missing_parent(self): + profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "extends": ["does-not-exist"], + "spec": {"modules": []}, + } + resolved, _prov, diagnostics = resolve_extends_from_profile(profile, self.profile_dir) + self.assertIsNone(resolved) + self.assertTrue(any(d.code == "E112" for d in diagnostics)) + + def test_resolve_profile_from_profile_with_no_environment_validates_directly(self): + profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "spec": { + "runtime": {"type": "docker-compose"}, + "modules": [ + { + "id": "db", + "source": "../../modules/warehouse/postgres", + "version": "0.1.0", + "enabled": True, + "config": {"replicas": 1}, + } + ], + }, + } + resolved, provenance, diagnostics = resolve_profile_from_profile(profile, self.profile_dir) + self.assertFalse(any(d.level == "error" for d in diagnostics), diagnostics) + self.assertEqual(resolved, profile) + self.assertEqual(provenance, {}) + + def test_resolve_profile_from_profile_merges_environment_overlay(self): + env_dir = self.profile_dir / "environments" + env_dir.mkdir(parents=True) + (env_dir / "prod.yaml").write_text( + yaml.safe_dump({"spec": {"modules": [{"id": "db", "config": {"replicas": 3}}]}}) + ) + profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "spec": { + "runtime": {"type": "docker-compose"}, + "modules": [ + { + "id": "db", + "source": "../../modules/warehouse/postgres", + "version": "0.1.0", + "enabled": True, + "config": {"replicas": 1}, + } + ], + }, + } + resolved, provenance, diagnostics = resolve_profile_from_profile( + profile, self.profile_dir, environment="prod" + ) + self.assertFalse(any(d.level == "error" for d in diagnostics), diagnostics) + self.assertEqual(resolved["spec"]["modules"][0]["config"]["replicas"], 3) + self.assertIn("spec.modules[db]", provenance) + + def test_resolve_profile_from_profile_unknown_environment_is_rejected(self): + profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "spec": {"modules": []}, + } + resolved, _prov, diagnostics = resolve_profile_from_profile( + profile, self.profile_dir, environment="does-not-exist" + ) + self.assertIsNone(resolved) + self.assertTrue(any(d.code == "E091" for d in diagnostics)) + + def test_resolve_profile_from_profile_invalid_module_fails_validation(self): + profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "spec": {"modules": [{"id": "ghost", "source": "does/not/exist", "config": {}}]}, + } + resolved, _prov, diagnostics = resolve_profile_from_profile(profile, self.profile_dir) + self.assertIsNone(resolved) + self.assertTrue(any(d.level == "error" for d in diagnostics)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_planner.py b/tests/test_planner.py index 64c78271..40cb36d8 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -1347,5 +1347,199 @@ def test_missing_operator_reports_e021_instead_of_silently_skipping(self): self.assertIn("malformed requiredIf", errors[0].message) +class InMemoryProfilePlanningTest(unittest.TestCase): + """ + build_plan_from_profile()/plan_generated_profile() (issue #349): planning + a profile dict directly, without requiring it to be loaded from (or + written to) disk first. + """ + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.root = Path(self._tmp.name) + self.profiles_dir = self.root / "profiles" + self.profiles_dir.mkdir(parents=True) + self.modules_dir = self.root / "modules" / "warehouse" / "postgres" + self.modules_dir.mkdir(parents=True) + + import yaml + + (self.modules_dir / "module.yaml").write_text( + yaml.safe_dump( + { + "apiVersion": "cds/v1alpha1", + "kind": "Module", + "metadata": {"name": "postgres", "category": "warehouse", "version": "0.1.0"}, + "spec": { + "runtime": { + "type": "container", + "service": { + "name": "postgres", + "ports": [{"name": "db", "containerPort": 5432, "protocol": "TCP"}], + }, + }, + "configSchema": {"type": "object", "additionalProperties": True}, + "implementation": {"kind": "docker-compose", "compose": {"services": {}}}, + }, + } + ), + encoding="utf-8", + ) + + # A never-materialized profile directory: it exists on disk (so + # relative module `source:` fields can resolve), but has no + # profile.yaml of its own -- exercising the "path-optional" case + # from issue #349. + self.profile_dir = self.profiles_dir / "generated" + self.profile_dir.mkdir(parents=True) + + self.profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "spec": { + "runtime": {"type": "docker-compose"}, + "modules": [ + { + "id": "db", + "source": "../../modules/warehouse/postgres", + "version": "0.1.0", + "enabled": True, + "config": {"replicas": 1}, + } + ], + }, + } + + def test_build_plan_from_profile_plans_an_in_memory_profile_with_no_file_on_disk(self): + plan, diagnostics = planner.build_plan_from_profile(self.profile, self.profile_dir) + + self.assertFalse(any(d.level == "error" for d in diagnostics), diagnostics) + self.assertIsNotNone(plan) + self.assertEqual(plan["sourceProfile"], "") + self.assertEqual(plan["provenance"], {}) + self.assertEqual(len(plan["modules"]), 1) + self.assertEqual(plan["modules"][0]["id"], "db") + self.assertEqual(plan["modules"][0]["config"]["replicas"], 1) + + def test_build_plan_from_profile_honors_source_label_and_environment(self): + plan, diagnostics = planner.build_plan_from_profile( + self.profile, + self.profile_dir, + environment="prod", + source_label="generated-at-runtime", + ) + + self.assertFalse(any(d.level == "error" for d in diagnostics), diagnostics) + self.assertEqual(plan["sourceProfile"], "generated-at-runtime") + self.assertEqual(plan["environment"], "prod") + + def test_build_plan_from_profile_matches_disk_based_build_plan_output(self): + import yaml + + profile_file = self.profile_dir / "profile.yaml" + profile_file.write_text(yaml.safe_dump(self.profile), encoding="utf-8") + + disk_plan, disk_diags = planner.build_plan(str(profile_file)) + memory_plan, memory_diags = planner.build_plan_from_profile( + self.profile, self.profile_dir, source_label=str(profile_file) + ) + + self.assertFalse(any(d.level == "error" for d in disk_diags), disk_diags) + self.assertFalse(any(d.level == "error" for d in memory_diags), memory_diags) + self.assertEqual(disk_plan, memory_plan) + + def test_build_plan_from_profile_rejects_non_list_modules_without_crashing(self): + profile = dict(self.profile) + profile["spec"] = dict(profile["spec"]) + profile["spec"]["modules"] = "not-a-list" + + plan, diagnostics = planner.build_plan_from_profile(profile, self.profile_dir) + + self.assertIsNone(plan) + self.assertTrue(any(d.code == "E010" for d in diagnostics)) + + def test_plan_generated_profile_resolves_extends_from_an_in_memory_child(self): + import yaml + + base_dir = self.profiles_dir / "base" + base_dir.mkdir(parents=True) + (base_dir / "profile.yaml").write_text( + yaml.safe_dump( + { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "base", "environment": "local"}, + "spec": { + "runtime": {"type": "docker-compose"}, + "modules": [ + { + "id": "db", + "source": "../../modules/warehouse/postgres", + "version": "0.1.0", + "enabled": True, + "config": {"replicas": 1}, + } + ], + }, + } + ), + encoding="utf-8", + ) + + child_profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "extends": ["base"], + "spec": {"modules": [{"id": "db", "config": {"replicas": 5}}]}, + } + + plan, diagnostics = planner.plan_generated_profile(child_profile, self.profile_dir) + + self.assertFalse(any(d.level == "error" for d in diagnostics), diagnostics) + self.assertIsNotNone(plan) + self.assertEqual(plan["modules"][0]["config"]["replicas"], 5) + self.assertIn("spec.modules[db]", plan["provenance"]) + + def test_plan_generated_profile_resolves_environment_overlay_in_memory(self): + import yaml + + env_dir = self.profile_dir / "environments" + env_dir.mkdir(parents=True) + (env_dir / "prod.yaml").write_text( + yaml.safe_dump({"spec": {"modules": [{"id": "db", "config": {"replicas": 3}}]}}), + encoding="utf-8", + ) + + plan, diagnostics = planner.plan_generated_profile( + self.profile, self.profile_dir, environment="prod" + ) + + self.assertFalse(any(d.level == "error" for d in diagnostics), diagnostics) + self.assertIsNotNone(plan) + self.assertEqual(plan["modules"][0]["config"]["replicas"], 3) + self.assertEqual(plan["environment"], "prod") + + def test_plan_generated_profile_propagates_module_resolution_failure(self): + # A module with an unresolvable source produces the same E022 + # diagnostic build_plan() itself would (per-module errors skip that + # module rather than nulling the whole plan, matching build_plan()'s + # existing behavior for structurally valid-but-broken profiles). + bad_profile = { + "apiVersion": "cds/v1alpha1", + "kind": "Profile", + "metadata": {"name": "generated", "environment": "local"}, + "spec": {"modules": [{"id": "ghost", "source": "does/not/exist", "config": {}}]}, + } + + plan, diagnostics = planner.plan_generated_profile(bad_profile, self.profile_dir) + + self.assertIsNotNone(plan) + self.assertEqual(plan["modules"], []) + self.assertTrue(any(d.code == "E022" for d in diagnostics), diagnostics) + + if __name__ == "__main__": unittest.main()