fix(agentrun): authenticate managed repository imports
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ..`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user