@wdyy/skills 0.1.12 → 0.1.14
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 +37 -47
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +110 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +161 -155
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +133 -95
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +109 -116
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +309 -298
- package/.well-known/skills/wdyy-deployment-standard/templates/backend.Dockerfile.template +17 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +231 -252
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +18 -13
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +3 -19
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +7 -4
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +12 -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
- package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +0 -22
|
@@ -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,304 @@ 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
|
|
58
|
+
unset API_PREFIX VITE_API_BASE_URL
|
|
59
|
+
unset DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA
|
|
60
|
+
unset PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
|
|
43
61
|
}
|
|
44
62
|
|
|
45
|
-
|
|
63
|
+
is_configuration_key() {
|
|
46
64
|
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)
|
|
65
|
+
FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|API_PREFIX|VITE_API_BASE_URL|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|DOCKER_PLATFORM|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE)
|
|
50
66
|
return 0 ;;
|
|
51
67
|
*) return 1 ;;
|
|
52
68
|
esac
|
|
53
69
|
}
|
|
54
70
|
|
|
55
|
-
|
|
71
|
+
is_removed_key() {
|
|
56
72
|
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)
|
|
73
|
+
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
74
|
return 0 ;;
|
|
62
75
|
*) return 1 ;;
|
|
63
76
|
esac
|
|
64
77
|
}
|
|
65
78
|
|
|
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
79
|
load_dotenv() {
|
|
73
|
-
local file="$1" raw line key value first last
|
|
80
|
+
local file="$1" raw line key value first last loaded
|
|
74
81
|
local -a loaded_keys=()
|
|
75
|
-
[[ -r "$file" ]] || fail "required root environment file is missing: $file"
|
|
82
|
+
[[ -r "$file" && -f "$file" && ! -L "$file" ]] || fail "required root environment file is missing or unsafe: $file"
|
|
76
83
|
validate_env_permissions "$file"
|
|
77
84
|
reset_configuration
|
|
78
85
|
while IFS= read -r raw || [[ -n "$raw" ]]; do
|
|
79
86
|
line="${raw%$'\r'}"
|
|
80
87
|
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
|
81
88
|
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || fail "invalid .env line; expected KEY=VALUE"
|
|
82
|
-
key="${BASH_REMATCH[1]}"
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
key="${BASH_REMATCH[1]}"
|
|
90
|
+
value="${BASH_REMATCH[2]}"
|
|
91
|
+
! is_removed_key "$key" || fail "removed deployment configuration is forbidden: $key"
|
|
92
|
+
[[ "$value" != *'$('* && "$value" != *'`'* ]] || fail "executable syntax is forbidden in .env: $key"
|
|
93
|
+
for loaded in "${loaded_keys[@]:-}"; do [[ "$loaded" != "$key" ]] || fail "duplicate .env key: $key"; done
|
|
85
94
|
loaded_keys+=("$key")
|
|
86
95
|
if [[ ${#value} -ge 2 ]]; then
|
|
87
|
-
first="${value:0:1}"
|
|
96
|
+
first="${value:0:1}"
|
|
97
|
+
last="${value: -1}"
|
|
88
98
|
if [[ "$first" == "'" || "$first" == '"' ]]; then
|
|
89
99
|
[[ "$last" == "$first" ]] || fail "unclosed quoted .env value: $key"
|
|
90
100
|
value="${value:1:${#value}-2}"
|
|
91
101
|
fi
|
|
92
102
|
fi
|
|
93
|
-
if
|
|
103
|
+
if is_configuration_key "$key"; then printf -v "$key" '%s' "$value"; fi
|
|
94
104
|
done < "$file"
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
validate_configuration() {
|
|
98
|
-
local
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
local key value
|
|
109
|
+
for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT API_PREFIX VITE_API_BASE_URL DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE; do
|
|
110
|
+
value="${!key:-}"
|
|
111
|
+
[[ -n "$value" ]] || fail "required configuration is missing: $key"
|
|
112
|
+
done
|
|
101
113
|
[[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] || fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
|
|
102
|
-
|
|
114
|
+
[[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
|
|
115
|
+
[[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
|
|
116
|
+
[[ "$API_PREFIX" =~ ^[A-Za-z][A-Za-z0-9_-]*$ ]] || fail "API_PREFIX must be one safe path segment"
|
|
117
|
+
[[ "$VITE_API_BASE_URL" == "/$API_PREFIX" ]] || fail "VITE_API_BASE_URL must equal /$API_PREFIX"
|
|
118
|
+
[[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
|
|
119
|
+
[[ "$DOCKER_PLATFORM" =~ ^linux/(amd64|arm64)$ ]] || fail "DOCKER_PLATFORM must be linux/amd64 or linux/arm64"
|
|
120
|
+
[[ "$DB_URL" =~ ^[A-Za-z0-9._:/?@\&=%-]+$ ]] || fail "DB_URL contains unsafe characters"
|
|
121
|
+
[[ "$FRONTEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "FRONTEND_BASE_IMAGE is not a valid image reference"
|
|
122
|
+
[[ "$BACKEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "BACKEND_BASE_IMAGE is not a valid image reference"
|
|
103
123
|
assert_port FRONTEND_PORT "$FRONTEND_PORT"
|
|
104
124
|
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
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
endpoint_url() {
|
|
118
|
-
local host
|
|
119
|
-
host="$(probe_host "$1")"
|
|
120
|
-
printf 'http://%s:%s%s\n' "$host" "$2" "$3"
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
resolve_project_path() {
|
|
124
|
-
local project_root="$1" candidate="$2"
|
|
125
|
-
if [[ "$candidate" = /* ]]; then printf '%s\n' "$candidate"; else printf '%s/%s\n' "$project_root" "$candidate"; fi
|
|
125
|
+
assert_port DB_PORT "$DB_PORT"
|
|
126
|
+
[[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must differ"
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
fi
|
|
129
|
+
assert_image_platform() {
|
|
130
|
+
local image="$1" actual
|
|
131
|
+
actual="$(docker image inspect --platform "$DOCKER_PLATFORM" --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null)" || fail "image platform is unavailable for $image: $DOCKER_PLATFORM"
|
|
132
|
+
[[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "image platform mismatch for $image: expected $DOCKER_PLATFORM, got $actual"
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
assert_server_platform() {
|
|
136
|
+
local actual
|
|
137
|
+
actual="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null)" || fail "Docker Server platform is unavailable"
|
|
138
|
+
[[ -n "$actual" ]] || fail "Docker Server platform is empty"
|
|
139
|
+
[[ "$actual" =~ ^linux/(amd64|arm64)$ ]] || fail "unsupported Docker Server platform: $actual"
|
|
140
|
+
[[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "Docker Server platform mismatch: expected $DOCKER_PLATFORM, got $actual"
|
|
137
141
|
}
|
|
138
142
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
143
|
+
validate_dockerignore() {
|
|
144
|
+
local file="$1" pattern line
|
|
145
|
+
[[ -f "$file" && ! -L "$file" ]] || fail "required Docker ignore file is missing: $file"
|
|
146
|
+
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
|
|
147
|
+
grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"
|
|
148
|
+
done
|
|
149
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
150
|
+
[[ "$line" != !* || "$line" == '!.env.example' ]] || fail "Docker ignore file contains forbidden reverse include: $line"
|
|
151
|
+
done < "$file"
|
|
175
152
|
}
|
|
176
153
|
|
|
177
154
|
create_manifest() {
|
|
178
|
-
local
|
|
179
|
-
|
|
155
|
+
local root="$1" file checksum
|
|
156
|
+
: > "$root/manifest.sha256"
|
|
157
|
+
for file in .env backend-image.tar deploy.sh docker-compose.yml frontend-image.tar; do
|
|
158
|
+
checksum="$(checksum_value "$root/$file")"
|
|
159
|
+
printf '%s %s\n' "$checksum" "$file" >> "$root/manifest.sha256"
|
|
160
|
+
done
|
|
180
161
|
}
|
|
181
162
|
|
|
182
163
|
verify_manifest() {
|
|
183
|
-
local root="$1" line checksum relative actual
|
|
184
|
-
[[ -s "$root/manifest.sha256" ]] || fail "missing or
|
|
164
|
+
local root="$1" line checksum relative actual count=0
|
|
165
|
+
[[ -s "$root/manifest.sha256" && ! -L "$root/manifest.sha256" ]] || fail "missing or unsafe manifest.sha256"
|
|
185
166
|
while IFS= read -r line; do
|
|
186
|
-
checksum="${line%% *}"
|
|
187
|
-
|
|
167
|
+
checksum="${line%% *}"
|
|
168
|
+
relative="${line#* }"
|
|
169
|
+
[[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != *'/'* ]] || fail "invalid manifest entry"
|
|
188
170
|
[[ -f "$root/$relative" && ! -L "$root/$relative" ]] || fail "manifest path is missing: $relative"
|
|
189
|
-
actual="$(checksum_value "$root/$relative")"
|
|
171
|
+
actual="$(checksum_value "$root/$relative")"
|
|
172
|
+
[[ "$actual" == "$checksum" ]] || fail "checksum mismatch: $relative"
|
|
173
|
+
count=$((count + 1))
|
|
190
174
|
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"
|
|
175
|
+
[[ "$count" == 5 ]] || fail "manifest must contain exactly five entries"
|
|
176
|
+
[[ "$(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
177
|
}
|
|
196
178
|
|
|
197
|
-
|
|
198
|
-
local root="$1"
|
|
199
|
-
|
|
200
|
-
|
|
179
|
+
validate_package_layout() {
|
|
180
|
+
local root="$1" entry name
|
|
181
|
+
for name in .env backend-image.tar deploy.sh docker-compose.yml frontend-image.tar manifest.sha256; do
|
|
182
|
+
[[ -f "$root/$name" && ! -L "$root/$name" ]] || fail "required package file is missing or unsafe: $name"
|
|
183
|
+
done
|
|
184
|
+
while IFS= read -r entry; do
|
|
185
|
+
name="${entry##*/}"
|
|
186
|
+
case "$name" in
|
|
187
|
+
.env|backend-image.tar|deploy.sh|docker-compose.yml|frontend-image.tar|manifest.sha256) ;;
|
|
188
|
+
logs) [[ -d "$entry" && ! -L "$entry" ]] || fail "logs must be a regular directory" ;;
|
|
189
|
+
*) fail "unexpected package entry: $name" ;;
|
|
190
|
+
esac
|
|
191
|
+
done < <(find "$root" -mindepth 1 -maxdepth 1 -print | LC_ALL=C sort)
|
|
201
192
|
}
|
|
202
193
|
|
|
203
|
-
|
|
204
|
-
local
|
|
205
|
-
[[ -f "$
|
|
206
|
-
|
|
207
|
-
|
|
194
|
+
archive_has_tag() {
|
|
195
|
+
local archive="$1" tag="$2" manifest compact count
|
|
196
|
+
[[ -f "$archive" && ! -L "$archive" ]] || fail "image archive is missing: $archive"
|
|
197
|
+
manifest="$(tar -xOf "$archive" manifest.json 2>/dev/null)" || fail "image archive is missing manifest.json: $archive"
|
|
198
|
+
compact="$(tr -d '[:space:]' <<< "$manifest")"
|
|
199
|
+
count="$(grep -o '"RepoTags"' <<< "$compact" | wc -l | tr -d ' ')"
|
|
200
|
+
[[ "$count" == 1 && "$compact" == *"\"RepoTags\":[\"$tag\"]"* ]] || fail "image archive must contain exactly the expected tag: $tag"
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
acquire_build_lock() {
|
|
204
|
+
BUILD_LOCK="$SCRIPT_DIR/.deploy-build.lock"
|
|
205
|
+
mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or a stale lock exists: $BUILD_LOCK"
|
|
208
206
|
}
|
|
209
207
|
|
|
210
|
-
|
|
211
|
-
local
|
|
212
|
-
[[ -
|
|
213
|
-
|
|
214
|
-
|
|
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"
|
|
208
|
+
cleanup_build() {
|
|
209
|
+
local status=$?
|
|
210
|
+
if [[ -n "${STAGING_ROOT:-}" && -d "$STAGING_ROOT" ]]; then rm -rf -- "$STAGING_ROOT"; fi
|
|
211
|
+
if [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]]; then rmdir "$BUILD_LOCK" 2>/dev/null || true; fi
|
|
212
|
+
return "$status"
|
|
222
213
|
}
|
|
223
214
|
|
|
224
|
-
|
|
225
|
-
|
|
215
|
+
ensure_deploy_output_scope() {
|
|
216
|
+
local deploy_dir="$1" archive_name="$2" entry
|
|
217
|
+
mkdir -p "$deploy_dir"
|
|
218
|
+
[[ -d "$deploy_dir" && ! -L "$deploy_dir" ]] || fail "deploy output must be a regular directory"
|
|
219
|
+
while IFS= read -r entry; do
|
|
220
|
+
[[ "${entry##*/}" == "$archive_name" && -f "$entry" && ! -L "$entry" ]] || fail "deploy directory contains unexpected entry: ${entry##*/}"
|
|
221
|
+
done < <(find "$deploy_dir" -mindepth 1 -maxdepth 1 -print)
|
|
222
|
+
}
|
|
226
223
|
|
|
227
224
|
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
|
|
225
|
+
local project_root="$SCRIPT_DIR" frontend_dockerfile backend_dockerfile compose_source dockerignore_file
|
|
226
|
+
local frontend_image backend_image package_root deploy_dir archive_name candidate
|
|
227
|
+
for command in docker tar install find sort grep sed awk mktemp; do require_command "$command"; done
|
|
240
228
|
select_checksum_tool
|
|
241
|
-
|
|
229
|
+
load_dotenv "$project_root/.env"
|
|
230
|
+
validate_configuration
|
|
231
|
+
frontend_dockerfile="$project_root/src/frontend/Dockerfile"
|
|
232
|
+
backend_dockerfile="$project_root/src/backend/Dockerfile"
|
|
233
|
+
compose_source="$project_root/scripts/deployment/docker-compose.yml"
|
|
234
|
+
dockerignore_file="$project_root/.dockerignore"
|
|
235
|
+
for file in "$frontend_dockerfile" "$backend_dockerfile" "$compose_source"; do
|
|
236
|
+
[[ -f "$file" && ! -L "$file" ]] || fail "required generated deployment file is missing: ${file#"$project_root"/}"
|
|
237
|
+
done
|
|
242
238
|
validate_dockerignore "$dockerignore_file"
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
239
|
+
docker image inspect "$FRONTEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local frontend base image is missing: $FRONTEND_BASE_IMAGE"
|
|
240
|
+
docker image inspect "$BACKEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local backend base image is missing: $BACKEND_BASE_IMAGE"
|
|
241
|
+
assert_image_platform "$FRONTEND_BASE_IMAGE"
|
|
242
|
+
assert_image_platform "$BACKEND_BASE_IMAGE"
|
|
243
|
+
frontend_image="${PROJECT_NAME}_frontend:latest"
|
|
244
|
+
backend_image="${PROJECT_NAME}_backend:latest"
|
|
245
|
+
acquire_build_lock
|
|
246
|
+
trap cleanup_build EXIT
|
|
247
|
+
docker build --pull=false --platform "$DOCKER_PLATFORM" --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "VITE_API_BASE_URL=$VITE_API_BASE_URL" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
|
|
248
|
+
docker build --pull=false --platform "$DOCKER_PLATFORM" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
|
|
249
|
+
docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "frontend image build did not create $frontend_image"
|
|
250
|
+
docker image inspect "$backend_image" >/dev/null 2>&1 || fail "backend image build did not create $backend_image"
|
|
251
|
+
assert_image_platform "$frontend_image"
|
|
252
|
+
assert_image_platform "$backend_image"
|
|
253
|
+
STAGING_ROOT="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
|
|
254
|
+
package_root="$STAGING_ROOT/package"
|
|
255
|
+
mkdir -p "$package_root"
|
|
256
|
+
install -m 0600 "$project_root/.env" "$package_root/.env"
|
|
257
|
+
install -m 0755 "$SCRIPT_PATH" "$package_root/deploy.sh"
|
|
258
|
+
install -m 0644 "$compose_source" "$package_root/docker-compose.yml"
|
|
259
|
+
docker save -o "$package_root/frontend-image.tar" "$frontend_image"
|
|
260
|
+
docker save -o "$package_root/backend-image.tar" "$backend_image"
|
|
261
|
+
archive_has_tag "$package_root/frontend-image.tar" "$frontend_image"
|
|
262
|
+
archive_has_tag "$package_root/backend-image.tar" "$backend_image"
|
|
263
|
+
create_manifest "$package_root"
|
|
264
|
+
validate_package_layout "$package_root"
|
|
265
|
+
verify_manifest "$package_root"
|
|
266
|
+
deploy_dir="$project_root/deploy"
|
|
267
|
+
archive_name="${PROJECT_NAME}-docker.tar.gz"
|
|
268
|
+
ensure_deploy_output_scope "$deploy_dir" "$archive_name"
|
|
269
|
+
candidate="$STAGING_ROOT/$archive_name"
|
|
270
|
+
env COPYFILE_DISABLE=1 tar --no-xattrs -czf "$candidate" -C "$package_root" .
|
|
271
|
+
tar -tzf "$candidate" >/dev/null || fail "generated delivery archive is invalid"
|
|
272
|
+
mv -f "$candidate" "$deploy_dir/$archive_name"
|
|
273
|
+
rm -rf -- "$STAGING_ROOT"
|
|
274
|
+
STAGING_ROOT=
|
|
275
|
+
rmdir "$BUILD_LOCK"
|
|
276
|
+
BUILD_LOCK=
|
|
277
|
+
trap - EXIT
|
|
278
|
+
echo "delivery archive created: deploy/$archive_name"
|
|
267
279
|
}
|
|
268
280
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
281
|
+
initialize_server_context() {
|
|
282
|
+
DEPLOY_ROOT="$SCRIPT_DIR"
|
|
283
|
+
for command in docker tar find sort grep sed awk; do require_command "$command"; done
|
|
284
|
+
select_checksum_tool
|
|
285
|
+
load_dotenv "$DEPLOY_ROOT/.env"
|
|
286
|
+
validate_configuration
|
|
287
|
+
[[ -f "$DEPLOY_ROOT/docker-compose.yml" && ! -L "$DEPLOY_ROOT/docker-compose.yml" ]] || fail "docker-compose.yml is missing"
|
|
288
|
+
mkdir -p "$DEPLOY_ROOT/logs"
|
|
274
289
|
}
|
|
275
290
|
|
|
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"
|
|
291
|
+
compose() {
|
|
292
|
+
docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$DEPLOY_ROOT/docker-compose.yml" "$@"
|
|
280
293
|
}
|
|
281
294
|
|
|
282
295
|
record_operation() {
|
|
283
296
|
local result="$1" timestamp
|
|
284
297
|
[[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
|
|
285
298
|
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
|
-
|
|
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" "$@"
|
|
299
|
+
printf '%s operation=%s result=%s\n' "$timestamp" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
deploy_stack() {
|
|
303
|
+
local frontend_image="${PROJECT_NAME}_frontend:latest" backend_image="${PROJECT_NAME}_backend:latest"
|
|
304
|
+
validate_package_layout "$DEPLOY_ROOT"
|
|
305
|
+
verify_manifest "$DEPLOY_ROOT"
|
|
306
|
+
archive_has_tag "$DEPLOY_ROOT/frontend-image.tar" "$frontend_image"
|
|
307
|
+
archive_has_tag "$DEPLOY_ROOT/backend-image.tar" "$backend_image"
|
|
308
|
+
assert_server_platform
|
|
309
|
+
docker load -i "$DEPLOY_ROOT/frontend-image.tar" >/dev/null
|
|
310
|
+
docker load -i "$DEPLOY_ROOT/backend-image.tar" >/dev/null
|
|
311
|
+
assert_image_platform "${PROJECT_NAME}_frontend:latest"
|
|
312
|
+
assert_image_platform "${PROJECT_NAME}_backend:latest"
|
|
313
|
+
docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "loaded frontend image is missing: $frontend_image"
|
|
314
|
+
docker image inspect "$backend_image" >/dev/null 2>&1 || fail "loaded backend image is missing: $backend_image"
|
|
315
|
+
compose up -d --force-recreate --no-build --pull never --wait frontend backend
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
run_logged_operation() {
|
|
319
|
+
local operation="$1"
|
|
320
|
+
shift
|
|
321
|
+
OPERATION="$operation"
|
|
322
|
+
trap 'status=$?; if [[ $status -eq 0 ]]; then record_operation success; else record_operation failure; fi' EXIT
|
|
323
|
+
"$@"
|
|
324
|
+
trap - EXIT
|
|
325
|
+
record_operation success
|
|
321
326
|
}
|
|
322
327
|
|
|
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"
|
|
328
|
+
server_deploy() {
|
|
329
|
+
initialize_server_context
|
|
330
|
+
run_logged_operation deploy deploy_stack
|
|
330
331
|
}
|
|
331
332
|
|
|
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
|
|
333
|
+
server_stop() {
|
|
334
|
+
initialize_server_context
|
|
335
|
+
run_logged_operation stop compose stop frontend backend
|
|
341
336
|
}
|
|
342
337
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
server_menu() {
|
|
346
|
-
local choice status
|
|
338
|
+
server_status() {
|
|
347
339
|
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
|
|
340
|
+
compose ps frontend backend
|
|
364
341
|
}
|
|
365
342
|
|
|
366
|
-
if [[ "$#" -eq 0 ]]; then
|
|
343
|
+
if [[ "$#" -eq 0 ]]; then server_deploy
|
|
367
344
|
elif [[ "$#" -eq 1 && "$1" == build ]]; then build_release
|
|
345
|
+
elif [[ "$#" -eq 1 && "$1" == stop ]]; then server_stop
|
|
346
|
+
elif [[ "$#" -eq 1 && "$1" == status ]]; then server_status
|
|
368
347
|
else usage
|
|
369
348
|
fi
|