creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
226 lines (200 loc) • 7 kB
text/typescript
import tar from 'tar-stream';
import Logger from 'loglevel';
import { Writable } from 'stream';
import Dockerode, { Container } from 'dockerode';
import { existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { DockerAuth } from '../types.js';
import { logger } from './logger.js';
import { setWorkerContainer } from './worker/context.js';
export function findDockerSocket(): string | undefined {
// List of possible docker.sock locations in order of preference
const possiblePaths = [
// Standard Linux location
'/var/run/docker.sock',
// Docker Desktop for Mac
join(homedir(), '.docker', 'run', 'docker.sock'),
// Colima
join(homedir(), '.colima', 'default', 'docker.sock'),
join(homedir(), '.colima', 'docker.sock'),
// Podman
'/run/podman/podman.sock',
...(process.env.XDG_RUNTIME_DIR ? [join(process.env.XDG_RUNTIME_DIR, 'podman', 'podman.sock')] : []),
// Rancher Desktop
join(homedir(), '.rd', 'docker.sock'),
// Orbstack
join(homedir(), '.orbstack', 'run', 'docker.sock'),
];
for (const socketPath of possiblePaths) {
if (existsSync(socketPath)) {
logger().debug(`Found Docker socket at: ${socketPath}`);
return socketPath;
}
}
}
let docker: Dockerode | null = null;
function getDocker(): Dockerode {
if (!docker) {
const dockerSocketPath = findDockerSocket();
docker = new Dockerode(dockerSocketPath ? { socketPath: dockerSocketPath } : undefined);
}
return docker;
}
class DevNull extends Writable {
_write(_chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
setImmediate(callback);
}
}
export async function pullImages(
images: string[],
{ auth, platform }: { auth?: DockerAuth; platform?: string } = {},
): Promise<void> {
const args: Record<string, unknown> = {};
if (auth) args.authconfig = auth;
if (platform) args.platform = platform;
const docker = getDocker();
logger().info('Pull docker images');
// TODO Replace with `import from`
const { default: yoctoSpinner } = await import('yocto-spinner');
for (const image of images) {
await new Promise<void>((resolve, reject) => {
const spinner = yoctoSpinner({ text: `${image}: Pull start` }).start();
docker.pull(image, args, (pullError: Error | null, stream?: NodeJS.ReadableStream) => {
if (pullError || !stream) {
spinner.error(pullError?.message);
reject(pullError ?? new Error('Unknown error'));
return;
}
docker.modem.followProgress(stream, onFinished, onProgress);
function onFinished(error: Error | null): void {
if (error) {
spinner.error(error.message);
reject(error);
return;
}
spinner.success(`${image}: Pull complete`);
resolve();
}
function onProgress(event: { id: string; status: string; progress?: string }): void {
if (!/^[a-z0-9]{12}$/i.test(event.id)) return;
spinner.text = `${image}: [${event.id}] ${event.status} ${event.progress ?? ''}`;
}
});
});
}
}
export async function buildImage(imageName: string, version: string, dockerfile: string): Promise<void> {
const docker = getDocker();
const images = await docker.listImages({ filters: { label: [`creevey=${imageName}`] } });
const containers = await docker.listContainers({ all: true, filters: { label: [`creevey=${imageName}`] } });
if (containers.length > 0) {
await Promise.all(
containers.map(async (info) => {
const container = docker.getContainer(info.Id);
try {
await container.remove({ force: true });
} catch {
/* noop */
}
}),
);
}
const oldImages = images.filter((info) => info.Labels.version !== version);
if (oldImages.length > 0) {
await Promise.all(
oldImages.map(async (info) => {
const image = docker.getImage(info.Id);
try {
await image.remove({ force: true });
} catch {
/* noop */
}
}),
);
}
if (oldImages.length !== images.length) {
logger().info(`Image ${imageName} already exists`);
return;
}
const pack = tar.pack();
pack.entry({ name: 'Dockerfile' }, dockerfile);
pack.finalize();
const { default: yoctoSpinner } = await import('yocto-spinner');
const spinner = yoctoSpinner({ text: `${imageName}: Build start` });
if (logger().getLevel() > Logger.levels.DEBUG) {
spinner.start();
}
let isFailed = false;
await new Promise<void>((resolve, reject) => {
docker.buildImage(
pack,
// TODO Support buildkit decode grpc (version: '2')
{ t: imageName, labels: { creevey: imageName, version }, version: '1' },
(buildError: Error | null, stream) => {
if (buildError || !stream) {
// spinner.error(buildError?.message);
reject(buildError ?? new Error('Unknown error'));
return;
}
docker.modem.followProgress(stream, onFinished, onProgress);
function onFinished(error: Error | null): void {
if (isFailed) return;
if (error) {
spinner.error(error.message);
reject(error);
return;
}
spinner.success(`${imageName}: Build complete`);
resolve();
}
function onProgress(
event:
| { stream: string }
| { errorDetail: { code: number; message: string }; error: string }
| { id: string; aux: string }, // NOTE: Only with `version: '2'`
): void {
if ('stream' in event) {
if (logger().getLevel() <= Logger.levels.DEBUG) {
logger().debug(event.stream.trim());
} else {
spinner.text = `${imageName}: [Build] - ${event.stream}`;
}
} else if ('errorDetail' in event) {
isFailed = true;
spinner.error(event.error);
reject(new Error(event.error));
}
}
},
);
});
}
export async function runImage(
image: string,
args: string[],
options: Record<string, unknown>,
debug: boolean,
): Promise<string> {
const docker = getDocker();
const hub = docker.run(image, args, debug ? process.stdout : new DevNull(), options, (error) => {
if (error) throw error;
});
return new Promise((resolve) => {
hub.once('container', (container: Container) => {
setWorkerContainer(container);
});
hub.once(
'start',
(container: Container) =>
void container.inspect().then((info) => {
if ('podman' in info.NetworkSettings.Networks) {
// NOTE: Podman uses different default network
resolve(info.NetworkSettings.Networks.podman.IPAddress);
} else {
resolve(info.NetworkSettings.Networks.bridge.IPAddress);
}
}),
);
});
}