feat: sanitize K8s label values + collision-resistant job names (FAR-43)
- Add `sanitizeLabelValue()` export: strips [^a-z0-9._-], lowercases, truncates to 63 chars, warns on drop - Apply sanitizer to all paperclip.io/* label values (agent-id, run-id, company-id, extra labels) - Job name now includes 6-char sha256 hash over raw agent.id+runId for collision resistance - Trailing hyphens stripped from final job name - Slugs extended from 8 to 16 chars to match claude_k8s reference - 32 unit tests covering sanitizeLabelValue, job name format, determinism, and collision avoidance Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { buildJobManifest, type JobBuildInput } from "./job-manifest.js";
|
import { buildJobManifest, sanitizeLabelValue, type JobBuildInput } from "./job-manifest.js";
|
||||||
|
|
||||||
const mockSelfPod: JobBuildInput["selfPod"] = {
|
const mockSelfPod: JobBuildInput["selfPod"] = {
|
||||||
namespace: "paperclip",
|
namespace: "paperclip",
|
||||||
@@ -195,4 +195,98 @@ describe("buildJobManifest", () => {
|
|||||||
const container = result.job.spec?.template?.spec?.containers?.[0];
|
const container = result.job.spec?.template?.spec?.containers?.[0];
|
||||||
expect(container?.envFrom).toBeUndefined();
|
expect(container?.envFrom).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("job name includes 6-char hash suffix", () => {
|
||||||
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
// format: agent-opencode-{agentSlug}-{runSlug}-{6hexchars}
|
||||||
|
expect(result.jobName).toMatch(/^agent-opencode-[a-z0-9-]+-[a-f0-9]{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("job name has no trailing hyphens even when slug sanitization creates them", () => {
|
||||||
|
const ctx = {
|
||||||
|
...mockCtx,
|
||||||
|
agent: { ...mockCtx.agent, id: "agent---" },
|
||||||
|
runId: "run---",
|
||||||
|
};
|
||||||
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
expect(result.jobName).not.toMatch(/-$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("label values are sanitized to [a-z0-9._-]", () => {
|
||||||
|
const ctx = {
|
||||||
|
...mockCtx,
|
||||||
|
agent: { ...mockCtx.agent, id: "Agent_ID/123", companyId: "Co:456" },
|
||||||
|
runId: "Run@789",
|
||||||
|
};
|
||||||
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
const labels = result.job.metadata?.labels ?? {};
|
||||||
|
expect(labels["paperclip.io/agent-id"]).toMatch(/^[a-z0-9._-]*$/);
|
||||||
|
expect(labels["paperclip.io/run-id"]).toMatch(/^[a-z0-9._-]*$/);
|
||||||
|
expect(labels["paperclip.io/company-id"]).toMatch(/^[a-z0-9._-]*$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same agent+run always produces the same job name (deterministic hash)", () => {
|
||||||
|
const r1 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||||
|
const r2 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
expect(r1.jobName).toBe(r2.jobName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("different runIds produce different job names", () => {
|
||||||
|
const ctx2 = { ...mockCtx, runId: "run-different-999" };
|
||||||
|
const r1 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||||
|
const r2 = buildJobManifest({ ctx: ctx2, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
expect(r1.jobName).not.toBe(r2.jobName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeLabelValue", () => {
|
||||||
|
it("passes through clean values unchanged", () => {
|
||||||
|
expect(sanitizeLabelValue("abc-123")).toBe("abc-123");
|
||||||
|
expect(sanitizeLabelValue("foo.bar_baz")).toBe("foo.bar_baz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lowercases uppercase letters", () => {
|
||||||
|
expect(sanitizeLabelValue("MyAgent")).toBe("myagent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips chars outside [a-z0-9._-]", () => {
|
||||||
|
expect(sanitizeLabelValue("agent/id:test@v1")).toBe("agentidtestv1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates to 63 chars", () => {
|
||||||
|
const long = "a".repeat(80);
|
||||||
|
expect(sanitizeLabelValue(long)).toHaveLength(63);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls warn when chars are dropped", () => {
|
||||||
|
const warned: string[] = [];
|
||||||
|
sanitizeLabelValue("agent/id", (msg) => warned.push(msg));
|
||||||
|
expect(warned.length).toBe(1);
|
||||||
|
expect(warned[0]).toContain("agent/id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call warn when nothing is dropped", () => {
|
||||||
|
const warned: string[] = [];
|
||||||
|
sanitizeLabelValue("clean-value.123", (msg) => warned.push(msg));
|
||||||
|
expect(warned.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call warn when the only change is truncation to exactly 63", () => {
|
||||||
|
const warned: string[] = [];
|
||||||
|
// 63 chars all lowercase clean — no warn expected
|
||||||
|
sanitizeLabelValue("a".repeat(63), (msg) => warned.push(msg));
|
||||||
|
expect(warned.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls warn when value contains invalid chars even if truncated result looks clean", () => {
|
||||||
|
const warned: string[] = [];
|
||||||
|
// 'a/b' repeated — '/' is stripped, so sanitized differs from lower.slice(0,63)
|
||||||
|
sanitizeLabelValue("a/b".repeat(25), (msg) => warned.push(msg));
|
||||||
|
expect(warned.length).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+30
-10
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
import type * as k8s from "@kubernetes/client-node";
|
import type * as k8s from "@kubernetes/client-node";
|
||||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
@@ -80,8 +81,25 @@ function parseKeyValueOrObject(value: unknown): Record<string, string> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeForK8sName(value: string): string {
|
function sanitizeForK8sName(value: string, maxLen = 16): string {
|
||||||
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
|
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips chars outside [a-z0-9._-], lowercases, truncates to 63 chars (K8s label value limit).
|
||||||
|
* Emits a stderr warning via `warn` when chars are dropped.
|
||||||
|
*/
|
||||||
|
export function sanitizeLabelValue(value: string, warn?: (msg: string) => void): string {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
const sanitized = lower.replace(/[^a-z0-9._-]/g, "").slice(0, 63);
|
||||||
|
if (warn && sanitized !== lower.slice(0, 63)) {
|
||||||
|
warn(`[paperclip] sanitizeLabelValue: dropped chars from "${value}" -> "${sanitized}"\n`);
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameHash(...parts: string[]): string {
|
||||||
|
return createHash("sha256").update(parts.join(":")).digest("hex").slice(0, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEnvVars(
|
function buildEnvVars(
|
||||||
@@ -193,7 +211,8 @@ function buildRuntimeConfigJson(config: Record<string, unknown>): string | null
|
|||||||
|
|
||||||
export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||||
const { ctx, selfPod } = input;
|
const { ctx, selfPod } = input;
|
||||||
const { runId, agent, runtime, config: rawConfig, context } = ctx;
|
const { runId, agent, runtime, config: rawConfig, context, onLog } = ctx;
|
||||||
|
const warnLabel = (msg: string) => void onLog("stderr", msg).catch(() => {});
|
||||||
const config = parseObject(rawConfig);
|
const config = parseObject(rawConfig);
|
||||||
|
|
||||||
const namespace = asString(config.namespace, "") || selfPod.namespace;
|
const namespace = asString(config.namespace, "") || selfPod.namespace;
|
||||||
@@ -214,10 +233,11 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
const configuredCwd = asString(config.cwd, "");
|
const configuredCwd = asString(config.cwd, "");
|
||||||
const workingDir = workspaceCwd || configuredCwd || "/paperclip";
|
const workingDir = workspaceCwd || configuredCwd || "/paperclip";
|
||||||
|
|
||||||
// Job naming
|
// Job naming: slug + 6-char hash for collision resistance; strip trailing hyphens
|
||||||
const agentSlug = sanitizeForK8sName(agent.id);
|
const agentSlug = sanitizeForK8sName(agent.id);
|
||||||
const runSlug = sanitizeForK8sName(runId);
|
const runSlug = sanitizeForK8sName(runId);
|
||||||
const jobName = `agent-opencode-${agentSlug}-${runSlug}`;
|
const hash = nameHash(agent.id, runId);
|
||||||
|
const jobName = `agent-opencode-${agentSlug}-${runSlug}-${hash}`.replace(/-+$/, "");
|
||||||
|
|
||||||
// Build prompt
|
// Build prompt
|
||||||
const promptTemplate = asString(
|
const promptTemplate = asString(
|
||||||
@@ -291,17 +311,17 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Labels
|
// Labels: sanitize all values to [a-z0-9._-] max 63 chars (K8s RFC 1123)
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
"app.kubernetes.io/managed-by": "paperclip",
|
"app.kubernetes.io/managed-by": "paperclip",
|
||||||
"app.kubernetes.io/component": "agent-job",
|
"app.kubernetes.io/component": "agent-job",
|
||||||
"paperclip.io/agent-id": agent.id,
|
"paperclip.io/agent-id": sanitizeLabelValue(agent.id, warnLabel),
|
||||||
"paperclip.io/run-id": runId,
|
"paperclip.io/run-id": sanitizeLabelValue(runId, warnLabel),
|
||||||
"paperclip.io/company-id": agent.companyId,
|
"paperclip.io/company-id": sanitizeLabelValue(agent.companyId, warnLabel),
|
||||||
"paperclip.io/adapter-type": "opencode_k8s",
|
"paperclip.io/adapter-type": "opencode_k8s",
|
||||||
};
|
};
|
||||||
for (const [key, value] of Object.entries(extraLabels)) {
|
for (const [key, value] of Object.entries(extraLabels)) {
|
||||||
if (typeof value === "string") labels[key] = value;
|
if (typeof value === "string") labels[key] = sanitizeLabelValue(value, warnLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volumes
|
// Volumes
|
||||||
|
|||||||
Reference in New Issue
Block a user