diff --git a/README.md b/README.md index 455fef5..a282d6e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ Then open **http://localhost:8080**. On first run, you'll be guided through the - `-v /var/run/docker.sock:...` lets sv2-ui manage Translator and JDC containers - `-v sv2-config:/app/data/config` persists your configuration across restarts +### Environment Variables + +You can customize the Docker images used by the sv2-ui for the Translator and Job Declaration Client (JDC) containers, as well as the pull policy for these images: + +- `TRANSLATOR_IMAGE`: The Docker image for the Stratum V2 Translator. (Default: `stratumv2/translator_sv2:main`) +- `JDC_IMAGE`: The Docker image for the Stratum V2 Job Declaration Client. (Default: `stratumv2/jd_client_sv2:main`) +- `SV2_IMAGE_PULL_POLICY`: The policy determining when SV2 images are pulled. Options are `always`, `if-not-present`, and `never`. (Default: `always`) + Stopping with **Ctrl+C** will also stop the Translator and JDC containers automatically. ### macOS (Docker Desktop) diff --git a/server/src/config/image-config.test.ts b/server/src/config/image-config.test.ts new file mode 100644 index 0000000..e1d3fb6 --- /dev/null +++ b/server/src/config/image-config.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DEFAULT_JDC_IMAGE, + DEFAULT_TRANSLATOR_IMAGE, + normalizeImagePullPolicy, + resolveImageFetchAction, + resolveRuntimeImages, +} from '../image-config.js'; + +test('normalizeImagePullPolicy correctly parses policies', () => { + const dummyLogger = { warn: () => {} }; + + assert.equal(normalizeImagePullPolicy('always', dummyLogger), 'always'); + assert.equal(normalizeImagePullPolicy('never', dummyLogger), 'never'); + assert.equal(normalizeImagePullPolicy('if-not-present', dummyLogger), 'if-not-present'); + assert.equal(normalizeImagePullPolicy('if_not_present', dummyLogger), 'if-not-present'); + assert.equal(normalizeImagePullPolicy('ifnotpresent', dummyLogger), 'if-not-present'); + + assert.equal(normalizeImagePullPolicy('random', dummyLogger), 'always'); + assert.equal(normalizeImagePullPolicy('', dummyLogger), 'always'); + assert.equal(normalizeImagePullPolicy(null, dummyLogger), 'always'); + assert.equal(normalizeImagePullPolicy(undefined, dummyLogger), 'always'); +}); + +test('resolveRuntimeImages respects explicit config over env', () => { + const options = { + env: { + TRANSLATOR_IMAGE: 'env/translator:test', + JDC_IMAGE: 'env/jdc:test', + SV2_IMAGE_PULL_POLICY: 'never', + }, + logger: { warn: () => {} }, + }; + + const explicitConfig = { + translator_image: 'explicit/translator:test', + jdc_image: 'explicit/jdc:test', + pull_policy: 'if-not-present' as const, + }; + + const resolved = resolveRuntimeImages(explicitConfig, options); + + assert.equal(resolved.translatorImage, 'explicit/translator:test'); + assert.equal(resolved.jdcImage, 'explicit/jdc:test'); + assert.equal(resolved.pullPolicy, 'if-not-present'); +}); + +test('resolveRuntimeImages falls back to env when config is missing', () => { + const options = { + env: { + TRANSLATOR_IMAGE: 'env/translator:test', + JDC_IMAGE: 'env/jdc:test', + SV2_IMAGE_PULL_POLICY: 'never', + }, + logger: { warn: () => {} }, + }; + + const resolved = resolveRuntimeImages(null, options); + + assert.equal(resolved.translatorImage, 'env/translator:test'); + assert.equal(resolved.jdcImage, 'env/jdc:test'); + assert.equal(resolved.pullPolicy, 'never'); +}); + +test('resolveRuntimeImages applies defaults when both config and env are missing', () => { + const options = { + env: {}, + logger: { warn: () => {} }, + }; + + const resolved = resolveRuntimeImages(undefined, options); + + assert.equal(resolved.translatorImage, DEFAULT_TRANSLATOR_IMAGE); + assert.equal(resolved.jdcImage, DEFAULT_JDC_IMAGE); + assert.equal(resolved.pullPolicy, 'always'); +}); + +test('resolveImageFetchAction enforces pull policy when local image is missing', () => { + assert.equal(resolveImageFetchAction('always', false), 'pull'); + assert.equal(resolveImageFetchAction('if-not-present', true), 'use-local'); + assert.equal(resolveImageFetchAction('if-not-present', false), 'pull'); + assert.equal(resolveImageFetchAction('never', true), 'use-local'); + assert.equal(resolveImageFetchAction('never', false), 'error-missing-local'); +}); diff --git a/server/src/docker.ts b/server/src/docker.ts index 7a1d3a4..1a3c263 100644 --- a/server/src/docker.ts +++ b/server/src/docker.ts @@ -5,8 +5,14 @@ import fs from 'fs'; import Docker from 'dockerode'; import os from 'os'; -import type { SetupData, ContainerStatus, HealthStatus } from './types.js'; +import type { SetupData, ContainerStatus, HealthStatus, ImagePullPolicy } from './types.js'; import type { ContainerLogLine, LogContainerRole, LogOutputStream } from './logs/types.js'; +import { + resolveRuntimeImages, + DEFAULT_TRANSLATOR_IMAGE, + DEFAULT_JDC_IMAGE, + resolveImageFetchAction, +} from './image-config.js'; /** * Expand ~ to home directory in a path. @@ -145,8 +151,6 @@ const NETWORK_NAME = 'sv2-network'; const CONFIG_VOLUME = 'sv2-config'; const TRANSLATOR_CONTAINER = 'sv2-translator'; const JDC_CONTAINER = 'sv2-jdc'; -const TRANSLATOR_IMAGE = 'stratumv2/translator_sv2:main'; -const JDC_IMAGE = 'stratumv2/jd_client_sv2:main'; const DOCKER_LOG_HEADER_SIZE = 8; /** @@ -330,6 +334,53 @@ async function pullImage(imageName: string): Promise { }); } +function isDockerNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + + if ('statusCode' in error && (error as { statusCode?: number }).statusCode === 404) { + return true; + } + + if ('reason' in error && typeof (error as { reason?: string }).reason === 'string') { + return (error as { reason: string }).reason.includes('No such image'); + } + + return false; +} + +async function imageExists(imageName: string): Promise { + try { + await docker.getImage(imageName).inspect(); + return true; + } catch (error) { + if (isDockerNotFoundError(error)) { + return false; + } + throw error; + } +} + +async function ensureImageAvailable(imageName: string, pullPolicy: ImagePullPolicy): Promise { + const hasLocalImage = pullPolicy === 'always' ? false : await imageExists(imageName); + const action = resolveImageFetchAction(pullPolicy, hasLocalImage); + + if (action === 'use-local') { + console.log(`Using local image ${imageName}`); + return; + } + + if (action === 'error-missing-local') { + throw new Error( + `Docker image ${imageName} is not available locally and pull policy is "never". ` + + 'Build or load this image first, or set SV2_IMAGE_PULL_POLICY=if-not-present or always.' + ); + } + + await pullImage(imageName); +} + /** * Remove a container if it exists */ @@ -385,7 +436,7 @@ async function getContainerStatus(name: string): Promise * - In Docker: uses shared volume (sv2-config) for config * - In dev: bind-mounts config file from host filesystem */ -async function startTranslator(configPath: string): Promise { +async function startTranslator(configPath: string, imageName: string): Promise { await removeContainer(TRANSLATOR_CONTAINER); const binds = isRunningInDocker @@ -393,7 +444,7 @@ async function startTranslator(configPath: string): Promise { : [`${configPath}:/config/translator.toml:ro`]; const container = await docker.createContainer({ - Image: TRANSLATOR_IMAGE, + Image: imageName, name: TRANSLATOR_CONTAINER, Entrypoint: ['/app/translator_sv2'], Cmd: ['-c', '/config/translator.toml'], @@ -425,7 +476,8 @@ async function startTranslator(configPath: string): Promise { async function startJdc( configPath: string, bitcoinSocketPath: string, - network: string + network: string, + imageName: string ): Promise { await removeContainer(JDC_CONTAINER); @@ -447,7 +499,7 @@ async function startJdc( ]; const container = await docker.createContainer({ - Image: JDC_IMAGE, + Image: imageName, name: JDC_CONTAINER, Entrypoint: ['/app/jd_client_sv2'], Cmd: ['-c', '/config/jdc.toml'], @@ -480,28 +532,35 @@ export async function startStack( configDir: string ): Promise { await ensureDockerAvailable(); + const images = resolveRuntimeImages(data.images); + + console.log(`Translator image: ${images.translatorImage}${images.translatorImage === DEFAULT_TRANSLATOR_IMAGE ? ' (default)' : ''}`); + if (data.mode === 'jd') { + console.log(`JDC image: ${images.jdcImage}${images.jdcImage === DEFAULT_JDC_IMAGE ? ' (default)' : ''}`); + } + console.log(`Image pull policy: ${images.pullPolicy}`); // Ensure network exists await ensureNetwork(); // Connect sv2-ui to the network so it can proxy API requests await connectSv2UiToNetwork(); - // Pull latest images from Docker Hub - await pullImage(TRANSLATOR_IMAGE); + // Ensure images are available according to configured pull policy + await ensureImageAvailable(images.translatorImage, images.pullPolicy); if (data.mode === 'jd') { - await pullImage(JDC_IMAGE); + await ensureImageAvailable(images.jdcImage, images.pullPolicy); } // Start JDC first if in JD mode (Translator connects to JDC) if (data.mode === 'jd' && data.bitcoin) { const socketPath = expandHomePath(data.bitcoin.socket_path); - await startJdc(`${configDir}/jdc.toml`, socketPath, data.bitcoin.network); + await startJdc(`${configDir}/jdc.toml`, socketPath, data.bitcoin.network, images.jdcImage); console.log('Waiting for JDC to initialize...'); await new Promise(resolve => setTimeout(resolve, 3000)); } // Start Translator - await startTranslator(`${configDir}/translator.toml`); + await startTranslator(`${configDir}/translator.toml`, images.translatorImage); } /** diff --git a/server/src/image-config.ts b/server/src/image-config.ts new file mode 100644 index 0000000..8366b51 --- /dev/null +++ b/server/src/image-config.ts @@ -0,0 +1,88 @@ +import type { ImageConfig, ImagePullPolicy } from './types.js'; + +export const DEFAULT_TRANSLATOR_IMAGE = 'stratumv2/translator_sv2:main'; +export const DEFAULT_JDC_IMAGE = 'stratumv2/jd_client_sv2:main'; + +export interface ResolvedImageConfig { + translatorImage: string; + jdcImage: string; + pullPolicy: ImagePullPolicy; +} + +export type ImageFetchAction = 'pull' | 'use-local' | 'error-missing-local'; + +interface ResolveRuntimeImagesOptions { + env?: NodeJS.ProcessEnv; + logger?: Pick; +} + +function cleanString(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function parsePullPolicy(value: string): ImagePullPolicy | null { + const normalized = value.trim().toLowerCase(); + + if (normalized === 'always') return 'always'; + if (normalized === 'never') return 'never'; + if (normalized === 'if-not-present' || normalized === 'if_not_present' || normalized === 'ifnotpresent') { + return 'if-not-present'; + } + + return null; +} + +export function normalizeImagePullPolicy( + rawPolicy: string | null | undefined, + logger: Pick = console +): ImagePullPolicy { + const cleaned = cleanString(rawPolicy); + if (!cleaned) return 'always'; + + const policy = parsePullPolicy(cleaned); + if (policy) return policy; + + logger.warn( + `Invalid SV2 image pull policy "${cleaned}". ` + + 'Supported values: always, if-not-present, never. Falling back to "always".' + ); + return 'always'; +} + +function resolveImageRef(explicitRef: string | null | undefined, envRef: string | null | undefined, fallback: string): string { + return cleanString(explicitRef) || cleanString(envRef) || fallback; +} + +export function resolveImageFetchAction(pullPolicy: ImagePullPolicy, hasLocalImage: boolean): ImageFetchAction { + if (pullPolicy === 'always') return 'pull'; + if (hasLocalImage) return 'use-local'; + if (pullPolicy === 'never') return 'error-missing-local'; + return 'pull'; +} + +export function resolveRuntimeImages( + imageConfig: ImageConfig | null | undefined, + options: ResolveRuntimeImagesOptions = {} +): ResolvedImageConfig { + const env = options.env ?? process.env; + const logger = options.logger ?? console; + + return { + translatorImage: resolveImageRef( + imageConfig?.translator_image, + env.TRANSLATOR_IMAGE, + DEFAULT_TRANSLATOR_IMAGE + ), + jdcImage: resolveImageRef( + imageConfig?.jdc_image, + env.JDC_IMAGE, + DEFAULT_JDC_IMAGE + ), + pullPolicy: normalizeImagePullPolicy( + imageConfig?.pull_policy ?? env.SV2_IMAGE_PULL_POLICY, + logger + ), + }; +} diff --git a/server/src/types.ts b/server/src/types.ts index f17a444..59f4187 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -4,6 +4,7 @@ export type MiningMode = 'solo' | 'pool'; export type SetupMode = 'jd' | 'no-jd'; +export type ImagePullPolicy = 'always' | 'if-not-present' | 'never'; export interface PoolConfig { name: string; @@ -34,6 +35,12 @@ export interface TranslatorConfig { min_hashrate: number; } +export interface ImageConfig { + translator_image?: string; + jdc_image?: string; + pull_policy?: ImagePullPolicy; +} + export interface SetupData { miningMode: MiningMode; mode: SetupMode; @@ -41,6 +48,7 @@ export interface SetupData { bitcoin: BitcoinConfig | null; jdc: JdcConfig | null; translator: TranslatorConfig; + images?: ImageConfig | null; } export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'stopped';