@mriekki/lean 0.4.4 → 0.4.5

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.
Files changed (22) hide show
  1. package/assets/components.json +3 -3
  2. package/assets/core/distribution.json +5 -0
  3. package/assets/deployment/distribution.json +2 -0
  4. package/assets/generated/ASSEMBLY.json +48 -13
  5. package/assets/generated/core/core/instructions/lean-docs.instructions.md +33 -2
  6. package/assets/generated/core/core/instructions/lean-workflow.instructions.md +42 -0
  7. package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +108 -71
  8. package/assets/generated/core/core/process/LEAN_ENGINEERING_GUIDE.md +317 -0
  9. package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +324 -0
  10. package/assets/generated/core/core/skills/deploy/SKILL.md +164 -121
  11. package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +223 -250
  12. package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
  13. package/assets/generated/core/core/skills/next-step/SKILL.md +15 -10
  14. package/assets/generated/core/core/skills/tear-down/SKILL.md +101 -61
  15. package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
  16. package/assets/generated/core/tools/docs_site/docs_site.py +382 -0
  17. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +5 -1
  18. package/assets/generated/deployment/src/lean_poc_deployment/local_cli.py +112 -0
  19. package/assets/generated/deployment/src/lean_poc_deployment/local_runtime.py +523 -0
  20. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +49 -35
  21. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +31 -12
  22. package/package.json +1 -1
