174 lines
7.1 KiB
TypeScript
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 });
|
|
}
|
|
}
|