Files
windmill/ephemeral-backends/manager.ts
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

688 lines
23 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "child_process";
import * as readline from "readline";
import sodium from "libsodium-wrappers-sumo";
import { EphemeralBackend } from "./spawn";
import { WorktreePool } from "./worktree-pool";
import { Logger } from "./logger";
import { readFileSync, existsSync } from "fs";
process.on("unhandledRejection", (err) => {
console.error("UNHANDLED PROMISE:", err);
});
process.on("uncaughtException", (err) => {
console.error("UNCAUGHT EXCEPTION:", err);
});
const githubToken = process.env.GITHUB_TOKEN;
if (!githubToken) {
console.log("⚠️ GITHUB_TOKEN environment variable not set");
console.log("\n📝 Set a GitHub token with 'secrets' scope:");
console.log(" export GITHUB_TOKEN=github_pat_...");
process.exit(1);
}
const managerAuthToken = process.env.MANAGER_AUTH_TOKEN;
if (!managerAuthToken) {
console.log("⚠️ MANAGER_AUTH_TOKEN environment variable not set");
console.log("\n📝 Set a secure random token for API authentication:");
console.log(" export MANAGER_AUTH_TOKEN=$(openssl rand -hex 32)");
process.exit(1);
}
if (!process.env.GIT_EE_DEPLOY_KEY_FILE) {
console.log("⚠️ GIT_EE_DEPLOY_KEY_FILE environment variable not set");
console.log("\n📝 Set a read-only SSH deploy key:");
console.log(" export GIT_EE_DEPLOY_KEY_FILE=/home/...");
process.exit(1);
}
const MANAGER_PORT = 8001;
const BACKEND_TIMEOUT_MS = 120 * 60 * 1000; // 2 hours in milliseconds
interface BackendInfo {
backend: EphemeralBackend;
timeoutId: NodeJS.Timeout;
createdAt: Date;
}
interface ManagerResources {
cloudflaredProcess?: any;
tunnelUrl?: string;
ephemeralBackends: Map<string, BackendInfo>;
worktreePool?: WorktreePool;
cleanupInterval?: NodeJS.Timeout;
}
class EphemeralBackendManager {
private resources: ManagerResources = {
ephemeralBackends: new Map(),
};
private server?: any;
async start(): Promise<void> {
// Setup cleanup handlers early
process.on("SIGINT", () => this.cleanup());
process.on("SIGTERM", () => this.cleanup());
try {
console.log("🎛️ Starting Ephemeral Backend Manager...");
console.log(`📊 Manager port: ${MANAGER_PORT}`);
// Initialize the worktree pool
this.resources.worktreePool = new WorktreePool();
await this.resources.worktreePool.initialize();
// Set up periodic log cleanup (every 6 hours)
this.resources.cleanupInterval = setInterval(() => {
console.log("\n🧹 Running periodic log cleanup...");
Logger.cleanupOldLogs();
}, 6 * 60 * 60 * 1000);
await this.startHttpServer();
if (!process.env.SKIP_CLOUDFLARED) await this.startCloudflared();
if (!process.env.SKIP_SET_GH_SECRET) await this.updateGitHubSecret();
console.log("\n✅ Manager is ready!");
console.log(`📍 Tunnel URL: ${this.resources.tunnelUrl}`);
console.log("\n💡 Press Ctrl+C to stop...");
// Keep the process running indefinitely
await new Promise(() => {}); // Never resolves
} catch (error) {
console.error("❌ Error starting manager:", error);
await this.cleanup();
process.exit(1);
}
}
private async startHttpServer(): Promise<void> {
const self = this;
console.log("\n🌐 Starting HTTP server...");
return new Promise((resolve) => {
// Use Bun's built-in HTTP server
this.server = Bun.serve({
port: MANAGER_PORT,
idleTimeout: 30,
async fetch(req) {
const url = new URL(req.url);
// CORS headers for app.windmill.dev
const origin = req.headers.get("origin");
const corsHeaders: Record<string, string> = {};
if (origin === "https://app.windmill.dev") {
corsHeaders["Access-Control-Allow-Origin"] = origin;
corsHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
corsHeaders["Access-Control-Allow-Headers"] =
"Content-Type, Authorization";
corsHeaders["Access-Control-Max-Age"] = "86400";
}
// Handle preflight requests
if (req.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: corsHeaders,
});
}
// Authentication check function
const checkAuth = (): boolean => {
const authHeader = req.headers.get("authorization");
return authHeader === `Bearer ${managerAuthToken}`;
};
// Unauthorized response helper
const unauthorizedResponse = () => {
return new Response(
JSON.stringify({
error: "Unauthorized",
message: "Valid Bearer token required in Authorization header",
}),
{
status: 401,
headers: {
...corsHeaders,
"Content-Type": "application/json",
"WWW-Authenticate": 'Bearer realm="Manager API"',
},
}
);
};
// Health check endpoint
if (url.pathname === "/health") {
return new Response(
JSON.stringify({
status: "ok",
timestamp: new Date().toISOString(),
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// Status endpoint - shows all running backends and worktree pool stats
if (url.pathname === "/status") {
if (!checkAuth()) {
return unauthorizedResponse();
}
const backends = Array.from(
self.resources.ephemeralBackends.entries()
).map(([commitHash, backendInfo]) => {
const now = new Date();
const timeoutAt = new Date(
backendInfo.createdAt.getTime() + BACKEND_TIMEOUT_MS
);
const timeRemainingMs = timeoutAt.getTime() - now.getTime();
const timeRemainingMinutes = Math.floor(
timeRemainingMs / 1000 / 60
);
return {
commitHash,
shortHash: commitHash.substring(0, 8),
createdAt: backendInfo.createdAt.toISOString(),
timeoutAt: timeoutAt.toISOString(),
timeRemainingMinutes,
serverPort: backendInfo.backend.getServerPort(),
dbPort: backendInfo.backend.getDbPort(),
};
});
const worktreePoolStats =
self.resources.worktreePool?.getStats() || {
total: 0,
inUse: 0,
available: 0,
};
return new Response(
JSON.stringify({
activeBackends: backends.length,
backends,
worktreePool: worktreePoolStats,
timestamp: new Date().toISOString(),
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// Match /logs/{commit_hash} - serve log files
const logsMatch = url.pathname.match(/^\/logs\/([a-f0-9]+)$/);
if (logsMatch && req.method === "GET") {
if (!checkAuth()) {
return unauthorizedResponse();
}
const commitHash = logsMatch[1];
// Validate commit hash format (7-40 hex characters)
if (commitHash.length < 7 || commitHash.length > 40) {
return new Response("Invalid commit hash", { status: 400 });
}
try {
// Use the Logger class's secure path resolution
const logFilePath = Logger.getLogFilePathForCommit(commitHash);
// Check if file exists
if (!existsSync(logFilePath)) {
return new Response("Log file not found", {
status: 404,
headers: corsHeaders,
});
}
// Read the log file
const logContent = readFileSync(logFilePath, "utf-8");
return new Response(logContent, {
headers: {
...corsHeaders,
"Content-Type": "text/plain; charset=utf-8",
"X-Commit-Hash": commitHash,
},
});
} catch (error: any) {
console.error(`Error reading log file for ${commitHash}:`, error);
return new Response(`Error reading log file: ${error.message}`, {
status: 500,
headers: corsHeaders,
});
}
}
// Match /spawn/{commit_hash}
const spawnMatch = url.pathname.match(/^\/spawn\/([a-f0-9]+)$/);
if (spawnMatch && req.method === "POST") {
if (!checkAuth()) {
return unauthorizedResponse();
}
let body = await req.json();
if (typeof body !== "object")
return new Response("Invalid JSON body", { status: 400 });
let resumeUrl = body?.resume_url;
if (typeof resumeUrl !== "string")
return new Response("Invalid resume_url", { status: 400 });
let cancelUrl = body?.cancel_url;
if (typeof cancelUrl !== "string")
return new Response("Invalid cancel_url", { status: 400 });
const commitHash = spawnMatch[1];
console.log(
`\n🔹 Received request to spawn ephemeral backend for commit: ${commitHash}`
);
if (self.resources.ephemeralBackends.has(commitHash)) {
throw new Error(`Backend ${commitHash} is already running`);
}
if (!self.resources.worktreePool) {
throw new Error("Worktree pool not initialized");
}
const tunnelUrl = await new Promise<string>((res, err) => {
const adminRandomPwd = Math.random()
.toString(36)
.substring(2, 15);
const ephemeralBackend = new EphemeralBackend({
dbPort: self.findFreeDbPorts(),
serverPort: self.findFreeServerPorts(),
skipBuild: !!process.env.SKIP_BACKEND_BUILD,
commitHash: commitHash,
worktreePool: self.resources.worktreePool!,
adminPwd: adminRandomPwd,
onCloudflaredUrl: (url) => (res(url), clearTimeout(timeout)),
onCleanup: () => {
const backendInfo =
self.resources.ephemeralBackends.get(commitHash);
if (backendInfo) {
clearTimeout(backendInfo.timeoutId);
self.resources.ephemeralBackends.delete(commitHash);
}
},
});
function onError(e: any) {
ephemeralBackend.cleanup().catch(() => {
console.error(
`Failed to cleanup backend for commit ${commitHash}`
);
});
clearTimeout(timeout);
fetch(resumeUrl, {
// Cancel URL doesn't show any relevant info, use resume URL
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "error",
commitHash,
error: e.message,
}),
}).catch((e) => {
console.error(
`Failed to notify cancel URL for commit ${commitHash}:`,
e
);
});
}
const timeout = setTimeout(() => {
onError(new Error("Timeout waiting for backend URL"));
}, 20000);
try {
ephemeralBackend
.spawn()
.then(({ tunnelUrl }) => {
if (resumeUrl) {
fetch(resumeUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "ready",
timeoutAt:
(self.resources.ephemeralBackends
.get(commitHash)
?.createdAt.getTime() ?? 0) + BACKEND_TIMEOUT_MS,
commitHash,
adminPassword: adminRandomPwd,
tunnelUrl,
}),
}).catch((e) => {
onError(e);
});
}
})
.catch((e) => onError(e));
// Set up 1-hour timeout for automatic cleanup
const cleanupTimeoutId = setTimeout(async () => {
console.log(
`\n⏰ Backend ${commitHash} has reached 1-hour timeout, cleaning up...`
);
try {
await ephemeralBackend.cleanup();
self.resources.ephemeralBackends.delete(commitHash);
console.log(
`✓ Backend ${commitHash} cleaned up after timeout`
);
} catch (error) {
console.error(
`❌ Failed to cleanup backend ${commitHash} after timeout:`,
error
);
}
}, BACKEND_TIMEOUT_MS);
self.resources.ephemeralBackends.set(commitHash, {
backend: ephemeralBackend,
timeoutId: cleanupTimeoutId,
createdAt: new Date(),
});
} catch (e) {
onError(e);
}
});
return new Response(
JSON.stringify({
tunnelUrl,
timestamp: new Date().toISOString(),
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 202,
}
);
}
// Default 404
return new Response("Not Found", {
status: 404,
headers: corsHeaders,
});
},
});
console.log(`✓ HTTP server listening on port ${MANAGER_PORT}`);
resolve();
});
}
private async startCloudflared(): Promise<void> {
console.log("\n🌐 Starting Cloudflare tunnel for manager...");
return new Promise((resolve, reject) => {
this.resources.cloudflaredProcess = spawn("cloudflared", [
"tunnel",
"--url",
`http://localhost:${MANAGER_PORT}`,
"--config",
"/dev/null",
]);
const rl = readline.createInterface({
input: this.resources.cloudflaredProcess.stdout,
});
rl.on("line", (line: string) => {
console.log(`[cloudflared] ${line}`);
});
this.resources.cloudflaredProcess.stderr.on("data", (data: Buffer) => {
process.stderr.write(`[cloudflared] ${data}`);
const line = data.toString();
const match = line.match(/https:\/\/([a-z0-9-]+\.trycloudflare\.com)/);
if (match) {
this.resources.tunnelUrl = match[1];
console.log(`✓ Tunnel URL extracted: ${this.resources.tunnelUrl}`);
resolve();
}
});
this.resources.cloudflaredProcess.on("close", (code: number) => {
console.log(`Cloudflared process exited with code ${code}`);
});
// Timeout if we can't find the URL in 30 seconds
setTimeout(() => {
if (!this.resources.tunnelUrl) {
reject(new Error("Failed to extract Cloudflare tunnel URL"));
}
}, 30000);
});
}
private async updateGitHubSecret(): Promise<void> {
console.log("\n🔐 Updating GitHub Actions secret...");
if (!this.resources.tunnelUrl) {
console.error("❌ No tunnel URL available to update secret");
return;
}
const fullUrl = `https://${this.resources.tunnelUrl}`;
const repo = "windmill-labs/windmill";
const secretName = "EPHEMERAL_BACKEND_QUEUE_URL";
try {
// First, get the repository public key for encrypting the secret
console.log(" Fetching repository public key...");
const keyResponse = await fetch(
`https://api.github.com/repos/${repo}/actions/secrets/public-key`,
{
headers: {
Authorization: `Bearer ${githubToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
);
if (!keyResponse.ok) {
throw new Error(
`Failed to fetch public key: ${keyResponse.statusText}`
);
}
const { key, key_id } = await keyResponse.json();
// Encrypt the secret using libsodium (via tweetnacl for Bun compatibility)
console.log(" Encrypting secret value...");
await sodium.ready;
const messageBytes = new TextEncoder().encode(fullUrl);
const keyBytes = sodium.from_base64(key, sodium.base64_variants.ORIGINAL);
const encryptedBytes = sodium.crypto_box_seal(messageBytes, keyBytes);
const encryptedValue = sodium.to_base64(
encryptedBytes,
sodium.base64_variants.ORIGINAL
);
// Update the secret
console.log(" Updating secret...");
const updateResponse = await fetch(
`https://api.github.com/repos/${repo}/actions/secrets/${secretName}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${githubToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
encrypted_value: encryptedValue,
key_id: key_id,
}),
}
);
if (!updateResponse.ok) {
const errorText = await updateResponse.text();
throw new Error(
`Failed to update secret: ${updateResponse.statusText} - ${errorText}`
);
}
console.log(`✓ GitHub secret updated successfully!`);
console.log(` Repository: ${repo}`);
console.log(` Secret: ${secretName}`);
console.log(` Value: ${fullUrl}`);
} catch (error: any) {
console.error("❌ Failed to update GitHub secret:", error.message);
}
}
private async deleteGitHubSecret(): Promise<void> {
const repo = "windmill-labs/windmill";
const secretName = "EPHEMERAL_BACKEND_QUEUE_URL";
try {
console.log(" Deleting GitHub Actions secret...");
const deleteResponse = await fetch(
`https://api.github.com/repos/${repo}/actions/secrets/${secretName}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${githubToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
);
if (!deleteResponse.ok) {
// 404 is acceptable - secret might not exist
if (deleteResponse.status === 404) {
console.log(
` ✓ Secret ${secretName} does not exist (already deleted)`
);
return;
}
const errorText = await deleteResponse.text();
throw new Error(
`Failed to delete secret: ${deleteResponse.statusText} - ${errorText}`
);
}
console.log(`✓ GitHub secret deleted successfully!`);
console.log(` Repository: ${repo}`);
console.log(` Secret: ${secretName}`);
} catch (error: any) {
console.error("❌ Failed to delete GitHub secret:", error.message);
}
}
isCleaningUp: boolean = false;
private async cleanup(): Promise<void> {
if (this.isCleaningUp) return;
this.isCleaningUp = true;
console.log("\n🧹 Cleaning up manager resources...");
// Stop periodic cleanup interval
if (this.resources.cleanupInterval) {
console.log(" Stopping periodic log cleanup...");
clearInterval(this.resources.cleanupInterval);
this.resources.cleanupInterval = undefined;
}
// Delete GitHub secret
if (!process.env.SKIP_SET_GH_SECRET) {
console.log(" Deleting GitHub Actions secret...");
try {
await this.deleteGitHubSecret();
} catch (error) {
console.error(" Failed to delete GitHub secret:", error);
}
}
// Stop HTTP server
if (this.server) {
console.log(" Stopping HTTP server...");
try {
this.server.stop();
} catch (error) {
console.error(" Failed to stop HTTP server:", error);
}
}
// Kill cloudflared process
if (this.resources.cloudflaredProcess) {
console.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
}
}
for (const [commitHash, backendInfo] of this.resources.ephemeralBackends) {
const hash = commitHash.substring(0, 8);
console.log(
` Cleaning up ephemeral backend ${hash} on port ${backendInfo.backend.getServerPort()}...`
);
try {
clearTimeout(backendInfo.timeoutId); // Clear the timeout before cleanup
await backendInfo.backend.cleanup();
} catch (error) {
console.error(
` Failed to clean up backend ${hash} on port ${backendInfo.backend.getServerPort()}:`,
error
);
}
}
console.log("✅ Cleanup complete");
process.exit(0);
}
private findFreeDbPorts(): number {
const minPort = 5433;
for (let port = minPort; port < minPort + 100; port++) {
if (
![...this.resources.ephemeralBackends.values()].some(
(backendInfo) => port === backendInfo.backend.getDbPort()
)
) {
return port;
}
}
throw new Error("No free DB ports available");
}
private findFreeServerPorts(): number {
const minPort = 8002;
for (let port = minPort; port < minPort + 100; port++) {
if (
![...this.resources.ephemeralBackends.values()].some(
(backendInfo) => port === backendInfo.backend.getServerPort()
)
) {
return port;
}
}
throw new Error("No free server ports available");
}
}
// Main execution
async function main() {
const manager = new EphemeralBackendManager();
await manager.start();
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});