datahaven/test/utils/docker.ts
Steve Degosserie 1f38b4e343
fix: Complete CI compatibility with self-hosted GitHub runners (#134)
## Summary

This PR resolves all CI failures following the migration to self-hosted
GitHub runners (`DH-Testing` group) by eliminating sudo dependencies and
fixing Docker connectivity issues.

## Key Changes

### 🔧 **Eliminated sudo requirements across all workflows**
- **Setup Environment**: Installed mold linker and system dependencies
in userspace without sudo
- **Tool Installation**: Replaced apt/system package installations with
direct binary downloads:
  - Kurtosis: Direct binary download from GitHub releases (v1.10.3)
  - Taplo: Direct binary installation for Cargo.toml formatting
- cargo-nextest: Using `cargo install` instead of GitHub action
(v0.9.100)
- **Runner Cleanup**: Skipped cleanup-runner action entirely on
self-hosted runners (bare-metal manages disk space externally)

### 🐳 **Fixed Docker connectivity for E2E tests**  
- **Enhanced dockerode configuration** with robust fallback logic for
different socket locations
- **Added DOCKER_HOST environment variable** to E2E workflow for
consistent Docker daemon access
- **Implemented connection testing** with detailed error diagnostics for
troubleshooting
- **Resolves FailedToOpenSocket errors** by supporting multiple socket
paths and connection methods

### 🏷️ **Workflow optimizations**
- **Label-based targeting**: All heavy workloads (Rust builds, E2E
tests) now run on `DH-Testing` runners
- **Dependency management**: Used `install-deps: false` flag instead of
hardcoded runner detection
- **Permission fixes**: Corrected Docker build permissions and GHCR
organization names

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-09 21:18:50 +02:00

261 lines
7.7 KiB
TypeScript

import { existsSync } from "node:fs";
import { type Duplex, PassThrough, Transform } from "node:stream";
import Docker from "dockerode";
import invariant from "tiny-invariant";
import { logger, type ServiceInfo, StandardServiceMappings } from "utils";
function createDockerConnection(): Docker {
const dockerHost = process.env.DOCKER_HOST;
if (dockerHost) {
logger.debug(`Using DOCKER_HOST: ${dockerHost}`);
if (dockerHost.startsWith("unix://")) {
return new Docker({ socketPath: dockerHost.replace("unix://", "") });
}
if (dockerHost.startsWith("tcp://")) {
const url = new URL(dockerHost);
return new Docker({
host: url.hostname,
port: Number.parseInt(url.port) || 2375,
protocol: "http"
});
}
}
const socketPaths = [
"/var/run/docker.sock",
"/run/user/1000/docker.sock",
`${process.env.HOME}/.docker/run/docker.sock`
];
for (const socketPath of socketPaths) {
try {
if (existsSync(socketPath)) {
logger.debug(`Using Docker socket: ${socketPath}`);
return new Docker({ socketPath });
}
} catch (error) {
logger.debug(`Failed to access socket ${socketPath}:`, error);
}
}
logger.debug("Falling back to default Docker configuration");
return new Docker({});
}
const docker = createDockerConnection();
async function testDockerConnection(): Promise<void> {
try {
await docker.ping();
logger.debug("Docker connection successful");
} catch (error) {
logger.error("Docker connection failed:", error);
throw new Error(
`Failed to connect to Docker daemon: ${error instanceof Error ? error.message : String(error)}`
);
}
}
export const getServicesFromDocker = async (): Promise<ServiceInfo[]> => {
let containers: Docker.ContainerInfo[];
try {
containers = await docker.listContainers();
} catch (error) {
logger.error("Failed to list containers:", error);
await testDockerConnection();
throw error;
}
const services: ServiceInfo[] = [];
for (const mapping of StandardServiceMappings) {
try {
const container = containers.find((container) =>
container.Names.some((name) => name.includes(mapping.containerPattern))
);
if (!container) {
logger.warn(`Container with pattern "${mapping.containerPattern}" not found.`);
services.push({
service: mapping.service,
port: "Not found",
url: "N/A"
});
continue;
}
const portMappings = container.Ports.filter(
(port) => port.PrivatePort === mapping.internalPort && port.Type === mapping.protocol
);
let selectedMapping = portMappings.find((port) => port.IP === "0.0.0.0" || port.IP === ":::");
if (!selectedMapping && portMappings.length > 0) {
selectedMapping = portMappings[0];
}
if (!selectedMapping || !selectedMapping.PublicPort) {
logger.warn(
`Port mapping not found for ${mapping.service} (${mapping.internalPort}/${mapping.protocol}).`
);
services.push({
service: mapping.service,
port: "Not found",
url: "N/A"
});
continue;
}
services.push({
service: mapping.service,
port: selectedMapping.PublicPort.toString(),
url: `http://127.0.0.1:${selectedMapping.PublicPort}`
});
} catch (error) {
logger.error(`Error getting info for ${mapping.service}:`, error);
services.push({
service: mapping.service,
port: "Error",
url: "N/A"
});
}
}
return services;
};
export const getContainersMatchingImage = async (imageName: string) => {
const containers = await docker.listContainers({ all: true });
const matches = containers.filter((container) => container.Image.includes(imageName));
return matches;
};
export const getContainersByPrefix = async (prefix: string) => {
const containers = await docker.listContainers({ all: true });
const matches = containers.filter((container) =>
container.Names.some((name) => name.startsWith(`/${prefix}`))
);
return matches;
};
export const getPublicPort = async (
containerName: string,
internalPort: number
): Promise<number> => {
const containers = await docker.listContainers();
const container = containers.find((container) =>
container.Names.some((name) => name.includes(containerName))
);
invariant(container, `❌ container ${container} cannot be found in running container list`);
const portMappings = container.Ports.find(
(port) => port.PrivatePort === internalPort && port.Type === "tcp"
);
logger.debug(`Port mappings for ${containerName}:${internalPort}`, portMappings);
invariant(portMappings, `❌ port mapping not found for ${containerName}:${internalPort}`);
return portMappings.PublicPort;
};
export async function waitForLog(opts: {
search: string | RegExp;
containerName: string;
timeoutSeconds?: number;
}): Promise<string> {
const container = docker.getContainer(opts.containerName);
await container.inspect();
const timeoutMs = (opts.timeoutSeconds ?? 10) * 1_000;
const rawStream = (await container.logs({
stdout: true,
stderr: true,
follow: true,
since: 0
})) as Duplex;
const pass = new PassThrough();
container.modem.demuxStream(rawStream, pass, pass);
const { readable } = Transform.toWeb(pass);
const decoder = new TextDecoder();
const timer = setTimeout(
() =>
pass.destroy(
new Error(
`Timed out after ${timeoutMs} ms waiting for "${opts.search}" in ${opts.containerName}`
)
),
timeoutMs
);
try {
for await (const chunk of readable) {
const text = decoder.decode(chunk as Uint8Array, { stream: false });
const hit =
typeof opts.search === "string" ? text.includes(opts.search) : opts.search.test(text);
if (hit) return text.trim();
}
throw new Error(
`Log stream ended before "${opts.search}" appeared for container ${opts.containerName}`
);
} finally {
if (timer) {
clearTimeout(timer);
}
if (pass && typeof pass.destroy === "function" && !pass.destroyed) {
pass.destroy();
}
if (rawStream) {
if (typeof rawStream.destroy === "function" && !rawStream.destroyed) {
rawStream.destroy();
}
const socket = (rawStream as any).socket;
if (socket && typeof socket.destroy === "function" && !socket.destroyed) {
socket.destroy();
}
}
}
}
export const waitForContainerToStart = async (
containerName: string,
options?: { timeoutSeconds?: number }
) => {
logger.debug(`Waiting for container ${containerName} to start...`);
const seconds = options?.timeoutSeconds ?? 30;
for (let i = 0; i < seconds; i++) {
const containers = await docker.listContainers();
const container = containers.find((container) =>
container.Names.some((name) => name.includes(containerName))
);
if (container) {
logger.debug(`Container ${containerName} started after ${i} seconds`);
return;
}
await Bun.sleep(1000);
}
invariant(
false,
`❌ container ${containerName} cannot be found in running container list after ${seconds} seconds`
);
};
export const killExistingContainers = async (prefix: string) => {
logger.debug(`Searching for containers with image ${prefix}...`);
const containerInfos = await getContainersByPrefix(prefix);
if (containerInfos.length === 0) {
logger.debug(`No containers found with name starting with "${prefix}"`);
return;
}
const promises = containerInfos.map(({ Id }) => docker.getContainer(Id).remove({ force: true }));
await Promise.all(promises);
logger.debug(`${containerInfos.length} containers with name starting with "${prefix}" killed`);
};