composable-data-stack 0.11.0__py3-none-any.whl → 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/audit_log.py +155 -0
- cli/composer.py +366 -0
- cli/getter.py +164 -13
- cli/k8s_renderer.py +13 -1
- cli/main.py +554 -52
- cli/planner.py +4 -1
- cli/preflight.py +148 -7
- cli/renderer.py +36 -8
- cli/resources/rule-schema.json +1 -0
- cli/resources/rule-set.json +14 -11
- cli/secret_leak.py +210 -0
- cli/security.py +137 -1
- cli/update_check.py +128 -0
- {composable_data_stack-0.11.0.dist-info → composable_data_stack-0.13.0.dist-info}/METADATA +8 -85
- {composable_data_stack-0.11.0.dist-info → composable_data_stack-0.13.0.dist-info}/RECORD +19 -15
- {composable_data_stack-0.11.0.dist-info → composable_data_stack-0.13.0.dist-info}/WHEEL +1 -1
- {composable_data_stack-0.11.0.dist-info → composable_data_stack-0.13.0.dist-info}/entry_points.txt +0 -0
- {composable_data_stack-0.11.0.dist-info → composable_data_stack-0.13.0.dist-info}/licenses/LICENSE +0 -0
- {composable_data_stack-0.11.0.dist-info → composable_data_stack-0.13.0.dist-info}/top_level.txt +0 -0
cli/audit_log.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local, append-only audit trail of rendered/applied stacks (#737).
|
|
3
|
+
|
|
4
|
+
This is distinct from `cli/getter.py`'s `.cds/get-manifest.json`, which only
|
|
5
|
+
tracks file provenance for `cds get`. This module instead records *what was
|
|
6
|
+
run* for a profile over time: timestamp, profile identity, resolved module
|
|
7
|
+
versions/image references, the command invoked, and its outcome. Users who
|
|
8
|
+
are themselves subject to incident-reporting obligations (e.g.
|
|
9
|
+
NIS2/Cyberbeveiligingswet-style regimes) can use this to reconstruct what
|
|
10
|
+
was running and when, without relying on ad hoc memory or external
|
|
11
|
+
infrastructure logs.
|
|
12
|
+
|
|
13
|
+
The log is strictly local evidence for the user's own purposes: nothing
|
|
14
|
+
here is sent to CDS or any third party. Only secret *aliases* are ever
|
|
15
|
+
recorded (e.g. "CDS_DB_PASSWORD"), the same `${CDS_*}` placeholder
|
|
16
|
+
convention used by the planner/renderer (see `cli/secrets.py`) -- never
|
|
17
|
+
secret values.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
_LOG_FILE = Path(".cds") / "audit-log.jsonl"
|
|
29
|
+
|
|
30
|
+
# Set to disable the audit log without touching project config, e.g. for a
|
|
31
|
+
# single invocation or a CI job that should not write local evidence files.
|
|
32
|
+
_ENV_DISABLE = "CDS_AUDIT_LOG_DISABLE"
|
|
33
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def audit_log_path(project_root: Path | None = None) -> Path:
|
|
37
|
+
"""Return the path audit entries are appended to for `project_root`.
|
|
38
|
+
|
|
39
|
+
No `cli.main` call site currently passes an explicit `project_root`, so
|
|
40
|
+
this defaults to `Path.cwd()` rather than the `resolve_project_root
|
|
41
|
+
(profile_path)` convention used elsewhere (e.g. for rendered Compose
|
|
42
|
+
output). That means audit entries land in whatever directory the user
|
|
43
|
+
happened to invoke `cds` from, not necessarily the profile's own
|
|
44
|
+
project root (e.g. when `CDS_PROFILE_PATH` points at a separate
|
|
45
|
+
profiles checkout). Tracked by #822.
|
|
46
|
+
"""
|
|
47
|
+
root = (project_root or Path.cwd()).expanduser().resolve()
|
|
48
|
+
return root / _LOG_FILE
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_enabled(*, config_disabled: bool = False) -> bool:
|
|
52
|
+
"""Whether audit logging is active.
|
|
53
|
+
|
|
54
|
+
Disabled if the project config has `audit.enabled` set to `false`
|
|
55
|
+
(`config_disabled=True`), or if `CDS_AUDIT_LOG_DISABLE` is truthy.
|
|
56
|
+
"""
|
|
57
|
+
if config_disabled:
|
|
58
|
+
return False
|
|
59
|
+
return os.environ.get(_ENV_DISABLE, "").strip().lower() not in _TRUTHY
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _module_summary(plan: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
63
|
+
"""Resolved module id/source/version and image reference fields.
|
|
64
|
+
|
|
65
|
+
Only reference-shaped image fields (repository/tag/digest/variant) are
|
|
66
|
+
included -- never the full module config, which could carry
|
|
67
|
+
`secrets.<alias>` references or other user-provided values.
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(plan, dict):
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
summary: list[dict[str, Any]] = []
|
|
73
|
+
for module in plan.get("modules", []):
|
|
74
|
+
if not isinstance(module, dict):
|
|
75
|
+
continue
|
|
76
|
+
entry: dict[str, Any] = {
|
|
77
|
+
"id": module.get("id"),
|
|
78
|
+
"source": module.get("source"),
|
|
79
|
+
"version": module.get("version"),
|
|
80
|
+
}
|
|
81
|
+
config = module.get("config")
|
|
82
|
+
image_config = config.get("image") if isinstance(config, dict) else None
|
|
83
|
+
if isinstance(image_config, dict):
|
|
84
|
+
image_ref = {
|
|
85
|
+
key: image_config[key]
|
|
86
|
+
for key in ("repository", "tag", "digest", "variant")
|
|
87
|
+
if key in image_config
|
|
88
|
+
}
|
|
89
|
+
if image_ref:
|
|
90
|
+
entry["image"] = image_ref
|
|
91
|
+
summary.append(entry)
|
|
92
|
+
return summary
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _secret_aliases(plan: dict[str, Any] | None) -> list[str]:
|
|
96
|
+
"""Secret alias names referenced by the plan, never their values.
|
|
97
|
+
|
|
98
|
+
`plan["secrets"]` already maps alias/env names to `CDS_*` env var
|
|
99
|
+
*names* (see `cli/secrets.py:load_profile_secrets`) -- no secret value
|
|
100
|
+
ever passes through the planner, so this is safe to record verbatim.
|
|
101
|
+
"""
|
|
102
|
+
if not isinstance(plan, dict):
|
|
103
|
+
return []
|
|
104
|
+
secrets = plan.get("secrets")
|
|
105
|
+
if not isinstance(secrets, dict):
|
|
106
|
+
return []
|
|
107
|
+
return sorted(secrets.keys())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def record_entry(
|
|
111
|
+
*,
|
|
112
|
+
command: str,
|
|
113
|
+
profile: str,
|
|
114
|
+
outcome: str,
|
|
115
|
+
environment: str | None = None,
|
|
116
|
+
plan: dict[str, Any] | None = None,
|
|
117
|
+
details: dict[str, Any] | None = None,
|
|
118
|
+
project_root: Path | None = None,
|
|
119
|
+
config_disabled: bool = False,
|
|
120
|
+
) -> Path | None:
|
|
121
|
+
"""Append one JSON Lines entry describing a render/apply attempt.
|
|
122
|
+
|
|
123
|
+
Returns the log path written to, or `None` if audit logging is
|
|
124
|
+
disabled. Never raises: a failure to write the audit log must not
|
|
125
|
+
block the primary command it is observing.
|
|
126
|
+
"""
|
|
127
|
+
if not is_enabled(config_disabled=config_disabled):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
log_path = audit_log_path(project_root)
|
|
131
|
+
entry: dict[str, Any] = {
|
|
132
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
133
|
+
"command": command,
|
|
134
|
+
"profile": profile,
|
|
135
|
+
"environment": environment,
|
|
136
|
+
"outcome": outcome,
|
|
137
|
+
"modules": _module_summary(plan),
|
|
138
|
+
"secretAliases": _secret_aliases(plan),
|
|
139
|
+
}
|
|
140
|
+
if details:
|
|
141
|
+
entry["details"] = details
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
if log_path.is_symlink():
|
|
146
|
+
# Never follow/overwrite-through a symlink planted at the log
|
|
147
|
+
# path (same guard as the get-manifest writer in getter.py).
|
|
148
|
+
log_path.unlink()
|
|
149
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
150
|
+
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
print(f"WARNING Could not write audit log entry to {log_path}: {exc}", file=sys.stderr)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
return log_path
|
cli/composer.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# cli/composer.py
|
|
2
|
+
"""
|
|
3
|
+
Compose-time module wiring (#807): the follow-up to #349's
|
|
4
|
+
`cds generate-profile` noted in docs/roadmap.md ("allow runtime-generated
|
|
5
|
+
profiles for planning and composition beyond `cds generate-profile`").
|
|
6
|
+
`generate-profile` only persists an already-assembled profile document;
|
|
7
|
+
`compose_profile()` here builds that document by merging a new module
|
|
8
|
+
instance into an existing profile, resolving its `consumes` entries
|
|
9
|
+
against contracts already `provides`d by the profile's existing modules
|
|
10
|
+
instead of requiring the caller to hand-write the `contractRef`/
|
|
11
|
+
`dependsOn`/`secrets.values` wiring.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from copy import deepcopy
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .diagnostics import Diagnostic
|
|
21
|
+
from .loader import load_yaml_file, resolve_module_file
|
|
22
|
+
from .resolver import is_secret_ref, parse_contract_ref
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _collect_provided_contracts(
|
|
26
|
+
profile: dict[str, Any],
|
|
27
|
+
profile_dir: Path,
|
|
28
|
+
modules_root: Path | None,
|
|
29
|
+
) -> tuple[dict[str, str], list[str], list[Diagnostic]]:
|
|
30
|
+
"""
|
|
31
|
+
Loads every enabled module instance already in `profile` and returns a
|
|
32
|
+
mapping of "<instanceId>.<providedContractName>" -> contract kind,
|
|
33
|
+
plus every instance id already in use (enabled or not, for collision
|
|
34
|
+
checks). Deliberately only reads each module's `provides` entries
|
|
35
|
+
rather than reusing validator.load_module_instances()'s full
|
|
36
|
+
config/schema validation pass, since compose only needs to know what
|
|
37
|
+
contracts are available to bind against.
|
|
38
|
+
"""
|
|
39
|
+
providers: dict[str, str] = {}
|
|
40
|
+
existing_ids: list[str] = []
|
|
41
|
+
diagnostics: list[Diagnostic] = []
|
|
42
|
+
|
|
43
|
+
modules = profile.get("spec", {}).get("modules", [])
|
|
44
|
+
for i, instance in enumerate(modules):
|
|
45
|
+
if not isinstance(instance, dict):
|
|
46
|
+
continue
|
|
47
|
+
instance_id = instance.get("id")
|
|
48
|
+
if isinstance(instance_id, str):
|
|
49
|
+
existing_ids.append(instance_id)
|
|
50
|
+
if instance.get("enabled", True) is False:
|
|
51
|
+
continue
|
|
52
|
+
source = instance.get("source")
|
|
53
|
+
if not isinstance(source, str):
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
module_file, diags = resolve_module_file(
|
|
57
|
+
source=source,
|
|
58
|
+
profile_dir=profile_dir,
|
|
59
|
+
module_root=modules_root,
|
|
60
|
+
diagnostic_path=f"spec.modules[{i}].source",
|
|
61
|
+
)
|
|
62
|
+
diagnostics.extend(diags)
|
|
63
|
+
if module_file is None:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
module_def, diags = load_yaml_file(module_file)
|
|
67
|
+
diagnostics.extend(diags)
|
|
68
|
+
if module_def is None or not isinstance(instance_id, str):
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
for provided in module_def.get("spec", {}).get("provides", []) or []:
|
|
72
|
+
if not isinstance(provided, dict):
|
|
73
|
+
continue
|
|
74
|
+
name = provided.get("name")
|
|
75
|
+
kind = (provided.get("contract") or {}).get("kind")
|
|
76
|
+
if name and kind:
|
|
77
|
+
providers[f"{instance_id}.{name}"] = kind
|
|
78
|
+
|
|
79
|
+
return providers, existing_ids, diagnostics
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _set_nested(config: dict[str, Any], dotted_path: str, value: Any) -> None:
|
|
83
|
+
"""Sets `config[<dotted_path>] = value`, creating intermediate dicts as
|
|
84
|
+
needed, and merging `value` into an existing dict at that path rather
|
|
85
|
+
than clobbering sibling keys (so a resolved `contractRef` can coexist
|
|
86
|
+
with e.g. a `driver` field set by --set on the same config object)."""
|
|
87
|
+
parts = dotted_path.split(".")
|
|
88
|
+
current = config
|
|
89
|
+
for part in parts[:-1]:
|
|
90
|
+
nxt = current.get(part)
|
|
91
|
+
if not isinstance(nxt, dict):
|
|
92
|
+
nxt = {}
|
|
93
|
+
current[part] = nxt
|
|
94
|
+
current = nxt
|
|
95
|
+
last = parts[-1]
|
|
96
|
+
existing = current.get(last)
|
|
97
|
+
if isinstance(value, dict) and isinstance(existing, dict):
|
|
98
|
+
existing.update(value)
|
|
99
|
+
else:
|
|
100
|
+
current[last] = value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _iter_secret_aliases(value: Any):
|
|
104
|
+
if isinstance(value, dict):
|
|
105
|
+
for v in value.values():
|
|
106
|
+
yield from _iter_secret_aliases(v)
|
|
107
|
+
elif isinstance(value, list):
|
|
108
|
+
for item in value:
|
|
109
|
+
yield from _iter_secret_aliases(item)
|
|
110
|
+
elif is_secret_ref(value):
|
|
111
|
+
yield value.split(".", 1)[1]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def compose_profile(
|
|
115
|
+
profile: dict[str, Any],
|
|
116
|
+
profile_dir: Path,
|
|
117
|
+
module_source: str,
|
|
118
|
+
*,
|
|
119
|
+
instance_id: str | None = None,
|
|
120
|
+
version: str | None = None,
|
|
121
|
+
enabled: bool = True,
|
|
122
|
+
depends_on: list[str] | None = None,
|
|
123
|
+
config_settings: list[tuple[str, Any]] | None = None,
|
|
124
|
+
bindings: dict[str, str] | None = None,
|
|
125
|
+
secret_env_names: dict[str, str] | None = None,
|
|
126
|
+
modules_root: Path | None = None,
|
|
127
|
+
) -> tuple[dict[str, Any] | None, list[Diagnostic]]:
|
|
128
|
+
"""
|
|
129
|
+
Returns (merged_profile, diagnostics). merged_profile is a deep copy of
|
|
130
|
+
`profile` with a new `spec.modules` entry for `module_source` appended
|
|
131
|
+
(and `spec.secrets.values` extended if the new module's config
|
|
132
|
+
references an as-yet-undefined secret alias); it is None if any
|
|
133
|
+
diagnostic is an error, in which case `profile` is left untouched.
|
|
134
|
+
|
|
135
|
+
`bindings` maps a consume entry name to an explicit "<moduleId>.
|
|
136
|
+
<providedContract>" ref; any consume entry not covered there is
|
|
137
|
+
auto-resolved when exactly one existing enabled module instance
|
|
138
|
+
provides a matching contract kind, else reported as an error (0
|
|
139
|
+
candidates for a required consume, or more than 1 candidate -- both
|
|
140
|
+
ambiguous without an explicit binding).
|
|
141
|
+
|
|
142
|
+
`secret_env_names` maps a secret alias (as referenced by
|
|
143
|
+
"secrets.<alias>" somewhere in the new module's resolved config) to the
|
|
144
|
+
environment variable name that should back it; aliases already defined
|
|
145
|
+
on `profile` are left untouched, any other alias actually referenced by
|
|
146
|
+
the new config without an entry here is reported as an error.
|
|
147
|
+
"""
|
|
148
|
+
diagnostics: list[Diagnostic] = []
|
|
149
|
+
bindings = bindings or {}
|
|
150
|
+
secret_env_names = secret_env_names or {}
|
|
151
|
+
depends_on = list(depends_on or [])
|
|
152
|
+
|
|
153
|
+
if not isinstance(profile, dict) or not isinstance(profile.get("spec"), dict):
|
|
154
|
+
return None, [
|
|
155
|
+
Diagnostic(
|
|
156
|
+
level="error",
|
|
157
|
+
code="E120",
|
|
158
|
+
message='Profile must be a mapping with a "spec" object before composing a module into it.',
|
|
159
|
+
path="spec",
|
|
160
|
+
)
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
module_file, diags = resolve_module_file(
|
|
164
|
+
source=module_source,
|
|
165
|
+
profile_dir=profile_dir,
|
|
166
|
+
module_root=modules_root,
|
|
167
|
+
diagnostic_path="module_source",
|
|
168
|
+
)
|
|
169
|
+
diagnostics.extend(diags)
|
|
170
|
+
if module_file is None:
|
|
171
|
+
return None, diagnostics
|
|
172
|
+
|
|
173
|
+
module_def, diags = load_yaml_file(module_file)
|
|
174
|
+
diagnostics.extend(diags)
|
|
175
|
+
if module_def is None:
|
|
176
|
+
return None, diagnostics
|
|
177
|
+
|
|
178
|
+
if module_def.get("kind") != "Module":
|
|
179
|
+
diagnostics.append(
|
|
180
|
+
Diagnostic(
|
|
181
|
+
level="error",
|
|
182
|
+
code="E121",
|
|
183
|
+
message=f'Expected kind: "Module" at "{module_file}".',
|
|
184
|
+
path="module_source",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return None, diagnostics
|
|
188
|
+
|
|
189
|
+
providers, existing_ids, provider_diags = _collect_provided_contracts(profile, profile_dir, modules_root)
|
|
190
|
+
diagnostics.extend(provider_diags)
|
|
191
|
+
if any(d.level == "error" for d in diagnostics):
|
|
192
|
+
return None, diagnostics
|
|
193
|
+
|
|
194
|
+
metadata = module_def.get("metadata") if isinstance(module_def.get("metadata"), dict) else {}
|
|
195
|
+
resolved_id = instance_id or metadata.get("name")
|
|
196
|
+
if not isinstance(resolved_id, str) or not resolved_id.strip():
|
|
197
|
+
diagnostics.append(
|
|
198
|
+
Diagnostic(
|
|
199
|
+
level="error",
|
|
200
|
+
code="E122",
|
|
201
|
+
message=(
|
|
202
|
+
"Could not determine a module instance id; pass instance_id "
|
|
203
|
+
"explicitly or ensure the module declares metadata.name."
|
|
204
|
+
),
|
|
205
|
+
path="module_source",
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
return None, diagnostics
|
|
209
|
+
|
|
210
|
+
if resolved_id in existing_ids:
|
|
211
|
+
diagnostics.append(
|
|
212
|
+
Diagnostic(
|
|
213
|
+
level="error",
|
|
214
|
+
code="E123",
|
|
215
|
+
message=f'Profile already has a module instance with id "{resolved_id}"; pass a different instance_id.',
|
|
216
|
+
path=f"spec.modules.{resolved_id}",
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return None, diagnostics
|
|
220
|
+
|
|
221
|
+
resolved_version = version or metadata.get("version")
|
|
222
|
+
|
|
223
|
+
config: dict[str, Any] = {}
|
|
224
|
+
for path, value in config_settings or []:
|
|
225
|
+
_set_nested(config, path, value)
|
|
226
|
+
|
|
227
|
+
for consume in module_def.get("spec", {}).get("consumes", []) or []:
|
|
228
|
+
if not isinstance(consume, dict):
|
|
229
|
+
continue
|
|
230
|
+
consume_name = consume.get("name")
|
|
231
|
+
mapped_from = consume.get("mappedFrom")
|
|
232
|
+
if not consume_name or not mapped_from:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
required = consume.get("required", True)
|
|
236
|
+
contract_kind = (consume.get("contract") or {}).get("kind")
|
|
237
|
+
|
|
238
|
+
chosen = bindings.get(consume_name)
|
|
239
|
+
if chosen is not None:
|
|
240
|
+
if parse_contract_ref(chosen) is None:
|
|
241
|
+
diagnostics.append(
|
|
242
|
+
Diagnostic(
|
|
243
|
+
level="error",
|
|
244
|
+
code="E124",
|
|
245
|
+
message=f'Invalid contract ref "{chosen}" bound for "{consume_name}".',
|
|
246
|
+
path=f"consumes.{consume_name}",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
continue
|
|
250
|
+
provided_kind = providers.get(chosen)
|
|
251
|
+
if provided_kind is None:
|
|
252
|
+
diagnostics.append(
|
|
253
|
+
Diagnostic(
|
|
254
|
+
level="error",
|
|
255
|
+
code="E124",
|
|
256
|
+
message=(
|
|
257
|
+
f'Bound contract ref "{chosen}" for "{consume_name}" does not match any '
|
|
258
|
+
"contract provided by an existing enabled module in the profile."
|
|
259
|
+
),
|
|
260
|
+
path=f"consumes.{consume_name}",
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
continue
|
|
264
|
+
if provided_kind != contract_kind:
|
|
265
|
+
diagnostics.append(
|
|
266
|
+
Diagnostic(
|
|
267
|
+
level="error",
|
|
268
|
+
code="E124",
|
|
269
|
+
message=(
|
|
270
|
+
f'Bound contract ref "{chosen}" for "{consume_name}" provides a '
|
|
271
|
+
f'"{provided_kind}" contract, but "{consume_name}" requires "{contract_kind}".'
|
|
272
|
+
),
|
|
273
|
+
path=f"consumes.{consume_name}",
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
else:
|
|
278
|
+
candidates = sorted(ref for ref, kind in providers.items() if kind == contract_kind)
|
|
279
|
+
if len(candidates) == 1:
|
|
280
|
+
chosen = candidates[0]
|
|
281
|
+
elif not candidates:
|
|
282
|
+
if required:
|
|
283
|
+
diagnostics.append(
|
|
284
|
+
Diagnostic(
|
|
285
|
+
level="error",
|
|
286
|
+
code="E125",
|
|
287
|
+
message=(
|
|
288
|
+
f'No existing enabled module provides a "{contract_kind}" contract for '
|
|
289
|
+
f'required consume entry "{consume_name}"; bind one explicitly with '
|
|
290
|
+
f"--bind {consume_name}=<moduleId>.<providedContract>."
|
|
291
|
+
),
|
|
292
|
+
path=f"consumes.{consume_name}",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
continue
|
|
296
|
+
else:
|
|
297
|
+
diagnostics.append(
|
|
298
|
+
Diagnostic(
|
|
299
|
+
level="error",
|
|
300
|
+
code="E125",
|
|
301
|
+
message=(
|
|
302
|
+
f'Multiple existing modules provide a "{contract_kind}" contract for consume '
|
|
303
|
+
f'entry "{consume_name}": {", ".join(candidates)}. Disambiguate with --bind '
|
|
304
|
+
f"{consume_name}=<moduleId>.<providedContract>."
|
|
305
|
+
),
|
|
306
|
+
path=f"consumes.{consume_name}",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
producer_id = chosen.split(".", 1)[0]
|
|
312
|
+
if producer_id not in depends_on:
|
|
313
|
+
depends_on.append(producer_id)
|
|
314
|
+
|
|
315
|
+
relative_path = mapped_from
|
|
316
|
+
prefix = "spec.config."
|
|
317
|
+
if relative_path.startswith(prefix):
|
|
318
|
+
relative_path = relative_path[len(prefix) :]
|
|
319
|
+
_set_nested(config, relative_path, {"contractRef": chosen})
|
|
320
|
+
|
|
321
|
+
if any(d.level == "error" for d in diagnostics):
|
|
322
|
+
return None, diagnostics
|
|
323
|
+
|
|
324
|
+
secret_values = deepcopy((profile.get("spec", {}).get("secrets") or {}).get("values") or {})
|
|
325
|
+
for alias in sorted(set(_iter_secret_aliases(config))):
|
|
326
|
+
if alias in secret_values:
|
|
327
|
+
continue
|
|
328
|
+
env_name = secret_env_names.get(alias)
|
|
329
|
+
if not env_name:
|
|
330
|
+
diagnostics.append(
|
|
331
|
+
Diagnostic(
|
|
332
|
+
level="error",
|
|
333
|
+
code="E126",
|
|
334
|
+
message=(
|
|
335
|
+
f'Module config references secret alias "{alias}" ("secrets.{alias}") that is '
|
|
336
|
+
f"not defined on the profile; pass --secret {alias}=<ENV_VAR_NAME>."
|
|
337
|
+
),
|
|
338
|
+
path=f"secrets.values.{alias}",
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
continue
|
|
342
|
+
secret_values[alias] = {"env": env_name, "required": True}
|
|
343
|
+
|
|
344
|
+
if any(d.level == "error" for d in diagnostics):
|
|
345
|
+
return None, diagnostics
|
|
346
|
+
|
|
347
|
+
merged = deepcopy(profile)
|
|
348
|
+
merged.setdefault("spec", {}).setdefault("modules", [])
|
|
349
|
+
new_instance: dict[str, Any] = {
|
|
350
|
+
"id": resolved_id,
|
|
351
|
+
"source": module_source,
|
|
352
|
+
"enabled": enabled,
|
|
353
|
+
"config": config,
|
|
354
|
+
}
|
|
355
|
+
if resolved_version:
|
|
356
|
+
new_instance["version"] = resolved_version
|
|
357
|
+
if depends_on:
|
|
358
|
+
new_instance["dependsOn"] = sorted(set(depends_on))
|
|
359
|
+
merged["spec"]["modules"].append(new_instance)
|
|
360
|
+
|
|
361
|
+
if secret_values:
|
|
362
|
+
secrets_block = merged["spec"].setdefault("secrets", {"provider": {"type": "env"}})
|
|
363
|
+
secrets_block.setdefault("provider", {"type": "env"})
|
|
364
|
+
secrets_block["values"] = secret_values
|
|
365
|
+
|
|
366
|
+
return merged, diagnostics
|