@intentius/chant-lexicon-fly 0.101.0 → 0.104.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.
Files changed (76) hide show
  1. package/dist/codegen/docs.d.ts.map +1 -1
  2. package/dist/codegen/naming.d.ts.map +1 -1
  3. package/dist/codegen/package.d.ts +1 -1
  4. package/dist/components/capability-plugin.d.ts.map +1 -1
  5. package/dist/components/fly-release.d.ts +17 -1
  6. package/dist/components/fly-release.d.ts.map +1 -1
  7. package/dist/composites/fly-deploy.d.ts +2 -0
  8. package/dist/composites/fly-deploy.d.ts.map +1 -1
  9. package/dist/composites/fly-site.d.ts +2 -2
  10. package/dist/generated/index.d.ts +41 -41
  11. package/dist/generated/index.d.ts.map +1 -1
  12. package/dist/import/generator.d.ts.map +1 -1
  13. package/dist/import/parser.d.ts.map +1 -1
  14. package/dist/integrity.json +5 -5
  15. package/dist/manifest.json +1 -1
  16. package/dist/okf/types/Secret.md +1 -0
  17. package/dist/op/activities/flaps.d.ts.map +1 -1
  18. package/dist/op/activities/fly-apply.d.ts +87 -8
  19. package/dist/op/activities/fly-apply.d.ts.map +1 -1
  20. package/dist/op/activities/index.d.ts +2 -2
  21. package/dist/op/activities/index.d.ts.map +1 -1
  22. package/dist/op/activities/machines-contract.d.ts.map +1 -1
  23. package/dist/op/activities/machines-fake.d.ts +10 -1
  24. package/dist/op/activities/machines-fake.d.ts.map +1 -1
  25. package/dist/op/activities/machines-local.d.ts.map +1 -1
  26. package/dist/op/activities/progress.d.ts +9 -0
  27. package/dist/op/activities/progress.d.ts.map +1 -0
  28. package/dist/op/activities/sprite-config.d.ts.map +1 -1
  29. package/dist/op/activities/sprite-fs.d.ts.map +1 -1
  30. package/dist/op/activities/sprite-service-converge.d.ts.map +1 -1
  31. package/dist/op/activities/sprite-services.d.ts.map +1 -1
  32. package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
  33. package/dist/op/activities/sprites.d.ts.map +1 -1
  34. package/dist/op/activity-contracts.d.ts +10 -10
  35. package/dist/op/builders.d.ts.map +1 -1
  36. package/dist/release-metadata.d.ts.map +1 -1
  37. package/dist/serializer.d.ts.map +1 -1
  38. package/dist/skills/chant-fly-ops.md +8 -0
  39. package/dist/skills/chant-fly-patterns.md +1 -1
  40. package/dist/spec/parse.d.ts.map +1 -1
  41. package/dist/types/index.d.ts +2 -1
  42. package/package.json +3 -3
  43. package/src/codegen/docs.ts +32 -1
  44. package/src/components/fly-release.test.ts +36 -0
  45. package/src/components/fly-release.ts +37 -3
  46. package/src/composites/fly-deploy.ts +2 -0
  47. package/src/describe-resources.test.ts +18 -1
  48. package/src/describe-resources.ts +1 -1
  49. package/src/generated/index.d.ts +2 -1
  50. package/src/generated/index.ts +1 -1
  51. package/src/op/activities/fly-apply.integration.test.ts +12 -5
  52. package/src/op/activities/fly-apply.test.ts +483 -2
  53. package/src/op/activities/fly-apply.ts +229 -45
  54. package/src/op/activities/index.ts +27 -1
  55. package/src/op/activities/machines-contract.docker.integration.test.ts +22 -1
  56. package/src/op/activities/machines-contract.ts +5 -1
  57. package/src/op/activities/machines-fake.ts +69 -2
  58. package/src/op/activities/machines-local-cli.ts +3 -0
  59. package/src/op/activities/machines-local.test.ts +48 -12
  60. package/src/op/activities/machines-local.ts +28 -5
  61. package/src/op/activities/progress.ts +10 -0
  62. package/src/op/activities/sprite-config.ts +10 -9
  63. package/src/op/activities/sprite-fs.ts +3 -2
  64. package/src/op/activities/sprite-progress.test.ts +52 -0
  65. package/src/op/activities/sprite-service-converge.ts +2 -1
  66. package/src/op/activities/sprite-services.ts +5 -4
  67. package/src/op/activities/sprites.ts +6 -5
  68. package/src/serializer-telemetry.test.ts +80 -0
  69. package/src/serializer.test.ts +17 -2
  70. package/src/serializer.ts +58 -8
  71. package/src/skills/chant-fly-ops.md +8 -0
  72. package/src/skills/chant-fly-patterns.md +1 -1
  73. package/src/spec/parse.ts +25 -2
  74. package/src/teardown.test.ts +22 -3
  75. package/src/teardown.ts +1 -1
  76. package/src/telemetry-build.test.ts +75 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Telemetry attribution in the fly serializer (#3060): each Machine's
3
+ * `config.env` gets `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES` when the
4
+ * build hands it `context.telemetry`, and nothing changes without it.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import type { Declarable } from "@intentius/chant";
8
+ import { flySerializer } from "./serializer";
9
+ import { App, Machine, MachineConfig } from "./generated/index";
10
+
11
+ const telemetry = { workspace: "acme", member: "svc", environment: "prod" };
12
+
13
+ function stack(...entries: Array<[string, unknown]>): Map<string, Declarable> {
14
+ return new Map(entries as Array<[string, Declarable]>);
15
+ }
16
+
17
+ const site = (config: Record<string, unknown>) =>
18
+ stack(["app", new App({ name: "notes", org_slug: "acme" })], ["web", new Machine({ name: "web", config: new MachineConfig(config) })]);
19
+
20
+ const build = (entities: Map<string, Declarable>, withTelemetry = true) =>
21
+ JSON.parse(flySerializer.serialize(entities, undefined, withTelemetry ? { telemetry } : undefined) as string);
22
+
23
+ describe("fly telemetry attribution (#3060)", () => {
24
+ it("stamps each Machine with the app's name and the Machine's declaration id", () => {
25
+ const out = build(site({ image: "node:22-slim", env: { PORT: "8080" } }));
26
+ expect(out.web.body.config.env).toEqual({
27
+ PORT: "8080",
28
+ OTEL_SERVICE_NAME: "notes",
29
+ OTEL_RESOURCE_ATTRIBUTES: "chant.workspace=acme,chant.member=svc,chant.decl=web,deployment.environment.name=prod",
30
+ });
31
+ // The App's create body has no env.
32
+ expect(out.app.body).toEqual({ app_name: "notes", org_slug: "acme" });
33
+ });
34
+
35
+ it("adds an env to a Machine that declares none", () => {
36
+ const out = build(site({ image: "node:22-slim" }));
37
+ expect(out.web.body.config.env.OTEL_SERVICE_NAME).toBe("notes");
38
+ });
39
+
40
+ it("keeps the values a Machine sets and appends the missing attributes", () => {
41
+ const out = build(
42
+ site({
43
+ image: "node:22-slim",
44
+ env: { OTEL_SERVICE_NAME: "notes-web", OTEL_RESOURCE_ATTRIBUTES: "chant.member=team-a,team=core" },
45
+ }),
46
+ );
47
+ expect(out.web.body.config.env).toEqual({
48
+ OTEL_SERVICE_NAME: "notes-web",
49
+ OTEL_RESOURCE_ATTRIBUTES: "chant.member=team-a,team=core,chant.workspace=acme,chant.decl=web,deployment.environment.name=prod",
50
+ });
51
+ });
52
+
53
+ it("gives service.version from an image pinned by digest", () => {
54
+ const digest = "sha256:" + "a".repeat(64);
55
+ const out = build(site({ image: `registry.fly.io/notes@${digest}` }));
56
+ expect(out.web.body.config.env.OTEL_RESOURCE_ATTRIBUTES).toBe(
57
+ `chant.workspace=acme,chant.member=svc,chant.decl=web,deployment.environment.name=prod,service.version=${encodeURIComponent(digest)}`,
58
+ );
59
+ });
60
+
61
+ it("names the Machine's app when the stack has several, and falls back to the export name when none is resolved", () => {
62
+ const out = build(
63
+ stack(
64
+ ["a", new App({ name: "alpha" })],
65
+ ["b", new App({ name: "beta" })],
66
+ ["worker", new Machine({ app: "beta", config: new MachineConfig({ image: "x" }) })],
67
+ ["loose", new Machine({ config: new MachineConfig({ image: "x" }) })],
68
+ ),
69
+ );
70
+ expect(out.worker.body.config.env.OTEL_SERVICE_NAME).toBe("beta");
71
+ expect(out.loose.body.config.env.OTEL_SERVICE_NAME).toBe("loose");
72
+ });
73
+
74
+ it("writes the same bytes as before without context.telemetry", () => {
75
+ const entities = site({ image: "node:22-slim", env: { PORT: "8080" } });
76
+ const plain = flySerializer.serialize(entities) as string;
77
+ expect(plain).not.toContain("OTEL_");
78
+ expect(flySerializer.serialize(entities, undefined, {})).toBe(plain);
79
+ });
80
+ });
@@ -146,14 +146,14 @@ describe("fly serializer", () => {
146
146
  });
147
147
  });
