@mriekki/project-forge 0.0.0-stage → 2.0.0

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 (117) hide show
  1. package/README.md +134 -2
  2. package/assets/components.json +33 -0
  3. package/assets/core/distribution.json +66 -0
  4. package/assets/deployment/distribution.json +54 -0
  5. package/assets/generated/ASSEMBLY.json +557 -0
  6. package/assets/generated/core/core/agents/coach.agent.md +69 -0
  7. package/assets/generated/core/core/agents/reviewer.agent.md +85 -0
  8. package/assets/generated/core/core/instructions/project-forge-docs.instructions.md +45 -0
  9. package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +79 -0
  10. package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
  11. package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +59 -0
  12. package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +108 -0
  13. package/assets/generated/core/core/learning/LEARNING_MAP.md +108 -0
  14. package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +39 -0
  15. package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
  16. package/assets/generated/core/core/learning/SQL_DRILLS.md +90 -0
  17. package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +301 -0
  18. package/assets/generated/core/core/process/COACHING_GUIDE.md +92 -0
  19. package/assets/generated/core/core/process/DATABASE_LEARNING_WORKBENCH.md +397 -0
  20. package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +166 -0
  21. package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
  22. package/assets/generated/core/core/process/HUMAN_GATES.md +83 -0
  23. package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +64 -0
  24. package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +326 -0
  25. package/assets/generated/core/core/process/PROJECT_FORGE_ENGINEERING_GUIDE.md +308 -0
  26. package/assets/generated/core/core/process/PR_WORKFLOW.md +55 -0
  27. package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
  28. package/assets/generated/core/core/process/SYSTEM_DESIGN_LEARNING_WORKBENCH.md +520 -0
  29. package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
  30. package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
  31. package/assets/generated/core/core/skills/coach/SKILL.md +174 -0
  32. package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
  33. package/assets/generated/core/core/skills/core-sync/SKILL.md +178 -0
  34. package/assets/generated/core/core/skills/db-lab/SKILL.md +245 -0
  35. package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
  36. package/assets/generated/core/core/skills/deliver/SKILL.md +397 -0
  37. package/assets/generated/core/core/skills/deploy/SKILL.md +188 -0
  38. package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +234 -0
  39. package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +231 -0
  40. package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
  41. package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
  42. package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
  43. package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
  44. package/assets/generated/core/core/skills/next-step/SKILL.md +221 -0
  45. package/assets/generated/core/core/skills/progress/SKILL.md +207 -0
  46. package/assets/generated/core/core/skills/project-forge-update/SKILL.md +228 -0
  47. package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
  48. package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
  49. package/assets/generated/core/core/skills/setup/SKILL.md +362 -0
  50. package/assets/generated/core/core/skills/start-work/SKILL.md +190 -0
  51. package/assets/generated/core/core/skills/system-lab/SKILL.md +333 -0
  52. package/assets/generated/core/core/skills/tear-down/SKILL.md +140 -0
  53. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +221 -0
  54. package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
  55. package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
  56. package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
  57. package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
  58. package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
  59. package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
  60. package/assets/generated/core/profiles/greenfield/project-forge-workspace.json +12 -0
  61. package/assets/generated/core/tools/db_lab/sqlite_workbench.py +463 -0
  62. package/assets/generated/core/tools/docs_site/docs_site.py +512 -0
  63. package/assets/generated/core/tools/package/check_baseline.py +102 -0
  64. package/assets/generated/core/tools/package/check_upstream.py +171 -0
  65. package/assets/generated/core/tools/system_lab/system_workbench.py +243 -0
  66. package/assets/generated/deployment/distribution/project-forge-deploy.py +8 -0
  67. package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +335 -0
  68. package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +95 -0
  69. package/assets/generated/deployment/docs/WORKSPACE_INSTANCES.md +154 -0
  70. package/assets/generated/deployment/docs/images/developer-environment-journey.svg +56 -0
  71. package/assets/generated/deployment/docs/images/personal-environment-architecture.svg +54 -0
  72. package/assets/generated/deployment/docs/images/poc-environment-model.svg +61 -0
  73. package/assets/generated/deployment/src/project_forge_deployment/__init__.py +3 -0
  74. package/assets/generated/deployment/src/project_forge_deployment/__main__.py +3 -0
  75. package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +163 -0
  76. package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +455 -0
  77. package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +416 -0
  78. package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +136 -0
  79. package/assets/generated/deployment/src/project_forge_deployment/box_platform.py +208 -0
  80. package/assets/generated/deployment/src/project_forge_deployment/box_platform_assets.py +100 -0
  81. package/assets/generated/deployment/src/project_forge_deployment/box_platform_remote.py +271 -0
  82. package/assets/generated/deployment/src/project_forge_deployment/cli.py +437 -0
  83. package/assets/generated/deployment/src/project_forge_deployment/doctor.py +121 -0
  84. package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +37 -0
  85. package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +271 -0
  86. package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +228 -0
  87. package/assets/generated/deployment/src/project_forge_deployment/errors.py +5 -0
  88. package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +328 -0
  89. package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +105 -0
  90. package/assets/generated/deployment/src/project_forge_deployment/keys.py +171 -0
  91. package/assets/generated/deployment/src/project_forge_deployment/local_cli.py +189 -0
  92. package/assets/generated/deployment/src/project_forge_deployment/local_runtime.py +526 -0
  93. package/assets/generated/deployment/src/project_forge_deployment/local_spec.py +82 -0
  94. package/assets/generated/deployment/src/project_forge_deployment/local_sqlite.py +205 -0
  95. package/assets/generated/deployment/src/project_forge_deployment/portable_profile.py +181 -0
  96. package/assets/generated/deployment/src/project_forge_deployment/profile_migration.py +70 -0
  97. package/assets/generated/deployment/src/project_forge_deployment/profile_repository.py +348 -0
  98. package/assets/generated/deployment/src/project_forge_deployment/profiles.py +204 -0
  99. package/assets/generated/deployment/src/project_forge_deployment/registry_access.py +114 -0
  100. package/assets/generated/deployment/src/project_forge_deployment/registry_cli.py +51 -0
  101. package/assets/generated/deployment/src/project_forge_deployment/route_registry.py +367 -0
  102. package/assets/generated/deployment/src/project_forge_deployment/route_registry_remote.py +443 -0
  103. package/assets/generated/deployment/src/project_forge_deployment/security.py +290 -0
  104. package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +335 -0
  105. package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +340 -0
  106. package/assets/generated/deployment/src/project_forge_deployment/sqlite_cli.py +86 -0
  107. package/assets/generated/deployment/src/project_forge_deployment/sqlite_import_recovery.py +437 -0
  108. package/assets/generated/deployment/src/project_forge_deployment/ssh.py +119 -0
  109. package/assets/generated/deployment/src/project_forge_deployment/teardown.py +300 -0
  110. package/assets/generated/deployment/src/project_forge_deployment/teardown_cli.py +139 -0
  111. package/assets/generated/deployment/src/project_forge_deployment/teardown_remote.py +100 -0
  112. package/assets/generated/deployment/src/project_forge_deployment/trust.py +278 -0
  113. package/bin/project-forge.mjs +9 -0
  114. package/package.json +31 -4
  115. package/src/cli.mjs +150 -0
  116. package/src/engine.mjs +243 -0
  117. package/src/package-data.mjs +18 -0
