@wdyy/skills 0.1.5 → 0.1.7

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