@intentius/chant-lexicon-fly 0.100.0 → 0.102.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/codegen/package.d.ts +1 -1
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/composites/fly-deploy.d.ts +2 -0
- package/dist/composites/fly-deploy.d.ts.map +1 -1
- package/dist/composites/fly-site.d.ts +2 -2
- package/dist/generated/index.d.ts +41 -41
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/integrity.json +5 -5
- package/dist/manifest.json +1 -1
- package/dist/okf/types/Secret.md +1 -0
- package/dist/op/activities/flaps.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.d.ts +87 -8
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machines-contract.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.d.ts +10 -1
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-local.d.ts.map +1 -1
- package/dist/op/activities/progress.d.ts +9 -0
- package/dist/op/activities/progress.d.ts.map +1 -0
- package/dist/op/activities/sprite-config.d.ts.map +1 -1
- package/dist/op/activities/sprite-fs.d.ts.map +1 -1
- package/dist/op/activities/sprite-service-converge.d.ts.map +1 -1
- package/dist/op/activities/sprite-services.d.ts.map +1 -1
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +10 -10
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/release-metadata.d.ts.map +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-fly-ops.md +8 -0
- package/dist/skills/chant-fly-patterns.md +1 -1
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -1
- package/package.json +3 -3
- package/src/codegen/docs.ts +16 -1
- package/src/composites/fly-deploy.ts +2 -0
- package/src/describe-resources.test.ts +18 -1
- package/src/describe-resources.ts +1 -1
- package/src/generated/index.d.ts +2 -1
- package/src/generated/index.ts +1 -1
- package/src/op/activities/fly-apply.integration.test.ts +12 -5
- package/src/op/activities/fly-apply.test.ts +483 -2
- package/src/op/activities/fly-apply.ts +229 -45
- package/src/op/activities/index.ts +27 -1
- package/src/op/activities/machines-contract.docker.integration.test.ts +22 -1
- package/src/op/activities/machines-contract.ts +5 -1
- package/src/op/activities/machines-fake.ts +69 -2
- package/src/op/activities/machines-local-cli.ts +3 -0
- package/src/op/activities/machines-local.test.ts +48 -12
- package/src/op/activities/machines-local.ts +28 -5
- package/src/op/activities/progress.ts +10 -0
- package/src/op/activities/sprite-config.ts +10 -9
- package/src/op/activities/sprite-fs.ts +3 -2
- package/src/op/activities/sprite-progress.test.ts +52 -0
- package/src/op/activities/sprite-service-converge.ts +2 -1
- package/src/op/activities/sprite-services.ts +5 -4
- package/src/op/activities/sprites.ts +6 -5
- package/src/serializer.test.ts +17 -2
- package/src/serializer.ts +16 -7
- package/src/skills/chant-fly-ops.md +8 -0
- package/src/skills/chant-fly-patterns.md +1 -1
- package/src/spec/parse.ts +25 -2
- package/src/teardown.test.ts +22 -3
- package/src/teardown.ts +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
2
|
import { describeApplyConformance } from "@intentius/chant-test-utils";
|
|
3
3
|
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -38,13 +38,18 @@ import {
|
|
|
38
38
|
withLease,
|
|
39
39
|
waitForMachine,
|
|
40
40
|
flyApply,
|
|
41
|
+
flyDelete,
|
|
41
42
|
toApplyResult,
|
|
43
|
+
listCerts,
|
|
44
|
+
CERT_PAGE_LIMIT,
|
|
45
|
+
MAX_CERT_PAGES,
|
|
42
46
|
DEFAULT_FLAPS_BASE_URL,
|
|
43
47
|
LEASE_NONCE_HEADER,
|
|
44
48
|
type FlyHttp,
|
|
45
49
|
type FlapsRequest,
|
|
46
50
|
type ApplyCtx,
|
|
47
51
|
} from "./fly-apply";
|
|
52
|
+
import { createMachinesFake, CERT_PAGE_DEFAULT } from "./machines-fake";
|
|
48
53
|
|
|
49
54
|
const CTX: ApplyCtx = { base: "http://localhost:4280" };
|
|
50
55
|
const NO_WAIT = { intervalMs: 0, deadlineMs: 5_000 };
|
|
@@ -313,6 +318,7 @@ describe("owned-only prune", () => {
|
|
|
313
318
|
};
|
|
314
319
|
}
|
|
315
320
|
if (method === "POST" && url.endsWith("/lease")) return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: "N" } }) };
|
|
321
|
+
if (method === "POST" && url.endsWith("/stop")) return { status: 200, text: "{}" };
|
|
316
322
|
if (method === "DELETE" && /\/machines\/[^/]+$/.test(url)) {
|
|
317
323
|
destroyed.push(url.split("/").pop() ?? "");
|
|
318
324
|
return { status: 200, text: "{}" };
|
|
@@ -327,6 +333,169 @@ describe("owned-only prune", () => {
|
|
|
327
333
|
});
|
|
328
334
|
});
|
|
329
335
|
|
|
336
|
+
/**
|
|
337
|
+
* #3115: flaps destroys a machine only when it is stopped (or suspended,
|
|
338
|
+
* failed, created) unless the DELETE carries ?force=true; a bare DELETE on a
|
|
339
|
+
* started machine is a 412 failed_precondition. The in-memory flaps answers the
|
|
340
|
+
* same way, so a bare delete of a running machine fails here.
|
|
341
|
+
*/
|
|
342
|
+
describe("destroying a running machine (#3115)", () => {
|
|
343
|
+
const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
|
|
344
|
+
const machine = (name: string) => ({
|
|
345
|
+
endpoint: "/v1/apps/demo/machines",
|
|
346
|
+
method: "POST",
|
|
347
|
+
body: { name, config: { image: "nginx", metadata: { ...OWNED_META } } },
|
|
348
|
+
});
|
|
349
|
+
const planWith = (entries: Record<string, unknown>): string => {
|
|
350
|
+
const dir = mkdtempSync(join(tmpdir(), "fly-3115-"));
|
|
351
|
+
const planPath = join(dir, "plan.json");
|
|
352
|
+
writeFileSync(planPath, JSON.stringify(entries));
|
|
353
|
+
return planPath;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/** A scripted flaps that records each call and answers like Fly for one machine `m1` in `state`. */
|
|
357
|
+
function scripted(state: string, overrides: (method: string, url: string) => { status: number; text: string } | undefined = () => undefined) {
|
|
358
|
+
const calls: string[] = [];
|
|
359
|
+
const http: FlyHttp = async (method, url) => {
|
|
360
|
+
const path = url.replace(CTX.base, "");
|
|
361
|
+
calls.push(`${method} ${path}`);
|
|
362
|
+
const o = overrides(method, path);
|
|
363
|
+
if (o) return o;
|
|
364
|
+
if (method === "POST" && path.endsWith("/lease")) return { status: 200, text: JSON.stringify({ data: { nonce: "N" } }) };
|
|
365
|
+
if (method === "DELETE" && path.endsWith("/lease")) return { status: 200, text: "{}" };
|
|
366
|
+
if (method === "POST" && path.endsWith("/stop")) { state = "stopped"; return { status: 200, text: "{}" }; }
|
|
367
|
+
if (method === "GET" && path.includes("/wait")) {
|
|
368
|
+
const want = new URL(url).searchParams.get("state");
|
|
369
|
+
return { status: 200, text: JSON.stringify({ ok: want === "destroyed" || want === state }) };
|
|
370
|
+
}
|
|
371
|
+
if (method === "DELETE" && /\/machines\/m1(\?force=true)?$/.test(path)) {
|
|
372
|
+
if (!path.endsWith("?force=true") && !["stopped", "suspended", "failed", "created"].includes(state)) {
|
|
373
|
+
return { status: 412, text: JSON.stringify({ error: "failed_precondition: unable to destroy machine, not currently stopped" }) };
|
|
374
|
+
}
|
|
375
|
+
return { status: 200, text: "{}" };
|
|
376
|
+
}
|
|
377
|
+
return { status: 500, text: `unscripted: ${method} ${path}` };
|
|
378
|
+
};
|
|
379
|
+
return { http, calls };
|
|
380
|
+
}
|
|
381
|
+
const mutations = (calls: string[]) => calls.filter((c) => !c.startsWith("GET"));
|
|
382
|
+
|
|
383
|
+
test("a started machine is stopped under a lease, waited to stopped, then destroyed under a lease", async () => {
|
|
384
|
+
const { http, calls } = scripted("started");
|
|
385
|
+
await destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state: "started" });
|
|
386
|
+
expect(calls).toEqual([
|
|
387
|
+
"POST /v1/apps/demo/machines/m1/lease",
|
|
388
|
+
"POST /v1/apps/demo/machines/m1/stop",
|
|
389
|
+
"DELETE /v1/apps/demo/machines/m1/lease",
|
|
390
|
+
"GET /v1/apps/demo/machines/m1/wait?state=stopped&timeout=60",
|
|
391
|
+
"POST /v1/apps/demo/machines/m1/lease",
|
|
392
|
+
"DELETE /v1/apps/demo/machines/m1",
|
|
393
|
+
"DELETE /v1/apps/demo/machines/m1/lease",
|
|
394
|
+
"GET /v1/apps/demo/machines/m1/wait?state=destroyed&timeout=60",
|
|
395
|
+
]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test.each(["stopped", "suspended", "failed"])("a %s machine is destroyed without a stop", async (state) => {
|
|
399
|
+
const { http, calls } = scripted(state);
|
|
400
|
+
await destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state });
|
|
401
|
+
expect(mutations(calls)).toEqual([
|
|
402
|
+
"POST /v1/apps/demo/machines/m1/lease",
|
|
403
|
+
"DELETE /v1/apps/demo/machines/m1",
|
|
404
|
+
"DELETE /v1/apps/demo/machines/m1/lease",
|
|
405
|
+
]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("a stopping machine is waited on, not stopped again", async () => {
|
|
409
|
+
const { http, calls } = scripted("stopped");
|
|
410
|
+
await destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state: "stopping" });
|
|
411
|
+
expect(calls).not.toContain("POST /v1/apps/demo/machines/m1/stop");
|
|
412
|
+
expect(calls).toContain("GET /v1/apps/demo/machines/m1/wait?state=stopped&timeout=60");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("force sends DELETE ?force=true and no stop", async () => {
|
|
416
|
+
const { http, calls } = scripted("started");
|
|
417
|
+
await destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state: "started", force: true });
|
|
418
|
+
expect(mutations(calls)).toEqual([
|
|
419
|
+
"POST /v1/apps/demo/machines/m1/lease",
|
|
420
|
+
"DELETE /v1/apps/demo/machines/m1?force=true",
|
|
421
|
+
"DELETE /v1/apps/demo/machines/m1/lease",
|
|
422
|
+
]);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("a 412 from the delete (state unknown, or started since the list) gets one stop and a retry", async () => {
|
|
426
|
+
const { http, calls } = scripted("started");
|
|
427
|
+
await destroyMachine(CTX, "demo", "m1", http, undefined, NO_WAIT);
|
|
428
|
+
expect(mutations(calls).filter((c) => !c.endsWith("/lease"))).toEqual([
|
|
429
|
+
"DELETE /v1/apps/demo/machines/m1",
|
|
430
|
+
"POST /v1/apps/demo/machines/m1/stop",
|
|
431
|
+
"DELETE /v1/apps/demo/machines/m1",
|
|
432
|
+
]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("a stop that never reaches stopped fails naming the machine, and no delete is sent", async () => {
|
|
436
|
+
const { http, calls } = scripted("started", (method, path) =>
|
|
437
|
+
method === "POST" && path.endsWith("/stop") ? { status: 200, text: "{}" } : undefined,
|
|
438
|
+
);
|
|
439
|
+
await expect(
|
|
440
|
+
destroyMachine(CTX, "demo", "m1", http, undefined, { intervalMs: 0, deadlineMs: 20, state: "started", name: "web" }),
|
|
441
|
+
).rejects.toThrow(/machine demo\/web \(m1\) did not stop, so it was not destroyed.*force: true/);
|
|
442
|
+
expect(calls).not.toContain("DELETE /v1/apps/demo/machines/m1");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("a failed stop fails naming the machine", async () => {
|
|
446
|
+
const { http } = scripted("started", (method, path) =>
|
|
447
|
+
method === "POST" && path.endsWith("/stop") ? { status: 500, text: "boom" } : undefined,
|
|
448
|
+
);
|
|
449
|
+
await expect(
|
|
450
|
+
destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state: "started", name: "web" }),
|
|
451
|
+
).rejects.toThrow("machine demo/web (m1) stop before destroy failed (500): boom");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("the in-memory flaps refuses a bare DELETE on a started machine and honours force", async () => {
|
|
455
|
+
const fake = createMachinesFake();
|
|
456
|
+
await flyApply({ planPath: planWith({ app, web: machine("web") }), endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
|
|
457
|
+
const id = fake.machine("demo", "web")!.id;
|
|
458
|
+
expect((await fake.http("DELETE", `${CTX.base}/v1/apps/demo/machines/${id}`)).status).toBe(412);
|
|
459
|
+
expect((await fake.http("DELETE", `${CTX.base}/v1/apps/demo/machines/${id}?force=true`)).status).toBe(200);
|
|
460
|
+
expect(fake.machine("demo", "web")).toBeUndefined();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("prune removes a running machine: stop, then delete", async () => {
|
|
464
|
+
const fake = createMachinesFake();
|
|
465
|
+
await flyApply({ planPath: planWith({ app, web: machine("web"), worker: machine("worker") }), endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
|
|
466
|
+
const id = fake.machine("demo", "worker")!.id;
|
|
467
|
+
expect(fake.machine("demo", "worker")!.state).toBe("started");
|
|
468
|
+
|
|
469
|
+
const out = await flyApply({ planPath: planWith({ app, web: machine("web") }), endpoint: CTX.base, wait: NO_WAIT, prune: true }, undefined, fake.http);
|
|
470
|
+
expect(out.pruned).toEqual([{ app: "demo", name: "worker", id }]);
|
|
471
|
+
expect(fake.machine("demo", "worker")).toBeUndefined();
|
|
472
|
+
expect(fake.machine("demo", "web")!.state).toBe("started");
|
|
473
|
+
expect(fake.calls).toContain(`POST /v1/apps/demo/machines/${id}/stop`);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("prune with force removes a running machine without a stop", async () => {
|
|
477
|
+
const fake = createMachinesFake();
|
|
478
|
+
await flyApply({ planPath: planWith({ app, web: machine("web"), worker: machine("worker") }), endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
|
|
479
|
+
const id = fake.machine("demo", "worker")!.id;
|
|
480
|
+
await flyApply({ planPath: planWith({ app, web: machine("web") }), endpoint: CTX.base, wait: NO_WAIT, prune: true, force: true }, undefined, fake.http);
|
|
481
|
+
expect(fake.machine("demo", "worker")).toBeUndefined();
|
|
482
|
+
expect(fake.calls).not.toContain(`POST /v1/apps/demo/machines/${id}/stop`);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("flyDelete tears down an app whose machines are running", async () => {
|
|
486
|
+
const fake = createMachinesFake();
|
|
487
|
+
const planPath = planWith({ app, web: machine("web"), worker: machine("worker") });
|
|
488
|
+
await flyApply({ planPath, endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
|
|
489
|
+
const out = await flyDelete({ planPath, endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
|
|
490
|
+
expect(out.machines).toEqual([
|
|
491
|
+
{ app: "demo", name: "web" },
|
|
492
|
+
{ app: "demo", name: "worker" },
|
|
493
|
+
]);
|
|
494
|
+
expect(out.apps).toEqual([{ app: "demo", deleted: true }]);
|
|
495
|
+
expect(fake.calls.filter((c) => c.endsWith("/stop"))).toHaveLength(2);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
330
499
|
// ── destroyMachine + waitForMachine ──────────────────────────────────────────
|
|
331
500
|
|
|
332
501
|
describe("destroy + wait", () => {
|
|
@@ -361,7 +530,10 @@ describe("app-scoped resource classification + segments", () => {
|
|
|
361
530
|
const at = (endpoint: string): FlapsRequest => ({ endpoint, method: "POST", body: {} });
|
|
362
531
|
expect(isVolumeRequest(at("/v1/apps/demo/volumes"))).toBe(true);
|
|
363
532
|
expect(isIpRequest(at("/v1/apps/demo/ip_assignments"))).toBe(true);
|
|
533
|
+
expect(isCertRequest(at("/v1/apps/demo/certificates/acme"))).toBe(true);
|
|
534
|
+
// A plan built before #3114 names the list path; it still classifies.
|
|
364
535
|
expect(isCertRequest(at("/v1/apps/demo/certificates"))).toBe(true);
|
|
536
|
+
expect(isCertRequest(at("/v1/apps/demo/certificates/custom"))).toBe(false);
|
|
365
537
|
expect(isSecretRequest(at("/v1/apps/demo/secrets/db"))).toBe(true);
|
|
366
538
|
// Cross-checks: a secret endpoint is not a machine/volume request.
|
|
367
539
|
expect(isMachineRequest(at("/v1/apps/demo/secrets/db"))).toBe(false);
|
|
@@ -412,13 +584,173 @@ describe("applyVolume / applyIp / applyCert idempotency (drift handling)", () =>
|
|
|
412
584
|
});
|
|
413
585
|
|
|
414
586
|
test("cert absent → create; present (by hostname) → no-op", async () => {
|
|
415
|
-
const req: FlapsRequest = { endpoint: "/v1/apps/demo/certificates", method: "POST", body: { hostname: "example.com" } };
|
|
587
|
+
const req: FlapsRequest = { endpoint: "/v1/apps/demo/certificates/acme", method: "POST", body: { hostname: "example.com" } };
|
|
588
|
+
const posts: Array<{ url: string; body: unknown }> = [];
|
|
589
|
+
const empty: FlyHttp = async (method, url, body) =>
|
|
590
|
+
method === "GET"
|
|
591
|
+
? { status: 200, text: JSON.stringify({ certificates: [] }) }
|
|
592
|
+
: (posts.push({ url, body }), { status: 201, text: JSON.stringify({ hostname: "example.com" }) });
|
|
593
|
+
expect(await applyCert(CTX, "demo", req, empty)).toEqual({ action: "created", hostname: "example.com" });
|
|
594
|
+
expect(posts).toEqual([{ url: "http://localhost:4280/v1/apps/demo/certificates/acme", body: { hostname: "example.com" } }]);
|
|
595
|
+
|
|
416
596
|
const present: FlyHttp = async (method) =>
|
|
417
597
|
method === "GET" ? { status: 200, text: JSON.stringify({ certificates: [{ hostname: "example.com" }] }) } : { status: 500, text: "" };
|
|
418
598
|
expect(await applyCert(CTX, "demo", req, present)).toEqual({ action: "noop", hostname: "example.com" });
|
|
419
599
|
});
|
|
420
600
|
});
|
|
421
601
|
|
|
602
|
+
/**
|
|
603
|
+
* #3114: flaps has no POST on `.../certificates` and answers one with 404; ACME
|
|
604
|
+
* certificates are created at `.../certificates/acme`. The in-memory flaps
|
|
605
|
+
* routes certificates the same way, so a wrong path fails here.
|
|
606
|
+
*/
|
|
607
|
+
describe("flyApply creates certificates at /certificates/acme (#3114)", () => {
|
|
608
|
+
const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
|
|
609
|
+
const planWith = (entries: Record<string, unknown>): string => {
|
|
610
|
+
const dir = mkdtempSync(join(tmpdir(), "fly-3114-"));
|
|
611
|
+
const planPath = join(dir, "plan.json");
|
|
612
|
+
writeFileSync(planPath, JSON.stringify(entries));
|
|
613
|
+
return planPath;
|
|
614
|
+
};
|
|
615
|
+
const run = (fake: ReturnType<typeof createMachinesFake>, entries: Record<string, unknown>, prune = false) =>
|
|
616
|
+
flyApply({ planPath: planWith(entries), endpoint: CTX.base, wait: NO_WAIT, prune }, undefined, fake.http);
|
|
617
|
+
|
|
618
|
+
test("the in-memory flaps answers a POST to the bare list path with 404, as Fly does", async () => {
|
|
619
|
+
const fake = createMachinesFake();
|
|
620
|
+
fake.apps.add("demo");
|
|
621
|
+
const res = await fake.http("POST", `${CTX.base}/v1/apps/demo/certificates`, { hostname: "example.com" });
|
|
622
|
+
expect(res.status).toBe(404);
|
|
623
|
+
expect(fake.certs.get("demo") ?? []).toEqual([]);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("a certificate is created once, and a re-apply is a no-op", async () => {
|
|
627
|
+
const fake = createMachinesFake();
|
|
628
|
+
const cert = { endpoint: "/v1/apps/demo/certificates/acme", method: "POST", body: { hostname: "example.com" } };
|
|
629
|
+
const first = await run(fake, { app, cert });
|
|
630
|
+
expect(first.certs).toEqual([{ app: "demo", hostname: "example.com", action: "created" }]);
|
|
631
|
+
expect(fake.certs.get("demo")).toEqual(["example.com"]);
|
|
632
|
+
expect(fake.calls).toContain("POST /v1/apps/demo/certificates/acme");
|
|
633
|
+
expect(fake.calls).not.toContain("POST /v1/apps/demo/certificates");
|
|
634
|
+
|
|
635
|
+
const second = await run(fake, { app, cert });
|
|
636
|
+
expect(second.certs).toEqual([{ app: "demo", hostname: "example.com", action: "noop" }]);
|
|
637
|
+
expect(fake.calls.filter((c) => c === "POST /v1/apps/demo/certificates/acme")).toHaveLength(1);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("a plan built before #3114, naming /certificates, still creates the certificate at /acme", async () => {
|
|
641
|
+
const fake = createMachinesFake();
|
|
642
|
+
const legacy = { endpoint: "/v1/apps/demo/certificates", method: "POST", body: { hostname: "example.com" } };
|
|
643
|
+
const out = await run(fake, { app, cert: legacy });
|
|
644
|
+
expect(out.certs).toEqual([{ app: "demo", hostname: "example.com", action: "created" }]);
|
|
645
|
+
expect(fake.certs.get("demo")).toEqual(["example.com"]);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("prune deletes a certificate the plan no longer declares", async () => {
|
|
649
|
+
const fake = createMachinesFake();
|
|
650
|
+
const cert = (hostname: string) => ({ endpoint: "/v1/apps/demo/certificates/acme", method: "POST", body: { hostname } });
|
|
651
|
+
await run(fake, { app, keep: cert("keep.com"), drop: cert("drop.com") });
|
|
652
|
+
const out = await run(fake, { app, keep: cert("keep.com") }, true);
|
|
653
|
+
expect(out.prunedCerts).toEqual([{ app: "demo", hostname: "drop.com" }]);
|
|
654
|
+
expect(fake.certs.get("demo")).toEqual(["keep.com"]);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* #3224: Fly pages the certificate list (25 by default, 500 at most, with a
|
|
660
|
+
* `next_cursor`), and the in-memory flaps does the same. Every reader goes
|
|
661
|
+
* through listCerts, which follows the cursor to the end.
|
|
662
|
+
*/
|
|
663
|
+
describe("certificate list pagination (#3224)", () => {
|
|
664
|
+
const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
|
|
665
|
+
const cert = (hostname: string) => ({ endpoint: "/v1/apps/demo/certificates/acme", method: "POST", body: { hostname } });
|
|
666
|
+
const hosts = (n: number) => Array.from({ length: n }, (_, i) => `h${String(i).padStart(4, "0")}.example.com`);
|
|
667
|
+
const planOf = (hostnames: string[]): string => {
|
|
668
|
+
const dir = mkdtempSync(join(tmpdir(), "fly-3224-"));
|
|
669
|
+
const planPath = join(dir, "plan.json");
|
|
670
|
+
writeFileSync(planPath, JSON.stringify({ app, ...Object.fromEntries(hostnames.map((h) => [h, cert(h)])) }));
|
|
671
|
+
return planPath;
|
|
672
|
+
};
|
|
673
|
+
const run = (fake: ReturnType<typeof createMachinesFake>, hostnames: string[], prune = false) =>
|
|
674
|
+
flyApply({ planPath: planOf(hostnames), endpoint: CTX.base, wait: NO_WAIT, prune }, undefined, fake.http);
|
|
675
|
+
const certGets = (fake: ReturnType<typeof createMachinesFake>) => fake.calls.filter((c) => c === "GET /v1/apps/demo/certificates");
|
|
676
|
+
|
|
677
|
+
test("the in-memory flaps pages the list at 25 with a next_cursor, as Fly does", async () => {
|
|
678
|
+
const fake = createMachinesFake();
|
|
679
|
+
fake.apps.add("demo");
|
|
680
|
+
fake.certs.set("demo", hosts(30));
|
|
681
|
+
const first = JSON.parse((await fake.http("GET", `${CTX.base}/v1/apps/demo/certificates`)).text);
|
|
682
|
+
expect(first.certificates).toHaveLength(CERT_PAGE_DEFAULT);
|
|
683
|
+
expect(first.total_count).toBe(30);
|
|
684
|
+
expect(typeof first.next_cursor).toBe("string");
|
|
685
|
+
const second = JSON.parse((await fake.http("GET", `${CTX.base}/v1/apps/demo/certificates?cursor=${first.next_cursor}`)).text);
|
|
686
|
+
expect(second.certificates).toHaveLength(5);
|
|
687
|
+
expect(second.next_cursor).toBeUndefined();
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("a re-apply of more certificates than one page is a no-op", async () => {
|
|
691
|
+
const fake = createMachinesFake();
|
|
692
|
+
const all = hosts(30);
|
|
693
|
+
await run(fake, all);
|
|
694
|
+
expect(fake.certs.get("demo")).toEqual(all);
|
|
695
|
+
const again = await run(fake, all);
|
|
696
|
+
expect(again.certs.every((c) => c.action === "noop")).toBe(true);
|
|
697
|
+
expect(fake.calls.filter((c) => c === "POST /v1/apps/demo/certificates/acme")).toHaveLength(30);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("prune removes undeclared certificates past the first page", async () => {
|
|
701
|
+
const fake = createMachinesFake();
|
|
702
|
+
const all = hosts(30);
|
|
703
|
+
await run(fake, all);
|
|
704
|
+
const out = await run(fake, all.slice(0, 3), true);
|
|
705
|
+
expect(out.prunedCerts.map((c) => c.hostname)).toEqual(all.slice(3));
|
|
706
|
+
expect(fake.certs.get("demo")).toEqual(all.slice(0, 3));
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("listCerts asks for the largest page and follows the cursor past it", async () => {
|
|
710
|
+
const fake = createMachinesFake();
|
|
711
|
+
fake.apps.add("demo");
|
|
712
|
+
const all = hosts(2 * CERT_PAGE_LIMIT + 3);
|
|
713
|
+
fake.certs.set("demo", all);
|
|
714
|
+
const listed = await listCerts(CTX, "demo", fake.http);
|
|
715
|
+
expect(listed.map((c) => c.hostname)).toEqual(all);
|
|
716
|
+
expect(certGets(fake)).toHaveLength(3);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("an absent app lists no certificates", async () => {
|
|
720
|
+
const fake = createMachinesFake();
|
|
721
|
+
expect(await listCerts(CTX, "nope", fake.http)).toEqual([]);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("a repeated cursor fails loudly instead of looping", async () => {
|
|
725
|
+
let gets = 0;
|
|
726
|
+
const http: FlyHttp = async () => {
|
|
727
|
+
gets++;
|
|
728
|
+
return { status: 200, text: JSON.stringify({ certificates: [{ hostname: `h${gets}` }], next_cursor: "same" }) };
|
|
729
|
+
};
|
|
730
|
+
await expect(listCerts(CTX, "demo", http)).rejects.toThrow(/repeated cursor "same"/);
|
|
731
|
+
expect(gets).toBe(2);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test("a cursor that never ends fails after the page cap", async () => {
|
|
735
|
+
let gets = 0;
|
|
736
|
+
const http: FlyHttp = async () => {
|
|
737
|
+
gets++;
|
|
738
|
+
return { status: 200, text: JSON.stringify({ certificates: [], next_cursor: `c${gets}` }) };
|
|
739
|
+
};
|
|
740
|
+
await expect(listCerts(CTX, "demo", http)).rejects.toThrow(new RegExp(`after ${MAX_CERT_PAGES} pages`));
|
|
741
|
+
expect(gets).toBe(MAX_CERT_PAGES);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("a failed later page is an error, not a short list", async () => {
|
|
745
|
+
let gets = 0;
|
|
746
|
+
const http: FlyHttp = async () =>
|
|
747
|
+
++gets === 1
|
|
748
|
+
? { status: 200, text: JSON.stringify({ certificates: [{ hostname: "a" }], next_cursor: "n" }) }
|
|
749
|
+
: { status: 404, text: "gone" };
|
|
750
|
+
await expect(listCerts(CTX, "demo", http)).rejects.toThrow(/certificate list failed for demo \(404\)/);
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
422
754
|
describe("secret is apply-only (D7): set, never read-diffed", () => {
|
|
423
755
|
test("applySecret POSTs the value with no read-back GET", async () => {
|
|
424
756
|
const calls: string[] = [];
|
|
@@ -514,6 +846,109 @@ describe("flyApply orders volumes before the machines that mount them", () => {
|
|
|
514
846
|
});
|
|
515
847
|
});
|
|
516
848
|
|
|
849
|
+
/**
|
|
850
|
+
* #2516 — a Secret declared without a value is set outside chant. Posting it
|
|
851
|
+
* as `{}` on every apply clobbered or no-oped the real secret. Apply checks the
|
|
852
|
+
* app has it instead, and fails naming it when the app does not.
|
|
853
|
+
*/
|
|
854
|
+
describe("flyApply never sends a value-less secret (#2516)", () => {
|
|
855
|
+
const planWith = (entry: Record<string, unknown>): string => {
|
|
856
|
+
const dir = mkdtempSync(join(tmpdir(), "fly-2516-"));
|
|
857
|
+
const planPath = join(dir, "plan.json");
|
|
858
|
+
writeFileSync(planPath, JSON.stringify(entry));
|
|
859
|
+
return planPath;
|
|
860
|
+
};
|
|
861
|
+
const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
|
|
862
|
+
const valueless = { endpoint: "/v1/apps/demo/secrets/DATABASE_URL", method: "POST", body: {}, applyOnly: true, mustExist: true };
|
|
863
|
+
const scripted = (liveSecrets: string[], calls: string[]): FlyHttp => async (method, url) => {
|
|
864
|
+
calls.push(`${method} ${url}`);
|
|
865
|
+
if (method === "GET" && /\/apps\/demo$/.test(url)) return { status: 200, text: JSON.stringify({ name: "demo" }) };
|
|
866
|
+
if (method === "GET" && url.endsWith("/secrets")) {
|
|
867
|
+
return { status: 200, text: JSON.stringify({ secrets: liveSecrets.map((name) => ({ name })) }) };
|
|
868
|
+
}
|
|
869
|
+
return { status: 200, text: "{}" };
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
test("an existing value-less secret is checked, never posted, and reports unchanged", async () => {
|
|
873
|
+
const calls: string[] = [];
|
|
874
|
+
const out = await flyApply(
|
|
875
|
+
{ planPath: planWith({ app, db: valueless }), endpoint: "http://localhost:4280" },
|
|
876
|
+
undefined,
|
|
877
|
+
scripted(["DATABASE_URL"], calls),
|
|
878
|
+
);
|
|
879
|
+
expect(out.secrets).toEqual([{ app: "demo", name: "DATABASE_URL", action: "exists" }]);
|
|
880
|
+
expect(calls.filter((c) => c.includes("/secrets"))).toEqual(["GET http://localhost:4280/v1/apps/demo/secrets"]);
|
|
881
|
+
expect(toApplyResult(out).applied).toContainEqual({ kind: "secret", name: "DATABASE_URL", action: "unchanged" });
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("a missing value-less secret fails with its name and posts nothing", async () => {
|
|
885
|
+
const calls: string[] = [];
|
|
886
|
+
await expect(
|
|
887
|
+
flyApply({ planPath: planWith({ app, db: valueless }), endpoint: "http://localhost:4280" }, undefined, scripted([], calls)),
|
|
888
|
+
).rejects.toThrow(/secret demo\/DATABASE_URL is declared without a value.*does not have it/);
|
|
889
|
+
expect(calls.some((c) => c.startsWith("POST") && c.includes("/secrets"))).toBe(false);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("a plan from before mustExist, with an empty body, is treated the same", async () => {
|
|
893
|
+
const calls: string[] = [];
|
|
894
|
+
const legacy = { endpoint: "/v1/apps/demo/secrets/DATABASE_URL", method: "POST", body: {}, applyOnly: true };
|
|
895
|
+
await flyApply({ planPath: planWith({ app, db: legacy }), endpoint: "http://localhost:4280" }, undefined, scripted(["DATABASE_URL"], calls));
|
|
896
|
+
expect(calls.some((c) => c.startsWith("POST") && c.includes("/secrets"))).toBe(false);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("a secret with a value is still set", async () => {
|
|
900
|
+
const calls: string[] = [];
|
|
901
|
+
const withValue = { endpoint: "/v1/apps/demo/secrets/API_KEY", method: "POST", body: { value: "k" }, applyOnly: true };
|
|
902
|
+
const out = await flyApply({ planPath: planWith({ app, key: withValue }), endpoint: "http://localhost:4280" }, undefined, scripted([], calls));
|
|
903
|
+
expect(out.secrets).toEqual([{ app: "demo", name: "API_KEY", action: "set" }]);
|
|
904
|
+
expect(calls).toContain("POST http://localhost:4280/v1/apps/demo/secrets/API_KEY");
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* #2516 — an Op step's stdout is its output, so the applier writes progress to
|
|
910
|
+
* stderr and leaves stdout clean.
|
|
911
|
+
*/
|
|
912
|
+
describe("flyApply keeps stdout clean (#2516)", () => {
|
|
913
|
+
afterEach(() => vi.restoreAllMocks());
|
|
914
|
+
|
|
915
|
+
test("apply, prune and delete write progress to stderr only", async () => {
|
|
916
|
+
const dir = mkdtempSync(join(tmpdir(), "fly-2516-out-"));
|
|
917
|
+
const planPath = join(dir, "plan.json");
|
|
918
|
+
writeFileSync(
|
|
919
|
+
planPath,
|
|
920
|
+
JSON.stringify({
|
|
921
|
+
app: { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } },
|
|
922
|
+
key: { endpoint: "/v1/apps/demo/secrets/API_KEY", method: "POST", body: { value: "k" }, applyOnly: true },
|
|
923
|
+
}),
|
|
924
|
+
);
|
|
925
|
+
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
926
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
927
|
+
const stderr: string[] = [];
|
|
928
|
+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
|
|
929
|
+
stderr.push(String(chunk));
|
|
930
|
+
return true;
|
|
931
|
+
});
|
|
932
|
+
const http: FlyHttp = async (method, url) => {
|
|
933
|
+
if (method === "GET" && /\/apps\/demo$/.test(url)) return { status: 404, text: "" };
|
|
934
|
+
if (method === "GET" && url.endsWith("/secrets")) return { status: 200, text: JSON.stringify({ secrets: [{ name: "OLD" }] }) };
|
|
935
|
+
if (method === "GET" && /\/(volumes|machines)$/.test(url)) return { status: 200, text: "[]" };
|
|
936
|
+
return { status: 200, text: "{}" };
|
|
937
|
+
};
|
|
938
|
+
try {
|
|
939
|
+
await flyApply({ planPath, endpoint: "http://localhost:4280", prune: true }, undefined, http);
|
|
940
|
+
await flyDelete({ planPath, endpoint: "http://localhost:4280" }, undefined, http);
|
|
941
|
+
} finally {
|
|
942
|
+
rmSync(dir, { recursive: true, force: true });
|
|
943
|
+
}
|
|
944
|
+
expect(stdout).not.toHaveBeenCalled();
|
|
945
|
+
expect(log).not.toHaveBeenCalled();
|
|
946
|
+
expect(stderr.join("")).toMatch(/created: app\/demo/);
|
|
947
|
+
expect(stderr.join("")).toMatch(/set: secret\/demo\/API_KEY/);
|
|
948
|
+
expect(stderr.join("")).toMatch(/pruned: secret\/demo\/OLD/);
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
517
952
|
/**
|
|
518
953
|
* #1457 — a plan entry matching none of the six request predicates used to fall
|
|
519
954
|
* off the end of the classification chain. No log, no bucket, absent from all
|
|
@@ -636,4 +1071,50 @@ describeApplyConformance({
|
|
|
636
1071
|
expectApplied: ["app/solo", "machine/web"],
|
|
637
1072
|
},
|
|
638
1073
|
],
|
|
1074
|
+
pruneScenarios: [
|
|
1075
|
+
{
|
|
1076
|
+
// The orphan is running, so it is stopped before the delete (#3115). The
|
|
1077
|
+
// stop is a POST and lease releases are not resource deletes, so neither
|
|
1078
|
+
// is captured: the one delete is the machine's own.
|
|
1079
|
+
name: "a running owned orphan and a foreign machine in one app",
|
|
1080
|
+
run: async () => {
|
|
1081
|
+
const dir = mkdtempSync(join(tmpdir(), "fly-3115-prune-"));
|
|
1082
|
+
const write = (entries: Record<string, unknown>) => {
|
|
1083
|
+
const planPath = join(dir, `plan-${Object.keys(entries).length}.json`);
|
|
1084
|
+
writeFileSync(planPath, JSON.stringify(entries));
|
|
1085
|
+
return planPath;
|
|
1086
|
+
};
|
|
1087
|
+
const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "solo" } };
|
|
1088
|
+
const machine = (name: string) => ({
|
|
1089
|
+
endpoint: "/v1/apps/solo/machines",
|
|
1090
|
+
method: "POST",
|
|
1091
|
+
body: { name, config: { image: "nginx" } },
|
|
1092
|
+
});
|
|
1093
|
+
try {
|
|
1094
|
+
const fake = createMachinesFake();
|
|
1095
|
+
const wait = { intervalMs: 0, deadlineMs: 5_000 };
|
|
1096
|
+
await flyApply({ planPath: write({ app, web: machine("web"), orphan: machine("orphan") }), endpoint: CTX.base, wait }, undefined, fake.http);
|
|
1097
|
+
// A machine chant did not create: no ownership marker, running.
|
|
1098
|
+
fake.machines.get("solo")!.push({ id: "m-foreign", name: "foreign", state: "started", instance_id: "I-foreign", config: { image: "redis" } });
|
|
1099
|
+
// The serializer stamps the marker; this plan is hand-written, so stamp it on the fake.
|
|
1100
|
+
for (const m of fake.machines.get("solo")!) if (m.name !== "foreign") m.config.metadata = { ...OWNED_META };
|
|
1101
|
+
const orphanId = fake.machine("solo", "orphan")!.id;
|
|
1102
|
+
|
|
1103
|
+
const deletes: string[] = [];
|
|
1104
|
+
const http: FlyHttp = async (method, url, body, headers, signal) => {
|
|
1105
|
+
if (method === "DELETE" && !url.endsWith("/lease")) deletes.push(url.replace(orphanId, "orphan"));
|
|
1106
|
+
return fake.http(method, url, body, headers, signal);
|
|
1107
|
+
};
|
|
1108
|
+
const result = toApplyResult(
|
|
1109
|
+
await flyApply({ planPath: write({ app, web: machine("web") }), endpoint: CTX.base, wait, prune: true }, undefined, http),
|
|
1110
|
+
);
|
|
1111
|
+
return { result, deletes };
|
|
1112
|
+
} finally {
|
|
1113
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1114
|
+
}
|
|
1115
|
+
},
|
|
1116
|
+
ownedOrphan: "/machines/orphan",
|
|
1117
|
+
foreign: "m-foreign",
|
|
1118
|
+
},
|
|
1119
|
+
],
|
|
639
1120
|
});
|