Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
281 lines
8.0 KiB
TypeScript
281 lines
8.0 KiB
TypeScript
import { filterResponseHeaders, Method } from '../shared/gateway-utils';
|
|
|
|
export type RunnerBackendKind = 'knative' | 'docker';
|
|
|
|
export interface RunnerExecutePayload {
|
|
context: Record<string, unknown>;
|
|
http: {
|
|
method: Method;
|
|
path: string;
|
|
query: Record<string, string>;
|
|
headers: Record<string, string>;
|
|
body_b64?: string;
|
|
};
|
|
limits: { timeout_ms: number };
|
|
endpoint?: string;
|
|
providers?: unknown;
|
|
secret_envelope?: unknown;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface RunnerExecuteOptions {
|
|
requestId: string;
|
|
timeoutMs: number;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
export interface RunnerExecuteResult {
|
|
status: number;
|
|
headers: Record<string, string>;
|
|
body?: Buffer;
|
|
}
|
|
|
|
export interface RunnerFetchAssetOptions {
|
|
path: string;
|
|
search: string;
|
|
method: string;
|
|
headers: Headers;
|
|
}
|
|
|
|
export interface RunnerBackend {
|
|
readonly kind: RunnerBackendKind;
|
|
execute(payload: RunnerExecutePayload, options: RunnerExecuteOptions): Promise<RunnerExecuteResult>;
|
|
fetchStaticAsset(options: RunnerFetchAssetOptions): Promise<Response>;
|
|
getPublicBase(): string | null;
|
|
}
|
|
|
|
export class RunnerConfigError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'RunnerConfigError';
|
|
}
|
|
}
|
|
|
|
export class RunnerRequestError extends Error {
|
|
public readonly status?: number;
|
|
public readonly backend: RunnerBackendKind;
|
|
|
|
constructor(message: string, backend: RunnerBackendKind, status?: number) {
|
|
super(message);
|
|
this.name = 'RunnerRequestError';
|
|
this.backend = backend;
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
'connection',
|
|
'keep-alive',
|
|
'proxy-authenticate',
|
|
'proxy-authorization',
|
|
'te',
|
|
'trailer',
|
|
'transfer-encoding',
|
|
'upgrade',
|
|
]);
|
|
|
|
const STATIC_HEADER_ALLOWLIST = new Set([
|
|
'accept',
|
|
'accept-encoding',
|
|
'accept-language',
|
|
'if-modified-since',
|
|
'if-none-match',
|
|
'range',
|
|
'user-agent',
|
|
]);
|
|
|
|
class HttpRunnerBackend implements RunnerBackend {
|
|
public readonly kind: RunnerBackendKind;
|
|
private readonly baseUrl: string;
|
|
private readonly publicBase: string | null;
|
|
private readonly serviceToken: string | null;
|
|
|
|
constructor(kind: RunnerBackendKind, baseUrl: string, publicBase: string | null, serviceToken: string | null) {
|
|
this.kind = kind;
|
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
this.publicBase = publicBase ? trimTrailingSlashes(publicBase) : null;
|
|
this.serviceToken = serviceToken;
|
|
}
|
|
|
|
getPublicBase(): string | null {
|
|
return this.publicBase;
|
|
}
|
|
|
|
async execute(payload: RunnerExecutePayload, options: RunnerExecuteOptions): Promise<RunnerExecuteResult> {
|
|
const endpoint = `${this.baseUrl}/v1/execute`;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
|
|
const headers: Record<string, string> = {
|
|
'content-type': 'application/json',
|
|
'x-request-id': options.requestId,
|
|
};
|
|
|
|
if (this.serviceToken) {
|
|
headers['x-runner-service-token'] = this.serviceToken;
|
|
}
|
|
|
|
if (options.headers) {
|
|
for (const [key, value] of Object.entries(options.headers)) {
|
|
if (value !== undefined && value !== null) {
|
|
headers[key.toLowerCase()] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
let response: Response;
|
|
try {
|
|
console.log('[runner-backend] Fetching:', endpoint);
|
|
response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
signal: controller.signal,
|
|
cache: 'no-store', // Bypass Next.js fetch caching
|
|
} as RequestInit);
|
|
console.log('[runner-backend] Fetch succeeded:', response.status);
|
|
} catch (error: any) {
|
|
console.error('[runner-backend] Fetch error:', {
|
|
name: error?.name,
|
|
message: error?.message,
|
|
code: error?.code,
|
|
cause: error?.cause,
|
|
causeName: error?.cause?.name,
|
|
causeMessage: error?.cause?.message,
|
|
causeCode: error?.cause?.code,
|
|
causeErrno: error?.cause?.errno,
|
|
causeHostname: error?.cause?.hostname,
|
|
stack: error?.stack,
|
|
});
|
|
throw wrapFetchError(error, this.kind);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => undefined);
|
|
throw new RunnerRequestError(
|
|
`Runner responded with non-success status ${response.status}${text ? `: ${text}` : ''}`,
|
|
this.kind,
|
|
response.status,
|
|
);
|
|
}
|
|
|
|
const payloadJson: any = await response.json().catch((error) => {
|
|
throw new RunnerRequestError(`Runner returned invalid JSON: ${(error as Error).message}`, this.kind);
|
|
});
|
|
|
|
const status = typeof payloadJson?.status === 'number' ? payloadJson.status : 200;
|
|
const headersOut = filterResponseHeaders(payloadJson?.headers as Record<string, string | string[] | undefined> | undefined);
|
|
const body = typeof payloadJson?.body_b64 === 'string'
|
|
? Buffer.from(payloadJson.body_b64, 'base64')
|
|
: undefined;
|
|
|
|
return { status, headers: headersOut, body };
|
|
}
|
|
|
|
async fetchStaticAsset(options: RunnerFetchAssetOptions): Promise<Response> {
|
|
const url = buildUrlWithSearch(`${this.baseUrl}/${options.path}`.replace(/\/+$/, ''), options.search);
|
|
const headers = new Headers();
|
|
for (const [key, value] of options.headers.entries()) {
|
|
const lower = key.toLowerCase();
|
|
if (STATIC_HEADER_ALLOWLIST.has(lower)) {
|
|
headers.set(lower, value);
|
|
}
|
|
}
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method: options.method,
|
|
headers,
|
|
});
|
|
} catch (error) {
|
|
throw wrapFetchError(error, this.kind);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
}
|
|
|
|
function wrapFetchError(error: unknown, backend: RunnerBackendKind): Error {
|
|
if (error instanceof RunnerRequestError || error instanceof RunnerConfigError) {
|
|
return error;
|
|
}
|
|
if ((error as any)?.name === 'AbortError') {
|
|
const err = new RunnerRequestError('Runner request timed out', backend);
|
|
err.name = 'AbortError';
|
|
return err;
|
|
}
|
|
return new RunnerRequestError((error as Error)?.message ?? 'Runner request failed', backend);
|
|
}
|
|
|
|
function trimTrailingSlashes(value: string): string {
|
|
return value.replace(/\/+$/, '');
|
|
}
|
|
|
|
function buildUrlWithSearch(base: string, search: string): string {
|
|
if (!search) return base;
|
|
if (search.startsWith('?') || search.startsWith('&')) {
|
|
return `${base}${search}`;
|
|
}
|
|
return `${base}?${search}`;
|
|
}
|
|
|
|
let cachedBackend: RunnerBackend | null = null;
|
|
|
|
function resolveBackendKind(): RunnerBackendKind {
|
|
const raw = (process.env.RUNNER_BACKEND || 'knative').trim().toLowerCase();
|
|
return raw === 'docker' ? 'docker' : 'knative';
|
|
}
|
|
|
|
function resolveBaseUrl(kind: RunnerBackendKind): string {
|
|
const base = kind === 'docker'
|
|
? process.env.RUNNER_DOCKER_HOST || process.env.RUNNER_BASE_URL
|
|
: process.env.RUNNER_BASE_URL;
|
|
|
|
if (!base) {
|
|
throw new RunnerConfigError(`Runner base URL not configured for backend "${kind}"`);
|
|
}
|
|
return base.replace(/\/+$/, '');
|
|
}
|
|
|
|
function resolvePublicBase(): string | null {
|
|
const raw = process.env.RUNNER_PUBLIC_BASE;
|
|
if (!raw) return null;
|
|
return trimTrailingSlashes(raw);
|
|
}
|
|
|
|
function resolveServiceToken(): string | null {
|
|
return process.env.RUNNER_SERVICE_TOKEN || null;
|
|
}
|
|
|
|
function buildBackend(): RunnerBackend {
|
|
const kind = resolveBackendKind();
|
|
const base = resolveBaseUrl(kind);
|
|
const publicBase = resolvePublicBase();
|
|
const serviceToken = resolveServiceToken();
|
|
return new HttpRunnerBackend(kind, base, publicBase, serviceToken);
|
|
}
|
|
|
|
export function getRunnerBackend(): RunnerBackend {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
return buildBackend();
|
|
}
|
|
if (!cachedBackend) {
|
|
cachedBackend = buildBackend();
|
|
}
|
|
return cachedBackend;
|
|
}
|
|
|
|
export function filterHopByHopHeaders(headers: Headers): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
headers.forEach((value, key) => {
|
|
const lower = key.toLowerCase();
|
|
if (!HOP_BY_HOP_HEADERS.has(lower)) {
|
|
out[lower] = value;
|
|
}
|
|
});
|
|
return out;
|
|
}
|