Files
Diego Imbert 4f2f7356c0 Fullstack CI Preview (#7665)
* update cf worker hostname

* set remote_url cookie from param

* ephemeral backends v1

* nit

* Run queue server

* ntis

* timeout

* better db process management

* commit hash and worktree

* nit use map

* nit

* err handling

* Revert "err handling"

This reverts commit 19de00c0c0.

* nits

* auto cleanup

* Ephemeral backend command action

* remove checkout

* checkout ee repo

* nits

* process.env.GIT_EE_DEPLOY_KEY_FILE

* resumeURLs logic

* nit

* use windmill flow for ephemeral backend action

* fixes

* new token

* worktree pools

* Delete GH secret on cleanup

* linux deploy

* nit

* nit

* unhandled promises

* nit

* fix docker bridge IP on linux

* pass cf_frontend_url to wmill flow

* git fetch

* release worktree when binary started

* send error

* logger

* logging

* logging 2

* delete log files periodically

* redirect to raw app with logs

* CORS

* MANAGER_AUTH_TOKEN

* Check organization membership

* nit

* bwrap

* nit

* return timeoutAt in resumeUrl

* nit

* Change password

* nit remove https
2026-02-05 17:26:51 +00:00

671 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { spawn, exec } from "child_process";
import { promisify } from "util";
import * as readline from "readline";
import { WorktreePool, WorktreeInfo } from "./worktree-pool";
import { Logger } from "./logger";
const execAsync = promisify(exec);
export interface Config {
dbPort: number;
serverPort: number;
skipBuild: boolean;
commitHash: string;
worktreePool: WorktreePool;
adminPwd: string;
onCloudflaredUrl?: (url: string) => void;
onCleanup?: () => void;
}
interface SpawnedResources {
dbContainerId: string;
dbProcess?: any;
backendProcess?: any;
cloudflaredProcess?: any;
tunnelUrl?: string;
worktree?: WorktreeInfo;
eeWorktreePath?: string;
logger?: Logger;
}
// No default config needed since commitHash is always required
export class EphemeralBackend {
private config: Config;
private resources: SpawnedResources = { dbContainerId: "" };
getDbPort(): number {
return this.config.dbPort;
}
getServerPort(): number {
return this.config.serverPort;
}
getLogFilePath(): string | undefined {
return this.resources.logger?.getLogFilePath();
}
constructor(config: Config) {
this.config = config;
}
async spawn(): Promise<{
tunnelUrl: string;
}> {
try {
// Initialize logger for this ephemeral backend
this.resources.logger = new Logger(this.config.commitHash, "backend");
this.resources.logger.log("🚀 Starting ephemeral backend...");
this.resources.logger.log(`📊 Database port: ${this.config.dbPort}`);
this.resources.logger.log(`🌐 Server port: ${this.config.serverPort}`);
this.resources.logger.log(`📌 Commit hash: ${this.config.commitHash}`);
await this.startCloudflared();
if (!this.resources.tunnelUrl)
throw new Error("Cloudflare tunnel URL not available");
await this.acquireWorktree();
await this.setupEECode();
await this.spawnPostgres();
await this.waitForPostgres();
if (!this.config.skipBuild) {
await this.buildBackend();
} else {
this.resources.logger?.log(
"\n⏭ Skipping backend build (using existing binary)"
);
}
await this.startBackend();
// Release the worktree back to the pool now that the backend is running
// The binary is already compiled and running, so other spawns can reuse this worktree
if (this.resources.worktree) {
this.resources.logger?.log(
"\n♻ Releasing worktree back to pool (backend is now running)..."
);
await this.config.worktreePool.release(this.resources.worktree.id);
// Keep the reference for cleanup but mark it as released
this.resources.worktree = undefined;
}
this.resources.logger?.log("\n✅ Ephemeral backend is ready!");
this.resources.logger?.log(`📍 Tunnel URL: ${this.resources.tunnelUrl}`);
this.resources.logger?.log(
`📄 Log file: ${this.resources.logger.getLogFilePath()}`
);
return {
tunnelUrl: this.resources.tunnelUrl,
};
} catch (error) {
this.resources.logger?.error(
`❌ Error spawning ephemeral backend: ${error}`
);
throw error;
}
}
private async acquireWorktree(): Promise<void> {
this.resources.logger?.log("\n📂 Acquiring worktree from pool...");
// Acquire a worktree from the pool
this.resources.worktree = await this.config.worktreePool.acquire(
this.config.commitHash
);
this.resources.logger?.log(
`✓ Worktree acquired: ${this.resources.worktree.path}`
);
}
private async setupEECode(): Promise<void> {
this.resources.logger?.log("\n🔐 Setting up Enterprise Edition code...");
if (!this.resources.worktree) {
throw new Error("Worktree not acquired");
}
const worktreePath = this.resources.worktree.path;
const eeRefPath = `${worktreePath}/backend/ee-repo-ref.txt`;
const eeWorktreePath = this.config.worktreePool.getEEWorktreePath(
this.resources.worktree
);
this.resources.eeWorktreePath = eeWorktreePath;
// Read the EE commit hash from ee-repo-ref.txt
this.resources.logger?.log(
` Reading EE commit reference from ${eeRefPath}`
);
let eeCommitHash: string;
try {
const { stdout } = await execAsync(`cat ${eeRefPath}`);
eeCommitHash = stdout.trim();
if (!eeCommitHash) {
throw new Error("ee-repo-ref.txt is empty");
}
this.resources.logger?.log(` ✓ EE commit hash: ${eeCommitHash}`);
} catch (error) {
throw new Error(
`Failed to read ee-repo-ref.txt: ${
error instanceof Error ? error.message : String(error)
}`
);
}
// Remove existing EE private folder if it exists (from previous runs)
this.resources.logger?.log(` Cleaning up any existing EE repository...`);
try {
await execAsync(`rm -rf ${eeWorktreePath}`);
} catch (error) {
// Ignore errors if directory doesn't exist
}
// Clone the windmill-ee-private repo at the specific commit
this.resources.logger?.log(
` Cloning windmill-ee-private at commit ${eeCommitHash}`
);
try {
await execAsync(
`git clone git@github.com:windmill-labs/windmill-ee-private.git ${eeWorktreePath}`,
{
env: {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${process.env.GIT_EE_DEPLOY_KEY_FILE} -o StrictHostKeyChecking=accept-new`,
},
}
);
this.resources.logger?.log(` ✓ Repository cloned to ${eeWorktreePath}`);
} catch (error) {
throw new Error(
`Failed to clone windmill-ee-private: ${
error instanceof Error ? error.message : String(error)
}`
);
}
// Checkout the specific commit
this.resources.logger?.log(` Checking out commit ${eeCommitHash}`);
try {
await execAsync(`git checkout ${eeCommitHash}`, {
cwd: eeWorktreePath,
});
this.resources.logger?.log(` ✓ Checked out commit ${eeCommitHash}`);
} catch (error) {
throw new Error(
`Failed to checkout EE commit: ${
error instanceof Error ? error.message : String(error)
}`
);
}
// Run the substitute_ee_code.sh script to copy EE files
this.resources.logger?.log(
` Running substitute_ee_code.sh to copy EE files`
);
try {
await execAsync(`./substitute_ee_code.sh --copy -d ${eeWorktreePath}`, {
cwd: `${worktreePath}/backend`,
});
this.resources.logger?.log(` ✓ EE code substituted successfully`);
} catch (error) {
throw new Error(
`Failed to substitute EE code: ${
error instanceof Error ? error.message : String(error)
}`
);
}
this.resources.logger?.log("✓ Enterprise Edition code setup complete");
}
private async spawnPostgres(): Promise<void> {
this.resources.logger?.log("\n🐘 Spawning PostgreSQL container...");
this.resources.dbProcess = spawn("docker", [
"run",
"--rm",
"-p",
`${this.config.dbPort}:5432`,
"-e",
"POSTGRES_PASSWORD=changeme",
"-e",
"POSTGRES_DB=windmill",
"postgres:16",
]);
// Capture and log postgres stdout
this.resources.dbProcess.stdout.on("data", (data: Buffer) => {
const output = data.toString().trim();
if (output) {
this.resources.logger?.log(`[postgres] ${output}`);
}
});
// Capture and log postgres stderr
this.resources.dbProcess.stderr.on("data", (data: Buffer) => {
const output = data.toString().trim();
if (output) {
this.resources.logger?.error(`[postgres] ${output}`);
}
});
this.resources.dbProcess.on("close", (code: number) => {
this.resources.logger?.log(`PostgreSQL process exited with code ${code}`);
});
}
private async waitForPostgres(): Promise<void> {
this.resources.logger?.log("⏳ Waiting for PostgreSQL to be ready...");
const maxAttempts = 30;
const delayMs = 1000;
// Determine the host to connect to
// On Linux, we need to use the host's IP or localhost
// On macOS/Windows, host.docker.internal works
const isLinux = process.platform === "linux";
const dbHost = isLinux ? "172.17.0.1" : "host.docker.internal";
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await execAsync(
`docker run --rm postgres:16 pg_isready -h ${dbHost} -p ${this.config.dbPort} -U postgres`
);
this.resources.logger?.log("✓ PostgreSQL is ready");
return;
} catch (error) {
if (attempt === maxAttempts) {
throw new Error("PostgreSQL failed to start in time");
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
private async buildBackend(): Promise<void> {
this.resources.logger?.log(
"\n🔨 Building backend (this may take a while)..."
);
if (!this.resources.worktree) {
throw new Error("Worktree not acquired");
}
// Detect OS to use correct deno_core feature
const isMacOS = process.platform === "darwin";
const env = { ...process.env, SQLX_OFFLINE: "true" };
const features = [
"enterprise",
"enterprise_saml",
"stripe",
"embedding",
"parquet",
"prometheus",
"openidconnect",
"cloud",
"jemalloc",
"agent_worker_server",
"tantivy",
"license",
"http_trigger",
"zip",
"oauth2",
"kafka",
"sqs_trigger",
"nats",
"otel",
"dind",
"postgres_trigger",
"mqtt_trigger",
"gcp_trigger",
"websocket",
"smtp",
"all_languages",
"private",
isMacOS ? "deno_core_mac" : "deno_core",
"mcp",
].join(",");
return new Promise((resolve, reject) => {
const backendDir = `${this.resources.worktree?.path}/backend`;
const buildProcess = spawn(
"cargo",
["build", "--features", features, "--release"],
{ cwd: backendDir, env }
);
buildProcess.stdout.on("data", (data) => {
const output = data.toString().trim();
if (output) {
this.resources.logger?.log(`[build] ${output}`);
}
});
buildProcess.stderr.on("data", (data) => {
const output = data.toString().trim();
if (output) {
this.resources.logger?.error(`[build] ${output}`);
}
});
buildProcess.on("close", (code) => {
if (code === 0) {
this.resources.logger?.log("✓ Backend built successfully");
resolve();
} else {
this.resources.logger?.error(`Build failed with code ${code}`);
reject(new Error(`Build failed with code ${code}`));
}
});
});
}
private async startBackend(): Promise<void> {
this.resources.logger?.log("\n🚀 Starting Windmill backend...");
if (!this.resources.worktree) {
throw new Error("Worktree not acquired");
}
const env = {
// DO NOT PASS the entire process.env to avoid leaking sensitive information
...(process.env.LICENSE_KEY && { LICENSE_KEY: process.env.LICENSE_KEY }),
DATABASE_URL: `postgres://postgres:changeme@localhost:${this.config.dbPort}/windmill?sslmode=disable`,
PORT: this.config.serverPort.toString(),
};
const releaseDir = `${this.resources.worktree.path}/backend/target/release`;
// Get sandbox user UID/GID for proper sandboxing
const sandboxUid = await this.getSandboxUid();
const sandboxGid = await this.getSandboxGid();
// Use bwrap for sandboxing the windmill process
this.resources.backendProcess = spawn(
"bwrap",
[
...["--ro-bind", "/", "/"],
...["--bind", "/home/sandbox", "/home/sandbox"],
...["--bind", "/tmp", "/tmp"],
...["--dev", "/dev"],
...["--proc", "/proc"],
"--unshare-user",
...["--uid", sandboxUid],
...["--gid", sandboxGid],
`${releaseDir}/windmill`,
],
{
env,
}
);
// Wait for backend to be ready by watching for "Windmill Enterprise Edition" in output
const backendReady = new Promise<void>((resolve, reject) => {
// Capture and log backend stdout, watching for ready signal
this.resources.backendProcess.stdout.on("data", (data: any) => {
const output = data.toString().trim();
const timeout = setTimeout(() => {
reject(new Error("Backend failed to start in time"));
}, 30000); // 30 seconds timeout
if (output) {
this.resources.logger?.log(`[backend] ${output}`);
if (output.includes("Windmill Enterprise Edition")) {
clearTimeout(timeout);
resolve();
}
}
});
});
// Capture and log backend stderr
this.resources.backendProcess.stderr.on("data", (data: Buffer) => {
const output = data.toString().trim();
if (output) {
this.resources.logger?.error(`[backend] ${output}`);
}
});
this.resources.backendProcess.on("close", (code: number) => {
this.resources.logger?.log(`Backend process exited with code ${code}`);
});
// Wait for backend to be ready
await backendReady;
this.resources.logger?.log("✓ Backend is running");
// Wait 2 additional seconds to be sure
await new Promise((resolve) => setTimeout(resolve, 2000));
// Setup admin password
await this.setupAdminPassword();
}
private async startCloudflared(): Promise<void> {
if (process.env.SKIP_CLOUDFLARED) {
this.config.onCloudflaredUrl?.("SKIP_CLOUDFLARED");
return;
}
this.resources.logger?.log("\n🌐 Starting Cloudflare tunnel...");
return new Promise((resolve, reject) => {
this.resources.cloudflaredProcess = spawn("cloudflared", [
"tunnel",
"--url",
`http://localhost:${this.config.serverPort}`,
"--config",
"/dev/null",
]);
const rl = readline.createInterface({
input: this.resources.cloudflaredProcess.stdout,
});
rl.on("line", (line: string) => {
if (line.trim()) {
this.resources.logger?.log(`[cloudflared] ${line}`);
}
});
this.resources.cloudflaredProcess.stderr.on("data", (data: Buffer) => {
const line = data.toString();
if (line.trim()) {
this.resources.logger?.error(`[cloudflared] ${line.trim()}`);
}
const match = line.match(/https:\/\/([a-z0-9-]+\.trycloudflare\.com)/);
if (match) {
this.resources.tunnelUrl = match[1];
this.resources.logger?.log(
`✓ Tunnel URL: ${this.resources.tunnelUrl}`
);
this.config.onCloudflaredUrl?.(this.resources.tunnelUrl);
resolve();
}
});
this.resources.cloudflaredProcess.on("close", (code: number) => {
this.resources.logger?.log(
`Cloudflared process exited with code ${code}`
);
});
// Timeout if we can't find the URL in 30 seconds
setTimeout(() => {
if (!this.resources.tunnelUrl) {
this.resources.logger?.error(
"Failed to extract Cloudflare tunnel URL (timeout)"
);
reject(new Error("Failed to extract Cloudflare tunnel URL"));
}
}, 30000);
});
}
private async getSandboxUid(): Promise<string> {
try {
const { stdout } = await execAsync("id -u sandbox");
return stdout.trim();
} catch (error) {
this.resources.logger?.error(
`Failed to get sandbox UID: ${
error instanceof Error ? error.message : String(error)
}`
);
throw new Error("Failed to get sandbox user UID");
}
}
private async getSandboxGid(): Promise<string> {
try {
const { stdout } = await execAsync("id -g sandbox");
return stdout.trim();
} catch (error) {
this.resources.logger?.error(
`Failed to get sandbox GID: ${
error instanceof Error ? error.message : String(error)
}`
);
throw new Error("Failed to get sandbox user GID");
}
}
private async setupAdminPassword(): Promise<void> {
this.resources.logger?.log("\n🔑 Setting up admin password...");
const baseUrl = `http://localhost:${this.config.serverPort}`;
try {
// Login with default credentials
this.resources.logger?.log(" Logging in with default credentials...");
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "admin@windmill.dev",
password: "changeme",
}),
});
if (!loginResponse.ok) {
throw new Error(
`Login failed with status ${
loginResponse.status
}: ${await loginResponse.text()}`
);
}
const token = await loginResponse.text();
this.resources.logger?.log(" ✓ Logged in successfully");
// Set new admin password
this.resources.logger?.log(" Setting new admin password...");
const setPasswordResponse = await fetch(
`${baseUrl}/api/users/setpassword`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
password: this.config.adminPwd,
}),
}
);
if (!setPasswordResponse.ok) {
throw new Error(
`Set password failed with status ${
setPasswordResponse.status
}: ${await setPasswordResponse.text()}`
);
}
this.resources.logger?.log("✓ Admin password configured successfully");
} catch (error) {
this.resources.logger?.error(
`Failed to setup admin password: ${
error instanceof Error ? error.message : String(error)
}`
);
throw error;
}
}
async cleanup(): Promise<void> {
this.resources.logger?.log("\n🧹 Cleaning up resources...");
// Kill backend process
if (this.resources.backendProcess) {
this.resources.logger?.log(" Stopping backend...");
try {
this.resources.backendProcess.kill("SIGTERM");
// Give it a moment to gracefully shutdown
await new Promise((resolve) => setTimeout(resolve, 1000));
// Force kill if still running
this.resources.backendProcess.kill("SIGKILL");
} catch (error) {
// Process might already be dead
}
}
// Kill cloudflared process
if (this.resources.cloudflaredProcess) {
this.resources.logger?.log(" Stopping cloudflared...");
try {
this.resources.cloudflaredProcess.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1000));
this.resources.cloudflaredProcess.kill("SIGKILL");
} catch (error) {
// Process might already be dead
}
}
// Kill PostgreSQL process
if (this.resources.dbProcess) {
this.resources.logger?.log(" Stopping PostgreSQL container...");
try {
this.resources.dbProcess.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1000));
this.resources.dbProcess.kill("SIGKILL");
this.resources.logger?.log(" ✓ PostgreSQL container stopped");
} catch (error) {
console.error(" Failed to stop PostgreSQL container:", error);
}
}
// Remove EE private repository clone
if (this.resources.eeWorktreePath) {
this.resources.logger?.log(" Removing EE private repository clone...");
try {
await execAsync(`rm -rf ${this.resources.eeWorktreePath}`);
this.resources.logger?.log(" ✓ EE private repository clone removed");
} catch (error) {
console.error(" Failed to remove EE private repository clone:", error);
}
}
// Release git worktree back to pool (do not delete it)
// Note: worktree might already be released if backend started successfully
if (this.resources.worktree) {
this.resources.logger?.log(" Releasing worktree back to pool...");
try {
await this.config.worktreePool.release(this.resources.worktree.id);
this.resources.logger?.log(" ✓ Worktree released for reuse");
} catch (error) {
console.error(" Failed to release worktree:", error);
}
} else {
this.resources.logger?.log(" ✓ Worktree already released");
}
this.config.onCleanup?.();
this.resources.logger?.log("✅ Cleanup complete");
}
}