fix: 持久化 GitHub 到 Gitea 源码权威
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = process.argv[index + 1];
|
||||
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function record(value, path) {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function required(value, path) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function fullCommit(value, path) {
|
||||
const commit = required(value, path);
|
||||
if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`${path} must be a full Git commit SHA`);
|
||||
return commit;
|
||||
}
|
||||
|
||||
function safeRelativePath(value, path) {
|
||||
const result = required(value, path);
|
||||
if (result.startsWith("/") || result.endsWith("/") || !/^[A-Za-z0-9._/-]+$/u.test(result) || result.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
throw new Error(`${path} must be a safe relative path`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function run(command, args, cwd, allowFailure = false) {
|
||||
const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||
if (result.error !== undefined) throw result.error;
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-3000);
|
||||
throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${detail}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function validateDelivery(value) {
|
||||
const delivery = record(value, "gitOpsDelivery");
|
||||
if (delivery.enabled !== true) throw new Error("gitOpsDelivery.enabled must be true");
|
||||
const application = record(delivery.application, "gitOpsDelivery.application");
|
||||
const author = record(delivery.author, "gitOpsDelivery.author");
|
||||
const cas = record(delivery.cas, "gitOpsDelivery.cas");
|
||||
const maxAttempts = cas.maxAttempts;
|
||||
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) throw new Error("gitOpsDelivery.cas.maxAttempts must be an integer from 1 to 5");
|
||||
const parsed = {
|
||||
enabled: true,
|
||||
targetId: required(delivery.targetId, "gitOpsDelivery.targetId"),
|
||||
readUrl: required(delivery.readUrl, "gitOpsDelivery.readUrl"),
|
||||
writeUrl: required(delivery.writeUrl, "gitOpsDelivery.writeUrl"),
|
||||
branch: required(delivery.branch, "gitOpsDelivery.branch"),
|
||||
sourceSnapshotPrefix: required(delivery.sourceSnapshotPrefix, "gitOpsDelivery.sourceSnapshotPrefix").replace(/\/+$/u, ""),
|
||||
desiredManifestPath: safeRelativePath(delivery.desiredManifestPath, "gitOpsDelivery.desiredManifestPath"),
|
||||
bootstrapApplicationPath: safeRelativePath(delivery.bootstrapApplicationPath, "gitOpsDelivery.bootstrapApplicationPath"),
|
||||
releaseStatePath: safeRelativePath(delivery.releaseStatePath, "gitOpsDelivery.releaseStatePath"),
|
||||
application: {
|
||||
name: required(application.name, "gitOpsDelivery.application.name"),
|
||||
namespace: required(application.namespace, "gitOpsDelivery.application.namespace"),
|
||||
project: required(application.project, "gitOpsDelivery.application.project"),
|
||||
repoUrl: required(application.repoUrl, "gitOpsDelivery.application.repoUrl"),
|
||||
targetRevision: required(application.targetRevision, "gitOpsDelivery.application.targetRevision"),
|
||||
path: safeRelativePath(application.path, "gitOpsDelivery.application.path"),
|
||||
destinationNamespace: required(application.destinationNamespace, "gitOpsDelivery.application.destinationNamespace"),
|
||||
automated: application.automated === true,
|
||||
},
|
||||
author: {
|
||||
name: required(author.name, "gitOpsDelivery.author.name"),
|
||||
email: required(author.email, "gitOpsDelivery.author.email"),
|
||||
},
|
||||
cas: { maxAttempts },
|
||||
};
|
||||
if (parsed.application.targetRevision !== parsed.branch) throw new Error("gitOpsDelivery.application.targetRevision must match branch");
|
||||
if (parsed.application.repoUrl !== parsed.readUrl) throw new Error("gitOpsDelivery.application.repoUrl must match readUrl");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function renderPlatformInfraGiteaApplication(deliveryValue) {
|
||||
const delivery = validateDelivery(deliveryValue);
|
||||
const application = {
|
||||
apiVersion: "argoproj.io/v1alpha1",
|
||||
kind: "Application",
|
||||
metadata: {
|
||||
name: delivery.application.name,
|
||||
namespace: delivery.application.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"app.kubernetes.io/part-of": "platform-infra",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
project: delivery.application.project,
|
||||
source: {
|
||||
repoURL: delivery.application.repoUrl,
|
||||
targetRevision: delivery.application.targetRevision,
|
||||
path: delivery.application.path,
|
||||
},
|
||||
destination: {
|
||||
server: "https://kubernetes.default.svc",
|
||||
namespace: delivery.application.destinationNamespace,
|
||||
},
|
||||
syncPolicy: delivery.application.automated ? {
|
||||
automated: { prune: true, selfHeal: true },
|
||||
syncOptions: ["CreateNamespace=true"],
|
||||
} : undefined,
|
||||
},
|
||||
};
|
||||
return `${Bun.YAML.stringify(application).trim()}\n`;
|
||||
}
|
||||
|
||||
function readReleaseState(path, delivery) {
|
||||
if (!existsSync(path)) return null;
|
||||
const value = record(JSON.parse(readFileSync(path, "utf8")), path);
|
||||
if (value.version !== 1 || value.kind !== "PlatformInfraGiteaGitOpsState") throw new Error(`${path} must be a PlatformInfraGiteaGitOpsState v1 record`);
|
||||
if (value.targetId !== delivery.targetId) throw new Error(`${path}.targetId must match gitOpsDelivery.targetId`);
|
||||
const result = {
|
||||
sourceCommit: fullCommit(value.sourceCommit, `${path}.sourceCommit`),
|
||||
desiredManifestSha256: required(value.desiredManifestSha256, `${path}.desiredManifestSha256`),
|
||||
bootstrapApplicationSha256: required(value.bootstrapApplicationSha256, `${path}.bootstrapApplicationSha256`),
|
||||
};
|
||||
if (!/^[0-9a-f]{64}$/u.test(result.desiredManifestSha256) || !/^[0-9a-f]{64}$/u.test(result.bootstrapApplicationSha256)) {
|
||||
throw new Error(`${path} materialization hashes must be full SHA-256 hex values`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sourceRelation(sourceRoot, delivery, existingCommit, sourceCommit) {
|
||||
if (existingCommit === sourceCommit) return "same";
|
||||
const refs = [existingCommit, sourceCommit].map((commit, index) => `+${delivery.sourceSnapshotPrefix}/${commit}:refs/unidesk/platform-infra-gitea-source-${index}`);
|
||||
const fetchArgs = existsSync(resolve(sourceRoot, ".git", "shallow"))
|
||||
? ["fetch", "--unshallow", "--filter=blob:none", "origin", ...refs]
|
||||
: ["fetch", "--filter=blob:none", "origin", ...refs];
|
||||
run("git", fetchArgs, sourceRoot);
|
||||
if (run("git", ["merge-base", "--is-ancestor", existingCommit, sourceCommit], sourceRoot, true).status === 0) return "forward";
|
||||
if (run("git", ["merge-base", "--is-ancestor", sourceCommit, existingCommit], sourceRoot, true).status === 0) return "superseded";
|
||||
return "diverged";
|
||||
}
|
||||
|
||||
function checkoutGitOpsBranch(delivery, sourceRoot, worktree) {
|
||||
rmSync(worktree, { recursive: true, force: true });
|
||||
run("git", ["clone", "--no-checkout", delivery.readUrl, worktree], sourceRoot);
|
||||
run("git", ["remote", "set-url", "--push", "origin", delivery.writeUrl], worktree);
|
||||
const fetched = run("git", ["fetch", "origin", `+refs/heads/${delivery.branch}:refs/remotes/origin/${delivery.branch}`], worktree, true).status === 0;
|
||||
if (fetched) {
|
||||
run("git", ["checkout", "-B", delivery.branch, `refs/remotes/origin/${delivery.branch}`], worktree);
|
||||
return run("git", ["rev-parse", `refs/remotes/origin/${delivery.branch}`], worktree).stdout.trim();
|
||||
}
|
||||
run("git", ["checkout", "--orphan", delivery.branch], worktree);
|
||||
run("git", ["rm", "-rf", "."], worktree, true);
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeDesiredFiles(worktree, delivery, desiredManifest, applicationManifest, state) {
|
||||
const entries = [
|
||||
[delivery.desiredManifestPath, desiredManifest],
|
||||
[delivery.bootstrapApplicationPath, applicationManifest],
|
||||
[delivery.releaseStatePath, `${JSON.stringify(state, null, 2)}\n`],
|
||||
];
|
||||
for (const [relativePath, content] of entries) {
|
||||
const target = resolve(worktree, relativePath);
|
||||
if (!target.startsWith(`${worktree}/`)) throw new Error(`${relativePath} escaped the GitOps worktree`);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, content, { encoding: "utf8", mode: 0o644 });
|
||||
}
|
||||
run("git", ["add", "--", ...entries.map(([relativePath]) => relativePath)], worktree);
|
||||
}
|
||||
|
||||
export function publishPlatformInfraGiteaGitOps({
|
||||
delivery: deliveryValue,
|
||||
desiredManifest,
|
||||
sourceRoot,
|
||||
sourceCommit: sourceCommitValue,
|
||||
worktree,
|
||||
beforePush = null,
|
||||
}) {
|
||||
const delivery = validateDelivery(deliveryValue);
|
||||
const sourceCommit = fullCommit(sourceCommitValue, "sourceCommit");
|
||||
const resolvedSourceRoot = resolve(sourceRoot);
|
||||
const resolvedWorktree = resolve(worktree);
|
||||
const checkedOutCommit = run("git", ["rev-parse", "HEAD"], resolvedSourceRoot).stdout.trim();
|
||||
if (checkedOutCommit !== sourceCommit) throw new Error(`source checkout ${checkedOutCommit} does not match requested sourceCommit ${sourceCommit}`);
|
||||
if (typeof desiredManifest !== "string" || !desiredManifest.includes("kind: Deployment") || !desiredManifest.includes("kind: PersistentVolumeClaim") || !desiredManifest.includes("kind: ServiceAccount")) {
|
||||
throw new Error("desired manifest must contain the durable bridge Deployment, PersistentVolumeClaim and ServiceAccount");
|
||||
}
|
||||
const applicationManifest = renderPlatformInfraGiteaApplication(delivery);
|
||||
const state = {
|
||||
version: 1,
|
||||
kind: "PlatformInfraGiteaGitOpsState",
|
||||
targetId: delivery.targetId,
|
||||
sourceCommit,
|
||||
desiredManifestSha256: digest(desiredManifest),
|
||||
bootstrapApplicationSha256: digest(applicationManifest),
|
||||
};
|
||||
let lastPushError = "";
|
||||
for (let attempt = 1; attempt <= delivery.cas.maxAttempts; attempt += 1) {
|
||||
const baseCommit = checkoutGitOpsBranch(delivery, resolvedSourceRoot, resolvedWorktree);
|
||||
const statePath = resolve(resolvedWorktree, delivery.releaseStatePath);
|
||||
const existingState = readReleaseState(statePath, delivery);
|
||||
if (existingState !== null) {
|
||||
const relation = sourceRelation(resolvedSourceRoot, delivery, existingState.sourceCommit, sourceCommit);
|
||||
if (relation === "same" && (
|
||||
existingState.desiredManifestSha256 !== state.desiredManifestSha256
|
||||
|| existingState.bootstrapApplicationSha256 !== state.bootstrapApplicationSha256
|
||||
)) {
|
||||
throw new Error(`same source commit ${sourceCommit} has conflicting GitOps materialization state`);
|
||||
}
|
||||
if (relation === "superseded") {
|
||||
return { ok: true, phase: "gitops-publish", status: "superseded", sourceCommit, authoritativeSourceCommit: existingState.sourceCommit, changed: false, pushAttempts: attempt - 1, valuesPrinted: false };
|
||||
}
|
||||
if (relation === "diverged") throw new Error(`source commit ${sourceCommit} diverges from authoritative GitOps source ${existingState.sourceCommit}`);
|
||||
}
|
||||
writeDesiredFiles(resolvedWorktree, delivery, desiredManifest, applicationManifest, state);
|
||||
run("git", ["config", "user.name", delivery.author.name], resolvedWorktree);
|
||||
run("git", ["config", "user.email", delivery.author.email], resolvedWorktree);
|
||||
const changed = run("git", ["diff", "--cached", "--quiet"], resolvedWorktree, true).status !== 0;
|
||||
if (!changed) {
|
||||
return { ok: true, phase: "gitops-publish", status: "idempotent", sourceCommit, gitOpsCommit: baseCommit, changed: false, pushAttempts: attempt - 1, valuesPrinted: false };
|
||||
}
|
||||
run("git", ["commit", "-m", `platform-infra: deploy gitea bridge ${sourceCommit.slice(0, 12)}`], resolvedWorktree);
|
||||
const gitOpsCommit = run("git", ["rev-parse", "HEAD"], resolvedWorktree).stdout.trim();
|
||||
if (typeof beforePush === "function") beforePush({ attempt, baseCommit, gitOpsCommit, worktree: resolvedWorktree });
|
||||
const lease = baseCommit === null
|
||||
? `--force-with-lease=refs/heads/${delivery.branch}:`
|
||||
: `--force-with-lease=refs/heads/${delivery.branch}:${baseCommit}`;
|
||||
const push = run("git", ["push", "--porcelain", lease, "origin", `HEAD:refs/heads/${delivery.branch}`], resolvedWorktree, true);
|
||||
if (push.status !== 0) {
|
||||
lastPushError = `${push.stderr || push.stdout || "CAS push failed"}`.trim().slice(-2000);
|
||||
continue;
|
||||
}
|
||||
const remoteCommit = run("git", ["ls-remote", delivery.writeUrl, `refs/heads/${delivery.branch}`], resolvedWorktree).stdout.trim().split(/\s+/u)[0] ?? "";
|
||||
if (remoteCommit !== gitOpsCommit) throw new Error(`post-push GitOps ref proof failed: expected ${gitOpsCommit}, observed ${remoteCommit}`);
|
||||
return { ok: true, phase: "gitops-publish", status: "published", sourceCommit, gitOpsCommit, changed: true, pushAttempts: attempt, valuesPrinted: false };
|
||||
}
|
||||
throw new Error(`GitOps CAS push failed after ${delivery.cas.maxAttempts} attempts: ${lastPushError || "unknown error"}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sourceRoot = resolve(option("--source-root") ?? process.cwd());
|
||||
const sourceCommit = fullCommit(option("--source-commit"), "--source-commit");
|
||||
const worktree = resolve(option("--worktree") ?? "/workspace/platform-infra-gitea-gitops");
|
||||
const moduleUrl = pathToFileURL(resolve(sourceRoot, "scripts", "src", "platform-infra-gitea.ts")).href;
|
||||
const module = await import(moduleUrl);
|
||||
const delivery = module.readGiteaWebhookGitOpsDelivery();
|
||||
const desiredManifest = module.renderGiteaWebhookSyncDesiredManifest(delivery.targetId);
|
||||
const result = publishPlatformInfraGiteaGitOps({ delivery, desiredManifest, sourceRoot, sourceCommit, worktree });
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
||||
@@ -0,0 +1,229 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { publishPlatformInfraGiteaGitOps } from "./publish-platform-infra-gitea-gitops.mjs";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("independent desired publisher is idempotent and preserves unrelated GitOps files", () => {
|
||||
const fixture = createFixture();
|
||||
checkout(fixture.sourceWork, fixture.commits.second);
|
||||
const first = publish(fixture, fixture.commits.second);
|
||||
expect(first).toMatchObject({ ok: true, status: "published", changed: true, pushAttempts: 1 });
|
||||
expect(show(fixture.gitOpsBare, fixture.delivery.branch, "deploy/gitops/unidesk-host/todo-note.yaml")).toBe("existing todo\n");
|
||||
expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.desiredManifestPath)).toContain("kind: PersistentVolumeClaim");
|
||||
expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.bootstrapApplicationPath)).toContain("name: platform-infra-gitea-nc01");
|
||||
|
||||
const second = publish(fixture, fixture.commits.second);
|
||||
expect(second).toMatchObject({ ok: true, status: "idempotent", changed: false, pushAttempts: 0 });
|
||||
});
|
||||
|
||||
test("CAS retries from a fresh head and preserves a concurrent todo-note update", () => {
|
||||
const fixture = createFixture();
|
||||
checkout(fixture.sourceWork, fixture.commits.second);
|
||||
let raced = false;
|
||||
const result = publishPlatformInfraGiteaGitOps({
|
||||
delivery: fixture.delivery,
|
||||
desiredManifest: fixture.desiredManifest,
|
||||
sourceRoot: fixture.sourceWork,
|
||||
sourceCommit: fixture.commits.second,
|
||||
worktree: fixture.publisherWorktree,
|
||||
beforePush: ({ attempt }: { attempt: number }) => {
|
||||
if (attempt !== 1 || raced) return;
|
||||
raced = true;
|
||||
pushConcurrentFile(fixture.root, fixture.gitOpsBare, fixture.delivery.branch);
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, status: "published", changed: true, pushAttempts: 2 });
|
||||
expect(show(fixture.gitOpsBare, fixture.delivery.branch, "deploy/gitops/unidesk-host/concurrent-todo.yaml")).toBe("concurrent todo\n");
|
||||
expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath)).toContain(fixture.commits.second);
|
||||
});
|
||||
|
||||
test("out-of-order source is superseded and divergent history fails closed", () => {
|
||||
const fixture = createFixture();
|
||||
checkout(fixture.sourceWork, fixture.commits.second);
|
||||
expect(publish(fixture, fixture.commits.second)).toMatchObject({ status: "published" });
|
||||
|
||||
checkout(fixture.sourceWork, fixture.commits.first);
|
||||
expect(publish(fixture, fixture.commits.first)).toMatchObject({
|
||||
ok: true,
|
||||
status: "superseded",
|
||||
authoritativeSourceCommit: fixture.commits.second,
|
||||
changed: false,
|
||||
});
|
||||
expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath)).toContain(fixture.commits.second);
|
||||
|
||||
checkout(fixture.sourceWork, fixture.commits.diverged);
|
||||
expect(() => publish(fixture, fixture.commits.diverged)).toThrow(/diverges from authoritative GitOps source/);
|
||||
expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath)).toContain(fixture.commits.second);
|
||||
});
|
||||
|
||||
test("same source commit with conflicting stored materialization hashes fails closed", () => {
|
||||
const fixture = createFixture();
|
||||
checkout(fixture.sourceWork, fixture.commits.second);
|
||||
expect(publish(fixture, fixture.commits.second)).toMatchObject({ status: "published" });
|
||||
mutateGitOpsState(fixture.root, fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath, (state) => ({
|
||||
...state,
|
||||
desiredManifestSha256: "f".repeat(64),
|
||||
}));
|
||||
expect(() => publish(fixture, fixture.commits.second)).toThrow(/same source commit .* conflicting GitOps materialization state/);
|
||||
const state = JSON.parse(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath));
|
||||
expect(state.desiredManifestSha256).toBe("f".repeat(64));
|
||||
});
|
||||
|
||||
function createFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "unidesk-platform-infra-gitea-publish-"));
|
||||
roots.push(root);
|
||||
const sourceWork = join(root, "source-work");
|
||||
const sourceBare = join(root, "source.git");
|
||||
const gitOpsSeed = join(root, "gitops-seed");
|
||||
const gitOpsBare = join(root, "gitops.git");
|
||||
mkdirSync(sourceWork);
|
||||
git(["init", "-b", "master"], sourceWork);
|
||||
identity(sourceWork);
|
||||
write(sourceWork, "source.txt", "first\n");
|
||||
git(["add", "source.txt"], sourceWork);
|
||||
git(["commit", "-m", "first"], sourceWork);
|
||||
const first = git(["rev-parse", "HEAD"], sourceWork).stdout.trim();
|
||||
write(sourceWork, "source.txt", "second\n");
|
||||
git(["commit", "-am", "second"], sourceWork);
|
||||
const second = git(["rev-parse", "HEAD"], sourceWork).stdout.trim();
|
||||
git(["checkout", "-b", "diverged", first], sourceWork);
|
||||
write(sourceWork, "source.txt", "diverged\n");
|
||||
git(["commit", "-am", "diverged"], sourceWork);
|
||||
const diverged = git(["rev-parse", "HEAD"], sourceWork).stdout.trim();
|
||||
git(["checkout", "master"], sourceWork);
|
||||
git(["init", "--bare", sourceBare], root);
|
||||
git(["remote", "add", "origin", fileUrl(sourceBare)], sourceWork);
|
||||
git(["push", "origin", "master"], sourceWork);
|
||||
const prefix = "refs/unidesk/snapshots/test-unidesk-master";
|
||||
for (const commit of [first, second, diverged]) git(["push", "origin", `${commit}:${prefix}/${commit}`], sourceWork);
|
||||
|
||||
mkdirSync(gitOpsSeed);
|
||||
git(["init", "-b", "unidesk-host-gitops"], gitOpsSeed);
|
||||
identity(gitOpsSeed);
|
||||
write(gitOpsSeed, "deploy/gitops/unidesk-host/todo-note.yaml", "existing todo\n");
|
||||
git(["add", "."], gitOpsSeed);
|
||||
git(["commit", "-m", "seed gitops"], gitOpsSeed);
|
||||
git(["init", "--bare", gitOpsBare], root);
|
||||
git(["remote", "add", "origin", fileUrl(gitOpsBare)], gitOpsSeed);
|
||||
git(["push", "origin", "unidesk-host-gitops"], gitOpsSeed);
|
||||
|
||||
const delivery = {
|
||||
enabled: true,
|
||||
targetId: "NC01",
|
||||
readUrl: fileUrl(gitOpsBare),
|
||||
writeUrl: fileUrl(gitOpsBare),
|
||||
branch: "unidesk-host-gitops",
|
||||
sourceSnapshotPrefix: prefix,
|
||||
desiredManifestPath: "deploy/gitops/platform-infra/gitea-nc01/resources.yaml",
|
||||
bootstrapApplicationPath: "deploy/gitops/unidesk-host/platform-infra-gitea-nc01-application.yaml",
|
||||
releaseStatePath: "deploy/gitops-state/platform-infra/gitea-nc01.json",
|
||||
application: {
|
||||
name: "platform-infra-gitea-nc01",
|
||||
namespace: "argocd",
|
||||
project: "default",
|
||||
repoUrl: fileUrl(gitOpsBare),
|
||||
targetRevision: "unidesk-host-gitops",
|
||||
path: "deploy/gitops/platform-infra/gitea-nc01",
|
||||
destinationNamespace: "devops-infra",
|
||||
automated: true,
|
||||
},
|
||||
author: { name: "UniDesk Test", email: "unidesk-test@example.invalid" },
|
||||
cas: { maxAttempts: 3 },
|
||||
};
|
||||
return {
|
||||
root,
|
||||
sourceWork,
|
||||
gitOpsBare,
|
||||
publisherWorktree: join(root, "publisher-worktree"),
|
||||
commits: { first, second, diverged },
|
||||
delivery,
|
||||
desiredManifest: `---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: bridge
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: inbox
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bridge
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function publish(fixture: ReturnType<typeof createFixture>, sourceCommit: string) {
|
||||
return publishPlatformInfraGiteaGitOps({
|
||||
delivery: fixture.delivery,
|
||||
desiredManifest: fixture.desiredManifest,
|
||||
sourceRoot: fixture.sourceWork,
|
||||
sourceCommit,
|
||||
worktree: fixture.publisherWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
function pushConcurrentFile(root: string, remote: string, branch: string) {
|
||||
const worktree = join(root, "concurrent-gitops");
|
||||
rmSync(worktree, { recursive: true, force: true });
|
||||
git(["clone", "--branch", branch, fileUrl(remote), worktree], root);
|
||||
identity(worktree);
|
||||
write(worktree, "deploy/gitops/unidesk-host/concurrent-todo.yaml", "concurrent todo\n");
|
||||
git(["add", "."], worktree);
|
||||
git(["commit", "-m", "concurrent todo update"], worktree);
|
||||
git(["push", "origin", branch], worktree);
|
||||
}
|
||||
|
||||
function mutateGitOpsState(root: string, remote: string, branch: string, relativePath: string, mutate: (state: Record<string, unknown>) => Record<string, unknown>) {
|
||||
const worktree = join(root, "mutate-gitops-state");
|
||||
rmSync(worktree, { recursive: true, force: true });
|
||||
git(["clone", "--branch", branch, fileUrl(remote), worktree], root);
|
||||
identity(worktree);
|
||||
const path = join(worktree, relativePath);
|
||||
writeFileSync(path, `${JSON.stringify(mutate(JSON.parse(readFileSync(path, "utf8"))), null, 2)}\n`);
|
||||
git(["add", "--", relativePath], worktree);
|
||||
git(["commit", "-m", "inject conflicting materialization state"], worktree);
|
||||
git(["push", "origin", branch], worktree);
|
||||
}
|
||||
|
||||
function checkout(worktree: string, commit: string) {
|
||||
git(["checkout", "--detach", commit], worktree);
|
||||
}
|
||||
|
||||
function show(remote: string, branch: string, path: string): string {
|
||||
const result = git(["--git-dir", remote, "show", `${branch}:${path}`], dirname(remote));
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function write(root: string, relativePath: string, content: string) {
|
||||
const path = join(root, relativePath);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
expect(readFileSync(path, "utf8")).toBe(content);
|
||||
}
|
||||
|
||||
function identity(root: string) {
|
||||
git(["config", "user.name", "UniDesk Test"], root);
|
||||
git(["config", "user.email", "unidesk-test@example.invalid"], root);
|
||||
}
|
||||
|
||||
function fileUrl(path: string): string {
|
||||
return `file://${path}`;
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string) {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
||||
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user