Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions server/src/config/image-config.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
83 changes: 71 additions & 12 deletions server/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

/**
Expand Down Expand Up @@ -330,6 +334,53 @@ async function pullImage(imageName: string): Promise<void> {
});
}

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<boolean> {
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<void> {
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
*/
Expand Down Expand Up @@ -385,15 +436,15 @@ async function getContainerStatus(name: string): Promise<ContainerStatus | null>
* - In Docker: uses shared volume (sv2-config) for config
* - In dev: bind-mounts config file from host filesystem
*/
async function startTranslator(configPath: string): Promise<void> {
async function startTranslator(configPath: string, imageName: string): Promise<void> {
await removeContainer(TRANSLATOR_CONTAINER);

const binds = isRunningInDocker
? [`${CONFIG_VOLUME}:/config:ro`]
: [`${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'],
Expand Down Expand Up @@ -425,7 +476,8 @@ async function startTranslator(configPath: string): Promise<void> {
async function startJdc(
configPath: string,
bitcoinSocketPath: string,
network: string
network: string,
imageName: string
): Promise<void> {
await removeContainer(JDC_CONTAINER);

Expand All @@ -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'],
Expand Down Expand Up @@ -480,28 +532,35 @@ export async function startStack(
configDir: string
): Promise<void> {
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);
}

/**
Expand Down
88 changes: 88 additions & 0 deletions server/src/image-config.ts
Original file line number Diff line number Diff line change
@@ -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<Console, 'warn'>;
}

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, 'warn'> = 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
),
};
}
8 changes: 8 additions & 0 deletions server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,13 +35,20 @@ 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;
pool: PoolConfig | null;
bitcoin: BitcoinConfig | null;
jdc: JdcConfig | null;
translator: TranslatorConfig;
images?: ImageConfig | null;
}

export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'stopped';
Expand Down
Loading