@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.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +33 -44
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +27 -63
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +19 -9
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +16 -2
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +61 -123
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +145 -347
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +184 -675
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +35 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -7
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -2
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
- package/README.md +4 -2
- package/lib/wdyy-cli.js +3 -2
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
- 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
|
|
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
|
|
38
|
+
unset PROJECT_NAME DOCKER_BIND_IP
|
|
35
39
|
unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
|
|
36
|
-
unset
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
55
|
+
is_removed_configuration_key() {
|
|
57
56
|
case "$1" in
|
|
58
|
-
|
|
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
|
-
|
|
85
|
-
key
|
|
86
|
-
|
|
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
|
|
110
|
-
local -a required=(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
const headings
|
|
204
|
-
if
|
|
205
|
-
if
|
|
206
|
-
const headingIndex
|
|
207
|
-
let
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const section =
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
338
|
-
[[ -
|
|
339
|
-
[[ -
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
370
|
-
|
|
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.
|
|
387
|
-
|
|
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 "
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
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 [[ -
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
502
|
-
local
|
|
503
|
-
[[ -
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
530
|
-
local
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
543
|
-
local
|
|
544
|
-
[[
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
558
|
-
local
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|