Skip to content

Commit bdeccfa

Browse files
committed
feat: support for updating services
1 parent b6a32a5 commit bdeccfa

File tree

19 files changed

+1356
-61
lines changed

19 files changed

+1356
-61
lines changed

admin/app/controllers/system_controller.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { DockerService } from '#services/docker_service';
22
import { SystemService } from '#services/system_service'
33
import { SystemUpdateService } from '#services/system_update_service'
4-
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system';
4+
import { ContainerRegistryService } from '#services/container_registry_service'
5+
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
6+
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
57
import { inject } from '@adonisjs/core'
68
import type { HttpContext } from '@adonisjs/core/http'
79

@@ -10,7 +12,8 @@ export default class SystemController {
1012
constructor(
1113
private systemService: SystemService,
1214
private dockerService: DockerService,
13-
private systemUpdateService: SystemUpdateService
15+
private systemUpdateService: SystemUpdateService,
16+
private containerRegistryService: ContainerRegistryService
1417
) { }
1518

1619
async getInternetStatus({ }: HttpContext) {
@@ -104,9 +107,70 @@ export default class SystemController {
104107
response.send({ logs });
105108
}
106109

107-
110+
108111
async subscribeToReleaseNotes({ request }: HttpContext) {
109112
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
110113
return await this.systemService.subscribeToReleaseNotes(reqData.email);
111114
}
115+
116+
async checkServiceUpdates({ response }: HttpContext) {
117+
await CheckServiceUpdatesJob.dispatch()
118+
response.send({ success: true, message: 'Service update check dispatched' })
119+
}
120+
121+
async getAvailableVersions({ params, response }: HttpContext) {
122+
const serviceName = params.name
123+
const service = await (await import('#models/service')).default
124+
.query()
125+
.where('service_name', serviceName)
126+
.where('installed', true)
127+
.first()
128+
129+
if (!service) {
130+
return response.status(404).send({ error: `Service ${serviceName} not found or not installed` })
131+
}
132+
133+
try {
134+
const hostArch = await this.getHostArch()
135+
const updates = await this.containerRegistryService.getAvailableUpdates(
136+
service.container_image,
137+
hostArch,
138+
service.source_repo
139+
)
140+
response.send({ versions: updates })
141+
} catch (error) {
142+
response.status(500).send({ error: `Failed to fetch versions: ${error.message}` })
143+
}
144+
}
145+
146+
async updateService({ request, response }: HttpContext) {
147+
const payload = await request.validateUsing(updateServiceValidator)
148+
const result = await this.dockerService.updateContainer(
149+
payload.service_name,
150+
payload.target_version
151+
)
152+
153+
if (result.success) {
154+
response.send({ success: true, message: result.message })
155+
} else {
156+
response.status(400).send({ error: result.message })
157+
}
158+
}
159+
160+
private async getHostArch(): Promise<string> {
161+
try {
162+
const info = await this.dockerService.docker.info()
163+
const arch = info.Architecture || ''
164+
const archMap: Record<string, string> = {
165+
x86_64: 'amd64',
166+
aarch64: 'arm64',
167+
armv7l: 'arm',
168+
amd64: 'amd64',
169+
arm64: 'arm64',
170+
}
171+
return archMap[arch] || arch.toLowerCase()
172+
} catch {
173+
return 'amd64'
174+
}
175+
}
112176
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { Job } from 'bullmq'
2+
import { QueueService } from '#services/queue_service'
3+
import { DockerService } from '#services/docker_service'
4+
import { ContainerRegistryService } from '#services/container_registry_service'
5+
import Service from '#models/service'
6+
import logger from '@adonisjs/core/services/logger'
7+
import transmit from '@adonisjs/transmit/services/main'
8+
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
9+
import { DateTime } from 'luxon'
10+
11+
export class CheckServiceUpdatesJob {
12+
static get queue() {
13+
return 'service-updates'
14+
}
15+
16+
static get key() {
17+
return 'check-service-updates'
18+
}
19+
20+
async handle(_job: Job) {
21+
logger.info('[CheckServiceUpdatesJob] Checking for service updates...')
22+
23+
const dockerService = new DockerService()
24+
const registryService = new ContainerRegistryService()
25+
26+
// Determine host architecture
27+
const hostArch = await this.getHostArch(dockerService)
28+
29+
const installedServices = await Service.query().where('installed', true)
30+
let updatesFound = 0
31+
32+
for (const service of installedServices) {
33+
try {
34+
const updates = await registryService.getAvailableUpdates(
35+
service.container_image,
36+
hostArch,
37+
service.source_repo
38+
)
39+
40+
const latestUpdate = updates.length > 0 ? updates[0].tag : null
41+
42+
service.available_update_version = latestUpdate
43+
service.update_checked_at = DateTime.now()
44+
await service.save()
45+
46+
if (latestUpdate) {
47+
updatesFound++
48+
logger.info(
49+
`[CheckServiceUpdatesJob] Update available for ${service.service_name}: ${service.container_image}${latestUpdate}`
50+
)
51+
}
52+
} catch (error) {
53+
logger.error(
54+
`[CheckServiceUpdatesJob] Failed to check updates for ${service.service_name}: ${error.message}`
55+
)
56+
// Continue checking other services
57+
}
58+
}
59+
60+
logger.info(
61+
`[CheckServiceUpdatesJob] Completed. ${updatesFound} update(s) found for ${installedServices.length} service(s).`
62+
)
63+
64+
// Broadcast completion so the frontend can refresh
65+
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_UPDATES, {
66+
status: 'completed',
67+
updatesFound,
68+
timestamp: new Date().toISOString(),
69+
})
70+
71+
return { updatesFound }
72+
}
73+
74+
private async getHostArch(dockerService: DockerService): Promise<string> {
75+
try {
76+
const info = await dockerService.docker.info()
77+
const arch = info.Architecture || ''
78+
79+
// Map Docker architecture names to OCI names
80+
const archMap: Record<string, string> = {
81+
x86_64: 'amd64',
82+
aarch64: 'arm64',
83+
armv7l: 'arm',
84+
amd64: 'amd64',
85+
arm64: 'arm64',
86+
}
87+
88+
return archMap[arch] || arch.toLowerCase()
89+
} catch (error) {
90+
logger.warn(
91+
`[CheckServiceUpdatesJob] Could not detect host architecture: ${error.message}. Defaulting to amd64.`
92+
)
93+
return 'amd64'
94+
}
95+
}
96+
97+
static async scheduleNightly() {
98+
const queueService = new QueueService()
99+
const queue = queueService.getQueue(this.queue)
100+
101+
await queue.upsertJobScheduler(
102+
'nightly-service-update-check',
103+
{ pattern: '0 3 * * *' },
104+
{
105+
name: this.key,
106+
opts: {
107+
removeOnComplete: { count: 7 },
108+
removeOnFail: { count: 5 },
109+
},
110+
}
111+
)
112+
113+
logger.info('[CheckServiceUpdatesJob] Service update check scheduled with cron: 0 3 * * *')
114+
}
115+
116+
static async dispatch() {
117+
const queueService = new QueueService()
118+
const queue = queueService.getQueue(this.queue)
119+
120+
const job = await queue.add(
121+
this.key,
122+
{},
123+
{
124+
attempts: 3,
125+
backoff: { type: 'exponential', delay: 60000 },
126+
removeOnComplete: { count: 7 },
127+
removeOnFail: { count: 5 },
128+
}
129+
)
130+
131+
logger.info(`[CheckServiceUpdatesJob] Dispatched ad-hoc service update check job ${job.id}`)
132+
return job
133+
}
134+
}

admin/app/models/service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ export default class Service extends BaseModel {
6262
@column()
6363
declare metadata: string | null
6464

65+
@column()
66+
declare source_repo: string | null
67+
68+
@column()
69+
declare available_update_version: string | null
70+
71+
@column.dateTime()
72+
declare update_checked_at: DateTime | null
73+
6574
@column.dateTime({ autoCreate: true })
6675
declare created_at: DateTime
6776

0 commit comments

Comments
 (0)