148
148
 
149
- it("emits the Certificate create body (createCertificateRequest: hostname) under the app", () => {
149
+ it("emits the Certificate create body (createAcmeCertificateRequest: hostname) at /certificates/acme (#3114)", () => {
150
150
  const entities = stack(
151
151
  ["app", new App({ name: "my-app" })],
152
152
  ["cert", new Certificate({ hostname: "example.com" })],
153
153
  );
154
154
  const out = JSON.parse(flySerializer.serialize(entities) as string);
155
155
  expect(out.cert).toEqual({
156
- endpoint: "/v1/apps/my-app/certificates",
156
+ endpoint: "/v1/apps/my-app/certificates/acme",
157
157
  method: "POST",
158
158
  body: { hostname: "example.com" },
159
159
  });
@@ -172,6 +172,21 @@ describe("fly serializer", () => {
172
172
  applyOnly: true,
173
173
  });
174
174
  });
175
+
176
+ it("marks a value-less Secret mustExist and takes its name from `name` (#2516)", () => {
177
+ const entities = stack(
178
+ ["app", new App({ name: "my-app" })],
179
+ ["dbUrl", new Secret({ name: "DATABASE_URL" })],
180
+ );
181
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
182
+ expect(out.dbUrl).toEqual({
183
+ endpoint: "/v1/apps/my-app/secrets/DATABASE_URL",
184
+ method: "POST",
185
+ body: {},
186
+ applyOnly: true,
187
+ mustExist: true,
188
+ });
189
+ });
175
190
  });
