Files
kandev/apps/web/e2e/fixtures/ssh-image.ts
T
2026-08-24 21:05:02 +00:00

174 lines
7.1 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/**
* Image used by the SSH executor's e2e tests. Built once per machine and
* reused. Tag is stable so Docker's layer cache survives across runs.
*
* The image carries:
* - openssh-server + openssh-sftp-server (the remote target)
* - git + bash + curl + sudo + coreutils (for prepare scripts and broker health checks)
* - iproute2 + iptables (for fault-injection: drop traffic mid-session)
* - a pre-baked `mock-agent` binary at /usr/local/bin/mock-agent so the
* agentctl process the SSH executor uploads has something to spawn when
* the orchestrator asks for an agent subprocess.
*
* The `agentctl` binary is NOT baked in — the e2e tests verify the actual
* SFTP upload + sha256-cache path the production code uses.
*/
export const SSH_E2E_IMAGE_TAG = "kandev-sshd:e2e";
const ENTRYPOINT = `#!/bin/sh
set -e
# Generate a fresh host key on first start so each test run gets a new
# fingerprint. Persisted across container restarts within the same test so
# fingerprint-pinning works end-to-end.
ssh-keygen -A >/dev/null
# Verbose sshd logging so test failures can be diagnosed without rebuilding
# the image — landed via the entrypoint so it always lands.
printf "\\nLogLevel DEBUG2\\n" >> /etc/ssh/sshd_config.d/10-kandev-forwarding.conf
# If the test runner bind-mounted /home/kandev/.kandev to inspect logs from
# the host, the mount inherits host ownership; make it writable by the
# kandev user before sshd starts accepting sessions.
mkdir -p /home/kandev/.kandev
chown -R kandev:kandev /home/kandev/.kandev
# Worker mounts its public key in here; copy into the kandev user's
# authorized_keys with the right perms so sshd accepts it.
if [ -f /authorized_keys ]; then
mkdir -p /home/kandev/.ssh
cp /authorized_keys /home/kandev/.ssh/authorized_keys
chown -R kandev:kandev /home/kandev/.ssh
chmod 700 /home/kandev/.ssh
chmod 600 /home/kandev/.ssh/authorized_keys
fi
# Optional second authorized_keys for a different user (passphrase-protected
# key tests load a second key into ssh-agent and want it accepted too).
# Make sure the destination exists when only /authorized_keys.extra is mounted
# (otherwise "set -e" aborts the entrypoint on the >> redirect).
if [ -f /authorized_keys.extra ]; then
mkdir -p /home/kandev/.ssh
touch /home/kandev/.ssh/authorized_keys
cat /authorized_keys.extra >> /home/kandev/.ssh/authorized_keys
chown -R kandev:kandev /home/kandev/.ssh
chmod 700 /home/kandev/.ssh
chmod 600 /home/kandev/.ssh/authorized_keys
fi
# Run sshd in the foreground so docker logs and exit-codes propagate.
mkdir -p /var/run/sshd /var/empty
exec /usr/sbin/sshd -D -e
`;
const DOCKERFILE = `FROM alpine:3.20
RUN apk add --no-cache \\
openssh-server \\
openssh-sftp-server \\
git \\
bash \\
curl \\
sudo \\
shadow \\
nodejs \\
npm \\
coreutils \\
ca-certificates \\
iproute2 \\
iptables \\
# agentctl is built dynamically linked against glibc; Alpine is musl. gcompat
# ships a glibc-compat shim that lets glibc binaries exec on Alpine. Without
# it 'nohup /home/kandev/.kandev/bin/agentctl' fails with "No such file or
# directory" (the kernel can't find /lib64/ld-linux-x86-64.so.2).
gcompat \\
libstdc++ \\
# Alpine sshd_config sets AllowTcpForwarding no, which makes sshd reject
# every direct-tcpip channel request — exactly what the SSH executor's
# local port forward needs. Override to yes via a config.d drop-in.
# Also bump MaxSessions: kandev opens several concurrent direct-tcpip
# channels per session (health probe + agent stream + workspace stream +
# follow-up HTTP requests), and the OpenSSH default of 10 isn't enough
# under load — sshd starts EOF'ing channel open requests once exhausted.
&& printf "\\nAllowTcpForwarding yes\\nMaxSessions 100\\n" \\
> /etc/ssh/sshd_config.d/10-kandev-forwarding.conf \\
&& adduser -D -s /bin/bash kandev \\
# Alpine's adduser -D leaves the account "locked" (password = ! in /etc/shadow).
# sshd refuses ALL auth methods — including pubkey — for locked users, so we
# unlock by setting the password hash to '*' (no valid password, but the
# account itself is open for key-based login).
&& usermod -p '*' kandev \\
&& echo "kandev ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers \\
&& mkdir -p /home/kandev/.ssh /var/empty /var/run/sshd \\
&& chown -R kandev:kandev /home/kandev \\
&& rm -f /etc/ssh/ssh_host_* # generated fresh by entrypoint on each first-start
# The mock-agent binary is supplied by the test runner (linux/amd64 build the
# Go suite already produces). Without it agentctl can't spawn an agent
# subprocess; with it, the SSH executor can drive a full task end-to-end.
COPY mock-agent-linux-amd64 /usr/local/bin/mock-agent
RUN rm -f /usr/local/bin/npx
COPY managed-runtime-npx.sh /usr/local/bin/npx
RUN chmod +x /usr/local/bin/mock-agent
RUN chmod +x /usr/local/bin/npx
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 22
ENTRYPOINT ["/entrypoint.sh"]
`;
/**
* Returns true when a Docker daemon is reachable. Reused from docker-probe;
* duplicated here so callers can ask the "SSH-specific" question without
* implying anything about the Docker executor.
*/
export function hasSSHContainerSupport(): boolean {
// 10s ceiling so a hung / unreachable Docker daemon doesn't stall the
// Playwright worker indefinitely — we just want a yes/no probe.
return spawnSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 }).status === 0;
}
/**
* Build the kandev-sshd:e2e image. Idempotent: Docker layer caching makes
* repeated builds near-instant when the Dockerfile and mock-agent haven't
* changed.
*
* Throws if the mock-agent linux/amd64 binary isn't present — that's a
* setup error the global-setup pre-flight should have caught.
*/
export function buildE2ESSHImage(): void {
const mockAgentPath = path.resolve(__dirname, "../../../backend/bin/mock-agent-linux-amd64");
if (!fs.existsSync(mockAgentPath)) {
throw new Error(
`mock-agent-linux-amd64 not found at ${mockAgentPath}; build it with ` +
`'make -C apps/backend build-mock-agent-linux' before running container e2e tests.`,
);
}
const ctxDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-e2e-sshd-img-"));
try {
fs.writeFileSync(path.join(ctxDir, "Dockerfile"), DOCKERFILE);
fs.writeFileSync(path.join(ctxDir, "entrypoint.sh"), ENTRYPOINT);
fs.copyFileSync(mockAgentPath, path.join(ctxDir, "mock-agent-linux-amd64"));
fs.copyFileSync(
path.resolve(__dirname, "managed-runtime-npx.sh"),
path.join(ctxDir, "managed-runtime-npx.sh"),
);
execFileSync("docker", ["build", "-t", SSH_E2E_IMAGE_TAG, ctxDir], {
stdio: process.env.E2E_DEBUG ? "inherit" : "ignore",
// 15-minute ceiling — even cold-cache builds (apk add openssh-server
// + a few hundred KB of mock-agent + sshd config) finish well under
// a minute. A stuck registry pull or BuildKit hang should fail fast.
timeout: 15 * 60 * 1000,
});
} finally {
fs.rmSync(ctxDir, { recursive: true, force: true });
}
}