kdcube-cli 0.0.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.
@@ -0,0 +1,500 @@
1
+ # SPDX-License-Identifier: MIT
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import posixpath
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+ from rich.console import Console
12
+
13
+
14
+ _MANAGED_BUNDLE_METADATA_FILE = ".kdcube-managed-bundle.json"
15
+
16
+
17
+ def aws_cli_env(*, region: str | None, profile: str | None) -> dict[str, str]:
18
+ env = os.environ.copy()
19
+ if region:
20
+ env["AWS_REGION"] = region
21
+ env["AWS_DEFAULT_REGION"] = region
22
+ if profile:
23
+ env["AWS_PROFILE"] = profile
24
+ return env
25
+
26
+
27
+ def aws_secret_json(
28
+ *,
29
+ secret_id: str,
30
+ region: str | None,
31
+ profile: str | None,
32
+ required: bool,
33
+ ) -> dict[str, object] | None:
34
+ cmd = [
35
+ "aws",
36
+ "secretsmanager",
37
+ "get-secret-value",
38
+ "--secret-id",
39
+ secret_id,
40
+ "--query",
41
+ "SecretString",
42
+ "--output",
43
+ "text",
44
+ ]
45
+ proc = subprocess.run(
46
+ cmd,
47
+ capture_output=True,
48
+ text=True,
49
+ env=aws_cli_env(region=region, profile=profile),
50
+ )
51
+ if proc.returncode != 0:
52
+ stderr = (proc.stderr or "").strip()
53
+ if not required and "ResourceNotFoundException" in stderr:
54
+ return None
55
+ raise SystemExit(f"Failed to read AWS secret {secret_id}: {stderr or proc.stdout.strip()}")
56
+ raw = (proc.stdout or "").strip()
57
+ if not raw or raw == "None":
58
+ return {} if required else None
59
+ try:
60
+ payload = json.loads(raw)
61
+ except Exception as exc:
62
+ raise SystemExit(f"AWS secret {secret_id} does not contain valid JSON.") from exc
63
+ if not isinstance(payload, dict):
64
+ raise SystemExit(f"AWS secret {secret_id} must contain a JSON object.")
65
+ return payload
66
+
67
+
68
+ def resolve_aws_sm_prefix(*, tenant: str, project: str, explicit: str | None) -> str:
69
+ if explicit and explicit.strip():
70
+ return explicit.strip().strip("/")
71
+ tenant_text = str(tenant or "").strip()
72
+ project_text = str(project or "").strip()
73
+ if not tenant_text or not project_text:
74
+ raise SystemExit("--tenant and --project are required unless --aws-sm-prefix is provided.")
75
+ return f"kdcube/{tenant_text}/{project_text}"
76
+
77
+
78
+ def _empty_bundles_secrets_payload() -> dict[str, object]:
79
+ return {
80
+ "bundles": {
81
+ "version": "1",
82
+ "items": [],
83
+ }
84
+ }
85
+
86
+
87
+ def _format_bytes(size: int) -> str:
88
+ units = ["B", "KB", "MB", "GB"]
89
+ value = float(max(size, 0))
90
+ unit = units[0]
91
+ for unit in units:
92
+ if value < 1024 or unit == units[-1]:
93
+ break
94
+ value /= 1024
95
+ if unit == "B":
96
+ return f"{int(value)} {unit}"
97
+ return f"{value:.1f} {unit}"
98
+
99
+
100
+ def _get_nested(data: object, *parts: str) -> object:
101
+ cur = data
102
+ for part in parts:
103
+ if not isinstance(cur, dict):
104
+ return None
105
+ cur = cur.get(part)
106
+ return cur
107
+
108
+
109
+ def _strip_env_value(value: object) -> str:
110
+ if value is None:
111
+ return ""
112
+ text = str(value).strip()
113
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
114
+ text = text[1:-1]
115
+ return text.strip()
116
+
117
+
118
+ def _load_env_values(path: Path) -> dict[str, str]:
119
+ values: dict[str, str] = {}
120
+ if not path.exists():
121
+ return values
122
+ for line in path.read_text().splitlines():
123
+ stripped = line.strip()
124
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
125
+ continue
126
+ key, value = stripped.split("=", 1)
127
+ key = key.strip()
128
+ if key:
129
+ values[key] = _strip_env_value(value)
130
+ return values
131
+
132
+
133
+ def _descriptor_item_refs(data: object) -> list[tuple[str, dict[str, object]]]:
134
+ if not isinstance(data, dict):
135
+ return []
136
+ raw_bundles = data.get("bundles")
137
+ if isinstance(raw_bundles, dict):
138
+ raw_items = raw_bundles.get("items")
139
+ if isinstance(raw_items, list):
140
+ return [
141
+ (str(item.get("id") or "").strip(), item)
142
+ for item in raw_items
143
+ if isinstance(item, dict)
144
+ ]
145
+ items: list[tuple[str, dict[str, object]]] = []
146
+ for key, value in raw_bundles.items():
147
+ if key in {"items", "version", "default_bundle_id"} or not isinstance(value, dict):
148
+ continue
149
+ items.append((str(value.get("id") or key).strip(), value))
150
+ return items
151
+ if isinstance(raw_bundles, list):
152
+ return [
153
+ (str(item.get("id") or "").strip(), item)
154
+ for item in raw_bundles
155
+ if isinstance(item, dict)
156
+ ]
157
+ return []
158
+
159
+
160
+ def _norm_container_path(value: object) -> str:
161
+ text = str(value or "").strip().replace("\\", "/")
162
+ if not text:
163
+ return ""
164
+ normalized = posixpath.normpath(text)
165
+ if not normalized.startswith("/"):
166
+ normalized = "/" + normalized
167
+ return normalized
168
+
169
+
170
+ def _container_path_to_host_path(
171
+ raw_path: object,
172
+ *,
173
+ container_root: str,
174
+ host_root: str,
175
+ ) -> str | None:
176
+ path = _norm_container_path(raw_path)
177
+ root = _norm_container_path(container_root)
178
+ host = str(host_root or "").strip()
179
+ if not path or not root or not host:
180
+ return None
181
+ if path == root:
182
+ return str(Path(host).expanduser())
183
+ if not path.startswith(root.rstrip("/") + "/"):
184
+ return None
185
+ rel = path[len(root.rstrip("/")) :].lstrip("/")
186
+ return str(Path(host).expanduser().joinpath(*rel.split("/")))
187
+
188
+
189
+ def _is_platform_managed_example_path(
190
+ raw_path: object,
191
+ *,
192
+ bundle_id: str,
193
+ container_managed_root: str,
194
+ host_managed_root: str,
195
+ ) -> bool:
196
+ path = _norm_container_path(raw_path)
197
+ root = _norm_container_path(container_managed_root)
198
+ if not path or not root:
199
+ return False
200
+ if path != root and not path.startswith(root.rstrip("/") + "/"):
201
+ return False
202
+
203
+ host_path = _container_path_to_host_path(
204
+ raw_path,
205
+ container_root=container_managed_root,
206
+ host_root=host_managed_root,
207
+ )
208
+ if host_path:
209
+ metadata_path = Path(host_path).expanduser() / _MANAGED_BUNDLE_METADATA_FILE
210
+ try:
211
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
212
+ if str(metadata.get("kind") or "") == "shared_example_bundle":
213
+ return True
214
+ except Exception:
215
+ pass
216
+
217
+ leaf = posixpath.basename(path.rstrip("/"))
218
+ bid = str(bundle_id or "").strip()
219
+ return bool(bid and (leaf == bid or leaf.startswith(f"{bid}__")))
220
+
221
+
222
+ def _strip_platform_managed_source_fields(item: dict) -> list[str]:
223
+ removed: list[str] = []
224
+ for key in ("path", "repo", "ref", "subdir", "git_commit", "module", "name", "description", "singleton"):
225
+ if key in item:
226
+ item.pop(key, None)
227
+ removed.append(key)
228
+ return removed
229
+
230
+
231
+ def _normalize_exported_bundle_paths(
232
+ data: object,
233
+ *,
234
+ config_dir: Path | None,
235
+ ) -> list[dict[str, str]]:
236
+ """Convert runtime-local descriptor paths back to seed-descriptor paths.
237
+
238
+ Runtime descriptors consumed by Docker use container-visible paths such as
239
+ /bundles/... or /managed-bundles/.... Exported seed descriptors should use
240
+ host paths for unmanaged local-path bundles. Platform-managed example bundles
241
+ are exported without materialized paths; the runtime resolves those built-ins
242
+ from the bundle id. Git-backed descriptors should keep their repo/ref/subdir
243
+ fields and must not export an incidental materialized path.
244
+ """
245
+ if not isinstance(data, dict):
246
+ return []
247
+
248
+ assembly_path = config_dir / "assembly.yaml" if config_dir is not None else None
249
+ assembly = yaml.safe_load(assembly_path.read_text()) if assembly_path is not None and assembly_path.exists() else {}
250
+ env = _load_env_values(config_dir / ".env") if config_dir is not None else {}
251
+
252
+ host_bundles = str(_get_nested(assembly, "paths", "host_bundles_path") or env.get("HOST_BUNDLES_PATH") or "").strip()
253
+ container_bundles = str(
254
+ _get_nested(assembly, "platform", "services", "proc", "bundles", "bundles_root")
255
+ or env.get("BUNDLES_ROOT")
256
+ or "/bundles"
257
+ ).strip()
258
+ host_managed = str(
259
+ _get_nested(assembly, "paths", "host_managed_bundles_path")
260
+ or env.get("HOST_MANAGED_BUNDLES_PATH")
261
+ or ""
262
+ ).strip()
263
+ container_managed = str(
264
+ _get_nested(assembly, "platform", "services", "proc", "bundles", "managed_bundles_root")
265
+ or env.get("MANAGED_BUNDLES_ROOT")
266
+ or "/managed-bundles"
267
+ ).strip()
268
+
269
+ translations: list[dict[str, str]] = []
270
+ for bundle_id, item in _descriptor_item_refs(data):
271
+ if item.get("repo"):
272
+ if "path" in item:
273
+ old = str(item.pop("path") or "")
274
+ translations.append(
275
+ {
276
+ "bundle_id": bundle_id,
277
+ "action": "removed_git_path",
278
+ "runtime_path": old,
279
+ }
280
+ )
281
+ continue
282
+
283
+ raw_path = str(item.get("path") or "").strip()
284
+ if not raw_path:
285
+ continue
286
+ if _is_platform_managed_example_path(
287
+ raw_path,
288
+ bundle_id=bundle_id,
289
+ container_managed_root=container_managed,
290
+ host_managed_root=host_managed,
291
+ ):
292
+ removed = _strip_platform_managed_source_fields(item)
293
+ translations.append(
294
+ {
295
+ "bundle_id": bundle_id,
296
+ "action": "removed_platform_managed_path",
297
+ "runtime_path": raw_path,
298
+ "removed_fields": ",".join(removed),
299
+ }
300
+ )
301
+ continue
302
+ if _container_path_to_host_path(
303
+ raw_path,
304
+ container_root=container_managed,
305
+ host_root=host_managed,
306
+ ) is not None:
307
+ translations.append(
308
+ {
309
+ "bundle_id": bundle_id,
310
+ "action": "kept_managed_path",
311
+ "runtime_path": raw_path,
312
+ }
313
+ )
314
+ continue
315
+ host_path = _container_path_to_host_path(
316
+ raw_path,
317
+ container_root=container_bundles,
318
+ host_root=host_bundles,
319
+ )
320
+ source = "bundles"
321
+ if host_path is None:
322
+ continue
323
+ item["path"] = host_path
324
+ translations.append(
325
+ {
326
+ "bundle_id": bundle_id,
327
+ "action": "translated_path",
328
+ "source": source,
329
+ "runtime_path": raw_path,
330
+ "host_path": host_path,
331
+ }
332
+ )
333
+ return translations
334
+
335
+
336
+ def _export_live_bundle_descriptors_from_files(
337
+ console: Console,
338
+ *,
339
+ bundles_path: Path,
340
+ bundles_secrets_path: Path | None,
341
+ out_dir: Path,
342
+ ) -> None:
343
+ if not bundles_path.exists():
344
+ raise SystemExit(f"Local bundles descriptor not found: {bundles_path}")
345
+
346
+ out_dir.mkdir(parents=True, exist_ok=True)
347
+ out_bundles_path = out_dir / "bundles.yaml"
348
+ bundles_data = yaml.safe_load(bundles_path.read_text()) or {}
349
+ translations = _normalize_exported_bundle_paths(
350
+ bundles_data,
351
+ config_dir=bundles_path.parent,
352
+ )
353
+ out_bundles_path.write_text(
354
+ yaml.safe_dump(bundles_data, sort_keys=False, allow_unicode=True)
355
+ )
356
+
357
+ out_bundles_secrets_path = out_dir / "bundles.secrets.yaml"
358
+ if bundles_secrets_path is not None and bundles_secrets_path.exists():
359
+ out_bundles_secrets_path.write_text(bundles_secrets_path.read_text())
360
+ else:
361
+ out_bundles_secrets_path.write_text(
362
+ yaml.safe_dump(_empty_bundles_secrets_payload(), sort_keys=False, allow_unicode=True)
363
+ )
364
+
365
+ console.print("[green]Exported effective live bundle descriptors.[/green]")
366
+ console.print(f"[dim]Authority:[/dim] mounted local descriptors")
367
+ console.print(f"[dim]source bundles.yaml:[/dim] {bundles_path}")
368
+ if bundles_secrets_path is not None and bundles_secrets_path.exists():
369
+ console.print(f"[dim]source bundles.secrets.yaml:[/dim] {bundles_secrets_path}")
370
+ else:
371
+ console.print("[dim]source bundles.secrets.yaml:[/dim] <not configured>")
372
+ if translations:
373
+ console.print("[dim]path normalization:[/dim]")
374
+ for item in translations:
375
+ action = item.get("action")
376
+ bundle_id = item.get("bundle_id") or "<unknown>"
377
+ if action == "removed_git_path":
378
+ console.print(f"[dim] {bundle_id}: removed git materialized path[/dim]")
379
+ elif action == "removed_platform_managed_path":
380
+ console.print(
381
+ f"[dim] {bundle_id}: removed platform-managed materialized path "
382
+ f"{item.get('runtime_path')}[/dim]"
383
+ )
384
+ elif action == "kept_managed_path":
385
+ console.print(f"[dim] {bundle_id}: kept managed cache path {item.get('runtime_path')}[/dim]")
386
+ elif action == "translated_path":
387
+ console.print(
388
+ f"[dim] {bundle_id}: {item.get('runtime_path')} -> {item.get('host_path')}[/dim]"
389
+ )
390
+ console.print(
391
+ f"[green]created bundles.yaml:[/green] {out_bundles_path} "
392
+ f"({_format_bytes(out_bundles_path.stat().st_size)})"
393
+ )
394
+ console.print(
395
+ f"[green]created bundles.secrets.yaml:[/green] {out_bundles_secrets_path} "
396
+ f"({_format_bytes(out_bundles_secrets_path.stat().st_size)})"
397
+ )
398
+
399
+
400
+ def export_live_bundle_descriptors(
401
+ console: Console,
402
+ *,
403
+ tenant: str,
404
+ project: str,
405
+ out_dir: Path,
406
+ aws_region: str | None,
407
+ aws_profile: str | None,
408
+ aws_sm_prefix: str | None,
409
+ bundles_path: Path | None = None,
410
+ bundles_secrets_path: Path | None = None,
411
+ ) -> None:
412
+ if bundles_path is not None:
413
+ _export_live_bundle_descriptors_from_files(
414
+ console,
415
+ bundles_path=bundles_path,
416
+ bundles_secrets_path=bundles_secrets_path,
417
+ out_dir=out_dir,
418
+ )
419
+ return
420
+
421
+ prefix = resolve_aws_sm_prefix(tenant=tenant, project=project, explicit=aws_sm_prefix)
422
+ meta_secret_id = f"{prefix}/bundles-meta"
423
+ meta = aws_secret_json(
424
+ secret_id=meta_secret_id,
425
+ region=aws_region,
426
+ profile=aws_profile,
427
+ required=True,
428
+ )
429
+ bundle_ids = [
430
+ str(item).strip()
431
+ for item in (meta.get("bundle_ids") or [])
432
+ if str(item).strip()
433
+ ]
434
+ if not bundle_ids:
435
+ raise SystemExit(
436
+ f"{meta_secret_id} does not contain bundle_ids. "
437
+ "Bootstrap the authoritative bundle descriptor store first by applying a live bundle update or reload-authority."
438
+ )
439
+
440
+ default_bundle_id = str(meta.get("default_bundle_id") or "").strip() or None
441
+ bundles_items: list[dict[str, object]] = []
442
+ bundles_secrets_items: list[dict[str, object]] = []
443
+
444
+ for bundle_id in bundle_ids:
445
+ descriptor = aws_secret_json(
446
+ secret_id=f"{prefix}/bundles/{bundle_id}/descriptor",
447
+ region=aws_region,
448
+ profile=aws_profile,
449
+ required=True,
450
+ ) or {}
451
+ secrets = aws_secret_json(
452
+ secret_id=f"{prefix}/bundles/{bundle_id}/secrets",
453
+ region=aws_region,
454
+ profile=aws_profile,
455
+ required=False,
456
+ ) or {}
457
+ descriptor_payload = {"id": bundle_id, **descriptor}
458
+ props = descriptor_payload.pop("props", None)
459
+ if isinstance(props, dict) and props:
460
+ descriptor_payload["config"] = props
461
+ bundles_items.append(descriptor_payload)
462
+ bundles_secrets_items.append({"id": bundle_id, "secrets": secrets})
463
+
464
+ bundles_payload: dict[str, object] = {"bundles": {"version": "1", "items": bundles_items}}
465
+ if default_bundle_id:
466
+ bundles_payload["bundles"]["default_bundle_id"] = default_bundle_id
467
+ translations = _normalize_exported_bundle_paths(
468
+ bundles_payload,
469
+ config_dir=None,
470
+ )
471
+
472
+ bundles_secrets_payload: dict[str, object] = {
473
+ "bundles": {"version": "1", "items": bundles_secrets_items}
474
+ }
475
+
476
+ out_dir.mkdir(parents=True, exist_ok=True)
477
+ bundles_path = out_dir / "bundles.yaml"
478
+ bundles_secrets_path = out_dir / "bundles.secrets.yaml"
479
+ bundles_path.write_text(yaml.safe_dump(bundles_payload, sort_keys=False, allow_unicode=True))
480
+ bundles_secrets_path.write_text(
481
+ yaml.safe_dump(bundles_secrets_payload, sort_keys=False, allow_unicode=True)
482
+ )
483
+
484
+ console.print("[green]Exported effective live bundle descriptors.[/green]")
485
+ console.print(f"[dim]AWS SM prefix:[/dim] {prefix}")
486
+ if translations:
487
+ console.print("[dim]path normalization:[/dim]")
488
+ for item in translations:
489
+ action = item.get("action")
490
+ bundle_id = item.get("bundle_id") or "<unknown>"
491
+ if action == "removed_git_path":
492
+ console.print(f"[dim] {bundle_id}: removed git materialized path[/dim]")
493
+ console.print(
494
+ f"[green]created bundles.yaml:[/green] {bundles_path} "
495
+ f"({_format_bytes(bundles_path.stat().st_size)})"
496
+ )
497
+ console.print(
498
+ f"[green]created bundles.secrets.yaml:[/green] {bundles_secrets_path} "
499
+ f"({_format_bytes(bundles_secrets_path.stat().st_size)})"
500
+ )