176
191
 
177
192
  // ── Pseudo-parameters (Fly.Region / Fly.OrgSlug) ────────────────────────────
package/src/serializer.ts CHANGED
@@ -36,6 +36,7 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
36
36
  import { ownershipEntries, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
37
37
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
38
38
  import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
39
+ import { mergeResourceAttributes, telemetryEnvironment, type TelemetryAttribution } from "@intentius/chant/telemetry-attribution";
39
40
  import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
40
41
 
41
42
  const APP_ENTITY_TYPE = "Fly::Machines::App";
@@ -56,6 +57,11 @@ interface FlapsRequest {
56
57
  * honors this flag by skipping the drift/diff read and always POSTing.
57
58
  */
58
59
  applyOnly?: boolean;
60
+ /**
61
+ * A Secret declared without a value. Its value is set outside chant, so the
62
+ * applier never posts it and only checks that the app has it.
63
+ */
64
+ mustExist?: boolean;
59
65
  }
60
66
 
61
67
  /**
@@ -146,6 +152,39 @@ function appName(entity: Declarable, entityName: string): string {
146
152
  return typeof name === "string" ? name : entityName;
147
153
  }
148
154
 
155
+ // ── Telemetry attribution (#3060, D22) ────────────────────────────
156
+
157
+ /** The digest of an image reference pinned with `@sha256:...`, or undefined. */
158
+ function imageDigest(image: unknown): string | undefined {
159
+ if (typeof image !== "string") return undefined;
160
+ const at = image.lastIndexOf("@");
161
+ return at === -1 ? undefined : image.slice(at + 1);
162
+ }
163
+
164
+ /**
165
+ * Stamp one Machine's attributes as `OTEL_SERVICE_NAME` and
166
+ * `OTEL_RESOURCE_ATTRIBUTES` in its `config.env`, the env the Machine's
167
+ * process gets. `service` is the owning app's name on Fly, `decl` the
168
+ * Machine's declaration id. A value the Machine already sets is kept: its own
169
+ * `OTEL_SERVICE_NAME` wins, and its own resource attributes keep their keys
170
+ * while the missing ones are appended.
171
+ *
172
+ * Machine env values are literal strings, so there is no reference to the
173
+ * release attributes here: `fly-release` writes them into the value it
174
+ * deploys (#3061, ws-081).
175
+ */
176
+ function stampTelemetry(config: Record<string, unknown>, service: string, decl: string, telemetry: TelemetryAttribution): void {
177
+ const version = imageDigest(config.image);
178
+ const vars = telemetryEnvironment(telemetry, { service, decl, ...(version ? { version } : {}) });
179
+ const env: Record<string, unknown> =
180
+ config.env && typeof config.env === "object" && !Array.isArray(config.env) ? { ...(config.env as Record<string, unknown>) } : {};
181
+ if (!("OTEL_SERVICE_NAME" in env)) env.OTEL_SERVICE_NAME = vars.OTEL_SERVICE_NAME;
182
+ const own = env.OTEL_RESOURCE_ATTRIBUTES;
183
+ if (own === undefined) env.OTEL_RESOURCE_ATTRIBUTES = vars.OTEL_RESOURCE_ATTRIBUTES;
184
+ else if (typeof own === "string") env.OTEL_RESOURCE_ATTRIBUTES = mergeResourceAttributes(own, vars.OTEL_RESOURCE_ATTRIBUTES);
185
+ config.env = env;
186
+ }
187
+
149
188
  /**
150
189
  * fly flaps serializer.
151
190
  */
@@ -237,8 +276,15 @@ export const flySerializer: Serializer = {
237
276
  }
238
277
  body.config = config;
239
278
 
279
+ // Telemetry attribution (#3060): service.name is the app's name on
280
+ // Fly, or the Machine's export name when no app is resolved.
281
+ const owningApp = resolveOwningApp(entity);
282
+ if (context?.telemetry) {
283
+ stampTelemetry(config, owningApp === "{app}" ? name : owningApp, name, context.telemetry);
284
+ }
285
+
240
286
  requests[name] = {
241
- endpoint: `/v1/apps/${resolveOwningApp(entity)}/machines`,
287
+ endpoint: `/v1/apps/${owningApp}/machines`,
242
288
  method: "POST",
243
289
  body,
244
290
  };
@@ -260,22 +306,26 @@ export const flySerializer: Serializer = {
260
306
  if (entityType === SECRET_ENTITY_TYPE) {
261
307
  // Apply-only (D7): name is the URL segment; `value` is the only body
262
308
  // field. mudflaps returns just a digest, so this never enters a diff.
309
+ // A Secret with no value is set out of band. It is marked
310
+ // `mustExist`, and the applier checks for it instead of posting.
263
311
  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
- };
312
+ const endpoint = `/v1/apps/${app}/secrets/${encodeURIComponent(secretName)}`;
313
+ requests[name] =
314
+ props.value !== undefined
315
+ ? { endpoint, method: "POST", body: { value: props.value }, applyOnly: true }
316
+ : { endpoint, method: "POST", body: {}, applyOnly: true, mustExist: true };
270
317
  continue;
271
318
  }
272
319
 
320
+ // A Certificate is an ACME certificate. flaps creates those at
321
+ // `.../certificates/acme` and answers a bare POST to `.../certificates`
322
+ // with 404 (#3114).
273
323
  const segment =
274
324
  entityType === VOLUME_ENTITY_TYPE
275
325
  ? "volumes"
276
326
  : entityType === IP_ENTITY_TYPE
277
327
  ? "ip_assignments"
278
- : "certificates";
328
+ : "certificates/acme";
279
329
  const body: Record<string, unknown> = {};
280
330
  for (const [key, value] of Object.entries(props)) {
281
331
  if (key === "app" || value === undefined) continue;
@@ -46,6 +46,14 @@ Prune is off by default and destructive. It removes resources the plan no longer
46
46
 
47
47
  Each prune logs the resource and endpoint it removed, so a prune run is auditable from the Op output.
48
48
 
49
+ ## Destroying a running machine
50
+
51
+ Fly refuses to destroy a machine that is still running: a plain `DELETE` answers 412 `failed_precondition` unless it carries `?force=true`. So prune and `flyDelete` stop a running machine first (under a lease), wait for `state=stopped`, and then destroy it. The stop sends the machine's configured kill signal and honours its kill timeout, so the process can drain. A machine that is already stopped, suspended or failed is destroyed without a stop.
52
+
53
+ If the stop does not reach `stopped` before the wait deadline, the destroy fails with an error naming the app, machine and id, and the machine is left in place. Pass `force: true` to `flyApply` or `flyDelete` to skip the stop and destroy with `?force=true`, which kills the machine at once.
54
+
55
+ The mudflaps emulator does not enforce the 412 yet (INTENTIUS/mudflaps#70), so only the in-memory flaps and a real org exercise this path.
56
+
49
57
  ## Teardown
50
58
 
51
59
  `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.
@@ -38,7 +38,7 @@ An IP is assigned if the declared type is not already present, keyed by family (
38
38
 
39
39
  ## Certificates
40
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.
41
+ A `Certificate` is an ACME certificate that Fly issues once the hostname's DNS points at the app. `flyApply` creates it with `POST /v1/apps/{app}/certificates/acme`, if absent, idempotent by hostname. A re-apply for a hostname that already has a certificate is a no-op. Uploading your own certificate and key (Fly's `/certificates/custom`) is not modeled.
42
42
 
43
43
  ## Apply-only secrets
44
44
 
package/src/spec/parse.ts CHANGED
@@ -68,6 +68,11 @@ interface ResourceSpec {
68
68
  typeName: string;
69
69
  request: string;
70
70
  response: string;
71
+ /**
72
+ * Authoring props the request schema lacks because flaps takes them from the
73
+ * URL. The serializer moves them into the endpoint.
74
+ */
75
+ pathProps?: ParsedProperty[];
71
76
  }
72
77
 
73
78
  const RESOURCES: ResourceSpec[] = [
@@ -76,7 +81,22 @@ const RESOURCES: ResourceSpec[] = [
76
81
  { typeName: `Fly::${SERVICE}::Volume`, request: "CreateVolumeRequest", response: "Volume" },
77
82
  { typeName: `Fly::${SERVICE}::IPAddress`, request: "assignIPRequest", response: "IPAssignment" },
78
83
  { typeName: `Fly::${SERVICE}::Certificate`, request: "createAcmeCertificateRequest", response: "CertificateDetail" },
79
- { typeName: `Fly::${SERVICE}::Secret`, request: "SetAppSecretRequest", response: "AppSecret" },
84
+ {
85
+ typeName: `Fly::${SERVICE}::Secret`,
86
+ request: "SetAppSecretRequest",
87
+ response: "AppSecret",
88
+ pathProps: [
89
+ {
90
+ name: "name",
91
+ tsType: "string",
92
+ required: false,
93
+ description:
94
+ "The secret's name on the app. Defaults to the declaration's export name. " +
95
+ "A Secret with no value is never sent: apply checks that the app already has it.",
96
+ constraints: {},
97
+ },
98
+ ],
99
+ },
80
100
  ];
81
101
 
82
102
  const REF_PREFIX = "#/components/schemas/";
@@ -117,11 +137,14 @@ export function parseFlyOpenAPI(data: string | Buffer): FlyParseResult[] {
117
137
  constraints: coreExtractConstraints(prop as JsonSchemaProperty),
118
138
  });
119
139
  }
140
+ for (const extra of rspec.pathProps ?? []) {
141
+ if (!(extra.name in reqProps)) properties.push(extra);
142
+ }
120
143
 
121
144
  // Attributes = response props not present in the request schema.
122
145
  const attributes: Array<{ name: string; tsType: string }> = [];
123
146
  for (const [name, prop] of Object.entries(res?.properties ?? {})) {
124
- if (name in reqProps) continue;
147
+ if (properties.some((p) => p.name === name)) continue;
125
148
  attributes.push({ name, tsType: resolve(prop) });
126
149
  }
127
150
 
@@ -48,6 +48,8 @@ const OTHER_STACK = { "managed-by": "chant", "chant-stack": "blog", "chant-env":
48
48
  * A stateful scripted flaps: `apps` maps app name to its machines; deletes
49
49
  * mutate the state, so the app-boundary re-check after a destroy sees what a
50
50
  * real flaps would. An app mapped to the string "boom" fails its machine list.
51
+ * Like flaps, a bare DELETE on a machine that is not stopped answers 412
52
+ * (#3115), and a stop moves it to `stopped`.
51
53
  */
52
54
  function scriptedFlaps(apps: Record<string, FakeMachine[] | "boom">): { http: FlyHttp; log: string[] } {
53
55
  const log: string[] = [];
@@ -62,10 +64,22 @@ function scriptedFlaps(apps: Record<string, FakeMachine[] | "boom">): { http: Fl
62
64
  if (method === "GET" && /\/machines\/[^/]+\/wait\?/.test(url)) {
63
65
  return { status: 200, text: JSON.stringify({ ok: true }) };
64
66
  }
65
- let m = url.match(/\/v1\/apps\/([^/?]+)\/machines\/([^/?]+)$/);
67
+ let m = url.match(/\/v1\/apps\/([^/?]+)\/machines\/([^/?]+)\/stop$/);
68
+ if (m && method === "POST") {
69
+ const held = apps[m[1]];
70
+ if (Array.isArray(held)) for (const mm of held) if (mm.id === m[2]) mm.state = "stopped";
71
+ return { status: 200, text: "{}" };
72
+ }
73
+ m = url.match(/\/v1\/apps\/([^/?]+)\/machines\/([^/?]+)(\?force=true)?$/);
66
74
  if (m && method === "DELETE") {
67
75
  const held = apps[m[1]];
68
- if (Array.isArray(held)) apps[m[1]] = held.filter((mm) => mm.id !== m![2]);
76
+ if (Array.isArray(held)) {
77
+ const target = held.find((mm) => mm.id === m![2]);
78
+ if (target && target.state !== "stopped" && !m[3]) {
79
+ return { status: 412, text: JSON.stringify({ error: "failed_precondition: unable to destroy machine, not currently stopped" }) };
80
+ }
81
+ apps[m[1]] = held.filter((mm) => mm.id !== m![2]);
82
+ }
69
83
  return { status: 200, text: "{}" };
70
84
  }
71
85
  m = url.match(/\/v1\/apps\/([^/?]+)\/machines$/);
@@ -147,7 +161,7 @@ describe("teardownOwned — marker machines + the app boundary (#743)", () => {
147
161
  });
148
162
 
149
163
  describe("executeTeardown — machines first, apps last", () => {
150
- test("destroys the machines (lease → destroy → wait), then deletes the app", async () => {
164
+ test("destroys the machines (stop → destroy → wait, each under a lease), then deletes the app", async () => {
151
165
  const state: Record<string, FakeMachine[] | "boom"> = {
152
166
  mine: [machine("web", MINE), machine("worker", MINE)],
153
167
  };
@@ -176,6 +190,11 @@ describe("executeTeardown — machines first, apps last", () => {
176
190
  `DELETE ${ENDPOINT}/v1/apps/mine/machines/m-worker`,
177
191
  `DELETE ${ENDPOINT}/v1/apps/mine`,
178
192
  ]);
193
+ // Started machines are stopped before the destroy: flaps refuses a bare DELETE otherwise (#3115).
194
+ expect(log.filter((l) => l.endsWith("/stop"))).toEqual([
195
+ `POST ${ENDPOINT}/v1/apps/mine/machines/m-web/stop`,
196
+ `POST ${ENDPOINT}/v1/apps/mine/machines/m-worker/stop`,
197
+ ]);
179
198
  expect(state.mine).toBeUndefined();
180
199
  });
181
200
 
package/src/teardown.ts CHANGED
@@ -220,7 +220,7 @@ async function destroyMachineCandidate(
220
220
  }
221
221
 
222
222
  try {
223
- await destroyMachine(ctx, app, live.id, http, undefined, wait ?? {});
223
+ await destroyMachine(ctx, app, live.id, http, undefined, { ...(wait ?? {}), state: live.state, name: live.name });
224
224
  } catch (err) {
225
225
  return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
226
226
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Telemetry attribution through a real fly build (#3060): inside a workspace
3
+ * each Machine's env names the workspace and the member,
4
+ * `telemetry.attribution: false` turns that off, and outside a workspace
5
+ * nothing is stamped unless `telemetry.attribution: true` opts in.
6
+ */
7
+ import { afterAll, describe, expect, test } from "vitest";
8
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { buildCommand } from "@intentius/chant/cli/commands/build";
13
+ import { loadPlugins, resolveProjectLexicons } from "@intentius/chant/cli";
14
+
15
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
16
+ const made: string[] = [];
17
+ afterAll(() => {
18
+ for (const dir of made) rmSync(dir, { recursive: true, force: true });
19
+ });
20
+
21
+ const APP = `import { App, Machine, MachineConfig } from "@intentius/chant-lexicon-fly";
22
+
23
+ export const app = new App({ name: "notes", org_slug: "acme" });
24
+
25
+ export const web = new Machine({
26
+ name: "web",
27
+ region: "iad",
28
+ config: new MachineConfig({ image: "node:22-slim", env: { PORT: "8080" } }),
29
+ });
30
+ `;
31
+
32
+ /** A fly project, inside a one-member workspace or on its own, built to its flaps plan. */
33
+ async function build(options: { workspace: boolean; attribution?: boolean }): Promise<Record<string, { body: { config?: { env?: Record<string, string> } } }>> {
34
+ const root = mkdtempSync(join(tmpdir(), "chant-3060-"));
35
+ made.push(root);
36
+ mkdirSync(join(root, ".git"));
37
+ const project = options.workspace ? join(root, "svc") : root;
38
+ mkdirSync(join(project, "src"), { recursive: true });
39
+ if (options.workspace) {
40
+ writeFileSync(
41
+ join(root, "chant.workspace.json"),
42
+ JSON.stringify({ name: "acme", schema: 1, members: [{ name: "svc", dir: "svc", kind: "chant" }] }, null, 2),
43
+ );
44
+ }
45
+ const telemetry = options.attribution === undefined ? "" : ` telemetry: { attribution: ${options.attribution} },\n`;
46
+ writeFileSync(join(project, "chant.config.ts"), `export default {\n lexicons: ["fly"],\n${telemetry}};\n`);
47
+ writeFileSync(join(project, "src", "app.ts"), APP);
48
+ symlinkSync(join(repoRoot, "node_modules"), join(project, "node_modules"), "dir");
49
+
50
+ const src = join(project, "src");
51
+ const plugins = await loadPlugins(await resolveProjectLexicons(src));
52
+ const serializers = plugins.map((p) => p.serializer).filter((s) => s.name === "fly");
53
+ const output = join(project, "fly.json");
54
+ const result = await buildCommand({ path: src, output, format: "json", serializers, plugins });
55
+ expect(result.success, result.errors.join("\n")).toBe(true);
56
+ return JSON.parse(readFileSync(output, "utf-8"));
57
+ }
58
+
59
+ describe("telemetry attribution in a fly build (#3060)", () => {
60
+ test("inside a workspace the Machine names the workspace, the member and the declaration", async () => {
61
+ const env = (await build({ workspace: true })).web.body.config!.env!;
62
+ expect(env.OTEL_SERVICE_NAME).toBe("notes");
63
+ expect(env.OTEL_RESOURCE_ATTRIBUTES).toBe("chant.workspace=acme,chant.member=svc,chant.decl=web");
64
+ });
65
+
66
+ test("telemetry.attribution false turns it off inside a workspace", async () => {
67
+ expect(JSON.stringify(await build({ workspace: true, attribution: false }))).not.toContain("OTEL_");
68
+ });
69
+
70
+ test("outside a workspace nothing is stamped, and telemetry.attribution true opts in without workspace or member", async () => {
71
+ expect(JSON.stringify(await build({ workspace: false }))).not.toContain("OTEL_");
72
+ const env = (await build({ workspace: false, attribution: true })).web.body.config!.env!;
73
+ expect(env.OTEL_RESOURCE_ATTRIBUTES).toBe("chant.decl=web");
74
+ });
75
+ });