Skip to content

Commit 393c177

Browse files
committed
feat: [wip] self updates
1 parent b6ac6b1 commit 393c177

File tree

15 files changed

+821
-9
lines changed

15 files changed

+821
-9
lines changed

admin/app/controllers/settings_controller.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ export default class SettingsController {
4343
});
4444
}
4545

46+
async update({ inertia }: HttpContext) {
47+
const updateInfo = await this.systemService.checkLatestVersion();
48+
return inertia.render('settings/update', {
49+
system: {
50+
updateAvailable: updateInfo.updateAvailable,
51+
latestVersion: updateInfo.latestVersion,
52+
currentVersion: updateInfo.currentVersion
53+
}
54+
});
55+
}
56+
4657
async zim({ inertia }: HttpContext) {
4758
return inertia.render('settings/zim/index')
4859
}

admin/app/controllers/system_controller.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DockerService } from '#services/docker_service';
22
import { SystemService } from '#services/system_service'
3+
import { SystemUpdateService } from '#services/system_update_service'
34
import { affectServiceValidator, installServiceValidator } from '#validators/system';
45
import { inject } from '@adonisjs/core'
56
import type { HttpContext } from '@adonisjs/core/http'
@@ -8,7 +9,8 @@ import type { HttpContext } from '@adonisjs/core/http'
89
export default class SystemController {
910
constructor(
1011
private systemService: SystemService,
11-
private dockerService: DockerService
12+
private dockerService: DockerService,
13+
private systemUpdateService: SystemUpdateService
1214
) { }
1315

1416
async getInternetStatus({ }: HttpContext) {
@@ -43,4 +45,51 @@ export default class SystemController {
4345
}
4446
response.send({ success: result.success, message: result.message });
4547
}
48+
49+
async checkLatestVersion({ }: HttpContext) {
50+
return await this.systemService.checkLatestVersion();
51+
}
52+
53+
async requestSystemUpdate({ response }: HttpContext) {
54+
if (!this.systemUpdateService.isSidecarAvailable()) {
55+
response.status(503).send({
56+
success: false,
57+
error: 'Update sidecar is not available. Ensure the updater container is running.',
58+
});
59+
return;
60+
}
61+
62+
const result = await this.systemUpdateService.requestUpdate();
63+
64+
if (result.success) {
65+
response.send({
66+
success: true,
67+
message: result.message,
68+
note: 'Monitor update progress via GET /api/system/update/status. The connection may drop during container restart.',
69+
});
70+
} else {
71+
response.status(409).send({
72+
success: false,
73+
error: result.message,
74+
});
75+
}
76+
}
77+
78+
async getSystemUpdateStatus({ response }: HttpContext) {
79+
const status = this.systemUpdateService.getUpdateStatus();
80+
81+
if (!status) {
82+
response.status(500).send({
83+
error: 'Failed to retrieve update status',
84+
});
85+
return;
86+
}
87+
88+
response.send(status);
89+
}
90+
91+
async getSystemUpdateLogs({ response }: HttpContext) {
92+
const logs = this.systemUpdateService.getUpdateLogs();
93+
response.send({ logs });
94+
}
4695
}

