From 393c177af116263629820fc9a5cbb91efbcff9ce Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Thu, 15 Jan 2026 22:44:29 +0000 Subject: [PATCH] feat: [wip] self updates --- admin/app/controllers/settings_controller.ts | 11 + admin/app/controllers/system_controller.ts | 51 ++- admin/app/services/system_service.ts | 57 ++- admin/app/services/system_update_service.ts | 95 +++++ admin/inertia/layouts/SettingsLayout.tsx | 8 +- admin/inertia/lib/api.ts | 34 +- admin/inertia/pages/settings/update.tsx | 358 +++++++++++++++++++ admin/start/routes.ts | 5 + admin/types/system.ts | 7 + install/install_nomad.sh | 31 +- install/management_compose.yaml | 18 +- install/sidecar-updater/Dockerfile | 15 + install/sidecar-updater/update-watcher.sh | 134 +++++++ install/uninstall_nomad.sh | 2 +- install/update_nomad.sh | 4 +- 15 files changed, 821 insertions(+), 9 deletions(-) create mode 100644 admin/app/services/system_update_service.ts create mode 100644 admin/inertia/pages/settings/update.tsx create mode 100644 install/sidecar-updater/Dockerfile create mode 100644 install/sidecar-updater/update-watcher.sh diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index 1321821..df3ac76 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -43,6 +43,17 @@ export default class SettingsController { }); } + async update({ inertia }: HttpContext) { + const updateInfo = await this.systemService.checkLatestVersion(); + return inertia.render('settings/update', { + system: { + updateAvailable: updateInfo.updateAvailable, + latestVersion: updateInfo.latestVersion, + currentVersion: updateInfo.currentVersion + } + }); + } + async zim({ inertia }: HttpContext) { return inertia.render('settings/zim/index') } diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index f70d0f6..a664658 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -1,5 +1,6 @@ import { DockerService } from '#services/docker_service'; import { SystemService } from '#services/system_service' +import { SystemUpdateService } from '#services/system_update_service' import { affectServiceValidator, installServiceValidator } from '#validators/system'; import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' @@ -8,7 +9,8 @@ import type { HttpContext } from '@adonisjs/core/http' export default class SystemController { constructor( private systemService: SystemService, - private dockerService: DockerService + private dockerService: DockerService, + private systemUpdateService: SystemUpdateService ) { } async getInternetStatus({ }: HttpContext) { @@ -43,4 +45,51 @@ export default class SystemController { } response.send({ success: result.success, message: result.message }); } + + async checkLatestVersion({ }: HttpContext) { + return await this.systemService.checkLatestVersion(); + } + + async requestSystemUpdate({ response }: HttpContext) { + if (!this.systemUpdateService.isSidecarAvailable()) { + response.status(503).send({ + success: false, + error: 'Update sidecar is not available. Ensure the updater container is running.', + }); + return; + } + + const result = await this.systemUpdateService.requestUpdate(); + + if (result.success) { + response.send({ + success: true, + message: result.message, + note: 'Monitor update progress via GET /api/system/update/status. The connection may drop during container restart.', + }); + } else { + response.status(409).send({ + success: false, + error: result.message, + }); + } + } + + async getSystemUpdateStatus({ response }: HttpContext) { + const status = this.systemUpdateService.getUpdateStatus(); + + if (!status) { + response.status(500).send({ + error: 'Failed to retrieve update status', + }); + return; + } + + response.send(status); + } + + async getSystemUpdateLogs({ response }: HttpContext) { + const logs = this.systemUpdateService.getUpdateLogs(); + response.send({ logs }); + } } \ No newline at end of file diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 8656565..acf5480 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -62,7 +62,16 @@ export class SystemService { const query = Service.query() .orderBy('friendly_name', 'asc') - .select('id', 'service_name', 'installed', 'installation_status', 'ui_location', 'friendly_name', 'description', 'icon') + .select( + 'id', + 'service_name', + 'installed', + 'installation_status', + 'ui_location', + 'friendly_name', + 'description', + 'icon' + ) .where('is_dependency_service', false) if (installedOnly) { query.where('installed', true) @@ -166,6 +175,52 @@ export class SystemService { } } + async checkLatestVersion(): Promise<{ + success: boolean + updateAvailable: boolean + currentVersion: string + latestVersion: string + message?: string + }> { + try { + const response = await axios.get( + 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest', + { + headers: { Accept: 'application/vnd.github+json' }, + timeout: 5000, + } + ) + + if (!response || !response.data?.tag_name) { + throw new Error('Invalid response from GitHub API') + } + + const latestVersion = response.data.tag_name.replace(/^v/, '') // Remove leading 'v' if present + const currentVersion = SystemService.getAppVersion() + + logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`) + + // NOTE: this will always return true in dev environment! See getAppVersion() + const updateAvailable = latestVersion !== currentVersion + + return { + success: true, + updateAvailable, + currentVersion, + latestVersion, + } + } catch (error) { + logger.error('Error checking latest version:', error) + return { + success: false, + updateAvailable: false, + currentVersion: '', + latestVersion: '', + message: `Failed to check latest version: ${error instanceof Error ? error.message : error}`, + } + } + } + /** * Checks the current state of Docker containers against the database records and updates the database accordingly. * It will mark services as not installed if their corresponding containers are not running, and can also handle cleanup of any orphaned records. diff --git a/admin/app/services/system_update_service.ts b/admin/app/services/system_update_service.ts new file mode 100644 index 0000000..bbfc753 --- /dev/null +++ b/admin/app/services/system_update_service.ts @@ -0,0 +1,95 @@ +import logger from '@adonisjs/core/services/logger' +import { readFileSync, existsSync } from 'fs' +import { writeFile } from 'fs/promises' +import { join } from 'path' + +interface UpdateStatus { + stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error' + progress: number + message: string + timestamp: string +} + +export class SystemUpdateService { + private static SHARED_DIR = '/app/update-shared' + private static REQUEST_FILE = join(SystemUpdateService.SHARED_DIR, 'update-request') + private static STATUS_FILE = join(SystemUpdateService.SHARED_DIR, 'update-status') + private static LOG_FILE = join(SystemUpdateService.SHARED_DIR, 'update-log') + + /** + * Requests a system update by creating a request file that the sidecar will detect + */ + async requestUpdate(): Promise<{ success: boolean; message: string }> { + try { + const currentStatus = this.getUpdateStatus() + if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) { + return { + success: false, + message: `Update already in progress (stage: ${currentStatus.stage})`, + } + } + + const requestData = { + requested_at: new Date().toISOString(), + requester: 'admin-api', + } + + await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2)) + logger.info('[SystemUpdateService]: System update requested - sidecar will process shortly') + + return { + success: true, + message: 'System update initiated. The admin container will restart during the process.', + } + } catch (error) { + logger.error('[SystemUpdateService]: Failed to request system update:', error) + return { + success: false, + message: `Failed to request update: ${error.message}`, + } + } + } + + getUpdateStatus(): UpdateStatus | null { + try { + if (!existsSync(SystemUpdateService.STATUS_FILE)) { + return { + stage: 'idle', + progress: 0, + message: 'No update in progress', + timestamp: new Date().toISOString(), + } + } + + const statusContent = readFileSync(SystemUpdateService.STATUS_FILE, 'utf-8') + return JSON.parse(statusContent) as UpdateStatus + } catch (error) { + logger.error('[SystemUpdateService]: Failed to read update status:', error) + return null + } + } + + getUpdateLogs(): string { + try { + if (!existsSync(SystemUpdateService.LOG_FILE)) { + return 'No update logs available' + } + + return readFileSync(SystemUpdateService.LOG_FILE, 'utf-8') + } catch (error) { + logger.error('[SystemUpdateService]: Failed to read update logs:', error) + return `Error reading logs: ${error.message}` + } + } + + /** + * Check if the update sidecar is reachable (i.e. shared volume is mounted) + */ + isSidecarAvailable(): boolean { + try { + return existsSync(SystemUpdateService.SHARED_DIR) + } catch (error) { + return false + } + } +} diff --git a/admin/inertia/layouts/SettingsLayout.tsx b/admin/inertia/layouts/SettingsLayout.tsx index 9c77d86..b239685 100644 --- a/admin/inertia/layouts/SettingsLayout.tsx +++ b/admin/inertia/layouts/SettingsLayout.tsx @@ -4,7 +4,7 @@ import { FolderIcon, MagnifyingGlassIcon, } from '@heroicons/react/24/outline' -import { IconDashboard, IconGavel, IconMapRoute } from '@tabler/icons-react' +import { IconArrowBigUpLines, IconDashboard, IconGavel, IconMapRoute } from '@tabler/icons-react' import StyledSidebar from '~/components/StyledSidebar' import { getServiceLink } from '~/lib/navigation' @@ -26,6 +26,12 @@ const navigation = [ icon: MagnifyingGlassIcon, current: false, }, + { + name: 'Check for Updates', + href: '/settings/update', + icon: IconArrowBigUpLines, + current: false, + }, { name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true }, ] diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index d7f284b..c367094 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -2,7 +2,7 @@ import axios, { AxiosInstance } from 'axios' import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim' import { ServiceSlim } from '../../types/services' import { FileEntry } from '../../types/files' -import { SystemInformationResponse } from '../../types/system' +import { SystemInformationResponse, SystemUpdateStatus } from '../../types/system' import { CuratedCollectionWithStatus, DownloadJobWithProgress } from '../../types/downloads' import { catchInternal } from './util' @@ -116,6 +116,29 @@ class API { })() } + async getSystemUpdateStatus() { + return catchInternal(async () => { + const response = await this.client.get('/system/update/status') + return response.data + })() + } + + async getSystemUpdateLogs() { + return catchInternal(async () => { + const response = await this.client.get<{ logs: string }>('/system/update/logs') + return response.data + })() + } + + async healthCheck() { + return catchInternal(async () => { + const response = await this.client.get<{ status: string }>('/health', { + timeout: 5000, + }) + return response.data + })() + } + async installService(service_name: string) { return catchInternal(async () => { const response = await this.client.post<{ success: boolean; message: string }>( @@ -198,6 +221,15 @@ class API { return response.data })() } + + async startSystemUpdate() { + return catchInternal(async () => { + const response = await this.client.post<{ success: boolean; message: string }>( + '/system/update' + ) + return response.data + })() + } } export default new API() diff --git a/admin/inertia/pages/settings/update.tsx b/admin/inertia/pages/settings/update.tsx new file mode 100644 index 0000000..09812e2 --- /dev/null +++ b/admin/inertia/pages/settings/update.tsx @@ -0,0 +1,358 @@ +import { Head } from '@inertiajs/react' +import IconArrowBigUpLines from '@tabler/icons-react/dist/esm/icons/IconArrowBigUpLines' +import IconCheck from '@tabler/icons-react/dist/esm/icons/IconCheck' +import IconAlertCircle from '@tabler/icons-react/dist/esm/icons/IconAlertCircle' +import IconRefresh from '@tabler/icons-react/dist/esm/icons/IconRefresh' +import SettingsLayout from '~/layouts/SettingsLayout' +import StyledButton from '~/components/StyledButton' +import Alert from '~/components/Alert' +import { useEffect, useState } from 'react' +import { IconCircleCheck } from '@tabler/icons-react' +import { SystemUpdateStatus } from '../../../types/system' +import api from '~/lib/api' + +export default function SystemUpdatePage(props: { + system: { + updateAvailable: boolean + latestVersion: string + currentVersion: string + } +}) { + const [isUpdating, setIsUpdating] = useState(false) + const [updateStatus, setUpdateStatus] = useState(null) + const [error, setError] = useState(null) + const [showLogs, setShowLogs] = useState(false) + const [logs, setLogs] = useState('') + + useEffect(() => { + if (!isUpdating) return + + const interval = setInterval(async () => { + try { + const response = await api.getSystemUpdateStatus() + if (!response) { + throw new Error('Failed to fetch update status') + } + setUpdateStatus(response) + + // Check if update is complete or errored + if (response.stage === 'complete') { + // Give a moment for the new container to fully start + setTimeout(() => { + window.location.reload() + }, 2000) + } else if (response.stage === 'error') { + setIsUpdating(false) + setError(response.message) + } + } catch (err) { + // During container restart, we'll lose connection - this is expected + // Continue polling to detect when the container comes back up + console.log('Polling update status (container may be restarting)...') + } + }, 2000) + + 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) + setIsUpdating(true) + const response = await api.startSystemUpdate() + if (!response || !response.success) { + throw new Error('Failed to start update') + } + } catch (err: any) { + setIsUpdating(false) + setError(err.response?.data?.error || err.message || 'Failed to start update') + } + } + + const handleViewLogs = async () => { + try { + const response = await api.getSystemUpdateLogs() + if (!response) { + throw new Error('Failed to fetch update logs') + } + setLogs(response.logs) + setShowLogs(true) + } catch (err) { + setError('Failed to fetch update logs') + } + } + + const getProgressBarColor = () => { + if (updateStatus?.stage === 'error') return 'bg-desert-red' + if (updateStatus?.stage === 'complete') return 'bg-desert-olive' + return 'bg-desert-green' + } + + const getStatusIcon = () => { + if (updateStatus?.stage === 'complete') + return + if (updateStatus?.stage === 'error') + return + if (isUpdating) return + if (props.system.updateAvailable) return + return + } + + return ( + + +
+
+
+

