#!/usr/bin/env bash # deploy/single/update.sh — pull-and-restart update for a DChain single node. # # Modes # ───── # 1. RELEASE mode (preferred): DCHAIN_UPDATE_SOURCE_URL is set, node has # /api/update-check — we trust that endpoint to tell us what's latest, # then git-checkout the matching tag. # 2. BRANCH mode (fallback): follow origin/main HEAD. # # Both modes share the same safety flow: # - Fast-forward check (no auto-rewriting local history). # - Smoke-test the new binary BEFORE killing the running container. # - Pre-restart best-effort health probe. # - Poll /api/netstats after restart, fail loud if unhealthy in 60s. # # Semver guard # ──────────── # If UPDATE_ALLOW_MAJOR=false (default), the script refuses to cross a major # version boundary (v1.x → v2.y). Operator must flip the env var or bump # manually, avoiding surprise breaking changes from unattended restarts. # # Exit codes: # 0 — up to date or successfully updated # 1 — new container didn't become healthy # 2 — smoke test of new image failed # 3 — git state issue (fetch failed, not fast-forwardable, etc.) # 4 — semver guard blocked the update set -euo pipefail REPO_DIR="${REPO_DIR:-/opt/dchain}" COMPOSE_FILE="${COMPOSE_FILE:-$REPO_DIR/deploy/single/docker-compose.yml}" IMAGE_NAME="${IMAGE_NAME:-dchain-node-slim}" CONTAINER="${CONTAINER:-dchain_node}" HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/netstats}" UPDATE_CHECK_URL="${UPDATE_CHECK_URL:-http://127.0.0.1:8080/api/update-check}" GIT_REMOTE="${GIT_REMOTE:-origin}" GIT_BRANCH="${GIT_BRANCH:-main}" UPDATE_ALLOW_MAJOR="${UPDATE_ALLOW_MAJOR:-false}" log() { printf '%s %s\n' "$(date -Iseconds)" "$*"; } die() { log "ERROR: $*"; exit "${2:-1}"; } command -v docker >/dev/null || die "docker not on PATH" 3 command -v git >/dev/null || die "git not on PATH" 3 command -v curl >/dev/null || die "curl not on PATH" 3 cd "$REPO_DIR" || die "cannot cd to REPO_DIR=$REPO_DIR" 3 # ── Auth header for /api/update-check + /api/netstats on private nodes ──── auth_args=() [[ -n "${DCHAIN_API_TOKEN:-}" ]] && auth_args=(-H "Authorization: Bearer ${DCHAIN_API_TOKEN}") # ── 1. Discover target version ──────────────────────────────────────────── target_tag="" target_commit="" if [[ -n "${DCHAIN_UPDATE_SOURCE_URL:-}" ]]; then log "querying release source via $UPDATE_CHECK_URL" check_json=$(curl -fsS -m 10 "${auth_args[@]}" "$UPDATE_CHECK_URL" 2>/dev/null || echo "") if [[ -n "$check_json" ]]; then # Minimal grep-based JSON extraction — avoids adding a jq dependency. # The shape is stable: we defined it in api_update_check.go. target_tag=$(printf '%s' "$check_json" | grep -o '"tag":"[^"]*"' | head -1 | sed 's/"tag":"\(.*\)"/\1/') update_available=$(printf '%s' "$check_json" | grep -o '"update_available":\(true\|false\)' | head -1 | cut -d: -f2) if [[ "$update_available" != "true" ]]; then log "up to date according to release source ($target_tag) — nothing to do" exit 0 fi log "release source reports new tag: $target_tag" else log "release source query failed — falling back to git branch mode" fi fi # ── 2. Fetch git ────────────────────────────────────────────────────────── log "fetching $GIT_REMOTE" git fetch --quiet --tags "$GIT_REMOTE" "$GIT_BRANCH" || die "git fetch failed" 3 local_sha=$(git rev-parse HEAD) if [[ -n "$target_tag" ]]; then # Release mode: target is the tag we just read from the API. if ! git rev-parse --verify "refs/tags/$target_tag" >/dev/null 2>&1; then die "release tag $target_tag unknown to local git — fetch may have missed it" 3 fi target_commit=$(git rev-parse "refs/tags/$target_tag^{commit}") else # Branch mode: target is remote branch HEAD. target_commit=$(git rev-parse "$GIT_REMOTE/$GIT_BRANCH") target_tag=$(git describe --tags --abbrev=0 "$target_commit" 2>/dev/null || echo "$target_commit") fi if [[ "$local_sha" == "$target_commit" ]]; then log "up to date at $local_sha — nothing to do" exit 0 fi log "updating $local_sha → $target_commit ($target_tag)" # ── 3. Semver guard ─────────────────────────────────────────────────────── # Extract major components from vX.Y.Z tags; refuse to cross boundary unless # operator opts in. Treats non-semver tags (e.g. raw SHA in branch mode) as # unversioned — guard is a no-op there. current_tag=$(cat "$REPO_DIR/.last-update" 2>/dev/null || echo "") if [[ -z "$current_tag" ]]; then current_tag=$(git describe --tags --abbrev=0 "$local_sha" 2>/dev/null || echo "") fi current_major=$(printf '%s' "$current_tag" | sed -nE 's/^v([0-9]+)\..*/\1/p') target_major=$(printf '%s' "$target_tag" | sed -nE 's/^v([0-9]+)\..*/\1/p') if [[ -n "$current_major" && -n "$target_major" && "$current_major" != "$target_major" ]]; then if [[ "$UPDATE_ALLOW_MAJOR" != "true" ]]; then die "major version jump $current_tag → $target_tag blocked — set UPDATE_ALLOW_MAJOR=true to override" 4 fi log "semver guard: accepting major jump $current_tag → $target_tag (UPDATE_ALLOW_MAJOR=true)" fi # ── 4. Fast-forward / checkout ──────────────────────────────────────────── if [[ -n "$target_tag" ]] && git rev-parse --verify "refs/tags/$target_tag" >/dev/null 2>&1; then # Release mode: check out the tag in detached HEAD. git checkout --quiet "$target_tag" || die "checkout $target_tag failed" 3 else git merge --ff-only "$GIT_REMOTE/$GIT_BRANCH" || die "cannot fast-forward — manual merge required" 3 fi # ── 5. Build image with version metadata ────────────────────────────────── version_tag="$target_tag" version_commit="$target_commit" version_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) version_dirty=$(git diff --quiet HEAD -- 2>/dev/null && echo false || echo true) log "building image $IMAGE_NAME:$version_commit ($version_tag)" docker build --quiet \ --build-arg "VERSION_TAG=$version_tag" \ --build-arg "VERSION_COMMIT=$version_commit" \ --build-arg "VERSION_DATE=$version_date" \ --build-arg "VERSION_DIRTY=$version_dirty" \ -t "$IMAGE_NAME:$version_commit" \ -t "$IMAGE_NAME:latest" \ -f deploy/prod/Dockerfile.slim . \ || die "docker build failed" 2 # ── 6. Smoke-test the new binary ────────────────────────────────────────── log "smoke-testing new image" smoke=$(docker run --rm --entrypoint /usr/local/bin/node "$IMAGE_NAME:$version_commit" --version 2>&1) \ || die "new image --version failed: $smoke" 2 log "smoke ok: $smoke" # ── 7. Recreate the container ───────────────────────────────────────────── log "recreating container $CONTAINER" docker compose -f "$COMPOSE_FILE" up -d --force-recreate node \ || die "docker compose up failed" 1 # ── 8. Wait for health ──────────────────────────────────────────────────── log "waiting for health at $HEALTH_URL" for i in $(seq 1 30); do if curl -fsS -m 3 "${auth_args[@]}" "$HEALTH_URL" >/dev/null 2>&1; then log "node healthy after $((i*2))s — update done ($version_tag @ ${version_commit:0:8})" printf '%s\n' "$version_tag" > .last-update exit 0 fi sleep 2 done log "new container did not become healthy in 60s — dumping logs" docker logs --tail 80 "$CONTAINER" || true exit 1