admin/app/services/system_service.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,16 @@ export class SystemService {
6262

6363
const query = Service.query()
6464
.orderBy('friendly_name', 'asc')
65-
.select('id', 'service_name', 'installed', 'installation_status', 'ui_location', 'friendly_name', 'description', 'icon')
65+
.select(
66+
'id',
67+
'service_name',
68+
'installed',
69+
'installation_status',
70+
'ui_location',
71+
'friendly_name',
72+
'description',
73+
'icon'
74+
)
6675
.where('is_dependency_service', false)
6776
if (installedOnly) {
6877
query.where('installed', true)
@@ -166,6 +175,52 @@ export class SystemService {
166175
}
167176
}
168177

178+
async checkLatestVersion(): Promise<{
179+
success: boolean
180+
updateAvailable: boolean
181+
currentVersion: string
182+
latestVersion: string
183+
message?: string
184+
}> {
185+
try {
186+
const response = await axios.get(
187+
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
188+
{
189+
headers: { Accept: 'application/vnd.github+json' },
190+
timeout: 5000,
191+
}
192+
)
193+
194+
if (!response || !response.data?.tag_name) {
195+
throw new Error('Invalid response from GitHub API')
196+
}
197+
198+
const latestVersion = response.data.tag_name.replace(/^v/, '') // Remove leading 'v' if present
199+
const currentVersion = SystemService.getAppVersion()
200+
201+
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
202+
203+
// NOTE: this will always return true in dev environment! See getAppVersion()
204+
const updateAvailable = latestVersion !== currentVersion
205+
206+
return {
207+
success: true,
208+
updateAvailable,
209+
currentVersion,
210+
latestVersion,
211+
}
212+
} catch (error) {
213+
logger.error('Error checking latest version:', error)
214+
return {
215+
success: false,
216+
updateAvailable: false,
217+
currentVersion: '',
218+
latestVersion: '',
219+
message: `Failed to check latest version: ${error instanceof Error ? error.message : error}`,
220+
}
221+
}
222+
}
223+
169224
/**
170225
* Checks the current state of Docker containers against the database records and updates the database accordingly.
171226
* It will mark services as not installed if their corresponding containers are not running, and can also handle cleanup of any orphaned records.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import logger from '@adonisjs/core/services/logger'
2+
import { readFileSync, existsSync } from 'fs'
3+
import { writeFile } from 'fs/promises'
4+
import { join } from 'path'
5+
6+
interface UpdateStatus {
7+
stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error'
8+
progress: number
9+
message: string
10+
timestamp: string
11+
}
12+
13+
export class SystemUpdateService {
14+
private static SHARED_DIR = '/app/update-shared'
15+
private static REQUEST_FILE = join(SystemUpdateService.SHARED_DIR, 'update-request')
16+
private static STATUS_FILE = join(SystemUpdateService.SHARED_DIR, 'update-status')
17+
private static LOG_FILE = join(SystemUpdateService.SHARED_DIR, 'update-log')
18+
19+
/**
20+
* Requests a system update by creating a request file that the sidecar will detect
21+
*/
22+
async requestUpdate(): Promise<{ success: boolean; message: string }> {
23+
try {
24+
const currentStatus = this.getUpdateStatus()
25+
if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) {
26+
return {
27+
success: false,
28+
message: `Update already in progress (stage: ${currentStatus.stage})`,
29+
}
30+
}
31+
32+
const requestData = {
33+
requested_at: new Date().toISOString(),
34+
requester: 'admin-api',
35+
}
36+
37+
await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2))
38+
logger.info('[SystemUpdateService]: System update requested - sidecar will process shortly')
39+
40+
return {
41+
success: true,
42+
message: 'System update initiated. The admin container will restart during the process.',
43+
}
44+
} catch (error) {
45+
logger.error('[SystemUpdateService]: Failed to request system update:', error)
46+
return {
47+
success: false,
48+
message: `Failed to request update: ${error.message}`,
49+
}
50+
}
51+
}
52+
53+
getUpdateStatus(): UpdateStatus | null {
54+
try {
55+
if (!existsSync(SystemUpdateService.STATUS_FILE)) {
56+
return {
57+
stage: 'idle',
58+
progress: 0,
59+
message: 'No update in progress',
60+
timestamp: new Date().toISOString(),
61+
}
62+
}
63+
64+
const statusContent = readFileSync(SystemUpdateService.STATUS_FILE, 'utf-8')
65+
return JSON.parse(statusContent) as UpdateStatus
66+
} catch (error) {
67+
logger.error('[SystemUpdateService]: Failed to read update status:', error)
68+
return null
69+
}
70+
}
71+
72+
getUpdateLogs(): string {
73+
try {
74+
if (!existsSync(SystemUpdateService.LOG_FILE)) {
75+
return 'No update logs available'
76+
}
77+
78+
return readFileSync(SystemUpdateService.LOG_FILE, 'utf-8')
79+
} catch (error) {
80+
logger.error('[SystemUpdateService]: Failed to read update logs:', error)
81+
return `Error reading logs: ${error.message}`
82+
}
83+
}
84+
85+
/**
86+
* Check if the update sidecar is reachable (i.e. shared volume is mounted)
87+
*/
88+
isSidecarAvailable(): boolean {
89+
try {
90+
return existsSync(SystemUpdateService.SHARED_DIR)
91+
} catch (error) {
92+
return false
93+
}
94+
}
95+
}

admin/inertia/layouts/SettingsLayout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
FolderIcon,
55
MagnifyingGlassIcon,
66
} from '@heroicons/react/24/outline'
7-
import { IconDashboard, IconGavel, IconMapRoute } from '@tabler/icons-react'
7+
import { IconArrowBigUpLines, IconDashboard, IconGavel, IconMapRoute } from '@tabler/icons-react'
88
import StyledSidebar from '~/components/StyledSidebar'
99
import { getServiceLink } from '~/lib/navigation'
1010

@@ -26,6 +26,12 @@ const navigation = [
2626
icon: MagnifyingGlassIcon,
2727
current: false,
2828
},
29+
{
30+
name: 'Check for Updates',
31+
href: '/settings/update',
32+
icon: IconArrowBigUpLines,
33+
current: false,
34+
},
2935
{ name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true },
3036
]
3137

admin/inertia/lib/api.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import axios, { AxiosInstance } from 'axios'
22
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
33
import { ServiceSlim } from '../../types/services'
44
import { FileEntry } from '../../types/files'
5-
import { SystemInformationResponse } from '../../types/system'
5+
import { SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
66
import { CuratedCollectionWithStatus, DownloadJobWithProgress } from '../../types/downloads'
77
import { catchInternal } from './util'
88

@@ -116,6 +116,29 @@ class API {
116116
})()
117117
}
118118

119+
async getSystemUpdateStatus() {
120+
return catchInternal(async () => {
121+
const response = await this.client.get<SystemUpdateStatus>('/system/update/status')
122+
return response.data
123+
})()
124+
}
125+
126+
async getSystemUpdateLogs() {
127+
return catchInternal(async () => {
128+
const response = await this.client.get<{ logs: string }>('/system/update/logs')
129+
return response.data
130+
})()
131+
}
132+
133+
async healthCheck() {
134+
return catchInternal(async () => {
135+
const response = await this.client.get<{ status: string }>('/health', {
136+
timeout: 5000,
137+
})
138+
return response.data
139+
})()
140+
}
141+
119142
async installService(service_name: string) {
120143
return catchInternal(async () => {
121144
const response = await this.client.post<{ success: boolean; message: string }>(
@@ -198,6 +221,15 @@ class API {
198221
return response.data
199222
})()
200223
}
224+
225+
async startSystemUpdate() {
226+
return catchInternal(async () => {
227+
const response = await this.client.post<{ success: boolean; message: string }>(
228+
'/system/update'
229+
)
230+
return response.data
231+
})()
232+
}
201233
}
202234

203235
export default new API()

0 commit comments

Comments
 (0)