feat: [wip] self updates

This commit is contained in:
Jake Turner 2026-01-15 22:44:29 +00:00 committed by Jake Turner
parent b6ac6b1e84
commit 393c177af1
15 changed files with 821 additions and 9 deletions

View File

@ -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')
}

View File

@ -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 });
}
}

View File

@ -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.

View File

@ -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
}
}
}

View File

@ -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 },
]

View File

@ -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<SystemUpdateStatus>('/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()

View File

@ -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<SystemUpdateStatus | null>(null)
const [error, setError] = useState<string | null>(null)
const [showLogs, setShowLogs] = useState(false)
const [logs, setLogs] = useState<string>('')
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 <IconCheck className="h-12 w-12 text-desert-olive" />
if (updateStatus?.stage === 'error')
return <IconAlertCircle className="h-12 w-12 text-desert-red" />
if (isUpdating) return <IconRefresh className="h-12 w-12 text-desert-green animate-spin" />
if (props.system.updateAvailable) return <IconArrowBigUpLines className="h-16 w-16 text-desert-green" />
return <IconCircleCheck className="h-16 w-16 text-desert-olive" />
}
return (
<SettingsLayout>
<Head title="System Update" />
<div className="xl:pl-72 w-full">
<main className="px-6 lg:px-12 py-6 lg:py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-desert-green mb-2">System Update</h1>
<p className="text-desert-stone-dark">
Keep your Project N.O.M.A.D. instance up to date with the latest features and improvements.
</p>
</div>
{error && (
<div className="mb-6">
<Alert
type="error"
title="Update Failed"
message={error}
variant="bordered"
dismissible
onDismiss={() => setError(null)}
/>
</div>
)}
{isUpdating && updateStatus?.stage === 'recreating' && (
<div className="mb-6">
<Alert
type="info"
title="Container Restarting"
message="The admin container is restarting. This page will reload automatically when the update is complete."
variant="solid"
/>
</div>
)}
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
<div className="p-8 text-center">
<div className="flex justify-center mb-4">{getStatusIcon()}</div>
{!isUpdating && (
<>
<h2 className="text-2xl font-bold text-desert-green mb-2">
{props.system.updateAvailable
? 'Update Available'
: 'System Up to Date'}
</h2>
<p className="text-desert-stone-dark mb-6">
{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!'}
</p>
</>
)}
{isUpdating && updateStatus && (
<>
<h2 className="text-2xl font-bold text-desert-green mb-2 capitalize">
{updateStatus.stage === 'idle' ? 'Preparing Update' : updateStatus.stage}
</h2>
<p className="text-desert-stone-dark mb-6">{updateStatus.message}</p>
</>
)}
<div className="flex justify-center gap-8 mb-6">
<div className="text-center">
<p className="text-sm text-desert-stone mb-1">Current Version</p>
<p className="text-xl font-bold text-desert-green">
{props.system.currentVersion}
</p>
</div>
{props.system.updateAvailable && (
<>
<div className="flex items-center">
<svg
className="h-6 w-6 text-desert-stone"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</div>
<div className="text-center">
<p className="text-sm text-desert-stone mb-1">Latest Version</p>
<p className="text-xl font-bold text-desert-olive">
{props.system.latestVersion}
</p>
</div>
</>
)}
</div>
{isUpdating && updateStatus && (
<div className="mb-4">
<div className="w-full bg-desert-stone-light rounded-full h-3 overflow-hidden">
<div
className={`${getProgressBarColor()} h-full transition-all duration-500 ease-out`}
style={{ width: `${updateStatus.progress}%` }}
/>
</div>
<p className="text-sm text-desert-stone mt-2">
{updateStatus.progress}% complete
</p>
</div>
)}
{!isUpdating && (
<div className="flex justify-center gap-4">
<StyledButton
variant="primary"
size="lg"
icon="ArrowDownTrayIcon"
onClick={handleStartUpdate}
disabled={!props.system.updateAvailable}
>
{props.system.updateAvailable ? 'Start Update' : 'No Update Available'}
</StyledButton>
<StyledButton
variant="ghost"
size="lg"
icon="ArrowPathIcon"
onClick={() => window.location.reload()}
>
Check Again
</StyledButton>
</div>
)}
</div>
<div className="border-t bg-white p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4">
What happens during an update?
</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
1
</div>
<div>
<p className="font-medium text-desert-stone-dark">Pull Latest Images</p>
<p className="text-sm text-desert-stone">
Downloads the newest Docker images for all core containers
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
2
</div>
<div>
<p className="font-medium text-desert-stone-dark">Recreate Containers</p>
<p className="text-sm text-desert-stone">
Safely stops and recreates all core containers with the new images
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
3
</div>
<div>
<p className="font-medium text-desert-stone-dark">Automatic Reload</p>
<p className="text-sm text-desert-stone">
This page will automatically reload when the update is complete
</p>
</div>
</div>
</div>
{isUpdating && (
<div className="mt-6 pt-6 border-t border-desert-stone-light">
<StyledButton
variant="ghost"
size="sm"
icon="DocumentTextIcon"
onClick={handleViewLogs}
fullWidth
>
View Update Logs
</StyledButton>
</div>
)}
</div>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<Alert
type="info"
title="Backup Reminder"
message="While updates are designed to be safe, it's always recommended to backup any critical data before proceeding."
variant="solid"
/>
<Alert
type="warning"
title="Temporary Downtime"
message="Services will be briefly unavailable during the update process. This typically takes 2-5 minutes depending on your internet connection."
variant="solid"
/>
</div>
{showLogs && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-desert-stone-light flex justify-between items-center">
<h3 className="text-xl font-bold text-desert-green">Update Logs</h3>
<button
onClick={() => setShowLogs(false)}
className="text-desert-stone hover:text-desert-green transition-colors"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="p-6 overflow-auto flex-1">
<pre className="bg-black text-green-400 p-4 rounded text-xs font-mono whitespace-pre-wrap">
{logs || 'No logs available yet...'}
</pre>
</div>
<div className="p-6 border-t border-desert-stone-light">
<StyledButton variant="secondary" onClick={() => setShowLogs(false)} fullWidth>
Close
</StyledButton>
</div>
</div>
</div>
)}
</main>
</div>
</SettingsLayout>
)
}

View File

@ -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')

View File

@ -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
}

View File

@ -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

View File

@ -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
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

View File

@ -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"]

View File

@ -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" <<EOF
{
"stage": "$stage",
"progress": $progress,
"message": "$message",
"timestamp": "$(date -Iseconds)"
}
EOF
}
perform_update() {
log "Update request received - starting system update"
# Clear old logs
> "$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

View File

@ -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

View File

@ -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