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