feat(deploy): reliability API routes, atomic releases, version single-source
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build helpers shared by Mark VIII deploy scripts.
|
||||
|
||||
stamp_version() stamps release metadata into BUILD COPIES ONLY: callers
|
||||
always pass a staging/build directory, never the source tree, so the repo
|
||||
stays clean while every deployed artifact reports the same VERSION.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
VERSION_RE = re.compile(r'^[0-9][A-Za-z0-9._-]{0,31}$')
|
||||
SERVER_VERSION_LINE = "SERVER_VERSION = '%s'\n"
|
||||
|
||||
|
||||
def _stamp_manifest(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
data['version'] = version
|
||||
with open(path, 'w', encoding='ascii') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write('\n')
|
||||
return True
|
||||
|
||||
|
||||
def _stamp_payload_sh(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
return False
|
||||
out = []
|
||||
replaced = False
|
||||
for line in lines:
|
||||
m = None if replaced \
|
||||
else re.match(r'^(\s*#\s*[Vv]ersion:).*$', line)
|
||||
if m:
|
||||
out.append(m.group(1) + ' ' + version + '\n')
|
||||
replaced = True
|
||||
else:
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
insert = 1 if lines and lines[0].startswith('#!') else 0
|
||||
out.insert(insert, '# Version: %s\n' % version)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(out)
|
||||
return True
|
||||
|
||||
|
||||
def _server_insert_index(lines):
|
||||
"""Index just past any shebang/comments/blanks and module docstring."""
|
||||
i = 0
|
||||
n = len(lines)
|
||||
if i < n and lines[i].startswith('#!'):
|
||||
i += 1
|
||||
while i < n and (lines[i].strip().startswith('#')
|
||||
or not lines[i].strip()):
|
||||
i += 1
|
||||
if i < n:
|
||||
stripped = lines[i].lstrip()
|
||||
quote = stripped[:3]
|
||||
if quote in ('"""', "'''"):
|
||||
closed_here = quote in stripped[3:]
|
||||
i += 1
|
||||
if not closed_here:
|
||||
while i < n and quote not in lines[i]:
|
||||
i += 1
|
||||
i += 1
|
||||
return min(i, len(lines))
|
||||
|
||||
|
||||
def _stamp_server_py(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
return False
|
||||
lines = [l for l in lines if not l.startswith('SERVER_VERSION')]
|
||||
idx = _server_insert_index(lines)
|
||||
lines.insert(idx, SERVER_VERSION_LINE % version)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
return True
|
||||
|
||||
|
||||
def stamp_version(build_dir, version):
|
||||
"""Stamp <version> into build copies found under build_dir.
|
||||
|
||||
Updates every ``_hak5_manifest.json`` (version field), ``payload.sh``
|
||||
(header Version line) and ``server.py`` (injected SERVER_VERSION
|
||||
constant near the top). Idempotent: re-running never duplicates the
|
||||
injected constant or header. Returns the list of stamped paths."""
|
||||
if not VERSION_RE.match(str(version)):
|
||||
raise ValueError('invalid version string: %r' % (version,))
|
||||
stamped = []
|
||||
for root, dirs, files in os.walk(build_dir):
|
||||
for fname in ('_hak5_manifest.json', 'payload.sh', 'server.py'):
|
||||
if fname in files:
|
||||
path = os.path.join(root, fname)
|
||||
if fname == '_hak5_manifest.json':
|
||||
ok = _stamp_manifest(path, version)
|
||||
elif fname == 'payload.sh':
|
||||
ok = _stamp_payload_sh(path, version)
|
||||
else:
|
||||
ok = _stamp_server_py(path, version)
|
||||
if ok:
|
||||
stamped.append(path)
|
||||
return sorted(stamped)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
print('usage: build_common.py <build_dir> <version>',
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
for path in stamp_version(sys.argv[1], sys.argv[2]):
|
||||
print('stamped: %s' % path)
|
||||
+97
-36
@@ -21,6 +21,11 @@ Options:
|
||||
--no-portal-refresh Skip the best-effort portal refresh
|
||||
-h, --help Show this help
|
||||
|
||||
Deploys to /mmc/mk8/releases/<ts>/ with an atomically repointed 'current'
|
||||
symlink, mirrors the payload into the legacy /root/payloads location the
|
||||
init scripts run from, verifies sha256 of the upload, and polls the local
|
||||
API after start; on failure it rolls the symlink + legacy dir back.
|
||||
|
||||
If neither --password nor --ssh-key is supplied, ssh/scp prompt normally.
|
||||
EOF
|
||||
}
|
||||
@@ -61,6 +66,17 @@ STAGE="$OUT_DIR/stage"
|
||||
printf 'Payload directory not found: %s\n' "$PAYLOAD_DIR" >&2
|
||||
exit 1
|
||||
}
|
||||
VERSION_FILE="$ROOT/VERSION"
|
||||
[[ -f "$VERSION_FILE" ]] || {
|
||||
printf 'VERSION file not found: %s\n' "$VERSION_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")"
|
||||
[[ -n "$VERSION" ]] || {
|
||||
printf 'VERSION file is empty.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'Deploying Mark VIII version %s\n' "$VERSION"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
rm -rf "$STAGE"
|
||||
@@ -68,6 +84,10 @@ mkdir -p "$STAGE/user/$PAYLOAD_CATEGORY"
|
||||
cp -R "$PAYLOAD_DIR" "$STAGE/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
|
||||
find "$STAGE" \( -type d -name __pycache__ -o -type f -name '*.pyc' \) -prune -exec rm -rf {} +
|
||||
|
||||
# Stamp build copies only (never the source tree): payload.sh header,
|
||||
# staged server.py SERVER_VERSION constant; manifest is stamped below.
|
||||
python3 "$ROOT/scripts/build_common.py" "$STAGE" "$VERSION"
|
||||
|
||||
B64_KEY="$(python3 -c 'import base64; print(base64.urlsafe_b64encode(b"pager-webui").decode().rstrip("="))')"
|
||||
ZIP_NAME="payload-$B64_KEY.zip"
|
||||
ZIP_PATH="$OUT_DIR/$ZIP_NAME"
|
||||
@@ -94,7 +114,9 @@ with open(destination, 'w', encoding='ascii') as handle:
|
||||
json.dump(manifest, handle, indent=2)
|
||||
handle.write('\n')
|
||||
PY
|
||||
printf 'Built: %s\n' "$ZIP_PATH"
|
||||
# Single-source version: stamp the generated manifest copy too.
|
||||
python3 "$ROOT/scripts/build_common.py" "$MANIFEST_PATH" "$VERSION" >/dev/null
|
||||
printf 'Built: %s (sha256 %s)\n' "$ZIP_PATH" "$HASH"
|
||||
|
||||
TARGET="$PAGER_USER@$PAGER_HOST"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new)
|
||||
@@ -166,49 +188,88 @@ echo PYTHON_OK'
|
||||
|
||||
install_python3
|
||||
|
||||
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/"
|
||||
run_ssh "$TARGET" 'mkdir -p /tmp/mk8-stage && rm -rf /tmp/mk8-stage/*'
|
||||
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/mk8-stage/"
|
||||
|
||||
REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
|
||||
LEGACY_PAYLOAD_DIR="user/general/$PAYLOAD_KEY"
|
||||
RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
|
||||
REMOTE_COMMAND="set -e
|
||||
cd /root/payloads
|
||||
stage='.pager-webui.deploy.\$\$'
|
||||
backup='.pager-webui.backup.\$\$'
|
||||
trap 'rm -rf \"\$stage\" \"\$backup\"' EXIT
|
||||
mkdir -p \"\$stage\"
|
||||
cd \"\$stage\"
|
||||
unzip -q '/tmp/$ZIP_NAME'
|
||||
new=\"\$PWD/$REMOTE_PAYLOAD_DIR\"
|
||||
[ -f \"\$new/server.py\" ] && [ -f \"\$new/payload.sh\" ] && [ -d \"\$new/www\" ]
|
||||
cp /tmp/_hak5_manifest.json \"\$new/_hak5_manifest.json\"
|
||||
chmod +x \"\$new/payload.sh\" \"\$new/pagerwebui.init\"
|
||||
chmod -R 755 \"\$new/www\"
|
||||
cd /root/payloads
|
||||
if [ -d '$REMOTE_PAYLOAD_DIR' ]; then
|
||||
mkdir -p \"\$(dirname \"\$backup\")\"
|
||||
mv '$REMOTE_PAYLOAD_DIR' \"\$backup\"
|
||||
fi
|
||||
if mv \"\$new\" '$REMOTE_PAYLOAD_DIR'; then
|
||||
rm -rf \"\$backup\" '$LEGACY_PAYLOAD_DIR'
|
||||
else
|
||||
[ ! -d \"\$backup\" ] || mv \"\$backup\" '$REMOTE_PAYLOAD_DIR'
|
||||
STAGE_DIR='/tmp/mk8-stage'
|
||||
ZIP='\$STAGE_DIR/$ZIP_NAME'
|
||||
RELDIR='/mmc/mk8/releases/$RELEASE_TS'
|
||||
PAYDIR='$REMOTE_PAYLOAD_DIR'
|
||||
LIVE=\"/root/payloads/\$PAYDIR\"
|
||||
BACKUP=\"/root/payloads/.pager-webui.backup.\$\$\"
|
||||
CURRENT='/mmc/mk8/releases/current'
|
||||
PREV=\$(readlink \$CURRENT 2>/dev/null || true)
|
||||
cleanup() { rm -rf \"\$STAGE_DIR\"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Upload integrity gate: remote sha256 must match the local build hash.
|
||||
GOT=\$(sha256sum \"\$ZIP\" | awk '{print \$1}')
|
||||
[ \"\$GOT\" = '$HASH' ] || { echo 'sha256 mismatch on uploaded zip' >&2; exit 1; }
|
||||
[ -f \"\$STAGE_DIR/_hak5_manifest.json\" ] || { echo 'manifest missing' >&2; exit 1; }
|
||||
|
||||
/etc/init.d/pagerwebui stop >/dev/null 2>&1 || true
|
||||
mkdir -p \"\$RELDIR\"
|
||||
unzip -q \"\$ZIP\" -d \"\$RELDIR\"
|
||||
NEW=\"\$RELDIR/\$PAYDIR\"
|
||||
[ -f \"\$NEW/server.py\" ] && [ -f \"\$NEW/payload.sh\" ] && [ -d \"\$NEW/www\" ] || {
|
||||
echo 'release payload incomplete' >&2
|
||||
rm -rf \"\$RELDIR\"
|
||||
exit 1
|
||||
}
|
||||
cp \"\$STAGE_DIR/_hak5_manifest.json\" \"\$NEW/_hak5_manifest.json\"
|
||||
chmod +x \"\$NEW/payload.sh\" \"\$NEW/pagerwebui.init\"
|
||||
chmod -R 755 \"\$NEW/www\"
|
||||
ln -sfn \"\$RELDIR\" \"\$CURRENT\"
|
||||
|
||||
# Mirror into the legacy /root/payloads path the init scripts execute.
|
||||
rollback_install() {
|
||||
rm -rf \"\$LIVE\"
|
||||
[ ! -d \"\$BACKUP\" ] || mv \"\$BACKUP\" \"\$LIVE\"
|
||||
[ -z \"\$PREV\" ] || ln -sfn \"\$PREV\" \"\$CURRENT\"
|
||||
}
|
||||
if [ -d \"\$LIVE\" ]; then mv \"\$LIVE\" \"\$BACKUP\"; fi
|
||||
if ! mkdir -p \"\$LIVE\" || ! cp -a \"\$NEW/.\" \"\$LIVE/\"; then
|
||||
rollback_install
|
||||
exit 1
|
||||
fi
|
||||
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
|
||||
cp '$REMOTE_PAYLOAD_DIR/pagerwebui.init' /etc/init.d/pagerwebui
|
||||
cp -f \"\$LIVE/pagerwebui.init\" /etc/init.d/pagerwebui
|
||||
chmod +x /etc/init.d/pagerwebui
|
||||
/etc/init.d/pagerwebui enable
|
||||
if /etc/init.d/pagerwebui running >/dev/null 2>&1; then
|
||||
/etc/init.d/pagerwebui restart
|
||||
else
|
||||
/etc/init.d/pagerwebui start
|
||||
fi
|
||||
cp -f '$REMOTE_PAYLOAD_DIR/mk8-guard.init' /etc/init.d/mk8-guard
|
||||
cp -f \"\$LIVE/mk8-guard.init\" /etc/init.d/mk8-guard
|
||||
chmod 755 /etc/init.d/mk8-guard
|
||||
/etc/init.d/mk8-guard enable
|
||||
echo EXTRACT_OK"
|
||||
run_ssh "$TARGET" "$REMOTE_COMMAND"
|
||||
printf 'Installed to /root/payloads/%s/\n' "$REMOTE_PAYLOAD_DIR"
|
||||
/etc/init.d/pagerwebui start
|
||||
|
||||
# Post-deploy verification: API must answer within 60s or we roll back.
|
||||
i=0
|
||||
HEALTH_OK=''
|
||||
while [ \$i -lt 60 ]; do
|
||||
if curl -fsS -m 3 http://127.0.0.1:8080/api/api_ping >/dev/null 2>&1; then
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
i=\$((i+1))
|
||||
done
|
||||
if [ -z \"\$HEALTH_OK\" ]; then
|
||||
echo 'post-deploy health check failed; rolling back' >&2
|
||||
/etc/init.d/pagerwebui stop >/dev/null 2>&1 || true
|
||||
rollback_install
|
||||
rm -rf \"\$RELDIR\"
|
||||
/etc/init.d/pagerwebui start
|
||||
exit 1
|
||||
fi
|
||||
rm -rf \"\$BACKUP\"
|
||||
echo RELEASE_OK@\"\$RELDIR\""
|
||||
if run_ssh "$TARGET" "$REMOTE_COMMAND"; then
|
||||
printf 'Release active: /mmc/mk8/releases/%s\n' "$RELEASE_TS"
|
||||
else
|
||||
printf 'Deployment failed; previous release restored on the pager.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $PORTAL_REFRESH && [[ -n "$PASSWORD" ]]; then
|
||||
PASSWORD_B64="$(printf '%s' "$PASSWORD" | base64)"
|
||||
@@ -235,4 +296,4 @@ elif $PORTAL_REFRESH; then
|
||||
printf 'Skipping portal refresh without --password; payload installation is complete.\n'
|
||||
fi
|
||||
|
||||
printf 'Deploy complete. Browse http://%s:8080/\n' "$PAGER_HOST"
|
||||
printf 'Deploy complete (v%s). Browse http://%s:8080/\n' "$VERSION" "$PAGER_HOST"
|
||||
|
||||
Reference in New Issue
Block a user