#!/bin/bash set -euo pipefail usage() { cat <&2 usage exit 1 fi DEST="$2" shift 2 ;; -dest=*) DEST="${1#*=}" shift ;; -h|--help) usage exit 0 ;; *) echo "Error: Unknown argument: $1" >&2 usage exit 1 ;; esac done case "$DEST" in prod|stage) ;; *) echo "Error: Invalid -dest '$DEST' (use prod or stage)." >&2 usage exit 1 ;; esac SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" VERSION_FILE="$REPO_ROOT/VERSION" DEFAULT_VERSION="0.1.0.0" MAX_WAIT=90 REMOTE_USER="${REMOTE_USER:-root}" if [[ "$DEST" == "stage" ]]; then REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}" REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok-staging}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.staging.yml}" BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}" APP_URL="${APP_URL:-https://staging.kapteins-daagbok.eu}" DEPLOY_BRANCH="${DEPLOY_BRANCH:-master}" ENV_LABEL="Staging" else REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}" REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}" APP_URL="${APP_URL:-https://kapteins-daagbok.eu}" DEPLOY_BRANCH="" ENV_LABEL="Production" fi REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}" echo "==================================================" echo " Kapteins Daagbok ${ENV_LABEL} Update" echo "==================================================" echo "Destination: ${DEST}" echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}" if [[ "$DEST" == "stage" ]]; then echo "Branch: ${DEPLOY_BRANCH}" fi echo "URL: ${APP_URL}" echo "==================================================" cd "$REPO_ROOT" read_current_version() { if [ -f "$VERSION_FILE" ]; then tr -d '[:space:]' < "$VERSION_FILE" return fi local latest_tag latest_tag="$(git tag -l 'v*' --sort=-v:refname | head -n 1 || true)" if [ -n "$latest_tag" ]; then echo "${latest_tag#v}" return fi echo "$DEFAULT_VERSION" } bump_patch_version() { local version="$1" local major minor patch build IFS='.' read -r major minor patch build <<< "$version" major="${major:-0}" minor="${minor:-1}" patch="${patch:-0}" build="${build:-0}" build=$((10#$build + 1)) echo "${major}.${minor}.${patch}.${build}" } ensure_clean_git_tree() { if [ -z "$(git status --porcelain)" ]; then return 0 fi echo "" echo "Uncommitted local changes detected:" git status --short echo "" read -r -p "Commit all changes now before release? [y/N] " answer if [[ ! "$answer" =~ ^[yY]$ ]]; then echo "Aborting: working tree is not clean." exit 1 fi read -r -p "Commit message: " commit_message if [ -z "$commit_message" ]; then echo "Aborting: commit message is required." exit 1 fi git add -A git commit -m "$commit_message" } ensure_local_sync_with_origin() { local branch="$1" local local_sha origin_sha current_branch if [ -z "$branch" ]; then echo "Error: deploy branch is not set." >&2 exit 1 fi if [ -n "$(git status --porcelain)" ]; then echo "Error: Working tree is not clean. Commit or stash changes before deploying." >&2 git status --short exit 1 fi current_branch="$(git branch --show-current)" if [ -z "$current_branch" ]; then echo "Error: Detached HEAD — checkout branch '$branch' before deploying." >&2 exit 1 fi if [ "$current_branch" != "$branch" ]; then echo "Error: On branch '$current_branch', expected '$branch'." >&2 exit 1 fi echo "Syncing with origin..." git fetch --tags origin if [ $? -ne 0 ]; then echo "Error: git fetch origin failed." >&2 exit 1 fi if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then echo "Error: origin/${branch} does not exist." >&2 exit 1 fi local_sha="$(git rev-parse HEAD)" origin_sha="$(git rev-parse "origin/${branch}")" if [ "$local_sha" = "$origin_sha" ]; then echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))." return 0 fi echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2 echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2 echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2 if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then echo "Hint: run 'git pull' to fast-forward." >&2 elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then echo "Hint: run 'git push origin ${branch}' before deploying." >&2 else echo "Hint: branches have diverged — reconcile manually before deploying." >&2 fi exit 1 } prepare_release() { local current_version release_version next_version tag_name ensure_clean_git_tree current_version="$(read_current_version)" release_version="$current_version" next_version="$(bump_patch_version "$current_version")" tag_name="v${release_version}" if git rev-parse "$tag_name" >/dev/null 2>&1; then echo "Error: Git tag '$tag_name' already exists." exit 1 fi echo "$next_version" > "$VERSION_FILE" git add "$VERSION_FILE" git commit -m "chore: release ${tag_name}" git tag -a "$tag_name" -m "Release ${tag_name}" echo "" echo "Prepared release ${tag_name}" echo " Released: ${tag_name}" echo " Next prep: v${next_version}" echo "" read -r -p "Push commit and tag to origin? [Y/n] " push_answer if [[ ! "$push_answer" =~ ^[nN]$ ]]; then current_branch="$(git branch --show-current)" git push origin "$current_branch" git push origin "$tag_name" echo "Pushed ${current_branch} and ${tag_name} to origin." else echo "Skipped push. Remote host must receive this commit/tag manually." fi export APP_VERSION="$release_version" } if [[ "$DEST" == "prod" ]]; then prepare_release ensure_local_sync_with_origin "$(git branch --show-current)" else ensure_local_sync_with_origin "$DEPLOY_BRANCH" APP_VERSION="$(read_current_version)" fi if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)." else echo "==================================================" echo " Pre-deploy checks (local)" echo "==================================================" "$SCRIPT_DIR/predeploy-check.sh" fi echo "==================================================" echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}" echo "==================================================" ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \ "$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT' set -uo pipefail REMOTE_DIR="$1" COMPOSE_FILE="$2" BACKEND_CONTAINER="$3" MAX_WAIT="$4" APP_URL="$5" APP_VERSION="$6" DEST="$7" DEPLOY_BRANCH="${8:-}" cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; } if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then echo "Warning: Local changes on deployment host will be discarded." fi if [[ "$DEST" == "prod" ]]; then echo "Creating pre-deploy backup..." if [ -x "./scripts/backup.sh" ]; then if ! ./scripts/backup.sh --reason pre-deploy --tag "v${APP_VERSION}"; then echo "Error: Pre-deploy backup failed. Aborting update." exit 1 fi else echo "Warning: scripts/backup.sh not found or not executable — skipping backup." fi fi if [[ "$DEST" == "stage" ]]; then echo "Syncing repository from origin/${DEPLOY_BRANCH}..." git fetch origin if [ $? -ne 0 ]; then echo "Error: Git fetch failed." exit 1 fi git checkout "$DEPLOY_BRANCH" 2>/dev/null || git checkout -b "$DEPLOY_BRANCH" "origin/${DEPLOY_BRANCH}" git reset --hard "origin/${DEPLOY_BRANCH}" if [ $? -ne 0 ]; then echo "Error: Git reset to origin/${DEPLOY_BRANCH} failed." exit 1 fi else echo "Syncing repository from origin..." CURRENT_BRANCH="$(git branch --show-current)" if [ -z "$CURRENT_BRANCH" ]; then echo "Error: Could not determine current Git branch." exit 1 fi git fetch --tags origin if [ $? -ne 0 ]; then echo "Error: Git fetch failed." exit 1 fi git reset --hard "origin/${CURRENT_BRANCH}" if [ $? -ne 0 ]; then echo "Error: Git reset to origin/${CURRENT_BRANCH} failed." exit 1 fi REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)" if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})." echo " Building deployed release v${APP_VERSION}." fi fi export APP_VERSION="$APP_VERSION" if [[ "$DEST" == "prod" ]]; then echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..." docker compose -f "$COMPOSE_FILE" build --no-cache else echo "Rebuilding Docker images (APP_VERSION=${APP_VERSION})..." docker compose -f "$COMPOSE_FILE" build fi if [ $? -ne 0 ]; then echo "Error: Docker compose build failed." exit 1 fi echo "Starting updated container stack..." docker compose -f "$COMPOSE_FILE" up -d if [ $? -ne 0 ]; then echo "Error: Failed to spin up docker-compose stack." exit 1 fi echo "Cleaning up old/unused Docker resources..." docker system prune -f || echo "Warning: Docker system prune failed." echo "Waiting for services to become healthy..." COUNTER=0 IS_READY=false while [ $COUNTER -lt $MAX_WAIT ]; do STATUS=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true) if [ "$STATUS" = "healthy" ]; then IS_READY=true break fi # End-to-end fallback via frontend nginx (covers missing/stale container health state) if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then IS_READY=true break fi sleep 1 COUNTER=$((COUNTER + 1)) printf "." done echo "" echo "==================================================" echo "Container Statuses:" docker compose -f "$COMPOSE_FILE" ps echo "==================================================" if [ "$IS_READY" = true ]; then echo "SUCCESS: ${DEST} environment updated and healthy!" echo " -> Version: v${APP_VERSION}" echo " -> App Frontend: ${APP_URL}" echo " -> Backend API Health: ${APP_URL}/api/health" echo "==================================================" else echo "WARNING: Backend did not transition to healthy in time." echo "Check backend container logs:" echo " -> docker compose -f ${COMPOSE_FILE} logs backend" echo "==================================================" exit 3 fi REMOTE_SCRIPT REMOTE_EXIT=$? echo "==================================================" if [ $REMOTE_EXIT -eq 0 ]; then echo "${ENV_LABEL} update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})." elif [ $REMOTE_EXIT -eq 3 ]; then echo "${ENV_LABEL} update finished, but the backend was not healthy in time on ${REMOTE_TARGET}." else echo "${ENV_LABEL} update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})." fi echo "==================================================" exit $REMOTE_EXIT