@wdyy/skills 0.1.12 → 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 -46
- 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 +138 -155
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +97 -97
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +93 -116
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +185 -311
- 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 +210 -256
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +11 -13
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -19
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +0 -6
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +9 -4
- package/README.md +2 -2
- package/lib/wdyy-cli.js +2 -1
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -99
|
@@ -3,11 +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
8
|
DEPLOY_ROOT=
|
|
9
|
-
RELEASE_VERSION=
|
|
10
9
|
OPERATION=
|
|
10
|
+
CHECKSUM_TOOL=
|
|
11
11
|
|
|
12
12
|
fail() {
|
|
13
13
|
echo "$*" >&2
|
|
@@ -15,8 +15,10 @@ fail() {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
usage() {
|
|
18
|
-
echo "usage: ./deploy.sh build
|
|
19
|
-
echo " ./deploy.sh
|
|
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
|
|
20
22
|
exit 2
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -24,8 +26,17 @@ require_command() {
|
|
|
24
26
|
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
assert_port() {
|
|
@@ -34,336 +45,279 @@ assert_port() {
|
|
|
34
45
|
(( value >= 1 && value <= 65535 )) || fail "$name must be between 1 and 65535"
|
|
35
46
|
}
|
|
36
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
|
+
|
|
37
56
|
reset_configuration() {
|
|
38
|
-
unset PROJECT_NAME DOCKER_BIND_IP
|
|
39
57
|
unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
|
|
40
|
-
unset
|
|
41
|
-
unset
|
|
42
|
-
unset DEPLOY_CONFIG_DIR COMPOSE_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
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
|
|
62
|
+
is_configuration_key() {
|
|
46
63
|
case "$1" in
|
|
47
|
-
FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|PROJECT_NAME|DOCKER_BIND_IP
|
|
48
|
-
FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE|FRONTEND_DOCKERFILE|BACKEND_DOCKERFILE|\
|
|
49
|
-
DOCKERIGNORE_FILE|DEPLOY_CONFIG_DIR|COMPOSE_SOURCE)
|
|
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)
|
|
50
65
|
return 0 ;;
|
|
51
66
|
*) return 1 ;;
|
|
52
67
|
esac
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
|
|
70
|
+
is_removed_key() {
|
|
56
71
|
case "$1" in
|
|
57
|
-
PROJECT_HTTP_PORT|FRONTEND_BLUE_PORT|FRONTEND_GREEN_PORT|BACKEND_BLUE_PORT|BACKEND_GREEN_PORT
|
|
58
|
-
SERVER_PROJECTS_ROOT|NGINX_SOURCE|LOG_DIR|HOST_LOG_DIR|FRONTEND_CONTAINER_PORT|\
|
|
59
|
-
BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|DATABASE_MIGRATION_MODE|MIGRATIONS_SOURCE|\
|
|
60
|
-
MIGRATION_RUNNER_SOURCE|*_HEALTH_URL|*_VERSION_URL)
|
|
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)
|
|
61
73
|
return 0 ;;
|
|
62
74
|
*) return 1 ;;
|
|
63
75
|
esac
|
|
64
76
|
}
|
|
65
77
|
|
|
66
|
-
validate_env_permissions() {
|
|
67
|
-
local file="$1" mode
|
|
68
|
-
if stat -c '%a' "$file" >/dev/null 2>&1; then mode="$(stat -c '%a' "$file")"; else mode="$(stat -f '%Lp' "$file")"; fi
|
|
69
|
-
[[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
|
|
70
|
-
}
|
|
71
|
-
|
|
72
78
|
load_dotenv() {
|
|
73
|
-
local file="$1" raw line key value first last
|
|
79
|
+
local file="$1" raw line key value first last loaded
|
|
74
80
|
local -a loaded_keys=()
|
|
75
|
-
[[ -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"
|
|
76
82
|
validate_env_permissions "$file"
|
|
77
83
|
reset_configuration
|
|
78
84
|
while IFS= read -r raw || [[ -n "$raw" ]]; do
|
|
79
85
|
line="${raw%$'\r'}"
|
|
80
86
|
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
|
81
87
|
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || fail "invalid .env line; expected KEY=VALUE"
|
|
82
|
-
key="${BASH_REMATCH[1]}"
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
key="${BASH_REMATCH[1]}"
|
|
89
|
+
value="${BASH_REMATCH[2]}"
|
|
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
|
|
85
93
|
loaded_keys+=("$key")
|
|
86
94
|
if [[ ${#value} -ge 2 ]]; then
|
|
87
|
-
first="${value:0:1}"
|
|
95
|
+
first="${value:0:1}"
|
|
96
|
+
last="${value: -1}"
|
|
88
97
|
if [[ "$first" == "'" || "$first" == '"' ]]; then
|
|
89
98
|
[[ "$last" == "$first" ]] || fail "unclosed quoted .env value: $key"
|
|
90
99
|
value="${value:1:${#value}-2}"
|
|
91
100
|
fi
|
|
92
101
|
fi
|
|
93
|
-
if
|
|
102
|
+
if is_configuration_key "$key"; then printf -v "$key" '%s' "$value"; fi
|
|
94
103
|
done < "$file"
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
validate_configuration() {
|
|
98
|
-
local
|
|
99
|
-
|
|
100
|
-
|
|
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"
|
|
111
|
+
done
|
|
101
112
|
[[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] || fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
|
|
102
|
-
|
|
113
|
+
[[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
|
|
114
|
+
[[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
|
|
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"
|
|
103
119
|
assert_port FRONTEND_PORT "$FRONTEND_PORT"
|
|
104
120
|
assert_port BACKEND_PORT "$BACKEND_PORT"
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
probe_host() {
|
|
109
|
-
case "$1" in
|
|
110
|
-
0.0.0.0) printf '127.0.0.1\n' ;;
|
|
111
|
-
::) printf '[::1]\n' ;;
|
|
112
|
-
*:*) printf '[%s]\n' "$1" ;;
|
|
113
|
-
*) printf '%s\n' "$1" ;;
|
|
114
|
-
esac
|
|
121
|
+
assert_port DB_PORT "$DB_PORT"
|
|
122
|
+
[[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must differ"
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
local
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
select_checksum_tool() {
|
|
129
|
-
if command -v sha256sum >/dev/null 2>&1; then CHECKSUM_TOOL=sha256sum
|
|
130
|
-
elif command -v shasum >/dev/null 2>&1; then CHECKSUM_TOOL=shasum
|
|
131
|
-
else fail "sha256sum or shasum is required"
|
|
132
|
-
fi
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
checksum_value() {
|
|
136
|
-
if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
release_ledger() {
|
|
140
|
-
node --input-type=module - "$@" <<'NODE'
|
|
141
|
-
import fs from 'node:fs';
|
|
142
|
-
const [mode, agentsPath, value, timestamp, outputPath] = process.argv.slice(2);
|
|
143
|
-
const heading = '## 发布记录';
|
|
144
|
-
const header = '| 版本号 | 构建时间 |';
|
|
145
|
-
const separator = '|---|---|';
|
|
146
|
-
const rowPattern = /^\| (\d{8}-\d{3}) \| (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}) \|$/;
|
|
147
|
-
function fail(message) { throw new Error(message); }
|
|
148
|
-
function validDate(text) { const y=Number(text.slice(0,4)),m=Number(text.slice(4,6)),d=Number(text.slice(6,8)),date=new Date(Date.UTC(y,m-1,d)); return date.getUTCFullYear()===y&&date.getUTCMonth()===m-1&&date.getUTCDate()===d; }
|
|
149
|
-
function inspect(text) {
|
|
150
|
-
const lines=text.replace(/\r\n/g,'\n').split('\n');
|
|
151
|
-
const headings=lines.flatMap((line,index)=>line===heading?[index]:[]);
|
|
152
|
-
if(headings.length>1) fail('AGENTS.md contains multiple release record sections');
|
|
153
|
-
if(!headings.length) return {lines,headingIndex:-1,endIndex:lines.length,versions:[]};
|
|
154
|
-
const headingIndex=headings[0]; let endIndex=lines.length;
|
|
155
|
-
for(let index=headingIndex+1;index<lines.length;index+=1){if(/^## /.test(lines[index])){endIndex=index;break;}}
|
|
156
|
-
const section=lines.slice(headingIndex+1,endIndex); while(section[0]==='')section.shift(); while(section.at(-1)==='')section.pop();
|
|
157
|
-
if(section[0]!==header||section[1]!==separator) fail('AGENTS.md release record table is invalid');
|
|
158
|
-
const unique=new Set(),versions=[];
|
|
159
|
-
for(const row of section.slice(2)){const match=row.match(rowPattern);if(!match||!validDate(match[1].slice(0,8)))fail(`invalid AGENTS.md release record row: ${row}`);if(unique.has(match[1]))fail(`duplicate release version: ${match[1]}`);unique.add(match[1]);versions.push(match[1]);}
|
|
160
|
-
return {lines,headingIndex,endIndex,versions};
|
|
161
|
-
}
|
|
162
|
-
function nextVersion(ledger,date){if(!/^\d{8}$/.test(date)||!validDate(date))fail(`invalid current release date: ${date}`);const max=ledger.versions.filter(item=>item.startsWith(`${date}-`)).reduce((result,item)=>Math.max(result,Number(item.slice(-3))),0);if(max>=999)fail(`release sequence exhausted for ${date}`);return `${date}-${String(max+1).padStart(3,'0')}`;}
|
|
163
|
-
const original=fs.readFileSync(agentsPath,'utf8'); const ledger=inspect(original);
|
|
164
|
-
if(mode==='next') process.stdout.write(`${nextVersion(ledger,value)}\n`);
|
|
165
|
-
else if(mode==='prepare'){
|
|
166
|
-
if(!/^\d{8}-\d{3}$/.test(value)||!validDate(value.slice(0,8)))fail(`invalid release version: ${value}`);
|
|
167
|
-
if(!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}$/.test(timestamp))fail('invalid build time');
|
|
168
|
-
const expected=nextVersion(ledger,value.slice(0,8));if(value!==expected)fail(`release ledger changed: expected ${expected}, got ${value}`);
|
|
169
|
-
const row=`| ${value} | ${timestamp} |`;let updated;
|
|
170
|
-
if(ledger.headingIndex===-1){const gap=original.length===0?'':original.endsWith('\n')?'\n':'\n\n';updated=`${original}${gap}${heading}\n\n${header}\n${separator}\n${row}\n`;}
|
|
171
|
-
else{const lines=[...ledger.lines];let insertAt=ledger.endIndex;while(insertAt>ledger.headingIndex+1&&lines[insertAt-1]==='')insertAt-=1;lines.splice(insertAt,0,row);updated=lines.join('\n');}
|
|
172
|
-
fs.writeFileSync(outputPath,updated,{mode:fs.statSync(agentsPath).mode});
|
|
173
|
-
} else fail(`unknown release ledger mode: ${mode}`);
|
|
174
|
-
NODE
|
|
125
|
+
validate_dockerignore() {
|
|
126
|
+
local file="$1" pattern line
|
|
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
|
|
129
|
+
grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"
|
|
130
|
+
done
|
|
131
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
132
|
+
[[ "$line" != !* || "$line" == '!.env.example' ]] || fail "Docker ignore file contains forbidden reverse include: $line"
|
|
133
|
+
done < "$file"
|
|
175
134
|
}
|
|
176
135
|
|
|
177
136
|
create_manifest() {
|
|
178
|
-
local
|
|
179
|
-
|
|
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
|
|
180
143
|
}
|
|
181
144
|
|
|
182
145
|
verify_manifest() {
|
|
183
|
-
local root="$1" line checksum relative actual
|
|
184
|
-
[[ -s "$root/manifest.sha256" ]] || fail "missing or
|
|
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"
|
|
185
148
|
while IFS= read -r line; do
|
|
186
|
-
checksum="${line%% *}"
|
|
187
|
-
|
|
149
|
+
checksum="${line%% *}"
|
|
150
|
+
relative="${line#* }"
|
|
151
|
+
[[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != *'/'* ]] || fail "invalid manifest entry"
|
|
188
152
|
[[ -f "$root/$relative" && ! -L "$root/$relative" ]] || fail "manifest path is missing: $relative"
|
|
189
|
-
actual="$(checksum_value "$root/$relative")"
|
|
153
|
+
actual="$(checksum_value "$root/$relative")"
|
|
154
|
+
[[ "$actual" == "$checksum" ]] || fail "checksum mismatch: $relative"
|
|
155
|
+
count=$((count + 1))
|
|
190
156
|
done < "$root/manifest.sha256"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
declared_paths="$(sed -n 's/^[0-9a-f]\{64\} //p' "$root/manifest.sha256" | LC_ALL=C sort)"
|
|
194
|
-
[[ "$actual_paths" == "$declared_paths" ]] || fail "manifest coverage is incomplete"
|
|
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"
|
|
195
159
|
}
|
|
196
160
|
|
|
197
|
-
|
|
198
|
-
local root="$1"
|
|
199
|
-
|
|
200
|
-
|
|
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)
|
|
201
174
|
}
|
|
202
175
|
|
|
203
|
-
|
|
204
|
-
local
|
|
205
|
-
[[ -f "$
|
|
206
|
-
|
|
207
|
-
|
|
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"
|
|
208
183
|
}
|
|
209
184
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
cmp -s "$SCRIPT_PATH" "$output_root/deploy.sh" || fail "generated deploy.sh differs from its source"
|
|
214
|
-
cmp -s "$SCRIPT_DIR/.env" "$output_root/.env" || fail "generated root .env differs from project .env"
|
|
215
|
-
[[ "$(cat "$output_root/release.env")" == "RELEASE_VERSION=$version" ]] || fail "invalid release.env"
|
|
216
|
-
[[ -f "$version_root/frontend-image.tar" && -f "$version_root/backend-image.tar" && -f "$compose" ]] || fail "dual image package is incomplete"
|
|
217
|
-
[[ "$(grep -Ec '^ (frontend|backend):$' "$compose")" == 2 ]] || fail "Compose must define frontend and backend only"
|
|
218
|
-
[[ "$(grep -Fc '../../logs:/app/logs' "$compose")" == 2 ]] || fail "both services must mount root logs"
|
|
219
|
-
! grep -Eqi 'blue|green|nginx-upstream|PROJECT_HTTP_PORT' "$compose" || fail "blue-green or host Nginx configuration is forbidden"
|
|
220
|
-
[[ ! -e "$version_root/database" ]] || fail "release package must not contain database migration files"
|
|
221
|
-
reject_version_secrets "$version_root"; verify_manifest "$version_root"
|
|
185
|
+
acquire_build_lock() {
|
|
186
|
+
BUILD_LOCK="$SCRIPT_DIR/.deploy-build.lock"
|
|
187
|
+
mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or a stale lock exists: $BUILD_LOCK"
|
|
222
188
|
}
|
|
223
189
|
|
|
224
|
-
|
|
225
|
-
|
|
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"
|
|
195
|
+
}
|
|
196
|
+
|
|
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
|
+
}
|
|
226
205
|
|
|
227
206
|
build_release() {
|
|
228
|
-
local project_root="$SCRIPT_DIR"
|
|
229
|
-
local
|
|
230
|
-
|
|
231
|
-
trap 'rm -rf -- "${staging_parent:-}" "${old_deploy:-}"; rm -f -- "${agents_prepared:-}"; release_build_lock' EXIT
|
|
232
|
-
[[ -r "$agents_file" && -w "$agents_file" ]] || fail "project AGENTS.md must be readable and writable"
|
|
233
|
-
load_dotenv "$project_root/.env"; validate_configuration
|
|
234
|
-
frontend_dockerfile="$(resolve_project_path "$project_root" "${FRONTEND_DOCKERFILE:-src/frontend/Dockerfile}")"
|
|
235
|
-
backend_dockerfile="$(resolve_project_path "$project_root" "${BACKEND_DOCKERFILE:-src/backend/Dockerfile}")"
|
|
236
|
-
dockerignore_file="$(resolve_project_path "$project_root" "${DOCKERIGNORE_FILE:-.dockerignore}")"
|
|
237
|
-
deploy_config_dir="$(resolve_project_path "$project_root" "${DEPLOY_CONFIG_DIR:-scripts/deployment}")"
|
|
238
|
-
compose_source="$(resolve_project_path "$project_root" "${COMPOSE_SOURCE:-${deploy_config_dir#"$project_root"/}/docker-compose.yml}")"
|
|
239
|
-
for command in node pnpm docker install find sort cmp awk sed grep; do require_command "$command"; done
|
|
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
|
|
240
210
|
select_checksum_tool
|
|
241
|
-
|
|
211
|
+
load_dotenv "$project_root/.env"
|
|
212
|
+
validate_configuration
|
|
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
|
|
242
220
|
validate_dockerignore "$dockerignore_file"
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
docker build --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "
|
|
250
|
-
docker build --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
install -m
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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=
|
|
255
|
+
trap - EXIT
|
|
256
|
+
echo "delivery archive created: deploy/$archive_name"
|
|
267
257
|
}
|
|
268
258
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
259
|
+
initialize_server_context() {
|
|
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
|
|
263
|
+
load_dotenv "$DEPLOY_ROOT/.env"
|
|
264
|
+
validate_configuration
|
|
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"
|
|
274
267
|
}
|
|
275
268
|
|
|
276
|
-
|
|
277
|
-
DEPLOY_ROOT
|
|
278
|
-
for command in docker curl tar find sort awk sed grep; do require_command "$command"; done
|
|
279
|
-
select_checksum_tool; mkdir -p "$DEPLOY_ROOT/logs"
|
|
269
|
+
compose() {
|
|
270
|
+
docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$DEPLOY_ROOT/docker-compose.yml" "$@"
|
|
280
271
|
}
|
|
281
272
|
|
|
282
273
|
record_operation() {
|
|
283
274
|
local result="$1" timestamp
|
|
284
275
|
[[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
|
|
285
276
|
timestamp="$(date '+%Y-%m-%d %H:%M:%S %z')"
|
|
286
|
-
printf '%s
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
local
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
[[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$FRONTEND_IMAGE")" == "$RELEASE_VERSION" ]] || fail "frontend loaded image version mismatch"
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
load_backend_image() {
|
|
314
|
-
local archive="$DEPLOY_ROOT/$RELEASE_VERSION/backend-image.tar"
|
|
315
|
-
archive_has_tag "$archive" "$BACKEND_IMAGE"; docker load -i "$archive" >/dev/null
|
|
316
|
-
[[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$BACKEND_IMAGE")" == "$RELEASE_VERSION" ]] || fail "backend loaded image version mismatch"
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
compose() {
|
|
320
|
-
FRONTEND_IMAGE="$FRONTEND_IMAGE" BACKEND_IMAGE="$BACKEND_IMAGE" RELEASE_VERSION="$RELEASE_VERSION" docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$DEPLOY_ROOT/$RELEASE_VERSION/docker/docker-compose.yml" "$@"
|
|
277
|
+
printf '%s operation=%s result=%s\n' "$timestamp" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
|
|
278
|
+
}
|
|
279
|
+
|
|
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
|
|
291
|
+
}
|
|
292
|
+
|
|
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
|
+
"$@"
|
|
299
|
+
trap - EXIT
|
|
300
|
+
record_operation success
|
|
321
301
|
}
|
|
322
302
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
health="$(endpoint_url "$DOCKER_BIND_IP" "$port" /health)"; version_url="$(endpoint_url "$DOCKER_BIND_IP" "$port" /version)"
|
|
327
|
-
curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$health" >/dev/null
|
|
328
|
-
actual="$(curl --fail --silent --show-error "$version_url")"
|
|
329
|
-
[[ "$actual" == "$RELEASE_VERSION" ]] || fail "$service version mismatch: expected $RELEASE_VERSION, got $actual"
|
|
303
|
+
server_deploy() {
|
|
304
|
+
initialize_server_context
|
|
305
|
+
run_logged_operation deploy deploy_stack
|
|
330
306
|
}
|
|
331
307
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
case "$target" in
|
|
336
|
-
frontend) load_frontend_image; compose up -d --force-recreate --no-deps frontend; verify_service frontend ;;
|
|
337
|
-
backend) load_backend_image; compose up -d --force-recreate --no-deps backend; verify_service backend ;;
|
|
338
|
-
both) load_frontend_image; load_backend_image; compose up -d --force-recreate frontend backend; verify_service frontend; verify_service backend ;;
|
|
339
|
-
*) fail "invalid deployment target: $target" ;;
|
|
340
|
-
esac
|
|
308
|
+
server_stop() {
|
|
309
|
+
initialize_server_context
|
|
310
|
+
run_logged_operation stop compose stop frontend backend
|
|
341
311
|
}
|
|
342
312
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
server_menu() {
|
|
346
|
-
local choice status
|
|
313
|
+
server_status() {
|
|
347
314
|
initialize_server_context
|
|
348
|
-
|
|
349
|
-
IFS= read -r choice || fail "failed to read deployment menu selection"
|
|
350
|
-
case "$choice" in
|
|
351
|
-
1) OPERATION=run-or-replace-frontend ;;
|
|
352
|
-
2) OPERATION=run-or-replace-backend ;;
|
|
353
|
-
3) OPERATION=run-or-replace-both ;;
|
|
354
|
-
4) OPERATION=stop-both ;;
|
|
355
|
-
*) OPERATION=invalid-selection; record_operation failure; fail "invalid deployment menu selection: $choice" ;;
|
|
356
|
-
esac
|
|
357
|
-
trap 'status=$?; if [[ $status -eq 0 ]]; then record_operation success; else record_operation failure; fi' EXIT
|
|
358
|
-
case "$choice" in
|
|
359
|
-
1) run_or_replace frontend ;;
|
|
360
|
-
2) run_or_replace backend ;;
|
|
361
|
-
3) run_or_replace both ;;
|
|
362
|
-
4) stop_services ;;
|
|
363
|
-
esac
|
|
315
|
+
compose ps frontend backend
|
|
364
316
|
}
|
|
365
317
|
|
|
366
|
-
if [[ "$#" -eq 0 ]]; then
|
|
318
|
+
if [[ "$#" -eq 0 ]]; then server_deploy
|
|
367
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
|
|
368
322
|
else usage
|
|
369
323
|
fi
|