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