From 058a66140252688b0f2930da7c435cb2703e010f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:34:13 -0400 Subject: [PATCH] feat: add Docker self-upgrade capability for manager --- .env.example | 8 + Dockerfile | 5 + backend/src/db/manager-upgrade.ts | 117 ++++ .../db/migrations/016-manager-upgrade-jobs.ts | 27 + backend/src/db/migrations/index.ts | 2 + backend/src/index.ts | 15 + backend/src/routes/manager-upgrade.ts | 31 ++ backend/src/services/manager-upgrade.ts | 235 ++++++++ backend/src/utils/runtime-env.ts | 13 + backend/test/db/manager-upgrade.test.ts | 82 +++ backend/test/routes/manager-upgrade.test.ts | 126 +++++ backend/test/services/manager-upgrade.test.ts | 509 ++++++++++++++++++ backend/test/utils/runtime-env.test.ts | 64 +++ docker-compose.yml | 4 + frontend/src/api/settings.ts | 34 ++ .../settings/ServerHealthStatus.tsx | 29 + .../__tests__/useManagerUpgrade.test.tsx | 102 ++++ frontend/src/hooks/useManagerUpgrade.ts | 29 + scripts/docker-entrypoint.sh | 12 + 19 files changed, 1444 insertions(+) create mode 100644 backend/src/db/manager-upgrade.ts create mode 100644 backend/src/db/migrations/016-manager-upgrade-jobs.ts create mode 100644 backend/src/routes/manager-upgrade.ts create mode 100644 backend/src/services/manager-upgrade.ts create mode 100644 backend/src/utils/runtime-env.ts create mode 100644 backend/test/db/manager-upgrade.test.ts create mode 100644 backend/test/routes/manager-upgrade.test.ts create mode 100644 backend/test/services/manager-upgrade.test.ts create mode 100644 backend/test/utils/runtime-env.test.ts create mode 100644 frontend/src/hooks/__tests__/useManagerUpgrade.test.tsx create mode 100644 frontend/src/hooks/useManagerUpgrade.ts diff --git a/.env.example b/.env.example index 848020baa..3c574a74d 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,14 @@ DATABASE_PATH=./data/opencode.db # ============================================ WORKSPACE_PATH=./workspace +# ============================================ +# Manager Self-Upgrade (Docker) +# ============================================ +# The container image tag to pull for self-upgrade. +# OCM_IMAGE=ghcr.io/chriswritescode-dev/opencode-manager:latest +# Enable or disable the self-upgrade mechanism. +# OCM_MANAGER_UPGRADE_ENABLED=true + # Optional - convenience vars for Docker bind mounts documented in docs/configuration/docker.md # OCM_REPOS_HOST_PATH=/Users/you/Development # OCM_OPENCODE_CONFIG_HOST_PATH=/Users/you/.config/opencode diff --git a/Dockerfile b/Dockerfile index 7924a0645..54db438d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,11 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | d && apt-get update && apt-get install -y gh \ && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://download.docker.com/linux/debian/gpg | dd of=/usr/share/keyrings/docker-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \ + && apt-get update && apt-get install -y docker-ce-cli docker-compose-plugin \ + && rm -rf /var/lib/apt/lists/* + RUN corepack enable && corepack prepare pnpm@latest --activate RUN curl -fsSL https://bun.sh/install | bash && \ diff --git a/backend/src/db/manager-upgrade.ts b/backend/src/db/manager-upgrade.ts new file mode 100644 index 000000000..8697b0a48 --- /dev/null +++ b/backend/src/db/manager-upgrade.ts @@ -0,0 +1,117 @@ +import type { Database } from 'bun:sqlite' + +export type ManagerUpgradeStatus = 'pending' | 'pulling' | 'recreating' | 'completed' | 'failed' + +export interface ManagerUpgradeJob { + id: number + status: ManagerUpgradeStatus + fromVersion: string | null + toVersion: string | null + targetImage: string | null + error: string | null + startedAt: number + finishedAt: number | null +} + +interface ManagerUpgradeJobRow { + id: number + status: string + from_version: string | null + to_version: string | null + target_image: string | null + error: string | null + started_at: number + finished_at: number | null +} + +function rowToJob(row: ManagerUpgradeJobRow): ManagerUpgradeJob { + return { + id: row.id, + status: row.status as ManagerUpgradeStatus, + fromVersion: row.from_version, + toVersion: row.to_version, + targetImage: row.target_image, + error: row.error, + startedAt: row.started_at, + finishedAt: row.finished_at, + } +} + +export function insertUpgradeJob( + db: Database, + data: { + status: ManagerUpgradeStatus + fromVersion?: string + toVersion?: string + targetImage?: string + startedAt: number + }, +): ManagerUpgradeJob { + const stmt = db.prepare(` + INSERT INTO manager_upgrade_jobs (status, from_version, to_version, target_image, started_at) + VALUES (?, ?, ?, ?, ?) + `) + + const result = stmt.run( + data.status, + data.fromVersion ?? null, + data.toVersion ?? null, + data.targetImage ?? null, + data.startedAt, + ) + + const row = db.prepare('SELECT * FROM manager_upgrade_jobs WHERE id = ?') + .get(Number(result.lastInsertRowid)) as ManagerUpgradeJobRow | undefined + + if (!row) { + throw new Error('Failed to retrieve newly created upgrade job') + } + + return rowToJob(row) +} + +export function updateUpgradeJob( + db: Database, + id: number, + patch: Partial<{ + status: ManagerUpgradeStatus + error: string | null + finishedAt: number | null + }>, +): void { + const sets: string[] = [] + const values: unknown[] = [] + + if (patch.status !== undefined) { + sets.push('status = ?') + values.push(patch.status) + } + if (patch.error !== undefined) { + sets.push('error = ?') + values.push(patch.error) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + values.push(patch.finishedAt) + } + + if (sets.length === 0) return + + values.push(id) + db.prepare(`UPDATE manager_upgrade_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values as never) +} + +export function getLatestUpgradeJob(db: Database): ManagerUpgradeJob | null { + const row = db.prepare('SELECT * FROM manager_upgrade_jobs ORDER BY id DESC LIMIT 1') + .get() as ManagerUpgradeJobRow | undefined + + return row ? rowToJob(row) : null +} + +export function getActiveUpgradeJob(db: Database): ManagerUpgradeJob | null { + const row = db.prepare( + "SELECT * FROM manager_upgrade_jobs WHERE status IN ('pending', 'pulling', 'recreating') ORDER BY id DESC LIMIT 1", + ).get() as ManagerUpgradeJobRow | undefined + + return row ? rowToJob(row) : null +} diff --git a/backend/src/db/migrations/016-manager-upgrade-jobs.ts b/backend/src/db/migrations/016-manager-upgrade-jobs.ts new file mode 100644 index 000000000..769dfe454 --- /dev/null +++ b/backend/src/db/migrations/016-manager-upgrade-jobs.ts @@ -0,0 +1,27 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + version: 16, + name: 'manager-upgrade-jobs', + + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS manager_upgrade_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT NOT NULL, + from_version TEXT, + to_version TEXT, + target_image TEXT, + error TEXT, + started_at INTEGER NOT NULL, + finished_at INTEGER + ) + `) + }, + + down(db) { + db.run('DROP TABLE IF EXISTS manager_upgrade_jobs') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 809fd5614..0f2fde84e 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -14,6 +14,7 @@ import migration012 from './012-opencode-model-state' import migration013 from './013-app-secrets' import migration014 from './014-repos-add-name' import migration015 from './015-schedule-worktree-isolation' +import migration016 from './016-manager-upgrade-jobs' export const allMigrations: Migration[] = [ migration001, @@ -31,4 +32,5 @@ export const allMigrations: Migration[] = [ migration013, migration014, migration015, + migration016, ] diff --git a/backend/src/index.ts b/backend/src/index.ts index 7bdcd0246..ea8c89d43 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -13,6 +13,7 @@ import { createTTSRoutes, cleanupExpiredCache } from './routes/tts'; import { createSTTRoutes } from './routes/stt' import { createFileRoutes } from './routes/files' import { createScheduleRoutes } from './routes/schedules' +import { createManagerUpgradeRoutes } from './routes/manager-upgrade' async function getAppVersion(): Promise { try { @@ -50,6 +51,8 @@ import { migrateGlobalSkills } from './services/skills' import { installAssistantWorkspace } from './services/assistant-mode' import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import' import { OpenCodeSupervisor } from './services/opencode-supervisor' +import { ManagerUpgradeService, createDockerRunner } from './services/manager-upgrade' +import { isRunningInDocker, isDockerSocketAvailable } from './utils/runtime-env' import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' import { parse as parseJsonc } from 'jsonc-parser' import { getModelStatePath, ModelStateSchema } from './routes/providers' @@ -324,6 +327,17 @@ void scheduleRunnerInstance.start() const settingsService = new SettingsService(db) +const managerUpgradeService = new ManagerUpgradeService(db, { + runner: createDockerRunner(), + getCurrentVersion: () => getAppVersion(), + capability: () => ({ + inDocker: isRunningInDocker(), + socket: isDockerSocketAvailable(), + enabled: process.env.OCM_MANAGER_UPGRADE_ENABLED !== 'false', + }), +}) +managerUpgradeService.reconcile() + app.route('/api/auth', createAuthRoutes(auth)) app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) @@ -347,6 +361,7 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) +protectedApi.route('/manager-upgrade', createManagerUpgradeRoutes(managerUpgradeService)) app.route('/api', protectedApi) diff --git a/backend/src/routes/manager-upgrade.ts b/backend/src/routes/manager-upgrade.ts new file mode 100644 index 000000000..add8bf40e --- /dev/null +++ b/backend/src/routes/manager-upgrade.ts @@ -0,0 +1,31 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { ManagerUpgradeService, ManagerUpgradeError } from '../services/manager-upgrade' +import { handleServiceError } from '../utils/route-helpers' + +export function createManagerUpgradeRoutes(service: ManagerUpgradeService) { + const app = new Hono() + + app.get('/status', async (c) => { + try { + const status = await service.getStatus() + return c.json(status) + } catch (error) { + return handleServiceError(c, error, 'Failed to get manager upgrade status', ManagerUpgradeError) + } + }) + + app.post('/', async (c) => { + try { + const bodyText = await c.req.text() + const raw = bodyText.trim() === '' ? {} : JSON.parse(bodyText) + const { version } = z.object({ version: z.string().min(1).optional() }).parse(raw) + const job = await service.startUpgrade(version) + return c.json({ job }, 202) + } catch (error) { + return handleServiceError(c, error, 'Manager upgrade failed', ManagerUpgradeError) + } + }) + + return app +} diff --git a/backend/src/services/manager-upgrade.ts b/backend/src/services/manager-upgrade.ts new file mode 100644 index 000000000..5c88cd0d5 --- /dev/null +++ b/backend/src/services/manager-upgrade.ts @@ -0,0 +1,235 @@ +import type { Database } from 'bun:sqlite' +import { spawn } from 'child_process' +import { readFileSync } from 'fs' +import { hostname } from 'os' +import { executeCommand } from '../utils/process' +import { + insertUpgradeJob, + updateUpgradeJob, + getLatestUpgradeJob, + getActiveUpgradeJob, +} from '../db/manager-upgrade' +import type { ManagerUpgradeJob } from '../db/manager-upgrade' + +export interface SelfContainerInfo { + containerId: string + project: string + service: string + workingDir: string + image: string +} + +export interface DockerRunner { + inspectSelf(): Promise + pull(image: string): Promise + spawnRecreate(info: SelfContainerInfo, targetImage: string): void +} + +/** + * Replace only the tag suffix of a Docker image reference, preserving + * registry ports (e.g. `localhost:5000/org/app:old` → `localhost:5000/org/app:new`). + * If the image has no tag, appends `:newTag`. + */ +export function replaceImageTag(image: string, newTag: string): string { + const lastSlash = image.lastIndexOf('/') + const lastColon = image.lastIndexOf(':') + + if (lastColon > lastSlash) { + return image.slice(0, lastColon) + ':' + newTag + } + + return image + ':' + newTag +} + +export class ManagerUpgradeError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.status = status + } +} + +export interface UpgradeCapability { + inDocker: boolean + socket: boolean + enabled: boolean +} + +export class ManagerUpgradeService { + constructor( + private readonly db: Database, + private readonly deps: { + runner: DockerRunner + getCurrentVersion: () => Promise + capability: () => UpgradeCapability + }, + ) { + this.reconcile() + } + + reconcile(): void { + const active = getActiveUpgradeJob(this.db) + if (!active) return + + if (active.status === 'pulling' || active.status === 'pending') { + updateUpgradeJob(this.db, active.id, { + status: 'failed', + error: 'interrupted by restart', + finishedAt: Date.now(), + }) + return + } + + if (active.status === 'recreating') { + void this.deps.getCurrentVersion().then((currentVersion) => { + if (!currentVersion) return + if (currentVersion === active.toVersion || (active.fromVersion !== null && currentVersion !== active.fromVersion)) { + updateUpgradeJob(this.db, active.id, { + status: 'completed', + finishedAt: Date.now(), + error: null, + }) + } + }) + } + } + + async getStatus(): Promise<{ + supported: boolean + inDocker: boolean + socketAvailable: boolean + enabled: boolean + currentVersion: string | null + job: ManagerUpgradeJob | null + }> { + const cap = this.deps.capability() + const currentVersion = await this.deps.getCurrentVersion() + return { + supported: cap.inDocker && cap.socket && cap.enabled, + inDocker: cap.inDocker, + socketAvailable: cap.socket, + enabled: cap.enabled, + currentVersion, + job: getLatestUpgradeJob(this.db), + } + } + + async startUpgrade(targetTag?: string): Promise { + const cap = this.deps.capability() + const supported = cap.inDocker && cap.socket && cap.enabled + + if (!supported) { + throw new ManagerUpgradeError( + 'Manager self-upgrade is only available in Docker with a mounted docker socket', + 400, + ) + } + + // Check for an existing active job before any async Docker/version calls. + // This prevents a hung inspectSelf() from blocking the 409 response. + const activeEarly = getActiveUpgradeJob(this.db) + if (activeEarly) { + throw new ManagerUpgradeError('An upgrade is already in progress', 409) + } + + const currentVersion = await this.deps.getCurrentVersion() + const info = await this.deps.runner.inspectSelf() + + const baseImage = process.env.OCM_IMAGE || info.image + const resolvedTag = targetTag ?? 'latest' + const targetImage = replaceImageTag(baseImage, resolvedTag) + + // Synchronous check immediately before insert — no race with concurrent calls + const active = getActiveUpgradeJob(this.db) + if (active) { + throw new ManagerUpgradeError('An upgrade is already in progress', 409) + } + + const job = insertUpgradeJob(this.db, { + status: 'pending', + fromVersion: currentVersion ?? undefined, + toVersion: resolvedTag, + targetImage, + startedAt: Date.now(), + }) + + updateUpgradeJob(this.db, job.id, { status: 'pulling' }) + + try { + await this.deps.runner.pull(targetImage) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + updateUpgradeJob(this.db, job.id, { + status: 'failed', + error: message, + finishedAt: Date.now(), + }) + throw new ManagerUpgradeError(message, 500) + } + + updateUpgradeJob(this.db, job.id, { status: 'recreating' }) + this.deps.runner.spawnRecreate(info, targetImage) + + return getLatestUpgradeJob(this.db) as ManagerUpgradeJob + } +} + +function parseContainerId(): string { + try { + const mountinfo = readFileSync('/proc/self/mountinfo', 'utf-8') + const match = mountinfo.match(/\/docker\/containers\/([a-f0-9]+)\//) + if (match?.[1]) return match[1] + } catch { void null } + return hostname() +} + +export function createDockerRunner(): DockerRunner { + return { + async inspectSelf(): Promise { + const containerId = parseContainerId() + const output = await executeCommand([ + 'docker', 'inspect', containerId, + '--format', '{{json .Config.Labels}}|{{.Config.Image}}', + ]) + + const pipeIdx = output.indexOf('|') + const labelsJson = output.slice(0, pipeIdx) + const image = output.slice(pipeIdx + 1).trim() + const labels: Record = JSON.parse(labelsJson) + + return { + containerId, + project: labels['com.docker.compose.project'] || '', + service: labels['com.docker.compose.service'] || '', + workingDir: labels['com.docker.compose.project.working_dir'] || '', + image, + } + }, + + async pull(image: string): Promise { + await executeCommand(['docker', 'pull', image], { timeout: 600000 }) + }, + + spawnRecreate(info: SelfContainerInfo, targetImage: string): void { + const socketBind = '/var/run/docker.sock:/var/run/docker.sock' + const workBind = `${info.workingDir}:${info.workingDir}` + + // Dynamic values are passed as environment variables (separate spawn + // args, never interpreted by a shell) and referenced inside the + // static shell command via $VAR — no shell injection possible. + spawn('docker', [ + 'run', '-d', '--rm', + '-v', socketBind, + '-v', workBind, + '-w', info.workingDir, + '-e', `OCM_IMAGE=${targetImage}`, + '-e', `COMPOSE_PROJECT=${info.project}`, + '-e', `COMPOSE_SERVICE=${info.service}`, + 'docker:cli', + 'sh', '-c', + 'sleep 2; docker compose -p "$COMPOSE_PROJECT" pull "$COMPOSE_SERVICE" && docker compose -p "$COMPOSE_PROJECT" up -d --no-build "$COMPOSE_SERVICE"', + ], { detached: true, stdio: 'ignore' }).unref() + }, + } +} diff --git a/backend/src/utils/runtime-env.ts b/backend/src/utils/runtime-env.ts new file mode 100644 index 000000000..1dbf6161b --- /dev/null +++ b/backend/src/utils/runtime-env.ts @@ -0,0 +1,13 @@ +import { existsSync } from 'fs' + +export function isRunningInDocker( + exists: (p: string) => boolean = existsSync, +): boolean { + return exists('/.dockerenv') || process.env.OCM_IN_DOCKER === 'true' +} + +export function isDockerSocketAvailable( + exists: (p: string) => boolean = existsSync, +): boolean { + return !!process.env.DOCKER_HOST || exists('/var/run/docker.sock') +} diff --git a/backend/test/db/manager-upgrade.test.ts b/backend/test/db/manager-upgrade.test.ts new file mode 100644 index 000000000..43b657ffc --- /dev/null +++ b/backend/test/db/manager-upgrade.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { + insertUpgradeJob, + updateUpgradeJob, + getLatestUpgradeJob, + getActiveUpgradeJob, +} from '../../src/db/manager-upgrade' + +describe('manager upgrade jobs', () => { + let db: Database + + beforeEach(() => { + db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = OFF') + migrate(db, allMigrations) + }) + + it('inserts a job and getLatestUpgradeJob returns it with camelCase fields', () => { + const now = Date.now() + + const job = insertUpgradeJob(db, { + status: 'pending', + fromVersion: '1.0.0', + toVersion: '2.0.0', + targetImage: 'opencode-manager:latest', + startedAt: now, + }) + + expect(job.id).toBeGreaterThan(0) + expect(job.status).toBe('pending') + expect(job.fromVersion).toBe('1.0.0') + expect(job.toVersion).toBe('2.0.0') + expect(job.targetImage).toBe('opencode-manager:latest') + expect(job.startedAt).toBe(now) + expect(job.finishedAt).toBeNull() + expect(job.error).toBeNull() + + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.id).toBe(job.id) + expect(latest!.status).toBe('pending') + expect(latest!.fromVersion).toBe('1.0.0') + expect(latest!.toVersion).toBe('2.0.0') + expect(latest!.targetImage).toBe('opencode-manager:latest') + expect(latest!.startedAt).toBe(now) + expect(latest!.finishedAt).toBeNull() + expect(latest!.error).toBeNull() + }) + + it('getActiveUpgradeJob returns a recreating job and null after it is patched to completed', () => { + const now = Date.now() + + const job = insertUpgradeJob(db, { + status: 'recreating', + fromVersion: '1.0.0', + toVersion: '2.0.0', + startedAt: now, + }) + + // Should be found as active + const active = getActiveUpgradeJob(db) + expect(active).not.toBeNull() + expect(active!.id).toBe(job.id) + expect(active!.status).toBe('recreating') + + // Patch to completed + updateUpgradeJob(db, job.id, { status: 'completed', finishedAt: now + 1000, error: null }) + + // Should no longer be active + const afterPatch = getActiveUpgradeJob(db) + expect(afterPatch).toBeNull() + + // getLatestUpgradeJob still returns it with updated fields + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('completed') + expect(latest!.finishedAt).toBe(now + 1000) + }) +}) diff --git a/backend/test/routes/manager-upgrade.test.ts b/backend/test/routes/manager-upgrade.test.ts new file mode 100644 index 000000000..dae9f7ead --- /dev/null +++ b/backend/test/routes/manager-upgrade.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import { ManagerUpgradeError } from '../../src/services/manager-upgrade' +import type { ManagerUpgradeJob } from '../../src/db/manager-upgrade' + +const fakeJob: ManagerUpgradeJob = { + id: 1, + status: 'pending', + fromVersion: null, + toVersion: 'latest', + targetImage: 'ghcr.io/opencode-manager/manager:latest', + error: null, + startedAt: 1000, + finishedAt: null, +} + +const service = { + getStatus: vi.fn(), + startUpgrade: vi.fn(), + reconcile: vi.fn(), +} + +vi.mock('../../src/utils/logger', () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})) + +import { createManagerUpgradeRoutes } from '../../src/routes/manager-upgrade' + +describe('Manager Upgrade Routes', () => { + let app: Hono + + beforeEach(() => { + vi.clearAllMocks() + app = createManagerUpgradeRoutes(service as unknown as import('../../src/services/manager-upgrade').ManagerUpgradeService) + }) + + it('GET /status returns status from service', async () => { + service.getStatus.mockResolvedValue({ + supported: true, + inDocker: true, + socketAvailable: true, + enabled: true, + currentVersion: '1.0.0', + job: fakeJob, + }) + + const response = await app.request('/status') + const body = await response.json() as Record + + expect(response.status).toBe(200) + expect(body.supported).toBe(true) + expect(body.currentVersion).toBe('1.0.0') + expect(body.job).toEqual(fakeJob) + }) + + it('POST / returns 202 with job when upgrade starts', async () => { + service.startUpgrade.mockResolvedValue(fakeJob) + + const response = await app.request('/', { method: 'POST' }) + const body = await response.json() as { job: ManagerUpgradeJob } + + expect(response.status).toBe(202) + expect(body.job).toEqual(fakeJob) + }) + + it('POST / passes version to service when provided', async () => { + service.startUpgrade.mockResolvedValue(fakeJob) + + const response = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: '2.0.0' }), + }) + + expect(response.status).toBe(202) + expect(service.startUpgrade).toHaveBeenCalledWith('2.0.0') + }) + + it('POST / returns 409 when upgrade is already in progress', async () => { + service.startUpgrade.mockRejectedValue(new ManagerUpgradeError('An upgrade is already in progress', 409)) + + const response = await app.request('/', { method: 'POST' }) + const body = await response.json() as { error: string } + + expect(response.status).toBe(409) + expect(body.error).toBe('An upgrade is already in progress') + }) + + it('POST / tolerates empty JSON body', async () => { + service.startUpgrade.mockResolvedValue(fakeJob) + + const response = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(202) + expect(service.startUpgrade).toHaveBeenCalledWith(undefined) + }) + + it('POST / rejects malformed JSON without calling startUpgrade', async () => { + const response = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{ "version": "2.0.0"', // truncated/malformed JSON + }) + + expect(response.status).toBe(500) + expect(service.startUpgrade).not.toHaveBeenCalled() + }) + + it('POST / returns 500 for unexpected service errors', async () => { + service.startUpgrade.mockRejectedValue(new Error('Unexpected failure')) + + const response = await app.request('/', { method: 'POST' }) + const body = await response.json() as { error: string } + + expect(response.status).toBe(500) + expect(body.error).toBe('Unexpected failure') + }) +}) diff --git a/backend/test/services/manager-upgrade.test.ts b/backend/test/services/manager-upgrade.test.ts new file mode 100644 index 000000000..fcd2c606c --- /dev/null +++ b/backend/test/services/manager-upgrade.test.ts @@ -0,0 +1,509 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { + insertUpgradeJob, + updateUpgradeJob, + getLatestUpgradeJob, +} from '../../src/db/manager-upgrade' +import { + ManagerUpgradeService, + ManagerUpgradeError, + replaceImageTag, +} from '../../src/services/manager-upgrade' +import type { DockerRunner, SelfContainerInfo } from '../../src/services/manager-upgrade' + +/** Wait for microtasks to drain (e.g., reconcile's async version check) */ +function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 5)) +} + +function createRunner(): { runner: DockerRunner; calls: { inspectSelf: SelfContainerInfo[]; pulled: string[]; spawned: Array<{ info: SelfContainerInfo; targetImage: string }> } } { + const calls = { + inspectSelf: [] as SelfContainerInfo[], + pulled: [] as string[], + spawned: [] as Array<{ info: SelfContainerInfo; targetImage: string }>, + } + const runner: DockerRunner = { + inspectSelf: vi.fn().mockImplementation(async () => { + const info: SelfContainerInfo = { + containerId: 'abc123', + project: 'opencode', + service: 'manager', + workingDir: '/app', + image: 'opencode-manager:latest', + } + calls.inspectSelf.push(info) + return info + }), + pull: vi.fn().mockImplementation(async (image: string) => { + calls.pulled.push(image) + }), + spawnRecreate: vi.fn().mockImplementation((info: SelfContainerInfo, targetImage: string) => { + calls.spawned.push({ info, targetImage }) + }), + } + return { runner, calls } +} + +describe('ManagerUpgradeService', () => { + let db: Database + + beforeEach(() => { + db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = OFF') + migrate(db, allMigrations) + // Clean env between tests + delete process.env.OCM_IMAGE + }) + + describe('getStatus', () => { + it('reports supported=false when not in Docker', async () => { + const { runner } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: false, socket: true, enabled: true }), + }) + + const status = await service.getStatus() + expect(status.supported).toBe(false) + expect(status.inDocker).toBe(false) + expect(status.socketAvailable).toBe(true) + expect(status.enabled).toBe(true) + }) + + it('reports supported=true when all capabilities are met', async () => { + const { runner } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + const status = await service.getStatus() + expect(status.supported).toBe(true) + }) + + it('includes currentVersion and latest job', async () => { + const { runner } = createRunner() + const seedJob = insertUpgradeJob(db, { + status: 'completed', + fromVersion: '0.13.0', + toVersion: '0.14.0', + startedAt: Date.now(), + }) + updateUpgradeJob(db, seedJob.id, { finishedAt: Date.now() }) + + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + const status = await service.getStatus() + expect(status.currentVersion).toBe('0.14.0') + expect(status.job).not.toBeNull() + expect(status.job!.status).toBe('completed') + }) + }) + + describe('startUpgrade - capability gating', () => { + // Cycle 1: not supported → 400 + it('throws 400 when not in Docker', async () => { + const { runner, calls } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: false, socket: true, enabled: true }), + }) + + await expect(service.startUpgrade()).rejects.toThrow(ManagerUpgradeError) + await expect(service.startUpgrade()).rejects.toMatchObject({ status: 400 }) + expect(calls.pulled).toHaveLength(0) + expect(calls.spawned).toHaveLength(0) + }) + + it('throws 400 when socket is unavailable', async () => { + const { runner, calls } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: false, enabled: true }), + }) + + await expect(service.startUpgrade()).rejects.toMatchObject({ status: 400 }) + expect(calls.pulled).toHaveLength(0) + expect(calls.spawned).toHaveLength(0) + }) + + it('throws 400 when disabled', async () => { + const { runner, calls } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: false }), + }) + + await expect(service.startUpgrade()).rejects.toMatchObject({ status: 400 }) + expect(calls.pulled).toHaveLength(0) + expect(calls.spawned).toHaveLength(0) + }) + }) + + describe('startUpgrade - happy path', () => { + // Cycle 2: happy path — pull then spawnRecreate + it('pulls image then spawns recreate helper', async () => { + const { runner, calls } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + const job = await service.startUpgrade('0.15.0') + + expect(job.status).toBe('recreating') + expect(job.toVersion).toBe('0.15.0') + expect(job.targetImage).toBe('opencode-manager:0.15.0') + expect(job.fromVersion).toBe('0.14.0') + expect(job.error).toBeNull() + + // Pull was called with the resolved target image + expect(calls.pulled).toEqual(['opencode-manager:0.15.0']) + expect(calls.inspectSelf).toHaveLength(1) + + // spawnRecreate was called with inspectSelf result and targetImage + expect(calls.spawned).toHaveLength(1) + const spawnCall = calls.spawned[0]! + expect(spawnCall.info.project).toBe('opencode') + expect(spawnCall.info.service).toBe('manager') + expect(spawnCall.info.workingDir).toBe('/app') + expect(spawnCall.targetImage).toBe('opencode-manager:0.15.0') + + // DB reflects recreating + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('recreating') + }) + + it('uses OCM_IMAGE env var when set instead of info.image', async () => { + process.env.OCM_IMAGE = 'my-registry/opencode-manager' + const { runner, calls } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + await service.startUpgrade('0.15.0') + expect(calls.pulled).toEqual(['my-registry/opencode-manager:0.15.0']) + }) + + it('defaults to latest tag when no targetTag given', async () => { + const { runner, calls } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + await service.startUpgrade() + expect(calls.pulled).toEqual(['opencode-manager:latest']) + }) + }) + + describe('startUpgrade - concurrency guard', () => { + // Cycle 3: active job exists → 409 + it('rejects with 409 when an active upgrade job exists', async () => { + const { runner, calls } = createRunner() + const getCurrentVersion = vi.fn().mockResolvedValue('0.14.0') + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion, + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + // Insert active job *after* construction so reconcile doesn't clean it + insertUpgradeJob(db, { + status: 'pulling', + fromVersion: '0.14.0', + toVersion: '0.15.0', + startedAt: Date.now(), + }) + + await expect(service.startUpgrade('0.16.0')).rejects.toMatchObject({ + status: 409, + message: 'An upgrade is already in progress', + }) + // No Docker or version calls should happen before the 409 + expect(getCurrentVersion).not.toHaveBeenCalled() + expect(calls.inspectSelf).toHaveLength(0) + expect(calls.pulled).toHaveLength(0) + expect(calls.spawned).toHaveLength(0) + }) + + it('prevents concurrent upgrade attempts with overlapping startUpgrade calls', async () => { + const { runner, calls } = createRunner() + + // Deferred promise so both calls overlap at getCurrentVersion + let resolveVersion!: (v: string) => void + const versionPromise = new Promise((resolve) => { resolveVersion = resolve }) + + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockReturnValue(versionPromise), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + // Both calls start executing; both hit await getCurrentVersion and block + const call1 = service.startUpgrade('0.15.0') + const call2 = service.startUpgrade('0.15.0') + + // Now let them both proceed past the deferred promise + resolveVersion('0.14.0') + + const [result1, result2] = await Promise.allSettled([call1, call2]) + + // Exactly one call should succeed, one should be rejected with 409 + const fulfilled = [result1, result2].filter((r) => r.status === 'fulfilled') + const rejected = [result1, result2].filter( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ) + + expect(fulfilled).toHaveLength(1) + expect(rejected).toHaveLength(1) + expect(rejected[0]!.reason).toMatchObject({ + status: 409, + message: 'An upgrade is already in progress', + }) + + // Only one pull and one spawn should have occurred + expect(calls.pulled).toHaveLength(1) + expect(calls.spawned).toHaveLength(1) + }) + }) + + describe('startUpgrade - pull failure', () => { + // Cycle 4: pull fails → job marked failed, 500 thrown + it('marks job as failed and throws 500 when pull rejects', async () => { + const { runner, calls } = createRunner() + runner.pull = vi.fn().mockRejectedValue(new Error('Network error')) + + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + await expect(service.startUpgrade('0.15.0')).rejects.toMatchObject({ + status: 500, + message: 'Network error', + }) + + // spawnRecreate should NOT have been called + expect(calls.spawned).toHaveLength(0) + + // Job should be marked failed + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('failed') + expect(latest!.error).toBe('Network error') + expect(latest!.finishedAt).not.toBeNull() + }) + }) + + describe('reconcile', () => { + // Cycle 5: recreating job + version matches toVersion → completed + it('marks recreating job as completed when current version matches toVersion', async () => { + const { runner } = createRunner() + insertUpgradeJob(db, { + status: 'recreating', + fromVersion: '0.14.0', + toVersion: '0.15.0', + startedAt: Date.now(), + }) + + new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.15.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + await tick() + + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('completed') + expect(latest!.finishedAt).not.toBeNull() + }) + + it('marks recreating job as completed when version changed from fromVersion', async () => { + const { runner } = createRunner() + insertUpgradeJob(db, { + status: 'recreating', + fromVersion: '0.14.0', + toVersion: '0.15.0', + startedAt: Date.now(), + }) + + // Current version is neither fromVersion nor toVersion (e.g., rolled past target) + new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.16.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + await tick() + + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('completed') + }) + + it('leaves recreating job as-is when version has not changed', async () => { + const { runner } = createRunner() + insertUpgradeJob(db, { + status: 'recreating', + fromVersion: '0.14.0', + toVersion: '0.15.0', + startedAt: Date.now(), + }) + + // Same as fromVersion — still waiting for restart + new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + await tick() + + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('recreating') + }) + + it('leaves recreating job as-is when fromVersion was null and current version did not reach toVersion', async () => { + const { runner } = createRunner() + // fromVersion omitted → stored as NULL (e.g. version was null at start time) + insertUpgradeJob(db, { + status: 'recreating', + toVersion: '0.15.0', + startedAt: Date.now(), + }) + + // Current version is still 0.14.0 (target was 0.15.0, helper hasn't finished) + new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + await tick() + + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('recreating') + expect(latest!.fromVersion).toBeNull() + }) + + // Cycle 6: pulling/pending found at startup → failed + it('marks pulling job as failed when found after restart', async () => { + const { runner } = createRunner() + insertUpgradeJob(db, { + status: 'pulling', + fromVersion: '0.14.0', + toVersion: '0.15.0', + startedAt: Date.now(), + }) + + new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('failed') + expect(latest!.error).toMatch(/interrupted by restart/i) + expect(latest!.finishedAt).not.toBeNull() + }) + + it('marks pending job as failed when found after restart', async () => { + const { runner } = createRunner() + insertUpgradeJob(db, { + status: 'pending', + fromVersion: '0.14.0', + toVersion: '0.15.0', + startedAt: Date.now(), + }) + + new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + const latest = getLatestUpgradeJob(db) + expect(latest).not.toBeNull() + expect(latest!.status).toBe('failed') + expect(latest!.error).toMatch(/interrupted by restart/i) + }) + + it('does nothing when no active job exists', async () => { + const { runner } = createRunner() + + new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + const latest = getLatestUpgradeJob(db) + expect(latest).toBeNull() + }) + }) + + describe('replaceImageTag', () => { + it.each([ + // [image, newTag, expected] + ['opencode-manager:0.14.5', '0.15.0', 'opencode-manager:0.15.0'], + ['ghcr.io/org/app:0.14.5', '0.15.0', 'ghcr.io/org/app:0.15.0'], + ['localhost:5000/org/app:0.14.5', '0.15.0', 'localhost:5000/org/app:0.15.0'], + ['my-registry/opencode-manager', '0.15.0', 'my-registry/opencode-manager:0.15.0'], + ['ubuntu:latest', '22.04', 'ubuntu:22.04'], + ['ubuntu', '22.04', 'ubuntu:22.04'], + ])('replaces tag in %s → %s', (image, newTag, expected) => { + expect(replaceImageTag(image, newTag)).toBe(expected) + }) + }) + + describe('startUpgrade - registry port preservation', () => { + it('preserves registry port in target image resolution', async () => { + process.env.OCM_IMAGE = 'localhost:5000/opencode-manager:0.14.5' + const { runner, calls } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true }), + }) + + await service.startUpgrade('0.15.0') + // Should NOT produce 'localhost:0.15.0' + expect(calls.pulled).toEqual(['localhost:5000/opencode-manager:0.15.0']) + expect(calls.spawned[0]!.targetImage).toBe('localhost:5000/opencode-manager:0.15.0') + }) + }) + + describe('ManagerUpgradeError', () => { + it('is an Error with status', () => { + const err = new ManagerUpgradeError('test', 400) + expect(err).toBeInstanceOf(Error) + expect(err.message).toBe('test') + expect(err.status).toBe(400) + }) + }) +}) diff --git a/backend/test/utils/runtime-env.test.ts b/backend/test/utils/runtime-env.test.ts new file mode 100644 index 000000000..669d7018e --- /dev/null +++ b/backend/test/utils/runtime-env.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { isRunningInDocker, isDockerSocketAvailable } from '../../src/utils/runtime-env' + +describe('isRunningInDocker', () => { + afterEach(() => { + delete process.env.OCM_IN_DOCKER + }) + + it('returns true when /.dockerenv exists', () => { + const fakeExists = (p: string) => p === '/.dockerenv' + expect(isRunningInDocker(fakeExists)).toBe(true) + }) + + it('returns false when neither marker nor env var is set', () => { + const fakeExists = () => false + expect(isRunningInDocker(fakeExists)).toBe(false) + }) + + it('returns true when OCM_IN_DOCKER env is "true" even without marker', () => { + process.env.OCM_IN_DOCKER = 'true' + const fakeExists = () => false + expect(isRunningInDocker(fakeExists)).toBe(true) + }) + + it('returns false when OCM_IN_DOCKER env is set to a non-"true" value', () => { + process.env.OCM_IN_DOCKER = 'false' + const fakeExists = () => false + expect(isRunningInDocker(fakeExists)).toBe(false) + }) + + it('returns true when both marker and env are present', () => { + process.env.OCM_IN_DOCKER = 'true' + const fakeExists = (p: string) => p === '/.dockerenv' + expect(isRunningInDocker(fakeExists)).toBe(true) + }) +}) + +describe('isDockerSocketAvailable', () => { + afterEach(() => { + delete process.env.DOCKER_HOST + }) + + it('returns true when DOCKER_HOST env is set', () => { + process.env.DOCKER_HOST = 'tcp://127.0.0.1:2375' + const fakeExists = () => false + expect(isDockerSocketAvailable(fakeExists)).toBe(true) + }) + + it('returns true when /var/run/docker.sock exists', () => { + const fakeExists = (p: string) => p === '/var/run/docker.sock' + expect(isDockerSocketAvailable(fakeExists)).toBe(true) + }) + + it('returns false when neither socket nor DOCKER_HOST is present', () => { + const fakeExists = () => false + expect(isDockerSocketAvailable(fakeExists)).toBe(false) + }) + + it('prefers DOCKER_HOST over socket check', () => { + process.env.DOCKER_HOST = 'tcp://127.0.0.1:2375' + const fakeExists = () => false + expect(isDockerSocketAvailable(fakeExists)).toBe(true) + }) +}) diff --git a/docker-compose.yml b/docker-compose.yml index 00a023348..09aef83a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: dockerfile: Dockerfile args: TOOLS_CACHEBUST: ${TOOLS_CACHEBUST:-0} + image: ${OCM_IMAGE:-ghcr.io/chriswritescode-dev/opencode-manager:latest} container_name: opencode-manager ports: - "5003:5003" @@ -20,6 +21,8 @@ services: - OPENCODE_HOST=127.0.0.1 - DATABASE_PATH=/app/data/opencode.db - WORKSPACE_PATH=/workspace + - OCM_IMAGE=${OCM_IMAGE:-ghcr.io/chriswritescode-dev/opencode-manager:latest} + - OCM_MANAGER_UPGRADE_ENABLED=${OCM_MANAGER_UPGRADE_ENABLED:-true} - PROCESS_START_WAIT_MS=2000 - PROCESS_VERIFY_WAIT_MS=1000 - HEALTH_CHECK_INTERVAL_MS=5000 @@ -48,6 +51,7 @@ services: volumes: - opencode-workspace:/workspace - opencode-data:/app/data + - /var/run/docker.sock:/var/run/docker.sock restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5003/api/health"] diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 3f0787cb9..4960c3106 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -377,6 +377,18 @@ export const settingsApi = { params: { kind, relativePath }, }) }, + + getManagerUpgradeStatus: async (): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/manager-upgrade/status`) + }, + + startManagerUpgrade: async (version?: string): Promise<{ job: ManagerUpgradeJob }> => { + return fetchWrapper(`${API_BASE_URL}/api/manager-upgrade`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(version ? { version } : {}), + }) + }, } export interface VersionInfo { @@ -417,3 +429,25 @@ export async function rotateManagerToken(): Promise { method: 'POST', }) } + +export type ManagerUpgradeJobStatus = 'pending' | 'pulling' | 'recreating' | 'completed' | 'failed' + +export interface ManagerUpgradeJob { + id: number + status: ManagerUpgradeJobStatus + fromVersion: string | null + toVersion: string | null + targetImage: string | null + error: string | null + startedAt: number + finishedAt: number | null +} + +export interface ManagerUpgradeStatus { + supported: boolean + inDocker: boolean + socketAvailable: boolean + enabled: boolean + currentVersion: string | null + job: ManagerUpgradeJob | null +} diff --git a/frontend/src/components/settings/ServerHealthStatus.tsx b/frontend/src/components/settings/ServerHealthStatus.tsx index 446b23217..d9decce51 100644 --- a/frontend/src/components/settings/ServerHealthStatus.tsx +++ b/frontend/src/components/settings/ServerHealthStatus.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Loader2, ArrowUpCircle, RotateCcw, History } from 'lucide-react' import { useServerHealth } from '@/hooks/useServerHealth' +import { useManagerUpgrade } from '@/hooks/useManagerUpgrade' import { useMutation, useQueryClient } from '@tanstack/react-query' import { settingsApi } from '@/api/settings' import { showToast } from '@/lib/toast' @@ -15,6 +16,7 @@ interface ServerHealthStatusProps { export function ServerHealthStatus({ onOpenVersionDialog }: ServerHealthStatusProps) { const queryClient = useQueryClient() const { data: health } = useServerHealth() + const { isSupported, startUpgrade, isUpgrading, status } = useManagerUpgrade() const restartServerMutation = useMutation({ mutationFn: async () => settingsApi.restartOpenCodeServer(), @@ -162,6 +164,33 @@ export function ServerHealthStatus({ onOpenVersionDialog }: ServerHealthStatusPr Versions + {isSupported && ( + + )} diff --git a/frontend/src/hooks/__tests__/useManagerUpgrade.test.tsx b/frontend/src/hooks/__tests__/useManagerUpgrade.test.tsx new file mode 100644 index 000000000..6f8dda657 --- /dev/null +++ b/frontend/src/hooks/__tests__/useManagerUpgrade.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useManagerUpgrade } from '../useManagerUpgrade' +import type { ManagerUpgradeStatus } from '@/api/settings' + +const mocks = vi.hoisted(() => ({ + getManagerUpgradeStatus: vi.fn(), + startManagerUpgrade: vi.fn(), +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + getManagerUpgradeStatus: mocks.getManagerUpgradeStatus, + startManagerUpgrade: mocks.startManagerUpgrade, + }, +})) + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +describe('useManagerUpgrade', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('isSupported should be false when getManagerUpgradeStatus returns supported: false', async () => { + const status: ManagerUpgradeStatus = { + supported: false, + inDocker: true, + socketAvailable: true, + enabled: false, + currentVersion: null, + job: null, + } + mocks.getManagerUpgradeStatus.mockResolvedValue(status) + + const { result } = renderHook(() => useManagerUpgrade(), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.status).toEqual(status) + }) + + expect(result.current.isSupported).toBe(false) + }) + + it('isSupported should be true when getManagerUpgradeStatus returns supported: true', async () => { + const status: ManagerUpgradeStatus = { + supported: true, + inDocker: true, + socketAvailable: true, + enabled: true, + currentVersion: '1.0.0', + job: null, + } + mocks.getManagerUpgradeStatus.mockResolvedValue(status) + + const { result } = renderHook(() => useManagerUpgrade(), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isSupported).toBe(true) + }) + }) + + it('startUpgrade should call settingsApi.startManagerUpgrade', async () => { + const status: ManagerUpgradeStatus = { + supported: true, + inDocker: true, + socketAvailable: true, + enabled: true, + currentVersion: '1.0.0', + job: null, + } + mocks.getManagerUpgradeStatus.mockResolvedValue(status) + mocks.startManagerUpgrade.mockResolvedValue({ job: { id: 1, status: 'pending', fromVersion: '1.0.0', toVersion: 'latest', targetImage: null, error: null, startedAt: Date.now(), finishedAt: null } }) + + const { result } = renderHook(() => useManagerUpgrade(), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isSupported).toBe(true) + }) + + await result.current.startUpgrade() + + expect(mocks.startManagerUpgrade).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/hooks/useManagerUpgrade.ts b/frontend/src/hooks/useManagerUpgrade.ts new file mode 100644 index 000000000..e9885e2d4 --- /dev/null +++ b/frontend/src/hooks/useManagerUpgrade.ts @@ -0,0 +1,29 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { settingsApi } from '@/api/settings' + +export function useManagerUpgrade() { + const queryClient = useQueryClient() + + const { data: status } = useQuery({ + queryKey: ['manager-upgrade-status'], + queryFn: settingsApi.getManagerUpgradeStatus, + refetchInterval: (q) => { + const s = q.state.data?.job?.status + return s === 'pulling' || s === 'recreating' ? 3000 : false + }, + }) + + const mutation = useMutation({ + mutationFn: () => settingsApi.startManagerUpgrade(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['manager-upgrade-status'] }) + }, + }) + + return { + status, + isSupported: status?.supported ?? false, + startUpgrade: mutation.mutateAsync, + isUpgrading: mutation.isPending, + } +} diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index e8f613b1c..1fcc53427 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -89,4 +89,16 @@ fi mkdir -p /app/data /workspace /home/node/.cache /home/node/.opencode chown -R node:node /app/data /workspace /home/node +if [ -S /var/run/docker.sock ]; then + DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) + EXISTING_GROUP=$(getent group "$DOCKER_GID" | cut -d: -f1 || true) + if [ -n "$EXISTING_GROUP" ]; then + DOCKER_GROUP="$EXISTING_GROUP" + else + DOCKER_GROUP="dockerhost" + groupadd -g "$DOCKER_GID" "$DOCKER_GROUP" + fi + usermod -aG "$DOCKER_GROUP" node +fi + exec runuser -u node -- "$@"