fix(agentrun): authenticate managed repository imports

This commit is contained in:
Codex
2026-07-12 01:53:07 +02:00
parent 3820fcd785
commit 35021e1050
9 changed files with 617 additions and 66 deletions
+5 -3
View File
@@ -246,6 +246,8 @@ controlPlane:
deploymentName: agentrun-nc01-v02-managed-repository-reconciler
configMapName: agentrun-nc01-v02-managed-repository-reconciler
serviceAccountName: agentrun-nc01-v02-managed-repository-reconciler
remoteAuth:
configRef: config/platform-infra/gitea.yaml#sourceAuthority.credentials.github.gitFetchCredential
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
imagePullPolicy: IfNotPresent
@@ -281,16 +283,16 @@ controlPlane:
repositories:
- key: agentrun
repository: pikasTech/agentrun
remote: ssh://git@ssh.github.com:443/pikasTech/agentrun.git
remote: https://github.com/pikasTech/agentrun.git
sourceBranch: v0.2
gitopsBranch: nc01-v0.2-gitops
- key: unidesk
repository: pikasTech/unidesk
remote: ssh://git@ssh.github.com:443/pikasTech/unidesk.git
remote: https://github.com/pikasTech/unidesk.git
sourceBranch: master
- key: agent_skills
repository: pikasTech/agent_skills
remote: ssh://git@ssh.github.com:443/pikasTech/agent_skills.git
remote: https://github.com/pikasTech/agent_skills.git
sourceBranch: master
database:
mode: external-postgres
+10
View File
@@ -52,6 +52,16 @@ sourceAuthority:
requiredFor:
- upstream-mirror
- mirror-sync
- managed-repository-fetch
gitFetchCredential:
apiVersion: unidesk.ai/v1
kind: GitFetchCredential
authMode: github-https-token
host: github.com
secretRef:
namespace: devops-infra
name: gitea-github-sync-secrets
key: github-token
githubProxy:
enabled: true
url: http://10.42.0.1:10808
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
const tokenPath = new URL("./token", import.meta.url);
const prompt = process.argv[2] ?? "";
if (/username/iu.test(prompt)) {
process.stdout.write("x-access-token\n");
process.exit(0);
}
if (!/password/iu.test(prompt)) process.exit(1);
let token;
try {
token = readFileSync(tokenPath, "utf8").trim();
} catch {
process.exit(1);
}
if (token.length === 0 || /[\u0000\r\n]/u.test(token)) process.exit(1);
process.stdout.write(`${token}\n`);
@@ -87,6 +87,20 @@ function readConfig(path) {
if (raw.schemaVersion !== 1 || raw.kind !== "AgentRunManagedRepositoryReconciler") throw new Error("unsupported managed repository config envelope");
const cacheMountPath = requireAbsolutePath(raw.cacheMountPath, "cacheMountPath");
const stateDirectory = requireRelativePath(raw.stateDirectory, "stateDirectory");
requireObject(raw.remoteAuth, "remoteAuth");
requireObject(raw.remoteAuth.proxy, "remoteAuth.proxy");
const remoteAuth = {
configRef: requireString(raw.remoteAuth.configRef, "remoteAuth.configRef"),
capabilitySha256: requireHex(raw.remoteAuth.capabilitySha256, "remoteAuth.capabilitySha256"),
authMode: requireLiteral(raw.remoteAuth.authMode, "remoteAuth.authMode", "github-https-token"),
host: requireHost(raw.remoteAuth.host, "remoteAuth.host"),
tokenFile: requireAbsolutePath(raw.remoteAuth.tokenFile, "remoteAuth.tokenFile"),
askPassFile: requireAbsolutePath(raw.remoteAuth.askPassFile, "remoteAuth.askPassFile"),
proxy: {
host: requireHost(raw.remoteAuth.proxy.host, "remoteAuth.proxy.host"),
port: requirePort(raw.remoteAuth.proxy.port, "remoteAuth.proxy.port"),
},
};
const repositories = requireArray(raw.repositories, "repositories").map((item, index) => {
requireObject(item, `repositories[${index}]`);
const key = requireSimpleId(item.key, `repositories[${index}].key`);
@@ -96,6 +110,7 @@ function readConfig(path) {
const desiredFingerprint = requireHex(item.desiredFingerprint, `repositories[${index}].desiredFingerprint`);
const expectedFingerprint = sha256([key, repository, remote, sourceBranch].join("\0"));
if (desiredFingerprint !== expectedFingerprint) throw new Error(`repositories[${index}].desiredFingerprint does not match repository desired state`);
requireCanonicalRemote(remote, repository, remoteAuth.host, `repositories[${index}].remote`);
return { key, repository, remote, sourceBranch, desiredFingerprint };
});
if (repositories.length === 0) throw new Error("repositories must not be empty");
@@ -133,18 +148,13 @@ function readConfig(path) {
managedRefs: "source-branch-only",
},
repositories,
ssh: raw.ssh && typeof raw.ssh === "object" ? {
identityFile: requireAbsolutePath(raw.ssh.identityFile, "ssh.identityFile"),
knownHostsFile: requireAbsolutePath(raw.ssh.knownHostsFile, "ssh.knownHostsFile"),
proxyConnectScript: requireAbsolutePath(raw.ssh.proxyConnectScript, "ssh.proxyConnectScript"),
proxyHost: requireString(raw.ssh.proxyHost, "ssh.proxyHost"),
proxyPort: requirePositiveInteger(raw.ssh.proxyPort, "ssh.proxyPort"),
} : null,
remoteAuth,
};
}
async function reconcileOnce(cfg, path) {
mkdirSync(dirname(path), { recursive: true });
const credential = credentialStatus(cfg);
const previous = readState(path);
const previousByFingerprint = new Map(
Array.isArray(previous?.repositories)
@@ -165,8 +175,9 @@ async function reconcileOnce(cfg, path) {
lane: cfg.lane,
observedAt: now,
heartbeatAt: now,
ready: repositories.every((item) => item.fresh === true && item.refPresent === true),
ready: credential.ready === true && repositories.every((item) => item.fresh === true && item.refPresent === true),
lastCycleOk: repositories.every((item) => item.lastAttemptOk === true),
credential,
lifecycle: cfg.lifecycle,
repositories,
valuesPrinted: false,
@@ -219,6 +230,7 @@ async function reconcileWithRetry(cfg, repository, prior) {
}
async function materializeRepository(cfg, repository) {
assertCredentialReady(cfg);
const finalPath = repositoryPath(cfg, repository);
mkdirSync(dirname(finalPath), { recursive: true });
const existing = existsSync(finalPath);
@@ -277,25 +289,34 @@ async function isBareRepository(path, timeoutMs) {
}
async function git(cfg, gitArgs) {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (cfg.ssh) {
mkdirSync(dirname(cfg.ssh.knownHostsFile), { recursive: true });
const proxyCommand = `node ${shellWord(cfg.ssh.proxyConnectScript)} ${shellWord(cfg.ssh.proxyHost)} ${cfg.ssh.proxyPort} %h %p`;
env.GIT_SSH_COMMAND = [
"ssh",
"-i", shellWord(cfg.ssh.identityFile),
"-o", "IdentitiesOnly=yes",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", `UserKnownHostsFile=${shellWord(cfg.ssh.knownHostsFile)}`,
"-o", "ConnectTimeout=15",
"-o", `ProxyCommand=${shellWord(proxyCommand)}`,
].join(" ");
}
return run("git", gitArgs, { timeoutMs: cfg.fetchTimeoutMs, env });
const proxyUrl = `http://${cfg.remoteAuth.proxy.host}:${cfg.remoteAuth.proxy.port}`;
const env = {
PATH: process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
LANG: process.env.LANG ?? "C.UTF-8",
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: cfg.remoteAuth.askPassFile,
GIT_ASKPASS_REQUIRE: "force",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_SSL_NO_VERIFY: "0",
HTTPS_PROXY: proxyUrl,
HTTP_PROXY: proxyUrl,
https_proxy: proxyUrl,
http_proxy: proxyUrl,
NO_PROXY: "",
no_proxy: "",
};
return run("git", [
"-c", "credential.helper=",
"-c", "http.extraHeader=",
"-c", "http.sslVerify=true",
...gitArgs,
], { timeoutMs: cfg.fetchTimeoutMs, env });
}
async function currentStatus(cfg, path) {
const credential = credentialStatus(cfg);
const state = readState(path);
if (!state || state.desiredHash !== cfg.desiredHash || !Array.isArray(state.repositories)) {
return {
@@ -305,6 +326,7 @@ async function currentStatus(cfg, path) {
observedDesiredHash: state?.desiredHash ?? null,
reason: state ? "desired-state-mismatch" : "status-missing",
repositoryCount: cfg.repositories.length,
credential,
valuesPrinted: false,
};
}
@@ -331,7 +353,7 @@ async function currentStatus(cfg, path) {
}
const heartbeatAgeMs = isoAgeMs(state.heartbeatAt);
const heartbeatFresh = heartbeatAgeMs !== null && heartbeatAgeMs <= cfg.freshness.maxAgeMs;
const ready = heartbeatFresh && repositories.every((item) => item.refPresent === true && item.fresh === true && item.remoteFingerprintAligned === true);
const ready = credential.ready === true && heartbeatFresh && repositories.every((item) => item.refPresent === true && item.fresh === true && item.remoteFingerprintAligned === true);
return {
ok: ready,
ready,
@@ -343,6 +365,7 @@ async function currentStatus(cfg, path) {
heartbeatAgeMs,
heartbeatFresh,
lifecycle: cfg.lifecycle,
credential,
repositories,
valuesPrinted: false,
};
@@ -357,6 +380,7 @@ function compactStatus(cfg, state) {
lane: cfg.lane,
heartbeatAt: state.heartbeatAt,
lifecycle: cfg.lifecycle,
credential: state.credential,
repositories: state.repositories,
valuesPrinted: false,
};
@@ -518,10 +542,6 @@ function isoAgeMs(value) {
return Number.isFinite(time) ? Math.max(0, Date.now() - time) : null;
}
function shellWord(value) {
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
}
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
@@ -550,6 +570,67 @@ function requirePositiveInteger(value, path) {
return value;
}
function requirePort(value, path) {
const port = requirePositiveInteger(value, path);
if (port > 65535) throw new Error(`${path} must be at most 65535`);
return port;
}
function requireLiteral(value, path, expected) {
if (value !== expected) throw new Error(`${path} must be ${expected}`);
return expected;
}
function requireHost(value, path) {
const host = requireString(value, path);
if (host !== host.toLowerCase() || !/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\d{1,3}(?:\.\d{1,3}){3})$/u.test(host) || host.includes("..")) {
throw new Error(`${path} must be a lowercase hostname or IPv4 address without a port`);
}
return host;
}
function requireCanonicalRemote(value, repository, host, path) {
let parsed;
try {
parsed = new URL(value);
} catch {
throw new Error(`${path} must be a canonical HTTPS URL`);
}
const expected = `https://${host}/${repository}.git`;
if (
value !== expected
|| parsed.protocol !== "https:"
|| parsed.hostname !== host
|| parsed.port !== ""
|| parsed.username !== ""
|| parsed.password !== ""
|| parsed.search !== ""
|| parsed.hash !== ""
) throw new Error(`${path} must exactly match ${expected} without userinfo, port, query, or fragment`);
}
function credentialStatus(cfg) {
try {
assertCredentialReady(cfg);
return { ready: true, errorCode: null, errorFingerprint: null, valuesPrinted: false };
} catch (error) {
const failure = safeError(error);
return { ready: false, errorCode: failure.code, errorFingerprint: failure.fingerprint, valuesPrinted: false };
}
}
function assertCredentialReady(cfg) {
if (!existsSync(cfg.remoteAuth.askPassFile)) throw codedError("credential-helper-missing", "rendered credential helper is missing");
let token;
try {
token = readFileSync(cfg.remoteAuth.tokenFile, "utf8").trim();
} catch {
throw codedError("credential-file-missing", "projected credential file is missing or unreadable");
}
if (token.length === 0) throw codedError("credential-empty", "projected credential file is empty");
if (/[\u0000\r\n]/u.test(token)) throw codedError("credential-invalid", "projected credential file contains unsupported control characters");
}
function requireAbsolutePath(value, path) {
const text = requireString(value, path);
if (!text.startsWith("/") || text.includes("..")) throw new Error(`${path} must be an absolute path without ..`);
+71 -4
View File
@@ -14,6 +14,7 @@ import {
stringField,
} from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
import { resolveConfigRef } from "./ops/config-refs";
export const AGENTRUN_CONFIG_PATH = "config/agentrun.yaml";
@@ -30,6 +31,19 @@ export interface AgentRunManagedRepositoryReconcilerSpec {
readonly deploymentName: string;
readonly configMapName: string;
readonly serviceAccountName: string;
readonly remoteAuth: {
readonly configRef: string;
readonly capabilitySha256: string;
readonly apiVersion: "unidesk.ai/v1";
readonly kind: "GitFetchCredential";
readonly authMode: "github-https-token";
readonly host: "github.com";
readonly secretRef: {
readonly namespace: string;
readonly name: string;
readonly key: string;
};
};
readonly hostNetwork: boolean;
readonly dnsPolicy: "ClusterFirst" | "ClusterFirstWithHostNet" | "Default";
readonly imagePullPolicy: "Always" | "IfNotPresent" | "Never";
@@ -757,6 +771,16 @@ function validateAgentRunLaneSourceAuthority(spec: AgentRunLaneSpec, path: strin
if (spec.source.statusMode !== "k3s-git-mirror") throw new Error(`${path}.source.statusMode must be k3s-git-mirror for AgentRun v0.2`);
if (spec.source.sourceAuthority === null) throw new Error(`${path}.source.sourceAuthority is required for AgentRun v0.2`);
if (spec.source.sourceSnapshot === null) throw new Error(`${path}.source.sourceSnapshot is required for AgentRun v0.2`);
const remoteAuth = spec.gitMirror.repositoryReconciler.remoteAuth;
if (remoteAuth.secretRef.namespace !== spec.gitMirror.namespace) {
throw new Error(`${path}.gitMirror.repositoryReconciler.remoteAuth credential namespace must match gitMirror.namespace`);
}
for (const [index, repository] of spec.gitMirror.repositories.entries()) {
const expected = `https://${remoteAuth.host}/${repository.repository}.git`;
if (repository.remote !== expected) {
throw new Error(`${path}.gitMirror.repositories[${index}].remote must exactly match the declared remoteAuth host and repository path`);
}
}
}
function parseDeployment(input: Record<string, unknown>, path: string): AgentRunLaneSpec["deployment"] {
@@ -1148,6 +1172,7 @@ function parseManagedRepositoryReconciler(input: Record<string, unknown>, path:
deploymentName: kubernetesNameField(input, "deploymentName", path),
configMapName: kubernetesNameField(input, "configMapName", path),
serviceAccountName: kubernetesNameField(input, "serviceAccountName", path),
remoteAuth: parseManagedRepositoryRemoteAuth(recordField(input, "remoteAuth", path), `${path}.remoteAuth`),
hostNetwork,
dnsPolicy,
imagePullPolicy: enumField(input, "imagePullPolicy", path, ["Always", "IfNotPresent", "Never"]),
@@ -1182,6 +1207,40 @@ function parseManagedRepositoryReconciler(input: Record<string, unknown>, path:
};
}
function parseManagedRepositoryRemoteAuth(input: Record<string, unknown>, path: string): AgentRunManagedRepositoryReconcilerSpec["remoteAuth"] {
const configRef = stringField(input, "configRef", path);
const resolved = resolveConfigRef(configRef, `${path}.configRef`);
const capability = parseAgentRunGitFetchCredentialCapability(resolved.value, configRef);
return {
configRef,
capabilitySha256: resolved.sha256,
...capability,
};
}
export function parseAgentRunGitFetchCredentialCapability(value: unknown, path = "gitFetchCredential"): Omit<AgentRunManagedRepositoryReconcilerSpec["remoteAuth"], "configRef" | "capabilitySha256"> {
const capability = asRecord(value, path);
const secretRef = asRecord(capability.secretRef, `${path}.secretRef`);
const apiVersion = enumField(capability, "apiVersion", path, ["unidesk.ai/v1"] as const);
const kind = enumField(capability, "kind", path, ["GitFetchCredential"] as const);
const authMode = enumField(capability, "authMode", path, ["github-https-token"] as const);
const host = stringField(capability, "host", path);
if (host !== "github.com") throw new Error(`${path}.host must be github.com for github-https-token`);
const key = stringField(secretRef, "key", `${path}.secretRef`);
if (!/^[A-Za-z0-9._-]+$/u.test(key)) throw new Error(`${path}.secretRef.key must be a Kubernetes Secret data key`);
return {
apiVersion,
kind,
authMode,
host,
secretRef: {
namespace: kubernetesNameField(secretRef, "namespace", `${path}.secretRef`),
name: kubernetesNameField(secretRef, "name", `${path}.secretRef`),
key,
},
};
}
function validateRepositoryPath(value: string, path: string): void {
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u.test(value) || value.includes("..")) {
throw new Error(`${path} must be an owner/name repository path without ..`);
@@ -1191,13 +1250,21 @@ function validateRepositoryPath(value: string, path: string): void {
function validateRepositoryRemote(value: string, repository: string, path: string): void {
try {
const remote = new URL(value);
const remoteRepository = decodeURIComponent(remote.pathname).replace(/^\/+|\.git$/gu, "");
if (remote.protocol !== "ssh:" || remote.username.length === 0 || remote.password.length > 0 || remote.search.length > 0 || remote.hash.length > 0) {
const expectedPath = `/${repository}.git`;
if (
remote.protocol !== "https:"
|| remote.port.length > 0
|| remote.username.length > 0
|| remote.password.length > 0
|| remote.search.length > 0
|| remote.hash.length > 0
|| remote.pathname !== expectedPath
|| value !== `https://${remote.hostname}${expectedPath}`
) {
throw new Error("unsupported remote");
}
if (remoteRepository !== repository) throw new Error("repository mismatch");
} catch {
throw new Error(`${path} must be an ssh URL owned by the same repository entry (${repository})`);
throw new Error(`${path} must be a canonical HTTPS URL owned by the same repository entry (${repository}) without userinfo, port, query, or fragment`);
}
}
@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -14,13 +16,17 @@ import {
agentRunManagedRepositoryRenderHash,
renderAgentRunManagedRepositoryReconcilerFragment,
} from "./agentrun-managed-repository-reconciler";
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
import {
parseAgentRunGitFetchCredentialCapability,
resolveAgentRunLaneTarget,
} from "./agentrun-lanes";
import { resolveCicdDeliveryAuthority } from "./cicd-delivery-authority";
import { renderGitMirrorStatusEnvelope } from "./agentrun/git-mirror";
import { yamlLaneGitMirrorStatusScript } from "./agentrun/secrets";
import { renderPlatformInfraGiteaDesiredFragments } from "./platform-infra-gitea-desired-fragments";
const controllerPath = new URL("../native/agentrun/managed-repository-reconciler.mjs", import.meta.url).pathname;
const askPassPath = new URL("../native/agentrun/git-askpass.mjs", import.meta.url).pathname;
describe("AgentRun YAML-owned managed repository reconciler", () => {
test("renders one narrow controller from the lane repository list", () => {
@@ -31,7 +37,12 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
"pikasTech/unidesk",
"pikasTech/agent_skills",
]);
expect(desired.repositories.every((item) => item.remote.startsWith("ssh://"))).toBe(true);
expect(desired.repositories.every((item) => item.remote.startsWith("https://github.com/"))).toBe(true);
expect(desired.remoteAuth).toMatchObject({
authMode: "github-https-token",
host: "github.com",
proxy: { host: "127.0.0.1", port: 10808 },
});
expect(desired.lifecycle).toEqual({
undeclaredRepositoryPolicy: "retain",
managedRefs: "source-branch-only",
@@ -49,6 +60,16 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
expect(documents[2].spec.template.spec.hostNetwork).toBe(spec.gitMirror.repositoryReconciler.hostNetwork);
expect(documents[2].spec.template.spec.dnsPolicy).toBe(spec.gitMirror.repositoryReconciler.dnsPolicy);
expect(documents[2].spec.template.spec.containers[0].readinessProbe.exec.command).toContain("--health");
const authVolume = documents[2].spec.template.spec.volumes.find((item: any) => item.name === "git-auth");
expect(authVolume.projected.sources).toHaveLength(2);
expect(authVolume.projected.sources[1].secret).toEqual({
name: "gitea-github-sync-secrets",
items: [{ key: "github-token", path: "token", mode: 256 }],
});
expect(JSON.stringify(authVolume)).not.toContain("gitea-username");
expect(JSON.stringify(authVolume)).not.toContain("gitea-password");
expect(JSON.stringify(authVolume)).not.toContain("github-webhook-secret");
expect(documents[2].spec.template.spec.volumes.some((item: any) => item.name === "git-ssh")).toBe(false);
expect(renderAgentRunManagedRepositoryReconcilerFragment("JD01")).toBe("");
const controllerSource = readFileSync(controllerPath, "utf8");
@@ -103,8 +124,8 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
try {
const source = readFileSync(new URL("../../config/agentrun.yaml", import.meta.url), "utf8");
const invalid = source.replace(
"remote: ssh://git@ssh.github.com:443/pikasTech/agent_skills.git",
"remote: ssh://git@ssh.github.com:443/pikasTech/unidesk.git",
"remote: https://github.com/pikasTech/agent_skills.git",
"remote: https://github.com/pikasTech/unidesk.git",
);
const configPath = join(temporary, "agentrun.yaml");
writeFileSync(configPath, invalid);
@@ -117,6 +138,48 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
}
});
test("fails closed on non-canonical remotes and malformed credential capabilities", () => {
const source = readFileSync(new URL("../../config/agentrun.yaml", import.meta.url), "utf8");
const canonical = "https://github.com/pikasTech/agent_skills.git";
const invalidRemotes = [
"ssh://git@ssh.github.com:443/pikasTech/agent_skills.git",
"https://user@github.com/pikasTech/agent_skills.git",
"https://github.com:443/pikasTech/agent_skills.git",
"https://github.com/pikasTech/agent_skills.git?token=forbidden",
"https://github.com/pikasTech/agent_skills.git#forbidden",
"https://example.com/pikasTech/agent_skills.git",
"https://github.com/pikasTech/unidesk.git",
];
const temporary = mkdtempSync("/tmp/unidesk-agentrun-remote-contract-");
try {
for (const [index, remote] of invalidRemotes.entries()) {
const configPath = join(temporary, `invalid-${index}.yaml`);
writeFileSync(configPath, source.replace(`remote: ${canonical}`, `remote: ${remote}`));
expect(() => resolveAgentRunLaneTarget(
{ node: "NC01", lane: "nc01-v02" },
{ ...process.env, AGENTRUN_CONTROL_PLANE_CONFIG: configPath },
)).toThrow();
}
} finally {
rmSync(temporary, { recursive: true, force: true });
}
const validCapability = {
apiVersion: "unidesk.ai/v1",
kind: "GitFetchCredential",
authMode: "github-https-token",
host: "github.com",
secretRef: { namespace: "devops-infra", name: "gitea-github-sync-secrets", key: "github-token" },
};
expect(parseAgentRunGitFetchCredentialCapability(validCapability)).toEqual(validCapability);
for (const invalid of [
{ ...validCapability, kind: "Secret" },
{ ...validCapability, authMode: "ssh" },
{ ...validCapability, host: "example.com" },
{ ...validCapability, secretRef: { ...validCapability.secretRef, key: "invalid/key" } },
]) expect(() => parseAgentRunGitFetchCredentialCapability(invalid)).toThrow();
});
test("renders host networking only from the explicit reconciler fields", () => {
const temporary = mkdtempSync("/tmp/unidesk-agentrun-repository-network-");
try {
@@ -179,8 +242,15 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
const work = join(temporary, "work");
const remote = join(temporary, "remote.git");
const cache = join(temporary, "cache");
const auth = join(temporary, "auth");
const fakeBin = join(temporary, "bin");
const gitObservationPath = join(temporary, "git-observations.jsonl");
const tokenPath = join(auth, "token");
const fixtureAskPassPath = join(auth, "git-askpass.mjs");
mkdirSync(work, { recursive: true });
mkdirSync(cache, { recursive: true });
mkdirSync(auth, { recursive: true });
mkdirSync(fakeBin, { recursive: true });
git(["init", work]);
git(["-C", work, "config", "user.name", "UniDesk fixture"]);
git(["-C", work, "config", "user.email", "fixture@unidesk.invalid"]);
@@ -189,12 +259,23 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
git(["-C", work, "commit", "-m", "fixture"]);
git(["-C", work, "branch", "-M", "main"]);
git(["clone", "--bare", work, remote]);
const fixtureToken = "github_pat_fixture_not_for_logs";
writeFileSync(tokenPath, `${fixtureToken}\n`);
copyFileSync(askPassPath, fixtureAskPassPath);
chmodSync(fixtureAskPassPath, 0o555);
const retainedPath = join(cache, "undeclared", "retained.git");
mkdirSync(retainedPath, { recursive: true });
writeFileSync(join(retainedPath, "marker"), "retain\n");
const remoteUrl = `file://${remote}`;
const remoteUrl = "https://github.com/acme/example.git";
writeGitShim({
path: join(fakeBin, "git"),
realGit: Bun.which("git")!,
observationPath: gitObservationPath,
remoteUrl,
replacementRemoteUrl: `file://${remote}`,
});
const repository = {
key: "fixture",
repository: "acme/example",
@@ -217,6 +298,15 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
freshness: { maxAgeMs: 60000 },
lifecycle: { undeclaredRepositoryPolicy: "retain", managedRefs: "source-branch-only" },
repositories: [repository],
remoteAuth: {
configRef: "config/platform-infra/gitea.yaml#sourceAuthority.credentials.github.gitFetchCredential",
capabilitySha256: "a".repeat(64),
authMode: "github-https-token",
host: "github.com",
tokenFile: tokenPath,
askPassFile: fixtureAskPassPath,
proxy: { host: "127.0.0.1", port: 10808 },
},
};
const config = { ...desired, desiredHash: sha256(JSON.stringify(desired)) };
const configPath = join(temporary, "config.json");
@@ -225,12 +315,25 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
const once = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], {
stdout: "pipe",
stderr: "pipe",
env: inheritedHostileGitEnvironment(fakeBin, fixtureToken),
});
expect(once.exitCode, once.stderr.toString()).toBe(0);
expect(`${once.stdout.toString()}\n${once.stderr.toString()}`).not.toContain(fixtureToken);
const onceStatus = JSON.parse(once.stdout.toString());
expect(onceStatus.ready).toBe(true);
expect(onceStatus.repositories[0].remoteFingerprint).toBe(`sha256:${sha256(remoteUrl)}`);
expect(existsSync(join(retainedPath, "marker"))).toBe(true);
const gitObservations = readFileSync(gitObservationPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(gitObservations.some((item) => item.args.includes(remoteUrl))).toBe(true);
expect(gitObservations.every((item) => item.inheritedGitSsh === false)).toBe(true);
expect(gitObservations.every((item) => item.inheritedGitConfig === false)).toBe(true);
expect(gitObservations.every((item) => item.inheritedGithubToken === false)).toBe(true);
expect(gitObservations.every((item) => item.askPassConfigured === true)).toBe(true);
expect(gitObservations.every((item) => item.tlsVerifyForced === true)).toBe(true);
expect(readFileSync(gitObservationPath, "utf8")).not.toContain(fixtureToken);
const mirroredCommit = git([
"--git-dir",
@@ -240,6 +343,8 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
"refs/heads/main^{commit}",
]).trim();
expect(mirroredCommit).toMatch(/^[0-9a-f]{40}$/u);
expect(readFileSync(join(cache, "acme", "example.git", "config"), "utf8")).not.toContain(fixtureToken);
expect(readFileSync(join(cache, ".managed-state", "status.json"), "utf8")).not.toContain(fixtureToken);
git([
"--git-dir",
join(cache, "acme", "example.git"),
@@ -250,6 +355,7 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
const secondOnce = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], {
stdout: "pipe",
stderr: "pipe",
env: inheritedHostileGitEnvironment(fakeBin, fixtureToken),
});
expect(secondOnce.exitCode, secondOnce.stderr.toString()).toBe(0);
expect(git([
@@ -270,6 +376,7 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
const health = Bun.spawnSync(["node", controllerPath, "--health", "--config", configPath], {
stdout: "pipe",
stderr: "pipe",
env: inheritedHostileGitEnvironment(fakeBin, fixtureToken),
});
expect(health.exitCode, health.stderr.toString()).toBe(0);
const healthStatus = JSON.parse(health.stdout.toString());
@@ -285,6 +392,113 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
}
});
test("keeps readiness false and skips Git when the projected credential is missing or empty", () => {
const temporary = mkdtempSync("/tmp/unidesk-agentrun-managed-repository-credential-");
try {
const cache = join(temporary, "cache");
const auth = join(temporary, "auth");
const fakeBin = join(temporary, "bin");
const gitMarker = join(temporary, "git-invoked");
const tokenPath = join(auth, "token");
const fixtureAskPassPath = join(auth, "git-askpass.mjs");
mkdirSync(cache, { recursive: true });
mkdirSync(auth, { recursive: true });
mkdirSync(fakeBin, { recursive: true });
copyFileSync(askPassPath, fixtureAskPassPath);
chmodSync(fixtureAskPassPath, 0o555);
writeFileSync(join(fakeBin, "git"), `#!/bin/sh\ntouch ${JSON.stringify(gitMarker)}\nexit 99\n`);
chmodSync(join(fakeBin, "git"), 0o755);
const configPath = join(temporary, "config.json");
writeControllerConfig(configPath, { cache, tokenPath, askPassFile: fixtureAskPassPath });
writeFileSync(tokenPath, "\n");
const empty = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], {
stdout: "pipe",
stderr: "pipe",
env: inheritedHostileGitEnvironment(fakeBin, "inherited-token-must-not-leak"),
});
expect(empty.exitCode).toBe(1);
const emptyStatus = lastJsonLine(empty.stdout.toString());
expect(emptyStatus).toMatchObject({
ready: false,
credential: { ready: false, errorCode: "credential-empty", valuesPrinted: false },
repositories: [{ lastAttemptOk: false, errorCode: "credential-empty" }],
valuesPrinted: false,
});
expect(existsSync(gitMarker)).toBe(false);
rmSync(tokenPath);
const missing = Bun.spawnSync(["node", controllerPath, "--health", "--config", configPath], {
stdout: "pipe",
stderr: "pipe",
env: inheritedHostileGitEnvironment(fakeBin, "inherited-token-must-not-leak"),
});
expect(missing.exitCode).toBe(1);
expect(JSON.parse(missing.stdout.toString())).toMatchObject({
ready: false,
credential: { ready: false, errorCode: "credential-file-missing", valuesPrinted: false },
valuesPrinted: false,
});
expect(existsSync(gitMarker)).toBe(false);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
});
test("redacts authentication failures from logs, status, and repository config", () => {
const temporary = mkdtempSync("/tmp/unidesk-agentrun-managed-repository-redaction-");
try {
const work = join(temporary, "work");
const remote = join(temporary, "remote.git");
const cache = join(temporary, "cache");
const auth = join(temporary, "auth");
const fakeBin = join(temporary, "bin");
const tokenPath = join(auth, "token");
const fixtureAskPassPath = join(auth, "git-askpass.mjs");
const gitObservationPath = join(temporary, "git-observations.jsonl");
const remoteUrl = "https://github.com/acme/example.git";
const sensitive = "github_pat_sensitive_authentication_failure";
mkdirSync(work, { recursive: true });
mkdirSync(cache, { recursive: true });
mkdirSync(auth, { recursive: true });
mkdirSync(fakeBin, { recursive: true });
git(["init", work]);
git(["clone", "--bare", work, remote]);
writeFileSync(tokenPath, `${sensitive}\n`);
copyFileSync(askPassPath, fixtureAskPassPath);
chmodSync(fixtureAskPassPath, 0o555);
writeGitShim({
path: join(fakeBin, "git"),
realGit: Bun.which("git")!,
observationPath: gitObservationPath,
remoteUrl,
replacementRemoteUrl: `file://${remote}`,
failFetchMessage: `authentication failed for ${sensitive}`,
});
const configPath = join(temporary, "config.json");
writeControllerConfig(configPath, { cache, tokenPath, askPassFile: fixtureAskPassPath });
const once = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], {
stdout: "pipe",
stderr: "pipe",
env: inheritedHostileGitEnvironment(fakeBin, sensitive),
});
expect(once.exitCode).toBe(1);
const combined = `${once.stdout.toString()}\n${once.stderr.toString()}`;
expect(combined).not.toContain(sensitive);
const status = lastJsonLine(once.stdout.toString());
expect(status.repositories[0]).toMatchObject({
lastAttemptOk: false,
errorCode: "authentication-failed",
});
expect(status.repositories[0].errorFingerprint).toMatch(/^[0-9a-f]{16}$/u);
expect(readFileSync(gitObservationPath, "utf8")).not.toContain(sensitive);
expect(readFileSync(join(cache, ".managed-state", "status.json"), "utf8")).not.toContain(sensitive);
expect(existsSync(join(cache, "acme", "example.git"))).toBe(false);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
});
test("keeps the legacy default summary contract and bounds the final CLI envelope", () => {
const target = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" });
const spec = target.spec;
@@ -413,3 +627,92 @@ function git(args: string[]): string {
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function writeControllerConfig(path: string, input: { cache: string; tokenPath: string; askPassFile: string }): void {
const remote = "https://github.com/acme/example.git";
const repository = {
key: "fixture",
repository: "acme/example",
remote,
sourceBranch: "main",
desiredFingerprint: sha256(["fixture", "acme/example", remote, "main"].join("\0")),
};
const desired = {
schemaVersion: 1,
kind: "AgentRunManagedRepositoryReconciler",
nodeId: "fixture-node",
lane: "fixture-lane",
cacheMountPath: input.cache,
stateDirectory: ".managed-state",
reconcileIntervalMs: 1000,
fetchTimeoutMs: 10000,
shutdownGraceMs: 1000,
maxConcurrentRepositories: 1,
retry: { maxAttempts: 1, initialDelayMs: 10, maxDelayMs: 10 },
freshness: { maxAgeMs: 60000 },
lifecycle: { undeclaredRepositoryPolicy: "retain", managedRefs: "source-branch-only" },
repositories: [repository],
remoteAuth: {
configRef: "config/platform-infra/gitea.yaml#sourceAuthority.credentials.github.gitFetchCredential",
capabilitySha256: "a".repeat(64),
authMode: "github-https-token",
host: "github.com",
tokenFile: input.tokenPath,
askPassFile: input.askPassFile,
proxy: { host: "127.0.0.1", port: 10808 },
},
};
writeFileSync(path, `${JSON.stringify({ ...desired, desiredHash: sha256(JSON.stringify(desired)) }, null, 2)}\n`);
}
function lastJsonLine(output: string): any {
const lines = output.trim().split("\n");
return JSON.parse(lines.at(-1) ?? "null");
}
function inheritedHostileGitEnvironment(fakeBin: string, fixtureToken: string): NodeJS.ProcessEnv {
return {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
GIT_SSH: "/tmp/inherited-git-ssh",
GIT_SSH_COMMAND: "inherited-ssh-command",
GIT_CONFIG: "/tmp/inherited-git-config",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "credential.helper",
GIT_CONFIG_VALUE_0: "!inherited-credential-helper",
GIT_SSL_NO_VERIFY: "1",
GH_TOKEN: fixtureToken,
GITHUB_TOKEN: fixtureToken,
};
}
function writeGitShim(input: {
path: string;
realGit: string;
observationPath: string;
remoteUrl: string;
replacementRemoteUrl: string;
failFetchMessage?: string;
}): void {
writeFileSync(input.path, `#!/usr/bin/env node
import { appendFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
const args = process.argv.slice(2);
appendFileSync(${JSON.stringify(input.observationPath)}, JSON.stringify({
args,
inheritedGitSsh: Object.hasOwn(process.env, "GIT_SSH") || Object.hasOwn(process.env, "GIT_SSH_COMMAND"),
inheritedGitConfig: Object.hasOwn(process.env, "GIT_CONFIG") || Object.hasOwn(process.env, "GIT_CONFIG_COUNT") || Object.keys(process.env).some((key) => /^GIT_CONFIG_(?:KEY|VALUE)_/u.test(key)),
inheritedGithubToken: Object.hasOwn(process.env, "GH_TOKEN") || Object.hasOwn(process.env, "GITHUB_TOKEN"),
askPassConfigured: typeof process.env.GIT_ASKPASS === "string" && process.env.GIT_ASKPASS.length > 0 && process.env.GIT_ASKPASS_REQUIRE === "force",
tlsVerifyForced: process.env.GIT_SSL_NO_VERIFY === "0" && args.includes("http.sslVerify=true"),
}) + "\\n");
if (args.includes("fetch") && ${JSON.stringify(input.failFetchMessage ?? null)} !== null) {
process.stderr.write(${JSON.stringify(input.failFetchMessage ?? "")} + "\\n");
process.exit(1);
}
const mapped = args.map((arg) => arg === ${JSON.stringify(input.remoteUrl)} ? ${JSON.stringify(input.replacementRemoteUrl)} : arg);
const result = spawnSync(${JSON.stringify(input.realGit)}, mapped, { env: process.env, stdio: "inherit" });
process.exit(result.status ?? 1);
`);
chmodSync(input.path, 0o755);
}
@@ -7,10 +7,9 @@ import {
} from "./agentrun-lanes";
const controllerSourcePath = rootPath("scripts", "native", "agentrun", "managed-repository-reconciler.mjs");
const proxySourcePath = rootPath("scripts", "native", "agentrun", "git-mirror-proxy-connect.cjs");
const askPassSourcePath = rootPath("scripts", "native", "agentrun", "git-askpass.mjs");
const controllerMountPath = "/etc/agentrun-managed-repository";
const sshMountPath = "/var/run/secrets/agentrun-managed-repository";
const knownHostsPath = "/tmp/agentrun-managed-repository/.ssh/known_hosts";
const authMountPath = "/var/run/secrets/agentrun-managed-repository-auth";
export interface AgentRunManagedRepositoryDesiredConfig {
readonly schemaVersion: 1;
@@ -42,12 +41,17 @@ export interface AgentRunManagedRepositoryDesiredConfig {
readonly sourceBranch: string;
readonly desiredFingerprint: string;
}[];
readonly ssh: {
readonly identityFile: string;
readonly knownHostsFile: string;
readonly proxyConnectScript: string;
readonly proxyHost: string;
readonly proxyPort: number;
readonly remoteAuth: {
readonly configRef: string;
readonly capabilitySha256: string;
readonly authMode: "github-https-token";
readonly host: "github.com";
readonly tokenFile: string;
readonly askPassFile: string;
readonly proxy: {
readonly host: string;
readonly port: number;
};
};
readonly desiredHash: string;
}
@@ -75,12 +79,14 @@ export function agentRunManagedRepositoryDesiredConfig(spec: AgentRunLaneSpec):
sourceBranch: repository.sourceBranch,
desiredFingerprint: sha256([repository.key, repository.repository, repository.remote, repository.sourceBranch].join("\0")),
})),
ssh: {
identityFile: `${sshMountPath}/ssh-privatekey`,
knownHostsFile: knownHostsPath,
proxyConnectScript: `${controllerMountPath}/proxy-connect.cjs`,
proxyHost: spec.gitMirror.githubProxy.host,
proxyPort: spec.gitMirror.githubProxy.port,
remoteAuth: {
configRef: reconciler.remoteAuth.configRef,
capabilitySha256: reconciler.remoteAuth.capabilitySha256,
authMode: reconciler.remoteAuth.authMode,
host: reconciler.remoteAuth.host,
tokenFile: `${authMountPath}/token`,
askPassFile: `${authMountPath}/git-askpass.mjs`,
proxy: spec.gitMirror.githubProxy,
},
};
return { ...desired, desiredHash: sha256(JSON.stringify(desired)) };
@@ -92,7 +98,7 @@ export function agentRunManagedRepositoryRenderHash(spec: AgentRunLaneSpec): str
JSON.stringify(config),
JSON.stringify(managedRepositoryManifestIdentity(spec)),
readFileSync(controllerSourcePath, "utf8"),
readFileSync(proxySourcePath, "utf8"),
readFileSync(askPassSourcePath, "utf8"),
].join("\0"));
}
@@ -133,7 +139,7 @@ function managedRepositoryObjects(spec: AgentRunLaneSpec): Record<string, unknow
const desired = agentRunManagedRepositoryDesiredConfig(spec);
const desiredJson = JSON.stringify(desired, null, 2);
const controllerSource = readFileSync(controllerSourcePath, "utf8");
const proxySource = readFileSync(proxySourcePath, "utf8");
const askPassSource = readFileSync(askPassSourcePath, "utf8");
const renderHash = agentRunManagedRepositoryRenderHash(spec);
const labels = {
"app.kubernetes.io/name": reconciler.deploymentName,
@@ -146,6 +152,7 @@ function managedRepositoryObjects(spec: AgentRunLaneSpec): Record<string, unknow
const annotations = {
"unidesk.ai/managed-repository-desired-sha": desired.desiredHash,
"unidesk.ai/managed-repository-render-sha": renderHash,
"unidesk.ai/git-fetch-credential-sha": reconciler.remoteAuth.capabilitySha256,
"unidesk.ai/undeclared-repository-policy": reconciler.lifecycle.undeclaredRepositoryPolicy,
"unidesk.ai/managed-refs": reconciler.lifecycle.managedRefs,
};
@@ -175,7 +182,7 @@ function managedRepositoryObjects(spec: AgentRunLaneSpec): Record<string, unknow
data: {
"config.json": `${desiredJson}\n`,
"controller.mjs": controllerSource,
"proxy-connect.cjs": proxySource,
"git-askpass.mjs": askPassSource,
},
},
{
@@ -239,13 +246,31 @@ function managedRepositoryObjects(spec: AgentRunLaneSpec): Record<string, unknow
volumeMounts: [
{ name: "config", mountPath: controllerMountPath, readOnly: true },
{ name: "cache", mountPath: reconciler.cacheMountPath },
{ name: "git-ssh", mountPath: sshMountPath, readOnly: true },
{ name: "git-auth", mountPath: authMountPath, readOnly: true },
],
}],
volumes: [
{ name: "config", configMap: { name: reconciler.configMapName, defaultMode: 0o555 } },
cacheVolume,
{ name: "git-ssh", secret: { secretName: spec.gitMirror.sshSecretName, defaultMode: 0o400 } },
{
name: "git-auth",
projected: {
sources: [
{
configMap: {
name: reconciler.configMapName,
items: [{ key: "git-askpass.mjs", path: "git-askpass.mjs", mode: 0o555 }],
},
},
{
secret: {
name: reconciler.remoteAuth.secretRef.name,
items: [{ key: reconciler.remoteAuth.secretRef.key, path: "token", mode: 0o400 }],
},
},
],
},
},
],
},
},
@@ -267,7 +292,11 @@ function managedRepositoryManifestIdentity(spec: AgentRunLaneSpec): Record<strin
imagePullPolicy: reconciler.imagePullPolicy,
cachePvc: spec.gitMirror.cachePvc,
cacheHostPath: spec.gitMirror.cacheHostPath,
sshSecretName: spec.gitMirror.sshSecretName,
remoteAuth: {
configRef: reconciler.remoteAuth.configRef,
capabilitySha256: reconciler.remoteAuth.capabilitySha256,
secretRef: reconciler.remoteAuth.secretRef,
},
readiness: reconciler.readiness,
resources: reconciler.resources,
};
+1 -1
View File
@@ -53,7 +53,7 @@ run_apply() {
fi
if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \
--from-literal=github-token="$UNIDESK_GITEA_GITHUB_TOKEN" \
--from-literal="$UNIDESK_GITEA_GITHUB_SECRET_KEY=$UNIDESK_GITEA_GITHUB_TOKEN" \
--from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \
--from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \
--from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \
+41 -3
View File
@@ -169,6 +169,17 @@ interface GiteaGithubCredential {
sourceKey: string;
format: "env" | "raw-token";
requiredFor: string[];
gitFetchCredential: {
apiVersion: "unidesk.ai/v1";
kind: "GitFetchCredential";
authMode: "github-https-token";
host: "github.com";
secretRef: {
namespace: string;
name: string;
key: string;
};
};
}
interface GiteaWebhookSync {
@@ -523,12 +534,25 @@ function parseAdminCredential(record: Record<string, unknown>, path: string): Gi
}
function parseGithubCredential(record: Record<string, unknown>, path: string): GiteaGithubCredential {
const gitFetchCredential = y.objectField(record, "gitFetchCredential", path);
const secretRef = y.objectField(gitFetchCredential, "secretRef", `${path}.gitFetchCredential`);
return {
transport: y.enumField(record, "transport", path, ["https-token"] as const),
sourceRef: secretSourceRefField(record, "sourceRef", path),
sourceKey: y.envKeyField(record, "sourceKey", path),
format: optionalLiteralField(record, "format", path, ["env", "raw-token"] as const) ?? "env",
requiredFor: y.stringArrayField(record, "requiredFor", path),
gitFetchCredential: {
apiVersion: y.enumField(gitFetchCredential, "apiVersion", `${path}.gitFetchCredential`, ["unidesk.ai/v1"] as const),
kind: y.enumField(gitFetchCredential, "kind", `${path}.gitFetchCredential`, ["GitFetchCredential"] as const),
authMode: y.enumField(gitFetchCredential, "authMode", `${path}.gitFetchCredential`, ["github-https-token"] as const),
host: y.enumField(gitFetchCredential, "host", `${path}.gitFetchCredential`, ["github.com"] as const),
secretRef: {
namespace: y.kubernetesNameField(secretRef, "namespace", `${path}.gitFetchCredential.secretRef`),
name: y.kubernetesNameField(secretRef, "name", `${path}.gitFetchCredential.secretRef`),
key: y.stringField(secretRef, "key", `${path}.gitFetchCredential.secretRef`),
},
},
};
}
@@ -765,6 +789,19 @@ function parseTargetWebhookSync(record: Record<string, unknown>, path: string):
function validateConfig(gitea: GiteaConfig): void {
resolveTarget(gitea, gitea.defaults.targetId);
const gitFetchCredential = gitea.sourceAuthority.credentials.github.gitFetchCredential;
if (!gitea.sourceAuthority.credentials.github.requiredFor.includes("managed-repository-fetch")) {
throw new Error(`${configLabel}.sourceAuthority.credentials.github.requiredFor must include managed-repository-fetch`);
}
if (!/^[A-Za-z0-9._-]+$/u.test(gitFetchCredential.secretRef.key)) {
throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key must be a Kubernetes Secret data key`);
}
if (gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) {
throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`);
}
if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) {
throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`);
}
if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`);
if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`);
if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`);
@@ -1413,7 +1450,7 @@ spec:
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: github-token
key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
- name: GITEA_USERNAME
valueFrom:
secretKeyRef:
@@ -1578,7 +1615,7 @@ spec:
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: github-token
key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
- name: GITEA_USERNAME
valueFrom:
secretKeyRef:
@@ -1687,6 +1724,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl,
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
UNIDESK_GITEA_GITHUB_SECRET_KEY: gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key,
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
UNIDESK_GITEA_FRPC_ENABLED: frpcExposure === null ? "0" : "1",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: frpcExposure?.frpc.deploymentName ?? gitea.app.publicExposure.frpc.deploymentName,
@@ -1988,7 +2026,7 @@ function webhookSecretSummary(gitea: GiteaConfig, target: GiteaTarget, secrets:
publicUrl: githubWebhookPublicUrl(gitea, target),
sourceRef: sync.secret.sourceRef,
targetSecret: sync.bridge.secretName,
keys: ["github-token", "gitea-username", "gitea-password", "github-webhook-secret"],
keys: [gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key, "gitea-username", "gitea-password", "github-webhook-secret"],
fingerprint: fingerprintSecretParts([secrets.webhookSecret]),
valuesPrinted: false,
};