@wdyy/skills 0.1.11 → 0.1.13

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 (25) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +30 -55
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
  4. package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +103 -0
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +140 -153
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +97 -85
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +102 -174
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +203 -455
  9. package/.well-known/skills/wdyy-deployment-standard/templates/{Dockerfile.template → backend.Dockerfile.template} +5 -9
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +207 -647
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +33 -0
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
  13. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -24
  14. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -8
  15. package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +9 -4
  16. package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
  17. package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
  18. package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
  19. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
  20. package/README.md +4 -2
  21. package/lib/wdyy-cli.js +4 -2
  22. package/package.json +1 -1
  23. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -127
  24. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
  25. package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +0 -25
@@ -3,8 +3,11 @@ set -euo pipefail
3
3
 
4
4
  SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
5
5
  SCRIPT_PATH="$SCRIPT_DIR/$(basename -- "${BASH_SOURCE[0]}")"
6
- CHECKSUM_TOOL=
7
6
  BUILD_LOCK=
7
+ STAGING_ROOT=
8
+ DEPLOY_ROOT=
9
+ OPERATION=
10
+ CHECKSUM_TOOL=
8
11
 
9
12
  fail() {
10
13
  echo "$*" >&2
@@ -12,7 +15,10 @@ fail() {
12
15
  }
13
16
 
14
17
  usage() {
15
- echo "usage: ./deploy.sh build|start|replace|restart|stop|remove|status|rollback <version>" >&2
18
+ echo "usage: ./deploy.sh build # local project root" >&2
19
+ echo " ./deploy.sh # server: deploy the complete stack" >&2
20
+ echo " ./deploy.sh stop # server: stop the complete stack" >&2
21
+ echo " ./deploy.sh status # server: inspect stack status" >&2
16
22
  exit 2
17
23
  }
18
24
 
@@ -20,8 +26,17 @@ require_command() {
20
26
  command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
21
27
  }
22
28
 
23
- assert_version() {
24
- [[ "${1:-}" =~ ^[0-9]{8}-[0-9]{3}$ ]] || fail "version must match YYYYMMDD-NNN"
29
+ select_checksum_tool() {
30
+ if command -v sha256sum >/dev/null 2>&1; then CHECKSUM_TOOL=sha256sum
31
+ elif command -v shasum >/dev/null 2>&1; then CHECKSUM_TOOL=shasum
32
+ else fail "required SHA-256 command not found"
33
+ fi
34
+ }
35
+
36
+ checksum_value() {
37
+ if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then sha256sum "$1" | awk '{print $1}'
38
+ else shasum -a 256 "$1" | awk '{print $1}'
39
+ fi
25
40
  }
26
41
 
27
42
  assert_port() {
@@ -30,65 +45,51 @@ assert_port() {
30
45
  (( value >= 1 && value <= 65535 )) || fail "$name must be between 1 and 65535"
31
46
  }
32
47
 
48
+ validate_env_permissions() {
49
+ local file="$1" mode
50
+ if stat -c '%a' "$file" >/dev/null 2>&1; then mode="$(stat -c '%a' "$file")"
51
+ else mode="$(stat -f '%Lp' "$file")"
52
+ fi
53
+ [[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
54
+ }
55
+
33
56
  reset_configuration() {
34
- unset PROJECT_NAME PROJECT_HTTP_PORT DOCKER_BIND_IP
35
57
  unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
36
- unset FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT
37
- unset FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
38
- unset FRONTEND_DOCKERFILE BACKEND_DOCKERFILE DOCKERIGNORE_FILE
39
- unset DEPLOY_CONFIG_DIR
40
- unset COMPOSE_SOURCE NGINX_SOURCE
58
+ unset DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA
59
+ unset PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
41
60
  }
42
61
 
43
- is_deployment_key() {
62
+ is_configuration_key() {
44
63
  case "$1" in
45
- FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|\
46
- PROJECT_NAME|PROJECT_HTTP_PORT|DOCKER_BIND_IP|\
47
- FRONTEND_BLUE_PORT|FRONTEND_GREEN_PORT|BACKEND_BLUE_PORT|BACKEND_GREEN_PORT|\
48
- FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE|\
49
- FRONTEND_DOCKERFILE|BACKEND_DOCKERFILE|DOCKERIGNORE_FILE|\
50
- DEPLOY_CONFIG_DIR|COMPOSE_SOURCE|NGINX_SOURCE)
64
+ FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE)
51
65
  return 0 ;;
52
66
  *) return 1 ;;
53
67
  esac
54
68
  }
55
69
 
56
- is_legacy_configuration_key() {
70
+ is_removed_key() {
57
71
  case "$1" in
58
- FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|DATABASE_MIGRATION_MODE|MIGRATIONS_SOURCE|MIGRATION_RUNNER_SOURCE|*_HEALTH_URL|*_VERSION_URL)
72
+ PROJECT_HTTP_PORT|FRONTEND_BLUE_PORT|FRONTEND_GREEN_PORT|BACKEND_BLUE_PORT|BACKEND_GREEN_PORT|SERVER_PROJECTS_ROOT|NGINX_SOURCE|LOG_DIR|HOST_LOG_DIR|FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|DATABASE_MIGRATION_MODE|MIGRATIONS_SOURCE|MIGRATION_RUNNER_SOURCE|RELEASE_VERSION|*_HEALTH_URL|*_VERSION_URL)
59
73
  return 0 ;;
60
74
  *) return 1 ;;
61
75
  esac
62
76
  }
63
77
 
64
- validate_env_permissions() {
65
- local file="$1" mode
66
- if stat -c '%a' "$file" >/dev/null 2>&1; then
67
- mode="$(stat -c '%a' "$file")"
68
- else
69
- mode="$(stat -f '%Lp' "$file")"
70
- fi
71
- [[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
72
- }
73
-
74
78
  load_dotenv() {
75
- local file="$1" raw line key value first last
79
+ local file="$1" raw line key value first last loaded
76
80
  local -a loaded_keys=()
77
- [[ -r "$file" ]] || fail "required root environment file is missing: $file"
81
+ [[ -r "$file" && -f "$file" && ! -L "$file" ]] || fail "required root environment file is missing or unsafe: $file"
78
82
  validate_env_permissions "$file"
79
83
  reset_configuration
80
84
  while IFS= read -r raw || [[ -n "$raw" ]]; do
81
85
  line="${raw%$'\r'}"
82
86
  [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
83
- [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] ||
84
- fail "invalid .env line; expected KEY=VALUE"
87
+ [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || fail "invalid .env line; expected KEY=VALUE"
85
88
  key="${BASH_REMATCH[1]}"
86
89
  value="${BASH_REMATCH[2]}"
87
- [[ "$key" != LOG_DIR && "$key" != HOST_LOG_DIR ]] || fail "root .env must not configure log directories"
88
- ! is_legacy_configuration_key "$key" || fail "legacy duplicate environment key is forbidden: $key"
89
- for loaded_key in "${loaded_keys[@]:-}"; do
90
- [[ "$loaded_key" != "$key" ]] || fail "duplicate .env key: $key"
91
- done
90
+ ! is_removed_key "$key" || fail "removed deployment configuration is forbidden: $key"
91
+ [[ "$value" != *'$('* && "$value" != *'`'* ]] || fail "executable syntax is forbidden in .env: $key"
92
+ for loaded in "${loaded_keys[@]:-}"; do [[ "$loaded" != "$key" ]] || fail "duplicate .env key: $key"; done
92
93
  loaded_keys+=("$key")
93
94
  if [[ ${#value} -ge 2 ]]; then
94
95
  first="${value:0:1}"
@@ -98,224 +99,33 @@ load_dotenv() {
98
99
  value="${value:1:${#value}-2}"
99
100
  fi
100
101
  fi
101
- if is_deployment_key "$key"; then
102
- printf -v "$key" '%s' "$value"
103
- export "$key"
104
- fi
102
+ if is_configuration_key "$key"; then printf -v "$key" '%s' "$value"; fi
105
103
  done < "$file"
106
104
  }
107
105
 
108
106
  validate_configuration() {
109
- local name port seen=" "
110
- local -a required=(
111
- FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
112
- PROJECT_NAME PROJECT_HTTP_PORT DOCKER_BIND_IP
113
- FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT
114
- FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
115
- )
116
- for name in "${required[@]}"; do
117
- [[ -n "${!name:-}" ]] || fail "$name is required in root .env"
107
+ local key value
108
+ for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE; do
109
+ value="${!key:-}"
110
+ [[ -n "$value" ]] || fail "required configuration is missing: $key"
118
111
  done
119
- [[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] ||
120
- fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
121
- [[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
112
+ [[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] || fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
122
113
  [[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
123
114
  [[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
124
- for name in PROJECT_HTTP_PORT FRONTEND_PORT BACKEND_PORT \
125
- FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT; do
126
- assert_port "$name" "${!name}"
127
- done
128
- for name in PROJECT_HTTP_PORT FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT; do
129
- port="${!name}"
130
- [[ "$seen" != *" $port "* ]] || fail "host ports must be unique; duplicate value: $port"
131
- seen+="$port "
132
- done
133
- }
134
-
135
- probe_host() {
136
- case "$1" in
137
- 0.0.0.0) printf '127.0.0.1\n' ;;
138
- ::) printf '[::1]\n' ;;
139
- *:*) printf '[%s]\n' "$1" ;;
140
- *) printf '%s\n' "$1" ;;
141
- esac
142
- }
143
-
144
- endpoint_url() {
145
- local host
146
- host="$(probe_host "$1")"
147
- printf 'http://%s:%s%s\n' "$host" "$2" "$3"
148
- }
149
-
150
- resolve_project_path() {
151
- local project_root="$1" candidate="$2"
152
- if [[ "$candidate" = /* ]]; then
153
- printf '%s\n' "$candidate"
154
- else
155
- printf '%s/%s\n' "$project_root" "$candidate"
156
- fi
157
- }
158
-
159
- select_checksum_tool() {
160
- if command -v sha256sum >/dev/null 2>&1; then
161
- CHECKSUM_TOOL=sha256sum
162
- elif command -v shasum >/dev/null 2>&1; then
163
- CHECKSUM_TOOL=shasum
164
- else
165
- fail "sha256sum or shasum is required"
166
- fi
167
- }
168
-
169
- checksum_value() {
170
- local file="$1"
171
- if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then
172
- sha256sum "$file" | awk '{print $1}'
173
- else
174
- shasum -a 256 "$file" | awk '{print $1}'
175
- fi
176
- }
177
-
178
- release_ledger() {
179
- node --input-type=module - "$@" <<'NODE'
180
- import fs from 'node:fs';
181
-
182
- const [mode, agentsPath, value, timestamp, outputPath] = process.argv.slice(2);
183
- const heading = '## 发布记录';
184
- const header = '| 版本号 | 构建时间 |';
185
- const separator = '|---|---|';
186
- const versionPattern = /^(\d{4})(\d{2})(\d{2})-(\d{3})$/;
187
- const rowPattern = /^\| (\d{8}-\d{3}) \| (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}) \|$/;
188
-
189
- function fail(message) { throw new Error(message); }
190
- function validDate(text) {
191
- const year = Number(text.slice(0, 4));
192
- const month = Number(text.slice(4, 6));
193
- const day = Number(text.slice(6, 8));
194
- const date = new Date(Date.UTC(year, month - 1, day));
195
- return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
196
- }
197
- function inspect(text) {
198
- const lines = text.replace(/\r\n/g, '\n').split('\n');
199
- const headings = lines.flatMap((line, index) => line === heading ? [index] : []);
200
- if (headings.length > 1) fail('AGENTS.md contains multiple release record sections');
201
- if (!headings.length) return { lines, headingIndex: -1, endIndex: lines.length, versions: [] };
202
- const headingIndex = headings[0];
203
- let endIndex = lines.length;
204
- for (let index = headingIndex + 1; index < lines.length; index += 1) {
205
- if (/^## /.test(lines[index])) { endIndex = index; break; }
206
- }
207
- const section = lines.slice(headingIndex + 1, endIndex);
208
- while (section[0] === '') section.shift();
209
- while (section.at(-1) === '') section.pop();
210
- if (section[0] !== header || section[1] !== separator) fail('AGENTS.md release record table is invalid');
211
- const unique = new Set();
212
- const versions = [];
213
- for (const row of section.slice(2)) {
214
- const match = row.match(rowPattern);
215
- if (!match || !validDate(match[1].slice(0, 8))) fail(`invalid AGENTS.md release record row: ${row}`);
216
- if (unique.has(match[1])) fail(`duplicate release version: ${match[1]}`);
217
- unique.add(match[1]);
218
- versions.push(match[1]);
219
- }
220
- return { lines, headingIndex, endIndex, versions };
221
- }
222
- function nextVersion(ledger, date) {
223
- if (!/^\d{8}$/.test(date) || !validDate(date)) fail(`invalid current release date: ${date}`);
224
- const maximum = ledger.versions
225
- .filter((item) => item.startsWith(`${date}-`))
226
- .reduce((max, item) => Math.max(max, Number(item.slice(-3))), 0);
227
- if (maximum >= 999) fail(`release sequence exhausted for ${date}`);
228
- return `${date}-${String(maximum + 1).padStart(3, '0')}`;
229
- }
230
-
231
- const original = fs.readFileSync(agentsPath, 'utf8');
232
- const ledger = inspect(original);
233
- if (mode === 'next') {
234
- process.stdout.write(`${nextVersion(ledger, value)}\n`);
235
- } else if (mode === 'prepare') {
236
- if (!versionPattern.test(value) || !validDate(value.slice(0, 8))) fail(`invalid release version: ${value}`);
237
- if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}$/.test(timestamp)) fail('invalid build time');
238
- const expected = nextVersion(ledger, value.slice(0, 8));
239
- if (value !== expected) fail(`release ledger changed: expected ${expected}, got ${value}`);
240
- const row = `| ${value} | ${timestamp} |`;
241
- let updated;
242
- if (ledger.headingIndex === -1) {
243
- const gap = original.length === 0 ? '' : original.endsWith('\n') ? '\n' : '\n\n';
244
- updated = `${original}${gap}${heading}\n\n${header}\n${separator}\n${row}\n`;
245
- } else {
246
- const lines = [...ledger.lines];
247
- let insertAt = ledger.endIndex;
248
- while (insertAt > ledger.headingIndex + 1 && lines[insertAt - 1] === '') insertAt -= 1;
249
- lines.splice(insertAt, 0, row);
250
- updated = lines.join('\n');
251
- }
252
- fs.writeFileSync(outputPath, updated, { mode: fs.statSync(agentsPath).mode });
253
- } else {
254
- fail(`unknown release ledger mode: ${mode}`);
255
- }
256
- NODE
257
- }
258
-
259
- create_manifest() {
260
- local version_root="$1" file relative checksum
261
- (
262
- cd "$version_root"
263
- find . -type f ! -name manifest.sha256 -print | LC_ALL=C sort |
264
- while IFS= read -r file; do
265
- relative="${file#./}"
266
- checksum="$(checksum_value "$file")"
267
- printf '%s %s\n' "$checksum" "$relative"
268
- done > manifest.sha256
269
- )
270
- }
271
-
272
- verify_manifest() {
273
- local version_root="$1" manifest="$1/manifest.sha256"
274
- local actual_paths declared_paths result=0 line checksum relative actual
275
- [[ -s "$manifest" ]] || fail "missing or empty manifest.sha256"
276
- actual_paths="$(mktemp)"
277
- declared_paths="$(mktemp)"
278
- if (
279
- cd "$version_root"
280
- find . -type f ! -name manifest.sha256 -print | sed 's|^\./||' | LC_ALL=C sort > "$actual_paths"
281
- while IFS= read -r line; do
282
- checksum="${line%% *}"
283
- relative="${line#* }"
284
- [[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != ../* && "$relative" != *"/../"* ]] || exit 11
285
- printf '%s\n' "$relative" >> "$declared_paths"
286
- [[ -f "$relative" && ! -L "$relative" ]] || exit 12
287
- actual="$(checksum_value "$relative")"
288
- [[ "$actual" == "$checksum" ]] || exit 13
289
- done < manifest.sha256
290
- LC_ALL=C sort -o "$declared_paths" "$declared_paths"
291
- [[ "$(uniq -d "$declared_paths" | wc -l | tr -d ' ')" == 0 ]] || exit 14
292
- cmp -s "$actual_paths" "$declared_paths"
293
- ); then
294
- result=0
295
- else
296
- result=$?
297
- fi
298
- rm -f -- "$actual_paths" "$declared_paths"
299
- [[ "$result" -eq 0 ]] || fail "release manifest validation failed"
300
- }
301
-
302
- reject_version_secrets() {
303
- local root="$1" file base
304
- if find "$root" -type l -print -quit | grep -q .; then fail "symbolic links must not be packaged"; fi
305
- while IFS= read -r file; do
306
- base="${file##*/}"
307
- case "$base" in
308
- .env|.env.*|*.pem|*.key|id_rsa|id_ed25519|credentials|credentials.*)
309
- fail "secret file must not enter the version directory: ${file#"$root"/}" ;;
310
- esac
311
- done < <(find "$root" -type f -print)
115
+ [[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
116
+ [[ "$DB_URL" =~ ^[A-Za-z0-9._:/?@\&=%-]+$ ]] || fail "DB_URL contains unsafe characters"
117
+ [[ "$FRONTEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "FRONTEND_BASE_IMAGE is not a valid image reference"
118
+ [[ "$BACKEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "BACKEND_BASE_IMAGE is not a valid image reference"
119
+ assert_port FRONTEND_PORT "$FRONTEND_PORT"
120
+ assert_port BACKEND_PORT "$BACKEND_PORT"
121
+ assert_port DB_PORT "$DB_PORT"
122
+ [[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must differ"
312
123
  }
313
124
 
314
125
  validate_dockerignore() {
315
126
  local file="$1" pattern line
316
- [[ -f "$file" ]] || fail "required Docker ignore file is missing: $file"
317
- for pattern in '.env' '.env.*' '*.pem' '*.key' id_rsa id_ed25519 credentials 'credentials.*' \
318
- 'deploy/' 'logs/' '.git/' 'node_modules/' 'venv/' '.pnpm-store/' '.deploy-build.lock/' '.deploy-build.*'; do
127
+ [[ -f "$file" && ! -L "$file" ]] || fail "required Docker ignore file is missing: $file"
128
+ for pattern in '.env' '.env.*' '*.pem' '*.key' id_rsa id_ed25519 credentials 'credentials.*' 'deploy/' 'logs/' '.git/' 'node_modules/' 'venv/' '.pnpm-store/' '.deploy-build.lock/' '.deploy-build.*/'; do
319
129
  grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"
320
130
  done
321
131
  while IFS= read -r line || [[ -n "$line" ]]; do
@@ -323,441 +133,191 @@ validate_dockerignore() {
323
133
  done < "$file"
324
134
  }
325
135
 
326
- validate_local_package() {
327
- local output_root="$1" version="$2" version_root="$1/$2"
328
- [[ -x "$output_root/deploy.sh" ]] || fail "generated deploy.sh is missing or not executable"
329
- cmp -s "$SCRIPT_PATH" "$output_root/deploy.sh" || fail "generated deploy.sh differs from its source"
330
- cmp -s "$SCRIPT_DIR/.env" "$output_root/.env" || fail "generated root .env differs from project .env"
331
- [[ "$(cat "$output_root/release.env")" == "RELEASE_VERSION=$version" ]] || fail "invalid release.env"
332
- [[ -f "$version_root/frontend-image.tar" && -f "$version_root/backend-image.tar" ]] || fail "dual image archives are required"
333
- [[ -f "$version_root/docker/docker-compose.blue-green.yml" ]] || fail "missing compose file"
334
- [[ -f "$version_root/nginx/site.conf" ]] || fail "missing Nginx site template"
335
- [[ "$(grep -Fc '../../logs:/app/logs' "$version_root/docker/docker-compose.blue-green.yml")" == 4 ]] || fail "all four services must mount root logs"
336
- grep -Fq 'frontend-blue:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing frontend-blue"
337
- grep -Fq 'backend-blue:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing backend-blue"
338
- grep -Fq 'frontend-green:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing frontend-green"
339
- grep -Fq 'backend-green:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing backend-green"
340
- grep -Fq '__PROJECT_HTTP_PORT__' "$version_root/nginx/site.conf" || fail "missing project port placeholder"
341
- [[ ! -e "$version_root/database" ]] || fail "release package must not contain database migration files"
342
- [[ "$(find "$version_root" -type f ! -name manifest.sha256 | wc -l | tr -d ' ')" -ge 4 ]] || fail "release package is incomplete"
343
- reject_version_secrets "$version_root"
344
- verify_manifest "$version_root"
136
+ create_manifest() {
137
+ local root="$1" file checksum
138
+ : > "$root/manifest.sha256"
139
+ for file in .env backend-image.tar deploy.sh docker-compose.yml frontend-image.tar; do
140
+ checksum="$(checksum_value "$root/$file")"
141
+ printf '%s %s\n' "$checksum" "$file" >> "$root/manifest.sha256"
142
+ done
143
+ }
144
+
145
+ verify_manifest() {
146
+ local root="$1" line checksum relative actual count=0
147
+ [[ -s "$root/manifest.sha256" && ! -L "$root/manifest.sha256" ]] || fail "missing or unsafe manifest.sha256"
148
+ while IFS= read -r line; do
149
+ checksum="${line%% *}"
150
+ relative="${line#* }"
151
+ [[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != *'/'* ]] || fail "invalid manifest entry"
152
+ [[ -f "$root/$relative" && ! -L "$root/$relative" ]] || fail "manifest path is missing: $relative"
153
+ actual="$(checksum_value "$root/$relative")"
154
+ [[ "$actual" == "$checksum" ]] || fail "checksum mismatch: $relative"
155
+ count=$((count + 1))
156
+ done < "$root/manifest.sha256"
157
+ [[ "$count" == 5 ]] || fail "manifest must contain exactly five entries"
158
+ [[ "$(sed -n 's/^[0-9a-f]\{64\} //p' "$root/manifest.sha256" | LC_ALL=C sort | tr '\n' '|')" == '.env|backend-image.tar|deploy.sh|docker-compose.yml|frontend-image.tar|' ]] || fail "manifest coverage is incomplete"
159
+ }
160
+
161
+ validate_package_layout() {
162
+ local root="$1" entry name
163
+ for name in .env backend-image.tar deploy.sh docker-compose.yml frontend-image.tar manifest.sha256; do
164
+ [[ -f "$root/$name" && ! -L "$root/$name" ]] || fail "required package file is missing or unsafe: $name"
165
+ done
166
+ while IFS= read -r entry; do
167
+ name="${entry##*/}"
168
+ case "$name" in
169
+ .env|backend-image.tar|deploy.sh|docker-compose.yml|frontend-image.tar|manifest.sha256) ;;
170
+ logs) [[ -d "$entry" && ! -L "$entry" ]] || fail "logs must be a regular directory" ;;
171
+ *) fail "unexpected package entry: $name" ;;
172
+ esac
173
+ done < <(find "$root" -mindepth 1 -maxdepth 1 -print | LC_ALL=C sort)
174
+ }
175
+
176
+ archive_has_tag() {
177
+ local archive="$1" tag="$2" manifest compact count
178
+ [[ -f "$archive" && ! -L "$archive" ]] || fail "image archive is missing: $archive"
179
+ manifest="$(tar -xOf "$archive" manifest.json 2>/dev/null)" || fail "image archive is missing manifest.json: $archive"
180
+ compact="$(tr -d '[:space:]' <<< "$manifest")"
181
+ count="$(grep -o '"RepoTags"' <<< "$compact" | wc -l | tr -d ' ')"
182
+ [[ "$count" == 1 && "$compact" == *"\"RepoTags\":[\"$tag\"]"* ]] || fail "image archive must contain exactly the expected tag: $tag"
345
183
  }
346
184
 
347
185
  acquire_build_lock() {
348
186
  BUILD_LOCK="$SCRIPT_DIR/.deploy-build.lock"
349
- mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or stale lock exists: $BUILD_LOCK"
187
+ mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or a stale lock exists: $BUILD_LOCK"
350
188
  }
351
189
 
352
- release_build_lock() {
353
- [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]] && rmdir "$BUILD_LOCK"
190
+ cleanup_build() {
191
+ local status=$?
192
+ if [[ -n "${STAGING_ROOT:-}" && -d "$STAGING_ROOT" ]]; then rm -rf -- "$STAGING_ROOT"; fi
193
+ if [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]]; then rmdir "$BUILD_LOCK" 2>/dev/null || true; fi
194
+ return "$status"
354
195
  }
355
196
 
356
- build_release() {
357
- local project_root="$SCRIPT_DIR" agents_file="$SCRIPT_DIR/AGENTS.md"
358
- local today version raw_time build_time frontend_image backend_image
359
- local frontend_dockerfile backend_dockerfile dockerignore_file deploy_config_dir compose_source nginx_source
360
- local staging_parent output_root version_root agents_prepared old_deploy
197
+ ensure_deploy_output_scope() {
198
+ local deploy_dir="$1" archive_name="$2" entry
199
+ mkdir -p "$deploy_dir"
200
+ [[ -d "$deploy_dir" && ! -L "$deploy_dir" ]] || fail "deploy output must be a regular directory"
201
+ while IFS= read -r entry; do
202
+ [[ "${entry##*/}" == "$archive_name" && -f "$entry" && ! -L "$entry" ]] || fail "deploy directory contains unexpected entry: ${entry##*/}"
203
+ done < <(find "$deploy_dir" -mindepth 1 -maxdepth 1 -print)
204
+ }
361
205
 
362
- acquire_build_lock
363
- staging_parent=
364
- agents_prepared=
365
- old_deploy=
366
- trap 'rm -rf -- "${staging_parent:-}" "${old_deploy:-}"; rm -f -- "${agents_prepared:-}"; release_build_lock' EXIT
367
- [[ -r "$agents_file" && -w "$agents_file" ]] || fail "project AGENTS.md must be readable and writable"
206
+ build_release() {
207
+ local project_root="$SCRIPT_DIR" frontend_dockerfile backend_dockerfile compose_source dockerignore_file
208
+ local frontend_image backend_image package_root deploy_dir archive_name candidate
209
+ for command in docker tar install find sort grep sed awk mktemp; do require_command "$command"; done
210
+ select_checksum_tool
368
211
  load_dotenv "$project_root/.env"
369
212
  validate_configuration
370
-
371
- frontend_dockerfile="$(resolve_project_path "$project_root" "${FRONTEND_DOCKERFILE:-src/frontend/Dockerfile}")"
372
- backend_dockerfile="$(resolve_project_path "$project_root" "${BACKEND_DOCKERFILE:-src/backend/Dockerfile}")"
373
- dockerignore_file="$(resolve_project_path "$project_root" "${DOCKERIGNORE_FILE:-.dockerignore}")"
374
- deploy_config_dir="$(resolve_project_path "$project_root" "${DEPLOY_CONFIG_DIR:-scripts/deployment}")"
375
- compose_source="$(resolve_project_path "$project_root" "${COMPOSE_SOURCE:-${deploy_config_dir#"$project_root"/}/docker-compose.blue-green.yml}")"
376
- nginx_source="$(resolve_project_path "$project_root" "${NGINX_SOURCE:-${deploy_config_dir#"$project_root"/}/nginx-site.conf}")"
377
-
378
- for command in node pnpm docker tar install find sort cmp awk sed grep uniq; do require_command "$command"; done
379
- select_checksum_tool
380
- [[ -f "$frontend_dockerfile" && -f "$backend_dockerfile" ]] || fail "frontend and backend Dockerfiles are required"
381
- [[ -f "$compose_source" && -f "$nginx_source" ]] || fail "deployment Compose and Nginx sources are required"
213
+ frontend_dockerfile="$project_root/src/frontend/Dockerfile"
214
+ backend_dockerfile="$project_root/src/backend/Dockerfile"
215
+ compose_source="$project_root/scripts/deployment/docker-compose.yml"
216
+ dockerignore_file="$project_root/.dockerignore"
217
+ for file in "$frontend_dockerfile" "$backend_dockerfile" "$compose_source"; do
218
+ [[ -f "$file" && ! -L "$file" ]] || fail "required generated deployment file is missing: ${file#"$project_root"/}"
219
+ done
382
220
  validate_dockerignore "$dockerignore_file"
383
-
384
- today="$(date +%Y%m%d)"
385
- version="$(release_ledger next "$agents_file" "$today")"
386
- assert_version "$version"
387
- frontend_image="${PROJECT_NAME}_frontend:$version"
388
- backend_image="${PROJECT_NAME}_backend:$version"
389
-
390
- echo "release gate: pnpm test"; pnpm test
391
- echo "release gate: pnpm lint"; pnpm lint
392
- echo "release gate: pnpm typecheck"; pnpm typecheck
393
- echo "release gate: pnpm build"; RELEASE_VERSION="$version" pnpm build
394
-
395
- echo "release gate: frontend docker build"
396
- docker build --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "RELEASE_VERSION=$version" \
397
- --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
398
- echo "release gate: backend docker build"
399
- docker build --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "RELEASE_VERSION=$version" \
400
- --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
401
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$frontend_image")" == "$version" ]] || fail "frontend image version label mismatch"
402
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$backend_image")" == "$version" ]] || fail "backend image version label mismatch"
403
-
404
- staging_parent="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
405
- output_root="$staging_parent/deploy"
406
- version_root="$output_root/$version"
407
- agents_prepared="$project_root/.AGENTS.md.release.$$.tmp"
408
- mkdir -p "$version_root/docker" "$version_root/nginx"
409
- install -m 0755 "$SCRIPT_PATH" "$output_root/deploy.sh"
410
- install -m 0600 "$project_root/.env" "$output_root/.env"
411
- printf 'RELEASE_VERSION=%s\n' "$version" > "$output_root/release.env"
412
- docker save -o "$version_root/frontend-image.tar" "$frontend_image"
413
- docker save -o "$version_root/backend-image.tar" "$backend_image"
414
- install -m 0644 "$compose_source" "$version_root/docker/docker-compose.blue-green.yml"
415
- install -m 0644 "$nginx_source" "$version_root/nginx/site.conf"
416
- cmp -s "$compose_source" "$version_root/docker/docker-compose.blue-green.yml" || fail "generated Compose is not deterministic"
417
- cmp -s "$nginx_source" "$version_root/nginx/site.conf" || fail "generated Nginx config is not deterministic"
418
- create_manifest "$version_root"
419
- validate_local_package "$output_root" "$version"
420
-
421
- raw_time="$(date '+%Y-%m-%d %H:%M:%S %z')"
422
- build_time="${raw_time%?????}${raw_time: -5:3}:${raw_time: -2}"
423
- release_ledger prepare "$agents_file" "$version" "$build_time" "$agents_prepared"
424
-
425
- if [[ -e "$project_root/deploy" ]]; then
426
- old_deploy="$project_root/.deploy.previous.$$"
427
- [[ ! -e "$old_deploy" ]] || fail "temporary deploy replacement path already exists"
428
- mv "$project_root/deploy" "$old_deploy"
429
- fi
430
- if ! mv "$output_root" "$project_root/deploy"; then
431
- [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"
432
- fail "failed to publish deploy directory"
433
- fi
434
- if ! mv "$agents_prepared" "$agents_file"; then
435
- rm -rf -- "$project_root/deploy"
436
- [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"
437
- fail "failed to write AGENTS.md release record"
438
- fi
439
- [[ -n "$old_deploy" ]] && rm -rf -- "$old_deploy" && old_deploy=
440
- rm -rf -- "$staging_parent"; staging_parent=
441
- release_build_lock; BUILD_LOCK=
221
+ docker image inspect "$FRONTEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local frontend base image is missing: $FRONTEND_BASE_IMAGE"
222
+ docker image inspect "$BACKEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local backend base image is missing: $BACKEND_BASE_IMAGE"
223
+ frontend_image="${PROJECT_NAME}_frontend:latest"
224
+ backend_image="${PROJECT_NAME}_backend:latest"
225
+ acquire_build_lock
226
+ trap cleanup_build EXIT
227
+ docker build --pull=false --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
228
+ docker build --pull=false --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
229
+ docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "frontend image build did not create $frontend_image"
230
+ docker image inspect "$backend_image" >/dev/null 2>&1 || fail "backend image build did not create $backend_image"
231
+ STAGING_ROOT="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
232
+ package_root="$STAGING_ROOT/package"
233
+ mkdir -p "$package_root"
234
+ install -m 0600 "$project_root/.env" "$package_root/.env"
235
+ install -m 0755 "$SCRIPT_PATH" "$package_root/deploy.sh"
236
+ install -m 0644 "$compose_source" "$package_root/docker-compose.yml"
237
+ docker save -o "$package_root/frontend-image.tar" "$frontend_image"
238
+ docker save -o "$package_root/backend-image.tar" "$backend_image"
239
+ archive_has_tag "$package_root/frontend-image.tar" "$frontend_image"
240
+ archive_has_tag "$package_root/backend-image.tar" "$backend_image"
241
+ create_manifest "$package_root"
242
+ validate_package_layout "$package_root"
243
+ verify_manifest "$package_root"
244
+ deploy_dir="$project_root/deploy"
245
+ archive_name="${PROJECT_NAME}-docker.tar.gz"
246
+ ensure_deploy_output_scope "$deploy_dir" "$archive_name"
247
+ candidate="$STAGING_ROOT/$archive_name"
248
+ tar -czf "$candidate" -C "$package_root" .
249
+ tar -tzf "$candidate" >/dev/null || fail "generated delivery archive is invalid"
250
+ mv -f "$candidate" "$deploy_dir/$archive_name"
251
+ rm -rf -- "$STAGING_ROOT"
252
+ STAGING_ROOT=
253
+ rmdir "$BUILD_LOCK"
254
+ BUILD_LOCK=
442
255
  trap - EXIT
443
- echo "release package created: deploy/$version"
444
- return 0
445
- }
446
-
447
- read_release_pointer() {
448
- local file="$DEPLOY_ROOT/release.env" line
449
- [[ -f "$file" && "$(wc -l < "$file" | tr -d ' ')" == 1 ]] || fail "release.env must contain exactly one line"
450
- line="$(cat "$file")"
451
- [[ "$line" =~ ^RELEASE_VERSION=([0-9]{8}-[0-9]{3})$ ]] || fail "invalid release.env"
452
- RELEASE_VERSION="${BASH_REMATCH[1]}"
453
- assert_version "$RELEASE_VERSION"
256
+ echo "delivery archive created: deploy/$archive_name"
454
257
  }
455
258
 
456
259
  initialize_server_context() {
457
260
  DEPLOY_ROOT="$SCRIPT_DIR"
261
+ for command in docker tar find sort grep sed awk; do require_command "$command"; done
262
+ select_checksum_tool
458
263
  load_dotenv "$DEPLOY_ROOT/.env"
459
264
  validate_configuration
460
- STATE_DIR="$DEPLOY_ROOT/state"
461
- ACTIVE_STATE="$STATE_DIR/active.env"
462
- PREVIOUS_STATE="$STATE_DIR/previous.env"
463
- NGINX_DIR="$DEPLOY_ROOT/nginx"
464
- NGINX_SITE_FILE="$NGINX_DIR/site.conf"
465
- UPSTREAM_FILE="$NGINX_DIR/active-upstreams.conf"
466
- FRONTEND_UPSTREAM_NAME="${PROJECT_NAME}_frontend_active"
467
- BACKEND_UPSTREAM_NAME="${PROJECT_NAME}_backend_active"
468
- FRONTEND_IMAGE_REPOSITORY="${PROJECT_NAME}_frontend"
469
- BACKEND_IMAGE_REPOSITORY="${PROJECT_NAME}_backend"
470
- for command in docker curl nginx tar find sort cmp awk sed grep uniq flock; do require_command "$command"; done
471
- select_checksum_tool
472
- }
473
-
474
- read_state_file() {
475
- local file="$1" line key value color= version= count=0
476
- [[ -f "$file" ]] || return 1
477
- while IFS= read -r line || [[ -n "$line" ]]; do
478
- [[ "$line" =~ ^(ACTIVE_COLOR|ACTIVE_VERSION)=([A-Za-z0-9_-]+)$ ]] || fail "invalid state file: $file"
479
- key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"; count=$((count + 1))
480
- if [[ "$key" == ACTIVE_COLOR ]]; then [[ -z "$color" ]] || fail "duplicate state color"; color="$value"; else [[ -z "$version" ]] || fail "duplicate state version"; version="$value"; fi
481
- done < "$file"
482
- [[ "$count" -eq 2 && ( "$color" == blue || "$color" == green ) ]] || fail "incomplete state file: $file"
483
- assert_version "$version"
484
- STATE_COLOR="$color"; STATE_VERSION="$version"
485
- }
486
-
487
- read_active() {
488
- ACTIVE_COLOR=; ACTIVE_VERSION=
489
- if read_state_file "$ACTIVE_STATE"; then ACTIVE_COLOR="$STATE_COLOR"; ACTIVE_VERSION="$STATE_VERSION"; fi
490
- }
491
-
492
- read_previous() {
493
- PREVIOUS_COLOR=; PREVIOUS_VERSION=
494
- if read_state_file "$PREVIOUS_STATE"; then PREVIOUS_COLOR="$STATE_COLOR"; PREVIOUS_VERSION="$STATE_VERSION"; fi
265
+ [[ -f "$DEPLOY_ROOT/docker-compose.yml" && ! -L "$DEPLOY_ROOT/docker-compose.yml" ]] || fail "docker-compose.yml is missing"
266
+ mkdir -p "$DEPLOY_ROOT/logs"
495
267
  }
496
268
 
497
- write_state_candidate() {
498
- local file="$1" color="$2" version="$3"
499
- printf 'ACTIVE_COLOR=%s\nACTIVE_VERSION=%s\n' "$color" "$version" > "$file"
269
+ compose() {
270
+ docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$DEPLOY_ROOT/docker-compose.yml" "$@"
500
271
  }
501
272
 
502
- compose_file_for() {
503
- local version="$1"
504
- assert_version "$version"
505
- printf '%s/%s/docker/docker-compose.blue-green.yml\n' "$DEPLOY_ROOT" "$version"
273
+ record_operation() {
274
+ local result="$1" timestamp
275
+ [[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
276
+ timestamp="$(date '+%Y-%m-%d %H:%M:%S %z')"
277
+ printf '%s operation=%s result=%s\n' "$timestamp" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
506
278
  }
507
279
 
508
- image_names_for() {
509
- local version="$1"
510
- assert_version "$version"
511
- FRONTEND_IMAGE="${FRONTEND_IMAGE_REPOSITORY}:$version"
512
- BACKEND_IMAGE="${BACKEND_IMAGE_REPOSITORY}:$version"
280
+ deploy_stack() {
281
+ local frontend_image="${PROJECT_NAME}_frontend:latest" backend_image="${PROJECT_NAME}_backend:latest"
282
+ validate_package_layout "$DEPLOY_ROOT"
283
+ verify_manifest "$DEPLOY_ROOT"
284
+ archive_has_tag "$DEPLOY_ROOT/frontend-image.tar" "$frontend_image"
285
+ archive_has_tag "$DEPLOY_ROOT/backend-image.tar" "$backend_image"
286
+ docker load -i "$DEPLOY_ROOT/frontend-image.tar" >/dev/null
287
+ docker load -i "$DEPLOY_ROOT/backend-image.tar" >/dev/null
288
+ docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "loaded frontend image is missing: $frontend_image"
289
+ docker image inspect "$backend_image" >/dev/null 2>&1 || fail "loaded backend image is missing: $backend_image"
290
+ compose up -d --force-recreate --no-build --pull never --wait frontend backend
513
291
  }
514
292
 
515
- validate_uploaded_package() {
516
- local package_root="$1"
517
- [[ -f "$package_root/frontend-image.tar" && -f "$package_root/backend-image.tar" ]] || fail "dual image archives are missing"
518
- [[ -f "$package_root/docker/docker-compose.blue-green.yml" && -f "$package_root/nginx/site.conf" ]] || fail "release configuration is incomplete"
519
- [[ ! -e "$package_root/database" ]] || fail "release package must not contain database migration files"
520
- reject_version_secrets "$package_root"
521
- verify_manifest "$package_root"
522
- tar -tf "$package_root/frontend-image.tar" >/dev/null || fail "invalid frontend image archive"
523
- tar -tf "$package_root/backend-image.tar" >/dev/null || fail "invalid backend image archive"
524
- }
525
-
526
- archive_has_tag() {
527
- local archive="$1" tag="$2" manifest compact repo_tag_count
528
- manifest="$(tar -xOf "$archive" manifest.json 2>/dev/null)" || fail "image archive is missing manifest.json: $archive"
529
- compact="$(tr -d '[:space:]' <<< "$manifest")"
530
- repo_tag_count="$(grep -o '"RepoTags"' <<< "$compact" | wc -l | tr -d ' ')"
531
- [[ "$repo_tag_count" == 1 && "$compact" == *"\"RepoTags\":[\"$tag\"]"* ]] ||
532
- fail "image archive must contain exactly one expected tag: $tag"
533
- }
534
-
535
- load_release_images() {
536
- local version="$1" package_root="$DEPLOY_ROOT/$1"
537
- image_names_for "$version"
538
- archive_has_tag "$package_root/frontend-image.tar" "$FRONTEND_IMAGE"
539
- archive_has_tag "$package_root/backend-image.tar" "$BACKEND_IMAGE"
540
- docker load -i "$package_root/frontend-image.tar" >/dev/null
541
- docker load -i "$package_root/backend-image.tar" >/dev/null
542
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$FRONTEND_IMAGE")" == "$version" ]] || fail "frontend loaded image version mismatch"
543
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$BACKEND_IMAGE")" == "$version" ]] || fail "backend loaded image version mismatch"
544
- }
545
-
546
- compose_for() {
547
- local version="$1"; shift
548
- local compose_file
549
- image_names_for "$version"
550
- compose_file="$(compose_file_for "$version")"
551
- [[ -f "$compose_file" ]] || fail "compose file is missing for version $version"
552
- FRONTEND_IMAGE="$FRONTEND_IMAGE" BACKEND_IMAGE="$BACKEND_IMAGE" \
553
- docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$compose_file" "$@"
554
- }
555
-
556
- up_pair() {
557
- local color="$1" version="$2"
558
- compose_for "$version" up -d --force-recreate "frontend-$color" "backend-$color"
559
- }
560
-
561
- verify_pair() {
562
- local color="$1" version="$2" frontend_health frontend_version backend_health backend_version frontend_actual backend_actual
563
- if [[ "$color" == blue ]]; then
564
- frontend_health="$(endpoint_url "$DOCKER_BIND_IP" "$FRONTEND_BLUE_PORT" /health)"; frontend_version="$(endpoint_url "$DOCKER_BIND_IP" "$FRONTEND_BLUE_PORT" /version)"
565
- backend_health="$(endpoint_url "$DOCKER_BIND_IP" "$BACKEND_BLUE_PORT" /health)"; backend_version="$(endpoint_url "$DOCKER_BIND_IP" "$BACKEND_BLUE_PORT" /version)"
566
- else
567
- frontend_health="$(endpoint_url "$DOCKER_BIND_IP" "$FRONTEND_GREEN_PORT" /health)"; frontend_version="$(endpoint_url "$DOCKER_BIND_IP" "$FRONTEND_GREEN_PORT" /version)"
568
- backend_health="$(endpoint_url "$DOCKER_BIND_IP" "$BACKEND_GREEN_PORT" /health)"; backend_version="$(endpoint_url "$DOCKER_BIND_IP" "$BACKEND_GREEN_PORT" /version)"
569
- fi
570
- curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$frontend_health" >/dev/null
571
- curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$backend_health" >/dev/null
572
- frontend_actual="$(curl --fail --silent --show-error "$frontend_version")"
573
- backend_actual="$(curl --fail --silent --show-error "$backend_version")"
574
- [[ "$frontend_actual" == "$version" ]] || fail "frontend version mismatch: expected $version, got $frontend_actual"
575
- [[ "$backend_actual" == "$version" ]] || fail "backend version mismatch: expected $version, got $backend_actual"
576
- }
577
-
578
- render_site_candidate() {
579
- local source="$1" output="$2" escaped_root
580
- for placeholder in __PROJECT_NAME__ __PROJECT_HTTP_PORT__ __DEPLOY_ROOT__ __FRONTEND_UPSTREAM_NAME__ __BACKEND_UPSTREAM_NAME__; do
581
- grep -Fq "$placeholder" "$source" || fail "Nginx template is missing $placeholder"
582
- done
583
- escaped_root="${DEPLOY_ROOT//\\/\\\\}"; escaped_root="${escaped_root//&/\\&}"; escaped_root="${escaped_root//|/\\|}"
584
- sed -e "s|__PROJECT_NAME__|$PROJECT_NAME|g" -e "s|__PROJECT_HTTP_PORT__|$PROJECT_HTTP_PORT|g" \
585
- -e "s|__DEPLOY_ROOT__|$escaped_root|g" -e "s|__FRONTEND_UPSTREAM_NAME__|$FRONTEND_UPSTREAM_NAME|g" \
586
- -e "s|__BACKEND_UPSTREAM_NAME__|$BACKEND_UPSTREAM_NAME|g" "$source" > "$output"
587
- }
588
-
589
- write_upstream_candidate() {
590
- local color="$1" output="$2" frontend_port backend_port
591
- if [[ "$color" == blue ]]; then frontend_port="$FRONTEND_BLUE_PORT"; backend_port="$BACKEND_BLUE_PORT"; else frontend_port="$FRONTEND_GREEN_PORT"; backend_port="$BACKEND_GREEN_PORT"; fi
592
- printf 'upstream %s {\n server %s:%s;\n keepalive 32;\n}\n\nupstream %s {\n server %s:%s;\n keepalive 32;\n}\n' \
593
- "$FRONTEND_UPSTREAM_NAME" "$DOCKER_BIND_IP" "$frontend_port" \
594
- "$BACKEND_UPSTREAM_NAME" "$DOCKER_BIND_IP" "$backend_port" > "$output"
595
- }
596
-
597
- restore_file() {
598
- local backup="$1" target="$2"
599
- if [[ -f "$backup" ]]; then cp -p "$backup" "$target"; else rm -f -- "$target"; fi
600
- }
601
-
602
- commit_traffic_and_state() {
603
- local site_candidate="$1" upstream_candidate="$2" active_candidate="$3" previous_candidate="${4:-}" expected_version="$5"
604
- local transaction public_health public_version_url public_version rollback_failed=0
605
- mkdir -p "$NGINX_DIR" "$STATE_DIR"
606
- transaction="$(mktemp -d "$DEPLOY_ROOT/.nginx-switch.XXXXXX")"
607
- [[ -f "$NGINX_SITE_FILE" ]] && cp -p "$NGINX_SITE_FILE" "$transaction/site.old"
608
- [[ -f "$UPSTREAM_FILE" ]] && cp -p "$UPSTREAM_FILE" "$transaction/upstreams.old"
609
- [[ -f "$ACTIVE_STATE" ]] && cp -p "$ACTIVE_STATE" "$transaction/active.old"
610
- [[ -f "$PREVIOUS_STATE" ]] && cp -p "$PREVIOUS_STATE" "$transaction/previous.old"
611
-
612
- exec 9>/tmp/wdyy-nginx-deploy.lock
613
- flock -x 9
614
- install -m 0644 "$site_candidate" "$NGINX_SITE_FILE"
615
- install -m 0644 "$upstream_candidate" "$UPSTREAM_FILE"
616
- if ! nginx -t; then
617
- restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
618
- restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
619
- rm -rf -- "$transaction"
620
- fail "Nginx candidate validation failed; project configuration restored"
621
- fi
622
- if ! nginx -s reload; then
623
- restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
624
- restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
625
- nginx -t && nginx -s reload || rollback_failed=1
626
- rm -rf -- "$transaction"
627
- [[ "$rollback_failed" -eq 0 ]] || fail "Nginx reload failed and old project configuration could not be restored"
628
- fail "Nginx reload failed; old project configuration restored"
629
- fi
630
-
631
- public_health="$(endpoint_url "$DOCKER_BIND_IP" "$PROJECT_HTTP_PORT" /health)"
632
- public_version_url="$(endpoint_url "$DOCKER_BIND_IP" "$PROJECT_HTTP_PORT" /api/version)"
633
- if ! curl --fail --silent --show-error --retry 6 --retry-delay 1 --retry-connrefused "$public_health" >/dev/null ||
634
- ! public_version="$(curl --fail --silent --show-error "$public_version_url")" ||
635
- [[ "$public_version" != "$expected_version" ]]; then
636
- restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
637
- restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
638
- nginx -t && nginx -s reload || rollback_failed=1
639
- rm -rf -- "$transaction"
640
- [[ "$rollback_failed" -eq 0 ]] || fail "public endpoint verification failed and old project traffic could not be restored"
641
- fail "public endpoint verification failed; old project traffic restored"
642
- fi
643
-
644
- if ! install -m 0644 "$active_candidate" "$ACTIVE_STATE" || { [[ -n "$previous_candidate" ]] && ! install -m 0644 "$previous_candidate" "$PREVIOUS_STATE"; }; then
645
- restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
646
- restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
647
- restore_file "$transaction/active.old" "$ACTIVE_STATE"
648
- restore_file "$transaction/previous.old" "$PREVIOUS_STATE"
649
- nginx -t && nginx -s reload || rollback_failed=1
650
- rm -rf -- "$transaction"
651
- [[ "$rollback_failed" -eq 0 ]] || fail "state commit failed and old traffic could not be restored"
652
- fail "state commit failed; old project traffic and state restored"
653
- fi
654
- [[ -n "$previous_candidate" ]] || rm -f -- "$PREVIOUS_STATE"
655
- rm -rf -- "$transaction"
656
- flock -u 9
657
- exec 9>&-
658
- }
659
-
660
- deploy_release() {
661
- local mode="$1" package_root target_color work site_candidate upstream_candidate active_candidate previous_candidate=
662
- read_release_pointer
663
- package_root="$DEPLOY_ROOT/$RELEASE_VERSION"
664
- validate_uploaded_package "$package_root"
665
- read_active
666
- if [[ "$mode" == start ]]; then
667
- [[ -z "$ACTIVE_VERSION" ]] || fail "an active deployment already exists; use replace"
668
- target_color=blue
669
- else
670
- [[ -n "$ACTIVE_VERSION" ]] || fail "no active deployment exists; use start"
671
- [[ "$RELEASE_VERSION" != "$ACTIVE_VERSION" ]] || fail "replace version must differ from active version"
672
- target_color=$([[ "$ACTIVE_COLOR" == blue ]] && printf green || printf blue)
673
- fi
674
- mkdir -p "$STATE_DIR" "$NGINX_DIR" "$DEPLOY_ROOT/logs"
675
- load_release_images "$RELEASE_VERSION"
676
- up_pair "$target_color" "$RELEASE_VERSION"
677
- verify_pair "$target_color" "$RELEASE_VERSION"
678
-
679
- work="$(mktemp -d "$DEPLOY_ROOT/.traffic-candidate.XXXXXX")"
680
- site_candidate="$work/site.conf"; upstream_candidate="$work/upstreams.conf"; active_candidate="$work/active.env"
681
- render_site_candidate "$package_root/nginx/site.conf" "$site_candidate"
682
- write_upstream_candidate "$target_color" "$upstream_candidate"
683
- write_state_candidate "$active_candidate" "$target_color" "$RELEASE_VERSION"
684
- if [[ -n "$ACTIVE_VERSION" ]]; then
685
- previous_candidate="$work/previous.env"
686
- write_state_candidate "$previous_candidate" "$ACTIVE_COLOR" "$ACTIVE_VERSION"
687
- fi
688
- trap 'rm -rf -- "${work:-}"' EXIT
689
- commit_traffic_and_state "$site_candidate" "$upstream_candidate" "$active_candidate" "$previous_candidate" "$RELEASE_VERSION"
690
- rm -rf -- "$work"
293
+ run_logged_operation() {
294
+ local operation="$1"
295
+ shift
296
+ OPERATION="$operation"
297
+ trap 'status=$?; if [[ $status -eq 0 ]]; then record_operation success; else record_operation failure; fi' EXIT
298
+ "$@"
691
299
  trap - EXIT
692
- echo "active release: $RELEASE_VERSION ($target_color)"
693
- return 0
300
+ record_operation success
694
301
  }
695
302
 
696
- restart_current() {
697
- read_active
698
- [[ -n "$ACTIVE_VERSION" ]] || fail "no active deployment"
699
- validate_uploaded_package "$DEPLOY_ROOT/$ACTIVE_VERSION"
700
- load_release_images "$ACTIVE_VERSION"
701
- up_pair "$ACTIVE_COLOR" "$ACTIVE_VERSION"
702
- verify_pair "$ACTIVE_COLOR" "$ACTIVE_VERSION"
303
+ server_deploy() {
304
+ initialize_server_context
305
+ run_logged_operation deploy deploy_stack
703
306
  }
704
307
 
705
- stop_current() {
706
- read_active
707
- [[ -n "$ACTIVE_VERSION" ]] || fail "no active deployment"
708
- compose_for "$ACTIVE_VERSION" stop "frontend-$ACTIVE_COLOR" "backend-$ACTIVE_COLOR"
308
+ server_stop() {
309
+ initialize_server_context
310
+ run_logged_operation stop compose stop frontend backend
709
311
  }
710
312
 
711
- status_current() {
712
- read_active; read_previous
713
- printf 'active_color=%s\nactive_version=%s\nprevious_color=%s\nprevious_version=%s\n' "$ACTIVE_COLOR" "$ACTIVE_VERSION" "$PREVIOUS_COLOR" "$PREVIOUS_VERSION"
714
- if [[ -n "$ACTIVE_VERSION" ]]; then
715
- compose_for "$ACTIVE_VERSION" ps
716
- fi
717
- }
718
-
719
- remove_project_docker() {
720
- local value container_output network_output image_output
721
- local -a container_ids=() network_ids=() image_references=()
722
- container_output="$(docker ps -aq --filter "label=com.docker.compose.project=$PROJECT_NAME")"
723
- while IFS= read -r value; do [[ -n "$value" ]] && container_ids+=("$value"); done <<< "$container_output"
724
- ((${#container_ids[@]} == 0)) || docker rm -f "${container_ids[@]}"
725
- network_output="$(docker network ls -q --filter "label=com.docker.compose.project=$PROJECT_NAME")"
726
- while IFS= read -r value; do [[ -n "$value" ]] && network_ids+=("$value"); done <<< "$network_output"
727
- ((${#network_ids[@]} == 0)) || docker network rm "${network_ids[@]}"
728
- image_output="$(docker image ls --format '{{.Repository}}:{{.Tag}}' | awk -F: -v frontend="$FRONTEND_IMAGE_REPOSITORY" -v backend="$BACKEND_IMAGE_REPOSITORY" '($1 == frontend || $1 == backend) && $2 != "<none>" { print $0 }' | LC_ALL=C sort -u)"
729
- while IFS= read -r value; do [[ -n "$value" ]] && image_references+=("$value"); done <<< "$image_output"
730
- ((${#image_references[@]} == 0)) || docker image rm -f "${image_references[@]}"
731
- echo "removed only Docker resources for project: $PROJECT_NAME"
732
- }
733
-
734
- rollback_version() {
735
- local requested="$1" work site_candidate upstream_candidate active_candidate previous_candidate
736
- assert_version "$requested"
737
- read_active; read_previous
738
- [[ -n "$ACTIVE_VERSION" && -n "$PREVIOUS_VERSION" ]] || fail "active and previous deployment states are required"
739
- [[ "$requested" == "$PREVIOUS_VERSION" ]] || fail "rollback target must equal previous version $PREVIOUS_VERSION"
740
- verify_pair "$PREVIOUS_COLOR" "$PREVIOUS_VERSION"
741
- work="$(mktemp -d "$DEPLOY_ROOT/.rollback-candidate.XXXXXX")"
742
- site_candidate="$work/site.conf"; upstream_candidate="$work/upstreams.conf"; active_candidate="$work/active.env"; previous_candidate="$work/previous.env"
743
- render_site_candidate "$DEPLOY_ROOT/$PREVIOUS_VERSION/nginx/site.conf" "$site_candidate"
744
- write_upstream_candidate "$PREVIOUS_COLOR" "$upstream_candidate"
745
- write_state_candidate "$active_candidate" "$PREVIOUS_COLOR" "$PREVIOUS_VERSION"
746
- write_state_candidate "$previous_candidate" "$ACTIVE_COLOR" "$ACTIVE_VERSION"
747
- trap 'rm -rf -- "${work:-}"' EXIT
748
- commit_traffic_and_state "$site_candidate" "$upstream_candidate" "$active_candidate" "$previous_candidate" "$PREVIOUS_VERSION"
749
- rm -rf -- "$work"
750
- trap - EXIT
313
+ server_status() {
314
+ initialize_server_context
315
+ compose ps frontend backend
751
316
  }
752
317
 
753
- case "${1:-}" in
754
- build) [[ "$#" -eq 1 ]] || usage; build_release ;;
755
- start) [[ "$#" -eq 1 ]] || usage; initialize_server_context; deploy_release start ;;
756
- replace) [[ "$#" -eq 1 ]] || usage; initialize_server_context; deploy_release replace ;;
757
- restart) [[ "$#" -eq 1 ]] || usage; initialize_server_context; restart_current ;;
758
- stop) [[ "$#" -eq 1 ]] || usage; initialize_server_context; stop_current ;;
759
- remove) [[ "$#" -eq 1 ]] || usage; initialize_server_context; remove_project_docker ;;
760
- status) [[ "$#" -eq 1 ]] || usage; initialize_server_context; status_current ;;
761
- rollback) [[ "$#" -eq 2 ]] || usage; initialize_server_context; rollback_version "$2" ;;
762
- *) usage ;;
763
- esac
318
+ if [[ "$#" -eq 0 ]]; then server_deploy
319
+ elif [[ "$#" -eq 1 && "$1" == build ]]; then build_release
320
+ elif [[ "$#" -eq 1 && "$1" == stop ]]; then server_stop
321
+ elif [[ "$#" -eq 1 && "$1" == status ]]; then server_status
322
+ else usage
323
+ fi