From 58b106f38865b337d40c764277cbeb166a33fa1b Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Mon, 9 Mar 2026 04:55:43 +0000 Subject: [PATCH] feat: support for updating services --- admin/app/controllers/system_controller.ts | 70 ++- admin/app/jobs/check_service_updates_job.ts | 134 +++++ admin/app/models/service.ts | 9 + .../services/container_registry_service.ts | 484 ++++++++++++++++++ admin/app/services/docker_service.ts | 184 ++++++- admin/app/services/system_service.ts | 40 +- admin/app/utils/version.ts | 49 ++ admin/app/validators/system.ts | 7 + admin/commands/queue/work.ts | 6 +- admin/constants/broadcast.ts | 1 + ...000000001_add_update_fields_to_services.ts | 21 + ...1771000000002_pin_latest_service_images.ts | 61 +++ admin/database/seeders/service_seeder.ts | 14 +- .../components/InstallActivityFeed.tsx | 12 +- .../inertia/components/UpdateServiceModal.tsx | 145 ++++++ admin/inertia/lib/api.ts | 28 + admin/inertia/pages/settings/apps.tsx | 147 +++++- admin/start/routes.ts | 3 + admin/types/services.ts | 2 + 19 files changed, 1356 insertions(+), 61 deletions(-) create mode 100644 admin/app/jobs/check_service_updates_job.ts create mode 100644 admin/app/services/container_registry_service.ts create mode 100644 admin/app/utils/version.ts create mode 100644 admin/database/migrations/1771000000001_add_update_fields_to_services.ts create mode 100644 admin/database/migrations/1771000000002_pin_latest_service_images.ts create mode 100644 admin/inertia/components/UpdateServiceModal.tsx diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 7907f40..cdcde7f 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -1,7 +1,9 @@ import { DockerService } from '#services/docker_service'; import { SystemService } from '#services/system_service' import { SystemUpdateService } from '#services/system_update_service' -import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system'; +import { ContainerRegistryService } from '#services/container_registry_service' +import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job' +import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system'; import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' @@ -10,7 +12,8 @@ export default class SystemController { constructor( private systemService: SystemService, private dockerService: DockerService, - private systemUpdateService: SystemUpdateService + private systemUpdateService: SystemUpdateService, + private containerRegistryService: ContainerRegistryService ) { } async getInternetStatus({ }: HttpContext) { @@ -104,9 +107,70 @@ export default class SystemController { response.send({ logs }); } - + async subscribeToReleaseNotes({ request }: HttpContext) { const reqData = await request.validateUsing(subscribeToReleaseNotesValidator); return await this.systemService.subscribeToReleaseNotes(reqData.email); } + + async checkServiceUpdates({ response }: HttpContext) { + await CheckServiceUpdatesJob.dispatch() + response.send({ success: true, message: 'Service update check dispatched' }) + } + + async getAvailableVersions({ params, response }: HttpContext) { + const serviceName = params.name + const service = await (await import('#models/service')).default + .query() + .where('service_name', serviceName) + .where('installed', true) + .first() + + if (!service) { + return response.status(404).send({ error: `Service ${serviceName} not found or not installed` }) + } + + try { + const hostArch = await this.getHostArch() + const updates = await this.containerRegistryService.getAvailableUpdates( + service.container_image, + hostArch, + service.source_repo + ) + response.send({ versions: updates }) + } catch (error) { + response.status(500).send({ error: `Failed to fetch versions: ${error.message}` }) + } + } + + async updateService({ request, response }: HttpContext) { + const payload = await request.validateUsing(updateServiceValidator) + const result = await this.dockerService.updateContainer( + payload.service_name, + payload.target_version + ) + + if (result.success) { + response.send({ success: true, message: result.message }) + } else { + response.status(400).send({ error: result.message }) + } + } + + private async getHostArch(): Promise { + try { + const info = await this.dockerService.docker.info() + const arch = info.Architecture || '' + const archMap: Record = { + x86_64: 'amd64', + aarch64: 'arm64', + armv7l: 'arm', + amd64: 'amd64', + arm64: 'arm64', + } + return archMap[arch] || arch.toLowerCase() + } catch { + return 'amd64' + } + } } \ No newline at end of file diff --git a/admin/app/jobs/check_service_updates_job.ts b/admin/app/jobs/check_service_updates_job.ts new file mode 100644 index 0000000..6fb7335 --- /dev/null +++ b/admin/app/jobs/check_service_updates_job.ts @@ -0,0 +1,134 @@ +import { Job } from 'bullmq' +import { QueueService } from '#services/queue_service' +import { DockerService } from '#services/docker_service' +import { ContainerRegistryService } from '#services/container_registry_service' +import Service from '#models/service' +import logger from '@adonisjs/core/services/logger' +import transmit from '@adonisjs/transmit/services/main' +import { BROADCAST_CHANNELS } from '../../constants/broadcast.js' +import { DateTime } from 'luxon' + +export class CheckServiceUpdatesJob { + static get queue() { + return 'service-updates' + } + + static get key() { + return 'check-service-updates' + } + + async handle(_job: Job) { + logger.info('[CheckServiceUpdatesJob] Checking for service updates...') + + const dockerService = new DockerService() + const registryService = new ContainerRegistryService() + + // Determine host architecture + const hostArch = await this.getHostArch(dockerService) + + const installedServices = await Service.query().where('installed', true) + let updatesFound = 0 + + for (const service of installedServices) { + try { + const updates = await registryService.getAvailableUpdates( + service.container_image, + hostArch, + service.source_repo + ) + + const latestUpdate = updates.length > 0 ? updates[0].tag : null + + service.available_update_version = latestUpdate + service.update_checked_at = DateTime.now() + await service.save() + + if (latestUpdate) { + updatesFound++ + logger.info( + `[CheckServiceUpdatesJob] Update available for ${service.service_name}: ${service.container_image} → ${latestUpdate}` + ) + } + } catch (error) { + logger.error( + `[CheckServiceUpdatesJob] Failed to check updates for ${service.service_name}: ${error.message}` + ) + // Continue checking other services + } + } + + logger.info( + `[CheckServiceUpdatesJob] Completed. ${updatesFound} update(s) found for ${installedServices.length} service(s).` + ) + + // Broadcast completion so the frontend can refresh + transmit.broadcast(BROADCAST_CHANNELS.SERVICE_UPDATES, { + status: 'completed', + updatesFound, + timestamp: new Date().toISOString(), + }) + + return { updatesFound } + } + + private async getHostArch(dockerService: DockerService): Promise { + try { + const info = await dockerService.docker.info() + const arch = info.Architecture || '' + + // Map Docker architecture names to OCI names + const archMap: Record = { + x86_64: 'amd64', + aarch64: 'arm64', + armv7l: 'arm', + amd64: 'amd64', + arm64: 'arm64', + } + + return archMap[arch] || arch.toLowerCase() + } catch (error) { + logger.warn( + `[CheckServiceUpdatesJob] Could not detect host architecture: ${error.message}. Defaulting to amd64.` + ) + return 'amd64' + } + } + + static async scheduleNightly() { + const queueService = new QueueService() + const queue = queueService.getQueue(this.queue) + + await queue.upsertJobScheduler( + 'nightly-service-update-check', + { pattern: '0 3 * * *' }, + { + name: this.key, + opts: { + removeOnComplete: { count: 7 }, + removeOnFail: { count: 5 }, + }, + } + ) + + logger.info('[CheckServiceUpdatesJob] Service update check scheduled with cron: 0 3 * * *') + } + + static async dispatch() { + const queueService = new QueueService() + const queue = queueService.getQueue(this.queue) + + const job = await queue.add( + this.key, + {}, + { + attempts: 3, + backoff: { type: 'exponential', delay: 60000 }, + removeOnComplete: { count: 7 }, + removeOnFail: { count: 5 }, + } + ) + + logger.info(`[CheckServiceUpdatesJob] Dispatched ad-hoc service update check job ${job.id}`) + return job + } +} diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index e87ec46..1c06ed5 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -62,6 +62,15 @@ export default class Service extends BaseModel { @column() declare metadata: string | null + @column() + declare source_repo: string | null + + @column() + declare available_update_version: string | null + + @column.dateTime() + declare update_checked_at: DateTime | null + @column.dateTime({ autoCreate: true }) declare created_at: DateTime diff --git a/admin/app/services/container_registry_service.ts b/admin/app/services/container_registry_service.ts new file mode 100644 index 0000000..01251fc --- /dev/null +++ b/admin/app/services/container_registry_service.ts @@ -0,0 +1,484 @@ +import logger from '@adonisjs/core/services/logger' +import { isNewerVersion, parseMajorVersion } from '../utils/version.js' + +export interface ParsedImageReference { + registry: string + namespace: string + repo: string + tag: string + /** Full name for registry API calls: namespace/repo */ + fullName: string +} + +export interface AvailableUpdate { + tag: string + isLatest: boolean + releaseUrl?: string +} + +interface TokenCacheEntry { + token: string + expiresAt: number +} + +const SEMVER_TAG_PATTERN = /^v?(\d+\.\d+(?:\.\d+)?)$/ +const PLATFORM_SUFFIXES = ['-arm64', '-amd64', '-alpine', '-slim', '-cuda', '-rocm'] +const REJECTED_TAGS = new Set(['latest', 'nightly', 'edge', 'dev', 'beta', 'alpha', 'canary', 'rc', 'test', 'debug']) + +export class ContainerRegistryService { + private tokenCache = new Map() + private sourceUrlCache = new Map() + private releaseTagPrefixCache = new Map() + + /** + * Parse a Docker image reference string into its components. + */ + parseImageReference(image: string): ParsedImageReference { + let registry: string + let remainder: string + let tag = 'latest' + + // Split off the tag + const lastColon = image.lastIndexOf(':') + if (lastColon > -1 && !image.substring(lastColon).includes('/')) { + tag = image.substring(lastColon + 1) + image = image.substring(0, lastColon) + } + + // Determine registry vs image path + const parts = image.split('/') + + if (parts.length === 1) { + // e.g. "nginx" → Docker Hub library image + registry = 'registry-1.docker.io' + remainder = `library/${parts[0]}` + } else if (parts.length === 2 && !parts[0].includes('.') && !parts[0].includes(':')) { + // e.g. "ollama/ollama" → Docker Hub user image + registry = 'registry-1.docker.io' + remainder = image + } else { + // e.g. "ghcr.io/kiwix/kiwix-serve" → custom registry + registry = parts[0] + remainder = parts.slice(1).join('/') + } + + const namespaceParts = remainder.split('/') + const repo = namespaceParts.pop()! + const namespace = namespaceParts.join('/') + + return { + registry, + namespace, + repo, + tag, + fullName: remainder, + } + } + + /** + * Get an anonymous auth token for the given registry and repository. + * NOTE: This could be expanded in the future to support private repo authentication + */ + private async getToken(registry: string, fullName: string): Promise { + const cacheKey = `${registry}/${fullName}` + const cached = this.tokenCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.token + } + + let tokenUrl: string + if (registry === 'registry-1.docker.io') { + tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${fullName}:pull` + } else if (registry === 'ghcr.io') { + tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${fullName}:pull` + } else { + // For other registries, try the standard v2 token endpoint + tokenUrl = `https://${registry}/token?service=${registry}&scope=repository:${fullName}:pull` + } + + const response = await this.fetchWithRetry(tokenUrl) + if (!response.ok) { + throw new Error(`Failed to get auth token from ${registry}: ${response.status}`) + } + + const data = (await response.json()) as { token?: string; access_token?: string } + const token = data.token || data.access_token || '' + + if (!token) { + throw new Error(`No token returned from ${registry}`) + } + + // Cache for 5 minutes (tokens usually last longer, but be conservative) + this.tokenCache.set(cacheKey, { + token, + expiresAt: Date.now() + 5 * 60 * 1000, + }) + + return token + } + + /** + * List all tags for a given image from the registry. + */ + async listTags(parsed: ParsedImageReference): Promise { + const token = await this.getToken(parsed.registry, parsed.fullName) + const allTags: string[] = [] + let url = `https://${parsed.registry}/v2/${parsed.fullName}/tags/list?n=1000` + + while (url) { + const response = await this.fetchWithRetry(url, { + headers: { Authorization: `Bearer ${token}` }, + }) + + if (!response.ok) { + throw new Error(`Failed to list tags for ${parsed.fullName}: ${response.status}`) + } + + const data = (await response.json()) as { tags?: string[] } + if (data.tags) { + allTags.push(...data.tags) + } + + // Handle pagination via Link header + const linkHeader = response.headers.get('link') + if (linkHeader) { + const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/) + url = match ? match[1] : '' + } else { + url = '' + } + } + + return allTags + } + + /** + * Check if a specific tag supports the given architecture by fetching its manifest. + */ + async checkArchSupport(parsed: ParsedImageReference, tag: string, hostArch: string): Promise { + try { + const token = await this.getToken(parsed.registry, parsed.fullName) + const url = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${tag}` + + const response = await this.fetchWithRetry(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: [ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json', + ].join(', '), + }, + }) + + if (!response.ok) return true // If we can't check, assume it's compatible + + const manifest = (await response.json()) as { + mediaType?: string + manifests?: Array<{ platform?: { architecture?: string } }> + } + const mediaType = manifest.mediaType || response.headers.get('content-type') || '' + + // Manifest list — check if any platform matches + if ( + mediaType.includes('manifest.list') || + mediaType.includes('image.index') || + manifest.manifests + ) { + const manifests = manifest.manifests || [] + return manifests.some( + (m: any) => m.platform && m.platform.architecture === hostArch + ) + } + + // Single manifest — assume compatible (can't easily determine arch without fetching config blob) + return true + } catch (error) { + logger.warn(`[ContainerRegistryService] Error checking arch for ${tag}: ${error.message}`) + return true // Assume compatible on error + } + } + + /** + * Extract the source repository URL from an image's OCI labels. + * Uses the standardized `org.opencontainers.image.source` label. + * Result is cached per image (not per tag). + */ + async getSourceUrl(parsed: ParsedImageReference): Promise { + const cacheKey = `${parsed.registry}/${parsed.fullName}` + if (this.sourceUrlCache.has(cacheKey)) { + return this.sourceUrlCache.get(cacheKey)! + } + + try { + const token = await this.getToken(parsed.registry, parsed.fullName) + + // First get the manifest to find the config blob digest + const manifestUrl = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${parsed.tag}` + const manifestRes = await this.fetchWithRetry(manifestUrl, { + headers: { + Authorization: `Bearer ${token}`, + Accept: [ + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + ].join(', '), + }, + }) + + if (!manifestRes.ok) { + this.sourceUrlCache.set(cacheKey, null) + return null + } + + const manifest = (await manifestRes.json()) as { + config?: { digest?: string } + manifests?: Array<{ digest?: string; mediaType?: string; platform?: { architecture?: string } }> + } + + // If this is a manifest list, pick the first manifest to get the config + let configDigest = manifest.config?.digest + if (!configDigest && manifest.manifests?.length) { + const firstManifest = manifest.manifests[0] + if (firstManifest.digest) { + const childRes = await this.fetchWithRetry( + `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${firstManifest.digest}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json', + }, + } + ) + if (childRes.ok) { + const childManifest = (await childRes.json()) as { config?: { digest?: string } } + configDigest = childManifest.config?.digest + } + } + } + + if (!configDigest) { + this.sourceUrlCache.set(cacheKey, null) + return null + } + + // Fetch the config blob to read labels + const blobUrl = `https://${parsed.registry}/v2/${parsed.fullName}/blobs/${configDigest}` + const blobRes = await this.fetchWithRetry(blobUrl, { + headers: { Authorization: `Bearer ${token}` }, + }) + + if (!blobRes.ok) { + this.sourceUrlCache.set(cacheKey, null) + return null + } + + const config = (await blobRes.json()) as { + config?: { Labels?: Record } + } + + const sourceUrl = config.config?.Labels?.['org.opencontainers.image.source'] || null + this.sourceUrlCache.set(cacheKey, sourceUrl) + return sourceUrl + } catch (error) { + logger.warn(`[ContainerRegistryService] Failed to get source URL for ${cacheKey}: ${error.message}`) + this.sourceUrlCache.set(cacheKey, null) + return null + } + } + + /** + * Detect whether a GitHub/GitLab repo uses a 'v' prefix on release tags. + * Probes the GitHub API with the current tag to determine the convention, + * then caches the result per source URL. + */ + async detectReleaseTagPrefix(sourceUrl: string, sampleTag: string): Promise { + if (this.releaseTagPrefixCache.has(sourceUrl)) { + return this.releaseTagPrefixCache.get(sourceUrl)! + } + + try { + const url = new URL(sourceUrl) + if (url.hostname !== 'github.com') { + this.releaseTagPrefixCache.set(sourceUrl, '') + return '' + } + + const cleanPath = url.pathname.replace(/\.git$/, '').replace(/\/$/, '') + const strippedTag = sampleTag.replace(/^v/, '') + const vTag = `v${strippedTag}` + + // Try both variants against GitHub's API — the one that 200s tells us the convention + // Try v-prefixed first since it's more common + const vRes = await this.fetchWithRetry( + `https://api.github.com/repos${cleanPath}/releases/tags/${vTag}`, + { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } }, + 1 + ) + if (vRes.ok) { + this.releaseTagPrefixCache.set(sourceUrl, 'v') + return 'v' + } + + const plainRes = await this.fetchWithRetry( + `https://api.github.com/repos${cleanPath}/releases/tags/${strippedTag}`, + { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } }, + 1 + ) + if (plainRes.ok) { + this.releaseTagPrefixCache.set(sourceUrl, '') + return '' + } + } catch { + // On error, fall through to default + } + + // Default: no prefix modification + this.releaseTagPrefixCache.set(sourceUrl, '') + return '' + } + + /** + * Build a release URL for a specific tag given a source repository URL and + * the detected release tag prefix convention. + * Supports GitHub and GitLab URL patterns. + */ + buildReleaseUrl(sourceUrl: string, tag: string, releaseTagPrefix: string): string | undefined { + try { + const url = new URL(sourceUrl) + if (url.hostname === 'github.com' || url.hostname.includes('gitlab')) { + const cleanPath = url.pathname.replace(/\.git$/, '').replace(/\/$/, '') + const strippedTag = tag.replace(/^v/, '') + const releaseTag = releaseTagPrefix ? `${releaseTagPrefix}${strippedTag}` : strippedTag + return `${url.origin}${cleanPath}/releases/tag/${releaseTag}` + } + } catch { + // Invalid URL, skip + } + return undefined + } + + /** + * Filter and sort tags to find compatible updates for a service. + */ + filterCompatibleUpdates( + tags: string[], + currentTag: string, + majorVersion: number + ): string[] { + return tags + .filter((tag) => { + // Must match semver pattern + if (!SEMVER_TAG_PATTERN.test(tag)) return false + + // Reject known non-version tags + if (REJECTED_TAGS.has(tag.toLowerCase())) return false + + // Reject platform suffixes + if (PLATFORM_SUFFIXES.some((suffix) => tag.toLowerCase().endsWith(suffix))) return false + + // Must be same major version + if (parseMajorVersion(tag) !== majorVersion) return false + + // Must be newer than current + return isNewerVersion(tag, currentTag) + }) + .sort((a, b) => (isNewerVersion(a, b) ? -1 : 1)) // Newest first + } + + /** + * High-level method to get available updates for a service. + * Returns a sorted list of compatible newer versions (newest first). + */ + async getAvailableUpdates( + containerImage: string, + hostArch: string, + fallbackSourceRepo?: string | null + ): Promise { + const parsed = this.parseImageReference(containerImage) + const currentTag = parsed.tag + + if (currentTag === 'latest') { + logger.warn( + `[ContainerRegistryService] Cannot check updates for ${containerImage} — using :latest tag` + ) + return [] + } + + const majorVersion = parseMajorVersion(currentTag) + + // Fetch tags and source URL in parallel + const [tags, ociSourceUrl] = await Promise.all([ + this.listTags(parsed), + this.getSourceUrl(parsed), + ]) + + // OCI label takes precedence, fall back to DB-stored source_repo + const sourceUrl = ociSourceUrl || fallbackSourceRepo || null + + const compatible = this.filterCompatibleUpdates(tags, currentTag, majorVersion) + + // Detect release tag prefix convention (e.g. 'v' vs no prefix) if we have a source URL + let releaseTagPrefix = '' + if (sourceUrl) { + releaseTagPrefix = await this.detectReleaseTagPrefix(sourceUrl, currentTag) + } + + // Check architecture support for the top candidates (limit checks to save API calls) + const maxArchChecks = 10 + const results: AvailableUpdate[] = [] + + for (const tag of compatible.slice(0, maxArchChecks)) { + const supported = await this.checkArchSupport(parsed, tag, hostArch) + if (supported) { + results.push({ + tag, + isLatest: results.length === 0, + releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined, + }) + } + } + + // For remaining tags (beyond arch check limit), include them but mark as not latest + for (const tag of compatible.slice(maxArchChecks)) { + results.push({ + tag, + isLatest: false, + releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined, + }) + } + + return results + } + + /** + * Fetch with retry and exponential backoff for rate limiting. + */ + private async fetchWithRetry( + url: string, + init?: RequestInit, + maxRetries = 3 + ): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const response = await fetch(url, init) + + if (response.status === 429 && attempt < maxRetries) { + const retryAfter = response.headers.get('retry-after') + const delay = retryAfter + ? parseInt(retryAfter, 10) * 1000 + : Math.pow(2, attempt) * 1000 + logger.warn( + `[ContainerRegistryService] Rate limited on ${url}, retrying in ${delay}ms` + ) + await new Promise((resolve) => setTimeout(resolve, delay)) + continue + } + + return response + } + + throw new Error(`Failed to fetch ${url} after ${maxRetries} retries`) + } +} diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index cc5a00f..8eafc64 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -113,8 +113,8 @@ export class DockerService { const containers = await this.docker.listContainers({ all: true }) const containerMap = new Map() containers.forEach((container) => { - const name = container.Names[0].replace('/', '') - if (name.startsWith('nomad_')) { + const name = container.Names[0]?.replace('/', '') + if (name && name.startsWith('nomad_')) { containerMap.set(name, container) } }) @@ -792,6 +792,186 @@ export class DockerService { // } // } + /** + * Update a service container to a new image version while preserving volumes and data. + * Includes automatic rollback if the new container fails health checks. + */ + async updateContainer( + serviceName: string, + targetVersion: string + ): Promise<{ success: boolean; message: string }> { + try { + const service = await Service.query().where('service_name', serviceName).first() + if (!service) { + return { success: false, message: `Service ${serviceName} not found` } + } + if (!service.installed) { + return { success: false, message: `Service ${serviceName} is not installed` } + } + if (this.activeInstallations.has(serviceName)) { + return { success: false, message: `Service ${serviceName} already has an operation in progress` } + } + + this.activeInstallations.add(serviceName) + + // Compute new image string + const currentImage = service.container_image + const imageBase = currentImage.includes(':') + ? currentImage.substring(0, currentImage.lastIndexOf(':')) + : currentImage + const newImage = `${imageBase}:${targetVersion}` + + // Step 1: Pull new image + this._broadcast(serviceName, 'update-pulling', `Pulling image ${newImage}...`) + const pullStream = await this.docker.pull(newImage) + await new Promise((res) => this.docker.modem.followProgress(pullStream, res)) + + // Step 2: Find and stop existing container + this._broadcast(serviceName, 'update-stopping', `Stopping current container...`) + const containers = await this.docker.listContainers({ all: true }) + const existingContainer = containers.find((c) => c.Names.includes(`/${serviceName}`)) + + if (!existingContainer) { + this.activeInstallations.delete(serviceName) + return { success: false, message: `Container for ${serviceName} not found` } + } + + const oldContainer = this.docker.getContainer(existingContainer.Id) + + // Inspect to capture full config before stopping + const inspectData = await oldContainer.inspect() + + if (existingContainer.State === 'running') { + await oldContainer.stop({ t: 15 }) + } + + // Step 3: Rename old container as safety net + const oldName = `${serviceName}_old` + await oldContainer.rename({ name: oldName }) + + // Step 4: Create new container with inspected config + new image + this._broadcast(serviceName, 'update-creating', `Creating updated container...`) + + const hostConfig = inspectData.HostConfig || {} + const newContainerConfig: any = { + Image: newImage, + name: serviceName, + Env: inspectData.Config?.Env || undefined, + Cmd: inspectData.Config?.Cmd || undefined, + ExposedPorts: inspectData.Config?.ExposedPorts || undefined, + WorkingDir: inspectData.Config?.WorkingDir || undefined, + User: inspectData.Config?.User || undefined, + HostConfig: { + Binds: hostConfig.Binds || undefined, + PortBindings: hostConfig.PortBindings || undefined, + RestartPolicy: hostConfig.RestartPolicy || undefined, + DeviceRequests: hostConfig.DeviceRequests || undefined, + Devices: hostConfig.Devices || undefined, + }, + NetworkingConfig: inspectData.NetworkSettings?.Networks + ? { + EndpointsConfig: Object.fromEntries( + Object.keys(inspectData.NetworkSettings.Networks).map((net) => [net, {}]) + ), + } + : undefined, + } + + // Remove undefined values from HostConfig + Object.keys(newContainerConfig.HostConfig).forEach((key) => { + if (newContainerConfig.HostConfig[key] === undefined) { + delete newContainerConfig.HostConfig[key] + } + }) + + let newContainer: any + try { + newContainer = await this.docker.createContainer(newContainerConfig) + } catch (createError) { + // Rollback: rename old container back + this._broadcast(serviceName, 'update-rollback', `Failed to create new container: ${createError.message}. Rolling back...`) + const rollbackContainer = this.docker.getContainer((await this.docker.listContainers({ all: true })).find((c) => c.Names.includes(`/${oldName}`))!.Id) + await rollbackContainer.rename({ name: serviceName }) + await rollbackContainer.start() + this.activeInstallations.delete(serviceName) + return { success: false, message: `Failed to create updated container: ${createError.message}` } + } + + // Step 5: Start new container + this._broadcast(serviceName, 'update-starting', `Starting updated container...`) + await newContainer.start() + + // Step 6: Health check — verify container stays running for 5 seconds + await new Promise((resolve) => setTimeout(resolve, 5000)) + const newContainerInfo = await newContainer.inspect() + + if (newContainerInfo.State?.Running) { + // Healthy — clean up old container + try { + const oldContainerRef = this.docker.getContainer( + (await this.docker.listContainers({ all: true })).find((c) => + c.Names.includes(`/${oldName}`) + )?.Id || '' + ) + await oldContainerRef.remove({ force: true }) + } catch { + // Old container may already be gone + } + + // Update DB + service.container_image = newImage + service.available_update_version = null + await service.save() + + this.activeInstallations.delete(serviceName) + this._broadcast( + serviceName, + 'update-complete', + `Successfully updated ${serviceName} to ${targetVersion}` + ) + return { success: true, message: `Service ${serviceName} updated to ${targetVersion}` } + } else { + // Unhealthy — rollback + this._broadcast( + serviceName, + 'update-rollback', + `New container failed health check. Rolling back to previous version...` + ) + + try { + await newContainer.stop({ t: 5 }).catch(() => {}) + await newContainer.remove({ force: true }) + } catch { + // Best effort cleanup + } + + // Restore old container + const oldContainers = await this.docker.listContainers({ all: true }) + const oldRef = oldContainers.find((c) => c.Names.includes(`/${oldName}`)) + if (oldRef) { + const rollbackContainer = this.docker.getContainer(oldRef.Id) + await rollbackContainer.rename({ name: serviceName }) + await rollbackContainer.start() + } + + this.activeInstallations.delete(serviceName) + return { + success: false, + message: `Update failed: new container did not stay running. Rolled back to previous version.`, + } + } + } catch (error) { + this.activeInstallations.delete(serviceName) + this._broadcast( + serviceName, + 'update-rollback', + `Update failed: ${error.message}` + ) + logger.error(`[DockerService] Update failed for ${serviceName}: ${error.message}`) + return { success: false, message: `Update failed: ${error.message}` } + } + } + private _broadcast(service: string, status: string, message: string) { transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, { service_name: service, diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 13cee1b..396ff30 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -13,6 +13,7 @@ import axios from 'axios' import env from '#start/env' import KVStore from '#models/kv_store' import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js' +import { isNewerVersion } from '../utils/version.js' @inject() @@ -142,7 +143,9 @@ export class SystemService { 'description', 'icon', 'powered_by', - 'display_order' + 'display_order', + 'container_image', + 'available_update_version' ) .where('is_dependency_service', false) if (installedOnly) { @@ -172,6 +175,8 @@ export class SystemService { ui_location: service.ui_location || '', powered_by: service.powered_by, display_order: service.display_order, + container_image: service.container_image, + available_update_version: service.available_update_version, }) } @@ -353,7 +358,7 @@ export class SystemService { const updateAvailable = process.env.NODE_ENV === 'development' ? false - : this.isNewerVersion(latestVersion, currentVersion.trim()) + : isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess) // Cache the results in KVStore for frontend checks await KVStore.setValue('system.updateAvailable', updateAvailable) @@ -494,35 +499,4 @@ export class SystemService { }) } - /** - * Compare two semantic version strings to determine if the first is newer than the second. - * @param version1 - The version to check (e.g., "1.25.0") - * @param version2 - The current version (e.g., "1.24.0") - * @returns true if version1 is newer than version2 - */ - private isNewerVersion(version1: string, version2: string): boolean { - const [base1, pre1] = version1.split('-') - const [base2, pre2] = version2.split('-') - - const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0) - const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0) - - const maxLen = Math.max(v1Parts.length, v2Parts.length) - for (let i = 0; i < maxLen; i++) { - const a = v1Parts[i] || 0 - const b = v2Parts[i] || 0 - if (a > b) return true - if (a < b) return false - } - - // Base versions equal — GA > RC, RC.n+1 > RC.n - if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer - if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer - if (!pre1 && !pre2) return false // both GA, equal - - // Both prerelease: compare numeric suffix (e.g. "rc.2" vs "rc.1") - const pre1Num = parseInt(pre1.split('.')[1], 10) || 0 - const pre2Num = parseInt(pre2.split('.')[1], 10) || 0 - return pre1Num > pre2Num - } } diff --git a/admin/app/utils/version.ts b/admin/app/utils/version.ts new file mode 100644 index 0000000..2eb195a --- /dev/null +++ b/admin/app/utils/version.ts @@ -0,0 +1,49 @@ +/** + * Compare two semantic version strings to determine if the first is newer than the second. + * @param version1 - The version to check (e.g., "1.25.0") + * @param version2 - The current version (e.g., "1.24.0") + * @returns true if version1 is newer than version2 + */ +export function isNewerVersion(version1: string, version2: string, includePreReleases = false): boolean { + const normalize = (v: string) => v.replace(/^v/, '') + const [base1, pre1] = normalize(version1).split('-') + const [base2, pre2] = normalize(version2).split('-') + + // If pre-releases are not included and version1 is a pre-release, don't consider it newer + if (!includePreReleases && pre1) { + return false + } + + const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0) + const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0) + + const maxLen = Math.max(v1Parts.length, v2Parts.length) + for (let i = 0; i < maxLen; i++) { + const a = v1Parts[i] || 0 + const b = v2Parts[i] || 0 + if (a > b) return true + if (a < b) return false + } + + // Base versions equal — GA > RC, RC.n+1 > RC.n + if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer + if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer + if (!pre1 && !pre2) return false // both GA, equal + + // Both prerelease: compare numeric suffix (e.g. "rc.2" vs "rc.1") + const pre1Num = parseInt(pre1.split('.')[1], 10) || 0 + const pre2Num = parseInt(pre2.split('.')[1], 10) || 0 + return pre1Num > pre2Num +} + +/** + * Parse the major version number from a tag string. + * Strips the 'v' prefix if present. + * @param tag - Version tag (e.g., "v3.8.1", "10.19.4") + * @returns The major version number + */ +export function parseMajorVersion(tag: string): number { + const normalized = tag.replace(/^v/, '') + const major = parseInt(normalized.split('.')[0], 10) + return isNaN(major) ? 0 : major +} diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index a167f89..41eb6a6 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -24,3 +24,10 @@ export const checkLatestVersionValidator = vine.compile( force: vine.boolean().optional(), // Optional flag to force bypassing cache and checking for updates immediately }) ) + +export const updateServiceValidator = vine.compile( + vine.object({ + service_name: vine.string().trim(), + target_version: vine.string().trim(), + }) +) diff --git a/admin/commands/queue/work.ts b/admin/commands/queue/work.ts index 78bcf65..e39fdbf 100644 --- a/admin/commands/queue/work.ts +++ b/admin/commands/queue/work.ts @@ -7,6 +7,7 @@ import { DownloadModelJob } from '#jobs/download_model_job' import { RunBenchmarkJob } from '#jobs/run_benchmark_job' import { EmbedFileJob } from '#jobs/embed_file_job' import { CheckUpdateJob } from '#jobs/check_update_job' +import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job' export default class QueueWork extends BaseCommand { static commandName = 'queue:work' @@ -76,8 +77,9 @@ export default class QueueWork extends BaseCommand { this.logger.info(`Worker started for queue: ${queueName}`) } - // Schedule nightly update check (idempotent, will persist over restarts) + // Schedule nightly update checks (idempotent, will persist over restarts) await CheckUpdateJob.scheduleNightly() + await CheckServiceUpdatesJob.scheduleNightly() // Graceful shutdown for all workers process.on('SIGTERM', async () => { @@ -97,12 +99,14 @@ export default class QueueWork extends BaseCommand { handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob()) handlers.set(EmbedFileJob.key, new EmbedFileJob()) handlers.set(CheckUpdateJob.key, new CheckUpdateJob()) + handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob()) queues.set(RunDownloadJob.key, RunDownloadJob.queue) queues.set(DownloadModelJob.key, DownloadModelJob.queue) queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue) queues.set(EmbedFileJob.key, EmbedFileJob.queue) queues.set(CheckUpdateJob.key, CheckUpdateJob.queue) + queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue) return [handlers, queues] } diff --git a/admin/constants/broadcast.ts b/admin/constants/broadcast.ts index ce0c106..55c3048 100644 --- a/admin/constants/broadcast.ts +++ b/admin/constants/broadcast.ts @@ -3,4 +3,5 @@ export const BROADCAST_CHANNELS = { BENCHMARK_PROGRESS: 'benchmark-progress', OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download', SERVICE_INSTALLATION: 'service-installation', + SERVICE_UPDATES: 'service-updates', } \ No newline at end of file diff --git a/admin/database/migrations/1771000000001_add_update_fields_to_services.ts b/admin/database/migrations/1771000000001_add_update_fields_to_services.ts new file mode 100644 index 0000000..9213dfe --- /dev/null +++ b/admin/database/migrations/1771000000001_add_update_fields_to_services.ts @@ -0,0 +1,21 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('source_repo', 255).nullable() + table.string('available_update_version', 50).nullable() + table.timestamp('update_checked_at').nullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('source_repo') + table.dropColumn('available_update_version') + table.dropColumn('update_checked_at') + }) + } +} diff --git a/admin/database/migrations/1771000000002_pin_latest_service_images.ts b/admin/database/migrations/1771000000002_pin_latest_service_images.ts new file mode 100644 index 0000000..d5b9523 --- /dev/null +++ b/admin/database/migrations/1771000000002_pin_latest_service_images.ts @@ -0,0 +1,61 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.defer(async (db) => { + // Pin :latest images to specific versions + await db + .from(this.tableName) + .where('container_image', 'ghcr.io/gchq/cyberchef:latest') + .update({ container_image: 'ghcr.io/gchq/cyberchef:10.19.4' }) + + await db + .from(this.tableName) + .where('container_image', 'dullage/flatnotes:latest') + .update({ container_image: 'dullage/flatnotes:v5.5.4' }) + + await db + .from(this.tableName) + .where('container_image', 'treehouses/kolibri:latest') + .update({ container_image: 'treehouses/kolibri:0.12.8' }) + + // Populate source_repo for services whose images lack the OCI source label + const sourceRepos: Record = { + nomad_kiwix_server: 'https://github.com/kiwix/kiwix-tools', + nomad_ollama: 'https://github.com/ollama/ollama', + nomad_qdrant: 'https://github.com/qdrant/qdrant', + nomad_cyberchef: 'https://github.com/gchq/CyberChef', + nomad_flatnotes: 'https://github.com/dullage/flatnotes', + nomad_kolibri: 'https://github.com/learningequality/kolibri', + } + + for (const [serviceName, repoUrl] of Object.entries(sourceRepos)) { + await db + .from(this.tableName) + .where('service_name', serviceName) + .update({ source_repo: repoUrl }) + } + }) + } + + async down() { + this.defer(async (db) => { + await db + .from(this.tableName) + .where('container_image', 'ghcr.io/gchq/cyberchef:10.19.4') + .update({ container_image: 'ghcr.io/gchq/cyberchef:latest' }) + + await db + .from(this.tableName) + .where('container_image', 'dullage/flatnotes:v5.5.4') + .update({ container_image: 'dullage/flatnotes:latest' }) + + await db + .from(this.tableName) + .where('container_image', 'treehouses/kolibri:0.12.8') + .update({ container_image: 'treehouses/kolibri:latest' }) + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index 38ed020..2afa6f3 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -12,7 +12,7 @@ export default class ServiceSeeder extends BaseSeeder { ) private static DEFAULT_SERVICES: Omit< ModelAttributes, - 'created_at' | 'updated_at' | 'metadata' | 'id' + 'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at' >[] = [ { service_name: SERVICE_NAMES.KIWIX, @@ -23,6 +23,7 @@ export default class ServiceSeeder extends BaseSeeder { 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias', icon: 'IconBooks', container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1', + source_repo: 'https://github.com/kiwix/kiwix-tools', container_command: '*.zim --address=all', container_config: JSON.stringify({ HostConfig: { @@ -46,6 +47,7 @@ export default class ServiceSeeder extends BaseSeeder { description: 'Vector database for storing and searching embeddings', icon: 'IconRobot', container_image: 'qdrant/qdrant:v1.16', + source_repo: 'https://github.com/qdrant/qdrant', container_command: null, container_config: JSON.stringify({ HostConfig: { @@ -69,6 +71,7 @@ export default class ServiceSeeder extends BaseSeeder { description: 'Local AI chat that runs entirely on your hardware - no internet required', icon: 'IconWand', container_image: 'ollama/ollama:0.15.2', + source_repo: 'https://github.com/ollama/ollama', container_command: 'serve', container_config: JSON.stringify({ HostConfig: { @@ -91,7 +94,8 @@ export default class ServiceSeeder extends BaseSeeder { display_order: 11, description: 'Swiss Army knife for data encoding, encryption, and analysis', icon: 'IconChefHat', - container_image: 'ghcr.io/gchq/cyberchef:latest', + container_image: 'ghcr.io/gchq/cyberchef:10.19.4', + source_repo: 'https://github.com/gchq/CyberChef', container_command: null, container_config: JSON.stringify({ HostConfig: { @@ -113,7 +117,8 @@ export default class ServiceSeeder extends BaseSeeder { display_order: 10, description: 'Simple note-taking app with local storage', icon: 'IconNotes', - container_image: 'dullage/flatnotes:latest', + container_image: 'dullage/flatnotes:v5.5.4', + source_repo: 'https://github.com/dullage/flatnotes', container_command: null, container_config: JSON.stringify({ HostConfig: { @@ -137,7 +142,8 @@ export default class ServiceSeeder extends BaseSeeder { display_order: 2, description: 'Interactive learning platform with video courses and exercises', icon: 'IconSchool', - container_image: 'treehouses/kolibri:latest', + container_image: 'treehouses/kolibri:0.12.8', + source_repo: 'https://github.com/learningequality/kolibri', container_command: null, container_config: JSON.stringify({ HostConfig: { diff --git a/admin/inertia/components/InstallActivityFeed.tsx b/admin/inertia/components/InstallActivityFeed.tsx index 0172883..135eb54 100644 --- a/admin/inertia/components/InstallActivityFeed.tsx +++ b/admin/inertia/components/InstallActivityFeed.tsx @@ -1,4 +1,4 @@ -import { IconCircleCheck } from '@tabler/icons-react' +import { IconCircleCheck, IconCircleX } from '@tabler/icons-react' import classNames from '~/lib/classNames' export type InstallActivityFeedProps = { @@ -16,6 +16,12 @@ export type InstallActivityFeedProps = { | 'started' | 'finalizing' | 'completed' + | 'update-pulling' + | 'update-stopping' + | 'update-creating' + | 'update-starting' + | 'update-complete' + | 'update-rollback' timestamp: string message: string }> @@ -40,8 +46,10 @@ const InstallActivityFeed: React.FC = ({ activity, cla <>
- {activityItem.type === 'completed' ? ( + {activityItem.type === 'completed' || activityItem.type === 'update-complete' ? (