@@ -0,0 +1,523 @@
1
+ """Loopback-only immutable application deployment on the developer workstation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Any, Iterable
14
+
15
+ from .app_runtime import PRODUCTION_APP_DEPLOY_WRITE_PHRASE, normalize_app_spec
16
+ from .errors import DeploymentKitError
17
+ from .profiles import config_root
18
+
19
+ LOCAL_RUNTIME_SCHEMA_VERSION = 1
20
+ LOCAL_APP_DEPLOY_WRITE_PHRASE = "[LOCAL APP DEPLOY]"
21
+ LOOPBACK_ADDRESS = "127.0.0.1"
22
+
23
+ _LABEL_PREFIX = "com.mriekki.lean-poc"
24
+ _LABEL_OWNER = f"{_LABEL_PREFIX}.owner-repo"
25
+ _LABEL_APP = f"{_LABEL_PREFIX}.app-slug"
26
+ _LABEL_ENV = f"{_LABEL_PREFIX}.environment"
27
+ _LABEL_SPEC = f"{_LABEL_PREFIX}.spec-sha256"
28
+ _LABEL_PLACEMENT = f"{_LABEL_PREFIX}.placement"
29
+
30
+
31
+ def _docker() -> str:
32
+ executable = shutil.which("docker")
33
+ if not executable:
34
+ raise DeploymentKitError(
35
+ "Docker is unavailable. Workstation-container deployment requires a working Docker CLI/engine."
36
+ )
37
+ completed = subprocess.run(
38
+ [executable, "info", "--format", "{{.ServerVersion}}"],
39
+ text=True,
40
+ encoding="utf-8",
41
+ errors="replace",
42
+ capture_output=True,
43
+ check=False,
44
+ )
45
+ if completed.returncode != 0:
46
+ detail = (completed.stderr or completed.stdout).strip()
47
+ raise DeploymentKitError("Docker engine is not ready" + (f": {detail}" if detail else "."))
48
+ return executable
49
+
50
+
51
+ def _run(docker: str, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
52
+ completed = subprocess.run(
53
+ [docker, *args],
54
+ text=True,
55
+ encoding="utf-8",
56
+ errors="replace",
57
+ capture_output=True,
58
+ check=False,
59
+ )
60
+ if check and completed.returncode != 0:
61
+ detail = (completed.stderr or completed.stdout).strip() or f"exit {completed.returncode}"
62
+ raise DeploymentKitError(f"Local Docker command failed: {detail}")
63
+ return completed
64
+
65
+
66
+ def _host_port(value: int) -> int:
67
+ if type(value) is not int or not 1 <= value <= 65535:
68
+ raise DeploymentKitError("hostPort must be an integer from 1 through 65535.")
69
+ return value
70
+
71
+
72
+ def _state_root(app_slug: str, environment: str) -> Path:
73
+ return config_root() / "local-apps" / app_slug / environment
74
+
75
+
76
+ def _state_file(app_slug: str, environment: str) -> Path:
77
+ return _state_root(app_slug, environment) / "deployment.json"
78
+
79
+
80
+ def _evidence_file(app_slug: str, environment: str) -> Path:
81
+ return _state_root(app_slug, environment) / "deployment-evidence.jsonl"
82
+
83
+
84
+ def _data_dir(app_slug: str, environment: str) -> Path:
85
+ return _state_root(app_slug, environment) / "data"
86
+
87
+
88
+ def _safe_state_write(path: Path, value: dict[str, Any]) -> None:
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ temporary = path.with_suffix(".tmp")
91
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
92
+ temporary.replace(path)
93
+
94
+
95
+ def _append_evidence(app_slug: str, environment: str, value: dict[str, Any]) -> None:
96
+ path = _evidence_file(app_slug, environment)
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ with path.open("a", encoding="utf-8") as target:
99
+ target.write(json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n")
100
+
101
+
102
+ def _load_state(app_slug: str, environment: str) -> dict[str, Any] | None:
103
+ path = _state_file(app_slug, environment)
104
+ if not path.is_file():
105
+ return None
106
+ try:
107
+ value = json.loads(path.read_text(encoding="utf-8"))
108
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
109
+ raise DeploymentKitError(f"Cannot read local deployment state {path}: {error}") from error
110
+ if not isinstance(value, dict) or value.get("schemaVersion") != LOCAL_RUNTIME_SCHEMA_VERSION:
111
+ raise DeploymentKitError(f"Unsupported local deployment state: {path}")
112
+ return value
113
+
114
+
115
+ def normalize_local_spec(
116
+ *,
117
+ owner_repo: str,
118
+ environment: str,
119
+ app_slug: str,
120
+ container_name: str,
121
+ image: str,
122
+ runtime_port: int,
123
+ host_port: int,
124
+ health_path: str,
125
+ run_as: str,
126
+ persistence_mode: str,
127
+ data_container_path: str | None,
128
+ environment_variables: dict[str, str] | Iterable[tuple[str, str]],
129
+ ) -> dict[str, Any]:
130
+ base = normalize_app_spec(
131
+ owner_repo=owner_repo,
132
+ environment=environment,
133
+ app_slug=app_slug,
134
+ container_name=container_name,
135
+ image=image,
136
+ runtime_port=runtime_port,
137
+ health_path=health_path,
138
+ run_as=run_as,
139
+ persistence_mode=persistence_mode,
140
+ data_container_path=data_container_path,
141
+ environment_variables=environment_variables,
142
+ )
143
+ base.pop("specSha256", None)
144
+ base.update(
145
+ {
146
+ "schemaVersion": LOCAL_RUNTIME_SCHEMA_VERSION,
147
+ "placement": "workstation",
148
+ "isolation": "container",
149
+ "exposure": "loopback",
150
+ "hostAddress": LOOPBACK_ADDRESS,
151
+ "hostPort": _host_port(host_port),
152
+ }
153
+ )
154
+ base["specSha256"] = hashlib.sha256(
155
+ json.dumps(base, sort_keys=True, separators=(",", ":")).encode("utf-8")
156
+ ).hexdigest()
157
+ return base
158
+
159
+
160
+ def _inspect_owned_container(docker: str, container_name: str) -> dict[str, Any] | None:
161
+ inspected = _run(
162
+ docker,
163
+ [
164
+ "inspect",
165
+ "--format",
166
+ "{{json .Config.Labels}}\t{{json .NetworkSettings.Ports}}\t{{.State.Running}}",
167
+ container_name,
168
+ ],
169
+ check=False,
170
+ )
171
+ if inspected.returncode != 0:
172
+ detail = (inspected.stderr or "").lower()
173
+ if "no such" in detail or "not found" in detail:
174
+ return None
175
+ raise DeploymentKitError(
176
+ f"Unable to inspect local container {container_name}: {(inspected.stderr or inspected.stdout).strip()}"
177
+ )
178
+ parts = inspected.stdout.strip().split("\t")
179
+ if len(parts) != 3:
180
+ raise DeploymentKitError("Local Docker inspect returned malformed container evidence.")
181
+ try:
182
+ labels = json.loads(parts[0]) if parts[0] else {}
183
+ ports = json.loads(parts[1]) if parts[1] else {}
184
+ except json.JSONDecodeError as error:
185
+ raise DeploymentKitError("Local Docker inspect returned malformed JSON evidence.") from error
186
+ if not isinstance(labels, dict):
187
+ labels = {}
188
+ return {
189
+ "labels": labels,
190
+ "ports": ports if isinstance(ports, dict) else {},
191
+ "running": parts[2].strip().lower() == "true",
192
+ }
193
+
194
+
195
+ def _assert_owned_or_absent(
196
+ docker: str,
197
+ *,
198
+ container_name: str,
199
+ owner_repo: str,
200
+ app_slug: str,
201
+ environment: str,
202
+ ) -> dict[str, Any] | None:
203
+ existing = _inspect_owned_container(docker, container_name)
204
+ if existing is None:
205
+ return None
206
+ labels = existing["labels"]
207
+ identity = (labels.get(_LABEL_OWNER), labels.get(_LABEL_APP), labels.get(_LABEL_ENV))
208
+ expected = (owner_repo, app_slug, environment)
209
+ if identity != expected:
210
+ raise DeploymentKitError(
211
+ f"Container name '{container_name}' already exists but is not owned by "
212
+ f"{owner_repo} / {app_slug} / {environment}; refusing replacement."
213
+ )
214
+ return existing
215
+
216
+
217
+ def _managed_containers(docker: str) -> list[dict[str, Any]]:
218
+ listed = _run(
219
+ docker,
220
+ ["ps", "-a", "--filter", f"label={_LABEL_PLACEMENT}=workstation", "--format", "{{.Names}}"],
221
+ )
222
+ result: list[dict[str, Any]] = []
223
+ for name in sorted(line.strip() for line in listed.stdout.splitlines() if line.strip()):
224
+ info = _inspect_owned_container(docker, name)
225
+ if info is None:
226
+ continue
227
+ labels = info["labels"]
228
+ if not all(labels.get(key) for key in (_LABEL_OWNER, _LABEL_APP, _LABEL_ENV, _LABEL_SPEC)):
229
+ continue
230
+ result.append(
231
+ {
232
+ "containerName": name,
233
+ "ownerRepo": labels[_LABEL_OWNER],
234
+ "appSlug": labels[_LABEL_APP],
235
+ "environment": labels[_LABEL_ENV],
236
+ "specSha256": labels[_LABEL_SPEC],
237
+ "running": info["running"],
238
+ }
239
+ )
240
+ return result
241
+
242
+
243
+ def _http_healthy(spec: dict[str, Any], *, timeout: float = 2.0) -> bool:
244
+ url = f"http://{LOOPBACK_ADDRESS}:{spec['hostPort']}{spec['healthPath']}"
245
+ try:
246
+ with urllib.request.urlopen(url, timeout=timeout) as response:
247
+ return 200 <= response.status < 400
248
+ except (OSError, urllib.error.URLError):
249
+ return False
250
+
251
+
252
+ def local_inventory() -> dict[str, Any]:
253
+ docker = _docker()
254
+ applications: list[dict[str, Any]] = []
255
+ for container in _managed_containers(docker):
256
+ state = _load_state(container["appSlug"], container["environment"])
257
+ item = dict(container)
258
+ item.update(
259
+ {
260
+ "placement": "workstation",
261
+ "isolation": "container",
262
+ "exposure": "loopback",
263
+ "hostAddress": LOOPBACK_ADDRESS,
264
+ "hostPort": state.get("hostPort") if state else None,
265
+ "image": state.get("image") if state else None,
266
+ "runtimePort": state.get("runtimePort") if state else None,
267
+ "healthPath": state.get("healthPath") if state else None,
268
+ "httpHealthy": bool(state and container["running"] and _http_healthy(state)),
269
+ }
270
+ )
271
+ applications.append(item)
272
+ return {
273
+ "schemaVersion": LOCAL_RUNTIME_SCHEMA_VERSION,
274
+ "placement": "workstation",
275
+ "isolation": "container",
276
+ "exposure": "loopback",
277
+ "applications": applications,
278
+ "mutated": False,
279
+ }
280
+
281
+
282
+ def _inventory_snapshot(inventory: dict[str, Any]) -> str:
283
+ value = {
284
+ "applications": [
285
+ {
286
+ key: item.get(key)
287
+ for key in (
288
+ "containerName",
289
+ "ownerRepo",
290
+ "appSlug",
291
+ "environment",
292
+ "specSha256",
293
+ "running",
294
+ "hostPort",
295
+ )
296
+ }
297
+ for item in inventory["applications"]
298
+ ]
299
+ }
300
+ return hashlib.sha256(
301
+ json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
302
+ ).hexdigest()
303
+
304
+
305
+ def _assert_port_available(
306
+ inventory: dict[str, Any], *, host_port: int, app_slug: str, environment: str
307
+ ) -> None:
308
+ for item in inventory["applications"]:
309
+ if item.get("hostPort") == host_port and (item["appSlug"], item["environment"]) != (
310
+ app_slug,
311
+ environment,
312
+ ):
313
+ raise DeploymentKitError(
314
+ f"Loopback host port {host_port} is already owned by "
315
+ f"{item['appSlug']} / {item['environment']}."
316
+ )
317
+
318
+
319
+ def plan_local_deploy(spec: dict[str, Any]) -> dict[str, Any]:
320
+ docker = _docker()
321
+ _assert_owned_or_absent(
322
+ docker,
323
+ container_name=spec["containerName"],
324
+ owner_repo=spec["ownerRepo"],
325
+ app_slug=spec["appSlug"],
326
+ environment=spec["environment"],
327
+ )
328
+ inventory = local_inventory()
329
+ _assert_port_available(
330
+ inventory,
331
+ host_port=spec["hostPort"],
332
+ app_slug=spec["appSlug"],
333
+ environment=spec["environment"],
334
+ )
335
+ existing = next(
336
+ (
337
+ item
338
+ for item in inventory["applications"]
339
+ if item["appSlug"] == spec["appSlug"] and item["environment"] == spec["environment"]
340
+ ),
341
+ None,
342
+ )
343
+ if existing and existing["specSha256"] == spec["specSha256"] and existing["running"] and existing["httpHealthy"]:
344
+ raise DeploymentKitError("The exact local application runtime is already deployed and healthy.")
345
+ return {
346
+ "action": "deploy",
347
+ "placement": "workstation",
348
+ "isolation": "container",
349
+ "exposure": "loopback",
350
+ "publicExposure": False,
351
+ "hostAddress": LOOPBACK_ADDRESS,
352
+ "hostPort": spec["hostPort"],
353
+ "environment": spec["environment"],
354
+ "appSlug": spec["appSlug"],
355
+ "ownerRepo": spec["ownerRepo"],
356
+ "containerName": spec["containerName"],
357
+ "image": spec["image"],
358
+ "runtimePort": spec["runtimePort"],
359
+ "healthPath": spec["healthPath"],
360
+ "persistenceMode": spec["persistence"]["mode"],
361
+ "changeType": "update" if existing else "create",
362
+ "previousImage": existing.get("image") if existing else None,
363
+ "inventorySnapshot": _inventory_snapshot(inventory),
364
+ "spec": spec,
365
+ }
366
+
367
+
368
+ def local_deploy_write_phrase(plan: dict[str, Any]) -> str:
369
+ return PRODUCTION_APP_DEPLOY_WRITE_PHRASE if plan["environment"] == "production" else LOCAL_APP_DEPLOY_WRITE_PHRASE
370
+
371
+
372
+ def _container_args(spec: dict[str, Any]) -> list[str]:
373
+ args = [
374
+ "run",
375
+ "-d",
376
+ "--name",
377
+ spec["containerName"],
378
+ "--restart",
379
+ "unless-stopped",
380
+ "--user",
381
+ spec["runAs"],
382
+ "-p",
383
+ f"{LOOPBACK_ADDRESS}:{spec['hostPort']}:{spec['runtimePort']}",
384
+ "--label",
385
+ f"{_LABEL_OWNER}={spec['ownerRepo']}",
386
+ "--label",
387
+ f"{_LABEL_APP}={spec['appSlug']}",
388
+ "--label",
389
+ f"{_LABEL_ENV}={spec['environment']}",
390
+ "--label",
391
+ f"{_LABEL_SPEC}={spec['specSha256']}",
392
+ "--label",
393
+ f"{_LABEL_PLACEMENT}=workstation",
394
+ ]
395
+ if spec["persistence"]["mode"] == "directory":
396
+ data_dir = _data_dir(spec["appSlug"], spec["environment"]).resolve()
397
+ data_dir.mkdir(parents=True, exist_ok=True)
398
+ args.extend(["-v", f"{data_dir}:{spec['persistence']['dataContainerPath']}"])
399
+ for key, value in spec["environmentVariables"].items():
400
+ args.extend(["-e", f"{key}={value}"])
401
+ args.append(spec["image"])
402
+ return args
403
+
404
+
405
+ def _wait_for_health(spec: dict[str, Any], *, timeout_seconds: float = 120.0) -> None:
406
+ deadline = time.monotonic() + timeout_seconds
407
+ while time.monotonic() < deadline:
408
+ if _http_healthy(spec):
409
+ return
410
+ time.sleep(1.0)
411
+ raise DeploymentKitError(
412
+ f"Local application failed loopback health check at "
413
+ f"http://{LOOPBACK_ADDRESS}:{spec['hostPort']}{spec['healthPath']}."
414
+ )
415
+
416
+
417
+ def _persist_state(spec: dict[str, Any]) -> None:
418
+ value = {
419
+ "schemaVersion": LOCAL_RUNTIME_SCHEMA_VERSION,
420
+ "ownerRepo": spec["ownerRepo"],
421
+ "appSlug": spec["appSlug"],
422
+ "environment": spec["environment"],
423
+ "containerName": spec["containerName"],
424
+ "image": spec["image"],
425
+ "specSha256": spec["specSha256"],
426
+ "runtimePort": spec["runtimePort"],
427
+ "hostPort": spec["hostPort"],
428
+ "hostAddress": LOOPBACK_ADDRESS,
429
+ "healthPath": spec["healthPath"],
430
+ "runAs": spec["runAs"],
431
+ "persistence": spec["persistence"],
432
+ "placement": "workstation",
433
+ "isolation": "container",
434
+ "exposure": "loopback",
435
+ "environmentVariableNames": sorted(spec["environmentVariables"]),
436
+ }
437
+ _safe_state_write(_state_file(spec["appSlug"], spec["environment"]), value)
438
+
439
+
440
+ def apply_local_deploy(plan: dict[str, Any]) -> dict[str, Any]:
441
+ required = {
442
+ "action",
443
+ "placement",
444
+ "isolation",
445
+ "exposure",
446
+ "publicExposure",
447
+ "hostAddress",
448
+ "hostPort",
449
+ "environment",
450
+ "appSlug",
451
+ "ownerRepo",
452
+ "containerName",
453
+ "image",
454
+ "runtimePort",
455
+ "healthPath",
456
+ "persistenceMode",
457
+ "changeType",
458
+ "previousImage",
459
+ "inventorySnapshot",
460
+ "spec",
461
+ }
462
+ if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
463
+ raise DeploymentKitError("Malformed local application deployment plan.")
464
+ current = local_inventory()
465
+ if _inventory_snapshot(current) != plan["inventorySnapshot"]:
466
+ raise DeploymentKitError(
467
+ "Local application inventory changed after plan review; create and authorize a fresh plan."
468
+ )
469
+ spec = plan["spec"]
470
+ docker = _docker()
471
+ existing = _assert_owned_or_absent(
472
+ docker,
473
+ container_name=spec["containerName"],
474
+ owner_repo=spec["ownerRepo"],
475
+ app_slug=spec["appSlug"],
476
+ environment=spec["environment"],
477
+ )
478
+ evidence_base = {
479
+ "schemaVersion": LOCAL_RUNTIME_SCHEMA_VERSION,
480
+ "ownerRepo": spec["ownerRepo"],
481
+ "appSlug": spec["appSlug"],
482
+ "environment": spec["environment"],
483
+ "containerName": spec["containerName"],
484
+ "image": spec["image"],
485
+ "specSha256": spec["specSha256"],
486
+ "placement": "workstation",
487
+ "exposure": "loopback",
488
+ }
489
+ try:
490
+ if existing is not None:
491
+ _run(docker, ["rm", "-f", spec["containerName"]])
492
+ _run(docker, ["pull", spec["image"]])
493
+ _run(docker, _container_args(spec))
494
+ _wait_for_health(spec)
495
+ except DeploymentKitError:
496
+ _append_evidence(spec["appSlug"], spec["environment"], {**evidence_base, "outcome": "failed"})
497
+ raise
498
+ _persist_state(spec)
499
+ _append_evidence(spec["appSlug"], spec["environment"], {**evidence_base, "outcome": "ready"})
500
+ final = local_inventory()
501
+ deployed = next(
502
+ (
503
+ item
504
+ for item in final["applications"]
505
+ if item["appSlug"] == spec["appSlug"] and item["environment"] == spec["environment"]
506
+ ),
507
+ None,
508
+ )
509
+ if not deployed or deployed["specSha256"] != spec["specSha256"] or not deployed["httpHealthy"]:
510
+ raise DeploymentKitError("Local deployment completed but readiness evidence is not healthy.")
511
+ final["mutated"] = True
512
+ return final
513
+
514
+
515
+ __all__ = [
516
+ "LOCAL_APP_DEPLOY_WRITE_PHRASE",
517
+ "LOOPBACK_ADDRESS",
518
+ "apply_local_deploy",
519
+ "local_deploy_write_phrase",
520
+ "local_inventory",
521
+ "normalize_local_spec",
522
+ "plan_local_deploy",
523
+ ]