@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.
- package/assets/components.json +3 -3
- package/assets/core/distribution.json +5 -0
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +48 -13
- package/assets/generated/core/core/instructions/lean-docs.instructions.md +33 -2
- package/assets/generated/core/core/instructions/lean-workflow.instructions.md +42 -0
- package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +108 -71
- package/assets/generated/core/core/process/LEAN_ENGINEERING_GUIDE.md +317 -0
- package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +324 -0
- package/assets/generated/core/core/skills/deploy/SKILL.md +164 -121
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +223 -250
- package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
- package/assets/generated/core/core/skills/next-step/SKILL.md +15 -10
- package/assets/generated/core/core/skills/tear-down/SKILL.md +101 -61
- package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
- package/assets/generated/core/tools/docs_site/docs_site.py +382 -0
- package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +5 -1
- package/assets/generated/deployment/src/lean_poc_deployment/local_cli.py +112 -0
- package/assets/generated/deployment/src/lean_poc_deployment/local_runtime.py +523 -0
- package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +49 -35
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +31 -12
- 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
|
+
]
|