diff --git a/admin/app/services/system_update_service.ts b/admin/app/services/system_update_service.ts index bbfc753..ad0d5c3 100644 --- a/admin/app/services/system_update_service.ts +++ b/admin/app/services/system_update_service.ts @@ -2,6 +2,7 @@ import logger from '@adonisjs/core/services/logger' import { readFileSync, existsSync } from 'fs' import { writeFile } from 'fs/promises' import { join } from 'path' +import KVStore from '#models/kv_store' interface UpdateStatus { stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error' @@ -21,7 +22,7 @@ export class SystemUpdateService { */ async requestUpdate(): Promise<{ success: boolean; message: string }> { try { - const currentStatus = this.getUpdateStatus() + const currentStatus = this.getUpdateStatus() if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) { return { success: false, @@ -29,13 +30,17 @@ export class SystemUpdateService { } } + // Determine the Docker image tag to install. + const latestVersion = await KVStore.getValue('system.latestVersion') + const requestData = { requested_at: new Date().toISOString(), requester: 'admin-api', + target_tag: latestVersion || 'latest', // We should always have a latest version, but fallback to 'latest' just in case } await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2)) - logger.info('[SystemUpdateService]: System update requested - sidecar will process shortly') + logger.info(`[SystemUpdateService]: System update requested (target tag: ${requestData.target_tag}) - sidecar will process shortly`) return { success: true, diff --git a/admin/inertia/pages/settings/update.tsx b/admin/inertia/pages/settings/update.tsx index 8f6b212..4bf21a0 100644 --- a/admin/inertia/pages/settings/update.tsx +++ b/admin/inertia/pages/settings/update.tsx @@ -278,28 +278,6 @@ export default function SystemUpdatePage(props: { system: Props }) { return () => clearInterval(interval) }, [isUpdating]) - // Poll health endpoint when update is in recreating stage - useEffect(() => { - if (updateStatus?.stage !== 'recreating') return - - const interval = setInterval(async () => { - try { - const response = await api.healthCheck() - if (!response) { - throw new Error('Health check failed') - } - if (response.status === 'ok') { - // Reload page when container is back up - window.location.reload() - } - } catch (err) { - // Still restarting, continue polling... - } - }, 3000) - - return () => clearInterval(interval) - }, [updateStatus?.stage]) - const handleStartUpdate = async () => { try { setError(null) diff --git a/install/management_compose.yaml b/install/management_compose.yaml index 08525e1..f3dc199 100644 --- a/install/management_compose.yaml +++ b/install/management_compose.yaml @@ -44,7 +44,7 @@ services: timeout: 10s retries: 3 dozzle: - image: amir20/dozzle:latest + image: amir20/dozzle:v10.0 container_name: nomad_dozzle restart: unless-stopped ports: @@ -93,7 +93,7 @@ services: restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon - - /opt/project-nomad:/opt/project-nomad:ro # Read-only access to the project dir for config files and scripts + - /opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml - nomad-update-shared:/shared # Shared volume for communication with admin container volumes: diff --git a/install/run_updater_fixes.sh b/install/run_updater_fixes.sh new file mode 100644 index 0000000..822b05e --- /dev/null +++ b/install/run_updater_fixes.sh @@ -0,0 +1,230 @@ +#!/bin/bash + +# Project N.O.M.A.D. - One-Time Updater Fix Script +# +# Script | Project N.O.M.A.D. One-Time Updater Fix Script +# Version | 1.0.0 +# Author | Crosstalk Solutions, LLC +# Website | https://crosstalksolutions.com +# +# PURPOSE: +# This is a one-time migration script. It deploys two fixes to the sidecar +# updater that cannot be applied through the normal in-app update mechanism: +# +# Fix 1 — Sidecar volume write access +# Removes the :ro (read-only) flag from the sidecar's /opt/project-nomad +# volume mount in compose.yml. The sidecar must be able to write to +# compose.yml so it can set the correct Docker image tag when installing +# RC or stable versions. +# +# Fix 2 — RC-aware sidecar watcher +# Downloads the updated sidecar Dockerfile (adds jq) and update-watcher.sh +# (reads target_tag from the update request and applies it to compose.yml +# before pulling images), then rebuilds and restarts the sidecar container. +# +# NOTE: The companion fix in the admin service (system_update_service.ts, +# which writes the target_tag into the update request) ships in the GHCR +# image and will take effect automatically on the next normal app update. + +############################################################################### +# Color Codes +############################################################################### + +RESET='\033[0m' +YELLOW='\033[1;33m' +RED='\033[1;31m' +GREEN='\033[1;32m' +WHITE_R='\033[39m' + +############################################################################### +# Constants +############################################################################### + +NOMAD_DIR="/opt/project-nomad" +COMPOSE_FILE="${NOMAD_DIR}/compose.yml" +SIDECAR_DIR="${NOMAD_DIR}/sidecar-updater" +COMPOSE_PROJECT_NAME="project-nomad" + +SIDECAR_DOCKERFILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/Dockerfile" +SIDECAR_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/update-watcher.sh" + +############################################################################### +# Pre-flight Checks +############################################################################### + +check_is_bash() { + if [[ -z "$BASH_VERSION" ]]; then + echo -e "${RED}#${RESET} This script must be run with bash." + echo -e "${RED}#${RESET} Example: bash $(basename "$0")" + exit 1 + fi + echo -e "${GREEN}#${RESET} Running in bash.\n" +} + +check_confirmation() { + echo -e "${YELLOW}#${RESET} This is a very specific fix script for a very specific issue. You probably don't need to run this unless you were specifically directed to by the N.O.M.A.D. team." + echo -e "${YELLOW}#${RESET} Please ensure you have a backup of your data before proceeding." + read -rp "Do you want to continue? (y/N) " response + if [[ ! "$response" =~ ^[Yy]$ ]]; then + echo -e "${RED}#${RESET} Aborting. No changes have been made." + exit 0 + fi + echo -e "${GREEN}#${RESET} Confirmation received. Proceeding with fixes...\n" +} + +check_has_sudo() { + if sudo -n true 2>/dev/null; then + echo -e "${GREEN}#${RESET} Sudo permissions confirmed.\n" + else + echo -e "${RED}#${RESET} This script requires sudo permissions." + echo -e "${RED}#${RESET} Example: sudo bash $(basename "$0")" + exit 1 + fi +} + +check_docker_running() { + if ! command -v docker &>/dev/null; then + echo -e "${RED}#${RESET} Docker is not installed. Cannot proceed." + exit 1 + fi + if ! systemctl is-active --quiet docker; then + echo -e "${RED}#${RESET} Docker is not running. Please start Docker and try again." + exit 1 + fi + echo -e "${GREEN}#${RESET} Docker is running.\n" +} + +check_compose_file() { + if [[ ! -f "$COMPOSE_FILE" ]]; then + echo -e "${RED}#${RESET} compose.yml not found at ${COMPOSE_FILE}." + echo -e "${RED}#${RESET} Please ensure Project N.O.M.A.D. is installed before running this script." + exit 1 + fi + echo -e "${GREEN}#${RESET} Found compose.yml at ${COMPOSE_FILE}.\n" +} + +check_sidecar_dir() { + if [[ ! -d "$SIDECAR_DIR" ]]; then + echo -e "${RED}#${RESET} Sidecar directory not found at ${SIDECAR_DIR}." + echo -e "${RED}#${RESET} Please ensure Project N.O.M.A.D. is installed before running this script." + exit 1 + fi + echo -e "${GREEN}#${RESET} Found sidecar directory at ${SIDECAR_DIR}.\n" +} + +############################################################################### +# Fix 1 — Remove :ro from sidecar volume mount +############################################################################### + +backup_compose_file() { + local backup="${COMPOSE_FILE}.bak.$(date +%Y%m%d%H%M%S)" + echo -e "${YELLOW}#${RESET} Backing up compose.yml to ${backup}..." + if cp "$COMPOSE_FILE" "$backup"; then + echo -e "${GREEN}#${RESET} Backup created at ${backup}.\n" + else + echo -e "${RED}#${RESET} Failed to create backup. Aborting." + exit 1 + fi +} + +fix_sidecar_volume_mount() { + # Idempotent: skip if :ro is already absent from the sidecar mount line + if ! grep -q '/opt/project-nomad:/opt/project-nomad:ro' "$COMPOSE_FILE"; then + echo -e "${GREEN}#${RESET} Sidecar volume mount is already writable — no change needed.\n" + return 0 + fi + + echo -e "${YELLOW}#${RESET} Removing :ro restriction from sidecar volume mount in compose.yml..." + sed -i 's|/opt/project-nomad:/opt/project-nomad:ro.*|/opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml|' "$COMPOSE_FILE" + + if grep -q '/opt/project-nomad:/opt/project-nomad:ro' "$COMPOSE_FILE"; then + echo -e "${RED}#${RESET} Failed to remove :ro from compose.yml. Please update it manually:" + echo -e "${WHITE_R} - /opt/project-nomad:/opt/project-nomad:ro${RESET} → ${WHITE_R}- /opt/project-nomad:/opt/project-nomad${RESET}" + exit 1 + fi + + echo -e "${GREEN}#${RESET} Sidecar volume mount updated successfully.\n" +} + +############################################################################### +# Fix 2 — Download updated sidecar files and rebuild +############################################################################### + +download_updated_sidecar_files() { + echo -e "${YELLOW}#${RESET} Downloading updated sidecar Dockerfile..." + if ! curl -fsSL "$SIDECAR_DOCKERFILE_URL" -o "${SIDECAR_DIR}/Dockerfile"; then + echo -e "${RED}#${RESET} Failed to download sidecar Dockerfile. Check your network connection." + exit 1 + fi + echo -e "${GREEN}#${RESET} Sidecar Dockerfile updated.\n" + + echo -e "${YELLOW}#${RESET} Downloading updated update-watcher.sh..." + if ! curl -fsSL "$SIDECAR_SCRIPT_URL" -o "${SIDECAR_DIR}/update-watcher.sh"; then + echo -e "${RED}#${RESET} Failed to download update-watcher.sh. Check your network connection." + exit 1 + fi + chmod +x "${SIDECAR_DIR}/update-watcher.sh" + echo -e "${GREEN}#${RESET} update-watcher.sh updated.\n" +} + +rebuild_sidecar() { + echo -e "${YELLOW}#${RESET} Rebuilding the updater container (this may take a moment)..." + if ! docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" build updater; then + echo -e "${RED}#${RESET} Failed to rebuild the updater container. See output above for details." + exit 1 + fi + echo -e "${GREEN}#${RESET} Updater container rebuilt successfully.\n" +} + +restart_sidecar() { + echo -e "${YELLOW}#${RESET} Restarting the updater container..." + if ! docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" up -d --force-recreate updater; then + echo -e "${RED}#${RESET} Failed to restart the updater container." + exit 1 + fi + echo -e "${GREEN}#${RESET} Updater container restarted.\n" +} + +verify_sidecar_running() { + sleep 3 + if docker ps --filter "name=nomad_updater" --filter "status=running" --format '{{.Names}}' | grep -q "nomad_updater"; then + echo -e "${GREEN}#${RESET} Updater container is running.\n" + else + echo -e "${RED}#${RESET} Updater container does not appear to be running." + echo -e "${RED}#${RESET} Check its logs with: docker logs nomad_updater" + exit 1 + fi +} + +############################################################################### +# Main +############################################################################### + +echo -e "${GREEN}#########################################################################${RESET}" +echo -e "${GREEN}#${RESET} Project N.O.M.A.D. — One-Time Updater Fix Script ${GREEN}#${RESET}" +echo -e "${GREEN}#########################################################################${RESET}\n" + +check_is_bash +check_has_sudo +chech_confirmation +check_docker_running +check_compose_file +check_sidecar_dir + +echo -e "${YELLOW}#${RESET} Starting Fix 1: Sidecar volume write access...\n" +backup_compose_file +fix_sidecar_volume_mount + +echo -e "${YELLOW}#${RESET} Starting Fix 2: RC-aware sidecar watcher...\n" +download_updated_sidecar_files +rebuild_sidecar +restart_sidecar +verify_sidecar_running + +echo -e "${GREEN}#########################################################################${RESET}" +echo -e "${GREEN}#${RESET} All fixes applied successfully!" +echo -e "${GREEN}#${RESET}" +echo -e "${GREEN}#${RESET} The updater sidecar can now install RC and stable versions correctly." +echo -e "${GREEN}#${RESET} The remaining fix (admin service target_tag support) will apply" +echo -e "${GREEN}#${RESET} automatically the next time you update N.O.M.A.D. via the UI." +echo -e "${GREEN}#########################################################################${RESET}\n" diff --git a/install/sidecar-updater/Dockerfile b/install/sidecar-updater/Dockerfile index 6ebe8da..527c321 100644 --- a/install/sidecar-updater/Dockerfile +++ b/install/sidecar-updater/Dockerfile @@ -1,7 +1,7 @@ FROM alpine:3.20 # Install Docker CLI for compose operations -RUN apk add --no-cache docker-cli docker-cli-compose bash +RUN apk add --no-cache docker-cli docker-cli-compose bash jq # Copy the update watcher script COPY update-watcher.sh /usr/local/bin/update-watcher.sh diff --git a/install/sidecar-updater/update-watcher.sh b/install/sidecar-updater/update-watcher.sh index 5512538..fbb515d 100644 --- a/install/sidecar-updater/update-watcher.sh +++ b/install/sidecar-updater/update-watcher.sh @@ -29,20 +29,32 @@ EOF } perform_update() { - log "Update request received - starting system update" - + local target_tag="$1" + + log "Update request received - starting system update (target tag: ${target_tag})" + # Clear old logs > "$LOG_FILE" - + # Stage 1: Starting write_status "starting" 0 "System update initiated" log "System update initiated" sleep 1 - + + # Apply target image tag to compose.yml before pulling + log "Applying image tag '${target_tag}' to compose.yml..." + if sed -i "s|\(image: ghcr\.io/crosstalk-solutions/project-nomad\):.*|\1:${target_tag}|" "$COMPOSE_FILE" 2>> "$LOG_FILE"; then + log "Successfully updated compose.yml admin image tag to '${target_tag}'" + else + log "ERROR: Failed to update compose.yml image tag" + write_status "error" 0 "Failed to update compose.yml image tag - check logs" + return 1 + fi + # Stage 2: Pulling images write_status "pulling" 20 "Pulling latest Docker images..." log "Pulling latest Docker images..." - + if docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" pull >> "$LOG_FILE" 2>&1; then log "Successfully pulled latest images" write_status "pulled" 60 "Images pulled successfully" @@ -112,14 +124,18 @@ while true; do if [ -f "$REQUEST_FILE" ]; then log "Found update request file" - # Read request details (could contain metadata like requester, timestamp, etc.) + # Read request details REQUEST_DATA=$(cat "$REQUEST_FILE" 2>/dev/null || echo "{}") log "Request data: $REQUEST_DATA" - + + # Extract target tag from request (defaults to "latest" if not provided) + TARGET_TAG=$(echo "$REQUEST_DATA" | jq -r '.target_tag // "latest"') + log "Target image tag: ${TARGET_TAG}" + # Remove the request file to prevent re-processing rm -f "$REQUEST_FILE" - - if perform_update; then + + if perform_update "$TARGET_TAG"; then log "Update completed successfully" else log "Update failed - see logs for details"