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