@intentius/chant-lexicon-fly 0.124.67 → 0.124.68
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/README.md +1 -1
- package/dist/components/fly-release.d.ts +22 -3
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/components/fly-release.js +86 -38
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +8 -3
- package/dist/integrity.json +6 -6
- package/dist/lint/audit-catalog.js +1 -1
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +4 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -1
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +10 -2
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/FLY011.md +2 -2
- package/dist/okf/types/Machine.md +1 -1
- package/dist/okf/types/Volume.md +1 -1
- package/dist/op/activities/fly-apply.d.ts +91 -1
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.js +165 -7
- package/dist/op/activities/machine-release.d.ts +105 -4
- package/dist/op/activities/machine-release.d.ts.map +1 -1
- package/dist/op/activities/machine-release.js +147 -8
- package/dist/op/activities/machines-fake.d.ts +30 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.js +60 -3
- package/dist/rules/fly011-mount-references-declared-volume.ts +9 -2
- package/dist/skills/chant-fly-ops.md +26 -2
- package/dist/skills/chant-fly-patterns.md +4 -2
- package/dist/skills/chant-fly.md +1 -1
- package/dist/volume-id.d.ts +9 -0
- package/dist/volume-id.d.ts.map +1 -0
- package/dist/volume-id.js +10 -0
- package/package.json +3 -3
- package/src/components/fly-release.test.ts +231 -0
- package/src/components/fly-release.ts +106 -39
- package/src/describe-resources.test.ts +27 -0
- package/src/describe-resources.ts +9 -2
- package/src/lint/audit-catalog.ts +1 -1
- package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +31 -0
- package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +9 -2
- package/src/op/activities/fly-apply.test.ts +170 -2
- package/src/op/activities/fly-apply.ts +229 -9
- package/src/op/activities/machine-release.test.ts +175 -1
- package/src/op/activities/machine-release.ts +222 -12
- package/src/op/activities/machines-fake.ts +89 -3
- package/src/op/activities/machines-local.test.ts +1 -0
- package/src/skills/chant-fly-ops.md +26 -2
- package/src/skills/chant-fly-patterns.md +4 -2
- package/src/skills/chant-fly.md +1 -1
- package/src/volume-id.ts +11 -0
|
@@ -43,6 +43,13 @@ import {
|
|
|
43
43
|
flyDelete,
|
|
44
44
|
toApplyResult,
|
|
45
45
|
listCerts,
|
|
46
|
+
isVolumeId,
|
|
47
|
+
pickVolume,
|
|
48
|
+
resolveMountsWith,
|
|
49
|
+
volumeAliases,
|
|
50
|
+
alignMountsWithLive,
|
|
51
|
+
type FlapsVolume,
|
|
52
|
+
type FlyPlan,
|
|
46
53
|
CERT_PAGE_LIMIT,
|
|
47
54
|
MAX_CERT_PAGES,
|
|
48
55
|
DEFAULT_FLAPS_BASE_URL,
|
|
@@ -908,11 +915,12 @@ describe("flyApply orders volumes before the machines that mount them", () => {
|
|
|
908
915
|
writeFileSync(planPath, JSON.stringify(plan));
|
|
909
916
|
|
|
910
917
|
const order: string[] = [];
|
|
918
|
+
const vols: Array<{ id: string; name: string }> = [];
|
|
911
919
|
const http: FlyHttp = async (method, url) => {
|
|
912
920
|
if (method === "GET" && /\/apps\/demo$/.test(url)) return { status: 404, text: "" };
|
|
913
921
|
if (method === "POST" && /\/apps$/.test(url)) { order.push("app"); return { status: 200, text: "{}" }; }
|
|
914
|
-
if (method === "GET" && url.endsWith("/volumes")) return { status: 200, text:
|
|
915
|
-
if (method === "POST" && url.endsWith("/volumes")) { order.push("volume");
|
|
922
|
+
if (method === "GET" && url.endsWith("/volumes")) return { status: 200, text: JSON.stringify(vols) };
|
|
923
|
+
if (method === "POST" && url.endsWith("/volumes")) { order.push("volume"); vols.push({ id: "vol_1", name: "data" }); return { status: 200, text: JSON.stringify(vols[0]) }; }
|
|
916
924
|
if (method === "GET" && url.endsWith("/machines")) return { status: 200, text: "[]" };
|
|
917
925
|
if (method === "POST" && url.endsWith("/machines")) { order.push("machine"); return { status: 200, text: JSON.stringify({ id: "m1", name: "web", state: "created", instance_id: "I1", config: { image: "img:1" } }) }; }
|
|
918
926
|
if (method === "GET" && url.includes("/wait")) return { status: 200, text: JSON.stringify({ ok: true }) };
|
|
@@ -930,6 +938,166 @@ describe("flyApply orders volumes before the machines that mount them", () => {
|
|
|
930
938
|
});
|
|
931
939
|
});
|
|
932
940
|
|
|
941
|
+
/**
|
|
942
|
+
* #4717 — the Machines API takes a volume's id in `config.mounts[].volume`.
|
|
943
|
+
* A mount that names a volume is sent with the id of the app's live volume of
|
|
944
|
+
* that name, and the name in the mount's `name`.
|
|
945
|
+
*/
|
|
946
|
+
describe("a mount's volume name is sent as the volume's id (#4717)", () => {
|
|
947
|
+
const WAIT = { intervalMs: 0, deadlineMs: 5_000 };
|
|
948
|
+
const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
|
|
949
|
+
const volume = (name: string, region = "iad") => ({ endpoint: "/v1/apps/demo/volumes", method: "POST", body: { name, region, size_gb: 1 } });
|
|
950
|
+
const machine = (image: string, mount: Record<string, unknown>, region = "iad") => ({
|
|
951
|
+
endpoint: "/v1/apps/demo/machines",
|
|
952
|
+
method: "POST",
|
|
953
|
+
body: { name: "web", region, config: { image, mounts: [mount], metadata: { ...OWNED_META } } },
|
|
954
|
+
});
|
|
955
|
+
/** The bodies the applier POSTs to create or update a Machine. */
|
|
956
|
+
const machinePosts = (fake: ReturnType<typeof createMachinesFake>) => {
|
|
957
|
+
const posts: Array<Record<string, unknown>> = [];
|
|
958
|
+
const http: FlyHttp = async (method, url, body, headers, signal) => {
|
|
959
|
+
if (method === "POST" && /\/machines(\/[^/]+)?$/.test(new URL(url).pathname)) posts.push(body as Record<string, unknown>);
|
|
960
|
+
return fake.http(method, url, body, headers, signal);
|
|
961
|
+
};
|
|
962
|
+
return { http, posts };
|
|
963
|
+
};
|
|
964
|
+
const mountsOf = (body: Record<string, unknown> | undefined) => (body?.config as { mounts?: unknown[] } | undefined)?.mounts;
|
|
965
|
+
const vol = (id: string, name: string, extra: Partial<FlapsVolume> = {}): FlapsVolume => ({ id, name, state: "created", ...extra });
|
|
966
|
+
|
|
967
|
+
test("isVolumeId tells an id from a name", () => {
|
|
968
|
+
expect(isVolumeId("vol_r68lmz2doox9myd4")).toBe(true);
|
|
969
|
+
expect(isVolumeId("control_data")).toBe(false);
|
|
970
|
+
expect(isVolumeId("vol_")).toBe(false);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test("a volume declared in the same apply is created first and its id is mounted; a re-apply is a no-op", async () => {
|
|
974
|
+
const fake = createMachinesFake();
|
|
975
|
+
const { http, posts } = machinePosts(fake);
|
|
976
|
+
const plan: FlyPlan = { app, web: machine("img:1", { volume: "data", path: "/data" }), data: volume("data") };
|
|
977
|
+
const out = await flyApply({ plan, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
978
|
+
expect(out.volumes).toEqual([{ app: "demo", name: "data", action: "created" }]);
|
|
979
|
+
expect(out.machines).toEqual([{ app: "demo", name: "web", action: "created" }]);
|
|
980
|
+
const [created] = fake.volumes.get("demo")!;
|
|
981
|
+
expect(created.id).toMatch(/^vol_/);
|
|
982
|
+
expect(mountsOf(posts[0])).toEqual([{ volume: created.id, name: "data", path: "/data" }]);
|
|
983
|
+
expect(fake.machine("demo", "web")!.config.mounts).toEqual([{ volume: created.id, name: "data", path: "/data" }]);
|
|
984
|
+
expect(created.attached_machine_id).toBe(fake.machine("demo", "web")!.id);
|
|
985
|
+
|
|
986
|
+
const again = await flyApply({ plan, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
987
|
+
expect(again.machines).toEqual([{ app: "demo", name: "web", action: "noop" }]);
|
|
988
|
+
expect(posts).toHaveLength(1);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test("an update sends the id of the volume the Machine already has", async () => {
|
|
992
|
+
const fake = createMachinesFake();
|
|
993
|
+
const { http, posts } = machinePosts(fake);
|
|
994
|
+
await flyApply({ plan: { app, data: volume("data"), web: machine("img:1", { volume: "data", path: "/data" }) }, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
995
|
+
const id = fake.volumes.get("demo")![0].id;
|
|
996
|
+
// A second volume of the same name, free in the same region: the Machine keeps its own.
|
|
997
|
+
fake.volumes.get("demo")!.push({ id: "vol_spare", name: "data", region: "iad", state: "created", attached_machine_id: null });
|
|
998
|
+
|
|
999
|
+
const out = await flyApply({ plan: { app, data: volume("data"), web: machine("img:2", { volume: "data", path: "/data" }) }, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
1000
|
+
expect(out.machines).toEqual([{ app: "demo", name: "web", action: "updated" }]);
|
|
1001
|
+
expect(mountsOf(posts[1])).toEqual([{ volume: id, name: "data", path: "/data" }]);
|
|
1002
|
+
expect(fake.machine("demo", "web")!.config.image).toBe("img:2");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("a mount given an id goes through as it is, and no volume list is read for it", async () => {
|
|
1006
|
+
const fake = createMachinesFake();
|
|
1007
|
+
const { http, posts } = machinePosts(fake);
|
|
1008
|
+
const mount = { volume: "vol_r68lmz2doox9myd4", name: "control_data", path: "/data" };
|
|
1009
|
+
await flyApply({ plan: { app, web: machine("img:1", mount) }, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
1010
|
+
expect(mountsOf(posts[0])).toEqual([mount]);
|
|
1011
|
+
expect(fake.calls.filter((c) => c.endsWith("/volumes"))).toEqual([]);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("a mount naming no volume in the app fails clearly, and no Machine is posted", async () => {
|
|
1015
|
+
const fake = createMachinesFake();
|
|
1016
|
+
const { http, posts } = machinePosts(fake);
|
|
1017
|
+
await expect(
|
|
1018
|
+
flyApply({ plan: { app, logs: volume("logs"), web: machine("img:1", { volume: "data", path: "/data" }) }, endpoint: CTX.base, wait: WAIT }, undefined, http),
|
|
1019
|
+
).rejects.toThrow(/no volume named "data" in app demo .*the app's volumes: logs.*vol_\.\.\./);
|
|
1020
|
+
expect(posts).toEqual([]);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
test("two free volumes of the name in the Machine's region are refused, naming both", async () => {
|
|
1024
|
+
const fake = createMachinesFake();
|
|
1025
|
+
const { http, posts } = machinePosts(fake);
|
|
1026
|
+
await flyApply({ plan: { app }, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
1027
|
+
fake.volumes.set("demo", [
|
|
1028
|
+
{ id: "vol_a", name: "data", region: "iad", state: "created", attached_machine_id: null },
|
|
1029
|
+
{ id: "vol_b", name: "data", region: "iad", state: "created", attached_machine_id: null },
|
|
1030
|
+
]);
|
|
1031
|
+
await expect(
|
|
1032
|
+
flyApply({ plan: { app, web: machine("img:1", { volume: "data", path: "/data" }) }, endpoint: CTX.base, wait: WAIT }, undefined, http),
|
|
1033
|
+
).rejects.toThrow(/volume name "data" is ambiguous in app demo for a new Machine in iad: 2 are free in iad \(vol_a in iad, unattached; vol_b in iad, unattached\)/);
|
|
1034
|
+
expect(posts).toEqual([]);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("of several volumes of the name, the one in the Machine's region that is free is picked", async () => {
|
|
1038
|
+
const fake = createMachinesFake();
|
|
1039
|
+
const { http, posts } = machinePosts(fake);
|
|
1040
|
+
await flyApply({ plan: { app }, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
1041
|
+
fake.volumes.set("demo", [
|
|
1042
|
+
{ id: "vol_iad", name: "data", region: "iad", state: "created", attached_machine_id: null },
|
|
1043
|
+
{ id: "vol_ordtaken", name: "data", region: "ord", state: "created", attached_machine_id: "m-other" },
|
|
1044
|
+
{ id: "vol_ord", name: "data", region: "ord", state: "created", attached_machine_id: null },
|
|
1045
|
+
]);
|
|
1046
|
+
await flyApply({ plan: { app, web: machine("img:1", { volume: "data", path: "/data" }, "ord") }, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
1047
|
+
expect(mountsOf(posts[0])).toEqual([{ volume: "vol_ord", name: "data", path: "/data" }]);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test("a mount naming a Volume's declaration resolves to that Volume's name, which goes in `name`", async () => {
|
|
1051
|
+
const fake = createMachinesFake();
|
|
1052
|
+
const { http, posts } = machinePosts(fake);
|
|
1053
|
+
// A `Volume` reference serializes to the declaration's name, here `store`.
|
|
1054
|
+
const plan: FlyPlan = { app, store: volume("files"), web: machine("img:1", { volume: "store", path: "/data" }) };
|
|
1055
|
+
expect(volumeAliases(plan)).toEqual(new Map([["store", "files"]]));
|
|
1056
|
+
await flyApply({ plan, endpoint: CTX.base, wait: WAIT }, undefined, http);
|
|
1057
|
+
expect(mountsOf(posts[0])).toEqual([{ volume: fake.volumes.get("demo")![0].id, name: "files", path: "/data" }]);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test("pickVolume: the Machine's own volume first, then a free one in its region; destroyed volumes never count", () => {
|
|
1061
|
+
const vols = [
|
|
1062
|
+
vol("vol_mine", "data", { region: "iad", attached_machine_id: "m1" }),
|
|
1063
|
+
vol("vol_free", "data", { region: "iad", attached_machine_id: null }),
|
|
1064
|
+
vol("vol_gone", "data", { region: "iad", state: "pending_destroy" }),
|
|
1065
|
+
];
|
|
1066
|
+
expect(pickVolume("demo", "data", vols, { machineId: "m1", region: "iad" }).id).toBe("vol_mine");
|
|
1067
|
+
expect(pickVolume("demo", "data", vols, { machineId: "m2", region: "iad" }).id).toBe("vol_free");
|
|
1068
|
+
expect(pickVolume("demo", "data", vols, {}).id).toBe("vol_free");
|
|
1069
|
+
expect(() => pickVolume("demo", "data", [vols[2]], {})).toThrow(/no volume named "data"/);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("pickVolume: a volume attached to another Machine, or only in another region, is refused naming it", () => {
|
|
1073
|
+
expect(() => pickVolume("demo", "data", [vol("vol_x", "data", { region: "iad", attached_machine_id: "m9" })], { machineId: "m1", region: "iad" })).toThrow(
|
|
1074
|
+
/ambiguous.*none is attached to Machine m1 or free in iad \(vol_x in iad, attached to m9\)/,
|
|
1075
|
+
);
|
|
1076
|
+
expect(() => pickVolume("demo", "data", [vol("vol_y", "data", { region: "ord" })], { region: "iad" })).toThrow(/vol_y in ord, unattached/);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
test("resolveMountsWith keeps a mount's own name, passes ids and other config through, and returns config itself when nothing names a volume", () => {
|
|
1080
|
+
const vols = [vol("vol_1", "data")];
|
|
1081
|
+
const config = { image: "x", mounts: [{ volume: "data", name: "custom", path: "/d" }, { volume: "vol_2", path: "/e" }] };
|
|
1082
|
+
expect(resolveMountsWith("demo", config, vols, {})).toEqual({
|
|
1083
|
+
image: "x",
|
|
1084
|
+
mounts: [{ volume: "vol_1", name: "custom", path: "/d" }, { volume: "vol_2", path: "/e" }],
|
|
1085
|
+
});
|
|
1086
|
+
const byId = { image: "x", mounts: [{ volume: "vol_2", path: "/e" }] };
|
|
1087
|
+
expect(resolveMountsWith("demo", byId, [], {})).toBe(byId);
|
|
1088
|
+
expect(resolveMountsWith("demo", undefined, [], {})).toBeUndefined();
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
test("alignMountsWithLive takes the live id where the live mount carries the declared name at the same path", () => {
|
|
1092
|
+
const declared = { image: "x", mounts: [{ volume: "data", path: "/data" }] };
|
|
1093
|
+
const live = { image: "x", mounts: [{ volume: "vol_1", name: "data", path: "/data" }] };
|
|
1094
|
+
expect(configEqual(alignMountsWithLive(declared, live), live)).toBe(true);
|
|
1095
|
+
const elsewhere = { image: "x", mounts: [{ volume: "vol_1", name: "data", path: "/other" }] };
|
|
1096
|
+
expect(alignMountsWithLive(declared, elsewhere)).toBe(declared);
|
|
1097
|
+
expect(configEqual(alignMountsWithLive({ mounts: [{ volume: "store", path: "/d" }] }, { mounts: [{ volume: "vol_1", name: "files", path: "/d" }] }, new Map([["store", "files"]])), { mounts: [{ volume: "vol_1", name: "files", path: "/d" }] })).toBe(true);
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
1100
|
+
|
|
933
1101
|
/**
|
|
934
1102
|
* #2516 — a Secret declared without a value is set outside chant. Posting it
|
|
935
1103
|
* as `{}` on every apply clobbered or no-oped the real secret. Apply checks the
|
|
@@ -26,7 +26,10 @@ 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 { isVolumeId } from "../../volume-id";
|
|
29
30
|
import { logProgress } from "./progress";
|
|
31
|
+
|
|
32
|
+
export { isVolumeId } from "../../volume-id";
|
|
30
33
|
import {
|
|
31
34
|
applyResult,
|
|
32
35
|
type ApplyResult,
|
|
@@ -70,7 +73,34 @@ export interface FlapsMachine {
|
|
|
70
73
|
name: string;
|
|
71
74
|
state: string;
|
|
72
75
|
instance_id: string;
|
|
76
|
+
region?: string;
|
|
73
77
|
config?: { metadata?: Record<string, string> } & Record<string, unknown>;
|
|
78
|
+
/** When flaps last changed the Machine, by its own clock (RFC 3339). */
|
|
79
|
+
updated_at?: string;
|
|
80
|
+
/** The status of each of the Machine's Fly checks, as the Machines API reports them (#4719). */
|
|
81
|
+
checks?: FlapsCheckStatus[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One of a Machine's Fly checks as the Machines API reports it: `passing`, `warning` or `critical`. */
|
|
85
|
+
export interface FlapsCheckStatus {
|
|
86
|
+
name: string;
|
|
87
|
+
status: string;
|
|
88
|
+
output?: string;
|
|
89
|
+
updated_at?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One Machine {@link flyApply} has just created, updated or left alone. */
|
|
93
|
+
export interface AppliedMachine {
|
|
94
|
+
app: string;
|
|
95
|
+
/** The plan entity that declares it. */
|
|
96
|
+
entity: string;
|
|
97
|
+
name: string;
|
|
98
|
+
id: string;
|
|
99
|
+
action: "created" | "updated" | "noop";
|
|
100
|
+
/** True when it was left unstarted (a scheduled Machine, #4696). */
|
|
101
|
+
unlaunched: boolean;
|
|
102
|
+
/** The Machine's `updated_at` as the create or update call answered it, when it changed and flaps gave one. */
|
|
103
|
+
updatedAt?: string;
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
// ── Pure helpers (unit-testable without http) ─────────────────────────────────
|
|
@@ -528,6 +558,10 @@ export async function listMachines(
|
|
|
528
558
|
*
|
|
529
559
|
* A Machine that {@link skipsLaunch} (a scheduled one, #4696) is created and
|
|
530
560
|
* updated with `skip_launch: true`, and the wait is for it to rest unstarted.
|
|
561
|
+
*
|
|
562
|
+
* A mount that names a volume is sent with the volume's id (#4717), looked up
|
|
563
|
+
* among the app's live volumes ({@link pickVolume}); the comparison with the
|
|
564
|
+
* live config is made after the lookup, so an unchanged Machine stays a no-op.
|
|
531
565
|
*/
|
|
532
566
|
export async function applyMachine(
|
|
533
567
|
ctx: ApplyCtx,
|
|
@@ -536,12 +570,10 @@ export async function applyMachine(
|
|
|
536
570
|
req: FlapsRequest,
|
|
537
571
|
http: FlyHttp,
|
|
538
572
|
signal?: AbortSignal,
|
|
539
|
-
opts: WaitOpts = {},
|
|
540
|
-
): Promise<{ action: "created" | "updated" | "noop"; id: string; name: string }> {
|
|
573
|
+
opts: WaitOpts & { volumeAliases?: ReadonlyMap<string, string> } = {},
|
|
574
|
+
): Promise<{ action: "created" | "updated" | "noop"; id: string; name: string; updatedAt?: string }> {
|
|
541
575
|
const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
|
|
542
576
|
const unlaunched = skipsLaunch(req.body);
|
|
543
|
-
const body = { ...req.body, name, ...(unlaunched ? { skip_launch: true } : {}) };
|
|
544
|
-
const desiredConfig = req.body.config;
|
|
545
577
|
const settle = (m: FlapsMachine) =>
|
|
546
578
|
unlaunched
|
|
547
579
|
? waitForUnlaunched(ctx, app, m.id, m.instance_id, http, signal, opts)
|
|
@@ -549,12 +581,21 @@ export async function applyMachine(
|
|
|
549
581
|
|
|
550
582
|
const live = (await listMachines(ctx, app, http, signal)).find((m) => m.name === name);
|
|
551
583
|
|
|
584
|
+
const region = typeof req.body.region === "string" && req.body.region ? req.body.region : live?.region;
|
|
585
|
+
const desiredConfig = await resolveMounts(ctx, app, req.body.config, { machineId: live?.id, region }, http, signal, opts.volumeAliases);
|
|
586
|
+
const body = {
|
|
587
|
+
...req.body,
|
|
588
|
+
name,
|
|
589
|
+
...(desiredConfig !== undefined ? { config: desiredConfig } : {}),
|
|
590
|
+
...(unlaunched ? { skip_launch: true } : {}),
|
|
591
|
+
};
|
|
592
|
+
|
|
552
593
|
if (!live) {
|
|
553
594
|
const res = await http("POST", machinesUrl(ctx.base, app), body, undefined, signal);
|
|
554
595
|
if (res.status >= 300) throw new Error(`machine ${app}/${name} create failed (${res.status}): ${res.text}`);
|
|
555
596
|
const m = parseMachine(res.text);
|
|
556
597
|
await settle(m);
|
|
557
|
-
return { action: "created", id: m.id, name };
|
|
598
|
+
return { action: "created", id: m.id, name, ...(m.updated_at ? { updatedAt: m.updated_at } : {}) };
|
|
558
599
|
}
|
|
559
600
|
|
|
560
601
|
if (configEqual(desiredConfig, live.config)) {
|
|
@@ -567,7 +608,7 @@ export async function applyMachine(
|
|
|
567
608
|
if (res.status >= 300) throw new Error(`machine ${app}/${name} update failed (${res.status}): ${res.text}`);
|
|
568
609
|
const m = parseMachine(res.text);
|
|
569
610
|
await settle(m);
|
|
570
|
-
return { action: "updated", id: m.id, name };
|
|
611
|
+
return { action: "updated", id: m.id, name, ...(m.updated_at ? { updatedAt: m.updated_at } : {}) };
|
|
571
612
|
}
|
|
572
613
|
|
|
573
614
|
/** How {@link destroyMachine} treats a machine that is still running. */
|
|
@@ -738,6 +779,9 @@ export interface FlapsVolume {
|
|
|
738
779
|
id: string;
|
|
739
780
|
name: string;
|
|
740
781
|
state?: string;
|
|
782
|
+
region?: string;
|
|
783
|
+
/** The Machine the volume is attached to; empty or absent when it is free. */
|
|
784
|
+
attached_machine_id?: string | null;
|
|
741
785
|
}
|
|
742
786
|
|
|
743
787
|
/** A live IP assignment as flaps lists it. */
|
|
@@ -783,6 +827,165 @@ export async function listVolumes(ctx: ApplyCtx, app: string, http: FlyHttp, sig
|
|
|
783
827
|
return Array.isArray(list) ? (list as FlapsVolume[]) : [];
|
|
784
828
|
}
|
|
785
829
|
|
|
830
|
+
// ── Mounts: a volume's name to its id (#4717) ────────────────────────────────
|
|
831
|
+
//
|
|
832
|
+
// The Machines API takes a volume's id in `config.mounts[].volume`; flyctl
|
|
833
|
+
// looks a name up and sends the id, with the name in the mount's `name`. A
|
|
834
|
+
// chant mount may name the volume (FLY011 checks the name is declared), so the
|
|
835
|
+
// applier does the same lookup right before it creates or updates a Machine.
|
|
836
|
+
// The plan keeps the name: the serialized build, its digest and a release's
|
|
837
|
+
// recorded config don't depend on which volume a name happens to resolve to.
|
|
838
|
+
|
|
839
|
+
/** Volume states that mean the volume is going away; never a mount's target. */
|
|
840
|
+
const GONE_VOLUME_STATES = new Set(["destroyed", "destroying", "pending_destroy", "scheduling_destroy"]);
|
|
841
|
+
|
|
842
|
+
/** The Machine a mount is resolved for: its id once it exists, and its region. */
|
|
843
|
+
export interface MountTarget {
|
|
844
|
+
machineId?: string;
|
|
845
|
+
region?: string;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Plan entity names that name a volume by its declaration, mapped to the
|
|
850
|
+
* volume's Fly name. A mount written as a `Volume` reference serializes to the
|
|
851
|
+
* declaration's name, which can differ from the volume's `name`. An entity
|
|
852
|
+
* name that is also some declared volume's `name` is left out, so a mount
|
|
853
|
+
* naming it means that volume. Pure.
|
|
854
|
+
*/
|
|
855
|
+
export function volumeAliases(plan: FlyPlan): Map<string, string> {
|
|
856
|
+
const named = new Map<string, string>();
|
|
857
|
+
for (const [entity, req] of Object.entries(plan)) {
|
|
858
|
+
if (!isVolumeRequest(req)) continue;
|
|
859
|
+
named.set(entity, typeof req.body.name === "string" && req.body.name ? req.body.name : entity);
|
|
860
|
+
}
|
|
861
|
+
const names = new Set(named.values());
|
|
862
|
+
const aliases = new Map<string, string>();
|
|
863
|
+
for (const [entity, name] of named) if (entity !== name && !names.has(entity)) aliases.set(entity, name);
|
|
864
|
+
return aliases;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** True when a config has a mount that names its volume rather than giving its id. Pure. */
|
|
868
|
+
export function mountsNeedResolving(config: unknown): boolean {
|
|
869
|
+
const mounts = (config as { mounts?: unknown } | undefined)?.mounts;
|
|
870
|
+
if (!Array.isArray(mounts)) return false;
|
|
871
|
+
return mounts.some((m) => {
|
|
872
|
+
const volume = (m as { volume?: unknown } | null)?.volume;
|
|
873
|
+
return typeof volume === "string" && volume !== "" && !isVolumeId(volume);
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function describeVolume(v: FlapsVolume): string {
|
|
878
|
+
const where = v.region ? ` in ${v.region}` : "";
|
|
879
|
+
const attached = v.attached_machine_id ? `attached to ${v.attached_machine_id}` : "unattached";
|
|
880
|
+
return `${v.id}${where}, ${attached}`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* The volume a mount naming `name` means, for the Machine `target`. Fly lets
|
|
885
|
+
* several volumes share a name (one per Machine, often across regions), so
|
|
886
|
+
* the pick is: the volume of that name already attached to this Machine, else
|
|
887
|
+
* the one in the Machine's region that is attached to no Machine. A volume
|
|
888
|
+
* whose region or attachment flaps doesn't report counts as matching. No
|
|
889
|
+
* volume of that name, none that fits, or more than one that fits, is refused
|
|
890
|
+
* naming the candidates. Pure.
|
|
891
|
+
*/
|
|
892
|
+
export function pickVolume(app: string, name: string, volumes: FlapsVolume[], target: MountTarget): FlapsVolume {
|
|
893
|
+
const named = volumes.filter((v) => v.name === name && !GONE_VOLUME_STATES.has(v.state ?? ""));
|
|
894
|
+
if (named.length === 0) {
|
|
895
|
+
const others = [...new Set(volumes.filter((v) => !GONE_VOLUME_STATES.has(v.state ?? "")).map((v) => v.name))];
|
|
896
|
+
throw new Error(
|
|
897
|
+
`no volume named "${name}" in app ${app} for a Machine to mount ` +
|
|
898
|
+
`(the app's volumes: ${others.length ? others.join(", ") : "none"}). ` +
|
|
899
|
+
`Declare a Volume named "${name}", or give the mount a volume id (vol_...).`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
const inRegion = named.filter((v) => !target.region || !v.region || v.region === target.region);
|
|
903
|
+
const mine = target.machineId ? inRegion.filter((v) => v.attached_machine_id === target.machineId) : [];
|
|
904
|
+
const fits = mine.length > 0 ? mine : inRegion.filter((v) => !v.attached_machine_id);
|
|
905
|
+
if (fits.length === 1) return fits[0];
|
|
906
|
+
const machine = target.machineId ? `Machine ${target.machineId}` : "a new Machine";
|
|
907
|
+
const region = target.region ? ` in ${target.region}` : "";
|
|
908
|
+
const why =
|
|
909
|
+
fits.length === 0
|
|
910
|
+
? `none is attached to ${machine} or free${region}`
|
|
911
|
+
: `${fits.length} are ${mine.length > 0 ? `attached to ${machine}` : `free${region}`}`;
|
|
912
|
+
throw new Error(
|
|
913
|
+
`volume name "${name}" is ambiguous in app ${app} for ${machine}${region}: ${why} ` +
|
|
914
|
+
`(${named.map(describeVolume).join("; ")}). Give the mount the volume's id (vol_...).`,
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* A Machine config with each mount that names a volume given that volume's id
|
|
920
|
+
* in `volume` and its name in `name` (a `name` the mount already has is
|
|
921
|
+
* kept). A mount with an id goes through as it is. `aliases` maps a plan's
|
|
922
|
+
* volume declaration names to volume names ({@link volumeAliases}). Returns
|
|
923
|
+
* `config` itself when no mount names a volume. Pure.
|
|
924
|
+
*/
|
|
925
|
+
export function resolveMountsWith<T>(
|
|
926
|
+
app: string,
|
|
927
|
+
config: T,
|
|
928
|
+
volumes: FlapsVolume[],
|
|
929
|
+
target: MountTarget,
|
|
930
|
+
aliases: ReadonlyMap<string, string> = new Map(),
|
|
931
|
+
): T {
|
|
932
|
+
if (!mountsNeedResolving(config)) return config;
|
|
933
|
+
const c = config as Record<string, unknown> & { mounts: unknown[] };
|
|
934
|
+
const mounts = c.mounts.map((m) => {
|
|
935
|
+
const mount = m as Record<string, unknown>;
|
|
936
|
+
const volume = mount.volume;
|
|
937
|
+
if (typeof volume !== "string" || volume === "" || isVolumeId(volume)) return m;
|
|
938
|
+
const picked = pickVolume(app, aliases.get(volume) ?? volume, volumes, target);
|
|
939
|
+
const name = typeof mount.name === "string" && mount.name ? mount.name : picked.name;
|
|
940
|
+
return { ...mount, volume: picked.id, name };
|
|
941
|
+
});
|
|
942
|
+
return { ...c, mounts } as T;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* {@link resolveMountsWith} over the app's live volumes, listed only when a
|
|
947
|
+
* mount names a volume. Volumes are listed per call, so a volume an earlier
|
|
948
|
+
* Machine in the same apply took is seen as attached.
|
|
949
|
+
*/
|
|
950
|
+
export async function resolveMounts<T>(
|
|
951
|
+
ctx: ApplyCtx,
|
|
952
|
+
app: string,
|
|
953
|
+
config: T,
|
|
954
|
+
target: MountTarget,
|
|
955
|
+
http: FlyHttp,
|
|
956
|
+
signal?: AbortSignal,
|
|
957
|
+
aliases?: ReadonlyMap<string, string>,
|
|
958
|
+
): Promise<T> {
|
|
959
|
+
if (!mountsNeedResolving(config)) return config;
|
|
960
|
+
return resolveMountsWith(app, config, await listVolumes(ctx, app, http, signal), target, aliases);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* A declared config with each mount that names a volume given the id the live
|
|
965
|
+
* config mounts at the same path, when the live mount carries that name. A
|
|
966
|
+
* read-only plan uses it to compare a declared config with a live one the way
|
|
967
|
+
* {@link applyMachine} will after it resolves the names, without listing
|
|
968
|
+
* volumes. Pure.
|
|
969
|
+
*/
|
|
970
|
+
export function alignMountsWithLive<T>(declared: T, live: unknown, aliases: ReadonlyMap<string, string> = new Map()): T {
|
|
971
|
+
if (!mountsNeedResolving(declared)) return declared;
|
|
972
|
+
const liveMounts = (live as { mounts?: unknown } | undefined)?.mounts;
|
|
973
|
+
if (!Array.isArray(liveMounts)) return declared;
|
|
974
|
+
const d = declared as Record<string, unknown> & { mounts: unknown[] };
|
|
975
|
+
const mounts = d.mounts.map((m) => {
|
|
976
|
+
const mount = m as Record<string, unknown>;
|
|
977
|
+
const volume = mount.volume;
|
|
978
|
+
if (typeof volume !== "string" || volume === "" || isVolumeId(volume)) return m;
|
|
979
|
+
const name = typeof mount.name === "string" && mount.name ? mount.name : aliases.get(volume) ?? volume;
|
|
980
|
+
const match = liveMounts.find((l) => {
|
|
981
|
+
const lm = l as Record<string, unknown> | null;
|
|
982
|
+
return lm?.path === mount.path && lm?.name === name && typeof lm?.volume === "string";
|
|
983
|
+
}) as Record<string, unknown> | undefined;
|
|
984
|
+
return match ? { ...mount, volume: match.volume, name } : m;
|
|
985
|
+
});
|
|
986
|
+
return mounts.every((m, i) => m === d.mounts[i]) ? declared : ({ ...d, mounts } as T);
|
|
987
|
+
}
|
|
988
|
+
|
|
786
989
|
/** List an app's live IP assignments (`{ ips: [...] }`). */
|
|
787
990
|
export async function listIps(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise<FlapsIp[]> {
|
|
788
991
|
const res = await http("GET", ipsUrl(ctx.base, app), undefined, undefined, signal);
|
|
@@ -1061,6 +1264,13 @@ export interface FlyApplyArgs {
|
|
|
1061
1264
|
force?: boolean;
|
|
1062
1265
|
/** Wait-loop tuning (mainly for tests). */
|
|
1063
1266
|
wait?: WaitOpts;
|
|
1267
|
+
/**
|
|
1268
|
+
* Called after each Machine is applied and settled, before the next one
|
|
1269
|
+
* (#4719). `fly-release` waits here for the Machine's Fly checks, so a
|
|
1270
|
+
* group of serving Machines rolls one at a time. A throw stops the apply
|
|
1271
|
+
* there and fails it. For callers in code only; without it nothing changes.
|
|
1272
|
+
*/
|
|
1273
|
+
afterMachine?: (machine: AppliedMachine) => Promise<void>;
|
|
1064
1274
|
}
|
|
1065
1275
|
|
|
1066
1276
|
/**
|
|
@@ -1218,8 +1428,8 @@ export async function flyApply(
|
|
|
1218
1428
|
m.set(app, set);
|
|
1219
1429
|
};
|
|
1220
1430
|
|
|
1221
|
-
// Volumes before machines: a machine's `config.mounts[]`
|
|
1222
|
-
//
|
|
1431
|
+
// Volumes before machines: a machine's `config.mounts[]` names a volume,
|
|
1432
|
+
// which must exist so applyMachine can send its id (#4717).
|
|
1223
1433
|
const volumes: Array<{ app: string; name: string; action: "created" | "noop" }> = [];
|
|
1224
1434
|
for (const [entityName, req] of volumeReqs) {
|
|
1225
1435
|
const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
|
|
@@ -1230,13 +1440,23 @@ export async function flyApply(
|
|
|
1230
1440
|
}
|
|
1231
1441
|
|
|
1232
1442
|
const machines: Array<{ app: string; name: string; action: "created" | "updated" | "noop" }> = [];
|
|
1443
|
+
const aliases = volumeAliases(plan);
|
|
1233
1444
|
for (const [entityName, req] of machineReqs) {
|
|
1234
1445
|
const app = resolveApp(machineAppSegment(req.endpoint), soleApp);
|
|
1235
1446
|
const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
|
|
1236
1447
|
track(keepMachines, app, name);
|
|
1237
|
-
const result = await applyMachine(ctx, app, entityName, req, http, signal, opts);
|
|
1448
|
+
const result = await applyMachine(ctx, app, entityName, req, http, signal, { ...opts, volumeAliases: aliases });
|
|
1238
1449
|
logProgress(`${result.action}: machine/${app}/${result.name} (${ctx.base})`);
|
|
1239
1450
|
machines.push({ app, name: result.name, action: result.action });
|
|
1451
|
+
await args.afterMachine?.({
|
|
1452
|
+
app,
|
|
1453
|
+
entity: entityName,
|
|
1454
|
+
name: result.name,
|
|
1455
|
+
id: result.id,
|
|
1456
|
+
action: result.action,
|
|
1457
|
+
unlaunched: skipsLaunch(req.body),
|
|
1458
|
+
...(result.updatedAt ? { updatedAt: result.updatedAt } : {}),
|
|
1459
|
+
});
|
|
1240
1460
|
}
|
|
1241
1461
|
|
|
1242
1462
|
// IPs, certificates, secrets after machines (independent of them).
|