@intentius/chant-lexicon-fly 0.16.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/README.md +91 -0
- package/dist/codegen/docs-cli.d.ts +3 -0
- package/dist/codegen/docs-cli.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts +27 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/generate-cli.d.ts +6 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate-lexicon.d.ts +18 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -0
- package/dist/codegen/generate-typescript.d.ts +11 -0
- package/dist/codegen/generate-typescript.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts +15 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/naming.d.ts +14 -0
- package/dist/codegen/naming.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +17 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/composites/fly-deploy.d.ts +110 -0
- package/dist/composites/fly-deploy.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +74 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/detect.d.ts +22 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/export-resources.d.ts +25 -0
- package/dist/export-resources.d.ts.map +1 -0
- package/dist/generated/index.d.ts +42 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/generated/runtime.d.ts +5 -0
- package/dist/generated/runtime.d.ts.map +1 -0
- package/dist/import/generator.d.ts +21 -0
- package/dist/import/generator.d.ts.map +1 -0
- package/dist/import/live-export.d.ts +42 -0
- package/dist/import/live-export.d.ts.map +1 -0
- package/dist/import/parser.d.ts +40 -0
- package/dist/import/parser.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/integrity.json +9 -0
- package/dist/lint/post-synth/fly-helpers.d.ts +15 -0
- package/dist/lint/post-synth/fly-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/fly010-machine-requires-image.d.ts +10 -0
- package/dist/lint/post-synth/fly010-machine-requires-image.d.ts.map +1 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +12 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/rules/guest-sizing.d.ts +9 -0
- package/dist/lint/rules/guest-sizing.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +7 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/no-secret-literals.d.ts +13 -0
- package/dist/lint/rules/no-secret-literals.d.ts.map +1 -0
- package/dist/lint/rules/valid-region.d.ts +9 -0
- package/dist/lint/rules/valid-region.d.ts.map +1 -0
- package/dist/lsp/completions.d.ts +9 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/hover.d.ts +9 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/manifest.json +4 -0
- package/dist/meta.json +285 -0
- package/dist/op/activities/flaps.d.ts +29 -0
- package/dist/op/activities/flaps.d.ts.map +1 -0
- package/dist/op/activities/fly-apply.d.ts +382 -0
- package/dist/op/activities/fly-apply.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +12 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/ownership.d.ts +27 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/pseudo.d.ts +23 -0
- package/dist/pseudo.d.ts.map +1 -0
- package/dist/serializer.d.ts +36 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/spec/fetch.d.ts +12 -0
- package/dist/spec/fetch.d.ts.map +1 -0
- package/dist/spec/parse.d.ts +58 -0
- package/dist/spec/parse.d.ts.map +1 -0
- package/dist/types/index.d.ts +590 -0
- package/dist/validate-cli.d.ts +3 -0
- package/dist/validate-cli.d.ts.map +1 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +76 -0
- package/src/codegen/docs-cli.ts +7 -0
- package/src/codegen/docs.ts +208 -0
- package/src/codegen/generate-cli.ts +36 -0
- package/src/codegen/generate-lexicon.ts +52 -0
- package/src/codegen/generate-typescript.ts +69 -0
- package/src/codegen/generate.ts +106 -0
- package/src/codegen/naming.ts +45 -0
- package/src/codegen/package.ts +25 -0
- package/src/composites/fly-deploy.ts +169 -0
- package/src/describe-resources.test.ts +188 -0
- package/src/describe-resources.ts +0 -0
- package/src/detect.ts +64 -0
- package/src/export-resources.ts +123 -0
- package/src/generated/.gitkeep +0 -0
- package/src/generated/index.d.ts +590 -0
- package/src/generated/index.ts +47 -0
- package/src/generated/lexicon-fly.json +285 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +123 -0
- package/src/import/generator.ts +242 -0
- package/src/import/live-export.test.ts +150 -0
- package/src/import/live-export.ts +107 -0
- package/src/import/parser.test.ts +132 -0
- package/src/import/parser.ts +185 -0
- package/src/import/roundtrip.test.ts +77 -0
- package/src/index.ts +23 -0
- package/src/lint/post-synth/fly-helpers.ts +25 -0
- package/src/lint/post-synth/fly010-machine-requires-image.test.ts +59 -0
- package/src/lint/post-synth/fly010-machine-requires-image.ts +39 -0
- package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +92 -0
- package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +61 -0
- package/src/lint/post-synth/index.ts +9 -0
- package/src/lint/rules/guest-sizing.ts +100 -0
- package/src/lint/rules/index.ts +15 -0
- package/src/lint/rules/no-secret-literals.ts +67 -0
- package/src/lint/rules/rules.test.ts +127 -0
- package/src/lint/rules/valid-region.ts +58 -0
- package/src/lsp/completions.test.ts +10 -0
- package/src/lsp/completions.ts +14 -0
- package/src/lsp/hover.test.ts +10 -0
- package/src/lsp/hover.ts +14 -0
- package/src/op/activities/describe-resources.integration.test.ts +105 -0
- package/src/op/activities/flaps.test.ts +29 -0
- package/src/op/activities/flaps.ts +44 -0
- package/src/op/activities/fly-apply.integration.test.ts +245 -0
- package/src/op/activities/fly-apply.test.ts +513 -0
- package/src/op/activities/fly-apply.ts +1014 -0
- package/src/op/activities/index.ts +48 -0
- package/src/ownership.ts +32 -0
- package/src/package-cli.ts +24 -0
- package/src/plugin.test.ts +17 -0
- package/src/plugin.ts +285 -0
- package/src/pseudo.test.ts +32 -0
- package/src/pseudo.ts +29 -0
- package/src/serializer.test.ts +232 -0
- package/src/serializer.ts +293 -0
- package/src/skills/chant-fly-ops.md +55 -0
- package/src/skills/chant-fly-patterns.md +71 -0
- package/src/skills/chant-fly.md +123 -0
- package/src/spec/fetch.ts +34 -0
- package/src/spec/parse.ts +323 -0
- package/src/validate-cli.ts +5 -0
- package/src/validate.ts +28 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import type { Declarable } from "@intentius/chant";
|
|
3
|
+
import { flySerializer } from "./serializer";
|
|
4
|
+
import { App, Machine, MachineConfig, MachineGuest, Volume, IPAddress, Certificate, Secret } from "./generated/index";
|
|
5
|
+
import { Fly } from "./pseudo";
|
|
6
|
+
|
|
7
|
+
/** Author a map of declarables keyed by logical name, in insertion order. */
|
|
8
|
+
function stack(...entries: Array<[string, unknown]>): Map<string, Declarable> {
|
|
9
|
+
return new Map(entries as Array<[string, Declarable]>);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("fly serializer", () => {
|
|
13
|
+
it("serializes an empty map to valid JSON", () => {
|
|
14
|
+
const result = flySerializer.serialize(new Map());
|
|
15
|
+
expect(typeof result).toBe("string");
|
|
16
|
+
expect(JSON.parse(result)).toEqual({});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("has the correct name and rule prefix", () => {
|
|
20
|
+
expect(flySerializer.name).toBe("fly");
|
|
21
|
+
expect(flySerializer.rulePrefix).toBe("FLY");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("emits the App create body with app_name/org_slug (flaps CreateAppRequest)", () => {
|
|
25
|
+
const entities = stack([
|
|
26
|
+
"web",
|
|
27
|
+
new App({ name: "my-app", org_slug: "acme" }),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const out = JSON.parse(flySerializer.serialize(entities));
|
|
31
|
+
expect(out.web).toEqual({
|
|
32
|
+
endpoint: "/v1/apps",
|
|
33
|
+
method: "POST",
|
|
34
|
+
body: { app_name: "my-app", org_slug: "acme" },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("falls back to the entity name for app_name when no explicit name", () => {
|
|
39
|
+
const out = JSON.parse(flySerializer.serialize(stack(["billing", new App({ org_slug: "acme" })])));
|
|
40
|
+
expect(out.billing.body.app_name).toBe("billing");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("emits the Machine create body with config image/guest and endpoints the owning app", () => {
|
|
44
|
+
const entities = stack(
|
|
45
|
+
["app", new App({ name: "my-app", org_slug: "acme" })],
|
|
46
|
+
[
|
|
47
|
+
"api",
|
|
48
|
+
new Machine({
|
|
49
|
+
name: "api-1",
|
|
50
|
+
region: "iad",
|
|
51
|
+
config: new MachineConfig({
|
|
52
|
+
image: "flyio/hellofly:latest",
|
|
53
|
+
guest: new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 }),
|
|
54
|
+
env: { LOG_LEVEL: "info" },
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const out = JSON.parse(flySerializer.serialize(entities));
|
|
61
|
+
expect(out.api.endpoint).toBe("/v1/apps/my-app/machines");
|
|
62
|
+
expect(out.api.method).toBe("POST");
|
|
63
|
+
expect(out.api.body.name).toBe("api-1");
|
|
64
|
+
expect(out.api.body.region).toBe("iad");
|
|
65
|
+
expect(out.api.body.config.image).toBe("flyio/hellofly:latest");
|
|
66
|
+
expect(out.api.body.config.guest).toEqual({ cpu_kind: "shared", cpus: 1, memory_mb: 256 });
|
|
67
|
+
expect(out.api.body.config.env).toEqual({ LOG_LEVEL: "info" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("stamps managed-by: chant on every machine, including one with no metadata", () => {
|
|
71
|
+
const entities = stack(
|
|
72
|
+
["app", new App({ name: "my-app" })],
|
|
73
|
+
["bare", new Machine({ config: new MachineConfig({ image: "nginx" }) })],
|
|
74
|
+
[
|
|
75
|
+
"withMeta",
|
|
76
|
+
new Machine({
|
|
77
|
+
config: new MachineConfig({ image: "nginx", metadata: { role: "cache" } }),
|
|
78
|
+
}),
|
|
79
|
+
],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const out = JSON.parse(flySerializer.serialize(entities));
|
|
83
|
+
// No user metadata → marker still present.
|
|
84
|
+
expect(out.bare.body.config.metadata["managed-by"]).toBe("chant");
|
|
85
|
+
// User metadata is preserved and the marker is merged in.
|
|
86
|
+
expect(out.withMeta.body.config.metadata["managed-by"]).toBe("chant");
|
|
87
|
+
expect(out.withMeta.body.config.metadata.role).toBe("cache");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("stamps the stack/env ownership identity when context.ownership is passed", () => {
|
|
91
|
+
const entities = stack(
|
|
92
|
+
["app", new App({ name: "my-app" })],
|
|
93
|
+
["api", new Machine({ config: new MachineConfig({ image: "nginx" }) })],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const out = JSON.parse(
|
|
97
|
+
flySerializer.serialize(entities, undefined, { ownership: { stack: "billing", env: "prod" } }),
|
|
98
|
+
);
|
|
99
|
+
const meta = out.api.body.config.metadata;
|
|
100
|
+
expect(meta["managed-by"]).toBe("chant");
|
|
101
|
+
expect(meta["chant-stack"]).toBe("billing");
|
|
102
|
+
expect(meta["chant-env"]).toBe("prod");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("matches the mudflaps CreateMachineRequest field names (name/region/config)", () => {
|
|
106
|
+
const entities = stack(
|
|
107
|
+
["app", new App({ name: "my-app" })],
|
|
108
|
+
[
|
|
109
|
+
"api",
|
|
110
|
+
new Machine({ name: "api-1", region: "iad", config: new MachineConfig({ image: "nginx" }) }),
|
|
111
|
+
],
|
|
112
|
+
);
|
|
113
|
+
const body = JSON.parse(flySerializer.serialize(entities)).api.body;
|
|
114
|
+
// Only fields present on flaps CreateMachineRequest / MachineConfig.
|
|
115
|
+
expect(Object.keys(body).sort()).toEqual(["config", "name", "region"]);
|
|
116
|
+
expect(Object.keys(body.config.guest ?? {})).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── #741 app-scoped, metadata-less resources ────────────────────────────────
|
|
120
|
+
|
|
121
|
+
it("emits the Volume create body (CreateVolumeRequest: name/region/size_gb/encrypted) under the app", () => {
|
|
122
|
+
const entities = stack(
|
|
123
|
+
["app", new App({ name: "my-app" })],
|
|
124
|
+
["data", new Volume({ name: "data", region: "iad", size_gb: 10, encrypted: true })],
|
|
125
|
+
);
|
|
126
|
+
const out = JSON.parse(flySerializer.serialize(entities));
|
|
127
|
+
expect(out.data).toEqual({
|
|
128
|
+
endpoint: "/v1/apps/my-app/volumes",
|
|
129
|
+
method: "POST",
|
|
130
|
+
body: { name: "data", region: "iad", size_gb: 10, encrypted: true },
|
|
131
|
+
});
|
|
132
|
+
// No ownership marker — metadata-less type owned at the app boundary (D2).
|
|
133
|
+
expect(out.data.body.metadata).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("emits the IPAddress assign body (assignIPRequest: type/org_slug/...) under the app", () => {
|
|
137
|
+
const entities = stack(
|
|
138
|
+
["app", new App({ name: "my-app" })],
|
|
139
|
+
["ip", new IPAddress({ type: "shared_v4", region: "iad", org_slug: "acme", service_name: "web", network: "default" })],
|
|
140
|
+
);
|
|
141
|
+
const out = JSON.parse(flySerializer.serialize(entities));
|
|
142
|
+
expect(out.ip).toEqual({
|
|
143
|
+
endpoint: "/v1/apps/my-app/ip_assignments",
|
|
144
|
+
method: "POST",
|
|
145
|
+
body: { type: "shared_v4", region: "iad", org_slug: "acme", service_name: "web", network: "default" },
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("emits the Certificate create body (createCertificateRequest: hostname) under the app", () => {
|
|
150
|
+
const entities = stack(
|
|
151
|
+
["app", new App({ name: "my-app" })],
|
|
152
|
+
["cert", new Certificate({ hostname: "example.com" })],
|
|
153
|
+
);
|
|
154
|
+
const out = JSON.parse(flySerializer.serialize(entities));
|
|
155
|
+
expect(out.cert).toEqual({
|
|
156
|
+
endpoint: "/v1/apps/my-app/certificates",
|
|
157
|
+
method: "POST",
|
|
158
|
+
body: { hostname: "example.com" },
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("emits the Secret set body (SetAppSecretRequest: value) and flags it apply-only (D7)", () => {
|
|
163
|
+
const entities = stack(
|
|
164
|
+
["app", new App({ name: "my-app" })],
|
|
165
|
+
["db-password", new Secret({ value: "s3cret" })],
|
|
166
|
+
);
|
|
167
|
+
const out = JSON.parse(flySerializer.serialize(entities));
|
|
168
|
+
expect(out["db-password"]).toEqual({
|
|
169
|
+
endpoint: "/v1/apps/my-app/secrets/db-password",
|
|
170
|
+
method: "POST",
|
|
171
|
+
body: { value: "s3cret" },
|
|
172
|
+
applyOnly: true,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── Pseudo-parameters (Fly.Region / Fly.OrgSlug) ────────────────────────────
|
|
178
|
+
describe("fly serializer pseudo-parameters", () => {
|
|
179
|
+
const saved = {
|
|
180
|
+
FLY_REGION: process.env.FLY_REGION,
|
|
181
|
+
FLY_ORG: process.env.FLY_ORG,
|
|
182
|
+
FLY_ORG_SLUG: process.env.FLY_ORG_SLUG,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
afterEach(() => {
|
|
186
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
187
|
+
if (value === undefined) delete process.env[key];
|
|
188
|
+
else process.env[key] = value;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("resolves Fly.Region from FLY_REGION in the serialized body", () => {
|
|
193
|
+
process.env.FLY_REGION = "lhr";
|
|
194
|
+
const entities = stack(
|
|
195
|
+
["app", new App({ name: "my-app" })],
|
|
196
|
+
["web", new Machine({ region: Fly.Region, config: new MachineConfig({ image: "nginx" }) })],
|
|
197
|
+
);
|
|
198
|
+
const body = JSON.parse(flySerializer.serialize(entities)).web.body;
|
|
199
|
+
expect(body.region).toBe("lhr");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("falls back to iad when FLY_REGION is unset", () => {
|
|
203
|
+
delete process.env.FLY_REGION;
|
|
204
|
+
const entities = stack(
|
|
205
|
+
["app", new App({ name: "my-app" })],
|
|
206
|
+
["web", new Machine({ region: Fly.Region, config: new MachineConfig({ image: "nginx" }) })],
|
|
207
|
+
);
|
|
208
|
+
const body = JSON.parse(flySerializer.serialize(entities)).web.body;
|
|
209
|
+
expect(body.region).toBe("iad");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("resolves Fly.OrgSlug from FLY_ORG (preferred over FLY_ORG_SLUG)", () => {
|
|
213
|
+
process.env.FLY_ORG = "acme";
|
|
214
|
+
process.env.FLY_ORG_SLUG = "ignored";
|
|
215
|
+
const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])));
|
|
216
|
+
expect(out.app.body.org_slug).toBe("acme");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("resolves Fly.OrgSlug from FLY_ORG_SLUG when FLY_ORG is unset", () => {
|
|
220
|
+
delete process.env.FLY_ORG;
|
|
221
|
+
process.env.FLY_ORG_SLUG = "beta-org";
|
|
222
|
+
const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])));
|
|
223
|
+
expect(out.app.body.org_slug).toBe("beta-org");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("falls back to personal for Fly.OrgSlug when no org env var is set", () => {
|
|
227
|
+
delete process.env.FLY_ORG;
|
|
228
|
+
delete process.env.FLY_ORG_SLUG;
|
|
229
|
+
const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])));
|
|
230
|
+
expect(out.app.body.org_slug).toBe("personal");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fly (Machines API / "flaps") serializer.
|
|
3
|
+
*
|
|
4
|
+
* Turns declared `App`, `Machine`, `Volume`, `IPAddress`, `Certificate`, and
|
|
5
|
+
* `Secret` resources into the JSON create bodies the flaps REST API accepts, so
|
|
6
|
+
* #739's applier can POST them straight through and mudflaps (#740) can
|
|
7
|
+
* round-trip them.
|
|
8
|
+
*
|
|
9
|
+
* Output shape — a JSON object keyed by entity name. Each value is a single
|
|
10
|
+
* flaps request:
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* "<entityName>": {
|
|
14
|
+
* "endpoint": "/v1/apps", // or /v1/apps/{app}/machines
|
|
15
|
+
* "method": "POST",
|
|
16
|
+
* "body": { ... } // the create body
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Field names are taken from mudflaps' Go structs (the wire oracle), which win
|
|
21
|
+
* over the OpenAPI-generated TypeScript names:
|
|
22
|
+
* - App → CreateAppRequest `{ app_name, org_slug? }` (NOT `name`).
|
|
23
|
+
* - Machine→ CreateMachineRequest `{ name?, region?, config?, skip_launch? }`;
|
|
24
|
+
* `config` is the full MachineConfig. The owning app is a URL path segment,
|
|
25
|
+
* not a body field, so it lands in `endpoint`.
|
|
26
|
+
*
|
|
27
|
+
* D2: every serialized Machine carries the `managed-by: chant` ownership marker
|
|
28
|
+
* (plus the stack/env identity from `context.ownership`) merged into
|
|
29
|
+
* `config.metadata`, even when the user supplied no metadata.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
33
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
34
|
+
import type { Serializer, SerializeContext } from "@intentius/chant/serializer";
|
|
35
|
+
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
36
|
+
import { ownershipEntries, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
37
|
+
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
38
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
39
|
+
import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
|
|
40
|
+
|
|
41
|
+
const APP_ENTITY_TYPE = "Fly::Machines::App";
|
|
42
|
+
const MACHINE_ENTITY_TYPE = "Fly::Machines::Machine";
|
|
43
|
+
const VOLUME_ENTITY_TYPE = "Fly::Machines::Volume";
|
|
44
|
+
const IP_ENTITY_TYPE = "Fly::Machines::IPAddress";
|
|
45
|
+
const CERTIFICATE_ENTITY_TYPE = "Fly::Machines::Certificate";
|
|
46
|
+
const SECRET_ENTITY_TYPE = "Fly::Machines::Secret";
|
|
47
|
+
|
|
48
|
+
/** A single flaps REST call the applier can issue verbatim. */
|
|
49
|
+
interface FlapsRequest {
|
|
50
|
+
endpoint: string;
|
|
51
|
+
method: "POST";
|
|
52
|
+
body: Record<string, unknown>;
|
|
53
|
+
/**
|
|
54
|
+
* D7: apply-only resources (Secrets) are set through POST but never read back
|
|
55
|
+
* for a diff — flaps returns only a digest, never the value. The applier
|
|
56
|
+
* honors this flag by skipping the drift/diff read and always POSTing.
|
|
57
|
+
*/
|
|
58
|
+
applyOnly?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Visitor for the generic serializer walker. Property declarables (MachineConfig,
|
|
63
|
+
* MachineGuest, MachineService, ...) are unwrapped to plain objects; resource
|
|
64
|
+
* references resolve to their logical name.
|
|
65
|
+
*/
|
|
66
|
+
function flyVisitor(): SerializerVisitor {
|
|
67
|
+
return {
|
|
68
|
+
attrRef: (name) => name,
|
|
69
|
+
resourceRef: (name) => name,
|
|
70
|
+
propertyDeclarable: (entity, walk) => {
|
|
71
|
+
const props = (entity as unknown as { props?: unknown }).props;
|
|
72
|
+
if (!props || typeof props !== "object") return undefined;
|
|
73
|
+
const result: Record<string, unknown> = {};
|
|
74
|
+
for (const [key, value] of Object.entries(props)) {
|
|
75
|
+
if (value !== undefined) result[key] = walk(value);
|
|
76
|
+
}
|
|
77
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readProps(entity: Declarable): Record<string, unknown> {
|
|
83
|
+
const props = (entity as unknown as { props?: unknown }).props;
|
|
84
|
+
return props && typeof props === "object" ? (props as Record<string, unknown>) : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Pseudo-parameter → environment variable mapping. flaps create bodies are
|
|
89
|
+
* plain JSON, so `Fly.Region` / `Fly.OrgSlug` / `Fly.AppName` (which the walker
|
|
90
|
+
* lowers to a `{ Ref: "Fly::..." }` marker) are resolved from the environment
|
|
91
|
+
* at build time, mirroring gcp's PSEUDO_ENV_MAP. The first set env var wins;
|
|
92
|
+
* absent all of them, the fallback keeps output valid offline (mudflaps).
|
|
93
|
+
*/
|
|
94
|
+
const PSEUDO_ENV_MAP: Record<string, { envVars: string[]; fallback: string }> = {
|
|
95
|
+
"Fly::Region": { envVars: ["FLY_REGION"], fallback: "iad" },
|
|
96
|
+
"Fly::OrgSlug": { envVars: ["FLY_ORG", "FLY_ORG_SLUG"], fallback: "personal" },
|
|
97
|
+
"Fly::AppName": { envVars: ["FLY_APP_NAME"], fallback: "app" },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function resolvePseudoRef(ref: string): string | undefined {
|
|
101
|
+
const mapping = PSEUDO_ENV_MAP[ref];
|
|
102
|
+
if (!mapping) return undefined;
|
|
103
|
+
for (const envVar of mapping.envVars) {
|
|
104
|
+
const value = process.env[envVar];
|
|
105
|
+
if (value) return value;
|
|
106
|
+
}
|
|
107
|
+
return mapping.fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Recursively replace pseudo-parameters with their resolved environment value.
|
|
112
|
+
* Handles both a raw `PseudoParameter` instance (any intrinsic carrying the
|
|
113
|
+
* marker, unwrapped via `toJSON()`) and the already-walked `{ Ref: "Fly::X" }`
|
|
114
|
+
* shape. Non-Fly `{ Ref }` shapes and every other value pass through untouched.
|
|
115
|
+
*/
|
|
116
|
+
function resolvePseudoParameters(value: unknown): unknown {
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
return value.map(resolvePseudoParameters);
|
|
119
|
+
}
|
|
120
|
+
if (value && typeof value === "object") {
|
|
121
|
+
const record = value as Record<string, unknown>;
|
|
122
|
+
|
|
123
|
+
// Unwrap an intrinsic (e.g. a PseudoParameter assigned without walking) to
|
|
124
|
+
// the { Ref } envelope it serializes to, then resolve that.
|
|
125
|
+
if (INTRINSIC_MARKER in record && typeof record.toJSON === "function") {
|
|
126
|
+
return resolvePseudoParameters(record.toJSON());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const keys = Object.keys(record);
|
|
130
|
+
if (keys.length === 1 && keys[0] === "Ref" && typeof record.Ref === "string") {
|
|
131
|
+
const resolved = resolvePseudoRef(record.Ref);
|
|
132
|
+
if (resolved !== undefined) return resolved;
|
|
133
|
+
}
|
|
134
|
+
const result: Record<string, unknown> = {};
|
|
135
|
+
for (const [key, val] of Object.entries(record)) {
|
|
136
|
+
result[key] = resolvePseudoParameters(val);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The app_name flaps expects: an explicit `name` prop, else the entity name. */
|
|
144
|
+
function appName(entity: Declarable, entityName: string): string {
|
|
145
|
+
const name = readProps(entity).name;
|
|
146
|
+
return typeof name === "string" ? name : entityName;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* fly flaps serializer.
|
|
151
|
+
*/
|
|
152
|
+
export const flySerializer: Serializer = {
|
|
153
|
+
name: "fly",
|
|
154
|
+
rulePrefix: "FLY",
|
|
155
|
+
|
|
156
|
+
serialize(
|
|
157
|
+
entities: Map<string, Declarable>,
|
|
158
|
+
_outputs?: LexiconOutput[],
|
|
159
|
+
context?: SerializeContext,
|
|
160
|
+
): string {
|
|
161
|
+
const entityNames = new Map<Declarable, string>();
|
|
162
|
+
for (const [name, entity] of entities) entityNames.set(entity, name);
|
|
163
|
+
const visitor = flyVisitor();
|
|
164
|
+
|
|
165
|
+
// D2 ownership marker. `managed-by: chant` is always stamped; stack/env are
|
|
166
|
+
// added when the build threads an ownership marker through the context.
|
|
167
|
+
const ownershipMeta: Record<string, string> = context?.ownership
|
|
168
|
+
? ownershipEntries(FLY_METADATA_OWNERSHIP_KEYS, context.ownership)
|
|
169
|
+
: { [FLY_METADATA_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE };
|
|
170
|
+
|
|
171
|
+
// Resolve which app a machine belongs to (a URL path segment, not a body
|
|
172
|
+
// field). A machine may name its app explicitly via an `app` prop (a string
|
|
173
|
+
// or an `App` reference); otherwise, when the stack declares exactly one
|
|
174
|
+
// app, machines default to it. Failing both, a `{app}` placeholder is left
|
|
175
|
+
// for the applier to fill.
|
|
176
|
+
const apps: Array<[string, Declarable]> = [];
|
|
177
|
+
for (const [name, entity] of entities) {
|
|
178
|
+
if ((entity as unknown as { entityType?: string }).entityType === APP_ENTITY_TYPE) {
|
|
179
|
+
apps.push([name, entity]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const soleApp = apps.length === 1 ? appName(apps[0][1], apps[0][0]) : undefined;
|
|
183
|
+
|
|
184
|
+
// The owning app of any app-scoped resource (machine, volume, ip, cert,
|
|
185
|
+
// secret): an explicit `app` prop (string or `App` reference), else the
|
|
186
|
+
// stack's sole app, else a `{app}` placeholder for the applier to fill.
|
|
187
|
+
const resolveOwningApp = (entity: Declarable): string => {
|
|
188
|
+
const app = readProps(entity).app;
|
|
189
|
+
if (typeof app === "string") return app;
|
|
190
|
+
if (app && typeof app === "object" && "entityType" in app) {
|
|
191
|
+
const decl = app as Declarable;
|
|
192
|
+
return appName(decl, entityNames.get(decl) ?? "{app}");
|
|
193
|
+
}
|
|
194
|
+
return soleApp ?? "{app}";
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const requests: Record<string, FlapsRequest> = {};
|
|
198
|
+
|
|
199
|
+
for (const [name, entity] of entities) {
|
|
200
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
201
|
+
const entityType = (entity as unknown as { entityType?: string }).entityType;
|
|
202
|
+
const props = readProps(entity);
|
|
203
|
+
|
|
204
|
+
if (entityType === APP_ENTITY_TYPE) {
|
|
205
|
+
const body: Record<string, unknown> = { app_name: appName(entity, name) };
|
|
206
|
+
if (props.org_slug !== undefined) body.org_slug = props.org_slug;
|
|
207
|
+
if (props.network !== undefined) body.network = props.network;
|
|
208
|
+
if (props.enable_subdomains !== undefined) body.enable_subdomains = props.enable_subdomains;
|
|
209
|
+
requests[name] = { endpoint: "/v1/apps", method: "POST", body };
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (entityType === MACHINE_ENTITY_TYPE) {
|
|
214
|
+
const walkedConfig = props.config
|
|
215
|
+
? walkValue(props.config, entityNames, visitor)
|
|
216
|
+
: undefined;
|
|
217
|
+
const config: Record<string, unknown> =
|
|
218
|
+
walkedConfig && typeof walkedConfig === "object"
|
|
219
|
+
? { ...(walkedConfig as Record<string, unknown>) }
|
|
220
|
+
: {};
|
|
221
|
+
|
|
222
|
+
// Merge the ownership marker into config.metadata. The marker wins over
|
|
223
|
+
// any colliding user key so ownership can never be silently unset.
|
|
224
|
+
const userMeta =
|
|
225
|
+
config.metadata && typeof config.metadata === "object"
|
|
226
|
+
? (config.metadata as Record<string, unknown>)
|
|
227
|
+
: {};
|
|
228
|
+
config.metadata = { ...userMeta, ...ownershipMeta };
|
|
229
|
+
|
|
230
|
+
// Spread the create-body fields (name, region, skip_launch, ...) and
|
|
231
|
+
// override with the walked config. `app` is our association hint, not a
|
|
232
|
+
// flaps field, so it is dropped.
|
|
233
|
+
const body: Record<string, unknown> = {};
|
|
234
|
+
for (const [key, value] of Object.entries(props)) {
|
|
235
|
+
if (key === "config" || key === "app" || value === undefined) continue;
|
|
236
|
+
body[key] = walkValue(value, entityNames, visitor);
|
|
237
|
+
}
|
|
238
|
+
body.config = config;
|
|
239
|
+
|
|
240
|
+
requests[name] = {
|
|
241
|
+
endpoint: `/v1/apps/${resolveOwningApp(entity)}/machines`,
|
|
242
|
+
method: "POST",
|
|
243
|
+
body,
|
|
244
|
+
};
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── App-scoped, metadata-less resources (#741, D2). ──────────────────
|
|
249
|
+
// Each spreads its scalar create-body props straight through; the `app`
|
|
250
|
+
// association hint is a URL segment, not a body field, so it is dropped.
|
|
251
|
+
// Ownership is at the app boundary, so none carry a metadata marker.
|
|
252
|
+
if (
|
|
253
|
+
entityType === VOLUME_ENTITY_TYPE ||
|
|
254
|
+
entityType === IP_ENTITY_TYPE ||
|
|
255
|
+
entityType === CERTIFICATE_ENTITY_TYPE ||
|
|
256
|
+
entityType === SECRET_ENTITY_TYPE
|
|
257
|
+
) {
|
|
258
|
+
const app = resolveOwningApp(entity);
|
|
259
|
+
|
|
260
|
+
if (entityType === SECRET_ENTITY_TYPE) {
|
|
261
|
+
// Apply-only (D7): name is the URL segment; `value` is the only body
|
|
262
|
+
// field. mudflaps returns just a digest, so this never enters a diff.
|
|
263
|
+
const secretName = typeof props.name === "string" ? props.name : name;
|
|
264
|
+
requests[name] = {
|
|
265
|
+
endpoint: `/v1/apps/${app}/secrets/${encodeURIComponent(secretName)}`,
|
|
266
|
+
method: "POST",
|
|
267
|
+
body: props.value !== undefined ? { value: props.value } : {},
|
|
268
|
+
applyOnly: true,
|
|
269
|
+
};
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const segment =
|
|
274
|
+
entityType === VOLUME_ENTITY_TYPE
|
|
275
|
+
? "volumes"
|
|
276
|
+
: entityType === IP_ENTITY_TYPE
|
|
277
|
+
? "ip_assignments"
|
|
278
|
+
: "certificates";
|
|
279
|
+
const body: Record<string, unknown> = {};
|
|
280
|
+
for (const [key, value] of Object.entries(props)) {
|
|
281
|
+
if (key === "app" || value === undefined) continue;
|
|
282
|
+
body[key] = walkValue(value, entityNames, visitor);
|
|
283
|
+
}
|
|
284
|
+
requests[name] = { endpoint: `/v1/apps/${app}/${segment}`, method: "POST", body };
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Resolve pseudo-parameter markers (Fly.Region, Fly.OrgSlug, ...) to their
|
|
290
|
+
// environment value so create bodies carry plain strings, not `{ Ref }`.
|
|
291
|
+
return JSON.stringify(resolvePseudoParameters(requests), null, 2);
|
|
292
|
+
},
|
|
293
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: chant-fly-ops
|
|
3
|
+
description: Operate a live Fly deploy — wait on stuck machines, resolve lease conflicts, prune safely, and target a real org versus the emulator
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Fly Operations Playbook
|
|
8
|
+
|
|
9
|
+
This skill covers running `flyApply` against a live app: what the wait loop does, how leases resolve conflicts, when prune is safe, and how to point the same code at a real org or the mudflaps emulator. For authoring and the first deploy, see `chant-fly`; for the individual resource types, see `chant-fly-patterns`.
|
|
10
|
+
|
|
11
|
+
## Targeting real Fly or the emulator
|
|
12
|
+
|
|
13
|
+
The endpoint resolves in this order: an explicit `endpoint` arg, then `FLY_FLAPS_BASE_URL`, then the real-Fly default (`https://api.machines.dev`). The bearer token defaults to `FLY_API_TOKEN`.
|
|
14
|
+
|
|
15
|
+
| Target | How |
|
|
16
|
+
|--------|-----|
|
|
17
|
+
| Local mudflaps (offline, no account) | Leave `FLY_API_TOKEN` unset and point `FLY_FLAPS_BASE_URL` at the mudflaps host (the deploy Op does this for you against a local container) |
|
|
18
|
+
| Real Fly org | Set `FLY_API_TOKEN`, drop the local `FLY_FLAPS_BASE_URL` override |
|
|
19
|
+
|
|
20
|
+
The same plan applies to both. The only difference is the endpoint, so the loop you test offline is the loop you ship.
|
|
21
|
+
|
|
22
|
+
## Waiting for a machine to start
|
|
23
|
+
|
|
24
|
+
After a create or update, `flyApply` polls `GET .../wait` until the machine reaches `started` at its new `instance_id` (its config version). flaps caps its own long-poll at 60 seconds and answers 408 when that expires, so the client re-polls until an overall deadline (300 seconds by default). A destroy waits for `state=destroyed` the same way; a reaped machine satisfies that wait.
|
|
25
|
+
|
|
26
|
+
If a machine never reaches `started`:
|
|
27
|
+
|
|
28
|
+
| Symptom | Likely cause | What to do |
|
|
29
|
+
|---------|--------------|------------|
|
|
30
|
+
| Wait keeps re-polling, machine stays in `created` or `starting` | Image pull or boot is slow, or the guest sizing is under-provisioned | Check the image reference and the `MachineGuest` values; watch the machine on the target org |
|
|
31
|
+
| Wait fails with a non-408 status | flaps rejected the machine (bad config the build check did not catch, or an org-side limit) | Read the error body; fix the config and re-apply |
|
|
32
|
+
| Wait times out at the deadline | The machine cannot reach `started` in time | Inspect the machine directly on the org, then re-apply once the cause is fixed |
|
|
33
|
+
|
|
34
|
+
## Lease conflicts
|
|
35
|
+
|
|
36
|
+
Mutating an existing machine (update or destroy) is gated behind a Machines API lease. `flyApply` acquires a lease, echoes the nonce in the `fly-machine-lease-nonce` header on the mutation, and releases the lease afterward. A leaked lease expires on its own TTL, so release is best-effort.
|
|
37
|
+
|
|
38
|
+
Conflict handling is automatic: a 409 whose body mentions a lease is a stale or lost nonce, so the applier re-acquires a fresh lease and retries the mutation once. A 409 that is not lease-shaped (for example "app already exists") is not retried. If a mutation keeps failing on a lease conflict, another operator is holding the machine; wait for their lease to clear or coordinate before re-applying.
|
|
39
|
+
|
|
40
|
+
## Prune, and when it is safe
|
|
41
|
+
|
|
42
|
+
Prune is off by default and destructive. It removes resources the plan no longer declares.
|
|
43
|
+
|
|
44
|
+
- Machines are owned-only: a machine is pruned only if it carries the `managed-by: chant` marker. A foreign machine in the same app is never touched, so it is safe to run `flyApply` with prune against an app that also holds machines you manage elsewhere.
|
|
45
|
+
- Volumes, IPs, certificates, and secrets are app-scoped, because they have no marker channel. Under a chant-managed app, anything the plan no longer declares is removed, including a resource of those types created out of band. Before enabling prune on such an app, confirm every volume, IP, certificate, and secret in it is chant-declared, and keep prune to a single chant-declared app.
|
|
46
|
+
|
|
47
|
+
Each prune logs the resource and endpoint it removed, so a prune run is auditable from the Op output.
|
|
48
|
+
|
|
49
|
+
## Teardown
|
|
50
|
+
|
|
51
|
+
`flyDelete` is the inverse of `flyApply`: destroy the machines the plan declares (dependents first), then delete the apps. It is idempotent, so an already-absent machine or app is a no-op. The deploy Op's teardown phase uses this to tear the emulator's app down at the end of an offline loop.
|
|
52
|
+
|
|
53
|
+
## Re-applying is safe
|
|
54
|
+
|
|
55
|
+
A re-apply of an unchanged stack is a no-op per resource: machines whose config is structurally equal to live are skipped, volumes and certificates that already exist are skipped, and an IP of an already-present family is skipped. Only apply-only secrets are always re-set, because flaps exposes no value to diff against.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: chant-fly-patterns
|
|
3
|
+
description: Volumes and mounts, IP assignments, certificates, apply-only secrets, and the app-boundary ownership model for Fly
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Fly Resource Patterns
|
|
8
|
+
|
|
9
|
+
Beyond the App and Machine covered in `chant-fly`, the lexicon models `Volume`, `IPAddress`, `Certificate`, and `Secret`. This skill covers how they apply and prune, and the ownership boundary that makes prune safe.
|
|
10
|
+
|
|
11
|
+
## Volumes and mounts
|
|
12
|
+
|
|
13
|
+
`flyApply` applies volumes before machines, because a machine's `config.mounts[]` references a volume by name, so the volume must exist first. A volume is created if absent (idempotent by name); a re-apply of an existing volume is a no-op.
|
|
14
|
+
|
|
15
|
+
The FLY011 build check enforces the link statically: every machine mount must reference a `Volume` declared in the stack, checked across files. A mount that points at an undeclared volume fails `chant build` before anything reaches the API.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { App, Machine, MachineConfig, MachineGuest, Volume } from "@intentius/chant-lexicon-fly";
|
|
19
|
+
|
|
20
|
+
export const app = new App({ name: "my-app" });
|
|
21
|
+
|
|
22
|
+
export const data = new Volume({ name: "data", region: "iad", size_gb: 10 });
|
|
23
|
+
|
|
24
|
+
export const web = new Machine({
|
|
25
|
+
name: "web",
|
|
26
|
+
region: "iad",
|
|
27
|
+
config: new MachineConfig({
|
|
28
|
+
image: "flyio/hellofly:latest",
|
|
29
|
+
guest: new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 }),
|
|
30
|
+
mounts: [{ volume: "data", path: "/data" }],
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## IP assignments
|
|
36
|
+
|
|
37
|
+
An IP is assigned if the declared type is not already present, keyed by family (shared v4, dedicated v4, or v6). Because the address is server-allocated, a re-apply of the same declared type is a no-op rather than a second assignment.
|
|
38
|
+
|
|
39
|
+
## Certificates
|
|
40
|
+
|
|
41
|
+
A certificate is created if absent, idempotent by hostname. A re-apply for a hostname that already has a certificate is a no-op.
|
|
42
|
+
|
|
43
|
+
## Apply-only secrets
|
|
44
|
+
|
|
45
|
+
Secrets are apply-only. flaps returns only a digest for a secret, never the value, so there is nothing to read back for a diff. `flyApply` always POSTs a declared secret and excludes it from any drift comparison, so every apply re-sets it. Secret values may not be written inline in machine config (the FLY004 build check rejects that); declare them as a `Secret` or a reference.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { Secret } from "@intentius/chant-lexicon-fly";
|
|
49
|
+
|
|
50
|
+
// The value comes from the environment or a reference, not a literal in source.
|
|
51
|
+
export const dbUrl = new Secret({ name: "DATABASE_URL", value: process.env.DATABASE_URL! });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## The app-boundary ownership model
|
|
55
|
+
|
|
56
|
+
Ownership is asymmetric, by design:
|
|
57
|
+
|
|
58
|
+
- Machines carry `config.metadata`, so they get the primary marker `managed-by: chant`. Prune filters on it, so a foreign machine in the same app is never touched.
|
|
59
|
+
- Volumes, IPs, certificates, and secrets carry no arbitrary metadata, so they have no marker channel. Their ownership boundary is the app itself, the way a CloudFormation stack owns its resources: everything under a chant-managed app is treated as chant's, and prune for these types is app-scoped. Anything live that the plan no longer declares under that app is removed.
|
|
60
|
+
|
|
61
|
+
The limitation: because these four types have no marker, a volume, IP, certificate, or secret created out of band inside a chant-managed app is indistinguishable from a chant one and can be pruned. That is the price of app-boundary ownership. The safeguard is that an app is only ever chant-managed when it carries the marker through its machines. Do not enable prune on an app that mixes chant-declared and hand-created volumes, IPs, certificates, or secrets, and never widen app-scoped prune beyond a single chant-declared app.
|
|
62
|
+
|
|
63
|
+
## Apply order
|
|
64
|
+
|
|
65
|
+
`flyApply` applies in dependency order and prunes last:
|
|
66
|
+
|
|
67
|
+
1. Apps.
|
|
68
|
+
2. Volumes (before machines, so mounts resolve).
|
|
69
|
+
3. Machines (create or update, then wait for `started`; updates go through a lease).
|
|
70
|
+
4. IPs, certificates, secrets (independent of machines).
|
|
71
|
+
5. Prune, if enabled: machines owned-only by marker; volumes, IPs, certificates, and secrets app-scoped.
|