System Update

+

+ Keep your Project N.O.M.A.D. instance up to date with the latest features and improvements. +

+
+ + {error && ( +
+ setError(null)} + /> +
+ )} + {isUpdating && updateStatus?.stage === 'recreating' && ( +
+ +
+ )} +
+
+
{getStatusIcon()}
+ + {!isUpdating && ( + <> +

+ {props.system.updateAvailable + ? 'Update Available' + : 'System Up to Date'} +

+

+ {props.system.updateAvailable + ? `A new version (${props.system.latestVersion}) is available for your Project N.O.M.A.D. instance.` + : 'Your system is running the latest version!'} +

+ + )} + + {isUpdating && updateStatus && ( + <> +

+ {updateStatus.stage === 'idle' ? 'Preparing Update' : updateStatus.stage} +

+

{updateStatus.message}

+ + )} + +
+
+

Current Version

+

+ {props.system.currentVersion} +

+
+ {props.system.updateAvailable && ( + <> +
+ + + +
+
+

Latest Version

+

+ {props.system.latestVersion} +

+
+ + )} +
+ {isUpdating && updateStatus && ( +
+
+
+
+

+ {updateStatus.progress}% complete +

+
+ )} + {!isUpdating && ( +
+ + {props.system.updateAvailable ? 'Start Update' : 'No Update Available'} + + window.location.reload()} + > + Check Again + +
+ )} +
+
+

