@wdyy/skills 0.1.6 → 0.1.8

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 (19) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +53 -61
  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 +92 -88
  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 +168 -147
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +485 -526
  9. package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +10 -8
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +598 -416
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +46 -12
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +2 -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 -18
  17. package/README.md +66 -10
  18. package/lib/wdyy-cli.js +35 -9
  19. package/package.json +1 -1
@@ -3,6 +3,8 @@ 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
+ BUILD_LOCK=
6
8
 
7
9
  fail() {
8
10
  echo "$*" >&2
@@ -10,10 +12,14 @@ fail() {
10
12
  }
11
13
 
12
14
  usage() {
13
- echo "usage: ./deploy.sh build|start|stop|restart|status|rollback <version>" >&2
15
+ echo "usage: ./deploy.sh build|start|replace|restart|stop|remove|status|rollback <version>" >&2
14
16
  exit 2
15
17
  }
16
18
 
19
+ require_command() {
20
+ command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
21
+ }
22
+
17
23
  assert_version() {
18
24
  [[ "${1:-}" =~ ^[0-9]{8}-[0-9]{3}$ ]] || fail "version must match YYYYMMDD-NNN"
19
25
  }
@@ -24,27 +30,129 @@ assert_port() {
24
30
  (( value >= 1 && value <= 65535 )) || fail "$name must be between 1 and 65535"
25
31
  }
26
32
 
27
- require_command() {
28
- command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
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
100
+ fi
101
+ if is_deployment_key "$key"; then
102
+ printf -v "$key" '%s' "$value"
103
+ export "$key"
104
+ fi
105
+ done < "$file"
29
106
  }
30
107
 
31
- load_env_if_present() {
32
- local file="$1"
33
- if [[ -f "$file" ]]; then
34
- set -a
35
- # shellcheck disable=SC1090
36
- source "$file"
37
- set +a
38
- fi
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"
39
141
  }
40
142
 
41
- load_required_env() {
42
- local file="$1"
43
- [[ -r "$file" ]] || fail "required production environment file is missing: $file"
44
- set -a
45
- # shellcheck disable=SC1090
46
- source "$file"
47
- set +a
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"
48
156
  }
49
157
 
50
158
  resolve_project_path() {
@@ -86,95 +194,62 @@ const separator = '|---|---|';
86
194
  const versionPattern = /^(\d{4})(\d{2})(\d{2})-(\d{3})$/;
87
195
  const rowPattern = /^\| (\d{8}-\d{3}) \| (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}) \|$/;
88
196
 
89
- function fail(message) {
90
- throw new Error(message);
91
- }
92
-
93
- function validCalendarDate(dateText) {
94
- const year = Number(dateText.slice(0, 4));
95
- const month = Number(dateText.slice(4, 6));
96
- const day = Number(dateText.slice(6, 8));
97
- const value = new Date(Date.UTC(year, month - 1, day));
98
- return value.getUTCFullYear() === year
99
- && value.getUTCMonth() === month - 1
100
- && value.getUTCDate() === day;
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;
101
204
  }
102
-
103
- function inspectLedger(text) {
205
+ function inspect(text) {
104
206
  const lines = text.replace(/\r\n/g, '\n').split('\n');
105
- const headings = [];
106
- for (let index = 0; index < lines.length; index += 1) {
107
- if (lines[index] === heading) headings.push(index);
108
- }
207
+ const headings = lines.flatMap((line, index) => line === heading ? [index] : []);
109
208
  if (headings.length > 1) fail('AGENTS.md contains multiple release record sections');
110
- if (headings.length === 0) return { lines, headingIndex: -1, endIndex: lines.length, versions: [] };
111
-
209
+ if (!headings.length) return { lines, headingIndex: -1, endIndex: lines.length, versions: [] };
112
210
  const headingIndex = headings[0];
113
211
  let endIndex = lines.length;
114
212
  for (let index = headingIndex + 1; index < lines.length; index += 1) {
115
- if (/^## /.test(lines[index])) {
116
- endIndex = index;
117
- break;
118
- }
213
+ if (/^## /.test(lines[index])) { endIndex = index; break; }
119
214
  }
120
-
121
215
  const section = lines.slice(headingIndex + 1, endIndex);
122
216
  while (section[0] === '') section.shift();
123
- while (section[section.length - 1] === '') section.pop();
124
- if (section[0] !== header || section[1] !== separator) {
125
- fail('AGENTS.md release record table must contain only version and build time columns');
126
- }
127
-
128
- const versions = [];
217
+ while (section.at(-1) === '') section.pop();
218
+ if (section[0] !== header || section[1] !== separator) fail('AGENTS.md release record table is invalid');
129
219
  const unique = new Set();
220
+ const versions = [];
130
221
  for (const row of section.slice(2)) {
131
222
  const match = row.match(rowPattern);
132
- if (!match) fail(`invalid AGENTS.md release record row: ${row}`);
133
- const versionMatch = match[1].match(versionPattern);
134
- if (!versionMatch || !validCalendarDate(match[1].slice(0, 8))) {
135
- fail(`invalid release version: ${match[1]}`);
136
- }
137
- const sequence = Number(versionMatch[4]);
138
- if (sequence < 1 || sequence > 999) fail(`invalid release sequence: ${match[1]}`);
223
+ if (!match || !validDate(match[1].slice(0, 8))) fail(`invalid AGENTS.md release record row: ${row}`);
139
224
  if (unique.has(match[1])) fail(`duplicate release version: ${match[1]}`);
140
225
  unique.add(match[1]);
141
226
  versions.push(match[1]);
142
227
  }
143
228
  return { lines, headingIndex, endIndex, versions };
144
229
  }
145
-
146
- function nextVersion(ledger, currentDate) {
147
- if (!/^\d{8}$/.test(currentDate) || !validCalendarDate(currentDate)) {
148
- fail(`invalid current release date: ${currentDate}`);
149
- }
150
- let maximum = 0;
151
- for (const version of ledger.versions) {
152
- if (version.startsWith(`${currentDate}-`)) {
153
- maximum = Math.max(maximum, Number(version.slice(-3)));
154
- }
155
- }
156
- if (maximum >= 999) fail(`release sequence exhausted for ${currentDate}`);
157
- return `${currentDate}-${String(maximum + 1).padStart(3, '0')}`;
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')}`;
158
237
  }
159
238
 
160
239
  const original = fs.readFileSync(agentsPath, 'utf8');
161
- const ledger = inspectLedger(original);
162
-
240
+ const ledger = inspect(original);
163
241
  if (mode === 'next') {
164
242
  process.stdout.write(`${nextVersion(ledger, value)}\n`);
165
243
  } else if (mode === 'prepare') {
166
- const versionMatch = value.match(versionPattern);
167
- if (!versionMatch || !validCalendarDate(value.slice(0, 8))) fail(`invalid release version: ${value}`);
168
- if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}$/.test(timestamp)) {
169
- fail(`invalid release build time: ${timestamp}`);
170
- }
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');
171
246
  const expected = nextVersion(ledger, value.slice(0, 8));
172
247
  if (value !== expected) fail(`release ledger changed: expected ${expected}, got ${value}`);
173
248
  const row = `| ${value} | ${timestamp} |`;
174
249
  let updated;
175
250
  if (ledger.headingIndex === -1) {
176
- const separatorText = original.length === 0 ? '' : original.endsWith('\n') ? '\n' : '\n\n';
177
- updated = `${original}${separatorText}${heading}\n\n${header}\n${separator}\n${row}\n`;
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`;
178
253
  } else {
179
254
  const lines = [...ledger.lines];
180
255
  let insertAt = ledger.endIndex;
@@ -182,8 +257,7 @@ if (mode === 'next') {
182
257
  lines.splice(insertAt, 0, row);
183
258
  updated = lines.join('\n');
184
259
  }
185
- const modeBits = fs.statSync(agentsPath).mode;
186
- fs.writeFileSync(outputPath, updated, { mode: modeBits });
260
+ fs.writeFileSync(outputPath, updated, { mode: fs.statSync(agentsPath).mode });
187
261
  } else {
188
262
  fail(`unknown release ledger mode: ${mode}`);
189
263
  }
@@ -194,7 +268,6 @@ create_manifest() {
194
268
  local version_root="$1" file relative checksum
195
269
  (
196
270
  cd "$version_root"
197
- : > manifest.sha256
198
271
  find . -type f ! -name manifest.sha256 -print | LC_ALL=C sort |
199
272
  while IFS= read -r file; do
200
273
  relative="${file#./}"
@@ -205,45 +278,26 @@ create_manifest() {
205
278
  }
206
279
 
207
280
  verify_manifest() {
208
- local version_root="$1"
209
- local manifest="$version_root/manifest.sha256"
210
- local actual_paths declared_paths result line checksum relative actual
281
+ local version_root="$1" manifest="$1/manifest.sha256"
282
+ local actual_paths declared_paths result=0 line checksum relative actual
211
283
  [[ -s "$manifest" ]] || fail "missing or empty manifest.sha256"
212
284
  actual_paths="$(mktemp)"
213
285
  declared_paths="$(mktemp)"
214
- result=0
215
-
216
286
  if (
217
287
  cd "$version_root"
218
- find . -type f ! -name manifest.sha256 -print |
219
- sed 's|^\./||' |
220
- LC_ALL=C sort > "$actual_paths"
288
+ find . -type f ! -name manifest.sha256 -print | sed 's|^\./||' | LC_ALL=C sort > "$actual_paths"
221
289
  while IFS= read -r line; do
222
290
  checksum="${line%% *}"
223
291
  relative="${line#* }"
224
- if [[ ! "$checksum" =~ ^[0-9a-f]{64}$ || "$relative" == "$line" || "$relative" == /* || "$relative" == ../* || "$relative" == *"/../"* ]]; then
225
- echo "invalid manifest entry: $line" >&2
226
- result=1
227
- continue
228
- fi
292
+ [[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != ../* && "$relative" != *"/../"* ]] || exit 11
229
293
  printf '%s\n' "$relative" >> "$declared_paths"
230
- [[ -f "$relative" ]] || {
231
- echo "manifest file is missing: $relative" >&2
232
- result=1
233
- continue
234
- }
294
+ [[ -f "$relative" && ! -L "$relative" ]] || exit 12
235
295
  actual="$(checksum_value "$relative")"
236
- if [[ "$actual" != "$checksum" ]]; then
237
- echo "checksum mismatch: $relative" >&2
238
- result=1
239
- fi
296
+ [[ "$actual" == "$checksum" ]] || exit 13
240
297
  done < manifest.sha256
241
298
  LC_ALL=C sort -o "$declared_paths" "$declared_paths"
242
- if ! cmp -s "$actual_paths" "$declared_paths"; then
243
- echo "manifest does not cover the complete release package" >&2
244
- result=1
245
- fi
246
- exit "$result"
299
+ [[ "$(uniq -d "$declared_paths" | wc -l | tr -d ' ')" == 0 ]] || exit 14
300
+ cmp -s "$actual_paths" "$declared_paths"
247
301
  ); then
248
302
  result=0
249
303
  else
@@ -253,144 +307,140 @@ verify_manifest() {
253
307
  [[ "$result" -eq 0 ]] || fail "release manifest validation failed"
254
308
  }
255
309
 
256
- reject_secret_files() {
310
+ reject_version_secrets() {
257
311
  local root="$1" file base
258
- if find "$root" -type l -print -quit | grep -q .; then
259
- fail "symbolic links must not be packaged"
260
- fi
312
+ if find "$root" -type l -print -quit | grep -q .; then fail "symbolic links must not be packaged"; fi
261
313
  while IFS= read -r file; do
262
314
  base="${file##*/}"
263
315
  case "$base" in
264
316
  .env|.env.*|*.pem|*.key|id_rsa|id_ed25519|credentials|credentials.*)
265
- fail "production secret file must not be packaged: ${file#"$root"/}"
266
- ;;
317
+ fail "secret file must not enter the version directory: ${file#"$root"/}" ;;
267
318
  esac
268
319
  done < <(find "$root" -type f -print)
269
320
  }
270
321
 
271
322
  validate_dockerignore() {
272
- local file="$1" pattern
323
+ local file="$1" pattern line
273
324
  [[ -f "$file" ]] || fail "required Docker ignore file is missing: $file"
274
- for pattern in \
275
- '.env' \
276
- '.env.*' \
277
- '*.pem' \
278
- '*.key' \
279
- 'id_rsa' \
280
- 'id_ed25519' \
281
- 'credentials' \
282
- 'credentials.*' \
283
- 'deploy/' \
284
- 'logs/' \
285
- '.git/' \
286
- 'node_modules/' \
287
- 'venv/' \
288
- '.pnpm-store/'; do
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
289
327
  grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"
290
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"
291
332
  }
292
333
 
293
334
  validate_local_package() {
294
- local output_root="$1" version="$2"
295
- local version_root="$output_root/$version"
335
+ local output_root="$1" version="$2" version_root="$1/$2"
336
+ local expected_count
296
337
  [[ -x "$output_root/deploy.sh" ]] || fail "generated deploy.sh is missing or not executable"
297
- [[ "$(cat "$output_root/release.env")" == "RELEASE_VERSION=$version" ]] ||
298
- fail "release.env does not point to the generated version"
299
- [[ -f "$version_root/frontend.tar.gz" ]] || fail "missing frontend.tar.gz"
300
- [[ -f "$version_root/backend-image.tar" ]] || fail "missing backend-image.tar"
301
- [[ -d "$version_root/database/migrations" ]] || fail "missing database/migrations"
302
- [[ -x "$version_root/scripts/apply-migrations.sh" ]] || fail "missing executable migration runner"
303
- [[ -f "$version_root/docker/docker-compose.blue-green.yml" ]] || fail "missing blue-green compose file"
304
- [[ -f "$version_root/nginx/site.conf" ]] || fail "missing Nginx site configuration"
305
- grep -Fq '../../logs:/app/logs' "$version_root/docker/docker-compose.blue-green.yml" ||
306
- fail "compose does not mount deployment-root logs"
307
- grep -Fq '__DEPLOY_ROOT__' "$version_root/nginx/site.conf" ||
308
- fail "Nginx configuration does not contain the deployment-root placeholder"
309
- grep -Fq '__BACKEND_PROXY_URL__' "$version_root/nginx/site.conf" ||
310
- fail "Nginx configuration does not contain the backend proxy URL placeholder"
311
- reject_secret_files "$output_root"
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"
312
359
  verify_manifest "$version_root"
313
360
  }
314
361
 
315
- build_release() {
316
- local project_root="$SCRIPT_DIR" agents_file="$SCRIPT_DIR/AGENTS.md"
317
- local today version build_time raw_time image staging_parent output_root version_root agents_prepared
318
- local frontend_dist backend_dockerfile migrations_source migration_runner_source
319
- local deploy_config_dir compose_source nginx_source dockerignore_file
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
+ }
320
366
 
321
- [[ -r "$agents_file" && -w "$agents_file" ]] || fail "project AGENTS.md must exist and be readable and writable"
322
- load_env_if_present "$project_root/.env"
323
- : "${PROJECT_NAME:?PROJECT_NAME is required in the environment or project .env}"
324
- : "${BACKEND_CONTAINER_PORT:?BACKEND_CONTAINER_PORT is required in the environment or project .env}"
325
- assert_port BACKEND_CONTAINER_PORT "$BACKEND_CONTAINER_PORT"
367
+ release_build_lock() {
368
+ [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]] && rmdir "$BUILD_LOCK"
369
+ }
326
370
 
327
- frontend_dist="$(resolve_project_path "$project_root" "${FRONTEND_DIST_DIR:-src/frontend/dist}")"
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}")"
328
387
  backend_dockerfile="$(resolve_project_path "$project_root" "${BACKEND_DOCKERFILE:-src/backend/Dockerfile}")"
329
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}")"
330
392
  migrations_source="$(resolve_project_path "$project_root" "${MIGRATIONS_SOURCE:-database/migrations}")"
331
393
  migration_runner_source="$(resolve_project_path "$project_root" "${MIGRATION_RUNNER_SOURCE:-scripts/apply-migrations.sh}")"
332
- deploy_config_dir="$(resolve_project_path "$project_root" "${DEPLOY_CONFIG_DIR:-scripts/deployment}")"
333
- compose_source="$(resolve_project_path "$project_root" "${COMPOSE_SOURCE:-${deploy_config_dir#$project_root/}/docker-compose.blue-green.yml}")"
334
- nginx_source="$(resolve_project_path "$project_root" "${NGINX_SOURCE:-${deploy_config_dir#$project_root/}/nginx-site.conf}")"
335
- BACKEND_IMAGE_PREFIX="${BACKEND_IMAGE_PREFIX:-$PROJECT_NAME-backend}"
336
394
 
337
- for command in node pnpm docker tar install find sort cmp awk sed grep; do
338
- require_command "$command"
339
- done
395
+ for command in node pnpm docker tar install find sort cmp awk sed grep uniq; do require_command "$command"; done
340
396
  select_checksum_tool
341
- [[ -f "$backend_dockerfile" ]] || fail "backend Dockerfile not found: $backend_dockerfile"
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"
342
399
  validate_dockerignore "$dockerignore_file"
343
- [[ -d "$migrations_source" ]] || fail "migration directory not found: $migrations_source"
344
- [[ -x "$migration_runner_source" ]] || fail "migration runner is missing or not executable: $migration_runner_source"
345
- [[ -f "$compose_source" ]] || fail "compose source not found: $compose_source"
346
- [[ -f "$nginx_source" ]] || fail "Nginx source not found: $nginx_source"
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
347
404
 
348
405
  today="$(date +%Y%m%d)"
349
406
  version="$(release_ledger next "$agents_file" "$today")"
350
407
  assert_version "$version"
351
- image="$BACKEND_IMAGE_PREFIX:$version"
352
-
353
- echo "release gate: pnpm test"
354
- pnpm test
355
- echo "release gate: pnpm lint"
356
- pnpm lint
357
- echo "release gate: pnpm typecheck"
358
- pnpm typecheck
359
- echo "release gate: pnpm build"
360
- RELEASE_VERSION="$version" VITE_RELEASE_BASE="/releases/$version/" pnpm build
361
-
362
- [[ -f "$frontend_dist/index.html" ]] || fail "frontend build missing index.html: $frontend_dist"
363
- grep -Fq "/releases/$version/" "$frontend_dist/index.html" ||
364
- fail "frontend assets are not built with /releases/$version/"
365
-
366
- echo "release gate: docker build"
367
- docker build \
368
- --build-arg "RELEASE_VERSION=$version" \
369
- --build-arg "BACKEND_CONTAINER_PORT=$BACKEND_CONTAINER_PORT" \
370
- --tag "$image" \
371
- --file "$backend_dockerfile" \
372
- "$project_root"
373
- docker image inspect "$image" >/dev/null
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"
374
424
 
375
425
  staging_parent="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
376
426
  output_root="$staging_parent/deploy"
377
427
  version_root="$output_root/$version"
378
428
  agents_prepared="$project_root/.AGENTS.md.release.$$.tmp"
379
- trap 'rm -rf -- "${staging_parent:-}"; rm -f -- "${agents_prepared:-}"' EXIT
380
-
381
- mkdir -p \
382
- "$version_root/database" \
383
- "$version_root/scripts" \
384
- "$version_root/docker" \
385
- "$version_root/nginx"
429
+ mkdir -p "$version_root/docker" "$version_root/nginx"
386
430
  install -m 0755 "$SCRIPT_PATH" "$output_root/deploy.sh"
431
+ install -m 0600 "$project_root/.env" "$output_root/.env"
387
432
  printf 'RELEASE_VERSION=%s\n' "$version" > "$output_root/release.env"
388
- tar -czf "$version_root/frontend.tar.gz" -C "$frontend_dist" .
389
- docker save -o "$version_root/backend-image.tar" "$image"
390
- cp -R "$migrations_source" "$version_root/database/migrations"
391
- install -m 0755 "$migration_runner_source" "$version_root/scripts/apply-migrations.sh"
433
+ docker save -o "$version_root/frontend-image.tar" "$frontend_image"
434
+ docker save -o "$version_root/backend-image.tar" "$backend_image"
392
435
  install -m 0644 "$compose_source" "$version_root/docker/docker-compose.blue-green.yml"
393
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
394
444
  create_manifest "$version_root"
395
445
  validate_local_package "$output_root" "$version"
396
446
 
@@ -398,23 +448,32 @@ build_release() {
398
448
  build_time="${raw_time%?????}${raw_time: -5:3}:${raw_time: -2}"
399
449
  release_ledger prepare "$agents_file" "$version" "$build_time" "$agents_prepared"
400
450
 
401
- [[ "$project_root/deploy" == "$SCRIPT_DIR/deploy" ]] || fail "refusing to replace an unexpected deploy directory"
402
- rm -rf -- "$project_root/deploy"
403
- mv "$output_root" "$project_root/deploy"
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
404
460
  if ! mv "$agents_prepared" "$agents_file"; then
405
461
  rm -rf -- "$project_root/deploy"
462
+ [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"
406
463
  fail "failed to write AGENTS.md release record"
407
464
  fi
408
-
465
+ [[ -n "$old_deploy" ]] && rm -rf -- "$old_deploy" && old_deploy=
466
+ rm -rf -- "$staging_parent"; staging_parent=
467
+ release_build_lock; BUILD_LOCK=
409
468
  trap - EXIT
410
- rm -rf -- "$staging_parent"
411
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
412
472
  }
413
473
 
414
474
  read_release_pointer() {
415
475
  local file="$DEPLOY_ROOT/release.env" line
416
- [[ -f "$file" ]] || fail "missing release.env"
417
- [[ "$(wc -l < "$file" | tr -d ' ')" == 1 ]] || fail "release.env must contain exactly one line"
476
+ [[ -f "$file" && "$(wc -l < "$file" | tr -d ' ')" == 1 ]] || fail "release.env must contain exactly one line"
418
477
  line="$(cat "$file")"
419
478
  [[ "$line" =~ ^RELEASE_VERSION=([0-9]{8}-[0-9]{3})$ ]] || fail "invalid release.env"
420
479
  RELEASE_VERSION="${BASH_REMATCH[1]}"
@@ -422,56 +481,53 @@ read_release_pointer() {
422
481
  }
423
482
 
424
483
  initialize_server_context() {
484
+ local expected_root
425
485
  DEPLOY_ROOT="$SCRIPT_DIR"
426
- load_required_env "$DEPLOY_ROOT/.env.production"
427
- : "${PROJECT_NAME:?PROJECT_NAME is required in .env.production}"
428
- : "${DATABASE_URL:?DATABASE_URL is required in .env.production}"
429
- : "${BACKEND_BIND_IP:?BACKEND_BIND_IP is required in .env.production}"
430
- : "${BACKEND_LISTEN_HOST:?BACKEND_LISTEN_HOST is required in .env.production}"
431
- : "${BACKEND_CONTAINER_PORT:?BACKEND_CONTAINER_PORT is required in .env.production}"
432
- : "${BACKEND_CONTAINER_HEALTH_URL:?BACKEND_CONTAINER_HEALTH_URL is required in .env.production}"
433
- : "${BLUE_PORT:?BLUE_PORT is required in .env.production}"
434
- : "${GREEN_PORT:?GREEN_PORT is required in .env.production}"
435
- : "${BLUE_HEALTH_URL:?BLUE_HEALTH_URL is required in .env.production}"
436
- : "${GREEN_HEALTH_URL:?GREEN_HEALTH_URL is required in .env.production}"
437
- : "${BLUE_VERSION_URL:?BLUE_VERSION_URL is required in .env.production}"
438
- : "${GREEN_VERSION_URL:?GREEN_VERSION_URL is required in .env.production}"
439
- : "${BLUE_UPSTREAM:?BLUE_UPSTREAM is required in .env.production}"
440
- : "${GREEN_UPSTREAM:?GREEN_UPSTREAM is required in .env.production}"
441
- : "${BACKEND_PROXY_URL:?BACKEND_PROXY_URL is required in .env.production}"
442
- if [[ ! "$BACKEND_PROXY_URL" =~ ^https?://([A-Za-z_][A-Za-z0-9_]*)$ ]]; then
443
- fail "BACKEND_PROXY_URL must be an http(s) URL containing only a valid Nginx upstream name"
444
- fi
445
- BACKEND_UPSTREAM_NAME="${BASH_REMATCH[1]}"
446
- BACKEND_IMAGE_PREFIX="${BACKEND_IMAGE_PREFIX:-$PROJECT_NAME-backend}"
447
- assert_port BACKEND_CONTAINER_PORT "$BACKEND_CONTAINER_PORT"
448
- assert_port BLUE_PORT "$BLUE_PORT"
449
- assert_port GREEN_PORT "$GREEN_PORT"
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"
450
491
  STATE_DIR="$DEPLOY_ROOT/state"
451
492
  ACTIVE_STATE="$STATE_DIR/active.env"
452
493
  PREVIOUS_STATE="$STATE_DIR/previous.env"
453
- RELEASES_DIR="$DEPLOY_ROOT/releases"
454
- UPSTREAM_FILE="$DEPLOY_ROOT/nginx/backend-active.conf"
455
- NGINX_SITE_FILE="$DEPLOY_ROOT/nginx/site.conf"
456
- for command in docker curl nginx psql tar find sort cmp awk sed grep; do
457
- require_command "$command"
458
- done
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
459
502
  select_checksum_tool
460
503
  }
461
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
+
462
518
  read_active() {
463
- ACTIVE_COLOR=green
464
- ACTIVE_VERSION=
465
- if [[ -f "$ACTIVE_STATE" ]]; then
466
- # shellcheck disable=SC1090
467
- source "$ACTIVE_STATE"
468
- [[ "$ACTIVE_COLOR" == blue || "$ACTIVE_COLOR" == green ]] || fail "invalid active color state"
469
- assert_version "$ACTIVE_VERSION"
470
- fi
519
+ ACTIVE_COLOR=; ACTIVE_VERSION=
520
+ if read_state_file "$ACTIVE_STATE"; then ACTIVE_COLOR="$STATE_COLOR"; ACTIVE_VERSION="$STATE_VERSION"; fi
471
521
  }
472
522
 
473
- upstream_for() {
474
- [[ "$1" == blue ]] && printf '%s' "$BLUE_UPSTREAM" || printf '%s' "$GREEN_UPSTREAM"
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"
475
531
  }
476
532
 
477
533
  compose_file_for() {
@@ -480,203 +536,329 @@ compose_file_for() {
480
536
  printf '%s/%s/docker/docker-compose.blue-green.yml\n' "$DEPLOY_ROOT" "$version"
481
537
  }
482
538
 
483
- render_nginx_site() {
484
- local source="$1" escaped_root escaped_proxy_url
485
- grep -Fq '__DEPLOY_ROOT__' "$source" || fail "Nginx package is missing __DEPLOY_ROOT__ placeholder"
486
- grep -Fq '__BACKEND_PROXY_URL__' "$source" ||
487
- fail "Nginx package is missing __BACKEND_PROXY_URL__ placeholder"
488
- escaped_root="${DEPLOY_ROOT//\\/\\\\}"
489
- escaped_root="${escaped_root//&/\\&}"
490
- escaped_root="${escaped_root//|/\\|}"
491
- escaped_proxy_url="${BACKEND_PROXY_URL//\\/\\\\}"
492
- escaped_proxy_url="${escaped_proxy_url//&/\\&}"
493
- escaped_proxy_url="${escaped_proxy_url//|/\\|}"
494
- mkdir -p "$DEPLOY_ROOT/nginx"
495
- sed \
496
- -e "s|__DEPLOY_ROOT__|$escaped_root|g" \
497
- -e "s|__BACKEND_PROXY_URL__|$escaped_proxy_url|g" \
498
- "$source" > "$NGINX_SITE_FILE.new"
499
- mv "$NGINX_SITE_FILE.new" "$NGINX_SITE_FILE"
500
- }
501
-
502
- switch_traffic() {
503
- local color="$1" version="$2" upstream backup
504
- upstream="$(upstream_for "$color")"
505
- backup="$UPSTREAM_FILE.previous"
506
- printf 'upstream %s {\n server %s;\n keepalive 32;\n}\n' \
507
- "$BACKEND_UPSTREAM_NAME" "$upstream" > "$UPSTREAM_FILE.new"
508
- [[ -f "$UPSTREAM_FILE" ]] && cp "$UPSTREAM_FILE" "$backup"
509
- mv -f "$UPSTREAM_FILE.new" "$UPSTREAM_FILE"
510
- if ! nginx -t || ! nginx -s reload; then
511
- if [[ -f "$backup" ]]; then
512
- mv -f "$backup" "$UPSTREAM_FILE"
513
- nginx -t
514
- nginx -s reload
515
- fi
516
- echo "failed to switch Nginx upstream" >&2
517
- return 1
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"
518
554
  fi
519
- rm -f "$backup"
520
- ln -sfn "releases/$version" "$DEPLOY_ROOT/current.new"
521
- mv -Tf "$DEPLOY_ROOT/current.new" "$DEPLOY_ROOT/current"
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"
522
579
  }
523
580
 
524
- verify_backend() {
525
- local color="$1" version="$2" health_url version_url actual
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
526
598
  if [[ "$color" == blue ]]; then
527
- health_url="$BLUE_HEALTH_URL"
528
- version_url="$BLUE_VERSION_URL"
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)"
529
601
  else
530
- health_url="$GREEN_HEALTH_URL"
531
- version_url="$GREEN_VERSION_URL"
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)"
532
604
  fi
533
- HEALTH_URL="$health_url"
534
- VERSION_URL="$version_url"
535
- curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$HEALTH_URL"
536
- actual="$(curl --fail --silent --show-error "$VERSION_URL")"
537
- [[ "$actual" == "$version" ]] || fail "backend version mismatch: expected $version, got $actual"
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
538
681
  }
539
682
 
540
- validate_uploaded_package() {
541
- local package_root="$1"
542
- select_checksum_tool
543
- [[ -f "$package_root/frontend.tar.gz" ]] || fail "missing frontend.tar.gz"
544
- [[ -f "$package_root/backend-image.tar" ]] || fail "missing backend-image.tar"
545
- [[ -d "$package_root/database/migrations" ]] || fail "missing database/migrations"
546
- [[ -x "$package_root/scripts/apply-migrations.sh" ]] || fail "missing executable migration runner"
547
- [[ -f "$package_root/docker/docker-compose.blue-green.yml" ]] || fail "missing blue-green compose file"
548
- [[ -f "$package_root/nginx/site.conf" ]] || fail "missing Nginx site configuration"
549
- reject_secret_files "$package_root"
550
- verify_manifest "$package_root"
551
- if tar -tzf "$package_root/frontend.tar.gz" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
552
- fail "frontend archive contains an unsafe path"
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"
714
+ }
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"
553
752
  fi
753
+ [[ -n "$previous_candidate" ]] || rm -f -- "$PREVIOUS_STATE"
754
+ rm -rf -- "$transaction"
755
+ flock -u 9
756
+ exec 9>&-
554
757
  }
555
758
 
556
- start_release() {
557
- local package_root release_dir target_color image compose_file
558
- initialize_server_context
759
+ deploy_release() {
760
+ local mode="$1" package_root target_color work site_candidate upstream_candidate active_candidate previous_candidate=
559
761
  read_release_pointer
560
762
  package_root="$DEPLOY_ROOT/$RELEASE_VERSION"
561
763
  validate_uploaded_package "$package_root"
562
- mkdir -p "$STATE_DIR" "$RELEASES_DIR" "$DEPLOY_ROOT/logs" "$DEPLOY_ROOT/nginx"
563
- render_nginx_site "$package_root/nginx/site.conf"
564
-
565
- release_dir="$RELEASES_DIR/$RELEASE_VERSION"
566
- rm -rf -- "$release_dir"
567
- mkdir -p "$release_dir"
568
- tar -xzf "$package_root/frontend.tar.gz" -C "$release_dir"
569
- [[ -f "$release_dir/index.html" ]] || fail "frontend release missing index.html"
570
- grep -Fq "/releases/$RELEASE_VERSION/" "$release_dir/index.html" ||
571
- fail "frontend assets are not built with the versioned release URL"
572
-
573
- docker load -i "$package_root/backend-image.tar"
574
- image="$BACKEND_IMAGE_PREFIX:$RELEASE_VERSION"
575
- docker image inspect "$image" >/dev/null
576
- MIGRATIONS_DIR="$package_root/database/migrations" \
577
- DATABASE_URL="$DATABASE_URL" \
578
- "$package_root/scripts/apply-migrations.sh"
579
-
580
764
  read_active
581
- target_color=$([[ "$ACTIVE_COLOR" == blue ]] && echo green || echo blue)
582
- compose_file="$(compose_file_for "$RELEASE_VERSION")"
583
- if [[ "$target_color" == blue ]]; then
584
- BACKEND_BLUE_IMAGE="$image" docker compose --project-name "$PROJECT_NAME" -f "$compose_file" \
585
- 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
586
768
  else
587
- BACKEND_GREEN_IMAGE="$image" docker compose --project-name "$PROJECT_NAME" -f "$compose_file" \
588
- 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)
589
772
  fi
590
- verify_backend "$target_color" "$RELEASE_VERSION"
591
- [[ -f "$ACTIVE_STATE" ]] && cp "$ACTIVE_STATE" "$PREVIOUS_STATE"
592
- switch_traffic "$target_color" "$RELEASE_VERSION"
593
- printf 'ACTIVE_COLOR=%q\nACTIVE_VERSION=%q\n' "$target_color" "$RELEASE_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
594
795
  }
595
796
 
596
797
  restart_current() {
597
- local target_color image compose_file
598
798
  read_active
599
- [[ -n "$ACTIVE_VERSION" ]] || fail "no active version"
600
- target_color=$([[ "$ACTIVE_COLOR" == blue ]] && echo green || echo blue)
601
- image="$BACKEND_IMAGE_PREFIX:$ACTIVE_VERSION"
602
- compose_file="$(compose_file_for "$ACTIVE_VERSION")"
603
- [[ -f "$compose_file" ]] || fail "active version compose file is missing: $compose_file"
604
- if [[ "$target_color" == blue ]]; then
605
- BACKEND_BLUE_IMAGE="$image" docker compose --project-name "$PROJECT_NAME" -f "$compose_file" \
606
- up -d --force-recreate backend-blue
607
- else
608
- BACKEND_GREEN_IMAGE="$image" docker compose --project-name "$PROJECT_NAME" -f "$compose_file" \
609
- up -d --force-recreate backend-green
610
- fi
611
- verify_backend "$target_color" "$ACTIVE_VERSION"
612
- switch_traffic "$target_color" "$ACTIVE_VERSION"
613
- printf 'ACTIVE_COLOR=%q\nACTIVE_VERSION=%q\n' "$target_color" "$ACTIVE_VERSION" > "$ACTIVE_STATE"
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"
614
804
  }
615
805
 
616
806
  stop_current() {
617
- local compose_file
618
807
  read_active
619
- [[ -n "$ACTIVE_VERSION" ]] || fail "no active version"
620
- compose_file="$(compose_file_for "$ACTIVE_VERSION")"
621
- docker compose --project-name "$PROJECT_NAME" -f "$compose_file" down
808
+ [[ -n "$ACTIVE_VERSION" ]] || fail "no active deployment"
809
+ compose_for "$ACTIVE_VERSION" stop "frontend-$ACTIVE_COLOR" "backend-$ACTIVE_COLOR"
622
810
  }
623
811
 
624
812
  status_current() {
625
- local compose_file
626
- read_active
627
- printf 'color=%s\nversion=%s\n' "$ACTIVE_COLOR" "$ACTIVE_VERSION"
628
- [[ -n "$ACTIVE_VERSION" ]] || return 0
629
- compose_file="$(compose_file_for "$ACTIVE_VERSION")"
630
- docker compose --project-name "$PROJECT_NAME" -f "$compose_file" ps
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
817
+ fi
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"
631
833
  }
632
834
 
633
835
  rollback_version() {
634
- local version="$1"
635
- assert_version "$version"
636
- [[ -f "$PREVIOUS_STATE" ]] || fail "no previous deployment"
637
- # shellcheck disable=SC1090
638
- source "$PREVIOUS_STATE"
639
- [[ "$ACTIVE_COLOR" == blue || "$ACTIVE_COLOR" == green ]] || fail "invalid previous color state"
640
- assert_version "$ACTIVE_VERSION"
641
- [[ "$ACTIVE_VERSION" == "$version" ]] || fail "rollback target is not the retained previous version"
642
- [[ -d "$RELEASES_DIR/$ACTIVE_VERSION" ]] || fail "previous frontend release is missing"
643
- verify_backend "$ACTIVE_COLOR" "$ACTIVE_VERSION"
644
- switch_traffic "$ACTIVE_COLOR" "$ACTIVE_VERSION"
645
- cp "$ACTIVE_STATE" "$PREVIOUS_STATE.tmp"
646
- cp "$PREVIOUS_STATE" "$ACTIVE_STATE"
647
- 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
648
852
  }
649
853
 
650
854
  case "${1:-}" in
651
- build)
652
- [[ "$#" -eq 1 ]] || usage
653
- build_release
654
- ;;
655
- start)
656
- [[ "$#" -eq 1 ]] || usage
657
- start_release
658
- ;;
659
- stop)
660
- [[ "$#" -eq 1 ]] || usage
661
- initialize_server_context
662
- stop_current
663
- ;;
664
- restart)
665
- [[ "$#" -eq 1 ]] || usage
666
- initialize_server_context
667
- restart_current
668
- ;;
669
- status)
670
- [[ "$#" -eq 1 ]] || usage
671
- initialize_server_context
672
- status_current
673
- ;;
674
- rollback)
675
- [[ "$#" -eq 2 ]] || usage
676
- initialize_server_context
677
- rollback_version "$2"
678
- ;;
679
- *)
680
- usage
681
- ;;
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 ;;
682
864
  esac