Repository navigation
Expand file tree
/
Copy pathdeploy-php.sh
More file actions
executable file
·273 lines (244 loc) · 11.2 KB
/
Copy pathdeploy-php.sh
File metadata and controls
executable file
·273 lines (244 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
#!/usr/bin/env bash
# Rebuild + deploy: PostgreSQL -> SQLite -> shared host.
#
# Usage:
# ./deploy-php.sh [user@host] [remote_path] [flags]
#
# --code-only push the PHP tree, never the database. Manual releases from the Mac.
# --data-only push only posturi.sqlite. What the cron on the VPS runs.
# --no-export deploy the posturi.sqlite that is already there, don't rebuild it.
# --dry-run show what rsync would move, change nothing.
#
# Why the split: code deploys are manual from the development machine, while the
# data deploy runs unattended twice a day from the VPS. If the cron pushed the whole
# directory, the VPS's older checkout would silently revert templates pushed from the
# Mac. Each side ships only what it owns.
#
# Examples:
# ./deploy-php.sh user@example.com 'public_html' # both, one shot
# ./deploy-php.sh --code-only # after a template change
# ./deploy-php.sh --data-only --no-export # cron, after pipeline.py
# DEPLOY_HOST=user@example.com DEPLOY_PATH=public_html ./deploy-php.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DB_FILE="$SCRIPT_DIR/webapp-php/posturi.sqlite"
# Prefer the project venv (same convention as pipeline.py's VENV_PYTHON) so this
# runs correctly from a plain shell, not just one with the venv activated.
if [[ -x "$SCRIPT_DIR/.venv/bin/python" ]]; then
PYTHON="$SCRIPT_DIR/.venv/bin/python"
else
PYTHON="python3"
fi
deploy_code=1
deploy_data=1
do_export=1
dry_run=0
verify_only=0
positional=()
for arg in "$@"; do
case "$arg" in
--code-only) deploy_data=0 ;;
--data-only) deploy_code=0 ;;
--no-export) do_export=0 ;;
--dry-run) dry_run=1 ;;
--verify) verify_only=1 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
-*) echo "Unknown flag: $arg" >&2; exit 2 ;;
*) positional+=("$arg") ;;
esac
done
die() { echo "ERROR: $*" >&2; exit 1; }
# env_value(), shared with ops/run-pipeline.sh.
. "$SCRIPT_DIR/ops/env.sh"
ENV_FILE="$SCRIPT_DIR/.env"
DEPLOY_HOST="${positional[0]:-${DEPLOY_HOST:-$(env_value DEPLOY_HOST)}}"
DEPLOY_PATH="${positional[1]:-${DEPLOY_PATH:-$(env_value DEPLOY_PATH)}}"
SITE_URL="${SITE_URL:-$(env_value SITE_URL)}"
: "${DEPLOY_PATH:=public_html}"
if [[ -z "$DEPLOY_HOST" ]]; then
echo "Usage: $0 user@host [remote_path] [--code-only|--data-only] [--no-export]"
echo " or: DEPLOY_HOST=user@host ./deploy-php.sh"
echo " or: set DEPLOY_HOST / DEPLOY_PATH in .env"
exit 1
fi
# rsync --delete against the wrong path empties a home directory.
case "$DEPLOY_PATH" in
""|"/"|"."|".."|"~"|"~/") die "refusing to deploy to DEPLOY_PATH='$DEPLOY_PATH'" ;;
esac
if [[ "$DEPLOY_PATH" == "$HOME" || "$DEPLOY_PATH" == "$HOME/" ]]; then
die "DEPLOY_PATH is this machine's home directory — that is never the remote target"
fi
if [[ "$DEPLOY_PATH" == "$HOME/"* ]]; then
die "DEPLOY_PATH is '$DEPLOY_PATH' — an unquoted ~ expanded against the *local*
home. The remote host has a different one. Quote it: '~/posturi.gov2.ro'"
fi
echo "==> Target: ${DEPLOY_HOST}:${DEPLOY_PATH}/"
if [[ $dry_run -eq 1 ]]; then
echo "==> DRY RUN — nothing will be written"
do_export=0 # a dry run has no business spending three minutes on 50 MB
fi
# ---------------------------------------------------------------------------
# Version markers (FIX-06)
# ---------------------------------------------------------------------------
# The CODE marker is stamped from git at code-deploy time, so the served
# /versiuni.json always names the exact checkout the host is running. Data-only
# pushes never touch it; code-only pushes never touch the data marker.
code_sha() {
git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo ""
}
local_data_built_at() {
sqlite3 "$DB_FILE" "SELECT built_at FROM build_meta WHERE id=1;" 2>/dev/null || true
}
# Fetch the served /versiuni.json with cache bypass and print one field:
# served_marker code → the code SHA the host serves
# served_marker data.built_at → the data built_at the host serves
served_marker() {
[ -n "$SITE_URL" ] || { echo ""; return; }
curl -fsS -H 'Cache-Control: no-cache' --max-time 30 \
"${SITE_URL%/}/versiuni.json" 2>/dev/null \
| "$PYTHON" -c "
import json, sys
d = json.load(sys.stdin)
for key in '$1'.split('.'):
if d is None: break
d = d.get(key)
print(d or '')" 2>/dev/null || true
}
# Bounded retry: the shared host may serve the previous file for a moment.
verify_marker() { # $1 = field, $2 = expected, $3 = label
local field="$1" expected="$2" label="$3" got=""
[ -n "$expected" ] || { echo " (no local $label marker to compare — skipped)"; return 0; }
for attempt in 1 2 3 4 5; do
got="$(served_marker "$field")"
[ "$got" = "$expected" ] && break
sleep 5
done
if [ "$got" != "$expected" ]; then
echo " ✗ $label mismatch: served '$got', expected '$expected'"
return 1
fi
echo " ✓ $label verified: $expected"
return 0
}
# --verify: nothing is pushed — report the drift between this checkout/build
# and what the host serves, and exit non-zero when a release is pending.
# Placed after the marker helpers below; defined here for readability.
if [[ $verify_only -eq 1 ]]; then
[[ -n "$SITE_URL" ]] || die "--verify needs SITE_URL"
echo "==> Comparing local markers against ${SITE_URL} (nothing is pushed)"
drift=0
local_code="$(code_sha)"
served_code="$(served_marker code)"
if [[ -z "$local_code" ]]; then
echo " local code marker: none (outside a git checkout)"
elif [[ "$local_code" != "$served_code" ]]; then
echo " ✗ CODE release pending: local $local_code, served ${served_code:-none}"
drift=1
else
echo " ✓ code in sync: $local_code"
fi
local_built="$(local_data_built_at)"
served_built="$(served_marker data.built_at)"
if [[ -z "$local_built" ]]; then
echo " local data marker: none (no local build_meta)"
elif [[ "$local_built" != "$served_built" ]]; then
echo " ✗ DATA release pending: local built $local_built, served ${served_built:-none}"
drift=1
else
echo " ✓ data in sync: built $local_built"
fi
exit $drift
fi
# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
if [[ $deploy_code -eq 1 && ! -f "$SCRIPT_DIR/webapp-php/static/app.css" ]]; then
die "webapp-php/static/app.css is missing — run 'npm run css' first."
fi
if [[ $deploy_data -eq 1 && $do_export -eq 1 ]]; then
echo "==> Exporting PostgreSQL -> SQLite (active only)..."
"$PYTHON" "$SCRIPT_DIR/export-to-sqlite.py" --active-only --out "$DB_FILE"
fi
# The export enforces its own floors, but --no-export means somebody else built this
# file. Never ship one that cannot be opened or has nothing in it.
if [[ $deploy_data -eq 1 ]]; then
[[ -f "$DB_FILE" ]] || die "$DB_FILE not found — drop --no-export, or run export-to-sqlite.py"
if command -v sqlite3 >/dev/null 2>&1; then
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -n 1)
[[ "$integrity" == "ok" ]] || die "$DB_FILE fails integrity_check: $integrity"
rows=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM job_postings;" 2>/dev/null || echo 0)
[[ "$rows" -gt 0 ]] || die "$DB_FILE has no job_postings — refusing to deploy an empty site"
built=$(sqlite3 "$DB_FILE" "SELECT built_at FROM build_meta WHERE id=1;" 2>/dev/null || true)
echo "==> Database: ${rows} postings, built ${built:-unknown}"
fi
fi
# ---------------------------------------------------------------------------
# Deploy
# ---------------------------------------------------------------------------
rsync_common=(-avz --no-perms --no-owner --no-group --omit-dir-times)
if [[ $dry_run -eq 1 ]]; then rsync_common+=(--dry-run); fi
if [[ $deploy_code -eq 1 ]]; then
# Stamp the code marker before the push; the file is gitignored, so a
# checkout without it simply has no marker until its first code deploy.
if [[ $dry_run -eq 0 ]]; then
code_sha > "$SCRIPT_DIR/webapp-php/static/code-version.txt"
fi
echo "==> Deploying code (PHP, static, .htaccess)"
# Excluding *.sqlite* also protects it from --delete: the live database is not
# ours to remove, and assets/, router.php and tests/ are development-only —
# the fixture tests spawn servers and must never be web-requestable.
rsync "${rsync_common[@]}" --delete \
--exclude='.DS_Store' \
--exclude='assets/' \
--exclude='router.php' \
--exclude='tests/' \
--exclude='*.sqlite' \
--exclude='*.sqlite-wal' \
--exclude='*.sqlite-shm' \
"$SCRIPT_DIR/webapp-php/" \
"${DEPLOY_HOST}:${DEPLOY_PATH}/"
fi
if [[ $deploy_data -eq 1 ]]; then
echo "==> Deploying database"
if [[ $dry_run -eq 0 ]]; then
# Left over from when the export shipped a WAL database. A -wal describing a
# file that rsync has since replaced reads as "disk image is malformed".
ssh "$DEPLOY_HOST" \
"rm -f ${DEPLOY_PATH}/posturi.sqlite-wal ${DEPLOY_PATH}/posturi.sqlite-shm"
fi
# No --delete, and no --inplace: rsync writes a temp file and renames it over the
# target, so a request served mid-transfer still sees the whole previous database.
rsync "${rsync_common[@]}" \
"$DB_FILE" \
"${DEPLOY_HOST}:${DEPLOY_PATH}/posturi.sqlite"
fi
# ---------------------------------------------------------------------------
# Verify — the served markers, not just an HTTP 200
# ---------------------------------------------------------------------------
if [[ $dry_run -eq 0 && -n "$SITE_URL" ]]; then
echo "==> Checking ${SITE_URL}"
code=$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 30 "$SITE_URL" || echo "000")
[[ "$code" == "200" ]] || die "site returned HTTP ${code} after deploy"
echo " HTTP 200"
verify_failed=0
if [[ $deploy_code -eq 1 ]]; then
verify_marker code "$(code_sha)" "code" || verify_failed=1
fi
if [[ $deploy_data -eq 1 ]]; then
# A data-only push (the VPS cron) can land on a host still running code
# from before /versiuni.json existed (FIX-06). That host cannot report
# a marker, so a 404 there means "unverifiable", not "mismatch" —
# failing would block every unattended run until the next code deploy
# (REV-15). A code push ships the endpoint, so it must answer.
endpoint=$(curl -sS -o /dev/null -w '%{http_code}' -H 'Cache-Control: no-cache' \
--max-time 30 "${SITE_URL%/}/versiuni.json" 2>/dev/null || echo "000")
if [[ $deploy_code -eq 0 && "$endpoint" == "404" ]]; then
echo " ⚠ data built_at not verified: the host serves code without /versiuni.json"
echo " (pre-FIX-06). Run ./deploy-php.sh --code-only from the dev machine."
else
verify_marker "data.built_at" "$(local_data_built_at)" "data built_at" || verify_failed=1
fi
fi
[[ $verify_failed -eq 0 ]] || die "served version markers do not match this push"
fi
echo "==> Done."