@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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fly deploy Op (#744) — the integration proof: `boot → build → flyApply → wait
|
|
3
|
+
* → teardown` on the local executor. The peer of the aws/gcp deploy Ops in
|
|
4
|
+
* `examples/local-cloud-trio`, for a single cloud.
|
|
5
|
+
*
|
|
6
|
+
* The phases compose modeled activities, no raw shell:
|
|
7
|
+
* Emulator boot mudflaps (the Fly Machines API fake) via `flapsUp`
|
|
8
|
+
* Build run the project's `build:fly` npm script → the serialized plan
|
|
9
|
+
* Apply `flyApply` the plan straight to flaps (creates App + Machine and
|
|
10
|
+
* waits each machine to `started` via `/wait`)
|
|
11
|
+
* Verify GET the app's machines and assert one reached `started`
|
|
12
|
+
* Teardown stop and remove the mudflaps container via `flapsDown`
|
|
13
|
+
*
|
|
14
|
+
* The activity steps are the generic `activity(fn, args)` builder tagged with
|
|
15
|
+
* the exported activity function names the fly lexicon contributes
|
|
16
|
+
* (`flapsUp`/`flapsDown`/`flyApply` from `src/op/activities`). The core activity
|
|
17
|
+
* registry resolves them by name when a project lists the `fly` lexicon, so
|
|
18
|
+
* these typed builders give `gcpApply()`-style DX without a core change.
|
|
19
|
+
*
|
|
20
|
+
* D3 (local vs real Fly): the Apply step's flaps endpoint defaults to local
|
|
21
|
+
* mudflaps (`http://localhost:4280`). Pass `endpoint: null` to drop the override
|
|
22
|
+
* and let `flyApply` fall through to `FLY_FLAPS_BASE_URL` / real Fly (with a
|
|
23
|
+
* `FLY_API_TOKEN`).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Op, phase, build, activity, httpCheck } from "@intentius/chant/op";
|
|
27
|
+
import type { OpResource } from "@intentius/chant/op";
|
|
28
|
+
import type { ActivityStep } from "@intentius/chant/op";
|
|
29
|
+
|
|
30
|
+
/** The local mudflaps endpoint the deploy Op targets by default. */
|
|
31
|
+
export const LOCAL_FLAPS_ENDPOINT = "http://localhost:4280";
|
|
32
|
+
|
|
33
|
+
/** Options accepted by the mudflaps lifecycle step builders. */
|
|
34
|
+
export interface FlapsStepOpts {
|
|
35
|
+
/** Container name. Default: `chant-mudflaps`. */
|
|
36
|
+
name?: string;
|
|
37
|
+
/** Host port mapped to the emulator's `:4280`. Default: `4280`. */
|
|
38
|
+
port?: number;
|
|
39
|
+
/** Image. Default: `ghcr.io/intentius/mudflaps:0.3.0`. */
|
|
40
|
+
image?: string;
|
|
41
|
+
/** Readiness timeout in ms. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
/** Health poll interval in ms. */
|
|
44
|
+
intervalMs?: number;
|
|
45
|
+
/** Activity profile override. */
|
|
46
|
+
profile?: ActivityStep["profile"];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Boot a local mudflaps (Fly Machines API emulator) in Docker and point the
|
|
51
|
+
* apply step at it — the typed twin of `flociGcpUp` for flaps. Resolves to the
|
|
52
|
+
* `flapsUp` activity (#740). Defaults to the `longInfra` profile (the image may
|
|
53
|
+
* pull); override via `opts.profile`.
|
|
54
|
+
*/
|
|
55
|
+
export const flapsUp = (opts?: FlapsStepOpts): ActivityStep => {
|
|
56
|
+
const { profile, ...args } = opts ?? {};
|
|
57
|
+
return activity("flapsUp", args as Record<string, unknown>, profile ?? "longInfra");
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Stop and remove the local mudflaps container. Resolves to the `flapsDown` activity. Defaults to the `fastIdempotent` profile (override via `opts.profile`). */
|
|
61
|
+
export const flapsDown = (opts?: FlapsStepOpts): ActivityStep => {
|
|
62
|
+
const { profile, ...args } = opts ?? {};
|
|
63
|
+
return activity("flapsDown", args as Record<string, unknown>, profile ?? "fastIdempotent");
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Options for the flaps apply step (mirror `FlyApplyArgs`, minus `planPath`). */
|
|
67
|
+
export interface FlyApplyStepOpts {
|
|
68
|
+
/** flaps endpoint override (D3). Default: `FLY_FLAPS_BASE_URL` env, else real Fly. */
|
|
69
|
+
endpoint?: string;
|
|
70
|
+
/** Bearer token for real Fly. Default: `FLY_API_TOKEN`. mudflaps ignores it. */
|
|
71
|
+
token?: string;
|
|
72
|
+
/** Prune declared-then-removed resources (D2). Destructive — off by default. */
|
|
73
|
+
prune?: boolean;
|
|
74
|
+
/** Wait-loop tuning (mainly for tests). */
|
|
75
|
+
wait?: Record<string, unknown>;
|
|
76
|
+
/** Activity profile override. */
|
|
77
|
+
profile?: ActivityStep["profile"];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply the serialized flaps plan straight to the Machines API — the typed twin
|
|
82
|
+
* of `gcpApply` for fly. Resolves to the `flyApply` activity (#739): create
|
|
83
|
+
* App + Machine, wait each machine to `started`, prune owned-only when asked.
|
|
84
|
+
* Defaults to the `longInfra` profile (override via `opts.profile`).
|
|
85
|
+
*/
|
|
86
|
+
export const flyApplyStep = (planPath: string, opts?: FlyApplyStepOpts): ActivityStep => {
|
|
87
|
+
const { profile, ...rest } = opts ?? {};
|
|
88
|
+
return activity("flyApply", { planPath, ...(rest as Record<string, unknown>) }, profile ?? "longInfra");
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Options for {@link flyDeploy}. */
|
|
92
|
+
export interface FlyDeployOpts {
|
|
93
|
+
/** Op name (the `chant run <name>` target). Default: `fly`. */
|
|
94
|
+
name?: string;
|
|
95
|
+
/** Op overview line. */
|
|
96
|
+
overview?: string;
|
|
97
|
+
/** Temporal task queue (unused by the local executor). Default: `local-fly`. */
|
|
98
|
+
taskQueue?: string;
|
|
99
|
+
/** Directory the `build:fly` script and plan path are relative to. Default: `.`. */
|
|
100
|
+
path?: string;
|
|
101
|
+
/** npm build script to run. Default: `build:fly`. */
|
|
102
|
+
buildScript?: string;
|
|
103
|
+
/** Path to the serialized flaps plan the build writes. Default: `dist/fly.json`. */
|
|
104
|
+
planPath?: string;
|
|
105
|
+
/**
|
|
106
|
+
* flaps endpoint the apply + verify steps target (D3). Defaults to local
|
|
107
|
+
* mudflaps ({@link LOCAL_FLAPS_ENDPOINT}). Pass `null` to drop the override so
|
|
108
|
+
* `flyApply` targets real Fly via `FLY_FLAPS_BASE_URL` / a `FLY_API_TOKEN`.
|
|
109
|
+
*/
|
|
110
|
+
endpoint?: string | null;
|
|
111
|
+
/**
|
|
112
|
+
* App name for the Verify step. When set (with a local `endpoint`), the Op GETs
|
|
113
|
+
* the app's machines and asserts one reached `started`. Omitted → no Verify
|
|
114
|
+
* step (the Apply step still waits each machine to `started`).
|
|
115
|
+
*/
|
|
116
|
+
app?: string;
|
|
117
|
+
/** Prune declared-then-removed resources on apply (D2). Off by default. */
|
|
118
|
+
prune?: boolean;
|
|
119
|
+
/** Wait-loop tuning passed through to `flyApply` (mainly for tests). */
|
|
120
|
+
wait?: Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build a fly deploy Op: boot mudflaps, build the plan, apply App + Machine,
|
|
125
|
+
* verify the machine reached `started`, tear the emulator down. See the module
|
|
126
|
+
* docstring for the phase layout and the D3 endpoint story.
|
|
127
|
+
*/
|
|
128
|
+
export function flyDeploy(opts: FlyDeployOpts = {}): InstanceType<typeof OpResource> {
|
|
129
|
+
const path = opts.path ?? ".";
|
|
130
|
+
const planPath = opts.planPath ?? "dist/fly.json";
|
|
131
|
+
const endpoint = opts.endpoint === undefined ? LOCAL_FLAPS_ENDPOINT : opts.endpoint;
|
|
132
|
+
|
|
133
|
+
const applyOpts: FlyApplyStepOpts = {
|
|
134
|
+
...(endpoint ? { endpoint } : {}),
|
|
135
|
+
...(opts.prune ? { prune: true } : {}),
|
|
136
|
+
...(opts.wait ? { wait: opts.wait } : {}),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const phases = [
|
|
140
|
+
phase("Emulator", [flapsUp()]),
|
|
141
|
+
phase("Build", [build(path, { script: opts.buildScript ?? "build:fly" })]),
|
|
142
|
+
phase("Apply", [flyApplyStep(planPath, applyOpts)]),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// Verify only makes sense against a reachable local endpoint: mudflaps needs
|
|
146
|
+
// no auth, so a plain GET of the app's machines can assert `started`. Against
|
|
147
|
+
// real Fly (no endpoint) the flaps API needs a bearer token httpCheck can't
|
|
148
|
+
// carry, so the Apply step's own `/wait` is the verification there.
|
|
149
|
+
if (endpoint && opts.app) {
|
|
150
|
+
phases.push(
|
|
151
|
+
phase("Verify", [
|
|
152
|
+
httpCheck(`${endpoint}/v1/apps/${opts.app}/machines`, {
|
|
153
|
+
contains: "started",
|
|
154
|
+
retries: 15,
|
|
155
|
+
intervalMs: 2000,
|
|
156
|
+
}),
|
|
157
|
+
]),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
phases.push(phase("Teardown", [flapsDown()]));
|
|
162
|
+
|
|
163
|
+
return Op({
|
|
164
|
+
name: opts.name ?? "fly",
|
|
165
|
+
overview: opts.overview ?? "Fly: App + Machine → mudflaps (direct flaps apply), local, no account",
|
|
166
|
+
taskQueue: opts.taskQueue ?? "local-fly",
|
|
167
|
+
phases,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { describeResources, flyPlan } from "./describe-resources";
|
|
3
|
+
import type { FlyHttp } from "./op/activities/fly-apply";
|
|
4
|
+
|
|
5
|
+
// Injected-HTTP unit tests, mirroring fly-apply.test.ts: a scripted flaps answers
|
|
6
|
+
// list/get reads, and describeResources maps them to ResourceMetadata with the
|
|
7
|
+
// right ownership verdict.
|
|
8
|
+
|
|
9
|
+
const ENDPOINT = "http://localhost:4280";
|
|
10
|
+
const APP = "demo";
|
|
11
|
+
const OWNED_META = { "managed-by": "chant" };
|
|
12
|
+
|
|
13
|
+
/** A scripted flaps machine as list/get would return it. */
|
|
14
|
+
function liveMachine(
|
|
15
|
+
name: string,
|
|
16
|
+
image: string,
|
|
17
|
+
extra: Partial<{ id: string; state: string; instance_id: string; owned: boolean }> = {},
|
|
18
|
+
): Record<string, unknown> {
|
|
19
|
+
const metadata = extra.owned === false ? { role: "db" } : { ...OWNED_META };
|
|
20
|
+
return {
|
|
21
|
+
id: extra.id ?? `m-${name}`,
|
|
22
|
+
name,
|
|
23
|
+
state: extra.state ?? "started",
|
|
24
|
+
instance_id: extra.instance_id ?? "INST0",
|
|
25
|
+
config: { image, metadata },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** entities map keyed by declared entity name → its entityType (props unused here). */
|
|
30
|
+
function entities(pairs: Array<[string, string]>): Map<string, { entityType: string; props: Record<string, unknown> }> {
|
|
31
|
+
return new Map(pairs.map(([name, entityType]) => [name, { entityType, props: {} }]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A fake flaps that serves an app GET, a machine list, and empty breadth lists.
|
|
36
|
+
* `machines` is the live machine array returned for the app.
|
|
37
|
+
*/
|
|
38
|
+
function fakeHttp(machines: Array<Record<string, unknown>>): FlyHttp {
|
|
39
|
+
return async (method, url) => {
|
|
40
|
+
if (method === "GET" && /\/v1\/apps\/[^/]+$/.test(url)) return { status: 200, text: "{}" }; // app exists
|
|
41
|
+
if (method === "GET" && url.endsWith("/machines")) return { status: 200, text: JSON.stringify(machines) };
|
|
42
|
+
if (method === "GET" && url.endsWith("/volumes")) return { status: 200, text: "[]" };
|
|
43
|
+
if (method === "GET" && url.endsWith("/ip_assignments")) return { status: 200, text: JSON.stringify({ ips: [] }) };
|
|
44
|
+
if (method === "GET" && url.endsWith("/certificates")) return { status: 200, text: JSON.stringify({ certificates: [] }) };
|
|
45
|
+
return { status: 404, text: "" };
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PLAN = JSON.stringify({
|
|
50
|
+
app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } },
|
|
51
|
+
web: {
|
|
52
|
+
endpoint: `/v1/apps/${APP}/machines`,
|
|
53
|
+
method: "POST",
|
|
54
|
+
body: { name: "web", config: { image: "nginx:1", metadata: { ...OWNED_META } } },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const ENTS = entities([
|
|
59
|
+
["app", "Fly::Machines::App"],
|
|
60
|
+
["web", "Fly::Machines::Machine"],
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
describe("describeResources: live machines → ResourceMetadata verdicts", () => {
|
|
64
|
+
test("a managed-by:chant machine is owned; an unmarked one is foreign", async () => {
|
|
65
|
+
const http = fakeHttp([
|
|
66
|
+
liveMachine("web", "nginx:1", { id: "m-web" }), // declared + owned
|
|
67
|
+
liveMachine("legacy", "redis:1", { id: "m-legacy", owned: false }), // orphan + unmarked
|
|
68
|
+
]);
|
|
69
|
+
const res = await describeResources(
|
|
70
|
+
{ environment: "prod", buildOutput: PLAN, entityNames: ["app", "web"], entities: ENTS, endpoint: ENDPOINT },
|
|
71
|
+
http,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Declared machine keyed by its entity name; orphan keyed by its live name.
|
|
75
|
+
expect(res.web.type).toBe("Fly::Machines::Machine");
|
|
76
|
+
expect(res.web.ownership).toBe("owned");
|
|
77
|
+
expect(res.web.physicalId).toBe("m-web");
|
|
78
|
+
expect(res.web.status).toBe("started");
|
|
79
|
+
expect((res.web.attributes as { config?: unknown }).config).toEqual({
|
|
80
|
+
image: "nginx:1",
|
|
81
|
+
metadata: { "managed-by": "chant" },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(res.legacy.ownership).toBe("foreign");
|
|
85
|
+
expect(res.legacy.type).toBe("Fly::Machines::Machine");
|
|
86
|
+
|
|
87
|
+
// App surfaced as owned (app-boundary: it carries an owned machine).
|
|
88
|
+
expect(res.app.type).toBe("Fly::Machines::App");
|
|
89
|
+
expect(res.app.ownership).toBe("owned");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the owned filter drops foreign entries", async () => {
|
|
93
|
+
const http = fakeHttp([
|
|
94
|
+
liveMachine("web", "nginx:1", { id: "m-web" }),
|
|
95
|
+
liveMachine("legacy", "redis:1", { id: "m-legacy", owned: false }),
|
|
96
|
+
]);
|
|
97
|
+
const res = await describeResources(
|
|
98
|
+
{ environment: "prod", buildOutput: PLAN, entityNames: ["app", "web"], entities: ENTS, owned: true, endpoint: ENDPOINT },
|
|
99
|
+
http,
|
|
100
|
+
);
|
|
101
|
+
expect(res.web?.ownership).toBe("owned");
|
|
102
|
+
expect(res.legacy).toBeUndefined(); // foreign machine filtered out
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("terminal machines (destroyed/destroying) are not reported live", async () => {
|
|
106
|
+
const http = fakeHttp([
|
|
107
|
+
liveMachine("web", "nginx:1"),
|
|
108
|
+
liveMachine("gone", "nginx:1", { state: "destroyed" }),
|
|
109
|
+
]);
|
|
110
|
+
const res = await describeResources(
|
|
111
|
+
{ environment: "prod", buildOutput: PLAN, entityNames: ["app", "web"], entities: ENTS, endpoint: ENDPOINT },
|
|
112
|
+
http,
|
|
113
|
+
);
|
|
114
|
+
expect(res.web).toBeDefined();
|
|
115
|
+
expect(res.gone).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("empty build output returns nothing", async () => {
|
|
119
|
+
const res = await describeResources(
|
|
120
|
+
{ environment: "prod", buildOutput: "", entityNames: [], entities: new Map(), endpoint: ENDPOINT },
|
|
121
|
+
fakeHttp([]),
|
|
122
|
+
);
|
|
123
|
+
expect(res).toEqual({});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("flyPlan: declared-vs-live change set (create/noop/delete/adopt)", () => {
|
|
128
|
+
test("classifies each action; an unmarked machine is never a delete", async () => {
|
|
129
|
+
// Declared: app, web (live+equal → noop), web2 (not live → create).
|
|
130
|
+
const plan = JSON.stringify({
|
|
131
|
+
app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } },
|
|
132
|
+
web: {
|
|
133
|
+
endpoint: `/v1/apps/${APP}/machines`,
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: { name: "web", config: { image: "nginx:1", metadata: { ...OWNED_META } } },
|
|
136
|
+
},
|
|
137
|
+
web2: {
|
|
138
|
+
endpoint: `/v1/apps/${APP}/machines`,
|
|
139
|
+
method: "POST",
|
|
140
|
+
body: { name: "web2", config: { image: "nginx:1", metadata: { ...OWNED_META } } },
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
const ents = entities([
|
|
144
|
+
["app", "Fly::Machines::App"],
|
|
145
|
+
["web", "Fly::Machines::Machine"],
|
|
146
|
+
["web2", "Fly::Machines::Machine"],
|
|
147
|
+
]);
|
|
148
|
+
const http = fakeHttp([
|
|
149
|
+
liveMachine("web", "nginx:1"), // declared + owned + config equal → noop
|
|
150
|
+
liveMachine("orphan", "nginx:1"), // owned + undeclared → delete
|
|
151
|
+
liveMachine("legacy", "redis:1", { owned: false }), // unmarked + undeclared → adopt
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const { changeSet } = await flyPlan(
|
|
155
|
+
{ environment: "prod", buildOutput: plan, entityNames: [...ents.keys()], entities: ents, endpoint: ENDPOINT },
|
|
156
|
+
http,
|
|
157
|
+
);
|
|
158
|
+
const action = (name: string) => changeSet.entries.find((e) => e.name === name)?.action;
|
|
159
|
+
|
|
160
|
+
expect(action("web")).toBe("noop");
|
|
161
|
+
expect(action("web2")).toBe("create");
|
|
162
|
+
expect(action("orphan")).toBe("delete");
|
|
163
|
+
expect(action("legacy")).toBe("adopt"); // unmarked → adopt, never delete
|
|
164
|
+
// No entry, anywhere, deletes an unmarked machine.
|
|
165
|
+
expect(changeSet.entries.filter((e) => e.action === "delete").map((e) => e.name)).toEqual(["orphan"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("config drift on a declared machine → update (configEqual, matching apply)", async () => {
|
|
169
|
+
const plan = JSON.stringify({
|
|
170
|
+
app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP } },
|
|
171
|
+
web: {
|
|
172
|
+
endpoint: `/v1/apps/${APP}/machines`,
|
|
173
|
+
method: "POST",
|
|
174
|
+
body: { name: "web", config: { image: "nginx:2", metadata: { ...OWNED_META } } },
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
const ents = entities([
|
|
178
|
+
["app", "Fly::Machines::App"],
|
|
179
|
+
["web", "Fly::Machines::Machine"],
|
|
180
|
+
]);
|
|
181
|
+
const http = fakeHttp([liveMachine("web", "nginx:1")]); // live image nginx:1 ≠ declared nginx:2
|
|
182
|
+
const { changeSet } = await flyPlan(
|
|
183
|
+
{ environment: "prod", buildOutput: plan, entityNames: [...ents.keys()], entities: ents, endpoint: ENDPOINT },
|
|
184
|
+
http,
|
|
185
|
+
);
|
|
186
|
+
expect(changeSet.entries.find((e) => e.name === "web")?.action).toBe("update");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
Binary file
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template detection for the fly lexicon.
|
|
3
|
+
*
|
|
4
|
+
* `chant import` parses a template file as JSON and asks each installed lexicon
|
|
5
|
+
* whether it recognizes the shape. fly's importable source is flaps JSON — the
|
|
6
|
+
* Machines API surface the #738 serializer emits and the applier POSTs. Three
|
|
7
|
+
* shapes are recognized:
|
|
8
|
+
*
|
|
9
|
+
* 1. The serializer plan: an object keyed by entity name whose values are
|
|
10
|
+
* `{ endpoint, method, body }` flaps requests (what #738 produces).
|
|
11
|
+
* 2. A machine, a machines listing, or an app-with-machines bundle: what
|
|
12
|
+
* `GET /v1/apps/{app}/machines` returns, or a single machine create body.
|
|
13
|
+
* 3. An app: a create body (`{ app_name }`) or the `GET /v1/apps/{app}` shape.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
17
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A single flaps request as the serializer emits it. */
|
|
21
|
+
function isFlapsRequest(v: unknown): boolean {
|
|
22
|
+
if (!isRecord(v)) return false;
|
|
23
|
+
return typeof v.endpoint === "string" && v.endpoint.startsWith("/v1/apps") && "body" in v;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The serializer's whole output: entity name → flaps request. */
|
|
27
|
+
export function isSerializerPlan(v: unknown): boolean {
|
|
28
|
+
if (!isRecord(v)) return false;
|
|
29
|
+
const values = Object.values(v);
|
|
30
|
+
return values.length > 0 && values.every(isFlapsRequest);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A live or create-body machine: carries a `config` object, or the id+state+region GET shape. */
|
|
34
|
+
export function isMachineObject(v: unknown): boolean {
|
|
35
|
+
if (!isRecord(v)) return false;
|
|
36
|
+
if (isRecord(v.config)) return true;
|
|
37
|
+
return typeof v.id === "string" && typeof v.state === "string" && "region" in v;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An app: a create body (`app_name`) or the `GET /v1/apps/{app}` shape (`name` + machine_count/organization/status). */
|
|
41
|
+
export function isAppObject(v: unknown): boolean {
|
|
42
|
+
if (!isRecord(v)) return false;
|
|
43
|
+
if (typeof v.app_name === "string") return true;
|
|
44
|
+
return (
|
|
45
|
+
typeof v.name === "string" &&
|
|
46
|
+
("machine_count" in v || "organization" in v || typeof v.status === "string")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function detectTemplate(data: unknown): boolean {
|
|
51
|
+
if (isSerializerPlan(data)) return true;
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(data)) {
|
|
54
|
+
return data.length > 0 && data.some(isMachineObject);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (isRecord(data)) {
|
|
58
|
+
if (Array.isArray(data.machines)) return true;
|
|
59
|
+
if (isMachineObject(data)) return true;
|
|
60
|
+
if (isAppObject(data)) return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live export for the fly lexicon — implements `LexiconPlugin.exportResources()`
|
|
3
|
+
* so `chant import --from <fly-env>` regenerates existing Fly apps and machines
|
|
4
|
+
* as chant TypeScript.
|
|
5
|
+
*
|
|
6
|
+
* Reads live flaps state and hands it to the pure `./import/live-export`
|
|
7
|
+
* helpers, which strip server-written fields to the authored shape and map it to
|
|
8
|
+
* import IR via `FlyParser`. Endpoint + auth reuse the applier verbatim
|
|
9
|
+
* (`resolveEndpoint` / `defaultFlyHttp` → FLY_FLAPS_BASE_URL / FLY_API_TOKEN),
|
|
10
|
+
* and the machine listing reuses the applier's `listMachines`, so export reads
|
|
11
|
+
* the same target `flyApply` writes and `describeResources` observes.
|
|
12
|
+
*
|
|
13
|
+
* Which apps are exported: an app named by the selector, else `FLY_APP_NAME`,
|
|
14
|
+
* else every app in the org (`GET /v1/apps?org_slug=<FLY_ORG>`). All flaps I/O
|
|
15
|
+
* lives here; cleaning and IR-building are pure in `./import/live-export`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
|
|
19
|
+
import {
|
|
20
|
+
resolveEndpoint,
|
|
21
|
+
defaultFlyHttp,
|
|
22
|
+
listMachines,
|
|
23
|
+
type FlyHttp,
|
|
24
|
+
type ApplyCtx,
|
|
25
|
+
} from "./op/activities/fly-apply";
|
|
26
|
+
import { buildExportFromApp } from "./import/live-export";
|
|
27
|
+
|
|
28
|
+
const DEFAULT_ORG = "personal";
|
|
29
|
+
|
|
30
|
+
function parseJson(text: string): unknown {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function orgSlug(): string {
|
|
39
|
+
return process.env.FLY_ORG || process.env.FLY_ORG_SLUG || DEFAULT_ORG;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolve which apps to export: selector name → FLY_APP_NAME → org listing. */
|
|
43
|
+
async function resolveApps(
|
|
44
|
+
ctx: ApplyCtx,
|
|
45
|
+
selector: ResourceSelector | undefined,
|
|
46
|
+
http: FlyHttp,
|
|
47
|
+
signal?: AbortSignal,
|
|
48
|
+
): Promise<string[]> {
|
|
49
|
+
if (selector?.name) return [selector.name];
|
|
50
|
+
if (process.env.FLY_APP_NAME) return [process.env.FLY_APP_NAME];
|
|
51
|
+
|
|
52
|
+
const res = await http(
|
|
53
|
+
"GET",
|
|
54
|
+
`${ctx.base}/v1/apps?org_slug=${encodeURIComponent(orgSlug())}`,
|
|
55
|
+
undefined,
|
|
56
|
+
undefined,
|
|
57
|
+
signal,
|
|
58
|
+
);
|
|
59
|
+
if (res.status >= 300) return [];
|
|
60
|
+
const body = parseJson(res.text);
|
|
61
|
+
const apps = Array.isArray(body)
|
|
62
|
+
? body
|
|
63
|
+
: body && typeof body === "object" && Array.isArray((body as { apps?: unknown[] }).apps)
|
|
64
|
+
? (body as { apps: unknown[] }).apps
|
|
65
|
+
: [];
|
|
66
|
+
return apps
|
|
67
|
+
.map((a) => (a && typeof a === "object" ? (a as { name?: unknown }).name : undefined))
|
|
68
|
+
.filter((n): n is string => typeof n === "string");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function exportResources(
|
|
72
|
+
options: {
|
|
73
|
+
environment: string;
|
|
74
|
+
selector?: ResourceSelector;
|
|
75
|
+
owned?: boolean;
|
|
76
|
+
verbatim?: boolean;
|
|
77
|
+
},
|
|
78
|
+
http: FlyHttp = defaultFlyHttp(),
|
|
79
|
+
signal?: AbortSignal,
|
|
80
|
+
): Promise<ExportedTemplate> {
|
|
81
|
+
const ctx: ApplyCtx = { base: resolveEndpoint({}) };
|
|
82
|
+
const apps = await resolveApps(ctx, options.selector, http, signal);
|
|
83
|
+
if (apps.length === 0) return { resources: [], parameters: [] };
|
|
84
|
+
|
|
85
|
+
// The app-boundary log fires at most once per call, only under `owned`, when
|
|
86
|
+
// the marker-less app type is reached (mirrors describe-resources).
|
|
87
|
+
let loggedBoundary = false;
|
|
88
|
+
const onBoundaryInference = (): void => {
|
|
89
|
+
if (loggedBoundary) return;
|
|
90
|
+
loggedBoundary = true;
|
|
91
|
+
console.warn(
|
|
92
|
+
"[fly] apps carry no per-resource ownership marker — ownership is inferred at the app boundary (#741/#743); only apps with a chant-managed machine are exported under --owned",
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const resources: ExportedTemplate["resources"] = [];
|
|
97
|
+
for (const app of apps) {
|
|
98
|
+
const appRes = await http(
|
|
99
|
+
"GET",
|
|
100
|
+
`${ctx.base}/v1/apps/${encodeURIComponent(app)}`,
|
|
101
|
+
undefined,
|
|
102
|
+
undefined,
|
|
103
|
+
signal,
|
|
104
|
+
);
|
|
105
|
+
const appBody = appRes.status === 200 ? parseJson(appRes.text) : undefined;
|
|
106
|
+
const appObj =
|
|
107
|
+
appBody && typeof appBody === "object" && !Array.isArray(appBody)
|
|
108
|
+
? (appBody as Record<string, unknown>)
|
|
109
|
+
: { name: app };
|
|
110
|
+
|
|
111
|
+
const machines = await listMachines(ctx, app, http, signal);
|
|
112
|
+
|
|
113
|
+
const ir = buildExportFromApp(appObj, machines, {
|
|
114
|
+
verbatim: options.verbatim,
|
|
115
|
+
selector: options.selector,
|
|
116
|
+
owned: options.owned,
|
|
117
|
+
onBoundaryInference,
|
|
118
|
+
});
|
|
119
|
+
resources.push(...ir.resources);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { resources, parameters: [] };
|
|
123
|
+
}
|
|
File without changes
|