@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,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the fly post-synth checks. Not a check itself, so the
|
|
3
|
+
* generated barrel skips it (the scanner only picks up exported PostSynthChecks).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read an entity or property's constructor props. Nested Declarables
|
|
8
|
+
* (MachineConfig, MachineMount, Volume) stash their args under a non-enumerable
|
|
9
|
+
* `props`; a plain inline object carries them directly. Handle both so a check
|
|
10
|
+
* behaves the same whether the user authored `config: { image }` or
|
|
11
|
+
* `config: new MachineConfig({ image })`.
|
|
12
|
+
*/
|
|
13
|
+
export function readProps(value: unknown): Record<string, unknown> {
|
|
14
|
+
if (value && typeof value === "object") {
|
|
15
|
+
const nested = (value as { props?: unknown }).props;
|
|
16
|
+
if (nested && typeof nested === "object") return nested as Record<string, unknown>;
|
|
17
|
+
return value as Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The entityType of a declarable, or undefined. */
|
|
23
|
+
export function entityTypeOf(value: unknown): string | undefined {
|
|
24
|
+
return (value as { entityType?: string } | undefined)?.entityType;
|
|
25
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { Declarable } from "@intentius/chant";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { fly010 } from "./fly010-machine-requires-image";
|
|
5
|
+
import { App, Machine, MachineConfig } from "../../generated/index";
|
|
6
|
+
|
|
7
|
+
function ctx(...entries: Array<[string, unknown]>): PostSynthContext {
|
|
8
|
+
const entities = new Map(entries as Array<[string, Declarable]>);
|
|
9
|
+
return {
|
|
10
|
+
outputs: new Map(),
|
|
11
|
+
entities,
|
|
12
|
+
buildResult: { outputs: new Map(), entities, warnings: [], errors: [], sourceFileCount: 1 },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("FLY010: machine config requires an image", () => {
|
|
17
|
+
test("flags a machine whose config sets no image", () => {
|
|
18
|
+
const diags = fly010.check(
|
|
19
|
+
ctx(["web", new Machine({ config: new MachineConfig({ env: { PORT: "8080" } }) })]),
|
|
20
|
+
);
|
|
21
|
+
expect(diags).toHaveLength(1);
|
|
22
|
+
expect(diags[0].checkId).toBe("FLY010");
|
|
23
|
+
expect(diags[0].severity).toBe("error");
|
|
24
|
+
expect(diags[0].entity).toBe("web");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("passes a machine whose config sets an image (MachineConfig instance)", () => {
|
|
28
|
+
// The instance form nests its args under `.props`; the check must unwrap it
|
|
29
|
+
// rather than false-flagging a machine that does have an image.
|
|
30
|
+
const diags = fly010.check(
|
|
31
|
+
ctx(["web", new Machine({ config: new MachineConfig({ image: "nginx" }) })]),
|
|
32
|
+
);
|
|
33
|
+
expect(diags).toHaveLength(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("passes a machine whose config sets an image (plain inline object)", () => {
|
|
37
|
+
const diags = fly010.check(
|
|
38
|
+
ctx([
|
|
39
|
+
"web",
|
|
40
|
+
new Machine({ config: { image: "flyio/hellofly:latest" } as unknown as MachineConfig }),
|
|
41
|
+
]),
|
|
42
|
+
);
|
|
43
|
+
expect(diags).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("ignores non-machine entities and machines with no config", () => {
|
|
47
|
+
const diags = fly010.check(
|
|
48
|
+
ctx(
|
|
49
|
+
["app", new App({ name: "my-app" })],
|
|
50
|
+
["bare", new Machine({ name: "bare" })],
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
expect(diags).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("has the expected id", () => {
|
|
57
|
+
expect(fly010.id).toBe("FLY010");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLY010: Machine config must specify an image.
|
|
3
|
+
*
|
|
4
|
+
* A Machine cannot boot without a container image. The Machines API rejects a
|
|
5
|
+
* create request whose config omits `image`, so catch it at synth time with a
|
|
6
|
+
* clearer message.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { readProps, entityTypeOf } from "./fly-helpers";
|
|
11
|
+
|
|
12
|
+
export const fly010: PostSynthCheck = {
|
|
13
|
+
id: "FLY010",
|
|
14
|
+
description: "Machine config must specify an image — a Machine cannot boot without one",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [name, entity] of ctx.entities) {
|
|
20
|
+
if (entityTypeOf(entity) !== "Fly::Machines::Machine") continue;
|
|
21
|
+
|
|
22
|
+
const config = readProps(entity).config;
|
|
23
|
+
if (!config) continue; // no config authored — a different concern
|
|
24
|
+
|
|
25
|
+
const image = readProps(config).image;
|
|
26
|
+
if (typeof image !== "string" || image.length === 0) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "FLY010",
|
|
29
|
+
severity: "error",
|
|
30
|
+
message: `Machine "${name}" config has no image — set config.image, e.g. image: "flyio/fastify-functions"`,
|
|
31
|
+
entity: name,
|
|
32
|
+
lexicon: "fly",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return diagnostics;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { Declarable } from "@intentius/chant";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { fly011 } from "./fly011-mount-references-declared-volume";
|
|
5
|
+
import { App, Machine, MachineConfig, MachineMount, Volume } from "../../generated/index";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a whole-stack PostSynthContext from entities keyed by logical name.
|
|
9
|
+
* FLY011 only reads `ctx.entities`, so the rest of the context is minimal.
|
|
10
|
+
*/
|
|
11
|
+
function ctx(...entries: Array<[string, unknown]>): PostSynthContext {
|
|
12
|
+
const entities = new Map(entries as Array<[string, Declarable]>);
|
|
13
|
+
return {
|
|
14
|
+
outputs: new Map(),
|
|
15
|
+
entities,
|
|
16
|
+
buildResult: { outputs: new Map(), entities, warnings: [], errors: [], sourceFileCount: 1 },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("FLY011: mount references a declared volume", () => {
|
|
21
|
+
test("flags a machine mounting a volume declared nowhere in the stack", () => {
|
|
22
|
+
// Volume and machine are separate stack entities — the cross-file case.
|
|
23
|
+
const diags = fly011.check(
|
|
24
|
+
ctx(
|
|
25
|
+
["app", new App({ name: "my-app" })],
|
|
26
|
+
["data", new Volume({ name: "data", region: "iad", size_gb: 10 })],
|
|
27
|
+
[
|
|
28
|
+
"web",
|
|
29
|
+
new Machine({
|
|
30
|
+
config: new MachineConfig({
|
|
31
|
+
image: "nginx",
|
|
32
|
+
mounts: [new MachineMount({ volume: "cache", path: "/data" })],
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
expect(diags).toHaveLength(1);
|
|
39
|
+
expect(diags[0].checkId).toBe("FLY011");
|
|
40
|
+
expect(diags[0].severity).toBe("error");
|
|
41
|
+
expect(diags[0].entity).toBe("web");
|
|
42
|
+
expect(diags[0].message).toContain("cache");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("passes when the mount names a Volume declared in the stack", () => {
|
|
46
|
+
const diags = fly011.check(
|
|
47
|
+
ctx(
|
|
48
|
+
["data", new Volume({ name: "data", region: "iad", size_gb: 10 })],
|
|
49
|
+
[
|
|
50
|
+
"web",
|
|
51
|
+
new Machine({
|
|
52
|
+
config: new MachineConfig({
|
|
53
|
+
image: "nginx",
|
|
54
|
+
mounts: [new MachineMount({ volume: "data", path: "/data" })],
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
],
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
expect(diags).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("resolves a volume by its logical (declaration) name too", () => {
|
|
64
|
+
const diags = fly011.check(
|
|
65
|
+
ctx(
|
|
66
|
+
// No explicit `name` prop; the mount references the logical name.
|
|
67
|
+
["cache", new Volume({ region: "iad", size_gb: 3 })],
|
|
68
|
+
[
|
|
69
|
+
"web",
|
|
70
|
+
new Machine({
|
|
71
|
+
config: new MachineConfig({
|
|
72
|
+
image: "nginx",
|
|
73
|
+
mounts: [new MachineMount({ volume: "cache", path: "/data" })],
|
|
74
|
+
}),
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
expect(diags).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("does not flag a machine without mounts", () => {
|
|
83
|
+
const diags = fly011.check(
|
|
84
|
+
ctx(["web", new Machine({ config: new MachineConfig({ image: "nginx" }) })]),
|
|
85
|
+
);
|
|
86
|
+
expect(diags).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("has the expected id", () => {
|
|
90
|
+
expect(fly011.id).toBe("FLY011");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLY011: A machine mount must reference a declared volume.
|
|
3
|
+
*
|
|
4
|
+
* Every machine `config.mounts[].volume` must resolve to a `Volume` declared in
|
|
5
|
+
* the stack. A mount pointing at a volume that does not exist is rejected at
|
|
6
|
+
* apply time, so catch it at synth. This is a whole-stack check: it reads all
|
|
7
|
+
* entities in the build (`ctx.entities`), so the mount and the Volume can be
|
|
8
|
+
* declared in different files.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { readProps, entityTypeOf } from "./fly-helpers";
|
|
13
|
+
|
|
14
|
+
const MACHINE_ENTITY_TYPE = "Fly::Machines::Machine";
|
|
15
|
+
const VOLUME_ENTITY_TYPE = "Fly::Machines::Volume";
|
|
16
|
+
|
|
17
|
+
export const fly011: PostSynthCheck = {
|
|
18
|
+
id: "FLY011",
|
|
19
|
+
description: "A machine mount's volume must resolve to a Volume declared in the stack",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
// A mount can name a declared volume by the Volume's `name` or by its
|
|
25
|
+
// logical (declaration) name — collect both.
|
|
26
|
+
const declaredVolumes = new Set<string>();
|
|
27
|
+
for (const [logicalName, entity] of ctx.entities) {
|
|
28
|
+
if (entityTypeOf(entity) !== VOLUME_ENTITY_TYPE) continue;
|
|
29
|
+
declaredVolumes.add(logicalName);
|
|
30
|
+
const name = readProps(entity).name;
|
|
31
|
+
if (typeof name === "string" && name.length > 0) declaredVolumes.add(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const [machineName, entity] of ctx.entities) {
|
|
35
|
+
if (entityTypeOf(entity) !== MACHINE_ENTITY_TYPE) continue;
|
|
36
|
+
const config = readProps(entity).config;
|
|
37
|
+
if (!config) continue;
|
|
38
|
+
const mounts = readProps(config).mounts;
|
|
39
|
+
if (!Array.isArray(mounts)) continue;
|
|
40
|
+
|
|
41
|
+
for (const mount of mounts) {
|
|
42
|
+
const volume = readProps(mount).volume;
|
|
43
|
+
// Only a string names a volume by value. A Declarable/AttrRef is a live
|
|
44
|
+
// reference to a Volume that exists in the stack by construction, so it
|
|
45
|
+
// never dangles — skip it to avoid false positives.
|
|
46
|
+
if (typeof volume !== "string" || volume.length === 0) continue;
|
|
47
|
+
if (!declaredVolumes.has(volume)) {
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
checkId: "FLY011",
|
|
50
|
+
severity: "error",
|
|
51
|
+
message: `Machine "${machineName}" mounts volume "${volume}", which is not declared as a Volume in the stack.`,
|
|
52
|
+
entity: machineName,
|
|
53
|
+
lexicon: "fly",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return diagnostics;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Code generated by chant generate. DO NOT EDIT.
|
|
2
|
+
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { fly010 } from "./fly010-machine-requires-image";
|
|
4
|
+
import { fly011 } from "./fly011-mount-references-declared-volume";
|
|
5
|
+
|
|
6
|
+
export const postSynthChecks: PostSynthCheck[] = [
|
|
7
|
+
fly010,
|
|
8
|
+
fly011,
|
|
9
|
+
];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fly guest presets, per `cpu_kind`.
|
|
6
|
+
*
|
|
7
|
+
* `cpus` is the set of valid CPU counts; `memory_mb` must be a multiple of 256
|
|
8
|
+
* within [minPerCpu * cpus, maxPerCpu * cpus]. Values mirror Fly's published
|
|
9
|
+
* guest presets (shared-cpu-Nx / performance-Nx): shared allows 256–2048 MB per
|
|
10
|
+
* CPU, performance allows 2048–8192 MB per CPU. The 256 MB step is the loosest
|
|
11
|
+
* safe multiple, so a valid config is never flagged; only clearly-invalid combos
|
|
12
|
+
* are. fly-go was not vendored in this tree to cross-check against, so this table
|
|
13
|
+
* is a documented representative set (a limitation, widen if Fly changes presets).
|
|
14
|
+
*/
|
|
15
|
+
const GUEST_PRESETS: Record<string, { cpus: Set<number>; minPerCpu: number; maxPerCpu: number }> = {
|
|
16
|
+
shared: { cpus: new Set([1, 2, 4, 8]), minPerCpu: 256, maxPerCpu: 2048 },
|
|
17
|
+
performance: { cpus: new Set([1, 2, 4, 8, 16]), minPerCpu: 2048, maxPerCpu: 8192 },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MEMORY_STEP_MB = 256;
|
|
21
|
+
|
|
22
|
+
/** Read a numeric property from an object literal, if present as a plain number literal. */
|
|
23
|
+
function numberProp(obj: ts.ObjectLiteralExpression, key: string, sf: ts.SourceFile): number | undefined {
|
|
24
|
+
for (const prop of obj.properties) {
|
|
25
|
+
if (ts.isPropertyAssignment(prop) && prop.name.getText(sf) === key && ts.isNumericLiteral(prop.initializer)) {
|
|
26
|
+
return Number(prop.initializer.text);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read a string property from an object literal, if present as a plain string literal. */
|
|
33
|
+
function stringProp(obj: ts.ObjectLiteralExpression, key: string, sf: ts.SourceFile): string | undefined {
|
|
34
|
+
for (const prop of obj.properties) {
|
|
35
|
+
if (ts.isPropertyAssignment(prop) && prop.name.getText(sf) === key && ts.isStringLiteral(prop.initializer)) {
|
|
36
|
+
return prop.initializer.text;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* FLY002: Sane guest sizing
|
|
44
|
+
*
|
|
45
|
+
* A guest's `cpu_kind`, `cpus`, and `memory_mb` must be a valid combination per
|
|
46
|
+
* Fly's guest presets. An invalid combination is rejected at apply time.
|
|
47
|
+
*/
|
|
48
|
+
export const guestSizingRule: LintRule = {
|
|
49
|
+
id: "FLY002",
|
|
50
|
+
severity: "error",
|
|
51
|
+
category: "correctness",
|
|
52
|
+
description: "A guest's cpu_kind, cpus and memory_mb must be a valid Fly combination",
|
|
53
|
+
|
|
54
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
55
|
+
const { sourceFile } = context;
|
|
56
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
57
|
+
|
|
58
|
+
function report(node: ts.Node, message: string): void {
|
|
59
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
60
|
+
diagnostics.push({
|
|
61
|
+
file: sourceFile.fileName,
|
|
62
|
+
line: line + 1,
|
|
63
|
+
column: character + 1,
|
|
64
|
+
ruleId: "FLY002",
|
|
65
|
+
severity: "error",
|
|
66
|
+
message,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function visit(node: ts.Node): void {
|
|
71
|
+
// A guest is any object literal carrying a cpu_kind property (new MachineGuest({...})
|
|
72
|
+
// or an inline guest object).
|
|
73
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
74
|
+
const cpuKind = stringProp(node, "cpu_kind", sourceFile);
|
|
75
|
+
if (cpuKind !== undefined) {
|
|
76
|
+
const preset = GUEST_PRESETS[cpuKind];
|
|
77
|
+
if (!preset) {
|
|
78
|
+
report(node, `Invalid guest cpu_kind "${cpuKind}". Use "shared" or "performance".`);
|
|
79
|
+
} else {
|
|
80
|
+
const cpus = numberProp(node, "cpus", sourceFile);
|
|
81
|
+
const memoryMb = numberProp(node, "memory_mb", sourceFile);
|
|
82
|
+
if (cpus !== undefined && !preset.cpus.has(cpus)) {
|
|
83
|
+
report(node, `Invalid guest sizing: cpu_kind "${cpuKind}" does not allow ${cpus} cpus (valid: ${[...preset.cpus].join(", ")}).`);
|
|
84
|
+
} else if (cpus !== undefined && memoryMb !== undefined) {
|
|
85
|
+
const min = preset.minPerCpu * cpus;
|
|
86
|
+
const max = preset.maxPerCpu * cpus;
|
|
87
|
+
if (memoryMb % MEMORY_STEP_MB !== 0 || memoryMb < min || memoryMb > max) {
|
|
88
|
+
report(node, `Invalid guest sizing: ${cpuKind}/${cpus} cpu requires memory_mb between ${min} and ${max} in steps of ${MEMORY_STEP_MB}, got ${memoryMb}.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
ts.forEachChild(node, visit);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
visit(sourceFile);
|
|
98
|
+
return diagnostics;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
2
|
+
import { validRegionRule } from "./valid-region";
|
|
3
|
+
import { guestSizingRule } from "./guest-sizing";
|
|
4
|
+
import { noSecretLiteralsRule } from "./no-secret-literals";
|
|
5
|
+
|
|
6
|
+
export { validRegionRule } from "./valid-region";
|
|
7
|
+
export { guestSizingRule } from "./guest-sizing";
|
|
8
|
+
export { noSecretLiteralsRule } from "./no-secret-literals";
|
|
9
|
+
|
|
10
|
+
/** All lint rules provided by the fly lexicon. */
|
|
11
|
+
export const rules: LintRule[] = [
|
|
12
|
+
validRegionRule,
|
|
13
|
+
guestSizingRule,
|
|
14
|
+
noSecretLiteralsRule,
|
|
15
|
+
];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Property names that carry a credential. Kept conservative on purpose — an
|
|
6
|
+
* inline value under one of these keys is very likely a real secret.
|
|
7
|
+
*/
|
|
8
|
+
const SECRET_KEY_PATTERN = /(?:^|[_-])(?:password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|credential|auth[_-]?token)(?:$|[_-])/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Values that are references or placeholders, not literal secrets:
|
|
12
|
+
* shell/interpolation references ($FOO, ${FOO}), and secret-manager style
|
|
13
|
+
* references. These are skipped so a `secrets` reference is never flagged.
|
|
14
|
+
*/
|
|
15
|
+
const REFERENCE_VALUE_PATTERN = /^(?:\$\{?[A-Za-z0-9_]+\}?|(?:secret|ref|env|vault):\S+)$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* FLY004: No secret literals in machine config
|
|
19
|
+
*
|
|
20
|
+
* Flags secret values written inline in config (e.g. an env value under a
|
|
21
|
+
* credential-looking key). Secrets belong in `secrets` (apply-only) or a
|
|
22
|
+
* reference. The heuristic is conservative: it fires only when the property
|
|
23
|
+
* name looks like a credential AND the value is a plain string literal that is
|
|
24
|
+
* not a reference/placeholder, so a `secrets` reference (an identifier or
|
|
25
|
+
* `$FOO` placeholder) is not flagged.
|
|
26
|
+
*/
|
|
27
|
+
export const noSecretLiteralsRule: LintRule = {
|
|
28
|
+
id: "FLY004",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
category: "security",
|
|
31
|
+
description: "Secret values must not be written inline — use secrets or a reference",
|
|
32
|
+
|
|
33
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
34
|
+
const { sourceFile } = context;
|
|
35
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
36
|
+
|
|
37
|
+
function visit(node: ts.Node): void {
|
|
38
|
+
if (ts.isPropertyAssignment(node)) {
|
|
39
|
+
const rawName = node.name.getText(sourceFile).replace(/^["']|["']$/g, "");
|
|
40
|
+
const init = node.initializer;
|
|
41
|
+
const literal =
|
|
42
|
+
ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init) ? init.text : undefined;
|
|
43
|
+
|
|
44
|
+
if (
|
|
45
|
+
literal !== undefined &&
|
|
46
|
+
literal.length >= 4 &&
|
|
47
|
+
SECRET_KEY_PATTERN.test(rawName) &&
|
|
48
|
+
!REFERENCE_VALUE_PATTERN.test(literal.trim())
|
|
49
|
+
) {
|
|
50
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(init.getStart(sourceFile));
|
|
51
|
+
diagnostics.push({
|
|
52
|
+
file: sourceFile.fileName,
|
|
53
|
+
line: line + 1,
|
|
54
|
+
column: character + 1,
|
|
55
|
+
ruleId: "FLY004",
|
|
56
|
+
severity: "warning",
|
|
57
|
+
message: `Possible inline secret under "${rawName}". Move the value to \`secrets\` (apply-only) or use a reference instead of an inline literal.`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
ts.forEachChild(node, visit);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
visit(sourceFile);
|
|
65
|
+
return diagnostics;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import { validRegionRule } from "./valid-region";
|
|
4
|
+
import { guestSizingRule } from "./guest-sizing";
|
|
5
|
+
import { noSecretLiteralsRule } from "./no-secret-literals";
|
|
6
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
7
|
+
|
|
8
|
+
function createContext(code: string, fileName = "infra.ts"): LintContext {
|
|
9
|
+
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
|
|
10
|
+
return { sourceFile, entities: [], filePath: fileName };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("Fly lint rules", () => {
|
|
14
|
+
describe("FLY001: valid region", () => {
|
|
15
|
+
test("flags an unknown region", () => {
|
|
16
|
+
const ctx = createContext(`new Machine({ region: "narnia", config: new MachineConfig({ image: "nginx" }) });`);
|
|
17
|
+
const diags = validRegionRule.check(ctx);
|
|
18
|
+
expect(diags).toHaveLength(1);
|
|
19
|
+
expect(diags[0].ruleId).toBe("FLY001");
|
|
20
|
+
expect(diags[0].severity).toBe("error");
|
|
21
|
+
expect(diags[0].message).toContain("narnia");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("passes a known region", () => {
|
|
25
|
+
const ctx = createContext(`new Machine({ region: "iad", config: new MachineConfig({ image: "nginx" }) });`);
|
|
26
|
+
expect(validRegionRule.check(ctx)).toHaveLength(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("passes a known region on a Volume", () => {
|
|
30
|
+
const ctx = createContext(`new Volume({ name: "data", region: "lhr", size_gb: 10 });`);
|
|
31
|
+
expect(validRegionRule.check(ctx)).toHaveLength(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("ignores a non-literal region (reference/expression)", () => {
|
|
35
|
+
const ctx = createContext(`new Machine({ region: Fly.Region });`);
|
|
36
|
+
expect(validRegionRule.check(ctx)).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("has correct metadata", () => {
|
|
40
|
+
expect(validRegionRule.id).toBe("FLY001");
|
|
41
|
+
expect(validRegionRule.severity).toBe("error");
|
|
42
|
+
expect(validRegionRule.category).toBe("correctness");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("FLY002: sane guest sizing", () => {
|
|
47
|
+
test("passes a valid shared combo", () => {
|
|
48
|
+
const ctx = createContext(`new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 });`);
|
|
49
|
+
expect(guestSizingRule.check(ctx)).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("passes a valid performance combo", () => {
|
|
53
|
+
const ctx = createContext(`new MachineGuest({ cpu_kind: "performance", cpus: 2, memory_mb: 4096 });`);
|
|
54
|
+
expect(guestSizingRule.check(ctx)).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("flags an invalid cpu_kind", () => {
|
|
58
|
+
const ctx = createContext(`new MachineGuest({ cpu_kind: "turbo", cpus: 1, memory_mb: 256 });`);
|
|
59
|
+
const diags = guestSizingRule.check(ctx);
|
|
60
|
+
expect(diags).toHaveLength(1);
|
|
61
|
+
expect(diags[0].ruleId).toBe("FLY002");
|
|
62
|
+
expect(diags[0].message).toContain("turbo");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("flags an invalid cpu count for the kind", () => {
|
|
66
|
+
const ctx = createContext(`new MachineGuest({ cpu_kind: "shared", cpus: 3, memory_mb: 512 });`);
|
|
67
|
+
const diags = guestSizingRule.check(ctx);
|
|
68
|
+
expect(diags).toHaveLength(1);
|
|
69
|
+
expect(diags[0].message).toContain("3 cpus");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("flags a memory value out of range for the cpu count", () => {
|
|
73
|
+
const ctx = createContext(`new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 8192 });`);
|
|
74
|
+
const diags = guestSizingRule.check(ctx);
|
|
75
|
+
expect(diags).toHaveLength(1);
|
|
76
|
+
expect(diags[0].message).toContain("memory_mb");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("flags a memory value that is not a 256 MB multiple", () => {
|
|
80
|
+
const ctx = createContext(`new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 300 });`);
|
|
81
|
+
expect(guestSizingRule.check(ctx)).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("has correct metadata", () => {
|
|
85
|
+
expect(guestSizingRule.id).toBe("FLY002");
|
|
86
|
+
expect(guestSizingRule.severity).toBe("error");
|
|
87
|
+
expect(guestSizingRule.category).toBe("correctness");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("FLY004: no secret literals", () => {
|
|
92
|
+
test("flags an inline secret literal under a credential key", () => {
|
|
93
|
+
const ctx = createContext(`new MachineConfig({ image: "nginx", env: { DB_PASSWORD: "s3cr3t-p@ss" } });`);
|
|
94
|
+
const diags = noSecretLiteralsRule.check(ctx);
|
|
95
|
+
expect(diags).toHaveLength(1);
|
|
96
|
+
expect(diags[0].ruleId).toBe("FLY004");
|
|
97
|
+
expect(diags[0].severity).toBe("warning");
|
|
98
|
+
expect(diags[0].message).toContain("DB_PASSWORD");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("flags an inline api key", () => {
|
|
102
|
+
const ctx = createContext(`new MachineConfig({ env: { STRIPE_API_KEY: "sk_live_abcdef123456" } });`);
|
|
103
|
+
expect(noSecretLiteralsRule.check(ctx)).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("does not flag a reference to a secret", () => {
|
|
107
|
+
const ctx = createContext(`new MachineConfig({ env: { DB_PASSWORD: dbSecret } });`);
|
|
108
|
+
expect(noSecretLiteralsRule.check(ctx)).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("does not flag an interpolation/placeholder value", () => {
|
|
112
|
+
const ctx = createContext(`new MachineConfig({ env: { DB_PASSWORD: "$DB_PASSWORD" } });`);
|
|
113
|
+
expect(noSecretLiteralsRule.check(ctx)).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("does not flag non-credential keys", () => {
|
|
117
|
+
const ctx = createContext(`new MachineConfig({ env: { LOG_LEVEL: "info" } });`);
|
|
118
|
+
expect(noSecretLiteralsRule.check(ctx)).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("has correct metadata", () => {
|
|
122
|
+
expect(noSecretLiteralsRule.id).toBe("FLY004");
|
|
123
|
+
expect(noSecretLiteralsRule.severity).toBe("warning");
|
|
124
|
+
expect(noSecretLiteralsRule.category).toBe("security");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Known Fly.io region codes.
|
|
6
|
+
*
|
|
7
|
+
* Sourced from the representative region list mudflaps serves at
|
|
8
|
+
* `GET /v1/platform/regions` (mudflaps `internal/server/regions.go`), which
|
|
9
|
+
* mirrors fly-go's `GetRegions` wire shape. The generated Fly types (#737)
|
|
10
|
+
* type `region` as a plain `string`, so there is no generated enum to import;
|
|
11
|
+
* this static set is the closest stable list a unit test can check without a
|
|
12
|
+
* live platform. It is representative, not exhaustive — add codes as Fly adds
|
|
13
|
+
* regions.
|
|
14
|
+
*/
|
|
15
|
+
const KNOWN_REGIONS = new Set<string>([
|
|
16
|
+
"ams", "atl", "bog", "bos", "cdg", "den", "dfw", "ewr", "fra", "gru",
|
|
17
|
+
"hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "scl",
|
|
18
|
+
"sea", "sin", "sjc", "syd", "yyz",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* FLY001: Valid Fly region
|
|
23
|
+
*
|
|
24
|
+
* A machine (or volume/IP) `region` must be a known Fly region code. An unknown
|
|
25
|
+
* region is rejected at apply time; catch it at build with a clearer message.
|
|
26
|
+
*/
|
|
27
|
+
export const validRegionRule: LintRule = {
|
|
28
|
+
id: "FLY001",
|
|
29
|
+
severity: "error",
|
|
30
|
+
category: "correctness",
|
|
31
|
+
description: "A region must be a known Fly region code",
|
|
32
|
+
|
|
33
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
34
|
+
const { sourceFile } = context;
|
|
35
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
36
|
+
|
|
37
|
+
function visit(node: ts.Node): void {
|
|
38
|
+
if (ts.isPropertyAssignment(node) && node.name.getText(sourceFile) === "region") {
|
|
39
|
+
const init = node.initializer;
|
|
40
|
+
if (ts.isStringLiteral(init) && init.text.length > 0 && !KNOWN_REGIONS.has(init.text)) {
|
|
41
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(init.getStart(sourceFile));
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
file: sourceFile.fileName,
|
|
44
|
+
line: line + 1,
|
|
45
|
+
column: character + 1,
|
|
46
|
+
ruleId: "FLY001",
|
|
47
|
+
severity: "error",
|
|
48
|
+
message: `Unknown Fly region "${init.text}". Use a known region code, e.g. iad, lhr, sjc, fra, syd.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
ts.forEachChild(node, visit);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
visit(sourceFile);
|
|
56
|
+
return diagnostics;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { completions } from "./completions";
|
|
3
|
+
|
|
4
|
+
describe("LSP completions", () => {
|
|
5
|
+
it("returns an array", () => {
|
|
6
|
+
// TODO: Replace with a real CompletionContext
|
|
7
|
+
const result = completions({} as any);
|
|
8
|
+
expect(Array.isArray(result)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
});
|