@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,416 @@
1
+ """Remote Bash template for generic application runtime deployment."""
2
+
3
+ REMOTE_SCRIPT = r'''#!/usr/bin/env bash
4
+ set -Eeuo pipefail
5
+
6
+ ACTION="$1"
7
+ EXPECTED_SPEC_SHA="${2:-}"
8
+ DEPLOY_USER="${3:-}"
9
+ APP_ROOT="__APP_ROOT__"
10
+ APP_SCHEMA_VERSION="__APP_SCHEMA_VERSION__"
11
+ NETWORK_NAME="__NETWORK_NAME__"
12
+ CADDY_CONTAINER="__CADDY_CONTAINER__"
13
+ DESIRED_PRESENT="__DESIRED_PRESENT__"
14
+
15
+ D_OWNER_B64="__D_OWNER_B64__"
16
+ D_ENV_B64="__D_ENV_B64__"
17
+ D_APP_B64="__D_APP_B64__"
18
+ D_CONTAINER_B64="__D_CONTAINER_B64__"
19
+ D_IMAGE_B64="__D_IMAGE_B64__"
20
+ D_PORT="__D_PORT__"
21
+ D_HEALTH_B64="__D_HEALTH_B64__"
22
+ D_RUN_AS_B64="__D_RUN_AS_B64__"
23
+ D_PERSISTENCE="__D_PERSISTENCE__"
24
+ D_DATA_TARGET_B64="__D_DATA_TARGET_B64__"
25
+ D_SPEC_SHA="__D_SPEC_SHA__"
26
+ D_ENV_KEYS=(__D_ENV_KEYS__)
27
+ D_ENV_VALUES=(__D_ENV_VALUES__)
28
+
29
+ b64d() { printf '%s' "$1" | base64 --decode; }
30
+ meta_value() {
31
+ local path="$1" key="$2"
32
+ [[ -f "$path" ]] || return 1
33
+ sed -n "s/^${key}=//p" "$path" | head -n1
34
+ }
35
+ valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
36
+ valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
37
+ valid_container() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]]; }
38
+ valid_image() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]]; }
39
+ valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( 1 <= 10#$1 && 10#$1 <= 65535 )); }
40
+ valid_run_as() { [[ "$1" =~ ^[0-9]+(:[0-9]+)?$ ]]; }
41
+ valid_env_key() { [[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; }
42
+
43
+ platform_ready() {
44
+ [[ -f /srv/project-forge-platform/platform.env ]] &&
45
+ docker network inspect "$NETWORK_NAME" >/dev/null 2>&1 &&
46
+ [[ "$(docker inspect --format '{{.State.Status}}' "$CADDY_CONTAINER" 2>/dev/null || true)" == "running" ]]
47
+ }
48
+
49
+ network_has_container() {
50
+ local container="$1"
51
+ docker network inspect "$NETWORK_NAME" --format '{{range .Containers}}{{.Name}}{{"\n"}}{{end}}' 2>/dev/null |
52
+ grep -Fxq "$container"
53
+ }
54
+
55
+ probe_http() {
56
+ local container="$1" port="$2" path="$3"
57
+ docker exec "$CADDY_CONTAINER" \
58
+ wget -q -T 5 -O /dev/null "http://${container}:${port}${path}" >/dev/null 2>&1
59
+ }
60
+
61
+ wait_ready() {
62
+ local container="$1" port="$2" path="$3"
63
+ local deadline=$((SECONDS + 120)) state=""
64
+ while (( SECONDS < deadline )); do
65
+ state="$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || true)"
66
+ if [[ "$state" == "running" ]] && network_has_container "$container" && probe_http "$container" "$port" "$path"; then
67
+ return 0
68
+ fi
69
+ if [[ "$state" == "exited" || "$state" == "dead" ]]; then
70
+ return 1
71
+ fi
72
+ sleep 2
73
+ done
74
+ return 1
75
+ }
76
+
77
+ inventory() {
78
+ local count=0 state current previous release_file
79
+ local owner env app container image spec_sha port health persistence
80
+ local running connected healthy
81
+ if [[ -d "$APP_ROOT" ]]; then
82
+ while IFS= read -r state; do
83
+ [[ -n "$state" ]] || continue
84
+ owner="$(meta_value "$state" OWNER_REPO || true)"
85
+ env="$(meta_value "$state" ENVIRONMENT || true)"
86
+ app="$(meta_value "$state" APP_SLUG || true)"
87
+ current="$(meta_value "$state" CURRENT_RELEASE || true)"
88
+ previous="$(meta_value "$state" PREVIOUS_RELEASE || true)"
89
+ spec_sha="$(meta_value "$state" SPEC_SHA256 || true)"
90
+ [[ -n "$owner" && -n "$env" && -n "$app" && -n "$current" && -n "$spec_sha" ]] || {
91
+ echo "Managed application state is malformed: $state" >&2
92
+ exit 31
93
+ }
94
+ release_file="$(dirname "$state")/releases/$current/release.env"
95
+ [[ -f "$release_file" && ! -L "$release_file" ]] || {
96
+ echo "Current application release metadata is missing: $release_file" >&2
97
+ exit 31
98
+ }
99
+ container="$(meta_value "$release_file" CONTAINER_NAME || true)"
100
+ image="$(meta_value "$release_file" IMAGE || true)"
101
+ port="$(meta_value "$release_file" RUNTIME_PORT || true)"
102
+ health="$(b64d "$(meta_value "$release_file" HEALTH_PATH_B64 || true)")"
103
+ persistence="$(meta_value "$release_file" PERSISTENCE_MODE || true)"
104
+ [[ "$(meta_value "$release_file" SPEC_SHA256 || true)" == "$spec_sha" ]] || {
105
+ echo "Application state/release fingerprint mismatch: $state" >&2
106
+ exit 31
107
+ }
108
+ running=false
109
+ connected=false
110
+ healthy=false
111
+ [[ "$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || true)" == "running" ]] && running=true || true
112
+ network_has_container "$container" && connected=true || true
113
+ if [[ "$running" == true && "$connected" == true ]]; then
114
+ probe_http "$container" "$port" "$health" && healthy=true || true
115
+ fi
116
+ printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
117
+ "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
118
+ "$current" "$previous" "$running" "$connected" "$healthy"
119
+ count=$((count + 1))
120
+ done < <(find "$APP_ROOT" -mindepth 3 -maxdepth 3 -type f -name deployment.env -print 2>/dev/null | sort)
121
+ fi
122
+ printf 'SUMMARY\tappCount\t%s\n' "$count"
123
+ }
124
+
125
+ configure_registry_auth() {
126
+ [[ -n "$DEPLOY_USER" ]] || { echo "Deployment account is required." >&2; exit 32; }
127
+ [[ "$DEPLOY_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]{0,31}$ ]] || {
128
+ echo "Deployment account contains unsupported characters." >&2
129
+ exit 32
130
+ }
131
+ local home config dir_mode file_mode deploy_uid
132
+ home="$(getent passwd "$DEPLOY_USER" | cut -d: -f6)"
133
+ [[ -n "$home" && "$home" == /* ]] || { echo "Could not resolve deployment account home." >&2; exit 32; }
134
+ config="$home/.docker"
135
+ if [[ ! -e "$config/config.json" ]]; then
136
+ return 0
137
+ fi
138
+ [[ -d "$config" && ! -L "$config" && -f "$config/config.json" && ! -L "$config/config.json" ]] || {
139
+ echo "Docker credential path is not a regular protected directory/file." >&2
140
+ exit 32
141
+ }
142
+ dir_mode="$(stat -c '%a' "$config")"
143
+ file_mode="$(stat -c '%a' "$config/config.json")"
144
+ deploy_uid="$(id -u "$DEPLOY_USER")"
145
+ if (( (8#$dir_mode & 077) != 0 || (8#$file_mode & 077) != 0 )) ||
146
+ [[ "$(stat -c '%u' "$config")" != "$deploy_uid" || "$(stat -c '%u' "$config/config.json")" != "$deploy_uid" ]]; then
147
+ echo "Docker credentials must be owned by the deployment account and deny group/other access." >&2
148
+ exit 32
149
+ fi
150
+ export DOCKER_CONFIG="$config"
151
+ }
152
+
153
+ decode_desired() {
154
+ [[ "$DESIRED_PRESENT" == true ]] || { echo "Deploy action omitted desired runtime spec." >&2; exit 33; }
155
+ D_OWNER="$(b64d "$D_OWNER_B64")"
156
+ D_ENV="$(b64d "$D_ENV_B64")"
157
+ D_APP="$(b64d "$D_APP_B64")"
158
+ D_CONTAINER="$(b64d "$D_CONTAINER_B64")"
159
+ D_IMAGE="$(b64d "$D_IMAGE_B64")"
160
+ D_HEALTH="$(b64d "$D_HEALTH_B64")"
161
+ D_RUN_AS="$(b64d "$D_RUN_AS_B64")"
162
+ D_DATA_TARGET="$(b64d "$D_DATA_TARGET_B64")"
163
+
164
+ valid_repo "$D_OWNER" || { echo "Invalid owner repository." >&2; exit 33; }
165
+ valid_slug "$D_ENV" || { echo "Invalid environment slug." >&2; exit 33; }
166
+ valid_slug "$D_APP" || { echo "Invalid application slug." >&2; exit 33; }
167
+ valid_container "$D_CONTAINER" || { echo "Invalid container name." >&2; exit 33; }
168
+ valid_image "$D_IMAGE" || { echo "Image is not an immutable sha256 digest reference." >&2; exit 33; }
169
+ valid_port "$D_PORT" || { echo "Invalid runtime port." >&2; exit 33; }
170
+ [[ "$D_HEALTH" == /* && "$D_HEALTH" != *$'\n'* && "$D_HEALTH" != *$'\r'* ]] || {
171
+ echo "Invalid health path." >&2
172
+ exit 33
173
+ }
174
+ valid_run_as "$D_RUN_AS" || { echo "Invalid numeric runAs value." >&2; exit 33; }
175
+ [[ "$D_PERSISTENCE" == "none" || "$D_PERSISTENCE" == "directory" ]] || {
176
+ echo "Invalid persistence mode." >&2
177
+ exit 33
178
+ }
179
+ if [[ "$D_PERSISTENCE" == "directory" ]]; then
180
+ [[ "$D_DATA_TARGET" == /* && "$D_DATA_TARGET" != *"/../"* && "$D_DATA_TARGET" != *$'\n'* ]] || {
181
+ echo "Invalid persistent data container path." >&2
182
+ exit 33
183
+ }
184
+ elif [[ -n "$D_DATA_TARGET" ]]; then
185
+ echo "Non-persistent runtime unexpectedly supplied a data path." >&2
186
+ exit 33
187
+ fi
188
+ [[ "$D_SPEC_SHA" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid runtime spec fingerprint." >&2; exit 33; }
189
+ [[ "${#D_ENV_KEYS[@]}" -eq "${#D_ENV_VALUES[@]}" ]] || { echo "Environment payload mismatch." >&2; exit 33; }
190
+
191
+ local index key
192
+ for index in "${!D_ENV_KEYS[@]}"; do
193
+ key="$(b64d "${D_ENV_KEYS[$index]}")"
194
+ valid_env_key "$key" || { echo "Invalid environment variable name." >&2; exit 33; }
195
+ done
196
+ }
197
+
198
+ release_value() {
199
+ meta_value "$1" "$2"
200
+ }
201
+
202
+ write_release_metadata() {
203
+ local release_file="$1"
204
+ local tmp="$release_file.tmp.$$" index key_b64 value_b64
205
+ {
206
+ printf 'SCHEMA_VERSION=%s\n' "$APP_SCHEMA_VERSION"
207
+ printf 'OWNER_REPO=%s\n' "$D_OWNER"
208
+ printf 'ENVIRONMENT=%s\n' "$D_ENV"
209
+ printf 'APP_SLUG=%s\n' "$D_APP"
210
+ printf 'CONTAINER_NAME=%s\n' "$D_CONTAINER"
211
+ printf 'IMAGE=%s\n' "$D_IMAGE"
212
+ printf 'RUNTIME_PORT=%s\n' "$D_PORT"
213
+ printf 'HEALTH_PATH_B64=%s\n' "$D_HEALTH_B64"
214
+ printf 'RUN_AS=%s\n' "$D_RUN_AS"
215
+ printf 'PERSISTENCE_MODE=%s\n' "$D_PERSISTENCE"
216
+ printf 'DATA_TARGET_B64=%s\n' "$D_DATA_TARGET_B64"
217
+ printf 'SPEC_SHA256=%s\n' "$D_SPEC_SHA"
218
+ printf 'ENV_COUNT=%s\n' "${#D_ENV_KEYS[@]}"
219
+ for index in "${!D_ENV_KEYS[@]}"; do
220
+ key_b64="${D_ENV_KEYS[$index]}"
221
+ value_b64="${D_ENV_VALUES[$index]}"
222
+ printf 'ENV_KEY_B64_%s=%s\n' "$index" "$key_b64"
223
+ printf 'ENV_VALUE_B64_%s=%s\n' "$index" "$value_b64"
224
+ done
225
+ } > "$tmp"
226
+ chmod 0600 "$tmp"
227
+ mv -f "$tmp" "$release_file"
228
+ }
229
+
230
+ start_desired_container() {
231
+ local root data
232
+ local uid gid index key value
233
+ root="$1"
234
+ data="$root/data"
235
+ uid="${D_RUN_AS%%:*}"
236
+ if [[ "$D_RUN_AS" == *:* ]]; then gid="${D_RUN_AS#*:}"; else gid="$uid"; fi
237
+
238
+ if [[ "$D_PERSISTENCE" == "directory" ]]; then
239
+ if [[ -L "$data" ]]; then
240
+ echo "Persistent data directory must not be a symbolic link." >&2
241
+ return 1
242
+ fi
243
+ if [[ ! -e "$data" ]]; then
244
+ install -d -m 0750 -o "$uid" -g "$gid" "$data"
245
+ elif [[ ! -d "$data" ]]; then
246
+ echo "Persistent data path is not a directory." >&2
247
+ return 1
248
+ elif [[ "$(stat -c '%u' "$data")" != "$uid" || "$(stat -c '%g' "$data")" != "$gid" ]]; then
249
+ echo "Persistent data directory ownership does not match runAs; use the persistence adapter/import path." >&2
250
+ return 1
251
+ fi
252
+ fi
253
+
254
+ local -a docker_args
255
+ docker_args=(
256
+ docker run -d
257
+ --name "$D_CONTAINER"
258
+ --restart unless-stopped
259
+ --network "$NETWORK_NAME"
260
+ --user "$D_RUN_AS"
261
+ --log-driver json-file
262
+ --log-opt max-size=10m
263
+ --log-opt max-file=3
264
+ )
265
+ if [[ "$D_PERSISTENCE" == "directory" ]]; then
266
+ docker_args+=(--mount "type=bind,src=$data,dst=$D_DATA_TARGET")
267
+ fi
268
+ for index in "${!D_ENV_KEYS[@]}"; do
269
+ key="$(b64d "${D_ENV_KEYS[$index]}")"
270
+ value="$(b64d "${D_ENV_VALUES[$index]}")"
271
+ docker_args+=(--env "$key=$value")
272
+ done
273
+ docker_args+=("$D_IMAGE")
274
+ "${docker_args[@]}" >/dev/null
275
+ }
276
+
277
+ record_evidence() {
278
+ local root="$1" outcome="$2" release_id="$3" previous="$4"
279
+ local timestamp
280
+ timestamp="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
281
+ printf '{"timestamp":"%s","ownerRepo":"%s","environment":"%s","appSlug":"%s","container":"%s","image":"%s","specSha256":"%s","release":"%s","previousRelease":"%s","persistence":"%s","outcome":"%s"}\n' \
282
+ "$timestamp" "$D_OWNER" "$D_ENV" "$D_APP" "$D_CONTAINER" "$D_IMAGE" "$D_SPEC_SHA" \
283
+ "$release_id" "$previous" "$D_PERSISTENCE" "$outcome" >> "$root/deployment-evidence.jsonl"
284
+ chmod 0600 "$root/deployment-evidence.jsonl"
285
+ }
286
+
287
+ deploy() {
288
+ platform_ready || { echo "Kit shared platform is not ready." >&2; exit 34; }
289
+ decode_desired
290
+ configure_registry_auth
291
+
292
+ local root releases state current_release="" previous_release="" current_spec="" current_container="" next_previous=""
293
+ local digest release_id release_dir release_file lock_file tmp
294
+ root="$APP_ROOT/$D_APP/$D_ENV"
295
+ releases="$root/releases"
296
+ state="$root/deployment.env"
297
+ lock_file="/run/lock/project-forge-app-$D_APP--$D_ENV.lock"
298
+ digest="${D_IMAGE##*@sha256:}"
299
+ release_id="sha256-${digest}-${D_SPEC_SHA:0:16}"
300
+ release_dir="$releases/$release_id"
301
+ release_file="$release_dir/release.env"
302
+
303
+ exec 9>>"$lock_file"
304
+ if ! flock -n 9; then
305
+ echo "Another application deployment holds the lock for $D_APP--$D_ENV." >&2
306
+ exit 75
307
+ fi
308
+
309
+ for path in "$root" "$releases" "$state" "$root/data" "$root/deployment-evidence.jsonl"; do
310
+ [[ ! -L "$path" ]] || { echo "Managed application path must not be a symbolic link: $path" >&2; exit 35; }
311
+ done
312
+
313
+ if [[ -f "$state" ]]; then
314
+ [[ "$(meta_value "$state" OWNER_REPO || true)" == "$D_OWNER" &&
315
+ "$(meta_value "$state" ENVIRONMENT || true)" == "$D_ENV" &&
316
+ "$(meta_value "$state" APP_SLUG || true)" == "$D_APP" ]] || {
317
+ echo "Existing application state has different ownership." >&2
318
+ exit 35
319
+ }
320
+ current_release="$(meta_value "$state" CURRENT_RELEASE || true)"
321
+ previous_release="$(meta_value "$state" PREVIOUS_RELEASE || true)"
322
+ current_spec="$(meta_value "$state" SPEC_SHA256 || true)"
323
+ [[ -n "$current_release" && "$current_spec" =~ ^[0-9a-f]{64}$ ]] || {
324
+ echo "Existing application state is malformed." >&2
325
+ exit 35
326
+ }
327
+ [[ "$EXPECTED_SPEC_SHA" != "NONE" && "$current_spec" == "$EXPECTED_SPEC_SHA" ]] || {
328
+ echo "Application state changed after planning; create a fresh plan." >&2
329
+ exit 36
330
+ }
331
+ local current_file="$releases/$current_release/release.env"
332
+ [[ -f "$current_file" && ! -L "$current_file" ]] || {
333
+ echo "Current release metadata is missing." >&2
334
+ exit 35
335
+ }
336
+ current_container="$(release_value "$current_file" CONTAINER_NAME || true)"
337
+ valid_container "$current_container" || { echo "Current release container metadata is malformed." >&2; exit 35; }
338
+ if [[ "$current_spec" == "$D_SPEC_SHA" ]]; then
339
+ next_previous="$previous_release"
340
+ else
341
+ next_previous="$current_release"
342
+ fi
343
+ else
344
+ [[ "$EXPECTED_SPEC_SHA" == "NONE" ]] || {
345
+ echo "Application state disappeared after planning; create a fresh plan." >&2
346
+ exit 36
347
+ }
348
+ fi
349
+
350
+ if docker inspect "$D_CONTAINER" >/dev/null 2>&1 && [[ "$D_CONTAINER" != "$current_container" ]]; then
351
+ echo "Desired container name is already occupied outside this managed runtime." >&2
352
+ exit 37
353
+ fi
354
+
355
+ install -d -m 0750 "$APP_ROOT" "$APP_ROOT/$D_APP" "$root" "$releases"
356
+ if [[ -e "$release_dir" && ! -d "$release_dir" ]]; then
357
+ echo "Release path exists and is not a directory." >&2
358
+ exit 35
359
+ fi
360
+ install -d -m 0750 "$release_dir"
361
+ if [[ -f "$release_file" ]]; then
362
+ [[ "$(release_value "$release_file" SPEC_SHA256 || true)" == "$D_SPEC_SHA" ]] || {
363
+ echo "Release directory collision with different runtime metadata." >&2
364
+ exit 35
365
+ }
366
+ else
367
+ write_release_metadata "$release_file"
368
+ fi
369
+
370
+ docker pull "$D_IMAGE" >/dev/null
371
+
372
+ if [[ -n "$current_container" ]]; then
373
+ docker rm -f "$current_container" >/dev/null
374
+ fi
375
+
376
+ if ! start_desired_container "$root"; then
377
+ docker rm -f "$D_CONTAINER" >/dev/null 2>&1 || true
378
+ record_evidence "$root" "failed-start" "$release_id" "$current_release"
379
+ echo "Application container failed to start; previous release metadata was retained but runtime rollback was not attempted." >&2
380
+ exit 38
381
+ fi
382
+ if ! wait_ready "$D_CONTAINER" "$D_PORT" "$D_HEALTH"; then
383
+ docker rm -f "$D_CONTAINER" >/dev/null 2>&1 || true
384
+ record_evidence "$root" "failed-readiness" "$release_id" "$current_release"
385
+ echo "Application failed readiness; previous release metadata was retained but runtime rollback was not attempted." >&2
386
+ exit 39
387
+ fi
388
+
389
+ tmp="$state.tmp.$$"
390
+ {
391
+ printf 'SCHEMA_VERSION=%s\n' "$APP_SCHEMA_VERSION"
392
+ printf 'OWNER_REPO=%s\n' "$D_OWNER"
393
+ printf 'ENVIRONMENT=%s\n' "$D_ENV"
394
+ printf 'APP_SLUG=%s\n' "$D_APP"
395
+ printf 'CURRENT_RELEASE=%s\n' "$release_id"
396
+ printf 'PREVIOUS_RELEASE=%s\n' "$next_previous"
397
+ printf 'SPEC_SHA256=%s\n' "$D_SPEC_SHA"
398
+ } > "$tmp"
399
+ chmod 0600 "$tmp"
400
+ mv -f "$tmp" "$state"
401
+ record_evidence "$root" "deployed" "$release_id" "$next_previous"
402
+ }
403
+
404
+ case "$ACTION" in
405
+ inventory)
406
+ inventory
407
+ ;;
408
+ deploy)
409
+ deploy
410
+ ;;
411
+ *)
412
+ echo "Unsupported application-runtime action: $ACTION" >&2
413
+ exit 2
414
+ ;;
415
+ esac
416
+ '''
@@ -0,0 +1,136 @@
1
+ """Remote application deployment using an exact image transferred from the workstation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from typing import Any
7
+
8
+ from .app_runtime import (
9
+ NETWORK_NAME,
10
+ _find_app,
11
+ _inventory_snapshot,
12
+ app_inventory,
13
+ build_remote_script,
14
+ )
15
+ from .box_platform import platform_status
16
+ from .errors import DeploymentKitError
17
+ from .image_transfer import transfer_image
18
+ from .profiles import load_profile
19
+ from .ssh import ssh_args, verify_access
20
+
21
+
22
+ def _preloaded_script(spec: dict[str, Any]) -> str:
23
+ script = build_remote_script(spec)
24
+ registry_block = " decode_desired\n configure_registry_auth\n"
25
+ if registry_block not in script:
26
+ raise DeploymentKitError("Application runtime template no longer contains the expected registry-auth hook.")
27
+ script = script.replace(registry_block, " decode_desired\n", 1)
28
+ pull = ' docker pull "$D_IMAGE" >/dev/null\n'
29
+ replacement = ''' if ! docker image inspect "$D_IMAGE" >/dev/null 2>&1; then
30
+ echo "Exact application image was not preloaded by the workstation-mediated deployment path." >&2
31
+ exit 40
32
+ fi
33
+ '''
34
+ if pull not in script:
35
+ raise DeploymentKitError("Application runtime template no longer contains the expected image-pull hook.")
36
+ return script.replace(pull, replacement, 1)
37
+
38
+
39
+ def _run_preloaded_deploy(
40
+ profile: dict[str, Any],
41
+ address: str,
42
+ spec: dict[str, Any],
43
+ expected_spec_sha: str,
44
+ ) -> None:
45
+ command = ssh_args(
46
+ profile,
47
+ address,
48
+ command="sudo -n bash -s -- deploy " + (expected_spec_sha or "NONE") + " " + profile["user"],
49
+ )
50
+ completed = subprocess.run(
51
+ command,
52
+ input=_preloaded_script(spec).encode("utf-8"),
53
+ check=False,
54
+ capture_output=True,
55
+ )
56
+ if completed.returncode != 0:
57
+ stdout = completed.stdout.decode("utf-8", errors="replace")
58
+ stderr = completed.stderr.decode("utf-8", errors="replace")
59
+ detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
60
+ raise DeploymentKitError(f"Workstation-mediated remote application deployment failed: {detail}")
61
+
62
+
63
+ def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
64
+ required = {
65
+ "targetName",
66
+ "action",
67
+ "environment",
68
+ "appSlug",
69
+ "ownerRepo",
70
+ "containerName",
71
+ "image",
72
+ "runtimePort",
73
+ "healthPath",
74
+ "persistenceMode",
75
+ "changeType",
76
+ "previousImage",
77
+ "previousRelease",
78
+ "retainsPreviousRelease",
79
+ "publishesHostPort",
80
+ "attachesSharedNetwork",
81
+ "modifiesRoutes",
82
+ "persistentDataDeletion",
83
+ "inventorySnapshot",
84
+ "expectedSpecSha256",
85
+ "spec",
86
+ }
87
+ if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
88
+ raise DeploymentKitError("Malformed application deployment plan.")
89
+
90
+ current = app_inventory(plan["targetName"])
91
+ if _inventory_snapshot(current) != plan["inventorySnapshot"]:
92
+ raise DeploymentKitError(
93
+ "Application inventory changed after the plan was reviewed; create and authorize a fresh plan."
94
+ )
95
+ platform = platform_status(plan["targetName"])
96
+ if platform["state"] != "ready":
97
+ raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
98
+
99
+ transfer_image(plan["targetName"], plan["image"])
100
+
101
+ # Image transfer can take time. Re-check the material app/platform evidence before
102
+ # changing runtime state; a changed plan must never ride through on stale approval.
103
+ current = app_inventory(plan["targetName"])
104
+ if _inventory_snapshot(current) != plan["inventorySnapshot"]:
105
+ raise DeploymentKitError(
106
+ "Application inventory changed while transferring the image; create and authorize a fresh plan."
107
+ )
108
+ platform = platform_status(plan["targetName"])
109
+ if platform["state"] != "ready":
110
+ raise DeploymentKitError("Shared host platform changed while transferring the image; deployment is blocked.")
111
+
112
+ profile = load_profile(plan["targetName"])
113
+ evidence = verify_access(profile)
114
+ _run_preloaded_deploy(
115
+ profile,
116
+ evidence["address"],
117
+ plan["spec"],
118
+ plan["expectedSpecSha256"] or "",
119
+ )
120
+
121
+ final = app_inventory(plan["targetName"])
122
+ deployed = _find_app(final, plan["appSlug"], plan["environment"])
123
+ if deployed is None:
124
+ raise DeploymentKitError("Application deployment returned without managed runtime state.")
125
+ if deployed["specSha256"] != plan["spec"]["specSha256"]:
126
+ raise DeploymentKitError("Application deployment returned with a different runtime spec.")
127
+ if not (
128
+ deployed["containerRunning"]
129
+ and deployed["connectedToSharedNetwork"]
130
+ and deployed["httpHealthy"]
131
+ ):
132
+ raise DeploymentKitError("Application deployment completed but runtime readiness is not healthy.")
133
+ final["mutated"] = True
134
+ final["imageSource"] = "workstation-transfer"
135
+ final["attachesSharedNetwork"] = NETWORK_NAME
136
+ return final