1074 lines
44 KiB
JavaScript
1074 lines
44 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { createHash, createHmac } from "node:crypto";
|
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
createGithubSyncHandler,
|
|
createDurableInboxStore,
|
|
createDurableSyncController,
|
|
createSyncCoordinator,
|
|
readFailedDeliveryRecoveryStatus,
|
|
run,
|
|
runResult,
|
|
syncRepository,
|
|
} from "./platform-infra-gitea-github-sync-server.mjs";
|
|
|
|
const zeroDelay = async () => {};
|
|
|
|
test("primary status exposes a bounded redacted failed-delivery recovery ledger", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "gitea-hook-recovery-status-"));
|
|
const statePath = join(root, "ledger.json");
|
|
try {
|
|
const records = Object.fromEntries(Array.from({ length: 55 }, (_, index) => [`record-${index}`, {
|
|
repository: "pikasTech/agentrun",
|
|
hookId: 77,
|
|
deliveryId: `guid-${index}`,
|
|
apiDeliveryId: String(1000 + index),
|
|
state: index % 2 === 0 ? "attempted" : "pending",
|
|
attempts: index % 3,
|
|
cooldownMs: 300_000,
|
|
nextAt: "2026-07-12T07:11:40Z",
|
|
updatedAt: `2026-07-12T07:${String(index).padStart(2, "0")}:00Z`,
|
|
lastStatusCode: 502,
|
|
secret: "must-not-leak",
|
|
url: "https://must-not-leak.invalid/hook",
|
|
}]));
|
|
writeFileSync(statePath, JSON.stringify({ version: 1, kind: "GiteaGithubHookRecoveryLedger", records }));
|
|
const status = await readFailedDeliveryRecoveryStatus(statePath);
|
|
assert.equal(status.ready, true);
|
|
assert.equal(status.entries.length, 50);
|
|
assert.equal(status.counts.attempted + status.counts.pending, 55);
|
|
assert.equal(JSON.stringify(status).includes("must-not-leak"), false);
|
|
writeFileSync(statePath, JSON.stringify({
|
|
version: 1,
|
|
kind: "GiteaGithubHookRecoveryLedger",
|
|
records: { corrupt: { ...records["record-0"], state: "unknown-hidden-state" } },
|
|
}));
|
|
const invalid = await readFailedDeliveryRecoveryStatus(statePath);
|
|
assert.equal(invalid.ready, false);
|
|
assert.equal(invalid.state, "invalid-or-unreadable");
|
|
assert.equal(invalid.errorType, "hook-recovery-ledger-record-invalid");
|
|
assert.deepEqual(invalid.entries, []);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("explicit entrypoint starts through Kubernetes ConfigMap symlinks", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "gitea-sync-main-"));
|
|
try {
|
|
const dataDirectory = join(root, "..data");
|
|
const serverSource = join(dataDirectory, "server.mjs");
|
|
const entrypointSource = join(dataDirectory, "entrypoint.mjs");
|
|
const marker = join(root, "started");
|
|
mkdirSync(dataDirectory);
|
|
writeFileSync(serverSource, `import { writeFileSync } from "node:fs";\nexport function startServer() { writeFileSync(${JSON.stringify(marker)}, "started\\n"); }\n`, { flag: "wx" });
|
|
writeFileSync(entrypointSource, 'import { startServer } from "./server.mjs";\nstartServer();\n', { flag: "wx" });
|
|
symlinkSync(join("..data", "server.mjs"), join(root, "server.mjs"));
|
|
symlinkSync(join("..data", "entrypoint.mjs"), join(root, "entrypoint.mjs"));
|
|
|
|
execFileSync(process.execPath, [join(root, "entrypoint.mjs")], { stdio: "pipe" });
|
|
assert.equal(existsSync(marker), true);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("HTTP 202 waits for durable acceptance but does not claim refs are committed", async () => {
|
|
const repo = fakeRepo("one");
|
|
const logs = [];
|
|
let release;
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
let mode = "accept";
|
|
const controller = {
|
|
readiness: () => ({ ready: true }),
|
|
status: async () => ({ ready: true, counts: {}, deliveries: [] }),
|
|
async accept() {
|
|
if (mode === "accept") {
|
|
await gate;
|
|
return { state: "accepted", duplicate: false };
|
|
}
|
|
throw new Error("Authorization: Basic c2VjcmV0");
|
|
},
|
|
};
|
|
const server = createServer(createGithubSyncHandler({
|
|
path: "/hook",
|
|
responseBudgetMs: 1000,
|
|
maxBodyBytes: 4096,
|
|
webhookSecret: "test-secret",
|
|
repos: [repo],
|
|
controller,
|
|
log: (event) => logs.push(event),
|
|
}));
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = server.address().port;
|
|
try {
|
|
const pending = postWebhook(port, repo, "http-success", "a".repeat(40), "test-secret");
|
|
const beforeCommit = await Promise.race([pending.then(() => "responded"), new Promise((resolve) => setTimeout(() => resolve("waiting"), 30))]);
|
|
assert.equal(beforeCommit, "waiting");
|
|
release();
|
|
const accepted = await pending;
|
|
assert.equal(accepted.status, 202);
|
|
assert.deepEqual(await accepted.json(), {
|
|
ok: true,
|
|
event: "push",
|
|
deliveryId: "http-success",
|
|
repository: repo.upstream.repository,
|
|
ref: "refs/heads/main",
|
|
repo: repo.key,
|
|
requestedCommit: "a".repeat(40),
|
|
state: "accepted",
|
|
disposition: "durable-inbox-accepted",
|
|
refsCommitted: false,
|
|
valuesPrinted: false,
|
|
});
|
|
|
|
mode = "throw";
|
|
const unexpected = await postWebhook(port, repo, "http-throw", "d".repeat(40), "test-secret");
|
|
assert.equal(unexpected.status, 503);
|
|
assert.equal((await unexpected.json()).error, "internal-sync-error");
|
|
const rejected = logs.at(-1);
|
|
assert.deepEqual(
|
|
[rejected.deliveryId, rejected.repository, rejected.ref, rejected.requestedCommit],
|
|
["http-throw", repo.upstream.repository, "refs/heads/main", "d".repeat(40)],
|
|
);
|
|
assert.match(rejected.error, /<redacted>/);
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test("duplicate HTTP 202 reports the durable accepted, processing, failed or committed state truthfully", async () => {
|
|
const repo = fakeRepo("duplicate-state");
|
|
const committedResult = {
|
|
sourceCommit: "c".repeat(40),
|
|
authorityCommit: "c".repeat(40),
|
|
snapshotRef: `refs/snapshots/source/${"c".repeat(40)}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
};
|
|
let durable = { state: "accepted", duplicate: true, result: null };
|
|
const controller = {
|
|
readiness: () => ({ ready: true }),
|
|
status: async () => ({ ready: true, counts: {}, deliveries: [] }),
|
|
async accept() { return durable; },
|
|
};
|
|
const server = createServer(createGithubSyncHandler({
|
|
path: "/hook",
|
|
responseBudgetMs: 1000,
|
|
maxBodyBytes: 4096,
|
|
webhookSecret: "test-secret",
|
|
repos: [repo],
|
|
controller,
|
|
log: () => {},
|
|
}));
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = server.address().port;
|
|
try {
|
|
for (const state of ["accepted", "processing", "failed", "committed"]) {
|
|
durable = { state, duplicate: true, result: state === "committed" ? committedResult : null };
|
|
const response = await postWebhook(port, repo, `duplicate-${state}`, "c".repeat(40), "test-secret");
|
|
assert.equal(response.status, 202);
|
|
const body = await response.json();
|
|
assert.equal(body.state, state);
|
|
assert.equal(body.disposition, "duplicate-durable-inbox-record");
|
|
assert.equal(body.refsCommitted, state === "committed");
|
|
if (state === "committed") assert.deepEqual(body.result, committedResult);
|
|
else assert.equal(Object.hasOwn(body, "result"), false);
|
|
}
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test("durable acceptance response budget returns typed 503 while a late write continues and remains idempotent", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-acceptance-budget-"));
|
|
const repo = fakeRepo("acceptance-budget");
|
|
let releaseFirstAccept;
|
|
const firstAcceptGate = new Promise((resolve) => { releaseFirstAccept = resolve; });
|
|
let releaseSync;
|
|
const syncGate = new Promise((resolve) => { releaseSync = resolve; });
|
|
const logs = [];
|
|
const durableController = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
sync: async (_repo, delivery) => {
|
|
await syncGate;
|
|
return {
|
|
ok: true,
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
};
|
|
},
|
|
}));
|
|
let acceptCalls = 0;
|
|
const delayedController = {
|
|
readiness: () => durableController.readiness(),
|
|
status: () => durableController.status(),
|
|
async accept(repoValue, delivery) {
|
|
acceptCalls += 1;
|
|
if (acceptCalls === 1) await firstAcceptGate;
|
|
return await durableController.accept(repoValue, delivery);
|
|
},
|
|
};
|
|
const server = createServer(createGithubSyncHandler({
|
|
path: "/hook",
|
|
responseBudgetMs: 40,
|
|
maxBodyBytes: 4096,
|
|
webhookSecret: "test-secret",
|
|
repos: [repo],
|
|
controller: delayedController,
|
|
log: (event) => logs.push(event),
|
|
}));
|
|
try {
|
|
await durableController.start();
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = server.address().port;
|
|
const commit = "7".repeat(40);
|
|
const startedAt = performance.now();
|
|
const timedOut = await postWebhook(port, repo, "late-durable-write", commit, "test-secret", { marker: "late" });
|
|
const elapsedMs = performance.now() - startedAt;
|
|
assert.equal(timedOut.status, 503);
|
|
assert.equal((await timedOut.json()).error, "durable-acceptance-response-deadline-exhausted");
|
|
assert.ok(elapsedMs < 200, `durable acceptance deadline responded after ${elapsedMs}ms`);
|
|
assert.equal((await durableController.status()).deliveries.length, 0);
|
|
|
|
releaseFirstAccept();
|
|
await waitFor(async () => (await durableController.status()).deliveries.length === 1);
|
|
const duplicate = await postWebhook(port, repo, "late-durable-write", commit, "test-secret", { marker: "late" });
|
|
assert.equal(duplicate.status, 202);
|
|
const duplicateBody = await duplicate.json();
|
|
assert.equal(duplicateBody.disposition, "duplicate-durable-inbox-record");
|
|
assert.equal((await durableController.status()).deliveries.length, 1);
|
|
assert.equal(logs.some((event) => event.event === "github-to-gitea-delivery-accepted-after-response-deadline"), true);
|
|
|
|
releaseSync();
|
|
await waitFor(async () => (await durableController.status()).counts.committed === 1);
|
|
const committedDuplicate = await postWebhook(port, repo, "late-durable-write", commit, "test-secret", { marker: "late" });
|
|
const committedBody = await committedDuplicate.json();
|
|
assert.equal(committedBody.state, "committed");
|
|
assert.equal(committedBody.refsCommitted, true);
|
|
assert.equal(committedBody.result.sourceCommit, commit);
|
|
} finally {
|
|
server.close();
|
|
await durableController.stop();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("durable inbox fsync acceptance is body-sensitive, fast, and later becomes committed", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-http-"));
|
|
const repo = fakeRepo("durable");
|
|
let releaseSync;
|
|
const syncGate = new Promise((resolve) => { releaseSync = resolve; });
|
|
let syncStarted = false;
|
|
const controller = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
sync: async (_repo, delivery) => {
|
|
syncStarted = true;
|
|
await syncGate;
|
|
return {
|
|
ok: true,
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
};
|
|
},
|
|
}));
|
|
const server = createServer(createGithubSyncHandler({
|
|
path: "/hook",
|
|
responseBudgetMs: 1000,
|
|
maxBodyBytes: 4096,
|
|
webhookSecret: "test-secret",
|
|
repos: [repo],
|
|
controller,
|
|
log: () => {},
|
|
}));
|
|
try {
|
|
await controller.start();
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = server.address().port;
|
|
const commit = "a".repeat(40);
|
|
const startedAt = performance.now();
|
|
const accepted = await postWebhook(port, repo, "durable-delivery", commit, "test-secret", { marker: "one" });
|
|
const elapsedMs = performance.now() - startedAt;
|
|
assert.equal(accepted.status, 202);
|
|
assert.ok(elapsedMs < 300, `fsync acceptance took ${elapsedMs}ms`);
|
|
assert.equal((await accepted.json()).refsCommitted, false);
|
|
await waitFor(() => syncStarted);
|
|
|
|
const duplicate = await postWebhook(port, repo, "durable-delivery", commit, "test-secret", { marker: "one" });
|
|
assert.equal(duplicate.status, 202);
|
|
assert.match((await duplicate.json()).disposition, /duplicate-durable-inbox-record/);
|
|
|
|
const bodyConflict = await postWebhook(port, repo, "durable-delivery", commit, "test-secret", { marker: "two" });
|
|
assert.equal(bodyConflict.status, 409);
|
|
|
|
releaseSync();
|
|
await waitFor(async () => (await controller.status()).deliveries.some((record) => record.deliveryId === "durable-delivery" && record.state === "committed"));
|
|
const committed = (await controller.status()).deliveries.find((record) => record.deliveryId === "durable-delivery");
|
|
assert.equal(committed.result.sourceCommit, commit);
|
|
assert.equal(committed.result.authorityCommit, commit);
|
|
assert.equal(committed.result.snapshotRef, `refs/snapshots/source/${commit}`);
|
|
} finally {
|
|
server.close();
|
|
await controller.stop();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("startup rejects corrupt journal records and recovers a crash-left processing record", async () => {
|
|
const corruptRoot = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-corrupt-"));
|
|
const corruptInbox = join(corruptRoot, "inbox");
|
|
const journalCreatedAt = Date.parse("2026-07-11T07:00:00.000Z");
|
|
const corruptStore = createDurableInboxStore({ path: corruptInbox, maxBytes: 1024 * 1024, now: () => journalCreatedAt });
|
|
await corruptStore.initialize();
|
|
assert.equal(corruptStore.journalMetadata().createdAt, new Date(journalCreatedAt).toISOString());
|
|
const restartedStore = createDurableInboxStore({ path: corruptInbox, maxBytes: 1024 * 1024, now: () => journalCreatedAt + 60_000 });
|
|
await restartedStore.initialize();
|
|
assert.equal(restartedStore.journalMetadata().createdAt, new Date(journalCreatedAt).toISOString());
|
|
writeFileSync(join(corruptInbox, `${"a".repeat(64)}.json`), JSON.stringify({ version: 1, deliveryId: "corrupt", fingerprint: "b".repeat(64), state: "accepted" }));
|
|
const corruptController = createDurableSyncController(durableControllerOptions(corruptRoot, [fakeRepo("corrupt")]));
|
|
try {
|
|
await corruptController.start();
|
|
assert.equal(corruptController.readiness().ready, false);
|
|
assert.equal(corruptController.readiness().errorType, "durable-inbox-record-invalid");
|
|
} finally {
|
|
await corruptController.stop();
|
|
rmSync(corruptRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
const recoveryRoot = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-recovery-"));
|
|
const repo = fakeRepo("recovery");
|
|
const store = createDurableInboxStore({ path: join(recoveryRoot, "inbox"), maxBytes: 1024 * 1024 });
|
|
try {
|
|
await store.initialize();
|
|
const delivery = fakeDelivery("processing-before-crash", "b".repeat(40), repo);
|
|
await store.accept(repo, delivery);
|
|
await assert.rejects(
|
|
() => store.update(delivery.deliveryId, (record) => ({ ...record, acceptedSequence: record.acceptedSequence + 1 })),
|
|
/invalid-record-update/,
|
|
);
|
|
await store.update(delivery.deliveryId, (record) => ({
|
|
...record,
|
|
state: "processing",
|
|
processingAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
totalAttempts: 1,
|
|
cycleAttempt: 1,
|
|
}));
|
|
const controller = createDurableSyncController(durableControllerOptions(recoveryRoot, [repo], {
|
|
sync: async () => ({
|
|
ok: true,
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
}),
|
|
}));
|
|
await controller.start();
|
|
await waitFor(async () => (await controller.status()).deliveries.some((record) => record.deliveryId === delivery.deliveryId && record.state === "committed"));
|
|
await controller.stop();
|
|
} finally {
|
|
rmSync(recoveryRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("unavailable or full durable inbox is not ready and fails closed with 503", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-unavailable-"));
|
|
const blockingFile = join(root, "not-a-directory");
|
|
writeFileSync(blockingFile, "blocked");
|
|
const repo = fakeRepo("capacity");
|
|
const unavailable = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
inbox: { path: join(blockingFile, "inbox"), maxBytes: 1024 },
|
|
}));
|
|
await unavailable.start();
|
|
assert.equal(unavailable.readiness().ready, false);
|
|
await assert.rejects(() => unavailable.accept(repo, fakeDelivery("unavailable", "a".repeat(40), repo)), /durable-inbox/);
|
|
await unavailable.stop();
|
|
|
|
const full = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
inbox: { path: join(root, "full-inbox"), maxBytes: 1 },
|
|
}));
|
|
try {
|
|
await full.start();
|
|
await assert.rejects(() => full.accept(repo, fakeDelivery("full", "b".repeat(40), repo)), /capacity-exhausted/);
|
|
assert.equal(full.readiness().ready, false);
|
|
assert.equal(full.readiness().errorType, "durable-inbox-capacity-exhausted");
|
|
} finally {
|
|
await full.stop();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("retention keeps each repository latest committed watermark and status keeps every repo latest record", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-retention-"));
|
|
const store = createDurableInboxStore({ path: join(root, "inbox"), maxBytes: 4 * 1024 * 1024 });
|
|
try {
|
|
await store.initialize();
|
|
const old = new Date(Date.now() - 10_000).toISOString();
|
|
for (let index = 0; index < 105; index += 1) {
|
|
const repo = fakeRepo(index < 2 ? "quiet" : `noise-${index}`);
|
|
const delivery = { ...fakeDelivery(`retained-${index}`, index.toString(16).padStart(40, "0"), repo), receivedAt: old };
|
|
await store.accept(repo, delivery);
|
|
await store.update(delivery.deliveryId, (record) => ({
|
|
...record,
|
|
state: "committed",
|
|
updatedAt: old,
|
|
committedAt: old,
|
|
nextAttemptAt: null,
|
|
result: {
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
},
|
|
}));
|
|
}
|
|
const removed = await store.cleanupCommitted(Date.now());
|
|
assert.equal(removed, 1);
|
|
const retained = await store.list();
|
|
assert.equal(retained.filter((record) => record.repoKey === "quiet").length, 1);
|
|
assert.equal(retained.find((record) => record.repoKey === "quiet").deliveryId, "retained-1");
|
|
|
|
const quietRepo = fakeRepo("quiet");
|
|
const failedDelivery = fakeDelivery("quiet-failed", "f".repeat(40), quietRepo);
|
|
await store.accept(quietRepo, failedDelivery);
|
|
await store.update(failedDelivery.deliveryId, (record) => ({
|
|
...record,
|
|
state: "failed",
|
|
updatedAt: new Date().toISOString(),
|
|
failedAt: new Date().toISOString(),
|
|
nextAttemptAt: "2099-01-01T00:00:00.000Z",
|
|
lastError: { errorType: "test-failure", retryable: true, terminal: true },
|
|
}));
|
|
|
|
const controller = createDurableSyncController(durableControllerOptions(root, [...retained.map((record) => fakeRepo(record.repoKey)), quietRepo], { store }));
|
|
await controller.start();
|
|
const status = await controller.status();
|
|
assert.ok(status.deliveries.some((record) => record.repo === "quiet" && record.deliveryId === "retained-1"));
|
|
assert.ok(status.deliveries.some((record) => record.repo === "quiet" && record.deliveryId === "quiet-failed"));
|
|
await controller.stop();
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("same-millisecond records are listed and scheduled by immutable acceptedSequence", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-same-millisecond-order-"));
|
|
const repo = fakeRepo("same-millisecond");
|
|
const store = createDurableInboxStore({ path: join(root, "inbox"), maxBytes: 1024 * 1024 });
|
|
const receivedAt = "2026-07-11T08:00:00.000Z";
|
|
const first = { ...fakeDelivery("same-ms-first", "1".repeat(40), repo), receivedAt };
|
|
const second = { ...fakeDelivery("same-ms-second", "2".repeat(40), repo), receivedAt };
|
|
const executionOrder = [];
|
|
let controller;
|
|
try {
|
|
await store.initialize();
|
|
await store.accept(repo, first);
|
|
await store.accept(repo, second);
|
|
const listed = await store.list();
|
|
assert.deepEqual(listed.map((record) => [record.deliveryId, record.acceptedSequence]), [
|
|
[first.deliveryId, 1],
|
|
[second.deliveryId, 2],
|
|
]);
|
|
controller = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
sync: async (_repo, delivery) => {
|
|
executionOrder.push(delivery.deliveryId);
|
|
return {
|
|
ok: true,
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
};
|
|
},
|
|
}));
|
|
await controller.start();
|
|
await waitFor(async () => (await controller.status()).counts.committed === 2);
|
|
assert.deepEqual(executionOrder, [first.deliveryId, second.deliveryId]);
|
|
} finally {
|
|
if (controller) await controller.stop();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("concurrent duplicate delivery shares one promise and payload conflict fails closed", async () => {
|
|
let release;
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
let calls = 0;
|
|
const coordinator = createSyncCoordinator({
|
|
responseBudgetMs: 1000,
|
|
retryMaxAttempts: 2,
|
|
retryInitialDelayMs: 1,
|
|
retryMaxDelayMs: 1,
|
|
sleep: zeroDelay,
|
|
log: () => {},
|
|
sync: async (_repo, delivery) => {
|
|
calls += 1;
|
|
await gate;
|
|
return { ok: true, sourceCommit: delivery.requestedCommit, authorityCommit: delivery.requestedCommit, snapshotRef: `refs/snapshots/${delivery.requestedCommit}`, disposition: "atomic-source-authority-committed" };
|
|
},
|
|
});
|
|
const repo = fakeRepo("one");
|
|
const delivery = fakeDelivery("delivery-1", "a".repeat(40), repo);
|
|
const first = coordinator.execute(repo, delivery);
|
|
const duplicate = coordinator.execute(repo, { ...delivery });
|
|
assert.throws(
|
|
() => coordinator.execute(repo, { ...delivery, requestedCommit: "b".repeat(40) }),
|
|
/github-delivery-id-payload-conflict/,
|
|
);
|
|
release();
|
|
const [firstResult, duplicateResult] = await Promise.all([first, duplicate]);
|
|
assert.equal(calls, 1);
|
|
assert.equal(firstResult.ok, true);
|
|
assert.equal(duplicateResult.duplicate, true);
|
|
});
|
|
|
|
test("different repositories are isolated while each repository stays serialized", async () => {
|
|
let releaseOne;
|
|
const gateOne = new Promise((resolve) => { releaseOne = resolve; });
|
|
const order = [];
|
|
const coordinator = createSyncCoordinator({
|
|
responseBudgetMs: 1000,
|
|
retryMaxAttempts: 1,
|
|
retryInitialDelayMs: 1,
|
|
retryMaxDelayMs: 1,
|
|
sleep: zeroDelay,
|
|
log: () => {},
|
|
sync: async (repo, delivery) => {
|
|
order.push(`start:${repo.key}:${delivery.deliveryId}`);
|
|
if (repo.key === "one") await gateOne;
|
|
order.push(`end:${repo.key}:${delivery.deliveryId}`);
|
|
return { ok: true, sourceCommit: delivery.requestedCommit, authorityCommit: delivery.requestedCommit, snapshotRef: `refs/snapshots/${delivery.requestedCommit}`, disposition: "atomic-source-authority-committed" };
|
|
},
|
|
});
|
|
const repoOne = fakeRepo("one");
|
|
const repoTwo = fakeRepo("two");
|
|
const oneA = coordinator.execute(repoOne, fakeDelivery("one-a", "a".repeat(40), repoOne));
|
|
const oneB = coordinator.execute(repoOne, fakeDelivery("one-b", "b".repeat(40), repoOne));
|
|
const two = coordinator.execute(repoTwo, fakeDelivery("two-a", "c".repeat(40), repoTwo));
|
|
await two;
|
|
assert.deepEqual(order.slice(0, 3), ["start:one:one-a", "start:two:two-a", "end:two:two-a"]);
|
|
releaseOne();
|
|
await Promise.all([oneA, oneB]);
|
|
assert.ok(order.indexOf("start:one:one-b") > order.indexOf("end:one:one-a"));
|
|
});
|
|
|
|
test("retry exhaustion is terminal and observable", async () => {
|
|
const logs = [];
|
|
let calls = 0;
|
|
const coordinator = createSyncCoordinator({
|
|
responseBudgetMs: 1000,
|
|
retryMaxAttempts: 3,
|
|
retryInitialDelayMs: 1,
|
|
retryMaxDelayMs: 1,
|
|
sleep: zeroDelay,
|
|
log: (event) => logs.push(event),
|
|
sync: async () => {
|
|
calls += 1;
|
|
return { ok: false, retryable: true, errorType: "git-source-sync-failed" };
|
|
},
|
|
});
|
|
const repo = fakeRepo("one");
|
|
const result = await coordinator.execute(repo, fakeDelivery("delivery-fail", "a".repeat(40), repo));
|
|
assert.equal(result.ok, false);
|
|
assert.equal(calls, 3);
|
|
assert.deepEqual(logs.filter((event) => event.event === "github-to-gitea-sync").map((event) => [event.syncAttempt, event.willRetry, event.terminal]), [
|
|
[1, true, false],
|
|
[2, true, false],
|
|
[3, false, true],
|
|
]);
|
|
});
|
|
|
|
test("durable worker automatically starts a new cycle after terminal retry exhaustion", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-terminal-retry-"));
|
|
const repo = fakeRepo("terminal-retry");
|
|
const logs = [];
|
|
let calls = 0;
|
|
const controller = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
log: (event) => logs.push(event),
|
|
workerRetry: {
|
|
maxAttempts: 2,
|
|
initialDelayMs: 1,
|
|
maxDelayMs: 1,
|
|
terminalRetryDelayMs: 5,
|
|
scanIntervalMs: 1,
|
|
},
|
|
sync: async (_repo, delivery) => {
|
|
calls += 1;
|
|
if (calls < 3) return { ok: false, retryable: true, errorType: "transient-source-sync-failed" };
|
|
return {
|
|
ok: true,
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
};
|
|
},
|
|
}));
|
|
try {
|
|
await controller.start();
|
|
const delivery = fakeDelivery("terminal-retry-delivery", "a".repeat(40), repo);
|
|
await controller.accept(repo, delivery);
|
|
await waitFor(async () => (await controller.status()).deliveries.some((record) => record.deliveryId === delivery.deliveryId && record.state === "committed"));
|
|
const committed = (await controller.status()).deliveries.find((record) => record.deliveryId === delivery.deliveryId);
|
|
assert.equal(calls, 3);
|
|
assert.equal(committed.totalAttempts, 3);
|
|
assert.equal(logs.some((event) => event.event === "github-to-gitea-delivery-failed" && event.terminal === true && event.automaticRetryScheduled === true), true);
|
|
} finally {
|
|
await controller.stop();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("unexpected executor rejection is durably failed and automatically retries without restart", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-executor-rejection-"));
|
|
const repo = fakeRepo("executor-rejection");
|
|
const logs = [];
|
|
let calls = 0;
|
|
const controller = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
log: (event) => logs.push(event),
|
|
workerRetry: { initialDelayMs: 1, maxDelayMs: 1, scanIntervalMs: 1 },
|
|
executor: {
|
|
async execute(_repo, delivery) {
|
|
calls += 1;
|
|
if (calls === 1) throw new Error("unexpected executor rejection");
|
|
return {
|
|
ok: true,
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
};
|
|
},
|
|
},
|
|
}));
|
|
try {
|
|
await controller.start();
|
|
const delivery = fakeDelivery("executor-rejection-delivery", "8".repeat(40), repo);
|
|
await controller.accept(repo, delivery);
|
|
await waitFor(async () => (await controller.status()).counts.committed === 1);
|
|
const committed = (await controller.status()).deliveries[0];
|
|
assert.equal(calls, 2);
|
|
assert.equal(committed.state, "committed");
|
|
assert.equal(committed.totalAttempts, 2);
|
|
assert.equal(logs.some((event) => event.event === "github-to-gitea-delivery-failed" && event.errorType === "source-sync-executor-threw" && event.automaticRetryScheduled === true), true);
|
|
} finally {
|
|
await controller.stop();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("undefined and incomplete successful executor results are durably failed before retry", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-malformed-executor-result-"));
|
|
const repo = fakeRepo("malformed-executor-result");
|
|
const logs = [];
|
|
let calls = 0;
|
|
const controller = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
log: (event) => logs.push(event),
|
|
workerRetry: { maxAttempts: 3, initialDelayMs: 1, maxDelayMs: 1, scanIntervalMs: 1 },
|
|
executor: {
|
|
async execute(_repo, delivery) {
|
|
calls += 1;
|
|
if (calls === 1) return undefined;
|
|
if (calls === 2) return { ok: true };
|
|
return {
|
|
ok: true,
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
};
|
|
},
|
|
},
|
|
}));
|
|
try {
|
|
await controller.start();
|
|
const delivery = fakeDelivery("malformed-executor-result-delivery", "9".repeat(40), repo);
|
|
await controller.accept(repo, delivery);
|
|
await waitFor(async () => (await controller.status()).counts.committed === 1);
|
|
const committed = (await controller.status()).deliveries[0];
|
|
assert.equal(calls, 3);
|
|
assert.equal(committed.totalAttempts, 3);
|
|
assert.equal(logs.filter((event) => event.event === "github-to-gitea-delivery-failed" && event.errorType === "source-sync-executor-invalid-result").length, 2);
|
|
} finally {
|
|
await controller.stop();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("slow sync cannot return success after the absolute response deadline", async () => {
|
|
const coordinator = createSyncCoordinator({
|
|
responseBudgetMs: 20,
|
|
retryMaxAttempts: 1,
|
|
retryInitialDelayMs: 1,
|
|
retryMaxDelayMs: 1,
|
|
sleep: zeroDelay,
|
|
log: () => {},
|
|
sync: async (_repo, delivery) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 35));
|
|
return { ok: true, sourceCommit: delivery.requestedCommit, authorityCommit: delivery.requestedCommit, snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, disposition: "atomic-source-authority-committed" };
|
|
},
|
|
});
|
|
const repo = fakeRepo("deadline");
|
|
const result = await coordinator.execute(repo, fakeDelivery("slow", "e".repeat(40), repo));
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.retryable, true);
|
|
assert.equal(result.errorType, "response-deadline-exhausted");
|
|
assert.equal(result.committedBeforeDeadlineUnknown, true);
|
|
});
|
|
|
|
test("git subprocesses fail closed before launch when the shared deadline is exhausted", async () => {
|
|
let commands = 0;
|
|
const repo = {
|
|
...fakeRepo("expired"),
|
|
upstream: { ...fakeRepo("expired").upstream, cloneUrl: "/does/not/matter" },
|
|
gitea: { readUrl: "file:///does/not/matter" },
|
|
};
|
|
const result = await syncRepository(repo, fakeDelivery("expired", "f".repeat(40), repo), {
|
|
deadlineAt: 10,
|
|
now: () => 10,
|
|
runResult: () => {
|
|
commands += 1;
|
|
return { status: 0, stdout: "", stderr: "" };
|
|
},
|
|
githubAuthHeader: "",
|
|
giteaAuthHeader: "",
|
|
giteaBaseUrl: "file://",
|
|
});
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.retryable, true);
|
|
assert.equal(commands, 0);
|
|
assert.match(result.error, /response deadline exhausted/);
|
|
});
|
|
|
|
test("async process runner handles spawn errors and kills the whole subprocess group at deadline", async () => {
|
|
const missing = await runResult(["unidesk-command-that-does-not-exist"], 100);
|
|
assert.equal(missing.status, null);
|
|
assert.match(missing.stderr, /ENOENT|Executable not found/u);
|
|
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-process-tree-"));
|
|
const marker = join(root, "child-survived");
|
|
try {
|
|
const timedOut = await runResult(["sh", "-c", '(sleep 0.25; : > "$1") & wait', "sh", marker], 40);
|
|
assert.equal(timedOut.status, null);
|
|
assert.match(timedOut.stderr, /deadline exceeded/);
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
assert.equal(existsSync(marker), false);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("controller shutdown aborts the active process tree and the durable record resumes after restart", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-controller-shutdown-"));
|
|
const startedMarker = join(root, "worker-started");
|
|
const survivorMarker = join(root, "child-survived");
|
|
const repo = fakeRepo("shutdown");
|
|
const delivery = fakeDelivery("shutdown-delivery", "c".repeat(40), repo);
|
|
const controller = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
shutdownGraceMs: 500,
|
|
sync: async (_repo, _delivery, context) => {
|
|
const result = await runResult([
|
|
"sh", "-c", ': > "$1"; (sleep 0.25; : > "$2") & wait', "sh", startedMarker, survivorMarker,
|
|
], 10_000, context.signal);
|
|
return { ok: false, retryable: true, errorType: result.stderr.includes("aborted") ? "worker-aborted" : "worker-exited" };
|
|
},
|
|
}));
|
|
try {
|
|
await controller.start();
|
|
await controller.accept(repo, delivery);
|
|
await waitFor(() => existsSync(startedMarker));
|
|
await controller.stop();
|
|
await new Promise((resolve) => setTimeout(resolve, 320));
|
|
assert.equal(existsSync(survivorMarker), false);
|
|
|
|
const resumed = createDurableSyncController(durableControllerOptions(root, [repo], {
|
|
sync: async () => ({
|
|
ok: true,
|
|
sourceCommit: delivery.requestedCommit,
|
|
authorityCommit: delivery.requestedCommit,
|
|
snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`,
|
|
disposition: "atomic-source-authority-committed",
|
|
}),
|
|
}));
|
|
await resumed.start();
|
|
await waitFor(async () => (await resumed.status()).deliveries.some((record) => record.deliveryId === delivery.deliveryId && record.state === "committed"));
|
|
await resumed.stop();
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("atomic mirror handles partial refs, immutable snapshots, out-of-order delivery and lease race", async () => {
|
|
const fixture = gitFixture();
|
|
try {
|
|
const { repo, runtime, commits, gitea } = fixture;
|
|
const first = await syncRepository(repo, fakeDelivery("a", commits.a, repo), runtime);
|
|
assert.equal(first.ok, true);
|
|
assert.equal(remoteRef(gitea, "refs/heads/main"), commits.a);
|
|
assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.a}`), commits.a);
|
|
|
|
git("--git-dir", gitea, "update-ref", `refs/snapshots/source/${commits.a}`, "-d");
|
|
const partial = await syncRepository(repo, fakeDelivery("a-partial", commits.a, repo), runtime);
|
|
assert.equal(partial.ok, true);
|
|
assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.a}`), commits.a);
|
|
|
|
const forward = await syncRepository(repo, fakeDelivery("b", commits.b, repo), runtime);
|
|
assert.equal(forward.ok, true);
|
|
assert.equal(remoteRef(gitea, "refs/heads/main"), commits.b);
|
|
assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.b}`), commits.b);
|
|
|
|
const old = await syncRepository(repo, fakeDelivery("a-late", commits.a, repo), runtime);
|
|
assert.equal(old.ok, true);
|
|
assert.equal(old.disposition, "superseded-with-immutable-snapshot");
|
|
assert.equal(remoteRef(gitea, "refs/heads/main"), commits.b);
|
|
|
|
const sameSnapshot = await syncRepository(repo, fakeDelivery("b-again", commits.b, repo), runtime);
|
|
assert.equal(sameSnapshot.ok, true);
|
|
assert.equal(remoteRef(gitea, "refs/heads/main"), commits.b);
|
|
|
|
git("--git-dir", gitea, "update-ref", `refs/snapshots/source/${commits.c}`, commits.a);
|
|
const conflict = await syncRepository(repo, fakeDelivery("c-conflict", commits.c, repo), runtime);
|
|
assert.equal(conflict.ok, false);
|
|
assert.equal(conflict.errorType, "immutable-snapshot-conflict");
|
|
assert.equal(remoteRef(gitea, "refs/heads/main"), commits.b);
|
|
git("--git-dir", gitea, "update-ref", `refs/snapshots/source/${commits.c}`, "-d");
|
|
git("--git-dir", gitea, "fetch", "-q", fixture.source, `${commits.d}:refs/hidden/race-d`);
|
|
|
|
let raced = false;
|
|
const racingRuntime = {
|
|
...runtime,
|
|
async runResult(args) {
|
|
if (!raced && args.includes("push") && args.includes("--atomic")) {
|
|
raced = true;
|
|
git("--git-dir", gitea, "update-ref", "refs/heads/main", commits.d);
|
|
}
|
|
return await runResult(args);
|
|
},
|
|
};
|
|
const leaseRace = await syncRepository(repo, fakeDelivery("c-race", commits.c, repo), racingRuntime);
|
|
assert.equal(leaseRace.ok, false);
|
|
assert.equal(leaseRace.errorType, "atomic-push-race-or-failure");
|
|
assert.equal(remoteRef(gitea, "refs/heads/main"), commits.d);
|
|
assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.c}`), null);
|
|
|
|
const retry = await syncRepository(repo, fakeDelivery("c-retry", commits.c, repo), runtime);
|
|
assert.equal(retry.ok, true);
|
|
assert.equal(retry.disposition, "superseded-with-immutable-snapshot");
|
|
assert.equal(remoteRef(gitea, "refs/heads/main"), commits.d);
|
|
assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.c}`), commits.c);
|
|
|
|
const unavailable = await syncRepository(repo, fakeDelivery("force-push-unavailable", `${"0".repeat(39)}1`, repo), runtime);
|
|
assert.equal(unavailable.ok, false);
|
|
assert.equal(unavailable.retryable, true);
|
|
assert.equal(unavailable.errorType, "requested-commit-temporarily-unavailable");
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
test("production async Git runner preserves cross-repository concurrency", async () => {
|
|
const slow = gitFixture("slow");
|
|
const fast = gitFixture("fast");
|
|
const marker = join(slow.root, "pre-receive-entered");
|
|
writeFileSync(join(slow.gitea, "hooks", "pre-receive"), `#!/bin/sh\n: > "${marker}"\nsleep 1\nexit 0\n`, { mode: 0o755 });
|
|
const coordinator = createSyncCoordinator({
|
|
responseBudgetMs: 5000,
|
|
retryMaxAttempts: 1,
|
|
retryInitialDelayMs: 1,
|
|
retryMaxDelayMs: 1,
|
|
log: () => {},
|
|
run,
|
|
runResult,
|
|
githubAuthHeader: "",
|
|
giteaAuthHeader: "",
|
|
giteaBaseUrl: "file://",
|
|
});
|
|
try {
|
|
const slowResult = coordinator.execute(slow.repo, fakeDelivery("slow-a", slow.commits.a, slow.repo));
|
|
await waitFor(() => existsSync(marker));
|
|
const fastResult = coordinator.execute(fast.repo, fakeDelivery("fast-a", fast.commits.a, fast.repo));
|
|
const first = await Promise.race([
|
|
slowResult.then(() => "slow"),
|
|
fastResult.then(() => "fast"),
|
|
]);
|
|
assert.equal(first, "fast");
|
|
assert.equal((await fastResult).ok, true);
|
|
assert.equal((await slowResult).ok, true);
|
|
} finally {
|
|
slow.cleanup();
|
|
fast.cleanup();
|
|
}
|
|
});
|
|
|
|
test("atomic push rejection leaves branch and snapshot unchanged", async () => {
|
|
const fixture = gitFixture();
|
|
try {
|
|
const { repo, runtime, commits, gitea } = fixture;
|
|
assert.equal((await syncRepository(repo, fakeDelivery("a", commits.a, repo), runtime)).ok, true);
|
|
const hook = join(gitea, "hooks", "pre-receive");
|
|
writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
|
const rejected = await syncRepository(repo, fakeDelivery("b", commits.b, repo), runtime);
|
|
assert.equal(rejected.ok, false);
|
|
assert.equal(rejected.errorType, "atomic-push-race-or-failure");
|
|
assert.equal(remoteRef(gitea, "refs/heads/main"), commits.a);
|
|
assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.b}`), null);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
function gitFixture(key = "fixture") {
|
|
const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-sync-test-"));
|
|
const work = join(root, "work");
|
|
const source = join(root, "source.git");
|
|
const gitea = join(root, "gitea.git");
|
|
git("init", "-q", "-b", "main", work);
|
|
git("-C", work, "config", "user.name", "UniDesk Test");
|
|
git("-C", work, "config", "user.email", "test@unidesk.local");
|
|
const commits = {};
|
|
for (const name of ["a", "b", "c", "d"]) {
|
|
writeFileSync(join(work, "source.txt"), `${name}\n`);
|
|
git("-C", work, "add", "source.txt");
|
|
git("-C", work, "commit", "-q", "-m", name);
|
|
commits[name] = git("-C", work, "rev-parse", "HEAD").trim();
|
|
}
|
|
git("clone", "-q", "--bare", work, source);
|
|
git("init", "-q", "--bare", gitea);
|
|
const repo = {
|
|
key,
|
|
upstream: { repository: `pikasTech/${key}`, branch: "main", cloneUrl: source },
|
|
gitea: { readUrl: `file://${gitea}` },
|
|
snapshot: { prefix: "refs/snapshots/source" },
|
|
};
|
|
return {
|
|
root,
|
|
work,
|
|
source,
|
|
gitea,
|
|
commits,
|
|
repo,
|
|
runtime: {
|
|
run,
|
|
runResult,
|
|
githubAuthHeader: "",
|
|
giteaAuthHeader: "",
|
|
giteaBaseUrl: "file://",
|
|
deadlineAt: Date.now() + 60000,
|
|
now: Date.now,
|
|
},
|
|
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
};
|
|
}
|
|
|
|
function fakeRepo(key) {
|
|
return {
|
|
key,
|
|
upstream: { repository: `pikasTech/${key}`, branch: "main" },
|
|
snapshot: { prefix: "refs/snapshots/source" },
|
|
};
|
|
}
|
|
|
|
function fakeDelivery(deliveryId, requestedCommit, repo) {
|
|
return {
|
|
deliveryId,
|
|
repository: repo.upstream.repository,
|
|
ref: `refs/heads/${repo.upstream.branch}`,
|
|
requestedCommit,
|
|
payloadSha: createHash("sha256").update(`${deliveryId}:${requestedCommit}`).digest("hex"),
|
|
receivedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function remoteRef(repo, ref) {
|
|
try {
|
|
return git("--git-dir", repo, "rev-parse", "--verify", ref).trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function postWebhook(port, repo, deliveryId, requestedCommit, secret, extra = {}) {
|
|
const body = JSON.stringify({
|
|
ref: `refs/heads/${repo.upstream.branch}`,
|
|
after: requestedCommit,
|
|
repository: { full_name: repo.upstream.repository },
|
|
...extra,
|
|
});
|
|
const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
return await fetch(`http://127.0.0.1:${port}/hook`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-github-event": "push",
|
|
"x-github-delivery": deliveryId,
|
|
"x-hub-signature-256": signature,
|
|
},
|
|
body,
|
|
});
|
|
}
|
|
|
|
function durableControllerOptions(root, repos, overrides = {}) {
|
|
return {
|
|
repos,
|
|
inbox: {
|
|
path: join(root, "inbox"),
|
|
maxBytes: 4 * 1024 * 1024,
|
|
committedRetentionMs: 60_000,
|
|
cleanupIntervalMs: 60_000,
|
|
...overrides.inbox,
|
|
},
|
|
workerRetry: {
|
|
maxAttempts: 2,
|
|
initialDelayMs: 10,
|
|
maxDelayMs: 20,
|
|
terminalRetryDelayMs: 50,
|
|
attemptTimeoutMs: 2000,
|
|
scanIntervalMs: 10,
|
|
...overrides.workerRetry,
|
|
},
|
|
shutdownGraceMs: 50,
|
|
githubAuthHeader: "",
|
|
giteaAuthHeader: "",
|
|
giteaBaseUrl: "file://",
|
|
run,
|
|
runResult,
|
|
log: () => {},
|
|
...overrides,
|
|
inbox: {
|
|
path: join(root, "inbox"),
|
|
maxBytes: 4 * 1024 * 1024,
|
|
committedRetentionMs: 60_000,
|
|
cleanupIntervalMs: 60_000,
|
|
...overrides.inbox,
|
|
},
|
|
workerRetry: {
|
|
maxAttempts: 2,
|
|
initialDelayMs: 10,
|
|
maxDelayMs: 20,
|
|
terminalRetryDelayMs: 50,
|
|
attemptTimeoutMs: 2000,
|
|
scanIntervalMs: 10,
|
|
...overrides.workerRetry,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function waitFor(predicate, timeoutMs = 3000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error("condition not reached before timeout");
|
|
}
|
|
|
|
function git(...args) {
|
|
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
}
|