+ What happens during an update? +

+
+
+
+ 1 +
+
+

Pull Latest Images

+

+ Downloads the newest Docker images for all core containers +

+
+
+
+
+ 2 +
+
+

Recreate Containers

+

+ Safely stops and recreates all core containers with the new images +

+
+
+
+
+ 3 +
+
+

Automatic Reload

+

+ This page will automatically reload when the update is complete +

+
+
+
+ + {isUpdating && ( +
+ + View Update Logs + +
+ )} +
+
+
+ + +
+ {showLogs && ( +
+
+
+

Update Logs

+ +
+
+
+                    {logs || 'No logs available yet...'}
+                  
+
+
+ setShowLogs(false)} fullWidth> + Close + +
+
+
+ )} +
+
+
+ ) +} diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 29be319..06d05a8 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -32,6 +32,7 @@ router router.get('/apps', [SettingsController, 'apps']) router.get('/legal', [SettingsController, 'legal']) router.get('/maps', [SettingsController, 'maps']) + router.get('/update', [SettingsController, 'update']) router.get('/zim', [SettingsController, 'zim']) router.get('/zim/remote-explorer', [SettingsController, 'zimRemote']) }) @@ -90,6 +91,10 @@ router router.get('/services', [SystemController, 'getServices']) router.post('/services/affect', [SystemController, 'affectService']) router.post('/services/install', [SystemController, 'installService']) + router.get('/latest-version', [SystemController, 'checkLatestVersion']) + router.post('/update', [SystemController, 'requestSystemUpdate']) + router.get('/update/status', [SystemController, 'getSystemUpdateStatus']) + router.get('/update/logs', [SystemController, 'getSystemUpdateLogs']) }) .prefix('/api/system') diff --git a/admin/types/system.ts b/admin/types/system.ts index e9b5cb2..ded4a23 100644 --- a/admin/types/system.ts +++ b/admin/types/system.ts @@ -60,3 +60,10 @@ export type NomadDiskInfo = { percentUsed: number }[] } + +export type SystemUpdateStatus = { + stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error' + progress: number + message: string + timestamp: string +} diff --git a/install/install_nomad.sh b/install/install_nomad.sh index 5b86f4b..73de0da 100644 --- a/install/install_nomad.sh +++ b/install/install_nomad.sh @@ -32,6 +32,8 @@ WHIPTAIL_TITLE="Project N.O.M.A.D Installation" NOMAD_DIR="/opt/project-nomad" MANAGEMENT_COMPOSE_FILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/management_compose.yaml" ENTRYPOINT_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/entrypoint.sh" +SIDECAR_UPDATER_DOCKERFILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/sidecar-updater/Dockerfile" +SIDECAR_UPDATER_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/sidecar-updater/update-watcher.sh" START_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/start_nomad.sh" STOP_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/stop_nomad.sh" UPDATE_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/update_nomad.sh" @@ -293,6 +295,32 @@ download_entrypoint_script() { echo -e "${GREEN}#${RESET} entrypoint script downloaded successfully to $entrypoint_script_path.\\n" } +download_sidecar_files() { + # Create sidecar-updater directory if it doesn't exist + if [[ ! -d "${NOMAD_DIR}/sidecar-updater" ]]; then + sudo mkdir -p "${NOMAD_DIR}/sidecar-updater" + sudo chown "$(whoami):$(whoami)" "${NOMAD_DIR}/sidecar-updater" + fi + + local sidecar_dockerfile_path="${NOMAD_DIR}/sidecar-updater/Dockerfile" + local sidecar_script_path="${NOMAD_DIR}/sidecar-updater/update-watcher.sh" + + echo -e "${YELLOW}#${RESET} Downloading sidecar updater Dockerfile...\\n" + if ! curl -fsSL "$SIDECAR_UPDATER_DOCKERFILE_URL" -o "$sidecar_dockerfile_path"; then + echo -e "${RED}#${RESET} Failed to download the sidecar updater Dockerfile. Please check the URL and try again." + exit 1 + fi + echo -e "${GREEN}#${RESET} Sidecar updater Dockerfile downloaded successfully to $sidecar_dockerfile_path.\\n" + + echo -e "${YELLOW}#${RESET} Downloading sidecar updater script...\\n" + if ! curl -fsSL "$SIDECAR_UPDATER_SCRIPT_URL" -o "$sidecar_script_path"; then + echo -e "${RED}#${RESET} Failed to download the sidecar updater script. Please check the URL and try again." + exit 1 + fi + chmod +x "$sidecar_script_path" + echo -e "${GREEN}#${RESET} Sidecar updater script downloaded successfully to $sidecar_script_path.\\n" +} + download_and_start_collect_disk_info_script() { local collect_disk_info_script_path="${NOMAD_DIR}/collect_disk_info.sh" @@ -340,7 +368,7 @@ download_helper_scripts() { start_management_containers() { echo -e "${YELLOW}#${RESET} Starting management containers using docker compose...\\n" - if ! sudo docker compose -f "${NOMAD_DIR}/compose.yml" up -d; then + if ! sudo docker compose -p project-nomad -f "${NOMAD_DIR}/compose.yml" up -d; then echo -e "${RED}#${RESET} Failed to start management containers. Please check the logs and try again." exit 1 fi @@ -383,6 +411,7 @@ get_local_ip create_nomad_directory download_wait_for_it_script download_entrypoint_script +download_sidecar_files download_helper_scripts download_and_start_collect_disk_info_script download_management_compose_file diff --git a/install/management_compose.yaml b/install/management_compose.yaml index c71facf..0427f12 100644 --- a/install/management_compose.yaml +++ b/install/management_compose.yaml @@ -1,3 +1,4 @@ +name: project-nomad services: admin: image: ghcr.io/crosstalk-solutions/project-nomad:latest @@ -12,6 +13,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon - ./entrypoint.sh:/usr/local/bin/entrypoint.sh - ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh + - nomad-update-shared:/app/update-shared # Shared volume for update communication environment: - NODE_ENV=production - PORT=8080 @@ -80,4 +82,18 @@ services: test: ["CMD", "redis-cli", "ping"] interval: 30s timeout: 10s - retries: 3 \ No newline at end of file + retries: 3 + updater: + build: + context: ./sidecar-updater + dockerfile: Dockerfile + container_name: nomad_updater + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon + - /opt/project-nomad/compose.yml:/opt/project-nomad/compose.yml:ro + - nomad-update-shared:/shared # Shared volume for communication with admin container + +volumes: + nomad-update-shared: + driver: local \ No newline at end of file diff --git a/install/sidecar-updater/Dockerfile b/install/sidecar-updater/Dockerfile new file mode 100644 index 0000000..6ebe8da --- /dev/null +++ b/install/sidecar-updater/Dockerfile @@ -0,0 +1,15 @@ +FROM alpine:3.20 + +# Install Docker CLI for compose operations +RUN apk add --no-cache docker-cli docker-cli-compose bash + +# Copy the update watcher script +COPY update-watcher.sh /usr/local/bin/update-watcher.sh +RUN chmod +x /usr/local/bin/update-watcher.sh + +# Create shared communication directory +RUN mkdir -p /shared + +WORKDIR /shared + +CMD ["/usr/local/bin/update-watcher.sh"] diff --git a/install/sidecar-updater/update-watcher.sh b/install/sidecar-updater/update-watcher.sh new file mode 100644 index 0000000..5512538 --- /dev/null +++ b/install/sidecar-updater/update-watcher.sh @@ -0,0 +1,134 @@ +#!/bin/bash + +# Project N.O.M.A.D. Update Sidecar - Polls for update requests and executes them + +SHARED_DIR="/shared" +REQUEST_FILE="${SHARED_DIR}/update-request" +STATUS_FILE="${SHARED_DIR}/update-status" +LOG_FILE="${SHARED_DIR}/update-log" +COMPOSE_FILE="/opt/project-nomad/compose.yml" +COMPOSE_PROJECT_NAME="project-nomad" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +write_status() { + local stage="$1" + local progress="$2" + local message="$3" + + cat > "$STATUS_FILE" < "$LOG_FILE" + + # Stage 1: Starting + write_status "starting" 0 "System update initiated" + log "System update initiated" + sleep 1 + + # 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" + else + log "ERROR: Failed to pull images" + write_status "error" 0 "Failed to pull Docker images - check logs" + return 1 + fi + + sleep 2 + + # Stage 3: Recreating containers individually (excluding updater) + write_status "recreating" 65 "Recreating containers individually..." + log "Recreating containers individually (excluding updater)..." + + # List of services to update (excluding updater) + SERVICES_TO_UPDATE="admin mysql redis dozzle" + + local current_progress=65 + local progress_per_service=8 # (95 - 65) / 4 services ≈ 8% per service + + for service in $SERVICES_TO_UPDATE; do + log "Updating service: $service" + write_status "recreating" $current_progress "Recreating $service..." + + # Stop the service + log " Stopping $service..." + docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" stop "$service" >> "$LOG_FILE" 2>&1 || log " WARNING: Failed to stop $service" + + # Remove the container + log " Removing old $service container..." + docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" rm -f "$service" >> "$LOG_FILE" 2>&1 || log " WARNING: Failed to remove $service" + + # Recreate and start with new image + log " Starting new $service container..." + if docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" up -d --no-deps "$service" >> "$LOG_FILE" 2>&1; then + log " ✓ Successfully recreated $service" + else + log " ERROR: Failed to recreate $service" + write_status "error" $current_progress "Failed to recreate $service - check logs" + return 1 + fi + + current_progress=$((current_progress + progress_per_service)) + done + + log "Successfully recreated all containers" + write_status "complete" 100 "System update completed successfully" + log "System update completed successfully" + + return 0 +} + +cleanup() { + log "Update sidecar shutting down" + exit 0 +} + +trap cleanup SIGTERM SIGINT + +# Main watch loop +log "Update sidecar started - watching for update requests" +write_status "idle" 0 "Ready for update requests" + +while true; do + # Check if an update request file exists + if [ -f "$REQUEST_FILE" ]; then + log "Found update request file" + + # Read request details (could contain metadata like requester, timestamp, etc.) + REQUEST_DATA=$(cat "$REQUEST_FILE" 2>/dev/null || echo "{}") + log "Request data: $REQUEST_DATA" + + # Remove the request file to prevent re-processing + rm -f "$REQUEST_FILE" + + if perform_update; then + log "Update completed successfully" + else + log "Update failed - see logs for details" + fi + + sleep 5 + write_status "idle" 0 "Ready for update requests" + fi + + # Sleep before next check (1 second polling) + sleep 1 +done diff --git a/install/uninstall_nomad.sh b/install/uninstall_nomad.sh index 006f1cd..196d2f9 100644 --- a/install/uninstall_nomad.sh +++ b/install/uninstall_nomad.sh @@ -97,7 +97,7 @@ try_remove_disk_info_file() { uninstall_nomad() { echo "Stopping and removing Project N.O.M.A.D. management containers..." - docker compose -f "${MANAGEMENT_COMPOSE_FILE}" down + docker compose -p project-nomad -f "${MANAGEMENT_COMPOSE_FILE}" down echo "Allowing some time for management containers to stop..." sleep 5 diff --git a/install/update_nomad.sh b/install/update_nomad.sh index 6ec6cd0..d7be2d5 100644 --- a/install/update_nomad.sh +++ b/install/update_nomad.sh @@ -103,13 +103,13 @@ ensure_docker_compose_file_exists() { force_recreate() { echo -e "${YELLOW}#${RESET} Pulling the latest Docker images..." - if ! docker compose -f /opt/project-nomad/compose.yml pull; then + if ! docker compose -p project-nomad -f /opt/project-nomad/compose.yml pull; then echo -e "${RED}#${RESET} Failed to pull the latest Docker images. Please check your network connection and the Docker registry status, then try again." exit 1 fi echo -e "${YELLOW}#${RESET} Forcing recreation of containers..." - if ! docker compose -f /opt/project-nomad/compose.yml up -d --force-recreate; then + if ! docker compose -p project-nomad -f /opt/project-nomad/compose.yml up -d --force-recreate; then echo -e "${RED}#${RESET} Failed to recreate containers. Please check the Docker logs for more details." exit 1 fi