@wdyy/skills 0.1.10 → 0.1.12

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