mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-01 22:39:26 +02:00
feat: [wip] self updates
This commit is contained in:
parent
b6ac6b1e84
commit
393c177af1
|
|
@ -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) {
|
async zim({ inertia }: HttpContext) {
|
||||||
return inertia.render('settings/zim/index')
|
return inertia.render('settings/zim/index')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { DockerService } from '#services/docker_service';
|
import { DockerService } from '#services/docker_service';
|
||||||
import { SystemService } from '#services/system_service'
|
import { SystemService } from '#services/system_service'
|
||||||
|
import { SystemUpdateService } from '#services/system_update_service'
|
||||||
import { affectServiceValidator, installServiceValidator } from '#validators/system';
|
import { affectServiceValidator, installServiceValidator } from '#validators/system';
|
||||||
import { inject } from '@adonisjs/core'
|
import { inject } from '@adonisjs/core'
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
|
@ -8,7 +9,8 @@ import type { HttpContext } from '@adonisjs/core/http'
|
||||||
export default class SystemController {
|
export default class SystemController {
|
||||||
constructor(
|
constructor(
|
||||||
private systemService: SystemService,
|
private systemService: SystemService,
|
||||||
private dockerService: DockerService
|
private dockerService: DockerService,
|
||||||
|
private systemUpdateService: SystemUpdateService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getInternetStatus({ }: HttpContext) {
|
async getInternetStatus({ }: HttpContext) {
|
||||||
|
|
@ -43,4 +45,51 @@ export default class SystemController {
|
||||||
}
|
}
|
||||||
response.send({ success: result.success, message: result.message });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,16 @@ export class SystemService {
|
||||||
|
|
||||||
const query = Service.query()
|
const query = Service.query()
|
||||||
.orderBy('friendly_name', 'asc')
|
.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)
|
.where('is_dependency_service', false)
|
||||||
if (installedOnly) {
|
if (installedOnly) {
|
||||||
query.where('installed', true)
|
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.
|
* 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.
|
* It will mark services as not installed if their corresponding containers are not running, and can also handle cleanup of any orphaned records.
|
||||||
|
|
|
||||||
95
admin/app/services/system_update_service.ts
Normal file
95
admin/app/services/system_update_service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} 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 StyledSidebar from '~/components/StyledSidebar'
|
||||||
import { getServiceLink } from '~/lib/navigation'
|
import { getServiceLink } from '~/lib/navigation'
|
||||||
|
|
||||||
|
|
@ -26,6 +26,12 @@ const navigation = [
|
||||||
icon: MagnifyingGlassIcon,
|
icon: MagnifyingGlassIcon,
|
||||||
current: false,
|
current: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Check for Updates',
|
||||||
|
href: '/settings/update',
|
||||||
|
icon: IconArrowBigUpLines,
|
||||||
|
current: false,
|
||||||
|
},
|
||||||
{ name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true },
|
{ name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import axios, { AxiosInstance } from 'axios'
|
||||||
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
|
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
|
||||||
import { ServiceSlim } from '../../types/services'
|
import { ServiceSlim } from '../../types/services'
|
||||||
import { FileEntry } from '../../types/files'
|
import { FileEntry } from '../../types/files'
|
||||||
import { SystemInformationResponse } from '../../types/system'
|
import { SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
|
||||||
import { CuratedCollectionWithStatus, DownloadJobWithProgress } from '../../types/downloads'
|
import { CuratedCollectionWithStatus, DownloadJobWithProgress } from '../../types/downloads'
|
||||||
import { catchInternal } from './util'
|
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) {
|
async installService(service_name: string) {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||||
|
|
@ -198,6 +221,15 @@ class API {
|
||||||
return response.data
|
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()
|
export default new API()
|
||||||
|
|
|
||||||
358
admin/inertia/pages/settings/update.tsx
Normal file
358
admin/inertia/pages/settings/update.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ router
|
||||||
router.get('/apps', [SettingsController, 'apps'])
|
router.get('/apps', [SettingsController, 'apps'])
|
||||||
router.get('/legal', [SettingsController, 'legal'])
|
router.get('/legal', [SettingsController, 'legal'])
|
||||||
router.get('/maps', [SettingsController, 'maps'])
|
router.get('/maps', [SettingsController, 'maps'])
|
||||||
|
router.get('/update', [SettingsController, 'update'])
|
||||||
router.get('/zim', [SettingsController, 'zim'])
|
router.get('/zim', [SettingsController, 'zim'])
|
||||||
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
||||||
})
|
})
|
||||||
|
|
@ -90,6 +91,10 @@ router
|
||||||
router.get('/services', [SystemController, 'getServices'])
|
router.get('/services', [SystemController, 'getServices'])
|
||||||
router.post('/services/affect', [SystemController, 'affectService'])
|
router.post('/services/affect', [SystemController, 'affectService'])
|
||||||
router.post('/services/install', [SystemController, 'installService'])
|
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')
|
.prefix('/api/system')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,3 +60,10 @@ export type NomadDiskInfo = {
|
||||||
percentUsed: number
|
percentUsed: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SystemUpdateStatus = {
|
||||||
|
stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error'
|
||||||
|
progress: number
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ WHIPTAIL_TITLE="Project N.O.M.A.D Installation"
|
||||||
NOMAD_DIR="/opt/project-nomad"
|
NOMAD_DIR="/opt/project-nomad"
|
||||||
MANAGEMENT_COMPOSE_FILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/management_compose.yaml"
|
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"
|
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"
|
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"
|
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"
|
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"
|
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() {
|
download_and_start_collect_disk_info_script() {
|
||||||
local collect_disk_info_script_path="${NOMAD_DIR}/collect_disk_info.sh"
|
local collect_disk_info_script_path="${NOMAD_DIR}/collect_disk_info.sh"
|
||||||
|
|
||||||
|
|
@ -340,7 +368,7 @@ download_helper_scripts() {
|
||||||
|
|
||||||
start_management_containers() {
|
start_management_containers() {
|
||||||
echo -e "${YELLOW}#${RESET} Starting management containers using docker compose...\\n"
|
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."
|
echo -e "${RED}#${RESET} Failed to start management containers. Please check the logs and try again."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -383,6 +411,7 @@ get_local_ip
|
||||||
create_nomad_directory
|
create_nomad_directory
|
||||||
download_wait_for_it_script
|
download_wait_for_it_script
|
||||||
download_entrypoint_script
|
download_entrypoint_script
|
||||||
|
download_sidecar_files
|
||||||
download_helper_scripts
|
download_helper_scripts
|
||||||
download_and_start_collect_disk_info_script
|
download_and_start_collect_disk_info_script
|
||||||
download_management_compose_file
|
download_management_compose_file
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
name: project-nomad
|
||||||
services:
|
services:
|
||||||
admin:
|
admin:
|
||||||
image: ghcr.io/crosstalk-solutions/project-nomad:latest
|
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
|
- /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
|
- ./entrypoint.sh:/usr/local/bin/entrypoint.sh
|
||||||
- ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh
|
- ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh
|
||||||
|
- nomad-update-shared:/app/update-shared # Shared volume for update communication
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
|
|
@ -81,3 +83,17 @@ services:
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
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
|
||||||
15
install/sidecar-updater/Dockerfile
Normal file
15
install/sidecar-updater/Dockerfile
Normal 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"]
|
||||||
134
install/sidecar-updater/update-watcher.sh
Normal file
134
install/sidecar-updater/update-watcher.sh
Normal 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
|
||||||
|
|
@ -97,7 +97,7 @@ try_remove_disk_info_file() {
|
||||||
|
|
||||||
uninstall_nomad() {
|
uninstall_nomad() {
|
||||||
echo "Stopping and removing Project N.O.M.A.D. management containers..."
|
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..."
|
echo "Allowing some time for management containers to stop..."
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,13 +103,13 @@ ensure_docker_compose_file_exists() {
|
||||||
|
|
||||||
force_recreate() {
|
force_recreate() {
|
||||||
echo -e "${YELLOW}#${RESET} Pulling the latest Docker images..."
|
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."
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}#${RESET} Forcing recreation of containers..."
|
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."
|
echo -e "${RED}#${RESET} Failed to recreate containers. Please check the Docker logs for more details."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user