@@ -0,0 +1,208 @@
1
+ """Shared Ubuntu box platform ownership and lifecycle operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import shlex
9
+ import subprocess
10
+ from typing import Any
11
+
12
+ from .box_platform_assets import (
13
+ AVAHI_SERVICE,
14
+ CADDYFILE,
15
+ CADDY_CONTAINER,
16
+ CADDY_IMAGE,
17
+ COMPOSE_YAML,
18
+ HOST_PLATFORM_WRITE_PHRASE,
19
+ NETWORK_NAME,
20
+ PLATFORM_DISTRIBUTION,
21
+ PLATFORM_ROOT,
22
+ PLATFORM_SCHEMA_VERSION,
23
+ PLATFORM_VERSION,
24
+ PUBLISH_ALIAS_SH,
25
+ )
26
+ from .box_platform_remote import REMOTE_SCRIPT
27
+ from .errors import DeploymentKitError
28
+ from .profiles import load_profile
29
+ from .ssh import ssh_args, verify_access
30
+
31
+ _BOOL_KEYS = {
32
+ "markerPresent",
33
+ "prerequisitesReady",
34
+ "networkPresent",
35
+ "caddyRunning",
36
+ "avahiRunning",
37
+ "port80InUse",
38
+ }
39
+ _EXPECTED_KEYS = {
40
+ "state",
41
+ "markerPresent",
42
+ "markerSchemaVersion",
43
+ "markerDistribution",
44
+ "markerPlatformVersion",
45
+ "expectedPlatformVersion",
46
+ "prerequisitesReady",
47
+ "networkPresent",
48
+ "caddyRunning",
49
+ "avahiRunning",
50
+ "port80InUse",
51
+ "platformRoot",
52
+ "networkName",
53
+ "caddyContainer",
54
+ }
55
+
56
+
57
+ def build_remote_script() -> str:
58
+ replacements = {
59
+ "__PLATFORM_ROOT__": PLATFORM_ROOT,
60
+ "__NETWORK_NAME__": NETWORK_NAME,
61
+ "__CADDY_CONTAINER__": CADDY_CONTAINER,
62
+ "__CADDY_IMAGE__": CADDY_IMAGE,
63
+ "__SCHEMA_VERSION__": str(PLATFORM_SCHEMA_VERSION),
64
+ "__PLATFORM_VERSION__": PLATFORM_VERSION,
65
+ "__DISTRIBUTION__": PLATFORM_DISTRIBUTION,
66
+ "__COMPOSE_B64__": base64.b64encode(COMPOSE_YAML.encode()).decode("ascii"),
67
+ "__CADDYFILE_B64__": base64.b64encode(CADDYFILE.encode()).decode("ascii"),
68
+ "__PUBLISH_ALIAS_B64__": base64.b64encode(PUBLISH_ALIAS_SH.encode()).decode("ascii"),
69
+ "__AVAHI_SERVICE_B64__": base64.b64encode(AVAHI_SERVICE.encode()).decode("ascii"),
70
+ }
71
+ script = REMOTE_SCRIPT
72
+ for placeholder, value in replacements.items():
73
+ script = script.replace(placeholder, value)
74
+ if "__" in script:
75
+ raise DeploymentKitError("Internal host-platform script contains an unresolved placeholder.")
76
+ return script
77
+
78
+
79
+ def _parse_status(output: str) -> dict[str, Any]:
80
+ result: dict[str, Any] = {}
81
+ for raw in output.splitlines():
82
+ if not raw.strip():
83
+ continue
84
+ if "=" not in raw:
85
+ raise DeploymentKitError(f"Malformed host-platform status line: {raw!r}")
86
+ key, value = raw.split("=", 1)
87
+ if key not in _EXPECTED_KEYS or key in result:
88
+ raise DeploymentKitError(f"Unexpected or duplicate host-platform status key: {key!r}")
89
+ if key in _BOOL_KEYS:
90
+ if value not in {"true", "false"}:
91
+ raise DeploymentKitError(f"Invalid boolean host-platform status value for {key}.")
92
+ result[key] = value == "true"
93
+ else:
94
+ result[key] = value
95
+ missing = _EXPECTED_KEYS - result.keys()
96
+ if missing:
97
+ raise DeploymentKitError(
98
+ "Host-platform status omitted required keys: " + ", ".join(sorted(missing))
99
+ )
100
+ return result
101
+
102
+
103
+ def _run_remote(profile: dict[str, Any], address: str, action: str, *, sudo: bool) -> str:
104
+ if action not in {"status", "install", "upgrade"}:
105
+ raise DeploymentKitError(f"Unsupported host-platform action: {action}")
106
+ remote_command = "bash -s -- " + shlex.quote(action)
107
+ if sudo:
108
+ remote_command = "sudo -n " + remote_command
109
+ command = ssh_args(profile, address, command=remote_command)
110
+ completed = subprocess.run(
111
+ command,
112
+ input=build_remote_script().encode("utf-8"),
113
+ check=False,
114
+ capture_output=True,
115
+ )
116
+ stdout = completed.stdout.decode("utf-8", errors="replace")
117
+ stderr = completed.stderr.decode("utf-8", errors="replace")
118
+ if completed.returncode != 0:
119
+ detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
120
+ raise DeploymentKitError(f"Remote host-platform {action} failed: {detail}")
121
+ return stdout
122
+
123
+
124
+ def platform_status(name: str) -> dict[str, Any]:
125
+ profile = load_profile(name)
126
+ evidence = verify_access(profile)
127
+ status = _parse_status(_run_remote(profile, evidence["address"], "status", sudo=True))
128
+ status["targetName"] = name
129
+ status["verifiedAddress"] = evidence["address"]
130
+ return status
131
+
132
+
133
+ def platform_doctor(name: str) -> dict[str, Any]:
134
+ status = platform_status(name)
135
+ next_actions = {
136
+ "installable": "platform-install",
137
+ "upgrade-required": "platform-upgrade",
138
+ "drifted": "inspect-platform-drift",
139
+ "blocked": "prepare-host-prerequisites",
140
+ "blocked-port-80": "identify-existing-port-80-owner",
141
+ "incompatible": "manual-platform-recovery",
142
+ "ready": "none",
143
+ }
144
+ status["nextAction"] = next_actions.get(status["state"], "manual-review")
145
+ status["mutated"] = False
146
+ return status
147
+
148
+
149
+ def _snapshot(status: dict[str, Any]) -> str:
150
+ selected = {key: status[key] for key in sorted(_EXPECTED_KEYS)}
151
+ return hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
152
+
153
+
154
+ def plan_platform_change(name: str, action: str) -> dict[str, Any]:
155
+ if action not in {"install", "upgrade"}:
156
+ raise DeploymentKitError("Platform change action must be install or upgrade.")
157
+ status = platform_status(name)
158
+ expected_state = "installable" if action == "install" else "upgrade-required"
159
+ if status["state"] != expected_state:
160
+ raise DeploymentKitError(
161
+ f"Platform {action} requires state '{expected_state}', but target '{name}' is '{status['state']}'."
162
+ )
163
+ return {
164
+ "targetName": name,
165
+ "action": action,
166
+ "fromState": status["state"],
167
+ "toPlatformVersion": PLATFORM_VERSION,
168
+ "distribution": PLATFORM_DISTRIBUTION,
169
+ "platformRoot": PLATFORM_ROOT,
170
+ "preserves": ["route-conf.d", "route-registry", "caddy-data", "caddy-config", "shared-network"],
171
+ "doesNotOwn": ["application-containers", "application-data", "application-routes"],
172
+ "statusSnapshot": _snapshot(status),
173
+ }
174
+
175
+
176
+ def apply_platform_change(plan: dict[str, Any]) -> dict[str, Any]:
177
+ required = {
178
+ "targetName",
179
+ "action",
180
+ "fromState",
181
+ "toPlatformVersion",
182
+ "distribution",
183
+ "platformRoot",
184
+ "preserves",
185
+ "doesNotOwn",
186
+ "statusSnapshot",
187
+ }
188
+ if not isinstance(plan, dict) or set(plan) != required:
189
+ raise DeploymentKitError("Malformed host-platform change plan.")
190
+ name = plan["targetName"]
191
+ action = plan["action"]
192
+ if action not in {"install", "upgrade"}:
193
+ raise DeploymentKitError("Malformed host-platform change action.")
194
+ current = platform_status(name)
195
+ if _snapshot(current) != plan["statusSnapshot"]:
196
+ raise DeploymentKitError(
197
+ "Host-platform state changed after the plan was reviewed; create and authorize a fresh plan."
198
+ )
199
+ profile = load_profile(name)
200
+ evidence = verify_access(profile)
201
+ _run_remote(profile, evidence["address"], action, sudo=True)
202
+ final = platform_status(name)
203
+ if final["state"] != "ready":
204
+ raise DeploymentKitError(
205
+ f"Host-platform {action} completed but readiness is '{final['state']}', not 'ready'."
206
+ )
207
+ final["mutated"] = True
208
+ return final
@@ -0,0 +1,100 @@
1
+ """Static shared-box platform assets and contract constants."""
2
+
3
+ PLATFORM_SCHEMA_VERSION = 1
4
+ PLATFORM_VERSION = "2"
5
+ PLATFORM_DISTRIBUTION = "trusted-lan-ubuntu-compose"
6
+ PLATFORM_ROOT = "/srv/project-forge-platform"
7
+ NETWORK_NAME = "caddy-net"
8
+ CADDY_CONTAINER = "project-forge-caddy"
9
+ CADDY_IMAGE = "caddy:2.8.4-alpine@sha256:af32e97399febea808609119bb21544d0265c58a02836576e32a2d082c262c17"
10
+ HOST_PLATFORM_WRITE_PHRASE = "[HOST PLATFORM WRITE]"
11
+
12
+ COMPOSE_YAML = f"""services:
13
+ caddy:
14
+ image: {CADDY_IMAGE}
15
+ container_name: {CADDY_CONTAINER}
16
+ restart: unless-stopped
17
+ ports:
18
+ - \"80:80\"
19
+ volumes:
20
+ - {PLATFORM_ROOT}/proxy/Caddyfile:/etc/caddy/Caddyfile:ro
21
+ - {PLATFORM_ROOT}/proxy/conf.d:/etc/caddy/conf.d:ro
22
+ - {PLATFORM_ROOT}/proxy/data:/data
23
+ - {PLATFORM_ROOT}/proxy/config:/config
24
+ networks:
25
+ - {NETWORK_NAME}
26
+ logging:
27
+ driver: json-file
28
+ options:
29
+ max-size: \"10m\"
30
+ max-file: \"3\"
31
+
32
+ networks:
33
+ {NETWORK_NAME}:
34
+ external: true
35
+ """
36
+
37
+ CADDYFILE = """{
38
+ auto_https off
39
+ }
40
+
41
+ import /etc/caddy/conf.d/*.caddy
42
+ """
43
+
44
+ PUBLISH_ALIAS_SH = """#!/usr/bin/env bash
45
+ set -Eeuo pipefail
46
+
47
+ : \"${ALIAS_HOSTNAME:?ALIAS_HOSTNAME is required}\"
48
+
49
+ current_ip() {
50
+ local ip
51
+ ip=\"$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i==\"src\") print $(i+1)}')\"
52
+ if [[ -z \"$ip\" ]]; then
53
+ ip=\"$(ip -4 addr show scope global | grep -v 'docker' | grep -v 'br-' | awk '/inet / {print $2}' | cut -d/ -f1 | head -n1)\"
54
+ fi
55
+ printf '%s\\n' \"$ip\"
56
+ }
57
+
58
+ CURRENT_IP=\"$(current_ip)\"
59
+ if [[ -z \"$CURRENT_IP\" ]]; then
60
+ echo \"project-forge alias publisher: could not determine host LAN IPv4 address.\" >&2
61
+ exit 1
62
+ fi
63
+
64
+ /usr/bin/avahi-publish -a -R \"$ALIAS_HOSTNAME\" \"$CURRENT_IP\" &
65
+ AVAHI_PID=$!
66
+
67
+ cleanup() {
68
+ kill \"$AVAHI_PID\" 2>/dev/null || true
69
+ wait \"$AVAHI_PID\" 2>/dev/null || true
70
+ }
71
+ trap cleanup SIGTERM SIGINT EXIT
72
+
73
+ while kill -0 \"$AVAHI_PID\" 2>/dev/null; do
74
+ sleep 10
75
+ NEW_IP=\"$(current_ip)\"
76
+ if [[ -n \"$NEW_IP\" && \"$NEW_IP\" != \"$CURRENT_IP\" ]]; then
77
+ cleanup
78
+ exit 0
79
+ fi
80
+ done
81
+ wait \"$AVAHI_PID\"
82
+ """
83
+
84
+ AVAHI_SERVICE = """[Unit]
85
+ Description=Project Forge mDNS alias %i
86
+ After=network-online.target avahi-daemon.service
87
+ Wants=network-online.target
88
+ Requires=avahi-daemon.service
89
+
90
+ [Service]
91
+ Type=simple
92
+ EnvironmentFile=/etc/project-forge-platform/aliases/%i.env
93
+ ExecStart=/usr/local/lib/project-forge-platform/publish-alias.sh
94
+ Restart=always
95
+ RestartSec=2
96
+ NoNewPrivileges=true
97
+
98
+ [Install]
99
+ WantedBy=multi-user.target
100
+ """
@@ -0,0 +1,271 @@
1
+ """Remote Ubuntu host-platform script template."""
2
+
3
+ REMOTE_SCRIPT = r'''#!/usr/bin/env bash
4
+ set -Eeuo pipefail
5
+
6
+ ACTION="$1"
7
+ PLATFORM_ROOT="__PLATFORM_ROOT__"
8
+ PROXY_ROOT="$PLATFORM_ROOT/proxy"
9
+ ROUTE_ROOT="$PLATFORM_ROOT/routes"
10
+ MARKER="$PLATFORM_ROOT/platform.env"
11
+ LOCK_FILE="/run/lock/project-forge-platform.lock"
12
+ NETWORK_NAME="__NETWORK_NAME__"
13
+ CADDY_CONTAINER="__CADDY_CONTAINER__"
14
+ CADDY_IMAGE="__CADDY_IMAGE__"
15
+ SCHEMA_VERSION="__SCHEMA_VERSION__"
16
+ PLATFORM_VERSION="__PLATFORM_VERSION__"
17
+ DISTRIBUTION="__DISTRIBUTION__"
18
+ COMPOSE_B64="__COMPOSE_B64__"
19
+ CADDYFILE_B64="__CADDYFILE_B64__"
20
+ PUBLISH_ALIAS_B64="__PUBLISH_ALIAS_B64__"
21
+ AVAHI_SERVICE_B64="__AVAHI_SERVICE_B64__"
22
+
23
+ marker_value() {
24
+ local key="$1"
25
+ [[ -f "$MARKER" ]] || return 1
26
+ sed -n "s/^${key}=//p" "$MARKER" | head -n1
27
+ }
28
+ port80_in_use() {
29
+ if command -v ss >/dev/null 2>&1; then
30
+ ss -ltn '( sport = :80 )' 2>/dev/null | tail -n +2 | grep -q .
31
+ else
32
+ return 1
33
+ fi
34
+ }
35
+ prereqs_ready() {
36
+ command -v docker >/dev/null 2>&1 &&
37
+ docker compose version >/dev/null 2>&1 &&
38
+ command -v systemctl >/dev/null 2>&1 &&
39
+ command -v avahi-publish >/dev/null 2>&1 &&
40
+ command -v flock >/dev/null 2>&1 &&
41
+ command -v base64 >/dev/null 2>&1 &&
42
+ command -v install >/dev/null 2>&1 &&
43
+ command -v mktemp >/dev/null 2>&1 &&
44
+ command -v ss >/dev/null 2>&1
45
+ }
46
+
47
+ status() {
48
+ local marker_present=false prereqs=false network=false caddy_running=false avahi=false port80=false
49
+ [[ -f "$MARKER" ]] && marker_present=true
50
+ prereqs_ready && prereqs=true || true
51
+ docker network inspect "$NETWORK_NAME" >/dev/null 2>&1 && network=true || true
52
+ [[ "$(docker inspect --format '{{.State.Status}}' "$CADDY_CONTAINER" 2>/dev/null || true)" == "running" ]] && caddy_running=true || true
53
+ systemctl is-active --quiet avahi-daemon 2>/dev/null && avahi=true || true
54
+ port80_in_use && port80=true || true
55
+
56
+ local state marker_schema marker_distribution marker_version
57
+ marker_schema="$(marker_value SCHEMA_VERSION 2>/dev/null || true)"
58
+ marker_distribution="$(marker_value DISTRIBUTION 2>/dev/null || true)"
59
+ marker_version="$(marker_value PLATFORM_VERSION 2>/dev/null || true)"
60
+
61
+ if [[ "$marker_present" == false ]]; then
62
+ if [[ "$prereqs" != true ]]; then
63
+ state="blocked"
64
+ elif [[ "$port80" == true ]]; then
65
+ state="blocked-port-80"
66
+ else
67
+ state="installable"
68
+ fi
69
+ elif [[ "$marker_schema" != "$SCHEMA_VERSION" || "$marker_distribution" != "$DISTRIBUTION" ]]; then
70
+ state="incompatible"
71
+ elif [[ "$prereqs" != true ]]; then
72
+ state="blocked"
73
+ elif [[ "$marker_version" != "$PLATFORM_VERSION" ]]; then
74
+ state="upgrade-required"
75
+ elif [[ "$network" == true && "$caddy_running" == true && "$avahi" == true && -f "$PROXY_ROOT/compose.yaml" && -f "$PROXY_ROOT/Caddyfile" && -d "$ROUTE_ROOT" && -f /usr/local/lib/project-forge-platform/publish-alias.sh && -f /etc/systemd/system/project-forge-avahi-alias@.service ]]; then
76
+ state="ready"
77
+ else
78
+ state="drifted"
79
+ fi
80
+
81
+ printf 'state=%s\n' "$state"
82
+ printf 'markerPresent=%s\n' "$marker_present"
83
+ printf 'markerSchemaVersion=%s\n' "$marker_schema"
84
+ printf 'markerDistribution=%s\n' "$marker_distribution"
85
+ printf 'markerPlatformVersion=%s\n' "$marker_version"
86
+ printf 'expectedPlatformVersion=%s\n' "$PLATFORM_VERSION"
87
+ printf 'prerequisitesReady=%s\n' "$prereqs"
88
+ printf 'networkPresent=%s\n' "$network"
89
+ printf 'caddyRunning=%s\n' "$caddy_running"
90
+ printf 'avahiRunning=%s\n' "$avahi"
91
+ printf 'port80InUse=%s\n' "$port80"
92
+ printf 'platformRoot=%s\n' "$PLATFORM_ROOT"
93
+ printf 'networkName=%s\n' "$NETWORK_NAME"
94
+ printf 'caddyContainer=%s\n' "$CADDY_CONTAINER"
95
+ }
96
+
97
+ require_write_prereqs() {
98
+ if ! prereqs_ready; then
99
+ echo "Required host prerequisites are missing. Docker Engine + Compose, systemd, avahi-utils, iproute2/ss, flock, base64, install, and mktemp must be prepared separately." >&2
100
+ exit 3
101
+ fi
102
+ }
103
+
104
+ write_marker() {
105
+ local tmp="$MARKER.tmp.$$"
106
+ cat > "$tmp" <<EOF
107
+ SCHEMA_VERSION=$SCHEMA_VERSION
108
+ DISTRIBUTION=$DISTRIBUTION
109
+ PLATFORM_VERSION=$PLATFORM_VERSION
110
+ NETWORK_NAME=$NETWORK_NAME
111
+ CADDY_CONTAINER=$CADDY_CONTAINER
112
+ CADDY_IMAGE=$CADDY_IMAGE
113
+ EOF
114
+ chmod 0644 "$tmp"
115
+ mv -f "$tmp" "$MARKER"
116
+ }
117
+
118
+ decode_to() {
119
+ local payload="$1" dest="$2" mode="$3"
120
+ local tmp="${dest}.tmp.$$"
121
+ printf '%s' "$payload" | base64 --decode > "$tmp"
122
+ chmod "$mode" "$tmp"
123
+ mv -f "$tmp" "$dest"
124
+ }
125
+
126
+ validate_candidate() {
127
+ local candidate_compose="$1" candidate_caddy="$2"
128
+ docker compose -f "$candidate_compose" config >/dev/null
129
+ docker run --rm \
130
+ -v "$candidate_caddy:/etc/caddy/Caddyfile:ro" \
131
+ -v "$PROXY_ROOT/conf.d:/etc/caddy/conf.d:ro" \
132
+ "$CADDY_IMAGE" caddy validate --config /etc/caddy/Caddyfile >/dev/null
133
+ }
134
+
135
+ install_platform() {
136
+ require_write_prereqs
137
+ if [[ -f "$MARKER" ]]; then
138
+ echo "Platform marker already exists; use explicit upgrade instead of install." >&2
139
+ exit 4
140
+ fi
141
+ if port80_in_use; then
142
+ echo "Port 80 is already occupied; prepare a clean host before platform install." >&2
143
+ exit 6
144
+ fi
145
+
146
+ install -d -m 0750 "$PLATFORM_ROOT" "$PROXY_ROOT" "$ROUTE_ROOT"
147
+ install -d -m 0755 "$PROXY_ROOT/conf.d" "$PROXY_ROOT/data" "$PROXY_ROOT/config"
148
+ install -d -m 0750 "$ROUTE_ROOT"
149
+ install -d -m 0755 /usr/local/lib/project-forge-platform /etc/project-forge-platform/aliases
150
+ if [[ ! -e "$PROXY_ROOT/conf.d/00-platform-empty.caddy" ]]; then
151
+ printf '# Platform-owned placeholder; application routes are registered separately.\n' > "$PROXY_ROOT/conf.d/00-platform-empty.caddy"
152
+ chmod 0644 "$PROXY_ROOT/conf.d/00-platform-empty.caddy"
153
+ fi
154
+
155
+ if ! docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then
156
+ docker network create "$NETWORK_NAME" >/dev/null
157
+ fi
158
+
159
+ local candidate_dir candidate_compose candidate_caddy
160
+ candidate_dir="$(mktemp -d)"
161
+ trap 'rm -rf "$candidate_dir"' RETURN
162
+ candidate_compose="$candidate_dir/compose.yaml"
163
+ candidate_caddy="$candidate_dir/Caddyfile"
164
+ printf '%s' "$COMPOSE_B64" | base64 --decode > "$candidate_compose"
165
+ printf '%s' "$CADDYFILE_B64" | base64 --decode > "$candidate_caddy"
166
+ validate_candidate "$candidate_compose" "$candidate_caddy"
167
+
168
+ decode_to "$COMPOSE_B64" "$PROXY_ROOT/compose.yaml" 0644
169
+ decode_to "$CADDYFILE_B64" "$PROXY_ROOT/Caddyfile" 0644
170
+ decode_to "$PUBLISH_ALIAS_B64" /usr/local/lib/project-forge-platform/publish-alias.sh 0755
171
+ decode_to "$AVAHI_SERVICE_B64" /etc/systemd/system/project-forge-avahi-alias@.service 0644
172
+
173
+ systemctl daemon-reload
174
+ systemctl enable --now avahi-daemon >/dev/null
175
+ docker compose -f "$PROXY_ROOT/compose.yaml" up -d
176
+ docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile >/dev/null
177
+ write_marker
178
+ }
179
+
180
+ upgrade_platform() {
181
+ require_write_prereqs
182
+ if [[ ! -f "$MARKER" ]]; then
183
+ echo "Platform marker is absent; use install on a fresh host." >&2
184
+ exit 7
185
+ fi
186
+ local marker_schema marker_distribution marker_version
187
+ marker_schema="$(marker_value SCHEMA_VERSION || true)"
188
+ marker_distribution="$(marker_value DISTRIBUTION || true)"
189
+ marker_version="$(marker_value PLATFORM_VERSION || true)"
190
+ if [[ "$marker_schema" != "$SCHEMA_VERSION" || "$marker_distribution" != "$DISTRIBUTION" ]]; then
191
+ echo "Existing platform marker is incompatible; refusing in-place upgrade." >&2
192
+ exit 8
193
+ fi
194
+ if [[ "$marker_version" == "$PLATFORM_VERSION" ]]; then
195
+ echo "Platform is already at version $PLATFORM_VERSION; no upgrade applied."
196
+ return 0
197
+ fi
198
+
199
+ install -d -m 0755 "$PROXY_ROOT/conf.d" "$PROXY_ROOT/data" "$PROXY_ROOT/config"
200
+ install -d -m 0750 "$ROUTE_ROOT"
201
+ if [[ ! -e "$PROXY_ROOT/conf.d/00-platform-empty.caddy" ]]; then
202
+ printf '# Platform-owned placeholder; application routes are registered separately.\n' > "$PROXY_ROOT/conf.d/00-platform-empty.caddy"
203
+ chmod 0644 "$PROXY_ROOT/conf.d/00-platform-empty.caddy"
204
+ fi
205
+ if ! docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then
206
+ docker network create "$NETWORK_NAME" >/dev/null
207
+ fi
208
+ local candidate_dir candidate_compose candidate_caddy backup_dir
209
+ candidate_dir="$(mktemp -d)"
210
+ backup_dir="$(mktemp -d)"
211
+ candidate_compose="$candidate_dir/compose.yaml"
212
+ candidate_caddy="$candidate_dir/Caddyfile"
213
+ printf '%s' "$COMPOSE_B64" | base64 --decode > "$candidate_compose"
214
+ printf '%s' "$CADDYFILE_B64" | base64 --decode > "$candidate_caddy"
215
+ validate_candidate "$candidate_compose" "$candidate_caddy"
216
+
217
+ for path in "$PROXY_ROOT/compose.yaml" "$PROXY_ROOT/Caddyfile" /usr/local/lib/project-forge-platform/publish-alias.sh /etc/systemd/system/project-forge-avahi-alias@.service "$MARKER"; do
218
+ if [[ -f "$path" ]]; then cp -a "$path" "$backup_dir/$(basename "$path")"; fi
219
+ done
220
+
221
+ rollback() {
222
+ echo "Upgrade failed; restoring previous platform-managed files." >&2
223
+ [[ -f "$backup_dir/compose.yaml" ]] && cp -f "$backup_dir/compose.yaml" "$PROXY_ROOT/compose.yaml"
224
+ [[ -f "$backup_dir/Caddyfile" ]] && cp -f "$backup_dir/Caddyfile" "$PROXY_ROOT/Caddyfile"
225
+ [[ -f "$backup_dir/publish-alias.sh" ]] && cp -f "$backup_dir/publish-alias.sh" /usr/local/lib/project-forge-platform/publish-alias.sh
226
+ [[ -f "$backup_dir/project-forge-avahi-alias@.service" ]] && cp -f "$backup_dir/project-forge-avahi-alias@.service" /etc/systemd/system/project-forge-avahi-alias@.service
227
+ [[ -f "$backup_dir/platform.env" ]] && cp -f "$backup_dir/platform.env" "$MARKER"
228
+ systemctl daemon-reload || true
229
+ docker compose -f "$PROXY_ROOT/compose.yaml" up -d || true
230
+ docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile || true
231
+ }
232
+
233
+ set +e
234
+ decode_to "$COMPOSE_B64" "$PROXY_ROOT/compose.yaml" 0644
235
+ rc=$?
236
+ [[ $rc -eq 0 ]] && decode_to "$CADDYFILE_B64" "$PROXY_ROOT/Caddyfile" 0644; rc=$(( rc == 0 ? $? : rc ))
237
+ [[ $rc -eq 0 ]] && decode_to "$PUBLISH_ALIAS_B64" /usr/local/lib/project-forge-platform/publish-alias.sh 0755; rc=$(( rc == 0 ? $? : rc ))
238
+ [[ $rc -eq 0 ]] && decode_to "$AVAHI_SERVICE_B64" /etc/systemd/system/project-forge-avahi-alias@.service 0644; rc=$(( rc == 0 ? $? : rc ))
239
+ [[ $rc -eq 0 ]] && systemctl daemon-reload; rc=$(( rc == 0 ? $? : rc ))
240
+ [[ $rc -eq 0 ]] && systemctl enable --now avahi-daemon >/dev/null; rc=$(( rc == 0 ? $? : rc ))
241
+ [[ $rc -eq 0 ]] && docker compose -f "$PROXY_ROOT/compose.yaml" up -d; rc=$(( rc == 0 ? $? : rc ))
242
+ [[ $rc -eq 0 ]] && docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile; rc=$(( rc == 0 ? $? : rc ))
243
+ if [[ $rc -eq 0 ]]; then write_marker; rc=$?; fi
244
+ set -e
245
+ if [[ $rc -ne 0 ]]; then
246
+ rollback
247
+ rm -rf "$candidate_dir" "$backup_dir"
248
+ exit "$rc"
249
+ fi
250
+ rm -rf "$candidate_dir" "$backup_dir"
251
+ }
252
+
253
+ case "$ACTION" in
254
+ status)
255
+ status
256
+ ;;
257
+ install|upgrade)
258
+ exec 9>>"$LOCK_FILE"
259
+ if ! flock -n 9; then
260
+ echo "Another host-platform operation holds the lock." >&2
261
+ exit 75
262
+ fi
263
+ if [[ "$ACTION" == install ]]; then install_platform; else upgrade_platform; fi
264
+ status
265
+ ;;
266
+ *)
267
+ echo "Unsupported platform action: $ACTION" >&2
268
+ exit 2
269
+ ;;
270
+ esac
271
+ '''