Skip to content

Commit 18eb635

Browse files
myftijaTrigger.dev RepoOps
authored andcommitted
fix(supervisor): keep a Runner's token Secret after a 408 on its create
In the run-crd lane, a Runner's token Secret is no longer deleted when the Runner create fails with a 408. A proxy may answer 408 for an upstream timeout, so the Runner may exist and would then start without its token. The Secret is now kept, as it already was on a 5xx or a lost response, and the existing in-the-way handling takes over. Mono-RevId: 1701dd8134b222c49ac094db3dd18bba3f569e71
1 parent 1c92802 commit 18eb635

5 files changed

Lines changed: 367 additions & 8 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: supervisor
3+
type: fix
4+
---
5+
6+
Runs no longer lose their worker's token when the cluster answers the worker's create with a 408, so the worker starts instead of waiting for a requeue.
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import { execFileSync } from "node:child_process";
2+
import { randomBytes } from "node:crypto";
3+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import type { IncomingMessage, ServerResponse } from "node:http";
5+
import { createServer, request, type Server } from "node:https";
6+
import type { AddressInfo } from "node:net";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
9+
import * as k8s from "@kubernetes/client-node";
10+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
11+
import { getRunnerId } from "../util.js";
12+
import { RunCrdWorkloadManager, runnerTokenSecretName } from "./runCrd.js";
13+
import type { WorkloadManagerCreateOptions } from "./types.js";
14+
15+
/** A kubeconfig for a cluster with the Runner CRD installed, such as a kind cluster; the test makes a namespace of its own. */
16+
const KUBECONFIG = process.env.RUN_CRD_TEST_KUBECONFIG;
17+
const NAMESPACE = `v4-runs-test-${randomBytes(4).toString("hex")}`;
18+
const GROUP = "compute.trigger.dev";
19+
const VERSION = "v1alpha1";
20+
const PLURAL = "runners";
21+
const TEMPLATE_KEY = "4f2b1c0e9d8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c";
22+
const TOKEN = "tok_cluster_test";
23+
24+
/** What this test's proxy does to a Runner create: forwards it or not, then answers as a proxy that lost its upstream would. */
25+
type Fault = { forward: boolean; answer: number | "cut" };
26+
27+
/**
28+
* The manager against a real Kubernetes API, reached through a proxy of this test's own: the
29+
* statuses that matter for the token Secret's fate are ones the apiserver itself never sends,
30+
* so the proxy forwards the Runner create and then answers the client with them.
31+
*/
32+
describe.skipIf(!KUBECONFIG)("RunCrdWorkloadManager against a real Kubernetes API", () => {
33+
const cluster = new k8s.KubeConfig();
34+
let api: { core: k8s.CoreV1Api; custom: k8s.CustomObjectsApi };
35+
let upstream: { url: URL; ca: Buffer; cert: Buffer; key: Buffer };
36+
let proxy: Server;
37+
let fault: Fault | undefined;
38+
let dir: string;
39+
let previousKubeconfig: string | undefined;
40+
const runners: string[] = [];
41+
const secrets: string[] = [];
42+
43+
beforeAll(async () => {
44+
cluster.loadFromFile(KUBECONFIG!);
45+
api = {
46+
core: cluster.makeApiClient(k8s.CoreV1Api),
47+
custom: cluster.makeApiClient(k8s.CustomObjectsApi),
48+
};
49+
await api.core.createNamespace({ body: { metadata: { name: NAMESPACE } } });
50+
const target = cluster.getCurrentCluster()!;
51+
const user = cluster.getCurrentUser()!;
52+
upstream = {
53+
url: new URL(target.server),
54+
ca: Buffer.from(target.caData!, "base64"),
55+
cert: Buffer.from(user.certData!, "base64"),
56+
key: Buffer.from(user.keyData!, "base64"),
57+
};
58+
dir = mkdtempSync(join(tmpdir(), "run-crd-cluster-"));
59+
const key = join(dir, "key.pem");
60+
const cert = join(dir, "cert.pem");
61+
execFileSync(
62+
"openssl",
63+
[
64+
"req",
65+
"-x509",
66+
"-newkey",
67+
"rsa:2048",
68+
"-nodes",
69+
"-days",
70+
"1",
71+
"-subj",
72+
"/CN=127.0.0.1",
73+
"-addext",
74+
"subjectAltName=IP:127.0.0.1",
75+
"-keyout",
76+
key,
77+
"-out",
78+
cert,
79+
],
80+
{ stdio: "ignore" }
81+
);
82+
proxy = createServer({ key: readFileSync(key), cert: readFileSync(cert) }, relay);
83+
await new Promise<void>((resolve) => proxy.listen(0, "127.0.0.1", resolve));
84+
const { port } = proxy.address() as AddressInfo;
85+
const kubeconfig = join(dir, "config");
86+
writeFileSync(
87+
kubeconfig,
88+
[
89+
"apiVersion: v1",
90+
"kind: Config",
91+
"clusters:",
92+
"- name: proxy",
93+
" cluster:",
94+
` server: https://127.0.0.1:${port}`,
95+
" insecure-skip-tls-verify: true",
96+
"users:",
97+
"- name: proxy",
98+
" user: {}",
99+
"contexts:",
100+
"- name: proxy",
101+
" context:",
102+
" cluster: proxy",
103+
" user: proxy",
104+
"current-context: proxy",
105+
"",
106+
].join("\n")
107+
);
108+
previousKubeconfig = process.env.KUBECONFIG;
109+
process.env.KUBECONFIG = kubeconfig;
110+
});
111+
112+
afterAll(async () => {
113+
if (previousKubeconfig === undefined) {
114+
delete process.env.KUBECONFIG;
115+
} else {
116+
process.env.KUBECONFIG = previousKubeconfig;
117+
}
118+
await new Promise<void>((resolve) => proxy.close(() => resolve()));
119+
rmSync(dir, { recursive: true, force: true });
120+
await api.core.deleteNamespace({ name: NAMESPACE });
121+
});
122+
123+
afterEach(async () => {
124+
fault = undefined;
125+
for (const name of runners.splice(0)) {
126+
await api.custom
127+
.deleteNamespacedCustomObject({
128+
group: GROUP,
129+
version: VERSION,
130+
namespace: NAMESPACE,
131+
plural: PLURAL,
132+
name,
133+
})
134+
.catch(() => {});
135+
}
136+
for (const name of secrets.splice(0)) {
137+
await api.core.deleteNamespacedSecret({ name, namespace: NAMESPACE }).catch(() => {});
138+
}
139+
});
140+
141+
function relay(req: IncomingMessage, res: ServerResponse) {
142+
const url = new URL(req.url ?? "/", upstream.url);
143+
const runnerCreate = req.method === "POST" && url.pathname.endsWith(`/${PLURAL}`);
144+
const current = fault;
145+
if (runnerCreate && current && !current.forward) {
146+
req.resume();
147+
answer(res, current.answer);
148+
return;
149+
}
150+
const forwarded = request(
151+
url,
152+
{
153+
method: req.method,
154+
headers: { ...req.headers, host: url.host },
155+
ca: upstream.ca,
156+
cert: upstream.cert,
157+
key: upstream.key,
158+
},
159+
(reply) => {
160+
if (runnerCreate && current) {
161+
reply.resume();
162+
reply.on("end", () => answer(res, current.answer));
163+
return;
164+
}
165+
res.writeHead(reply.statusCode ?? 502, reply.headers);
166+
reply.pipe(res);
167+
}
168+
);
169+
forwarded.on("error", (error) => {
170+
res.writeHead(502);
171+
res.end(String(error));
172+
});
173+
req.pipe(forwarded);
174+
}
175+
176+
function answer(res: ServerResponse, how: number | "cut") {
177+
if (how === "cut") {
178+
res.destroy();
179+
return;
180+
}
181+
res.writeHead(how, { "content-type": "application/json" });
182+
res.end(
183+
JSON.stringify({
184+
kind: "Status",
185+
apiVersion: "v1",
186+
status: "Failure",
187+
code: how,
188+
message: `faulted ${how}`,
189+
})
190+
);
191+
}
192+
193+
function manager() {
194+
return new RunCrdWorkloadManager({
195+
workloadApiProtocol: "http",
196+
workloadApiPort: 8020,
197+
namespace: NAMESPACE,
198+
runtime: "microvm",
199+
});
200+
}
201+
202+
/** A cold start of a run of its own, so names never collide across tests. */
203+
function options(): WorkloadManagerCreateOptions & { runnerId: string; secretName: string } {
204+
const runFriendlyId = `run_${randomBytes(6).toString("hex")}`;
205+
const runnerId = getRunnerId(runFriendlyId);
206+
const secretName = runnerTokenSecretName(runnerId, TOKEN);
207+
runners.push(runnerId);
208+
secrets.push(secretName);
209+
return {
210+
image: "registry.example.com/proj/worker:20260827.1@sha256:" + "0".repeat(64),
211+
templateKey: TEMPLATE_KEY,
212+
machine: { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0 },
213+
version: "20260827.1",
214+
dequeuedAt: new Date(),
215+
envId: "env_abc",
216+
envType: "PRODUCTION",
217+
orgId: "org_abc",
218+
projectId: "proj_abc",
219+
deploymentFriendlyId: "deployment_abc",
220+
deploymentVersion: "20260827.1",
221+
deploymentToken: TOKEN,
222+
runId: "run_internal",
223+
runFriendlyId,
224+
snapshotId: "snapshot_internal",
225+
snapshotFriendlyId: "snapshot_abc",
226+
runnerId,
227+
secretName,
228+
};
229+
}
230+
231+
async function runner(name: string) {
232+
return (await api.custom.getNamespacedCustomObject({
233+
group: GROUP,
234+
version: VERSION,
235+
namespace: NAMESPACE,
236+
plural: PLURAL,
237+
name,
238+
})) as { metadata: { uid: string }; spec: { deployment: { token?: { name: string } } } };
239+
}
240+
241+
async function secret(name: string) {
242+
return await api.core.readNamespacedSecret({ name, namespace: NAMESPACE });
243+
}
244+
245+
/** 200 when the read succeeds, else the API's status code. */
246+
async function status(promise: Promise<unknown>): Promise<number | undefined> {
247+
try {
248+
await promise;
249+
return 200;
250+
} catch (error) {
251+
return (error as { code?: number }).code;
252+
}
253+
}
254+
255+
it("creates the Runner with a reference to its token Secret and adopts the Secret", async () => {
256+
const opts = options();
257+
await manager().create(opts);
258+
const created = await runner(opts.runnerId);
259+
expect(created.spec.deployment.token).toMatchObject({ name: opts.secretName });
260+
const adopted = await secret(opts.secretName);
261+
expect(adopted.immutable).toBe(true);
262+
expect(adopted.metadata?.ownerReferences).toMatchObject([
263+
{ kind: "Runner", name: opts.runnerId, uid: created.metadata.uid },
264+
]);
265+
});
266+
267+
it.each([408, 503])(
268+
"keeps the Secret when the Runner create was forwarded and answered with a %s, so the Runner it made can start",
269+
async (code) => {
270+
const opts = options();
271+
fault = { forward: true, answer: code };
272+
await expect(manager().create(opts)).rejects.toMatchObject({ code });
273+
const created = await runner(opts.runnerId);
274+
expect(created.spec.deployment.token).toMatchObject({ name: opts.secretName });
275+
expect(await status(secret(opts.secretName))).toBe(200);
276+
277+
// The redelivery finds the Runner in the way and leaves the Secret, which it did not write, in place.
278+
fault = undefined;
279+
await manager().create(opts);
280+
expect(await status(secret(opts.secretName))).toBe(200);
281+
}
282+
);
283+
284+
it("keeps the Secret when the Runner create's answer never came", async () => {
285+
const opts = options();
286+
fault = { forward: true, answer: "cut" };
287+
await expect(manager().create(opts)).rejects.toThrow();
288+
expect(await status(runner(opts.runnerId))).toBe(200);
289+
expect(await status(secret(opts.secretName))).toBe(200);
290+
});
291+
292+
it.each([403, 422])(
293+
"deletes the Secret it wrote when the apiserver refused the Runner create with a %s",
294+
async (code) => {
295+
const opts = options();
296+
fault = { forward: false, answer: code };
297+
await expect(manager().create(opts)).rejects.toMatchObject({ code });
298+
expect(await status(runner(opts.runnerId))).toBe(404);
299+
expect(await status(secret(opts.secretName))).toBe(404);
300+
}
301+
);
302+
});

