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 1/2] 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 -- "$@" From 8e1c67f7b05be8821ec36101d5e1e985b7fe0fb7 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:32:04 -0400 Subject: [PATCH 2/2] feat: add build strategy for docker self-upgrade --- .env.example | 4 + backend/src/index.ts | 1 + backend/src/services/manager-upgrade.ts | 74 ++++++-- backend/test/services/manager-upgrade.test.ts | 160 +++++++++++++++--- docker-compose.yml | 1 + docs/configuration/docker.md | 35 ++++ docs/configuration/environment.md | 9 + .../__tests__/useManagerUpgrade.test.tsx | 1 + shared/src/types/index.ts | 1 + shared/src/types/manager-upgrade.ts | 3 + 10 files changed, 252 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index 3c574a74d..7c994f7cd 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,10 @@ WORKSPACE_PATH=./workspace # OCM_IMAGE=ghcr.io/chriswritescode-dev/opencode-manager:latest # Enable or disable the self-upgrade mechanism. # OCM_MANAGER_UPGRADE_ENABLED=true +# Upgrade strategy: 'pull' fetches OCM_IMAGE from the registry; 'build' +# rebuilds the image from the compose project source directory as-is +# (update the source on the host first). Use 'build' for source-built deployments. +# OCM_UPGRADE_STRATEGY=pull # Optional - convenience vars for Docker bind mounts documented in docs/configuration/docker.md # OCM_REPOS_HOST_PATH=/Users/you/Development diff --git a/backend/src/index.ts b/backend/src/index.ts index b5c0f430b..0c41ad6ba 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -349,6 +349,7 @@ const managerUpgradeService = new ManagerUpgradeService(db, { inDocker: isRunningInDocker(), socket: isDockerSocketAvailable(), enabled: process.env.OCM_MANAGER_UPGRADE_ENABLED !== 'false', + strategy: process.env.OCM_UPGRADE_STRATEGY === 'build' ? 'build' as const : 'pull' as const, }), }) diff --git a/backend/src/services/manager-upgrade.ts b/backend/src/services/manager-upgrade.ts index 680abb795..95347218f 100644 --- a/backend/src/services/manager-upgrade.ts +++ b/backend/src/services/manager-upgrade.ts @@ -10,9 +10,11 @@ import { getActiveUpgradeJob, } from '../db/manager-upgrade' import type { ManagerUpgradeJob } from '../db/manager-upgrade' -import type { ManagerUpgradeStatusResponse } from '@opencode-manager/shared/types' +import type { ManagerUpgradeStatusResponse, ManagerUpgradeStrategy } from '@opencode-manager/shared/types' const RECREATE_STALE_MS = 10 * 60 * 1000 +const PULL_TIMEOUT_MS = 10 * 60 * 1000 +const BUILD_TIMEOUT_MS = 30 * 60 * 1000 export interface SelfContainerInfo { project: string @@ -24,6 +26,7 @@ export interface SelfContainerInfo { export interface DockerRunner { inspectSelf(): Promise pull(image: string): Promise + buildImage(info: SelfContainerInfo, targetImage: string): Promise spawnRecreate(info: SelfContainerInfo, targetImage: string): void } @@ -56,6 +59,7 @@ export interface UpgradeCapability { inDocker: boolean socket: boolean enabled: boolean + strategy: ManagerUpgradeStrategy } export class ManagerUpgradeService { @@ -117,6 +121,7 @@ export class ManagerUpgradeService { inDocker: cap.inDocker, socketAvailable: cap.socket, enabled: cap.enabled, + strategy: cap.strategy, currentVersion, job: getLatestUpgradeJob(this.db), } @@ -142,12 +147,26 @@ export class ManagerUpgradeService { throw new ManagerUpgradeError('An upgrade is already in progress', 409) } + if (cap.strategy === 'build' && targetTag) { + throw new ManagerUpgradeError( + 'Targeted version upgrades are not available with the build strategy; the source working tree is rebuilt as-is', + 400, + ) + } + const currentVersion = await this.deps.getCurrentVersion() const info = await this.deps.runner.inspectSelf() + if (!info.project || !info.service || !info.workingDir) { + throw new ManagerUpgradeError( + 'Manager self-upgrade requires a Docker Compose-managed container; compose labels were not found on this container', + 400, + ) + } + const baseImage = process.env.OCM_IMAGE || info.image const resolvedTag = targetTag ?? 'latest' - const targetImage = replaceImageTag(baseImage, resolvedTag) + const targetImage = cap.strategy === 'build' ? baseImage : replaceImageTag(baseImage, resolvedTag) // Synchronous check immediately before insert — no race with concurrent calls const active = getActiveUpgradeJob(this.db) @@ -158,21 +177,33 @@ export class ManagerUpgradeService { const job = insertUpgradeJob(this.db, { status: 'pulling', fromVersion: currentVersion ?? undefined, - toVersion: resolvedTag, + toVersion: cap.strategy === 'build' ? undefined : resolvedTag, targetImage, startedAt: Date.now(), }) - // The pull can take minutes; run it detached so the HTTP request returns - // immediately and progress/failure is surfaced via the polled job status. - void this.pullAndRecreate(job.id, info, targetImage) + // Acquiring the image (registry pull or source build) can take minutes; + // run it detached so the HTTP request returns immediately and + // progress/failure is surfaced via the polled job status. + void this.acquireAndRecreate(job.id, info, targetImage, cap.strategy) return job } - private async pullAndRecreate(jobId: number, info: SelfContainerInfo, targetImage: string): Promise { + private async acquireAndRecreate( + jobId: number, + info: SelfContainerInfo, + targetImage: string, + strategy: ManagerUpgradeStrategy, + ): Promise { try { - await this.deps.runner.pull(targetImage) + // Both phases run while this instance is alive, so failures are + // captured in the job. Only the final recreate is fire-and-forget. + if (strategy === 'build') { + await this.deps.runner.buildImage(info, targetImage) + } else { + await this.deps.runner.pull(targetImage) + } updateUpgradeJob(this.db, jobId, { status: 'recreating' }) this.deps.runner.spawnRecreate(info, targetImage) } catch (err) { @@ -218,16 +249,33 @@ export function createDockerRunner(): DockerRunner { }, async pull(image: string): Promise { - await executeCommand(['docker', 'pull', image], { timeout: 600000 }) + await executeCommand(['docker', 'pull', image], { timeout: PULL_TIMEOUT_MS }) + }, + + async buildImage(info: SelfContainerInfo, targetImage: string): Promise { + // Attached (awaited) helper: the build constructs image layers only and + // never touches the running container, so a failure here is harmless + // and its output is captured into the upgrade job. + await executeCommand([ + 'docker', 'run', '--rm', + '-v', '/var/run/docker.sock:/var/run/docker.sock', + '-v', `${info.workingDir}:${info.workingDir}`, + '-w', info.workingDir, + '-e', `OCM_IMAGE=${targetImage}`, + 'docker:cli', + 'docker', 'compose', '-p', info.project, 'build', info.service, + ], { timeout: BUILD_TIMEOUT_MS }) }, 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. + // The image was already pulled or built by the attached phase, so the + // helper only performs the recreate. 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, @@ -238,7 +286,7 @@ export function createDockerRunner(): DockerRunner { '-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"', + 'sleep 2; docker compose -p "$COMPOSE_PROJECT" up -d --no-build "$COMPOSE_SERVICE"', ], { detached: true, stdio: 'ignore' }).unref() }, } diff --git a/backend/test/services/manager-upgrade.test.ts b/backend/test/services/manager-upgrade.test.ts index 3d6f3519d..e507fe3d4 100644 --- a/backend/test/services/manager-upgrade.test.ts +++ b/backend/test/services/manager-upgrade.test.ts @@ -19,10 +19,11 @@ 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 }> } } { +function createRunner(): { runner: DockerRunner; calls: { inspectSelf: SelfContainerInfo[]; pulled: string[]; built: Array<{ info: SelfContainerInfo; targetImage: string }>; spawned: Array<{ info: SelfContainerInfo; targetImage: string }> } } { const calls = { inspectSelf: [] as SelfContainerInfo[], pulled: [] as string[], + built: [] as Array<{ info: SelfContainerInfo; targetImage: string }>, spawned: [] as Array<{ info: SelfContainerInfo; targetImage: string }>, } const runner: DockerRunner = { @@ -39,6 +40,9 @@ function createRunner(): { runner: DockerRunner; calls: { inspectSelf: SelfConta pull: vi.fn().mockImplementation(async (image: string) => { calls.pulled.push(image) }), + buildImage: vi.fn().mockImplementation(async (info: SelfContainerInfo, targetImage: string) => { + calls.built.push({ info, targetImage }) + }), spawnRecreate: vi.fn().mockImplementation((info: SelfContainerInfo, targetImage: string) => { calls.spawned.push({ info, targetImage }) }), @@ -63,7 +67,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: false, socket: true, enabled: true }), + capability: () => ({ inDocker: false, socket: true, enabled: true, strategy: 'pull' as const }), }) const status = await service.getStatus() @@ -78,7 +82,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const status = await service.getStatus() @@ -98,7 +102,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const status = await service.getStatus() @@ -115,7 +119,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: false, socket: true, enabled: true }), + capability: () => ({ inDocker: false, socket: true, enabled: true, strategy: 'pull' as const }), }) await expect(service.startUpgrade()).rejects.toThrow(ManagerUpgradeError) @@ -129,7 +133,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: false, enabled: true }), + capability: () => ({ inDocker: true, socket: false, enabled: true, strategy: 'pull' as const }), }) await expect(service.startUpgrade()).rejects.toMatchObject({ status: 400 }) @@ -142,7 +146,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: false }), + capability: () => ({ inDocker: true, socket: true, enabled: false, strategy: 'pull' as const }), }) await expect(service.startUpgrade()).rejects.toMatchObject({ status: 400 }) @@ -158,7 +162,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const job = await service.startUpgrade('0.15.0') @@ -197,7 +201,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) await service.startUpgrade('0.15.0') @@ -210,7 +214,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) await service.startUpgrade() @@ -227,7 +231,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion, - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) // Insert active job *after* construction so reconcile doesn't clean it @@ -259,7 +263,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockReturnValue(versionPromise), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) // Both calls start executing; both hit await getCurrentVersion and block @@ -301,7 +305,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const job = await service.startUpgrade('0.15.0') @@ -335,7 +339,7 @@ describe('ManagerUpgradeService', () => { runner, // Same as fromVersion — the recreate helper never completed getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const status = await service.getStatus() @@ -356,7 +360,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const job = await service.startUpgrade('0.16.0') @@ -377,7 +381,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const status = await service.getStatus() @@ -399,7 +403,7 @@ describe('ManagerUpgradeService', () => { new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.15.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) await tick() @@ -423,7 +427,7 @@ describe('ManagerUpgradeService', () => { new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.16.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) await tick() @@ -446,7 +450,7 @@ describe('ManagerUpgradeService', () => { new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) await tick() @@ -469,7 +473,7 @@ describe('ManagerUpgradeService', () => { new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) await tick() @@ -493,7 +497,7 @@ describe('ManagerUpgradeService', () => { new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const latest = getLatestUpgradeJob(db) @@ -515,7 +519,7 @@ describe('ManagerUpgradeService', () => { new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const latest = getLatestUpgradeJob(db) @@ -530,7 +534,7 @@ describe('ManagerUpgradeService', () => { new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) const latest = getLatestUpgradeJob(db) @@ -559,7 +563,7 @@ describe('ManagerUpgradeService', () => { const service = new ManagerUpgradeService(db, { runner, getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), - capability: () => ({ inDocker: true, socket: true, enabled: true }), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), }) await service.startUpgrade('0.15.0') @@ -570,6 +574,114 @@ describe('ManagerUpgradeService', () => { }) }) + describe('startUpgrade - build strategy', () => { + function buildService(db: Database, runner: DockerRunner) { + return new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'build' as const }), + }) + } + + it('builds from source instead of pulling, then spawns recreate', async () => { + const { runner, calls } = createRunner() + const service = buildService(db, runner) + + const job = await service.startUpgrade() + expect(job.status).toBe('pulling') + expect(job.toVersion).toBeNull() + expect(job.targetImage).toBe('opencode-manager:latest') + + await tick() + + expect(calls.pulled).toHaveLength(0) + expect(calls.built).toHaveLength(1) + expect(calls.built[0]!.targetImage).toBe('opencode-manager:latest') + expect(calls.spawned).toHaveLength(1) + + const latest = getLatestUpgradeJob(db) + expect(latest!.status).toBe('recreating') + }) + + it('keeps OCM_IMAGE untouched (no tag replacement) in build mode', async () => { + process.env.OCM_IMAGE = 'my-local/opencode-manager:dev' + const { runner, calls } = createRunner() + const service = buildService(db, runner) + + await service.startUpgrade() + await tick() + + expect(calls.built[0]!.targetImage).toBe('my-local/opencode-manager:dev') + expect(calls.spawned[0]!.targetImage).toBe('my-local/opencode-manager:dev') + }) + + it('rejects targeted version upgrades with 400 in build mode', async () => { + const { runner, calls } = createRunner() + const service = buildService(db, runner) + + await expect(service.startUpgrade('0.15.0')).rejects.toMatchObject({ status: 400 }) + expect(calls.built).toHaveLength(0) + expect(calls.spawned).toHaveLength(0) + expect(getLatestUpgradeJob(db)).toBeNull() + }) + + it('marks job as failed with the build error when the build rejects', async () => { + const { runner, calls } = createRunner() + runner.buildImage = vi.fn().mockRejectedValue(new Error('Command failed with code 1: tsc error TS2304')) + const service = buildService(db, runner) + + const job = await service.startUpgrade() + expect(job.status).toBe('pulling') + + await tick() + + expect(calls.spawned).toHaveLength(0) + const latest = getLatestUpgradeJob(db) + expect(latest!.status).toBe('failed') + expect(latest!.error).toContain('tsc error TS2304') + }) + }) + + describe('startUpgrade - compose label fail-fast', () => { + it('throws 400 when the container has no compose labels', async () => { + const { runner, calls } = createRunner() + runner.inspectSelf = vi.fn().mockResolvedValue({ + project: '', + service: '', + workingDir: '', + image: 'opencode-manager:latest', + }) + + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'pull' as const }), + }) + + await expect(service.startUpgrade()).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining('Compose-managed'), + }) + expect(calls.pulled).toHaveLength(0) + expect(calls.spawned).toHaveLength(0) + expect(getLatestUpgradeJob(db)).toBeNull() + }) + }) + + describe('getStatus - strategy', () => { + it('reports the configured strategy', async () => { + const { runner } = createRunner() + const service = new ManagerUpgradeService(db, { + runner, + getCurrentVersion: vi.fn().mockResolvedValue('0.14.0'), + capability: () => ({ inDocker: true, socket: true, enabled: true, strategy: 'build' as const }), + }) + + const status = await service.getStatus() + expect(status.strategy).toBe('build') + }) + }) + describe('ManagerUpgradeError', () => { it('is an Error with status', () => { const err = new ManagerUpgradeError('test', 400) diff --git a/docker-compose.yml b/docker-compose.yml index 17c400d85..9883b908d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - WORKSPACE_PATH=/workspace - OCM_IMAGE=${OCM_IMAGE:-ghcr.io/chriswritescode-dev/opencode-manager:latest} - OCM_MANAGER_UPGRADE_ENABLED=${OCM_MANAGER_UPGRADE_ENABLED:-true} + - OCM_UPGRADE_STRATEGY=${OCM_UPGRADE_STRATEGY:-pull} - OCM_IN_DOCKER=true - PROCESS_START_WAIT_MS=2000 - PROCESS_VERIFY_WAIT_MS=1000 diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index 3c8bcf3fb..4202debf1 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -35,6 +35,7 @@ services: build: context: . dockerfile: Dockerfile + image: ${OCM_IMAGE:-ghcr.io/chriswritescode-dev/opencode-manager:latest} container_name: opencode-manager ports: - "5003:5003" @@ -50,6 +51,10 @@ 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} + - OCM_UPGRADE_STRATEGY=${OCM_UPGRADE_STRATEGY:-pull} + - OCM_IN_DOCKER=true - PROCESS_START_WAIT_MS=2000 - PROCESS_VERIFY_WAIT_MS=1000 - HEALTH_CHECK_INTERVAL_MS=5000 @@ -78,6 +83,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"] @@ -348,6 +354,35 @@ docker-compose build --no-cache docker-compose up -d ``` +## Manager Self-Upgrade + +The Settings → OpenCode page shows an **Upgrade Manager** button when the app runs in Docker with the docker socket mounted and `OCM_MANAGER_UPGRADE_ENABLED` is not `false`. Self-upgrade only works for **compose-managed** containers: the recreate step reads the container's `com.docker.compose.*` labels (project, service, and the host path of the compose working directory) and re-runs `docker compose up` there, so every volume, port, and environment entry is re-derived from your `docker-compose.yml` — mounts are never enumerated or copied. Containers started with plain `docker run` have no compose labels and are rejected with a clear error. If OpenCode sessions are actively working, the UI asks for confirmation first because the container recreate interrupts them. + +### Upgrade strategies + +`OCM_UPGRADE_STRATEGY` selects how the new image is produced: + +- **`pull`** (default): pulls the image referenced by `OCM_IMAGE` from the registry, then recreates the service on it. For deployments running the published image. +- **`build`**: rebuilds the image from the compose project source directory using a helper container (`docker compose build `), then recreates the service. For deployments built from source with `docker-compose build`. The source working tree is built **as-is** — run `git pull` on the host first; the helper deliberately does not touch git (it has no credentials or SSH keys). Targeted version upgrades (`{ "version": ... }`) are rejected in build mode. + +### Failure behavior + +The upgrade runs in two phases so the running instance survives every failure except one narrow case: + +1. **Acquire (pull or build)** — runs while the manager is alive and is awaited, so a registry error or compiler failure is captured into the upgrade job (`failed` + error text visible in the UI) and the running container is untouched. Builds only construct image layers; they never affect running containers. +2. **Recreate** — a short-lived detached `docker:cli` helper runs `docker compose up -d --no-build `. If the helper itself fails, the manager keeps running and the job is marked failed by a 10-minute staleness guard. + +The one unprotected case: an image that builds/pulls fine but **crashes at runtime**. Compose removes the old container before starting the new one and has no rollback, so a runtime-broken image means downtime until manual recovery — the previous image still exists in the local store (retag it, or point `OCM_IMAGE` at the old tag, or fix the source and rebuild). + +Upgrade progress is tracked as a job (`pulling` → `recreating` → `completed`/`failed`) surfaced through `GET /api/manager-upgrade/status`; the `pulling` phase covers the build step in build mode. + +Notes: + +- **Security**: mounting `/var/run/docker.sock` grants the container root-equivalent access to the host Docker daemon (the entrypoint adds the `node` user to the socket's group). Remove the socket volume from `docker-compose.yml` if you do not want any in-container Docker access; this disables self-upgrade and any other Docker usage from inside the container. Setting `OCM_MANAGER_UPGRADE_ENABLED=false` disables only the upgrade API, not socket access. +- **Source builds with `pull` strategy**: `docker-compose build` tags your local build as the `OCM_IMAGE` reference, and a later self-upgrade **pull** overwrites that local tag with the registry image. Source-built deployments should set `OCM_UPGRADE_STRATEGY=build`. +- **Build load**: the image builds on the same host, so a heavy build competes with the running app for CPU/RAM. +- **Same-version rebuilds**: completion is detected by a version change after the restart. A `build` upgrade that does not bump the app version is marked failed by the staleness guard even when the recreate succeeded (known limitation). + ### Debugging ```bash diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 93a448278..e0544a053 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -108,6 +108,15 @@ When configured, users can enable push notifications in Settings → Notificatio | `OPENCODE_IMPORT_CONFIG_PATH` | Existing standalone OpenCode `opencode.json` to import on first startup | - | | `OPENCODE_IMPORT_STATE_PATH` | Existing standalone OpenCode state directory to import on first startup | - | +## Manager Self-Upgrade (Docker) + +| Variable | Description | Default | +|----------|-------------|---------| +| `OCM_IMAGE` | Container image reference pulled during self-upgrade; also used as the compose `image:` tag | `ghcr.io/chriswritescode-dev/opencode-manager:latest` | +| `OCM_MANAGER_UPGRADE_ENABLED` | Enable/disable the self-upgrade API (`false` disables) | `true` | +| `OCM_UPGRADE_STRATEGY` | `pull` fetches `OCM_IMAGE` from the registry; `build` rebuilds the image from the compose project source directory (for source-built deployments) | `pull` | +| `OCM_IN_DOCKER` | Marks the process as running inside Docker for runtimes without `/.dockerenv`; set automatically by docker-compose.yml | - | + ## Timeouts | Variable | Description | Default | diff --git a/frontend/src/hooks/__tests__/useManagerUpgrade.test.tsx b/frontend/src/hooks/__tests__/useManagerUpgrade.test.tsx index e7b75eec7..01a16e1b0 100644 --- a/frontend/src/hooks/__tests__/useManagerUpgrade.test.tsx +++ b/frontend/src/hooks/__tests__/useManagerUpgrade.test.tsx @@ -44,6 +44,7 @@ function makeStatus(overrides: Partial = {}): Mana inDocker: true, socketAvailable: true, enabled: true, + strategy: 'pull', currentVersion: '1.0.0', job: null, ...overrides, diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 59718deb2..1f2acc0a7 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -102,6 +102,7 @@ export interface SuccessResponse { export type { ManagerUpgradeJobStatus, + ManagerUpgradeStrategy, ManagerUpgradeJob, ManagerUpgradeStatusResponse, } from './manager-upgrade' diff --git a/shared/src/types/manager-upgrade.ts b/shared/src/types/manager-upgrade.ts index 12302c051..bcf110b14 100644 --- a/shared/src/types/manager-upgrade.ts +++ b/shared/src/types/manager-upgrade.ts @@ -1,5 +1,7 @@ export type ManagerUpgradeJobStatus = 'pending' | 'pulling' | 'recreating' | 'completed' | 'failed' +export type ManagerUpgradeStrategy = 'pull' | 'build' + export interface ManagerUpgradeJob { id: number status: ManagerUpgradeJobStatus @@ -16,6 +18,7 @@ export interface ManagerUpgradeStatusResponse { inDocker: boolean socketAvailable: boolean enabled: boolean + strategy: ManagerUpgradeStrategy currentVersion: string | null job: ManagerUpgradeJob | null }