@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
|
@@ -26,6 +26,7 @@ import { readFileSync } from "node:fs";
|
|
|
26
26
|
import { sleep } from "@intentius/chant/op";
|
|
27
27
|
import { hasOwnershipMarker } from "@intentius/chant/ownership";
|
|
28
28
|
import { FLY_METADATA_OWNERSHIP_KEYS } from "../../ownership";
|
|
29
|
+
import { logProgress } from "./progress";
|
|
29
30
|
import {
|
|
30
31
|
applyResult,
|
|
31
32
|
type ApplyResult,
|
|
@@ -50,8 +51,16 @@ export interface FlapsRequest {
|
|
|
50
51
|
* POSTs these and excludes them from any drift/diff.
|
|
51
52
|
*/
|
|
52
53
|
applyOnly?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* A Secret declared without a value: set outside chant. `flyApply` never
|
|
56
|
+
* POSTs it; it checks the app has the secret and fails naming it if not.
|
|
57
|
+
*/
|
|
58
|
+
mustExist?: boolean;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
// Progress lines go to stderr (#2516); the helper is shared with the Sprites activities.
|
|
62
|
+
export { logProgress } from "./progress";
|
|
63
|
+
|
|
55
64
|
/** The serializer's whole output: entity name → flaps create request. */
|
|
56
65
|
export type FlyPlan = Record<string, FlapsRequest>;
|
|
57
66
|
|
|
@@ -111,9 +120,13 @@ export function isIpRequest(req: FlapsRequest): boolean {
|
|
|
111
120
|
return /^\/v1\/apps\/[^/]+\/ip_assignments\/?$/.test(req.endpoint);
|
|
112
121
|
}
|
|
113
122
|
|
|
114
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* True when a request creates a Certificate: `.../certificates/acme`, or the
|
|
125
|
+
* bare `.../certificates` that plans built before #3114 carry. Both apply the
|
|
126
|
+
* same way, through {@link applyCert}. Pure.
|
|
127
|
+
*/
|
|
115
128
|
export function isCertRequest(req: FlapsRequest): boolean {
|
|
116
|
-
return /^\/v1\/apps\/[^/]+\/certificates
|
|
129
|
+
return /^\/v1\/apps\/[^/]+\/certificates(\/acme)?\/?$/.test(req.endpoint);
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
/** True when a request sets a Secret (`.../secrets/{name}`). Pure. */
|
|
@@ -235,6 +248,9 @@ const ipUrl = (base: string, app: string, ip: string): string => `${ipsUrl(base,
|
|
|
235
248
|
const certsUrl = (base: string, app: string): string => `${appUrl(base, app)}/certificates`;
|
|
236
249
|
const certUrl = (base: string, app: string, hostname: string): string =>
|
|
237
250
|
`${certsUrl(base, app)}/${encodeURIComponent(hostname)}`;
|
|
251
|
+
// Certificates are listed at `.../certificates` but created at `.../acme`:
|
|
252
|
+
// flaps has no POST on the list path and answers one with 404 (#3114).
|
|
253
|
+
const acmeCertUrl = (base: string, app: string): string => `${certsUrl(base, app)}/acme`;
|
|
238
254
|
const secretsUrl = (base: string, app: string): string => `${appUrl(base, app)}/secrets`;
|
|
239
255
|
const secretUrl = (base: string, app: string, secretName: string): string =>
|
|
240
256
|
`${secretsUrl(base, app)}/${encodeURIComponent(secretName)}`;
|
|
@@ -487,22 +503,103 @@ export async function applyMachine(
|
|
|
487
503
|
return { action: "updated", id: m.id, name };
|
|
488
504
|
}
|
|
489
505
|
|
|
490
|
-
/**
|
|
506
|
+
/** How {@link destroyMachine} treats a machine that is still running. */
|
|
507
|
+
export interface DestroyOpts extends WaitOpts {
|
|
508
|
+
/**
|
|
509
|
+
* Destroy with `?force=true`, which kills a running machine at once, instead
|
|
510
|
+
* of stopping it first. Default false: stop, wait for `stopped`, then delete.
|
|
511
|
+
*/
|
|
512
|
+
force?: boolean;
|
|
513
|
+
/** The machine's last listed state. It decides whether a stop is needed first; unknown means try the delete. */
|
|
514
|
+
state?: string;
|
|
515
|
+
/** The machine's name, for error messages. */
|
|
516
|
+
name?: string;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* States flaps destroys without `force`. A started machine answers a bare
|
|
521
|
+
* DELETE with 412 failed_precondition (#3115).
|
|
522
|
+
*/
|
|
523
|
+
const DESTROYABLE_STATES = new Set(["stopped", "suspended", "failed", "created"]);
|
|
524
|
+
|
|
525
|
+
/** True when a destroy response is flaps refusing a machine that is not stopped. Pure. */
|
|
526
|
+
export function isNotStoppedRefusal(status: number, text: string): boolean {
|
|
527
|
+
return status === 412 || (status >= 400 && /failed_precondition/.test(text));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Stop a machine under a lease, then wait for `stopped` with no lease held (a
|
|
532
|
+
* slow stop can outlive the lease TTL). A machine already `stopping` is only
|
|
533
|
+
* waited on. A wait that runs out fails naming the machine.
|
|
534
|
+
*/
|
|
535
|
+
async function stopForDestroy(
|
|
536
|
+
ctx: ApplyCtx,
|
|
537
|
+
app: string,
|
|
538
|
+
id: string,
|
|
539
|
+
http: FlyHttp,
|
|
540
|
+
signal: AbortSignal | undefined,
|
|
541
|
+
opts: DestroyOpts,
|
|
542
|
+
state: string | undefined,
|
|
543
|
+
): Promise<void> {
|
|
544
|
+
const label = opts.name ? `${app}/${opts.name} (${id})` : `${app}/${id}`;
|
|
545
|
+
if (state !== "stopping") {
|
|
546
|
+
const res = await withLease(ctx, app, id, http, signal, (nonce) =>
|
|
547
|
+
http("POST", `${machineUrl(ctx.base, app, id)}/stop`, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal),
|
|
548
|
+
);
|
|
549
|
+
if (res.status === 404) return;
|
|
550
|
+
if (res.status >= 300) throw new Error(`machine ${label} stop before destroy failed (${res.status}): ${res.text}`);
|
|
551
|
+
}
|
|
552
|
+
const { timeoutSecs, intervalMs, deadlineMs } = opts;
|
|
553
|
+
try {
|
|
554
|
+
await waitForMachine(ctx, app, id, "", http, signal, { timeoutSecs, intervalMs, deadlineMs, state: "stopped" });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
557
|
+
throw new Error(
|
|
558
|
+
`machine ${label} did not stop, so it was not destroyed (${why}). ` +
|
|
559
|
+
`Check the machine, or destroy it with force: true to kill it without a stop.`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Destroy a machine and wait for it to be reaped. flaps refuses a bare DELETE
|
|
566
|
+
* on a running machine (412, #3115), so by default a machine that is not
|
|
567
|
+
* stopped is stopped first (each mutation under a lease), and a 412 from the
|
|
568
|
+
* delete (a machine that started after it was listed) gets one stop and retry.
|
|
569
|
+
* With `force`, the delete carries `?force=true` and no stop is sent.
|
|
570
|
+
*/
|
|
491
571
|
export async function destroyMachine(
|
|
492
572
|
ctx: ApplyCtx,
|
|
493
573
|
app: string,
|
|
494
574
|
id: string,
|
|
495
575
|
http: FlyHttp,
|
|
496
576
|
signal?: AbortSignal,
|
|
497
|
-
opts:
|
|
577
|
+
opts: DestroyOpts = {},
|
|
498
578
|
): Promise<void> {
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
579
|
+
const { force, state } = opts;
|
|
580
|
+
const label = opts.name ? `${app}/${opts.name} (${id})` : `${app}/${id}`;
|
|
581
|
+
const waitOpts = { timeoutSecs: opts.timeoutSecs, intervalMs: opts.intervalMs, deadlineMs: opts.deadlineMs };
|
|
582
|
+
const gone = state === "destroyed" || state === "destroying";
|
|
583
|
+
|
|
584
|
+
if (!gone) {
|
|
585
|
+
if (!force && state !== undefined && !DESTROYABLE_STATES.has(state)) {
|
|
586
|
+
await stopForDestroy(ctx, app, id, http, signal, opts, state);
|
|
587
|
+
}
|
|
588
|
+
const url = force ? `${machineUrl(ctx.base, app, id)}?force=true` : machineUrl(ctx.base, app, id);
|
|
589
|
+
const del = () =>
|
|
590
|
+
withLease(ctx, app, id, http, signal, (nonce) =>
|
|
591
|
+
http("DELETE", url, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal),
|
|
592
|
+
);
|
|
593
|
+
let res = await del();
|
|
594
|
+
if (!force && isNotStoppedRefusal(res.status, res.text)) {
|
|
595
|
+
await stopForDestroy(ctx, app, id, http, signal, opts, undefined);
|
|
596
|
+
res = await del();
|
|
597
|
+
}
|
|
598
|
+
if (res.status >= 300 && res.status !== 404) {
|
|
599
|
+
throw new Error(`machine ${label} destroy failed (${res.status}): ${res.text}`);
|
|
600
|
+
}
|
|
504
601
|
}
|
|
505
|
-
await waitForMachine(ctx, app, id, "", http, signal, { ...
|
|
602
|
+
await waitForMachine(ctx, app, id, "", http, signal, { ...waitOpts, state: "destroyed" });
|
|
506
603
|
}
|
|
507
604
|
|
|
508
605
|
/** Delete an app (idempotent; a 404 means it is already gone). */
|
|
@@ -522,7 +619,8 @@ export async function deleteApp(
|
|
|
522
619
|
* Owned-only prune (D2): for one app, destroy the chant-owned machines whose
|
|
523
620
|
* name is not in `keep`. An unmarked machine (no `managed-by: chant`) is never
|
|
524
621
|
* touched, so it survives an apply that would otherwise delete it. Machines
|
|
525
|
-
* already tearing down are skipped.
|
|
622
|
+
* already tearing down are skipped. A running machine is stopped before it is
|
|
623
|
+
* destroyed unless `opts.force` is set (see {@link destroyMachine}).
|
|
526
624
|
*/
|
|
527
625
|
export async function pruneMachines(
|
|
528
626
|
ctx: ApplyCtx,
|
|
@@ -530,14 +628,14 @@ export async function pruneMachines(
|
|
|
530
628
|
keep: Set<string>,
|
|
531
629
|
http: FlyHttp,
|
|
532
630
|
signal?: AbortSignal,
|
|
533
|
-
opts: WaitOpts = {},
|
|
631
|
+
opts: WaitOpts & { force?: boolean } = {},
|
|
534
632
|
): Promise<Array<{ app: string; name: string; id: string }>> {
|
|
535
633
|
const pruned: Array<{ app: string; name: string; id: string }> = [];
|
|
536
634
|
for (const m of await listMachines(ctx, app, http, signal)) {
|
|
537
635
|
if (!isChantOwned(m.config?.metadata) || keep.has(m.name)) continue;
|
|
538
636
|
if (m.state === "destroyed" || m.state === "destroying") continue;
|
|
539
|
-
await destroyMachine(ctx, app, m.id, http, signal, opts);
|
|
540
|
-
|
|
637
|
+
await destroyMachine(ctx, app, m.id, http, signal, { ...opts, state: m.state, name: m.name });
|
|
638
|
+
logProgress(`pruned: ${app}/${m.name} (${ctx.base})`);
|
|
541
639
|
pruned.push({ app, name: m.name, id: m.id });
|
|
542
640
|
}
|
|
543
641
|
return pruned;
|
|
@@ -627,13 +725,49 @@ export async function listIps(ctx: ApplyCtx, app: string, http: FlyHttp, signal?
|
|
|
627
725
|
return body?.ips ?? [];
|
|
628
726
|
}
|
|
629
727
|
|
|
630
|
-
/**
|
|
728
|
+
/**
|
|
729
|
+
* The page size asked of the certificate list. Fly pages it whether or not a
|
|
730
|
+
* `limit` is given (default 25, max 500), so asking for the max keeps the
|
|
731
|
+
* common case to one request.
|
|
732
|
+
*/
|
|
733
|
+
export const CERT_PAGE_LIMIT = 500;
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* The most certificate pages {@link listCerts} reads before it gives up. At
|
|
737
|
+
* {@link CERT_PAGE_LIMIT} per page that is 100,000 certificates, far past any
|
|
738
|
+
* real app, so hitting it means the API never stopped handing out a cursor.
|
|
739
|
+
*/
|
|
740
|
+
export const MAX_CERT_PAGES = 200;
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* List an app's live certificates, every page of them (#3224). Fly answers
|
|
744
|
+
* `{ certificates, next_cursor?, total_count }` and pages the list even
|
|
745
|
+
* without a `limit`; this follows `next_cursor` until it is absent. A cursor
|
|
746
|
+
* the API already handed out, or more than {@link MAX_CERT_PAGES} pages,
|
|
747
|
+
* throws rather than looping or returning a partial list, because callers
|
|
748
|
+
* read "not listed" as "absent" (re-apply creates it, prune skips it).
|
|
749
|
+
*/
|
|
631
750
|
export async function listCerts(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise<FlapsCert[]> {
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
751
|
+
const certs: FlapsCert[] = [];
|
|
752
|
+
const seen = new Set<string>();
|
|
753
|
+
let cursor: string | undefined;
|
|
754
|
+
for (let page = 0; page < MAX_CERT_PAGES; page++) {
|
|
755
|
+
const q = new URLSearchParams({ limit: String(CERT_PAGE_LIMIT) });
|
|
756
|
+
if (cursor !== undefined) q.set("cursor", cursor);
|
|
757
|
+
const res = await http("GET", `${certsUrl(ctx.base, app)}?${q.toString()}`, undefined, undefined, signal);
|
|
758
|
+
// A 404 on the first page is an absent app; on a later one the list is
|
|
759
|
+
// half-read, so it is an error like any other.
|
|
760
|
+
if (res.status === 404 && page === 0) return [];
|
|
761
|
+
if (res.status >= 300) throw new Error(`certificate list failed for ${app} (${res.status}): ${res.text}`);
|
|
762
|
+
const body = parseJson(res.text) as { certificates?: FlapsCert[]; next_cursor?: string } | undefined;
|
|
763
|
+
certs.push(...(body?.certificates ?? []));
|
|
764
|
+
const next = body?.next_cursor;
|
|
765
|
+
if (!next) return certs;
|
|
766
|
+
if (seen.has(next)) throw new Error(`certificate list for ${app} repeated cursor ${JSON.stringify(next)} after ${page + 1} page(s)`);
|
|
767
|
+
seen.add(next);
|
|
768
|
+
cursor = next;
|
|
769
|
+
}
|
|
770
|
+
throw new Error(`certificate list for ${app} still had a next_cursor after ${MAX_CERT_PAGES} pages`);
|
|
637
771
|
}
|
|
638
772
|
|
|
639
773
|
/** List an app's live secrets (`{ secrets: [...] }`; digests only, never values). */
|
|
@@ -679,7 +813,11 @@ export async function applyIp(
|
|
|
679
813
|
return { action: "created", type };
|
|
680
814
|
}
|
|
681
815
|
|
|
682
|
-
/**
|
|
816
|
+
/**
|
|
817
|
+
* Create an ACME certificate if absent (idempotent by hostname). Always POSTs
|
|
818
|
+
* `{ hostname }` to `.../certificates/acme`, whatever endpoint the plan entry
|
|
819
|
+
* names, so a plan built before #3114 applies correctly too.
|
|
820
|
+
*/
|
|
683
821
|
export async function applyCert(
|
|
684
822
|
ctx: ApplyCtx,
|
|
685
823
|
app: string,
|
|
@@ -692,11 +830,42 @@ export async function applyCert(
|
|
|
692
830
|
if ((await listCerts(ctx, app, http, signal)).some((c) => c.hostname === hostname)) {
|
|
693
831
|
return { action: "noop", hostname };
|
|
694
832
|
}
|
|
695
|
-
const res = await http("POST",
|
|
833
|
+
const res = await http("POST", acmeCertUrl(ctx.base, app), { hostname }, undefined, signal);
|
|
696
834
|
if (res.status >= 300) throw new Error(`certificate ${app}/${hostname} create failed (${res.status}): ${res.text}`);
|
|
697
835
|
return { action: "created", hostname };
|
|
698
836
|
}
|
|
699
837
|
|
|
838
|
+
/**
|
|
839
|
+
* True when a plan entry is a Secret with no value, which apply must never
|
|
840
|
+
* POST. Plans built before `mustExist` existed mark it with an empty body.
|
|
841
|
+
* Pure.
|
|
842
|
+
*/
|
|
843
|
+
export function isValuelessSecret(req: FlapsRequest): boolean {
|
|
844
|
+
return isSecretRequest(req) && (req.mustExist === true || req.body.value === undefined);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Check a value-less secret (set outside chant) exists on the app. Never
|
|
849
|
+
* POSTs. Throws naming the secret when the app does not have it.
|
|
850
|
+
*/
|
|
851
|
+
export async function checkSecretExists(
|
|
852
|
+
ctx: ApplyCtx,
|
|
853
|
+
app: string,
|
|
854
|
+
name: string,
|
|
855
|
+
http: FlyHttp,
|
|
856
|
+
signal?: AbortSignal,
|
|
857
|
+
): Promise<{ action: "exists"; name: string }> {
|
|
858
|
+
const live = await listSecrets(ctx, app, http, signal);
|
|
859
|
+
if (!live.some((s) => s.name === name)) {
|
|
860
|
+
throw new Error(
|
|
861
|
+
`secret ${app}/${name} is declared without a value, so it must already exist, ` +
|
|
862
|
+
`and app ${app} does not have it. Set it with \`fly secrets set ${name}=...\` ` +
|
|
863
|
+
`or give the Secret a value.`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
return { action: "exists", name };
|
|
867
|
+
}
|
|
868
|
+
|
|
700
869
|
/**
|
|
701
870
|
* Set a secret (D7, apply-only): always POST, never read back for a diff. flaps
|
|
702
871
|
* returns only a digest, so there is nothing to compare — every apply re-sets it.
|
|
@@ -727,7 +896,7 @@ export async function pruneVolumes(
|
|
|
727
896
|
if (keep.has(v.name)) continue;
|
|
728
897
|
const res = await http("DELETE", volumeUrl(ctx.base, app, v.id), undefined, undefined, signal);
|
|
729
898
|
if (res.status >= 300 && res.status !== 404) throw new Error(`volume ${app}/${v.name} delete failed (${res.status}): ${res.text}`);
|
|
730
|
-
|
|
899
|
+
logProgress(`pruned: volume/${app}/${v.name} (${ctx.base})`);
|
|
731
900
|
pruned.push({ app, name: v.name, id: v.id });
|
|
732
901
|
}
|
|
733
902
|
return pruned;
|
|
@@ -746,7 +915,7 @@ export async function pruneIps(
|
|
|
746
915
|
if (keep.has(ipType(ip.shared, ip.ip))) continue;
|
|
747
916
|
const res = await http("DELETE", ipUrl(ctx.base, app, ip.ip), undefined, undefined, signal);
|
|
748
917
|
if (res.status >= 300 && res.status !== 404) throw new Error(`ip ${app}/${ip.ip} delete failed (${res.status}): ${res.text}`);
|
|
749
|
-
|
|
918
|
+
logProgress(`pruned: ip/${app}/${ip.ip} (${ctx.base})`);
|
|
750
919
|
pruned.push({ app, address: ip.ip });
|
|
751
920
|
}
|
|
752
921
|
return pruned;
|
|
@@ -765,7 +934,7 @@ export async function pruneCerts(
|
|
|
765
934
|
if (keep.has(c.hostname)) continue;
|
|
766
935
|
const res = await http("DELETE", certUrl(ctx.base, app, c.hostname), undefined, undefined, signal);
|
|
767
936
|
if (res.status >= 300 && res.status !== 404) throw new Error(`certificate ${app}/${c.hostname} delete failed (${res.status}): ${res.text}`);
|
|
768
|
-
|
|
937
|
+
logProgress(`pruned: certificate/${app}/${c.hostname} (${ctx.base})`);
|
|
769
938
|
pruned.push({ app, hostname: c.hostname });
|
|
770
939
|
}
|
|
771
940
|
return pruned;
|
|
@@ -787,7 +956,7 @@ export async function pruneSecrets(
|
|
|
787
956
|
if (keep.has(s.name)) continue;
|
|
788
957
|
const res = await http("DELETE", secretUrl(ctx.base, app, s.name), undefined, undefined, signal);
|
|
789
958
|
if (res.status >= 300 && res.status !== 404) throw new Error(`secret ${app}/${s.name} delete failed (${res.status}): ${res.text}`);
|
|
790
|
-
|
|
959
|
+
logProgress(`pruned: secret/${app}/${s.name} (${ctx.base})`);
|
|
791
960
|
pruned.push({ app, name: s.name });
|
|
792
961
|
}
|
|
793
962
|
return pruned;
|
|
@@ -817,6 +986,12 @@ export interface FlyApplyArgs {
|
|
|
817
986
|
* default.
|
|
818
987
|
*/
|
|
819
988
|
prune?: boolean;
|
|
989
|
+
/**
|
|
990
|
+
* Destroy machines (prune, `flyDelete`) with `?force=true`, killing a running
|
|
991
|
+
* machine at once. Default false: stop it, wait for `stopped`, then destroy,
|
|
992
|
+
* so it can shut down on its configured kill signal and timeout.
|
|
993
|
+
*/
|
|
994
|
+
force?: boolean;
|
|
820
995
|
/** Wait-loop tuning (mainly for tests). */
|
|
821
996
|
wait?: WaitOpts;
|
|
822
997
|
}
|
|
@@ -853,7 +1028,7 @@ export function toApplyResult(result: {
|
|
|
853
1028
|
volumes: Array<{ app: string; name: string; action: "created" | "noop" }>;
|
|
854
1029
|
ips: Array<{ app: string; type: string; action: "created" | "noop" }>;
|
|
855
1030
|
certs: Array<{ app: string; hostname: string; action: "created" | "noop" }>;
|
|
856
|
-
secrets: Array<{ app: string; name: string }>;
|
|
1031
|
+
secrets: Array<{ app: string; name: string; action?: "set" | "exists" }>;
|
|
857
1032
|
pruned: Array<{ app: string; name: string; id: string }>;
|
|
858
1033
|
prunedVolumes: Array<{ app: string; name: string; id: string }>;
|
|
859
1034
|
prunedIps: Array<{ app: string; address: string }>;
|
|
@@ -873,9 +1048,14 @@ export function toApplyResult(result: {
|
|
|
873
1048
|
...result.volumes.map((x) => ({ kind: "volume", name: x.name, action: act(x.action) })),
|
|
874
1049
|
...result.ips.map((x) => ({ kind: "ip", name: x.type, action: act(x.action) })),
|
|
875
1050
|
...result.certs.map((x) => ({ kind: "cert", name: x.hostname, action: act(x.action) })),
|
|
876
|
-
// Secrets are apply-only (D7) — POSTed every run, never read back, so
|
|
877
|
-
// is
|
|
878
|
-
|
|
1051
|
+
// Secrets are apply-only (D7) — POSTed every run, never read back, so a
|
|
1052
|
+
// set secret reports updated. A value-less secret is only checked, so it
|
|
1053
|
+
// reports unchanged.
|
|
1054
|
+
...result.secrets.map((x) => ({
|
|
1055
|
+
kind: "secret",
|
|
1056
|
+
name: x.name,
|
|
1057
|
+
action: (x.action === "exists" ? "unchanged" : "updated") as AppliedResource["action"],
|
|
1058
|
+
})),
|
|
879
1059
|
];
|
|
880
1060
|
const pruned: PrunedResource[] = [
|
|
881
1061
|
...result.pruned.map((x) => ({ kind: "machine", name: x.name, deleted: true })),
|
|
@@ -897,7 +1077,7 @@ export async function flyApply(
|
|
|
897
1077
|
volumes: Array<{ app: string; name: string; action: "created" | "noop" }>;
|
|
898
1078
|
ips: Array<{ app: string; type: string; action: "created" | "noop" }>;
|
|
899
1079
|
certs: Array<{ app: string; hostname: string; action: "created" | "noop" }>;
|
|
900
|
-
secrets: Array<{ app: string; name: string }>;
|
|
1080
|
+
secrets: Array<{ app: string; name: string; action?: "set" | "exists" }>;
|
|
901
1081
|
pruned: Array<{ app: string; name: string; id: string }>;
|
|
902
1082
|
prunedVolumes: Array<{ app: string; name: string; id: string }>;
|
|
903
1083
|
prunedIps: Array<{ app: string; address: string }>;
|
|
@@ -946,7 +1126,7 @@ export async function flyApply(
|
|
|
946
1126
|
const apps: Array<{ app: string; created: boolean }> = [];
|
|
947
1127
|
for (const req of appReqs) {
|
|
948
1128
|
const result = await applyApp(ctx, req, http, signal);
|
|
949
|
-
|
|
1129
|
+
logProgress(`${result.created ? "created" : "unchanged"}: app/${result.app} (${ctx.base})`);
|
|
950
1130
|
apps.push(result);
|
|
951
1131
|
}
|
|
952
1132
|
|
|
@@ -978,7 +1158,7 @@ export async function flyApply(
|
|
|
978
1158
|
const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
|
|
979
1159
|
const result = await applyVolume(ctx, app, entityName, req, http, signal);
|
|
980
1160
|
track(keepVolumes, app, result.name);
|
|
981
|
-
|
|
1161
|
+
logProgress(`${result.action}: volume/${app}/${result.name} (${ctx.base})`);
|
|
982
1162
|
volumes.push({ app, name: result.name, action: result.action });
|
|
983
1163
|
}
|
|
984
1164
|
|
|
@@ -988,7 +1168,7 @@ export async function flyApply(
|
|
|
988
1168
|
const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
|
|
989
1169
|
track(keepMachines, app, name);
|
|
990
1170
|
const result = await applyMachine(ctx, app, entityName, req, http, signal, opts);
|
|
991
|
-
|
|
1171
|
+
logProgress(`${result.action}: machine/${app}/${result.name} (${ctx.base})`);
|
|
992
1172
|
machines.push({ app, name: result.name, action: result.action });
|
|
993
1173
|
}
|
|
994
1174
|
|
|
@@ -998,7 +1178,7 @@ export async function flyApply(
|
|
|
998
1178
|
const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
|
|
999
1179
|
const result = await applyIp(ctx, app, req, http, signal);
|
|
1000
1180
|
track(keepIps, app, result.type);
|
|
1001
|
-
|
|
1181
|
+
logProgress(`${result.action}: ip/${app}/${result.type} (${ctx.base})`);
|
|
1002
1182
|
ips.push({ app, type: result.type, action: result.action });
|
|
1003
1183
|
}
|
|
1004
1184
|
|
|
@@ -1007,19 +1187,22 @@ export async function flyApply(
|
|
|
1007
1187
|
const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
|
|
1008
1188
|
const result = await applyCert(ctx, app, req, http, signal);
|
|
1009
1189
|
track(keepCerts, app, result.hostname);
|
|
1010
|
-
|
|
1190
|
+
logProgress(`${result.action}: certificate/${app}/${result.hostname} (${ctx.base})`);
|
|
1011
1191
|
certs.push({ app, hostname: result.hostname, action: result.action });
|
|
1012
1192
|
}
|
|
1013
1193
|
|
|
1014
|
-
// Secrets are apply-only (D7):
|
|
1015
|
-
|
|
1194
|
+
// Secrets are apply-only (D7): a secret with a value is set every run and
|
|
1195
|
+
// never read back for a diff. A value-less one is only checked for.
|
|
1196
|
+
const secrets: Array<{ app: string; name: string; action: "set" | "exists" }> = [];
|
|
1016
1197
|
for (const [, req] of secretReqs) {
|
|
1017
1198
|
const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
|
|
1018
1199
|
const name = secretNameSegment(req.endpoint);
|
|
1019
1200
|
track(keepSecrets, app, name);
|
|
1020
|
-
const result =
|
|
1021
|
-
|
|
1022
|
-
|
|
1201
|
+
const result = isValuelessSecret(req)
|
|
1202
|
+
? await checkSecretExists(ctx, app, name, http, signal)
|
|
1203
|
+
: await applySecret(ctx, app, name, req, http, signal);
|
|
1204
|
+
logProgress(`${result.action}: secret/${app}/${result.name} (${ctx.base})`);
|
|
1205
|
+
secrets.push({ app, name: result.name, action: result.action });
|
|
1023
1206
|
}
|
|
1024
1207
|
|
|
1025
1208
|
const pruned: Array<{ app: string; name: string; id: string }> = [];
|
|
@@ -1029,7 +1212,7 @@ export async function flyApply(
|
|
|
1029
1212
|
const prunedSecrets: Array<{ app: string; name: string }> = [];
|
|
1030
1213
|
if (args.prune) {
|
|
1031
1214
|
for (const [app, keep] of keepMachines) {
|
|
1032
|
-
pruned.push(...(await pruneMachines(ctx, app, keep, http, signal, opts)));
|
|
1215
|
+
pruned.push(...(await pruneMachines(ctx, app, keep, http, signal, { ...opts, force: args.force })));
|
|
1033
1216
|
}
|
|
1034
1217
|
for (const [app, keep] of keepVolumes) {
|
|
1035
1218
|
prunedVolumes.push(...(await pruneVolumes(ctx, app, keep, http, signal)));
|
|
@@ -1050,7 +1233,8 @@ export async function flyApply(
|
|
|
1050
1233
|
|
|
1051
1234
|
/**
|
|
1052
1235
|
* The inverse of {@link flyApply}: destroy the machines the plan declares, then
|
|
1053
|
-
* delete the apps (dependents before their app).
|
|
1236
|
+
* delete the apps (dependents before their app). A running machine is stopped
|
|
1237
|
+
* first unless `args.force` is set. Idempotent — already-absent
|
|
1054
1238
|
* resources are a no-op. `http` is injectable for tests.
|
|
1055
1239
|
*/
|
|
1056
1240
|
export async function flyDelete(
|
|
@@ -1076,14 +1260,14 @@ export async function flyDelete(
|
|
|
1076
1260
|
const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
|
|
1077
1261
|
const live = (await listMachines(ctx, app, http, signal)).find((m) => m.name === name);
|
|
1078
1262
|
if (!live) continue;
|
|
1079
|
-
await destroyMachine(ctx, app, live.id, http, signal, opts);
|
|
1263
|
+
await destroyMachine(ctx, app, live.id, http, signal, { ...opts, force: args.force, state: live.state, name });
|
|
1080
1264
|
machines.push({ app, name });
|
|
1081
1265
|
}
|
|
1082
1266
|
|
|
1083
1267
|
const apps: Array<{ app: string; deleted: boolean }> = [];
|
|
1084
1268
|
for (const req of appReqs) {
|
|
1085
1269
|
const result = await deleteApp(ctx, appNameFromRequest(req), http, signal);
|
|
1086
|
-
|
|
1270
|
+
logProgress(`${result.deleted ? "deleted" : "absent"}: app/${result.app} (${ctx.base})`);
|
|
1087
1271
|
apps.push(result);
|
|
1088
1272
|
}
|
|
1089
1273
|
|
|
@@ -14,6 +14,11 @@ export {
|
|
|
14
14
|
destroyMachine,
|
|
15
15
|
deleteApp,
|
|
16
16
|
listMachines,
|
|
17
|
+
listVolumes,
|
|
18
|
+
listIps,
|
|
19
|
+
listCerts,
|
|
20
|
+
listSecrets,
|
|
21
|
+
checkSecretExists,
|
|
17
22
|
pruneMachines,
|
|
18
23
|
waitForMachine,
|
|
19
24
|
acquireLease,
|
|
@@ -23,7 +28,16 @@ export {
|
|
|
23
28
|
parsePlan,
|
|
24
29
|
isAppRequest,
|
|
25
30
|
isMachineRequest,
|
|
31
|
+
isVolumeRequest,
|
|
32
|
+
isIpRequest,
|
|
33
|
+
isCertRequest,
|
|
34
|
+
isSecretRequest,
|
|
35
|
+
isValuelessSecret,
|
|
26
36
|
machineAppSegment,
|
|
37
|
+
resourceAppSegment,
|
|
38
|
+
secretNameSegment,
|
|
39
|
+
ipType,
|
|
40
|
+
declaredIpType,
|
|
27
41
|
resolveApp,
|
|
28
42
|
appNameFromRequest,
|
|
29
43
|
isChantOwned,
|
|
@@ -33,7 +47,19 @@ export {
|
|
|
33
47
|
DEFAULT_FLAPS_BASE_URL,
|
|
34
48
|
LEASE_NONCE_HEADER,
|
|
35
49
|
} from "./fly-apply";
|
|
36
|
-
export type {
|
|
50
|
+
export type {
|
|
51
|
+
FlyApplyArgs,
|
|
52
|
+
FlyPlan,
|
|
53
|
+
FlapsRequest,
|
|
54
|
+
FlapsMachine,
|
|
55
|
+
FlapsVolume,
|
|
56
|
+
FlapsIp,
|
|
57
|
+
FlapsCert,
|
|
58
|
+
FlapsSecret,
|
|
59
|
+
FlyHttp,
|
|
60
|
+
WaitOpts,
|
|
61
|
+
ApplyCtx,
|
|
62
|
+
} from "./fly-apply";
|
|
37
63
|
|
|
38
64
|
// The release a Machine serves and the site steps over the Machines API
|
|
39
65
|
// (#2736, ws-056): upload and start, exec (migrations), restart, stop, verify,
|
|
@@ -16,6 +16,17 @@ const PORT = 4283;
|
|
|
16
16
|
let available = false;
|
|
17
17
|
let endpoint = "";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Contract endpoints the pinned mudflaps is known not to serve, each with the
|
|
21
|
+
* upstream issue. The applier calls the real flaps path; the docker-gated
|
|
22
|
+
* apply test shims these. A gap the pin has since closed fails the last test
|
|
23
|
+
* below, so the entry and the shim get removed with the pin bump.
|
|
24
|
+
*/
|
|
25
|
+
const MUDFLAPS_KNOWN_GAPS = new Map<string, string>([
|
|
26
|
+
// ACME certificate create (#3114); mudflaps only serves POST .../certificates.
|
|
27
|
+
[normalizeEndpoint("POST", "/v1/apps/{app}/certificates/acme"), "INTENTIUS/mudflaps#69"],
|
|
28
|
+
]);
|
|
29
|
+
|
|
19
30
|
/** Parse a mudflaps health `implemented` entry ("METHOD path (note)") to a normalized key. */
|
|
20
31
|
function normalizeImplemented(entry: string): string {
|
|
21
32
|
const stripped = entry.replace(/\s*\(.*\)\s*$/, "").trim();
|
|
@@ -52,7 +63,7 @@ describe("Machines contract ⊆ mudflaps implemented paths", () => {
|
|
|
52
63
|
|
|
53
64
|
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
54
65
|
|
|
55
|
-
const missing = [...contractKeys()].filter((k) => !served.has(k));
|
|
66
|
+
const missing = [...contractKeys()].filter((k) => !served.has(k) && !MUDFLAPS_KNOWN_GAPS.has(k));
|
|
56
67
|
// A non-empty list means flyApply calls something mudflaps can't serve — a
|
|
57
68
|
// real fidelity gap between the applier and the pinned emulator.
|
|
58
69
|
expect(missing, `mudflaps is missing contract endpoints: ${missing.join(", ")}`).toEqual([]);
|
|
@@ -78,4 +89,14 @@ describe("Machines contract ⊆ mudflaps implemented paths", () => {
|
|
|
78
89
|
const missing = MACHINE_RELEASE_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)).filter((k) => !served.has(k));
|
|
79
90
|
expect(missing, `mudflaps is missing release endpoints: ${missing.join(", ")}`).toEqual([]);
|
|
80
91
|
});
|
|
92
|
+
|
|
93
|
+
test("every known mudflaps gap is still a gap (drop the entry when the pin closes it)", async (ctx) => {
|
|
94
|
+
if (!available) ctx.skip();
|
|
95
|
+
const res = await fetch(`${endpoint}/_mudflaps/health`);
|
|
96
|
+
const health = (await res.json()) as { implemented?: string[] };
|
|
97
|
+
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
98
|
+
for (const [key, issue] of MUDFLAPS_KNOWN_GAPS) {
|
|
99
|
+
expect(served.has(key), `${key} is served now (${issue}): remove it from MUDFLAPS_KNOWN_GAPS and the apply-test shim`).toBe(false);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
81
102
|
});
|
|
@@ -39,6 +39,9 @@ export const MACHINES_CONTRACT: readonly MachinesEndpoint[] = [
|
|
|
39
39
|
{ method: "GET", path: "/v1/apps/{app}/machines", op: "listMachines" },
|
|
40
40
|
{ method: "POST", path: "/v1/apps/{app}/machines", op: "createMachine" },
|
|
41
41
|
{ method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "updateMachine" },
|
|
42
|
+
// A running machine is stopped before the destroy (#3115); `?force=true` is a
|
|
43
|
+
// query, so it stays out of the path template.
|
|
44
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/stop", op: "stopForDestroy" },
|
|
42
45
|
{ method: "DELETE", path: "/v1/apps/{app}/machines/{id}", op: "destroyMachine" },
|
|
43
46
|
{ method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
|
|
44
47
|
// Leases
|
|
@@ -54,7 +57,8 @@ export const MACHINES_CONTRACT: readonly MachinesEndpoint[] = [
|
|
|
54
57
|
{ method: "DELETE", path: "/v1/apps/{app}/ip_assignments/{ip}", op: "releaseIp" },
|
|
55
58
|
// Certificates
|
|
56
59
|
{ method: "GET", path: "/v1/apps/{app}/certificates", op: "listCerts" },
|
|
57
|
-
|
|
60
|
+
// Created at /acme: flaps has no POST on the list path (#3114).
|
|
61
|
+
{ method: "POST", path: "/v1/apps/{app}/certificates/acme", op: "addCert" },
|
|
58
62
|
{ method: "DELETE", path: "/v1/apps/{app}/certificates/{hostname}", op: "deleteCert" },
|
|
59
63
|
// Secrets
|
|
60
64
|
{ method: "GET", path: "/v1/apps/{app}/secrets", op: "listSecrets" },
|