‎apps/supervisor/src/workloadManager/runCrd.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,54 @@ describe("RunCrdWorkloadManager.create", () => {
617617
expect(body.spec.templateKey).toBe(TEMPLATE_KEY);
618618
});
619619

620+
describe("the token Secret after a failed create", () => {
621+
beforeEach(() => {
622+
createSecret.mockReset();
623+
createSecret.mockResolvedValue({ metadata: { uid: "uid-secret" } });
624+
deleteSecret.mockReset();
625+
deleteSecret.mockResolvedValue({});
626+
});
627+
628+
it.each([
629+
["a 408", { code: 408 }],
630+
["a 500", { code: 500 }],
631+
["a 503", { code: 503 }],
632+
["no status", new Error("socket hang up")],
633+
])("is kept when the create's outcome is unknown: %s", async (_, err) => {
634+
createRunner.mockRejectedValue(err);
635+
await expect(manager().create(keyedOptions({ deploymentToken: "tok_secret" }))).rejects.toBe(
636+
err
637+
);
638+
expect(createSecret).toHaveBeenCalledTimes(1);
639+
expect(deleteSecret).not.toHaveBeenCalled();
640+
});
641+
642+
it.each([[400], [403], [422], [429]])(
643+
"is deleted, guarded by its uid, when the create was refused: %s",
644+
async (code) => {
645+
createRunner.mockRejectedValue({ code });
646+
await expect(
647+
manager().create(keyedOptions({ deploymentToken: "tok_secret" }))
648+
).rejects.toMatchObject({ code });
649+
expect(deleteSecret).toHaveBeenCalledWith(
650+
expect.objectContaining({
651+
name: expect.stringContaining("-token-"),
652+
body: { preconditions: { uid: "uid-secret" } },
653+
})
654+
);
655+
}
656+
);
657+
658+
it("is left alone when an earlier delivery wrote it, whatever refused the create", async () => {
659+
createSecret.mockRejectedValue({ code: 409 });
660+
createRunner.mockRejectedValue({ code: 422 });
661+
await expect(
662+
manager().create(keyedOptions({ deploymentToken: "tok_secret" }))
663+
).rejects.toMatchObject({ code: 422 });
664+
expect(deleteSecret).not.toHaveBeenCalled();
665+
});
666+
});
667+
620668
it.each([
621669
["no", undefined, "TemplateKeyMissing"],
622670
["a malformed", TEMPLATE_KEY.toUpperCase(), "TemplateKeyMalformed"],

‎apps/supervisor/src/workloadManager/runCrd.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -612,13 +612,12 @@ export class RunCrdWorkloadManager implements WorkloadManager, RunnerSnapshotter
612612

613613
/**
614614
* Deletes the Secret a failed create left behind; with no owner coming, the
615-
* collector would never take it.
616-
*
617-
* Only on a 4xx. No status or a 5xx may be a create that committed before its
618-
* response was lost, and deleting then would fail a Runner that is about to
619-
* start, so the Secret is kept and logged: an ownerless expiring token is the
620-
* cheaper mistake. Reading the Runner back would tell the cases apart; not worth it
621-
* for a path this rare. Only when this call wrote it: a create returns a uid, a 409 does not.
615+
* collector would never take it. Only on a 4xx that refused the request, and
616+
* only when this call wrote it: no status, a 5xx or a 408 may be a create that
617+
* committed (a 408 should mean the request never arrived whole, but a proxy
618+
* may send one for an upstream timeout), and deleting then would fail a Runner
619+
* about to start, so the Secret is kept and logged; an ownerless token is the
620+
* cheaper mistake.
622621
*/
623622
private async releaseRunnerToken(
624623
token: { name: string; uid?: string } | undefined,
@@ -629,7 +628,7 @@ export class RunCrdWorkloadManager implements WorkloadManager, RunnerSnapshotter
629628
}
630629

631630
const status = statusCodeOf(err);
632-
if (status !== undefined && status >= 400 && status < 500) {
631+
if (status !== undefined && status >= 400 && status < 500 && status !== 408) {
633632
await this.deleteRunnerToken(token.name, token.uid);
634633
return;
635634
}

0 commit comments

Comments
 (0)