@mriekki/lean 0.0.0-stage → 0.1.1
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/README.md +66 -2
- package/assets/components.json +33 -0
- package/assets/core/distribution.json +51 -0
- package/assets/deployment/distribution.json +34 -0
- package/assets/generated/ASSEMBLY.json +382 -0
- package/assets/generated/core/core/agents/coach.agent.md +69 -0
- package/assets/generated/core/core/agents/reviewer.agent.md +85 -0
- package/assets/generated/core/core/instructions/lean-docs.instructions.md +14 -0
- package/assets/generated/core/core/instructions/lean-workflow.instructions.md +35 -0
- package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
- package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +19 -0
- package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +82 -0
- package/assets/generated/core/core/learning/LEARNING_MAP.md +51 -0
- package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +31 -0
- package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
- package/assets/generated/core/core/learning/SQL_DRILLS.md +27 -0
- package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +21 -0
- package/assets/generated/core/core/process/COACHING_GUIDE.md +57 -0
- package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
- package/assets/generated/core/core/process/HUMAN_GATES.md +81 -0
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +62 -0
- package/assets/generated/core/core/process/PRODUCTION_ENABLEMENT.md +59 -0
- package/assets/generated/core/core/process/PR_WORKFLOW.md +53 -0
- package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
- package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
- package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
- package/assets/generated/core/core/skills/coach/SKILL.md +136 -0
- package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
- package/assets/generated/core/core/skills/core-sync/SKILL.md +220 -0
- package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
- package/assets/generated/core/core/skills/deliver/SKILL.md +315 -0
- package/assets/generated/core/core/skills/deploy/SKILL.md +193 -0
- package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
- package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
- package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
- package/assets/generated/core/core/skills/next-step/SKILL.md +166 -0
- package/assets/generated/core/core/skills/production-readiness/SKILL.md +357 -0
- package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
- package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
- package/assets/generated/core/core/skills/start-work/SKILL.md +178 -0
- package/assets/generated/core/core/skills/tear-down/SKILL.md +138 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +206 -0
- package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
- package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
- package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
- package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
- package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
- package/assets/generated/core/profiles/greenfield/lean-workspace.json +12 -0
- package/assets/generated/core/tools/package/check_baseline.py +102 -0
- package/assets/generated/core/tools/package/check_upstream.py +171 -0
- package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -0
- package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -0
- package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -0
- package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -0
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -0
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +416 -0
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -0
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -0
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -0
- package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -0
- package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -0
- package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -0
- package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -0
- package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -0
- package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -0
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -0
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -0
- package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -0
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +335 -0
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +340 -0
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +86 -0
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -0
- package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -0
- package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -0
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -0
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -0
- package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -0
- package/bin/lean.mjs +9 -0
- package/package.json +31 -4
- package/src/cli.mjs +131 -0
- package/src/engine.mjs +243 -0
- package/src/package-data.mjs +18 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""CLI for generic immutable application runtime deployment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .app_runtime import (
|
|
11
|
+
APP_DEPLOY_WRITE_PHRASE,
|
|
12
|
+
PRODUCTION_APP_DEPLOY_WRITE_PHRASE,
|
|
13
|
+
app_inventory,
|
|
14
|
+
apply_app_deploy,
|
|
15
|
+
deploy_write_phrase,
|
|
16
|
+
normalize_app_spec,
|
|
17
|
+
plan_app_deploy,
|
|
18
|
+
)
|
|
19
|
+
from .errors import DeploymentKitError
|
|
20
|
+
from .sqlite_adapter import assert_app_spec_matches_persistence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _print(value: Any, *, as_json: bool) -> None:
|
|
24
|
+
if as_json:
|
|
25
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
26
|
+
return
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
for key, item in value.items():
|
|
29
|
+
if isinstance(item, (dict, list)):
|
|
30
|
+
print(f"{key}: {json.dumps(item, sort_keys=True)}")
|
|
31
|
+
else:
|
|
32
|
+
print(f"{key}: {item}")
|
|
33
|
+
return
|
|
34
|
+
print(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_env(items: list[str]) -> list[tuple[str, str]]:
|
|
38
|
+
result: list[tuple[str, str]] = []
|
|
39
|
+
for item in items:
|
|
40
|
+
if "=" not in item:
|
|
41
|
+
raise DeploymentKitError("--env entries must use NAME=value form.")
|
|
42
|
+
key, value = item.split("=", 1)
|
|
43
|
+
result.append((key, value))
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
|
|
48
|
+
if args.yes:
|
|
49
|
+
return
|
|
50
|
+
entered = input(f"Type {phrase} to {action}: ").strip()
|
|
51
|
+
if entered != phrase:
|
|
52
|
+
raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
56
|
+
parser = argparse.ArgumentParser(prog="lean-poc-deploy app")
|
|
57
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
58
|
+
|
|
59
|
+
inventory = sub.add_parser(
|
|
60
|
+
"inventory",
|
|
61
|
+
help="Read Kit-managed application runtime ownership/readiness without writes",
|
|
62
|
+
)
|
|
63
|
+
inventory.add_argument("name")
|
|
64
|
+
inventory.add_argument("--json", action="store_true")
|
|
65
|
+
|
|
66
|
+
deploy = sub.add_parser(
|
|
67
|
+
"deploy",
|
|
68
|
+
help="Deploy one immutable application runtime without modifying routes",
|
|
69
|
+
)
|
|
70
|
+
deploy.add_argument("name")
|
|
71
|
+
deploy.add_argument("--owner-repo", required=True)
|
|
72
|
+
deploy.add_argument("--environment", required=True)
|
|
73
|
+
deploy.add_argument("--app-slug", required=True)
|
|
74
|
+
deploy.add_argument("--container-name", required=True)
|
|
75
|
+
deploy.add_argument("--image", required=True)
|
|
76
|
+
deploy.add_argument("--runtime-port", type=int, required=True)
|
|
77
|
+
deploy.add_argument("--health-path", required=True)
|
|
78
|
+
deploy.add_argument("--run-as", required=True)
|
|
79
|
+
deploy.add_argument(
|
|
80
|
+
"--persistence-mode",
|
|
81
|
+
choices=("none", "directory"),
|
|
82
|
+
required=True,
|
|
83
|
+
)
|
|
84
|
+
deploy.add_argument("--data-container-path")
|
|
85
|
+
deploy.add_argument(
|
|
86
|
+
"--env",
|
|
87
|
+
action="append",
|
|
88
|
+
default=[],
|
|
89
|
+
help="Non-secret runtime environment value in NAME=value form; repeat as needed",
|
|
90
|
+
)
|
|
91
|
+
deploy.add_argument("--json", action="store_true")
|
|
92
|
+
deploy.add_argument("--yes", action="store_true")
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
args = build_parser().parse_args(argv)
|
|
98
|
+
try:
|
|
99
|
+
if args.command == "inventory":
|
|
100
|
+
_print(app_inventory(args.name), as_json=args.json)
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
spec = normalize_app_spec(
|
|
104
|
+
owner_repo=args.owner_repo,
|
|
105
|
+
environment=args.environment,
|
|
106
|
+
app_slug=args.app_slug,
|
|
107
|
+
container_name=args.container_name,
|
|
108
|
+
image=args.image,
|
|
109
|
+
runtime_port=args.runtime_port,
|
|
110
|
+
health_path=args.health_path,
|
|
111
|
+
run_as=args.run_as,
|
|
112
|
+
persistence_mode=args.persistence_mode,
|
|
113
|
+
data_container_path=args.data_container_path,
|
|
114
|
+
environment_variables=_parse_env(args.env),
|
|
115
|
+
)
|
|
116
|
+
assert_app_spec_matches_persistence(args.name, spec)
|
|
117
|
+
plan = plan_app_deploy(args.name, spec)
|
|
118
|
+
_print(
|
|
119
|
+
{key: value for key, value in plan.items() if key != "spec"},
|
|
120
|
+
as_json=False,
|
|
121
|
+
)
|
|
122
|
+
phrase = deploy_write_phrase(plan)
|
|
123
|
+
_confirm(
|
|
124
|
+
args,
|
|
125
|
+
phrase,
|
|
126
|
+
f"deploy {plan['appSlug']} ({plan['environment']}) on target {args.name}",
|
|
127
|
+
)
|
|
128
|
+
_print(apply_app_deploy(plan), as_json=args.json)
|
|
129
|
+
return 0
|
|
130
|
+
except DeploymentKitError as error:
|
|
131
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = [
|
|
136
|
+
"APP_DEPLOY_WRITE_PHRASE",
|
|
137
|
+
"PRODUCTION_APP_DEPLOY_WRITE_PHRASE",
|
|
138
|
+
"build_parser",
|
|
139
|
+
"main",
|
|
140
|
+
]
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Generic immutable application-runtime deployment for Kit-managed hosts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
from .box_platform import platform_status
|
|
13
|
+
from .box_platform_assets import CADDY_CONTAINER, NETWORK_NAME
|
|
14
|
+
from .errors import DeploymentKitError
|
|
15
|
+
from .profiles import load_profile
|
|
16
|
+
from .ssh import ssh_args, verify_access
|
|
17
|
+
from .app_runtime_remote import REMOTE_SCRIPT
|
|
18
|
+
|
|
19
|
+
APP_ROOT = "/srv/lean-poc-apps"
|
|
20
|
+
APP_SCHEMA_VERSION = 1
|
|
21
|
+
APP_DEPLOY_WRITE_PHRASE = "[APP DEPLOY WRITE]"
|
|
22
|
+
PRODUCTION_APP_DEPLOY_WRITE_PHRASE = "[PRODUCTION APP DEPLOY]"
|
|
23
|
+
|
|
24
|
+
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
25
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
26
|
+
_CONTAINER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
27
|
+
_IMAGE_RE = re.compile(
|
|
28
|
+
r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$"
|
|
29
|
+
)
|
|
30
|
+
_RUN_AS_RE = re.compile(r"^[0-9]+(?::[0-9]+)?$")
|
|
31
|
+
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
32
|
+
_HEALTH_PATH_RE = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/\-]*$")
|
|
33
|
+
_CONTAINER_PATH_RE = re.compile(r"^/[A-Za-z0-9._~+:/\-]*$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _slug(value: str, field: str) -> str:
|
|
37
|
+
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
38
|
+
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _owner_repo(value: str) -> str:
|
|
43
|
+
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
44
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _container_name(value: str) -> str:
|
|
49
|
+
if not isinstance(value, str) or _CONTAINER_RE.fullmatch(value) is None:
|
|
50
|
+
raise DeploymentKitError("containerName contains unsupported characters.")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _image(value: str) -> str:
|
|
55
|
+
if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
|
|
56
|
+
raise DeploymentKitError("image must be an immutable image reference ending in @sha256:<64 lowercase hex>.")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _runtime_port(value: int) -> int:
|
|
61
|
+
if type(value) is not int or not 1 <= value <= 65535:
|
|
62
|
+
raise DeploymentKitError("runtimePort must be an integer from 1 through 65535.")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _health_path(value: str) -> str:
|
|
67
|
+
if not isinstance(value, str) or _HEALTH_PATH_RE.fullmatch(value) is None:
|
|
68
|
+
raise DeploymentKitError("healthPath must be a safe absolute HTTP path.")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _run_as(value: str) -> str:
|
|
73
|
+
if not isinstance(value, str) or _RUN_AS_RE.fullmatch(value) is None:
|
|
74
|
+
raise DeploymentKitError("runAs must be a numeric UID or UID:GID.")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _container_path(value: str | None) -> str | None:
|
|
79
|
+
if value is None:
|
|
80
|
+
return None
|
|
81
|
+
if not isinstance(value, str) or _CONTAINER_PATH_RE.fullmatch(value) is None or "/../" in f"{value}/":
|
|
82
|
+
raise DeploymentKitError("dataContainerPath must be a safe absolute container path.")
|
|
83
|
+
return value.rstrip("/") or "/"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _normalize_environment_variables(items: dict[str, str] | Iterable[tuple[str, str]]) -> dict[str, str]:
|
|
87
|
+
pairs = items.items() if isinstance(items, dict) else items
|
|
88
|
+
result: dict[str, str] = {}
|
|
89
|
+
for key, value in pairs:
|
|
90
|
+
if not isinstance(key, str) or _ENV_KEY_RE.fullmatch(key) is None:
|
|
91
|
+
raise DeploymentKitError(f"Invalid environment variable name: {key!r}")
|
|
92
|
+
if key in result:
|
|
93
|
+
raise DeploymentKitError(f"Duplicate environment variable: {key}")
|
|
94
|
+
if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
|
|
95
|
+
raise DeploymentKitError(f"Environment variable {key} contains unsupported control characters.")
|
|
96
|
+
result[key] = value
|
|
97
|
+
return dict(sorted(result.items()))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def normalize_app_spec(
|
|
101
|
+
*,
|
|
102
|
+
owner_repo: str,
|
|
103
|
+
environment: str,
|
|
104
|
+
app_slug: str,
|
|
105
|
+
container_name: str,
|
|
106
|
+
image: str,
|
|
107
|
+
runtime_port: int,
|
|
108
|
+
health_path: str,
|
|
109
|
+
run_as: str,
|
|
110
|
+
persistence_mode: str,
|
|
111
|
+
data_container_path: str | None,
|
|
112
|
+
environment_variables: dict[str, str] | Iterable[tuple[str, str]],
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
if persistence_mode not in {"none", "directory"}:
|
|
115
|
+
raise DeploymentKitError("persistenceMode must be 'none' or 'directory'.")
|
|
116
|
+
data_path = _container_path(data_container_path)
|
|
117
|
+
if persistence_mode == "directory" and data_path is None:
|
|
118
|
+
raise DeploymentKitError("directory persistence requires dataContainerPath.")
|
|
119
|
+
if persistence_mode == "none" and data_path is not None:
|
|
120
|
+
raise DeploymentKitError("dataContainerPath must be omitted when persistenceMode is 'none'.")
|
|
121
|
+
spec = {
|
|
122
|
+
"schemaVersion": APP_SCHEMA_VERSION,
|
|
123
|
+
"ownerRepo": _owner_repo(owner_repo),
|
|
124
|
+
"environment": _slug(environment, "environment"),
|
|
125
|
+
"appSlug": _slug(app_slug, "appSlug"),
|
|
126
|
+
"containerName": _container_name(container_name),
|
|
127
|
+
"image": _image(image),
|
|
128
|
+
"runtimePort": _runtime_port(runtime_port),
|
|
129
|
+
"healthPath": _health_path(health_path),
|
|
130
|
+
"runAs": _run_as(run_as),
|
|
131
|
+
"persistence": {
|
|
132
|
+
"mode": persistence_mode,
|
|
133
|
+
"dataContainerPath": data_path,
|
|
134
|
+
},
|
|
135
|
+
"environmentVariables": _normalize_environment_variables(environment_variables),
|
|
136
|
+
}
|
|
137
|
+
spec["specSha256"] = _spec_sha(spec)
|
|
138
|
+
return spec
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _spec_sha(spec: dict[str, Any]) -> str:
|
|
142
|
+
value = {key: spec[key] for key in spec if key != "specSha256"}
|
|
143
|
+
return hashlib.sha256(
|
|
144
|
+
json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
145
|
+
).hexdigest()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _b64(value: str) -> str:
|
|
149
|
+
return base64.b64encode(value.encode("utf-8")).decode("ascii")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_remote_script(spec: dict[str, Any] | None = None) -> str:
|
|
153
|
+
env_keys: list[str] = []
|
|
154
|
+
env_values: list[str] = []
|
|
155
|
+
desired = {
|
|
156
|
+
"ownerRepo": "",
|
|
157
|
+
"environment": "",
|
|
158
|
+
"appSlug": "",
|
|
159
|
+
"containerName": "",
|
|
160
|
+
"image": "",
|
|
161
|
+
"runtimePort": "",
|
|
162
|
+
"healthPath": "",
|
|
163
|
+
"runAs": "",
|
|
164
|
+
"persistenceMode": "",
|
|
165
|
+
"dataContainerPath": "",
|
|
166
|
+
"specSha256": "",
|
|
167
|
+
}
|
|
168
|
+
if spec is not None:
|
|
169
|
+
desired.update(
|
|
170
|
+
ownerRepo=spec["ownerRepo"],
|
|
171
|
+
environment=spec["environment"],
|
|
172
|
+
appSlug=spec["appSlug"],
|
|
173
|
+
containerName=spec["containerName"],
|
|
174
|
+
image=spec["image"],
|
|
175
|
+
runtimePort=str(spec["runtimePort"]),
|
|
176
|
+
healthPath=spec["healthPath"],
|
|
177
|
+
runAs=spec["runAs"],
|
|
178
|
+
persistenceMode=spec["persistence"]["mode"],
|
|
179
|
+
dataContainerPath=spec["persistence"]["dataContainerPath"] or "",
|
|
180
|
+
specSha256=spec["specSha256"],
|
|
181
|
+
)
|
|
182
|
+
for key, value in spec["environmentVariables"].items():
|
|
183
|
+
env_keys.append(_b64(key))
|
|
184
|
+
env_values.append(_b64(value))
|
|
185
|
+
|
|
186
|
+
replacements = {
|
|
187
|
+
"__APP_ROOT__": APP_ROOT,
|
|
188
|
+
"__APP_SCHEMA_VERSION__": str(APP_SCHEMA_VERSION),
|
|
189
|
+
"__NETWORK_NAME__": NETWORK_NAME,
|
|
190
|
+
"__CADDY_CONTAINER__": CADDY_CONTAINER,
|
|
191
|
+
"__DESIRED_PRESENT__": "true" if spec is not None else "false",
|
|
192
|
+
"__D_OWNER_B64__": _b64(desired["ownerRepo"]),
|
|
193
|
+
"__D_ENV_B64__": _b64(desired["environment"]),
|
|
194
|
+
"__D_APP_B64__": _b64(desired["appSlug"]),
|
|
195
|
+
"__D_CONTAINER_B64__": _b64(desired["containerName"]),
|
|
196
|
+
"__D_IMAGE_B64__": _b64(desired["image"]),
|
|
197
|
+
"__D_PORT__": desired["runtimePort"],
|
|
198
|
+
"__D_HEALTH_B64__": _b64(desired["healthPath"]),
|
|
199
|
+
"__D_RUN_AS_B64__": _b64(desired["runAs"]),
|
|
200
|
+
"__D_PERSISTENCE__": desired["persistenceMode"],
|
|
201
|
+
"__D_DATA_TARGET_B64__": _b64(desired["dataContainerPath"]),
|
|
202
|
+
"__D_SPEC_SHA__": desired["specSha256"],
|
|
203
|
+
"__D_ENV_KEYS__": " ".join(f"'{item}'" for item in env_keys),
|
|
204
|
+
"__D_ENV_VALUES__": " ".join(f"'{item}'" for item in env_values),
|
|
205
|
+
}
|
|
206
|
+
script = REMOTE_SCRIPT
|
|
207
|
+
for placeholder, value in replacements.items():
|
|
208
|
+
script = script.replace(placeholder, value)
|
|
209
|
+
if "__" in script:
|
|
210
|
+
raise DeploymentKitError("Internal application-runtime script contains an unresolved placeholder.")
|
|
211
|
+
return script
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _parse_inventory(output: str) -> dict[str, Any]:
|
|
215
|
+
apps: list[dict[str, Any]] = []
|
|
216
|
+
summary: dict[str, str] = {}
|
|
217
|
+
for raw in output.splitlines():
|
|
218
|
+
if not raw:
|
|
219
|
+
continue
|
|
220
|
+
parts = raw.split("\t")
|
|
221
|
+
if parts[0] == "SUMMARY":
|
|
222
|
+
if len(parts) != 3 or parts[1] in summary:
|
|
223
|
+
raise DeploymentKitError("Malformed or duplicate application inventory summary.")
|
|
224
|
+
summary[parts[1]] = parts[2]
|
|
225
|
+
continue
|
|
226
|
+
if parts[0] != "APP" or len(parts) != 15:
|
|
227
|
+
raise DeploymentKitError("Malformed application inventory record.")
|
|
228
|
+
try:
|
|
229
|
+
port = int(parts[7])
|
|
230
|
+
except ValueError as error:
|
|
231
|
+
raise DeploymentKitError("Malformed application inventory runtime port.") from error
|
|
232
|
+
if parts[12] not in {"true", "false"} or parts[13] not in {"true", "false"}:
|
|
233
|
+
raise DeploymentKitError("Malformed application inventory boolean value.")
|
|
234
|
+
owner = _owner_repo(parts[1])
|
|
235
|
+
environment = _slug(parts[2], "environment")
|
|
236
|
+
app_slug = _slug(parts[3], "appSlug")
|
|
237
|
+
container = _container_name(parts[4])
|
|
238
|
+
image = _image(parts[5])
|
|
239
|
+
if re.fullmatch(r"[0-9a-f]{64}", parts[6]) is None:
|
|
240
|
+
raise DeploymentKitError("Malformed application inventory spec fingerprint.")
|
|
241
|
+
health_path = _health_path(parts[8])
|
|
242
|
+
if parts[9] not in {"none", "directory"}:
|
|
243
|
+
raise DeploymentKitError("Malformed application inventory persistence mode.")
|
|
244
|
+
app = {
|
|
245
|
+
"ownerRepo": owner,
|
|
246
|
+
"environment": environment,
|
|
247
|
+
"appSlug": app_slug,
|
|
248
|
+
"containerName": container,
|
|
249
|
+
"image": image,
|
|
250
|
+
"specSha256": parts[6],
|
|
251
|
+
"runtimePort": port,
|
|
252
|
+
"healthPath": health_path,
|
|
253
|
+
"persistenceMode": parts[9],
|
|
254
|
+
"currentRelease": parts[10] or None,
|
|
255
|
+
"previousRelease": parts[11] or None,
|
|
256
|
+
"containerRunning": parts[12] == "true",
|
|
257
|
+
"connectedToSharedNetwork": parts[13] == "true",
|
|
258
|
+
"httpHealthy": parts[14] == "true",
|
|
259
|
+
}
|
|
260
|
+
apps.append(app)
|
|
261
|
+
if set(summary) != {"appCount"}:
|
|
262
|
+
raise DeploymentKitError("Application inventory omitted appCount.")
|
|
263
|
+
try:
|
|
264
|
+
count = int(summary["appCount"])
|
|
265
|
+
except ValueError as error:
|
|
266
|
+
raise DeploymentKitError("Malformed application inventory count.") from error
|
|
267
|
+
if count != len(apps):
|
|
268
|
+
raise DeploymentKitError("Application inventory count does not match records.")
|
|
269
|
+
keys = [(item["appSlug"], item["environment"]) for item in apps]
|
|
270
|
+
if len(keys) != len(set(keys)):
|
|
271
|
+
raise DeploymentKitError("Duplicate application/environment ownership records were reported.")
|
|
272
|
+
return {"applications": sorted(apps, key=lambda item: (item["appSlug"], item["environment"]))}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _run_remote(
|
|
276
|
+
profile: dict[str, Any],
|
|
277
|
+
address: str,
|
|
278
|
+
action: str,
|
|
279
|
+
*,
|
|
280
|
+
spec: dict[str, Any] | None = None,
|
|
281
|
+
expected_spec_sha: str = "",
|
|
282
|
+
) -> str:
|
|
283
|
+
if action not in {"inventory", "deploy"}:
|
|
284
|
+
raise DeploymentKitError(f"Unsupported application-runtime action: {action}")
|
|
285
|
+
args = [action]
|
|
286
|
+
if action == "deploy":
|
|
287
|
+
args.extend([expected_spec_sha or "NONE", profile["user"]])
|
|
288
|
+
command = ssh_args(
|
|
289
|
+
profile,
|
|
290
|
+
address,
|
|
291
|
+
command="sudo -n bash -s -- " + " ".join(args),
|
|
292
|
+
)
|
|
293
|
+
completed = subprocess.run(
|
|
294
|
+
command,
|
|
295
|
+
input=build_remote_script(spec).encode("utf-8"),
|
|
296
|
+
check=False,
|
|
297
|
+
capture_output=True,
|
|
298
|
+
)
|
|
299
|
+
stdout = completed.stdout.decode("utf-8", errors="replace")
|
|
300
|
+
stderr = completed.stderr.decode("utf-8", errors="replace")
|
|
301
|
+
if completed.returncode != 0:
|
|
302
|
+
detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
|
|
303
|
+
raise DeploymentKitError(f"Remote application-runtime {action} failed: {detail}")
|
|
304
|
+
return stdout
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def app_inventory(name: str) -> dict[str, Any]:
|
|
308
|
+
profile = load_profile(name)
|
|
309
|
+
evidence = verify_access(profile)
|
|
310
|
+
inventory = _parse_inventory(
|
|
311
|
+
_run_remote(profile, evidence["address"], "inventory")
|
|
312
|
+
)
|
|
313
|
+
inventory["targetName"] = name
|
|
314
|
+
inventory["verifiedAddress"] = evidence["address"]
|
|
315
|
+
inventory["mutated"] = False
|
|
316
|
+
return inventory
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _inventory_snapshot(inventory: dict[str, Any]) -> str:
|
|
320
|
+
value = {"applications": inventory["applications"]}
|
|
321
|
+
return hashlib.sha256(
|
|
322
|
+
json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
323
|
+
).hexdigest()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _find_app(inventory: dict[str, Any], app_slug: str, environment: str) -> dict[str, Any] | None:
|
|
327
|
+
return next(
|
|
328
|
+
(
|
|
329
|
+
item
|
|
330
|
+
for item in inventory["applications"]
|
|
331
|
+
if item["appSlug"] == app_slug and item["environment"] == environment
|
|
332
|
+
),
|
|
333
|
+
None,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
|
|
338
|
+
platform = platform_status(name)
|
|
339
|
+
if platform["state"] != "ready":
|
|
340
|
+
raise DeploymentKitError(
|
|
341
|
+
f"Application deployment requires a ready shared platform; target '{name}' is '{platform['state']}'."
|
|
342
|
+
)
|
|
343
|
+
inventory = app_inventory(name)
|
|
344
|
+
existing = _find_app(inventory, spec["appSlug"], spec["environment"])
|
|
345
|
+
if existing is not None and existing["ownerRepo"] != spec["ownerRepo"]:
|
|
346
|
+
raise DeploymentKitError("Application/environment ownership does not match the requested repository.")
|
|
347
|
+
for item in inventory["applications"]:
|
|
348
|
+
if (
|
|
349
|
+
item["containerName"] == spec["containerName"]
|
|
350
|
+
and (item["appSlug"], item["environment"])
|
|
351
|
+
!= (spec["appSlug"], spec["environment"])
|
|
352
|
+
):
|
|
353
|
+
raise DeploymentKitError(
|
|
354
|
+
f"Container name collision with managed app {item['appSlug']}--{item['environment']}."
|
|
355
|
+
)
|
|
356
|
+
if (
|
|
357
|
+
existing is not None
|
|
358
|
+
and existing["specSha256"] == spec["specSha256"]
|
|
359
|
+
and existing["containerRunning"]
|
|
360
|
+
and existing["connectedToSharedNetwork"]
|
|
361
|
+
and existing["httpHealthy"]
|
|
362
|
+
):
|
|
363
|
+
raise DeploymentKitError("The exact application runtime spec is already deployed and healthy.")
|
|
364
|
+
return {
|
|
365
|
+
"targetName": name,
|
|
366
|
+
"action": "deploy",
|
|
367
|
+
"environment": spec["environment"],
|
|
368
|
+
"appSlug": spec["appSlug"],
|
|
369
|
+
"ownerRepo": spec["ownerRepo"],
|
|
370
|
+
"containerName": spec["containerName"],
|
|
371
|
+
"image": spec["image"],
|
|
372
|
+
"runtimePort": spec["runtimePort"],
|
|
373
|
+
"healthPath": spec["healthPath"],
|
|
374
|
+
"persistenceMode": spec["persistence"]["mode"],
|
|
375
|
+
"changeType": "update" if existing is not None else "create",
|
|
376
|
+
"previousImage": existing["image"] if existing is not None else None,
|
|
377
|
+
"previousRelease": existing["currentRelease"] if existing is not None else None,
|
|
378
|
+
"retainsPreviousRelease": existing is not None,
|
|
379
|
+
"publishesHostPort": False,
|
|
380
|
+
"attachesSharedNetwork": NETWORK_NAME,
|
|
381
|
+
"modifiesRoutes": False,
|
|
382
|
+
"persistentDataDeletion": False,
|
|
383
|
+
"inventorySnapshot": _inventory_snapshot(inventory),
|
|
384
|
+
"expectedSpecSha256": existing["specSha256"] if existing is not None else None,
|
|
385
|
+
"spec": spec,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def deploy_write_phrase(plan: dict[str, Any]) -> str:
|
|
390
|
+
return (
|
|
391
|
+
PRODUCTION_APP_DEPLOY_WRITE_PHRASE
|
|
392
|
+
if plan["environment"] == "production"
|
|
393
|
+
else APP_DEPLOY_WRITE_PHRASE
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
|
|
398
|
+
required = {
|
|
399
|
+
"targetName",
|
|
400
|
+
"action",
|
|
401
|
+
"environment",
|
|
402
|
+
"appSlug",
|
|
403
|
+
"ownerRepo",
|
|
404
|
+
"containerName",
|
|
405
|
+
"image",
|
|
406
|
+
"runtimePort",
|
|
407
|
+
"healthPath",
|
|
408
|
+
"persistenceMode",
|
|
409
|
+
"changeType",
|
|
410
|
+
"previousImage",
|
|
411
|
+
"previousRelease",
|
|
412
|
+
"retainsPreviousRelease",
|
|
413
|
+
"publishesHostPort",
|
|
414
|
+
"attachesSharedNetwork",
|
|
415
|
+
"modifiesRoutes",
|
|
416
|
+
"persistentDataDeletion",
|
|
417
|
+
"inventorySnapshot",
|
|
418
|
+
"expectedSpecSha256",
|
|
419
|
+
"spec",
|
|
420
|
+
}
|
|
421
|
+
if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
|
|
422
|
+
raise DeploymentKitError("Malformed application deployment plan.")
|
|
423
|
+
|
|
424
|
+
current = app_inventory(plan["targetName"])
|
|
425
|
+
if _inventory_snapshot(current) != plan["inventorySnapshot"]:
|
|
426
|
+
raise DeploymentKitError(
|
|
427
|
+
"Application inventory changed after the plan was reviewed; create and authorize a fresh plan."
|
|
428
|
+
)
|
|
429
|
+
platform = platform_status(plan["targetName"])
|
|
430
|
+
if platform["state"] != "ready":
|
|
431
|
+
raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
|
|
432
|
+
|
|
433
|
+
profile = load_profile(plan["targetName"])
|
|
434
|
+
evidence = verify_access(profile)
|
|
435
|
+
_run_remote(
|
|
436
|
+
profile,
|
|
437
|
+
evidence["address"],
|
|
438
|
+
"deploy",
|
|
439
|
+
spec=plan["spec"],
|
|
440
|
+
expected_spec_sha=plan["expectedSpecSha256"] or "",
|
|
441
|
+
)
|
|
442
|
+
final = app_inventory(plan["targetName"])
|
|
443
|
+
deployed = _find_app(final, plan["appSlug"], plan["environment"])
|
|
444
|
+
if deployed is None:
|
|
445
|
+
raise DeploymentKitError("Application deployment returned without managed runtime state.")
|
|
446
|
+
if deployed["specSha256"] != plan["spec"]["specSha256"]:
|
|
447
|
+
raise DeploymentKitError("Application deployment returned with a different runtime spec.")
|
|
448
|
+
if not (
|
|
449
|
+
deployed["containerRunning"]
|
|
450
|
+
and deployed["connectedToSharedNetwork"]
|
|
451
|
+
and deployed["httpHealthy"]
|
|
452
|
+
):
|
|
453
|
+
raise DeploymentKitError("Application deployment completed but runtime readiness is not healthy.")
|
|
454
|
+
final["mutated"] = True
|
|
455
|
+
return final
|