This commit is contained in:
@@ -79,10 +79,14 @@ test("equivalent resolved inputs are byte-identical despite different run IDs",
|
||||
assert.equal(first.input.artifactBuildId, second.input.artifactBuildId);
|
||||
const engine = new BuildEngine(output);
|
||||
const one = engine.build(first.input);
|
||||
assert.equal(fs.statSync(one.outputDirectory).mode & 0o777, 0o750);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal(fs.statSync(one.outputDirectory).mode & 0o777, 0o750);
|
||||
}
|
||||
fs.chmodSync(one.outputDirectory, 0o700);
|
||||
const two = engine.build(second.input);
|
||||
assert.equal(fs.statSync(two.outputDirectory).mode & 0o777, 0o750);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal(fs.statSync(two.outputDirectory).mode & 0o777, 0o750);
|
||||
}
|
||||
assert.equal(one.artifactChecksum, two.artifactChecksum);
|
||||
assert.equal(one.outputDirectory, two.outputDirectory);
|
||||
assert.deepEqual(one.generatedFiles, two.generatedFiles);
|
||||
@@ -210,9 +214,17 @@ test("markdown output is escaped and heading IDs are deterministic", () => {
|
||||
});
|
||||
|
||||
test("production sources contain no embedded renderer or synthesized preview fallback", () => {
|
||||
assert.equal(fs.readFileSync(path.resolve("src/backend/buildEngine.ts"), "utf8").trim(), 'export * from "./repositoryBuildEngine";');
|
||||
const boundary = fs.readFileSync(path.resolve("src/backend/buildEngine.ts"), "utf8");
|
||||
assert.match(boundary, /AI or other open-schema output is never a release input/);
|
||||
assert.match(boundary, /export \* from ".\/repositoryBuildEngine"/);
|
||||
const server = fs.readFileSync(path.resolve("server.ts"), "utf8");
|
||||
assert.doesNotMatch(server, /cdn\.tailwindcss\.com|Simple HTML renderer|SITE HEADER/);
|
||||
assert.match(server, /E_RELEASE_UNAVAILABLE/);
|
||||
assert.match(server, /E_CONFIG_READ_ONLY/);
|
||||
assert.doesNotMatch(server, /child_process|execAsync/);
|
||||
const preview = fs.readFileSync(path.resolve("src/backend/http/previewRouter.ts"), "utf8");
|
||||
const control = fs.readFileSync(path.resolve("src/backend/http/controlRouter.ts"), "utf8");
|
||||
const storeSource = fs.readFileSync(path.resolve("src/backend/store.ts"), "utf8");
|
||||
assert.match(preview, /E_RELEASE_UNAVAILABLE/);
|
||||
assert.match(control, /E_CONFIG_READ_ONLY/);
|
||||
assert.match(storeSource, /REMOTE_PUBLICATION_IMPLEMENTED = false as const/);
|
||||
assert.doesNotMatch(storeSource, /REMOTE_PUBLICATION_IMPLEMENTED\s*=\s*process\.env/);
|
||||
});
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// Security boundary: AI or other open-schema output is never a release input.
|
||||
export * from "./repositoryBuildEngine";
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const cacheRoot = path.join(os.tmpdir(), "labyricorn-cache");
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
const allowedHosts = (): Set<string> => {
|
||||
const configured = (process.env.GIT_ALLOWED_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (process.env.GITEA_BASE_URL) {
|
||||
configured.push(new URL(process.env.GITEA_BASE_URL).hostname.toLowerCase());
|
||||
}
|
||||
return new Set(configured);
|
||||
};
|
||||
|
||||
const isWithin = (root: string, candidate: string): boolean => {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
};
|
||||
|
||||
const validateLocalRepository = (repository: string): string => {
|
||||
const resolved = path.resolve(repository);
|
||||
const configuredRoots = (process.env.GIT_ALLOWED_LOCAL_ROOTS ?? process.cwd())
|
||||
.split(path.delimiter)
|
||||
.map((entry) => path.resolve(entry));
|
||||
if (!configuredRoots.some((root) => isWithin(root, resolved))) {
|
||||
throw new Error("Local repository is outside GIT_ALLOWED_LOCAL_ROOTS.");
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const validateRepository = (repository: string): string => {
|
||||
const value = repository.trim();
|
||||
if (path.isAbsolute(value)) return validateLocalRepository(value);
|
||||
|
||||
if (!value.includes("://")) {
|
||||
const scpLike = /^(?:[^@\s]+@)?([^:/\s]+):(.+)$/.exec(value);
|
||||
if (scpLike) {
|
||||
if (!allowedHosts().has(scpLike[1].toLowerCase())) {
|
||||
throw new Error(`Git host '${scpLike[1]}' is not allow-listed.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(value);
|
||||
if (!["https:", "ssh:"].includes(url.protocol) || url.username === "root") {
|
||||
throw new Error("Repository URL must use HTTPS or SSH.");
|
||||
}
|
||||
if (
|
||||
(url.protocol === "https:" && (url.username || url.password)) ||
|
||||
(url.protocol === "ssh:" && url.password)
|
||||
) throw new Error("Repository URL contains disallowed credentials.");
|
||||
if (!allowedHosts().has(url.hostname.toLowerCase())) {
|
||||
throw new Error(`Git host '${url.hostname}' is not allow-listed.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const validateRef = (ref: string): string => {
|
||||
const value = ref.trim();
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.length > 255 ||
|
||||
/[\s~^:?*[\]\\]/.test(value) ||
|
||||
value.includes("..") ||
|
||||
value.includes("@{") ||
|
||||
value.startsWith("-")
|
||||
) {
|
||||
throw new Error("Git ref is invalid.");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const validateId = (id: string): string => {
|
||||
if (!SAFE_ID.test(id)) throw new Error("Git source ID is invalid.");
|
||||
return id;
|
||||
};
|
||||
|
||||
const gitEnvironment = {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
};
|
||||
|
||||
export const resolveRemote = async (
|
||||
repository: string,
|
||||
ref: string,
|
||||
): Promise<string | null> => {
|
||||
const safeRepository = validateRepository(repository);
|
||||
const safeRef = validateRef(ref || "HEAD");
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["-c", "protocol.ext.allow=never", "ls-remote", "--", safeRepository, safeRef],
|
||||
{ env: gitEnvironment, timeout: 10_000 },
|
||||
);
|
||||
return stdout.trim().split("\t")[0] || null;
|
||||
};
|
||||
|
||||
export const synchronizeRepository = async (
|
||||
id: string,
|
||||
repository: string,
|
||||
ref: string,
|
||||
): Promise<string> => {
|
||||
validateId(id);
|
||||
const safeRepository = validateRepository(repository);
|
||||
const safeRef = validateRef(ref || "main");
|
||||
fs.mkdirSync(cacheRoot, { recursive: true });
|
||||
const target = path.resolve(cacheRoot, id);
|
||||
if (!isWithin(cacheRoot, target) || target === cacheRoot) {
|
||||
throw new Error("Git cache target is invalid.");
|
||||
}
|
||||
const temporary = fs.mkdtempSync(path.join(cacheRoot, `.${id}-`));
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
try {
|
||||
await execFileAsync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"protocol.ext.allow=never",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
safeRef,
|
||||
"--",
|
||||
safeRepository,
|
||||
temporary,
|
||||
],
|
||||
{ env: gitEnvironment, timeout: 60_000 },
|
||||
);
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
fs.renameSync(temporary, target);
|
||||
return target;
|
||||
} catch (error) {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cachedRepositoryPath = (id: string): string => {
|
||||
validateId(id);
|
||||
const target = path.resolve(cacheRoot, id);
|
||||
if (!isWithin(cacheRoot, target) || target === cacheRoot) {
|
||||
throw new Error("Git cache target is invalid.");
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
export const listRepositoryFiles = (
|
||||
id: string,
|
||||
predicate: (relative: string) => boolean = () => true,
|
||||
maximum = 50,
|
||||
): Array<{ path: string; sizeBytes: number }> => {
|
||||
const root = cachedRepositoryPath(id);
|
||||
if (!fs.existsSync(root)) return [];
|
||||
const files: Array<{ path: string; sizeBytes: number }> = [];
|
||||
const visit = (directory: string): void => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
if (files.length >= maximum) return;
|
||||
if (entry.name === ".git") continue;
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isDirectory()) visit(absolute);
|
||||
if (entry.isFile()) {
|
||||
const relative = path.relative(root, absolute).replace(/\\/g, "/");
|
||||
if (predicate(relative)) {
|
||||
files.push({ path: relative, sizeBytes: fs.statSync(absolute).size });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return files.sort((a, b) => a.path.localeCompare(b.path)).slice(0, maximum);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
fetchGiteaRepositories,
|
||||
FixedWindowRateLimiter,
|
||||
loadGiteaDiscoveryConfig,
|
||||
toGitSource,
|
||||
} from "./http/giteaDiscovery";
|
||||
|
||||
test("Gitea discovery requires an HTTPS allow-listed configured origin", () => {
|
||||
assert.throws(
|
||||
() => loadGiteaDiscoveryConfig({}),
|
||||
/GITEA_TOKEN and GITEA_BASE_URL/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
loadGiteaDiscoveryConfig({
|
||||
GITEA_TOKEN: "secret",
|
||||
GITEA_BASE_URL: "http://git.example.test",
|
||||
GITEA_ALLOWED_ORIGINS: "http://git.example.test",
|
||||
}),
|
||||
/HTTPS/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
loadGiteaDiscoveryConfig({
|
||||
GITEA_TOKEN: "secret",
|
||||
GITEA_BASE_URL: "https://git.example.test",
|
||||
GITEA_ALLOWED_ORIGINS: "https://other.example.test",
|
||||
}),
|
||||
/not in GITEA_ALLOWED_ORIGINS/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Gitea requests authenticate, disable redirects, and use an abort signal", async () => {
|
||||
const config = loadGiteaDiscoveryConfig({
|
||||
GITEA_TOKEN: "secret",
|
||||
GITEA_BASE_URL: "https://git.example.test",
|
||||
GITEA_ALLOWED_ORIGINS: "https://git.example.test",
|
||||
});
|
||||
let requestInit: RequestInit | undefined;
|
||||
const fakeFetch = (async (_input: unknown, init?: RequestInit) => {
|
||||
requestInit = init;
|
||||
return new Response(JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
name: "engine",
|
||||
full_name: "platform/engine",
|
||||
clone_url: "https://git.example.test/platform/engine.git",
|
||||
default_branch: "main",
|
||||
private: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}) as typeof fetch;
|
||||
|
||||
const repositories = await fetchGiteaRepositories(config, fakeFetch);
|
||||
assert.equal(new Headers(requestInit?.headers).get("Authorization"), "token secret");
|
||||
assert.equal(requestInit?.redirect, "error");
|
||||
assert.ok(requestInit?.signal);
|
||||
assert.equal(repositories.length, 1);
|
||||
assert.equal(toGitSource(repositories[0]).id, "platform--engine");
|
||||
});
|
||||
|
||||
test("Gitea fetch timeout aborts the request", async () => {
|
||||
const config = {
|
||||
apiUrl: new URL("https://git.example.test/api/v1/repos/search"),
|
||||
token: "secret",
|
||||
timeoutMs: 5,
|
||||
};
|
||||
const hangingFetch = ((_input: unknown, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () =>
|
||||
reject(new DOMException("aborted", "AbortError")),
|
||||
);
|
||||
})) as typeof fetch;
|
||||
await assert.rejects(
|
||||
fetchGiteaRepositories(config, hangingFetch),
|
||||
/timed out after 5ms/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Gitea discovery limiter is fixed-window and returns retry timing", () => {
|
||||
const limiter = new FixedWindowRateLimiter(2, 1_000);
|
||||
assert.equal(limiter.take("client", 0).allowed, true);
|
||||
assert.equal(limiter.take("client", 1).allowed, true);
|
||||
assert.deepEqual(limiter.take("client", 2), {
|
||||
allowed: false,
|
||||
retryAfter: 1,
|
||||
});
|
||||
assert.equal(limiter.take("client", 1_000).allowed, true);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import express from "express";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { cachedRepositoryPath, listRepositoryFiles } from "../gitOperations";
|
||||
import { store } from "../store";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/style-packages", (_req, res) => {
|
||||
res.json(store.stylePackages);
|
||||
});
|
||||
|
||||
router.get("/style-instances", (_req, res) => {
|
||||
res.json(store.styleInstances);
|
||||
});
|
||||
|
||||
router.post("/style-instances", (req, res) => {
|
||||
const instance = req.body;
|
||||
store.styleInstances.push(instance);
|
||||
store.addAudit(
|
||||
"Add Style Instance",
|
||||
"config",
|
||||
`Created style instance '${instance.id}' using '${instance.uses}'`,
|
||||
);
|
||||
res.json({ success: true, instance });
|
||||
});
|
||||
|
||||
router.put("/style-instances/:id", (req, res) => {
|
||||
const index = store.styleInstances.findIndex(
|
||||
(instance) => instance.id === req.params.id,
|
||||
);
|
||||
if (index === -1) return res.status(404).json({ error: "Instance not found" });
|
||||
store.styleInstances[index] = { ...store.styleInstances[index], ...req.body };
|
||||
store.addAudit(
|
||||
"Update Style Instance",
|
||||
"config",
|
||||
`Updated style instance '${req.params.id}'`,
|
||||
);
|
||||
return res.json({ success: true, instance: store.styleInstances[index] });
|
||||
});
|
||||
|
||||
router.get("/content", (_req, res) => {
|
||||
res.json(store.contentItems);
|
||||
});
|
||||
|
||||
router.post("/content/discover", (_req, res) => {
|
||||
store.addAudit(
|
||||
"Discover Content",
|
||||
"config",
|
||||
"Executed content discovery scan across all Git sources",
|
||||
);
|
||||
const discovered = [];
|
||||
|
||||
for (const source of store.gitSources) {
|
||||
if (source.health !== "Healthy" || !source.projectionEnabled) continue;
|
||||
let root: string;
|
||||
try {
|
||||
root = cachedRepositoryPath(source.id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(root)) continue;
|
||||
|
||||
const markdownFiles = listRepositoryFiles(
|
||||
source.id,
|
||||
(relative) => relative.toLowerCase().endsWith(".md"),
|
||||
10_000,
|
||||
);
|
||||
for (const [index, file] of markdownFiles.entries()) {
|
||||
const title = path.posix.basename(file.path, ".md").trim() || "Untitled";
|
||||
const declaredModels = source.produces || [];
|
||||
const defaultModel = store.siteConfig.contentModels[0]?.id ?? "page";
|
||||
const artifactType = declaredModels[0] ?? defaultModel;
|
||||
const slug = title.toLowerCase();
|
||||
const model = store.siteConfig.contentModels.find(
|
||||
(candidate) => candidate.id === artifactType,
|
||||
);
|
||||
const navigation = store.siteConfig.navigation.find(
|
||||
(entry) => entry.contentModel === artifactType,
|
||||
);
|
||||
const routePattern =
|
||||
model?.routing?.detail ??
|
||||
(navigation
|
||||
? `${navigation.route}/{slug}/`
|
||||
: `/${artifactType}s/{slug}/`);
|
||||
discovered.push({
|
||||
id: `art-${source.id}-${index}`,
|
||||
title: title
|
||||
.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase()),
|
||||
slug,
|
||||
published: new Date().toISOString(),
|
||||
status: "published" as const,
|
||||
artifactType,
|
||||
summary: `Discovered artifact from ${file.path}`,
|
||||
tags: [source.id],
|
||||
aliases: [],
|
||||
sourceRepo: source.id,
|
||||
path: file.path,
|
||||
contentMarkdown: fs.readFileSync(
|
||||
path.join(root, ...file.path.split("/")),
|
||||
"utf8",
|
||||
),
|
||||
mediaReferences: [],
|
||||
youtubeDirectives: [],
|
||||
wikipediaLinks: [],
|
||||
validationStatus: "valid" as const,
|
||||
validationMessages: [],
|
||||
route: routePattern.replace("{slug}", slug).replace("//", "/"),
|
||||
styleInstanceId: "default",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
store.contentItems = discovered;
|
||||
res.json({
|
||||
success: true,
|
||||
discoveredCount: discovered.length,
|
||||
mediaCount: store.mediaAssets.length,
|
||||
items: discovered,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/media", (_req, res) => {
|
||||
res.json(store.mediaAssets);
|
||||
});
|
||||
|
||||
export { router as contentRouter };
|
||||
@@ -0,0 +1,124 @@
|
||||
import express from "express";
|
||||
import { CredentialRef } from "../../types";
|
||||
import { store } from "../store";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok", service: "labyricorn-site-builder" });
|
||||
});
|
||||
|
||||
router.get("/site-config", (_req, res) => {
|
||||
res.json(store.siteConfig);
|
||||
});
|
||||
|
||||
router.put("/site-config", (_req, res) => {
|
||||
res.status(405).json({
|
||||
code: "E_CONFIG_READ_ONLY",
|
||||
message: "Site configuration is Git-owned and read-only in v1.",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/theme", (_req, res) => {
|
||||
res.json(store.themeConfig);
|
||||
});
|
||||
|
||||
router.post("/theme/validate", (_req, res) => {
|
||||
const theme = store.validateTheme();
|
||||
res.status(theme.status === "valid" ? 200 : 422).json({
|
||||
success: theme.status === "valid",
|
||||
theme,
|
||||
commits: theme.commit ? { [theme.sourceId ?? "theme"]: theme.commit } : {},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/builds", (_req, res) => {
|
||||
res.json(store.builds);
|
||||
});
|
||||
|
||||
router.post("/builds/trigger", (_req, res) => {
|
||||
res.json(store.runBuild());
|
||||
});
|
||||
|
||||
router.get("/nginx", (_req, res) => {
|
||||
res.json(store.nginxStatus);
|
||||
});
|
||||
|
||||
router.post("/nginx/test", (_req, res) => {
|
||||
store.nginxStatus.configValid = true;
|
||||
store.addAudit(
|
||||
"Test Nginx Config",
|
||||
"nginx",
|
||||
"Control-plane simulation reported a valid Nginx configuration.",
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
simulated: true,
|
||||
message: "Nginx validation is simulated by the control plane.",
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/nginx/activate", (req, res) => {
|
||||
res.json(store.activateReleaseLocally(req.body.buildId));
|
||||
});
|
||||
|
||||
router.get("/push-targets", (_req, res) => {
|
||||
res.json(store.pushTargets);
|
||||
});
|
||||
|
||||
router.post("/push-targets/:id/push", (req, res) => {
|
||||
const result = store.pushToTarget(req.params.id, req.body.buildId);
|
||||
res.status(result.success ? 200 : 501).json(result);
|
||||
});
|
||||
|
||||
router.post("/push-targets/:id/test", (_req, res) => {
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
code: "E_REMOTE_PUBLICATION_NOT_IMPLEMENTED",
|
||||
message: "Remote SSH and rsync checks are not implemented.",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/credentials", (_req, res) => {
|
||||
res.json(store.credentials);
|
||||
});
|
||||
|
||||
router.post("/credentials", (req, res) => {
|
||||
const { name, type, secretValue } = req.body;
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
!["ssh_key", "token", "password"].includes(type) ||
|
||||
typeof secretValue !== "string"
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "name, type, and secretValue are required.",
|
||||
});
|
||||
}
|
||||
const id = `secret:${name.toLowerCase().replace(/[^a-z0-9]/g, "-")}`;
|
||||
const masked =
|
||||
type === "ssh_key"
|
||||
? `${secretValue.substring(0, 20)}...`
|
||||
: `${secretValue.substring(0, 4)}********************`;
|
||||
const credential: CredentialRef = {
|
||||
id,
|
||||
name,
|
||||
type: type as CredentialRef["type"],
|
||||
maskedValue: masked,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
usageCount: 0,
|
||||
};
|
||||
store.credentials.push(credential);
|
||||
store.addAudit(
|
||||
"Add Credential",
|
||||
"credential",
|
||||
`Stored credential reference ${id}`,
|
||||
);
|
||||
return res.json({ success: true, credential });
|
||||
});
|
||||
|
||||
router.get("/audit-logs", (_req, res) => {
|
||||
res.json(store.auditLogs);
|
||||
});
|
||||
|
||||
export { router as controlRouter };
|
||||
@@ -0,0 +1,230 @@
|
||||
import express from "express";
|
||||
import fs from "node:fs";
|
||||
import { GitSource } from "../../types";
|
||||
import {
|
||||
cachedRepositoryPath,
|
||||
listRepositoryFiles,
|
||||
resolveRemote,
|
||||
synchronizeRepository,
|
||||
validateRepository,
|
||||
} from "../gitOperations";
|
||||
import { store } from "../store";
|
||||
import {
|
||||
fetchGiteaRepositories,
|
||||
FixedWindowRateLimiter,
|
||||
loadGiteaDiscoveryConfig,
|
||||
toGitSource,
|
||||
} from "./giteaDiscovery";
|
||||
|
||||
const router = express.Router();
|
||||
const discoveryLimiter = new FixedWindowRateLimiter();
|
||||
let discoveryInFlight: Promise<ReturnType<typeof toGitSource>[]> | null = null;
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
router.post("/gitea/discover", async (req, res) => {
|
||||
const limit = discoveryLimiter.take(req.ip || req.socket.remoteAddress || "unknown");
|
||||
if (!limit.allowed) {
|
||||
res.setHeader("Retry-After", String(limit.retryAfter));
|
||||
return res.status(429).json({
|
||||
code: "E_GITEA_DISCOVERY_RATE_LIMIT",
|
||||
error: "Gitea discovery rate limit exceeded.",
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
let config: ReturnType<typeof loadGiteaDiscoveryConfig>;
|
||||
try {
|
||||
config = loadGiteaDiscoveryConfig();
|
||||
} catch (error) {
|
||||
return res.status(503).json({
|
||||
code: "E_GITEA_DISCOVERY_CONFIG",
|
||||
error: errorMessage(error),
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
store.addAudit(
|
||||
"Gitea Discovery",
|
||||
"config",
|
||||
`Initiating repository discovery from ${config.apiUrl.origin}`,
|
||||
);
|
||||
try {
|
||||
discoveryInFlight ??= fetchGiteaRepositories(config)
|
||||
.then((repositories) => repositories.map(toGitSource))
|
||||
.finally(() => {
|
||||
discoveryInFlight = null;
|
||||
});
|
||||
const discovered = await discoveryInFlight;
|
||||
const added = store.mergeDiscoveredGitSources(discovered);
|
||||
return res.json({
|
||||
success: true,
|
||||
discoveredCount: discovered.length,
|
||||
addedCount: added,
|
||||
message: `Successfully discovered ${discovered.length} repositories from Gitea.`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
store.addAudit("Gitea Discovery Failed", "config", message, "failure");
|
||||
return res.status(502).json({ error: message, success: false });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/git-sources", (_req, res) => {
|
||||
res.json(store.gitSources);
|
||||
});
|
||||
|
||||
router.post("/git-sources", async (req, res) => {
|
||||
const body = req.body as Partial<GitSource>;
|
||||
if (
|
||||
typeof body.id !== "string" ||
|
||||
typeof body.repository !== "string" ||
|
||||
typeof body.ref !== "string"
|
||||
) {
|
||||
return res.status(400).json({ error: "id, repository, and ref are required." });
|
||||
}
|
||||
|
||||
try {
|
||||
validateRepository(body.repository);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: errorMessage(error) });
|
||||
}
|
||||
|
||||
const source: GitSource = {
|
||||
id: body.id,
|
||||
repository: body.repository,
|
||||
ref: body.ref,
|
||||
branch: body.branch || body.ref,
|
||||
type: body.type || "hybrid",
|
||||
required: body.required ?? false,
|
||||
credential: body.credential,
|
||||
health: "Warning",
|
||||
reachability: "Unreachable",
|
||||
configurationState: "Not Configured",
|
||||
visibility: "Private in Gitea",
|
||||
projectionEnabled: false,
|
||||
produces: [],
|
||||
explainWhy: "Awaiting initial synchronization.",
|
||||
status: "syncing",
|
||||
};
|
||||
|
||||
try {
|
||||
const commit = await resolveRemote(source.repository, source.ref);
|
||||
if (commit) {
|
||||
source.status = "connected";
|
||||
source.reachability = "Healthy";
|
||||
source.lastResolvedCommit = commit;
|
||||
source.lastSyncedAt = new Date().toISOString();
|
||||
source.explainWhy =
|
||||
"Connected to repository, but missing .labyricorn/site.yml metadata.";
|
||||
} else {
|
||||
source.status = "error";
|
||||
source.explainWhy = "Ref not found in repository.";
|
||||
}
|
||||
} catch (error) {
|
||||
source.status = "error";
|
||||
source.explainWhy = `Git ls-remote failed: ${errorMessage(error)}`;
|
||||
}
|
||||
|
||||
store.gitSources.push(source);
|
||||
store.addAudit(
|
||||
"Add Git Source",
|
||||
"source",
|
||||
`Added Git source ${source.id} (${source.repository})`,
|
||||
);
|
||||
return res.json({ success: true, source });
|
||||
});
|
||||
|
||||
router.put("/git-sources/:id", (req, res) => {
|
||||
const index = store.gitSources.findIndex((source) => source.id === req.params.id);
|
||||
if (index === -1) return res.status(404).json({ error: "Source not found" });
|
||||
store.gitSources[index] = { ...store.gitSources[index], ...req.body };
|
||||
store.addAudit(
|
||||
"Update Git Source",
|
||||
"source",
|
||||
`Updated Git source ${req.params.id}`,
|
||||
);
|
||||
return res.json({ success: true, source: store.gitSources[index] });
|
||||
});
|
||||
|
||||
router.delete("/git-sources/:id", (req, res) => {
|
||||
store.gitSources = store.gitSources.filter(
|
||||
(source) => source.id !== req.params.id,
|
||||
);
|
||||
store.addAudit(
|
||||
"Delete Git Source",
|
||||
"source",
|
||||
`Removed Git source ${req.params.id}`,
|
||||
);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post("/git-sources/:id/test", async (req, res) => {
|
||||
const source = store.gitSources.find((item) => item.id === req.params.id);
|
||||
if (!source) return res.status(404).json({ error: "Source not found" });
|
||||
source.status = "syncing";
|
||||
source.explainWhy = "Synchronization running...";
|
||||
|
||||
try {
|
||||
const commit = await resolveRemote(source.repository, source.ref || "HEAD");
|
||||
if (!commit) {
|
||||
source.status = "error";
|
||||
source.explainWhy = "Ref not found in repository.";
|
||||
return res.status(400).json({ error: source.explainWhy });
|
||||
}
|
||||
|
||||
source.status = "connected";
|
||||
source.reachability = "Healthy";
|
||||
source.lastResolvedCommit = commit;
|
||||
source.lastSyncedAt = new Date().toISOString();
|
||||
const target = await synchronizeRepository(
|
||||
source.id,
|
||||
source.repository,
|
||||
source.ref || "main",
|
||||
);
|
||||
if (fs.existsSync(`${target}/.labyricorn/site.yml`)) {
|
||||
source.configurationState = "Valid";
|
||||
source.projectionEnabled = true;
|
||||
source.explainWhy =
|
||||
"Repository synchronized and valid site.yml detected.";
|
||||
source.health = "Healthy";
|
||||
} else {
|
||||
source.configurationState = "Not Configured";
|
||||
source.projectionEnabled = false;
|
||||
source.explainWhy =
|
||||
"Repository synchronized, but missing .labyricorn/site.yml metadata.";
|
||||
source.health = "Warning";
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Successfully connected and synchronized ${source.repository}!`,
|
||||
resolvedCommit: source.lastResolvedCommit,
|
||||
syncedAt: source.lastSyncedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
source.status = "error";
|
||||
source.health = "Invalid";
|
||||
source.explainWhy = `Git sync failed: ${errorMessage(error)}`;
|
||||
return res.status(400).json({ error: source.explainWhy });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/git-sources/:id/tree", (req, res) => {
|
||||
if (!store.gitSources.some((source) => source.id === req.params.id)) {
|
||||
return res.status(404).json({ error: "Source not found" });
|
||||
}
|
||||
try {
|
||||
cachedRepositoryPath(req.params.id);
|
||||
const files = listRepositoryFiles(req.params.id).map((file) => ({
|
||||
path: file.path,
|
||||
type: "file",
|
||||
size: `${file.sizeBytes} B`,
|
||||
}));
|
||||
return res.json({ sourceId: req.params.id, files });
|
||||
} catch {
|
||||
return res.json({ sourceId: req.params.id, files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
export { router as gitSourceRouter };
|
||||
@@ -0,0 +1,198 @@
|
||||
import { GitSource } from "../../types";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_RATE_LIMIT = 5;
|
||||
const DEFAULT_RATE_WINDOW_MS = 60_000;
|
||||
|
||||
export interface GiteaDiscoveryConfig {
|
||||
apiUrl: URL;
|
||||
token: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface GiteaRepository {
|
||||
clone_url?: unknown;
|
||||
default_branch?: unknown;
|
||||
full_name?: unknown;
|
||||
name?: unknown;
|
||||
private?: unknown;
|
||||
ssh_url?: unknown;
|
||||
}
|
||||
|
||||
export interface DiscoveredRepository {
|
||||
cloneUrl: string;
|
||||
defaultBranch: string;
|
||||
fullName: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
sshUrl?: string;
|
||||
}
|
||||
|
||||
const positiveInteger = (value: string | undefined, fallback: number): number => {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`Invalid positive integer configuration value '${value}'.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const allowedOrigins = (value: string | undefined): Set<string> =>
|
||||
new Set(
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => new URL(entry).origin),
|
||||
);
|
||||
|
||||
export const loadGiteaDiscoveryConfig = (
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): GiteaDiscoveryConfig => {
|
||||
const token = env.GITEA_TOKEN?.trim();
|
||||
const baseUrl = env.GITEA_BASE_URL?.trim();
|
||||
if (!token || !baseUrl) {
|
||||
throw new Error("Gitea discovery requires GITEA_TOKEN and GITEA_BASE_URL.");
|
||||
}
|
||||
|
||||
const base = new URL(baseUrl);
|
||||
if (base.protocol !== "https:" || base.username || base.password) {
|
||||
throw new Error("GITEA_BASE_URL must be an HTTPS origin without credentials.");
|
||||
}
|
||||
|
||||
const allowlist = allowedOrigins(env.GITEA_ALLOWED_ORIGINS);
|
||||
if (!allowlist.has(base.origin)) {
|
||||
throw new Error(
|
||||
`GITEA_BASE_URL origin '${base.origin}' is not in GITEA_ALLOWED_ORIGINS.`,
|
||||
);
|
||||
}
|
||||
|
||||
const apiUrl = new URL("/api/v1/repos/search", base);
|
||||
apiUrl.searchParams.set("limit", "50");
|
||||
return {
|
||||
apiUrl,
|
||||
token,
|
||||
timeoutMs: positiveInteger(
|
||||
env.GITEA_DISCOVERY_TIMEOUT_MS,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const repository = (value: GiteaRepository): DiscoveredRepository | null => {
|
||||
if (
|
||||
typeof value.name !== "string" ||
|
||||
typeof value.clone_url !== "string" ||
|
||||
value.name.length === 0 ||
|
||||
value.clone_url.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const fullName =
|
||||
typeof value.full_name === "string" && value.full_name.length > 0
|
||||
? value.full_name
|
||||
: value.name;
|
||||
return {
|
||||
cloneUrl: value.clone_url,
|
||||
defaultBranch:
|
||||
typeof value.default_branch === "string" && value.default_branch.length > 0
|
||||
? value.default_branch
|
||||
: "main",
|
||||
fullName,
|
||||
name: value.name,
|
||||
private: value.private === true,
|
||||
sshUrl: typeof value.ssh_url === "string" ? value.ssh_url : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchGiteaRepositories = async (
|
||||
config: GiteaDiscoveryConfig,
|
||||
fetchImplementation: typeof fetch = fetch,
|
||||
): Promise<DiscoveredRepository[]> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
try {
|
||||
const response = await fetchImplementation(config.apiUrl, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${config.token}`,
|
||||
},
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Gitea returned HTTP ${response.status}.`);
|
||||
const document = (await response.json()) as { data?: unknown };
|
||||
if (!Array.isArray(document.data)) {
|
||||
throw new Error("Gitea returned an invalid repository search response.");
|
||||
}
|
||||
return document.data
|
||||
.map((entry) => repository(entry as GiteaRepository))
|
||||
.filter((entry): entry is DiscoveredRepository => entry !== null);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`Gitea discovery timed out after ${config.timeoutMs}ms.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const sourceId = (repo: DiscoveredRepository): string =>
|
||||
repo.fullName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "--")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 128) || repo.name.toLowerCase();
|
||||
|
||||
export const toGitSource = (repo: DiscoveredRepository): GitSource => ({
|
||||
id: sourceId(repo),
|
||||
repository: repo.cloneUrl,
|
||||
ref: repo.defaultBranch,
|
||||
branch: repo.defaultBranch,
|
||||
type: "hybrid",
|
||||
health: "Warning",
|
||||
reachability: "Unreachable",
|
||||
configurationState: "Not Configured",
|
||||
visibility: repo.private ? "Private in Gitea" : "Public in Gitea",
|
||||
projectionEnabled: false,
|
||||
produces: [],
|
||||
status: "idle",
|
||||
explainWhy: "Discovered from Gitea. Awaiting initial synchronization.",
|
||||
required: false,
|
||||
credential: "none",
|
||||
});
|
||||
|
||||
export class FixedWindowRateLimiter {
|
||||
private readonly clients = new Map<
|
||||
string,
|
||||
{ count: number; startedAt: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly maximum = positiveInteger(
|
||||
process.env.GITEA_DISCOVERY_RATE_LIMIT,
|
||||
DEFAULT_RATE_LIMIT,
|
||||
),
|
||||
private readonly windowMs = positiveInteger(
|
||||
process.env.GITEA_DISCOVERY_RATE_WINDOW_MS,
|
||||
DEFAULT_RATE_WINDOW_MS,
|
||||
),
|
||||
) {}
|
||||
|
||||
take(client: string, now = Date.now()): { allowed: boolean; retryAfter: number } {
|
||||
const current = this.clients.get(client);
|
||||
if (!current || now - current.startedAt >= this.windowMs) {
|
||||
this.clients.set(client, { count: 1, startedAt: now });
|
||||
return { allowed: true, retryAfter: 0 };
|
||||
}
|
||||
if (current.count >= this.maximum) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfter: Math.ceil((this.windowMs - (now - current.startedAt)) / 1_000),
|
||||
};
|
||||
}
|
||||
current.count += 1;
|
||||
return { allowed: true, retryAfter: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import express from "express";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { store } from "../store";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/live-site/html", (req, res) => {
|
||||
const rootDir = store.getPreviewDirectory();
|
||||
if (!rootDir) {
|
||||
return res.status(503).json({
|
||||
code: "E_RELEASE_UNAVAILABLE",
|
||||
message: "No verified staging or live release is selected.",
|
||||
});
|
||||
}
|
||||
|
||||
let requested: string;
|
||||
try {
|
||||
requested = decodeURIComponent(
|
||||
String(req.query.route ?? "/").split(/[?#]/, 1)[0],
|
||||
);
|
||||
} catch {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
if (
|
||||
!requested.startsWith("/") ||
|
||||
requested.includes("\\") ||
|
||||
requested.split("/").includes("..")
|
||||
) {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
|
||||
const relative = requested.replace(/^\/+/, "");
|
||||
const outputFile =
|
||||
relative && path.posix.extname(relative)
|
||||
? relative
|
||||
: path.posix.join(relative, "index.html");
|
||||
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
|
||||
const target = path.resolve(rootDir, ...outputFile.split("/"));
|
||||
if (!target.startsWith(rootPath)) {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'",
|
||||
);
|
||||
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
|
||||
return res.type(path.extname(target)).send(fs.readFileSync(target));
|
||||
}
|
||||
const notFound = path.join(rootDir, "404.html");
|
||||
if (fs.existsSync(notFound)) {
|
||||
return res.status(404).type("html").send(fs.readFileSync(notFound));
|
||||
}
|
||||
return res.status(404).json({ code: "E_ROUTE_NOT_FOUND" });
|
||||
});
|
||||
|
||||
export { router as previewRouter };
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { ChildProcess, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { BuildEngine } from "./buildEngine";
|
||||
import {
|
||||
commit,
|
||||
createRepository,
|
||||
loadFixture,
|
||||
} from "./testing/repositoryFixture";
|
||||
|
||||
const waitForExit = (child: ChildProcess): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve(stdout.trim());
|
||||
else reject(new Error(`worker exited ${code}: ${stderr}`));
|
||||
});
|
||||
});
|
||||
|
||||
const waitFor = async (predicate: () => boolean): Promise<void> => {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error("Timed out waiting for workers.");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
|
||||
test("concurrent writers converge on one verified immutable release", async () => {
|
||||
const root = createRepository();
|
||||
const output = fs.mkdtempSync(path.join(os.tmpdir(), "concurrent-release-"));
|
||||
const barrier = path.join(output, "barrier");
|
||||
const worker = path.resolve("src/backend/testing/concurrentBuildWorker.ts");
|
||||
try {
|
||||
const children = [1, 2].map(() =>
|
||||
spawn(process.execPath, ["--import", "tsx", worker, root, output, barrier], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}),
|
||||
);
|
||||
const completions = children.map(waitForExit);
|
||||
await waitFor(
|
||||
() =>
|
||||
fs
|
||||
.readdirSync(output)
|
||||
.filter((entry) => entry.startsWith("barrier.") && entry.endsWith(".ready"))
|
||||
.length === 2,
|
||||
);
|
||||
fs.writeFileSync(`${barrier}.go`, "");
|
||||
const checksums = await Promise.all(completions);
|
||||
assert.equal(new Set(checksums).size, 1);
|
||||
|
||||
const releases = fs.readdirSync(path.join(output, "releases"));
|
||||
assert.equal(releases.length, 1);
|
||||
new BuildEngine(output).verifyRelease(releases[0]);
|
||||
assert.equal(
|
||||
fs.readdirSync(output).some((entry) => entry.startsWith(".release-")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(output, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("partial rendering failure leaves no release or temporary directory", () => {
|
||||
const root = createRepository();
|
||||
const output = fs.mkdtempSync(path.join(os.tmpdir(), "partial-release-"));
|
||||
try {
|
||||
const template = path.join(
|
||||
root,
|
||||
"packages/site-definition/.theme/templates/home.liquid",
|
||||
);
|
||||
fs.appendFileSync(template, "\n{{ undefined.value }}\n");
|
||||
commit(root, "break rendering");
|
||||
const fixture = loadFixture(root, "partial-failure");
|
||||
try {
|
||||
const engine = new BuildEngine(output);
|
||||
assert.throws(() => engine.build(fixture.input), /undefined|not defined/i);
|
||||
assert.equal(fs.existsSync(engine.buildDirectory(fixture.input.artifactBuildId)), false);
|
||||
assert.equal(
|
||||
fs.readdirSync(output).some((entry) => entry.startsWith(".release-")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fixture.manager.dispose();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(output, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("staging and current pointers promote only checksum-verified releases", () => {
|
||||
const root = createRepository();
|
||||
const output = fs.mkdtempSync(path.join(os.tmpdir(), "promotion-release-"));
|
||||
const first = loadFixture(root, "first");
|
||||
try {
|
||||
const engine = new BuildEngine(output);
|
||||
const firstResult = engine.build(first.input);
|
||||
engine.stage(first.input.artifactBuildId);
|
||||
engine.activate(first.input.artifactBuildId);
|
||||
|
||||
const css = path.join(
|
||||
root,
|
||||
"packages/site-definition/.theme/assets/styles/theme.css",
|
||||
);
|
||||
fs.appendFileSync(css, "\n.second-release{display:block}\n");
|
||||
commit(root, "second release");
|
||||
const second = loadFixture(root, "second");
|
||||
try {
|
||||
const secondResult = engine.build(second.input);
|
||||
engine.stage(second.input.artifactBuildId);
|
||||
assert.equal(path.basename(fs.realpathSync(engine.currentDirectory())), first.input.artifactBuildId);
|
||||
assert.equal(path.basename(fs.realpathSync(engine.stagingDirectory())), second.input.artifactBuildId);
|
||||
|
||||
engine.activate(second.input.artifactBuildId);
|
||||
assert.equal(path.basename(fs.realpathSync(engine.currentDirectory())), second.input.artifactBuildId);
|
||||
assert.equal(path.basename(fs.realpathSync(engine.stagingDirectory())), second.input.artifactBuildId);
|
||||
|
||||
fs.appendFileSync(path.join(firstResult.outputDirectory, "index.html"), "tamper");
|
||||
assert.throws(() => engine.activate(first.input.artifactBuildId), /E_RELEASE_CHECKSUM/);
|
||||
assert.equal(path.basename(fs.realpathSync(engine.currentDirectory())), second.input.artifactBuildId);
|
||||
assert.ok(fs.existsSync(secondResult.outputDirectory));
|
||||
} finally {
|
||||
second.manager.dispose();
|
||||
}
|
||||
} finally {
|
||||
first.manager.dispose();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(output, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("repeated identical builds retain identical artifact checksums", () => {
|
||||
const root = createRepository();
|
||||
const output = fs.mkdtempSync(path.join(os.tmpdir(), "idempotent-release-"));
|
||||
const fixture = loadFixture(root, "idempotent");
|
||||
try {
|
||||
const engine = new BuildEngine(output);
|
||||
const checksums = Array.from(
|
||||
{ length: 20 },
|
||||
() => engine.build(fixture.input).artifactChecksum,
|
||||
);
|
||||
assert.equal(new Set(checksums).size, 1);
|
||||
} finally {
|
||||
fixture.manager.dispose();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(output, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -375,7 +375,19 @@ export class BuildEngine {
|
||||
const artifactChecksum = `sha256-${sha256(checksumsDocument)}`;
|
||||
const generatedFiles = [...files].sort();
|
||||
const artifactSizeBytes = generatedFiles.reduce((sum, relative) => sum + fs.statSync(path.join(temporaryDirectory, ...relative.split("/"))).size, 0);
|
||||
fs.renameSync(temporaryDirectory, outputDirectory);
|
||||
try {
|
||||
fs.renameSync(temporaryDirectory, outputDirectory);
|
||||
} catch (error) {
|
||||
if (!fs.existsSync(outputDirectory)) throw error;
|
||||
const verified = this.verifyRelease(input.artifactBuildId);
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
fs.chmodSync(outputDirectory, 0o750);
|
||||
return {
|
||||
success: true, outputDirectory, generatedRoutesCount: verified.routes.length,
|
||||
artifactChecksum: verified.artifactChecksum, artifactSizeBytes: verified.sizeBytes,
|
||||
validationReport, generatedFiles: verified.files,
|
||||
};
|
||||
}
|
||||
fs.chmodSync(outputDirectory, 0o750);
|
||||
return { success: true, outputDirectory, generatedRoutesCount: routes.size, artifactChecksum, artifactSizeBytes, validationReport, generatedFiles };
|
||||
} catch (error) {
|
||||
@@ -432,8 +444,28 @@ export class BuildEngine {
|
||||
const pointer = path.join(this.outputRoot, name);
|
||||
const next = path.join(this.outputRoot, `.${name}-${process.pid}-${crypto.randomUUID()}`);
|
||||
fs.symlinkSync(target, next, process.platform === "win32" ? "junction" : "dir");
|
||||
try { if (fs.lstatSync(pointer, { throwIfNoEntry: false })) fs.rmSync(pointer, { recursive: true, force: true }); fs.renameSync(next, pointer); }
|
||||
catch (error) { fs.rmSync(next, { recursive: true, force: true }); throw error; }
|
||||
try {
|
||||
if (process.platform !== "win32") {
|
||||
fs.renameSync(next, pointer);
|
||||
} else {
|
||||
const backup = path.join(
|
||||
this.outputRoot,
|
||||
`.${name}-backup-${process.pid}-${crypto.randomUUID()}`,
|
||||
);
|
||||
const existing = fs.lstatSync(pointer, { throwIfNoEntry: false });
|
||||
if (existing) fs.renameSync(pointer, backup);
|
||||
try {
|
||||
fs.renameSync(next, pointer);
|
||||
if (existing) fs.rmSync(backup, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
if (existing && !fs.existsSync(pointer)) fs.renameSync(backup, pointer);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
fs.rmSync(next, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { RepositorySnapshotManager } from "./repositories/repositorySnapshot";
|
||||
import { commit, createRepository, git } from "./testing/repositoryFixture";
|
||||
|
||||
test("snapshot loader materializes and retains one exact commit", () => {
|
||||
const root = createRepository();
|
||||
const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "snapshot-test-"));
|
||||
const manager = new RepositorySnapshotManager(workRoot);
|
||||
try {
|
||||
const expectedCommit = git(root, ["rev-parse", "HEAD"]);
|
||||
const snapshot = manager.resolve("site-definition", root, "HEAD");
|
||||
const css = path.join(
|
||||
snapshot.checkoutRoot,
|
||||
"packages/site-definition/.theme/assets/styles/theme.css",
|
||||
);
|
||||
const original = fs.readFileSync(css, "utf8");
|
||||
|
||||
fs.appendFileSync(
|
||||
path.join(
|
||||
root,
|
||||
"packages/site-definition/.theme/assets/styles/theme.css",
|
||||
),
|
||||
"\n.branch-advanced{display:block}\n",
|
||||
);
|
||||
const advancedCommit = commit(root, "advance branch");
|
||||
|
||||
assert.equal(snapshot.commit, expectedCommit);
|
||||
assert.notEqual(snapshot.commit, advancedCommit);
|
||||
assert.equal(fs.readFileSync(css, "utf8"), original);
|
||||
assert.equal(fs.existsSync(path.join(snapshot.checkoutRoot, ".git")), false);
|
||||
const checkoutRoot = snapshot.checkoutRoot;
|
||||
manager.dispose();
|
||||
assert.equal(fs.existsSync(checkoutRoot), false);
|
||||
} finally {
|
||||
manager.dispose();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(workRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -132,6 +132,18 @@ export class LabyricornStore {
|
||||
this.auditLogs.unshift({ id: `log-${Date.now()}-${this.auditLogs.length}`, timestamp: new Date().toISOString(), user: "[email protected]", action, category, details, result });
|
||||
}
|
||||
|
||||
mergeDiscoveredGitSources(discovered: readonly GitSource[]): number {
|
||||
const repositories = new Set(this.gitSources.map((source) => source.repository));
|
||||
const additions: GitSource[] = [];
|
||||
for (const source of discovered) {
|
||||
if (repositories.has(source.repository)) continue;
|
||||
repositories.add(source.repository);
|
||||
additions.push(source);
|
||||
}
|
||||
if (additions.length > 0) this.gitSources = [...this.gitSources, ...additions];
|
||||
return additions.length;
|
||||
}
|
||||
|
||||
validateTheme(): ThemeConfig {
|
||||
const loader = this.loader();
|
||||
try {
|
||||
@@ -235,6 +247,10 @@ export class LabyricornStore {
|
||||
return null;
|
||||
}
|
||||
pushToTarget(targetId: string, buildId: string): { success: boolean; logs: string[] } {
|
||||
// Deliberately fail closed: no configuration field can enable remote execution.
|
||||
if (!REMOTE_PUBLICATION_IMPLEMENTED) {
|
||||
return { success: false, logs: ["Remote publication is not implemented."] };
|
||||
}
|
||||
const target = this.pushTargets.find((item) => item.id === targetId);
|
||||
const build = this.builds.find((item) => item.id === buildId);
|
||||
if (!target || !build?.artifactBuildId) return { success: false, logs: ["Target or verified build not found."] };
|
||||
@@ -244,4 +260,5 @@ export class LabyricornStore {
|
||||
rollbackLocal(targetReleaseId: string): { success: boolean; message: string } { return this.activateReleaseLocally(targetReleaseId); }
|
||||
}
|
||||
|
||||
export const REMOTE_PUBLICATION_IMPLEMENTED = false as const;
|
||||
export const store = new LabyricornStore();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import fs from "node:fs";
|
||||
import { BuildEngine } from "../buildEngine";
|
||||
import { loadFixture } from "./repositoryFixture";
|
||||
|
||||
const [repository, output, barrier] = process.argv.slice(2);
|
||||
if (!repository || !output || !barrier) {
|
||||
throw new Error("repository, output, and barrier arguments are required.");
|
||||
}
|
||||
|
||||
const fixture = loadFixture(repository, `worker-${process.pid}`);
|
||||
try {
|
||||
fs.writeFileSync(`${barrier}.${process.pid}.ready`, "");
|
||||
const waitBuffer = new Int32Array(new SharedArrayBuffer(4));
|
||||
while (!fs.existsSync(`${barrier}.go`)) {
|
||||
Atomics.wait(waitBuffer, 0, 0, 10);
|
||||
}
|
||||
const result = new BuildEngine(output).build(fixture.input);
|
||||
process.stdout.write(`${result.artifactChecksum}\n`);
|
||||
} finally {
|
||||
fixture.manager.dispose();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { BuildInputLoader } from "../buildInputLoader";
|
||||
import { BUILDER_VERSION } from "../buildEngine";
|
||||
|
||||
export const git = (root: string, args: string[]): string =>
|
||||
execFileSync("git", ["-C", root, ...args], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
}).trim();
|
||||
|
||||
export const createRepository = (): string => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-git-fixture-"));
|
||||
fs.mkdirSync(path.join(root, "packages"), { recursive: true });
|
||||
fs.cpSync(
|
||||
path.resolve("packages/site-definition"),
|
||||
path.join(root, "packages/site-definition"),
|
||||
{ recursive: true },
|
||||
);
|
||||
fs.cpSync(
|
||||
path.resolve("packages/content-models"),
|
||||
path.join(root, "packages/content-models"),
|
||||
{ recursive: true },
|
||||
);
|
||||
git(root, ["init", "-b", "main"]);
|
||||
git(root, ["config", "user.name", "Fixture"]);
|
||||
git(root, ["config", "user.email", "[email protected]"]);
|
||||
git(root, ["add", "."]);
|
||||
git(root, ["commit", "-m", "fixture"]);
|
||||
return root;
|
||||
};
|
||||
|
||||
export const commit = (root: string, message: string): string => {
|
||||
git(root, ["add", "-A"]);
|
||||
git(root, ["commit", "-m", message]);
|
||||
return git(root, ["rev-parse", "HEAD"]);
|
||||
};
|
||||
|
||||
export const loadFixture = (root: string, runId: string) => {
|
||||
const manager = new BuildInputLoader({
|
||||
repositoryRoot: root,
|
||||
ref: "HEAD",
|
||||
siteDefinitionPath: "packages/site-definition",
|
||||
builderVersion: BUILDER_VERSION,
|
||||
});
|
||||
return { manager, input: manager.load(runId) };
|
||||
};
|
||||
Reference in New Issue
Block a user