composable-data-stack 0.10.0__py3-none-any.whl → 0.12.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 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