@intentius/chant-lexicon-fly 0.17.0 → 0.18.1
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/dist/composites/fly-deploy.d.ts +1 -1
- package/dist/composites/fly-deploy.d.ts.map +1 -1
- package/dist/emulator-freshness-cli.d.ts +11 -0
- package/dist/emulator-freshness-cli.d.ts.map +1 -0
- package/dist/emulator-freshness.d.ts +38 -0
- package/dist/emulator-freshness.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/op/activities/emulator-images.d.ts +20 -0
- package/dist/op/activities/emulator-images.d.ts.map +1 -0
- package/dist/op/activities/flaps.d.ts +1 -1
- package/dist/op/activities/flaps.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machines-contract.d.ts +36 -0
- package/dist/op/activities/machines-contract.d.ts.map +1 -0
- package/dist/op/activities/sprites-contract.d.ts +45 -0
- package/dist/op/activities/sprites-contract.d.ts.map +1 -0
- package/dist/op/activities/sprites-emulator.d.ts +29 -0
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +62 -0
- package/dist/op/activities/sprites-fake.d.ts.map +1 -0
- package/dist/op/activities/sprites.d.ts +195 -0
- package/dist/op/activities/sprites.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/composites/fly-deploy.ts +1 -1
- package/src/emulator-freshness-cli.ts +49 -0
- package/src/emulator-freshness.test.ts +86 -0
- package/src/emulator-freshness.ts +87 -0
- package/src/index.ts +15 -0
- package/src/op/activities/emulator-images.ts +21 -0
- package/src/op/activities/flaps.test.ts +2 -1
- package/src/op/activities/flaps.ts +3 -2
- package/src/op/activities/index.ts +53 -0
- package/src/op/activities/machines-contract.docker.integration.test.ts +72 -0
- package/src/op/activities/machines-contract.test.ts +49 -0
- package/src/op/activities/machines-contract.ts +73 -0
- package/src/op/activities/sprites-contract.docker.integration.test.ts +74 -0
- package/src/op/activities/sprites-contract.test.ts +60 -0
- package/src/op/activities/sprites-contract.ts +61 -0
- package/src/op/activities/sprites-emulator.ts +46 -0
- package/src/op/activities/sprites-fake.ts +314 -0
- package/src/op/activities/sprites.docker.integration.test.ts +99 -0
- package/src/op/activities/sprites.integration.test.ts +158 -0
- package/src/op/activities/sprites.real.test.ts +56 -0
- package/src/op/activities/sprites.test.ts +296 -0
- package/src/op/activities/sprites.ts +527 -0
- package/src/plugin.ts +13 -0
- package/src/skills/chant-fly-sprites.md +104 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI for the emulator-freshness check (#808 T2). Fetches the latest mudflaps /
|
|
3
|
+
* spritzer releases, compares to the pinned tags, prints a report, and — when
|
|
4
|
+
* run in CI — writes `behind` + a Markdown `body` to `$GITHUB_OUTPUT` so the
|
|
5
|
+
* weekly workflow can open/refresh a single "N releases behind" notice issue.
|
|
6
|
+
*
|
|
7
|
+
* Advisory only: exits 0 whether or not a pin is behind (the bump is a human
|
|
8
|
+
* decision per the #808 policy). A hard failure (network/API) exits 1.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFileSync } from "node:fs";
|
|
12
|
+
import { checkFreshness, formatResult, type FreshnessResult } from "./emulator-freshness";
|
|
13
|
+
|
|
14
|
+
function issueBody(behind: FreshnessResult[]): string {
|
|
15
|
+
const rows = behind.map((r) => `- **${r.name}** — pinned \`${r.pinned}\`, latest \`${r.latest}\``).join("\n");
|
|
16
|
+
return [
|
|
17
|
+
"The pinned Fly emulator image(s) are behind their latest upstream release:",
|
|
18
|
+
"",
|
|
19
|
+
rows,
|
|
20
|
+
"",
|
|
21
|
+
"The tag lives in `lexicons/fly/src/op/activities/emulator-images.ts` (single source).",
|
|
22
|
+
"",
|
|
23
|
+
"Per the #808 bump policy this is **advisory** — move the pin only when a consuming",
|
|
24
|
+
"test needs the newer emulator (a fidelity fix the fly activities exercise), not on",
|
|
25
|
+
"every release. Close this issue once reviewed or bumped.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main(): Promise<void> {
|
|
30
|
+
const results = await checkFreshness();
|
|
31
|
+
for (const r of results) console.error(formatResult(r));
|
|
32
|
+
|
|
33
|
+
const behind = results.filter((r) => r.behind);
|
|
34
|
+
console.log(JSON.stringify({ behind: behind.length > 0, results }, null, 2));
|
|
35
|
+
|
|
36
|
+
const out = process.env.GITHUB_OUTPUT;
|
|
37
|
+
if (out) {
|
|
38
|
+
appendFileSync(out, `behind=${behind.length > 0}\n`);
|
|
39
|
+
if (behind.length > 0) {
|
|
40
|
+
// Multiline output via the GITHUB_OUTPUT heredoc form.
|
|
41
|
+
appendFileSync(out, `body<<FRESHNESS_EOF\n${issueBody(behind)}\nFRESHNESS_EOF\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
main().catch((err: unknown) => {
|
|
47
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseVersion,
|
|
4
|
+
compare,
|
|
5
|
+
latestRelease,
|
|
6
|
+
checkFreshness,
|
|
7
|
+
formatResult,
|
|
8
|
+
EMULATOR_PINS,
|
|
9
|
+
} from "./emulator-freshness";
|
|
10
|
+
|
|
11
|
+
describe("parseVersion", () => {
|
|
12
|
+
test("extracts the version from a ghcr image ref, stripping a leading v", () => {
|
|
13
|
+
expect(parseVersion("ghcr.io/intentius/mudflaps:0.3.1")).toBe("0.3.1");
|
|
14
|
+
expect(parseVersion("ghcr.io/intentius/spritzer:v1.2.0")).toBe("1.2.0");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("compare", () => {
|
|
19
|
+
test("not behind when pinned equals latest", () => {
|
|
20
|
+
expect(compare("mudflaps", "0.3.1", "v0.3.1").behind).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
test("behind when latest is a newer patch/minor/major", () => {
|
|
23
|
+
expect(compare("m", "0.3.1", "v0.3.2").behind).toBe(true);
|
|
24
|
+
expect(compare("m", "0.3.1", "v0.4.0").behind).toBe(true);
|
|
25
|
+
expect(compare("m", "0.3.1", "v1.0.0").behind).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
test("not behind when pinned is ahead of latest", () => {
|
|
28
|
+
expect(compare("m", "0.4.0", "v0.3.9").behind).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
test("normalizes the leading v out of the reported versions", () => {
|
|
31
|
+
const r = compare("m", "0.3.1", "v0.3.2");
|
|
32
|
+
expect(r.pinned).toBe("0.3.1");
|
|
33
|
+
expect(r.latest).toBe("0.3.2");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("EMULATOR_PINS", () => {
|
|
38
|
+
test("tracks mudflaps and spritzer from the single-source image constants", () => {
|
|
39
|
+
expect(EMULATOR_PINS.map((p) => p.name).sort()).toEqual(["mudflaps", "spritzer"]);
|
|
40
|
+
for (const p of EMULATOR_PINS) {
|
|
41
|
+
expect(p.repo).toMatch(/^intentius\//);
|
|
42
|
+
expect(p.pinned).toMatch(/^\d+\.\d+\.\d+$/);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("latestRelease + checkFreshness (mocked fetch)", () => {
|
|
48
|
+
const mkFetch = (tags: Record<string, string>): typeof fetch =>
|
|
49
|
+
(async (url: string | URL | Request) => {
|
|
50
|
+
const u = String(url);
|
|
51
|
+
const repo = u.match(/repos\/([^/]+\/[^/]+)\/releases/)?.[1] ?? "";
|
|
52
|
+
return { ok: true, status: 200, json: async () => ({ tag_name: tags[repo] }) } as Response;
|
|
53
|
+
}) as unknown as typeof fetch;
|
|
54
|
+
|
|
55
|
+
test("latestRelease returns the tag_name", async () => {
|
|
56
|
+
const f = mkFetch({ "intentius/mudflaps": "v0.9.0" });
|
|
57
|
+
expect(await latestRelease("intentius/mudflaps", f)).toBe("v0.9.0");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("checkFreshness flags a pin behind its latest release", async () => {
|
|
61
|
+
// Force both upstreams to a high version so the check reports behind
|
|
62
|
+
// regardless of the currently-pinned tag.
|
|
63
|
+
const f = mkFetch({ "intentius/mudflaps": "v99.0.0", "intentius/spritzer": "v99.0.0" });
|
|
64
|
+
const results = await checkFreshness(f);
|
|
65
|
+
expect(results).toHaveLength(2);
|
|
66
|
+
expect(results.every((r) => r.behind)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("checkFreshness reports current when latest matches the pin", async () => {
|
|
70
|
+
const pins = Object.fromEntries(EMULATOR_PINS.map((p) => [p.repo, `v${p.pinned}`]));
|
|
71
|
+
const results = await checkFreshness(mkFetch(pins));
|
|
72
|
+
expect(results.every((r) => !r.behind)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("latestRelease throws on a non-ok response", async () => {
|
|
76
|
+
const f = (async () => ({ ok: false, status: 404 }) as Response) as unknown as typeof fetch;
|
|
77
|
+
await expect(latestRelease("intentius/nope", f)).rejects.toThrow(/HTTP 404/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("formatResult", () => {
|
|
82
|
+
test("marks behind vs current distinctly", () => {
|
|
83
|
+
expect(formatResult({ name: "m", pinned: "0.3.1", latest: "0.4.0", behind: true })).toMatch(/behind/);
|
|
84
|
+
expect(formatResult({ name: "m", pinned: "0.3.1", latest: "0.3.1", behind: false })).toMatch(/current/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emulator-freshness check (#808 T2, second half).
|
|
3
|
+
*
|
|
4
|
+
* mudflaps (Machines) and spritzer (Sprites) are pinned to a single source
|
|
5
|
+
* (./op/activities/emulator-images.ts). Their upstream repos cut GitHub releases,
|
|
6
|
+
* so this compares each pinned tag against the latest release and reports how far
|
|
7
|
+
* behind it is. A weekly workflow surfaces a "N releases behind" notice — never
|
|
8
|
+
* an auto-bump. Per the bump policy (#808), the tag moves only when a consuming
|
|
9
|
+
* test needs the newer emulator, so this is advisory, not gating on the pin.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { MUDFLAPS_IMAGE, SPRITZER_IMAGE } from "./op/activities/emulator-images";
|
|
13
|
+
|
|
14
|
+
export interface EmulatorPin {
|
|
15
|
+
/** Short name, e.g. "mudflaps". */
|
|
16
|
+
name: string;
|
|
17
|
+
/** GitHub repo "owner/name" whose releases publish the emulator. */
|
|
18
|
+
repo: string;
|
|
19
|
+
/** Pinned version, no leading "v" (e.g. "0.3.1"). */
|
|
20
|
+
pinned: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FreshnessResult {
|
|
24
|
+
name: string;
|
|
25
|
+
pinned: string;
|
|
26
|
+
latest: string;
|
|
27
|
+
/** True when the latest release is newer than the pinned version. */
|
|
28
|
+
behind: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parse the version tag from a ghcr image ref (".../mudflaps:0.3.1" → "0.3.1"). */
|
|
32
|
+
export function parseVersion(image: string): string {
|
|
33
|
+
const tag = image.slice(image.lastIndexOf(":") + 1);
|
|
34
|
+
return tag.replace(/^v/, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toParts(v: string): number[] {
|
|
38
|
+
return v.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compare pinned vs latest as dotted numeric versions; `behind` when latest > pinned. */
|
|
42
|
+
export function compare(name: string, pinned: string, latest: string): FreshnessResult {
|
|
43
|
+
const p = toParts(pinned);
|
|
44
|
+
const l = toParts(latest);
|
|
45
|
+
let behind = false;
|
|
46
|
+
for (let i = 0; i < Math.max(p.length, l.length); i++) {
|
|
47
|
+
const a = p[i] ?? 0;
|
|
48
|
+
const b = l[i] ?? 0;
|
|
49
|
+
if (b > a) {
|
|
50
|
+
behind = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (b < a) break;
|
|
54
|
+
}
|
|
55
|
+
return { name, pinned: pinned.replace(/^v/, ""), latest: latest.replace(/^v/, ""), behind };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The emulator pins to check, read from the single-source image constants. */
|
|
59
|
+
export const EMULATOR_PINS: readonly EmulatorPin[] = [
|
|
60
|
+
{ name: "mudflaps", repo: "intentius/mudflaps", pinned: parseVersion(MUDFLAPS_IMAGE) },
|
|
61
|
+
{ name: "spritzer", repo: "intentius/spritzer", pinned: parseVersion(SPRITZER_IMAGE) },
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
/** Fetch the latest release tag for a repo via the GitHub REST API. */
|
|
65
|
+
export async function latestRelease(repo: string, fetchImpl: typeof fetch = fetch): Promise<string> {
|
|
66
|
+
const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
|
|
67
|
+
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
68
|
+
const res = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, { headers });
|
|
69
|
+
if (!res.ok) throw new Error(`releases/latest ${repo}: HTTP ${res.status}`);
|
|
70
|
+
const body = (await res.json()) as { tag_name?: string };
|
|
71
|
+
if (!body.tag_name) throw new Error(`releases/latest ${repo}: no tag_name`);
|
|
72
|
+
return body.tag_name;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Check every emulator pin against its latest release. */
|
|
76
|
+
export async function checkFreshness(fetchImpl: typeof fetch = fetch): Promise<FreshnessResult[]> {
|
|
77
|
+
return Promise.all(
|
|
78
|
+
EMULATOR_PINS.map(async (p) => compare(p.name, p.pinned, await latestRelease(p.repo, fetchImpl))),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One-line human summary of a result. */
|
|
83
|
+
export function formatResult(r: FreshnessResult): string {
|
|
84
|
+
return r.behind
|
|
85
|
+
? `⚠ ${r.name}: pinned ${r.pinned}, latest ${r.latest} — behind`
|
|
86
|
+
: `✓ ${r.name}: pinned ${r.pinned} is current (latest ${r.latest})`;
|
|
87
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,21 @@ export { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
|
|
|
17
17
|
export { flyDeploy, flapsUp, flapsDown, flyApplyStep, LOCAL_FLAPS_ENDPOINT } from "./composites/fly-deploy";
|
|
18
18
|
export type { FlyDeployOpts, FlyApplyStepOpts, FlapsStepOpts } from "./composites/fly-deploy";
|
|
19
19
|
|
|
20
|
+
// Sprite Op step builders (re-exported from core for single-import convenience).
|
|
21
|
+
// These author `activity("spriteCreate", ...)` steps; `loadActivities(["fly"])`
|
|
22
|
+
// binds them to the implementations in ./op/activities/sprites.ts. The `spritesUp`
|
|
23
|
+
// /`spritesDown` builders boot/tear down the spritzer emulator as modeled steps.
|
|
24
|
+
export {
|
|
25
|
+
spriteCreate,
|
|
26
|
+
spriteExec,
|
|
27
|
+
spriteCheckpoint,
|
|
28
|
+
spriteRestore,
|
|
29
|
+
listCheckpoints,
|
|
30
|
+
spriteDestroy,
|
|
31
|
+
spritesUp,
|
|
32
|
+
spritesDown,
|
|
33
|
+
} from "@intentius/chant/op";
|
|
34
|
+
|
|
20
35
|
// Generated resources — export everything from generated index.
|
|
21
36
|
// Provides `App`, `Machine`, `Volume`, and the property types
|
|
22
37
|
// (`MachineConfig`, `MachineGuest`, `MachineService`, ...) for authoring.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the Fly emulator image pins (#808 T2).
|
|
3
|
+
*
|
|
4
|
+
* mudflaps (Machines API) and spritzer (Sprites API) are the local fakes fly's
|
|
5
|
+
* activities are exercised against. These tags were previously duplicated across
|
|
6
|
+
* flaps.ts, sprites-emulator.ts, the fly-deploy composite, and tests — and had
|
|
7
|
+
* already drifted (the composite's docstring said mudflaps 0.3.0 while the
|
|
8
|
+
* activity pinned 0.3.1). Pin each here so a bump touches one line and the
|
|
9
|
+
* emulator-freshness check (#808 T2) has a single target to compare against the
|
|
10
|
+
* latest GHCR release.
|
|
11
|
+
*
|
|
12
|
+
* Bump policy (#808): move these only when a consuming test needs a newer
|
|
13
|
+
* emulator (a fidelity fix the activities exercise), not on every emulator
|
|
14
|
+
* release. For new upstream API surface the emulator leads, then fly bumps here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Pinned mudflaps (Fly Machines / flaps emulator) image. */
|
|
18
|
+
export const MUDFLAPS_IMAGE = "ghcr.io/intentius/mudflaps:0.4.0";
|
|
19
|
+
|
|
20
|
+
/** Pinned spritzer (Fly Sprites emulator) image. */
|
|
21
|
+
export const SPRITZER_IMAGE = "ghcr.io/intentius/spritzer:0.3.1";
|
|
@@ -6,11 +6,12 @@ import {
|
|
|
6
6
|
flapsHealthUrl,
|
|
7
7
|
flapsEndpoint,
|
|
8
8
|
} from "./flaps";
|
|
9
|
+
import { MUDFLAPS_IMAGE } from "./emulator-images";
|
|
9
10
|
|
|
10
11
|
describe("flaps (mudflaps) lifecycle commands", () => {
|
|
11
12
|
test("run command uses defaults and maps the port", () => {
|
|
12
13
|
expect(flapsRunCommand({})).toBe(
|
|
13
|
-
|
|
14
|
+
`docker run -d --rm --name chant-mudflaps -p 4280:4280 ${MUDFLAPS_IMAGE}`,
|
|
14
15
|
);
|
|
15
16
|
});
|
|
16
17
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { emulatorLifecycle } from "@intentius/chant/op";
|
|
2
|
+
import { MUDFLAPS_IMAGE } from "./emulator-images";
|
|
2
3
|
|
|
3
4
|
export interface FlapsUpArgs {
|
|
4
5
|
/** Container name. Default: `chant-mudflaps`. */
|
|
5
6
|
name?: string;
|
|
6
7
|
/** Host port mapped to the emulator's `:4280`. Default: `4280`. */
|
|
7
8
|
port?: number;
|
|
8
|
-
/** Image. Default:
|
|
9
|
+
/** Image. Default: the pinned mudflaps image ({@link MUDFLAPS_IMAGE}). */
|
|
9
10
|
image?: string;
|
|
10
11
|
/** Readiness timeout in ms. Default: `60000`. */
|
|
11
12
|
timeoutMs?: number;
|
|
@@ -23,7 +24,7 @@ export interface FlapsDownArgs {
|
|
|
23
24
|
// with FLY_FLAPS_BASE_URL. Shared lifecycle: emulatorLifecycle (#746).
|
|
24
25
|
const flaps = emulatorLifecycle({
|
|
25
26
|
name: "chant-mudflaps",
|
|
26
|
-
image:
|
|
27
|
+
image: MUDFLAPS_IMAGE,
|
|
27
28
|
containerPort: 4280,
|
|
28
29
|
healthPath: "/_mudflaps/health",
|
|
29
30
|
});
|
|
@@ -46,3 +46,56 @@ export {
|
|
|
46
46
|
flapsEndpoint,
|
|
47
47
|
} from "./flaps";
|
|
48
48
|
export type { FlapsUpArgs, FlapsDownArgs } from "./flaps";
|
|
49
|
+
|
|
50
|
+
// Sprites (sprites.dev) — the other Fly product: imperative, checkpointable
|
|
51
|
+
// sandbox activities. `loadActivities(["fly"])` provides these; the fake lives
|
|
52
|
+
// in `sprites-fake.ts` and is imported only by tests (not an activity). Unlike
|
|
53
|
+
// Machines, Sprites have no desired state to reconcile — they are runtime
|
|
54
|
+
// primitives driven inside an Op, with checkpoint-as-compensation as the
|
|
55
|
+
// headline capability.
|
|
56
|
+
export {
|
|
57
|
+
spriteCreate,
|
|
58
|
+
spriteExec,
|
|
59
|
+
spriteCheckpoint,
|
|
60
|
+
spriteRestore,
|
|
61
|
+
listCheckpoints,
|
|
62
|
+
spriteDestroy,
|
|
63
|
+
resolveSpritesEndpoint,
|
|
64
|
+
defaultSpritesHttp,
|
|
65
|
+
spriteCreateBody,
|
|
66
|
+
parseCreateResponse,
|
|
67
|
+
accumulateExecFrames,
|
|
68
|
+
parseCheckpointNdjson,
|
|
69
|
+
pickCheckpointByComment,
|
|
70
|
+
splitCommand,
|
|
71
|
+
spriteExecWsUrl,
|
|
72
|
+
DEFAULT_SPRITES_BASE_URL,
|
|
73
|
+
} from "./sprites";
|
|
74
|
+
export type {
|
|
75
|
+
SpritesHttp,
|
|
76
|
+
SpriteCreateArgs,
|
|
77
|
+
SpriteCreateResult,
|
|
78
|
+
SpriteExecArgs,
|
|
79
|
+
SpriteExecResult,
|
|
80
|
+
SpriteCheckpointArgs,
|
|
81
|
+
SpriteCheckpointResult,
|
|
82
|
+
SpriteRestoreArgs,
|
|
83
|
+
ListCheckpointsArgs,
|
|
84
|
+
Checkpoint,
|
|
85
|
+
SpriteDestroyArgs,
|
|
86
|
+
} from "./sprites";
|
|
87
|
+
|
|
88
|
+
// spritzer (the Sprites API emulator) Docker lifecycle — the twin of mudflaps
|
|
89
|
+
// above. `spritesUp`/`spritesDown` resolve by name so an Op can boot/tear down
|
|
90
|
+
// the emulator as a modeled step; the sprite activities target it via
|
|
91
|
+
// SPRITES_BASE_URL.
|
|
92
|
+
export {
|
|
93
|
+
spritesUp,
|
|
94
|
+
spritesDown,
|
|
95
|
+
spritesRunCommand,
|
|
96
|
+
spritesRmCommand,
|
|
97
|
+
spritesExistsCommand,
|
|
98
|
+
spritesHealthUrl,
|
|
99
|
+
spritesEndpoint,
|
|
100
|
+
} from "./sprites-emulator";
|
|
101
|
+
export type { SpritesUpArgs, SpritesDownArgs } from "./sprites-emulator";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { flapsUp, flapsDown } from "./flaps";
|
|
3
|
+
import { MACHINES_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
4
|
+
|
|
5
|
+
// Fidelity check: every flaps endpoint the flyApply applier depends on
|
|
6
|
+
// (MACHINES_CONTRACT) must be served by the pinned mudflaps image — the twin of
|
|
7
|
+
// the Sprites contract ⊆ spritzer check (#808 T3). mudflaps enumerates its
|
|
8
|
+
// implemented paths at `/_mudflaps/health` and answers roadmap endpoints
|
|
9
|
+
// (machines/{id}/signal, /exec, /ps) with 501; flyApply must never depend on one.
|
|
10
|
+
// Docker required; skipped in CI unless FLY_DOCKER=1 (GitHub runners have Docker,
|
|
11
|
+
// so relying on absence would pull the image on every run).
|
|
12
|
+
|
|
13
|
+
const CONTAINER = "chant-mudflaps-contract-it";
|
|
14
|
+
const PORT = 4283;
|
|
15
|
+
|
|
16
|
+
let available = false;
|
|
17
|
+
let endpoint = "";
|
|
18
|
+
|
|
19
|
+
/** Parse a mudflaps health `implemented` entry ("METHOD path (note)") to a normalized key. */
|
|
20
|
+
function normalizeImplemented(entry: string): string {
|
|
21
|
+
const stripped = entry.replace(/\s*\(.*\)\s*$/, "").trim();
|
|
22
|
+
const sp = stripped.indexOf(" ");
|
|
23
|
+
return normalizeEndpoint(stripped.slice(0, sp), stripped.slice(sp + 1));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
if (process.env.CI && !process.env.FLY_DOCKER) {
|
|
28
|
+
available = false;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const up = await flapsUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 });
|
|
33
|
+
endpoint = up.endpoint;
|
|
34
|
+
available = true;
|
|
35
|
+
} catch {
|
|
36
|
+
available = false;
|
|
37
|
+
}
|
|
38
|
+
}, 60_000);
|
|
39
|
+
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
if (available) await flapsDown({ name: CONTAINER });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("Machines contract ⊆ mudflaps implemented paths", () => {
|
|
45
|
+
test("the pinned mudflaps serves every endpoint flyApply depends on", async (ctx) => {
|
|
46
|
+
if (!available) ctx.skip();
|
|
47
|
+
|
|
48
|
+
const res = await fetch(`${endpoint}/_mudflaps/health`);
|
|
49
|
+
expect(res.ok).toBe(true);
|
|
50
|
+
const health = (await res.json()) as { implemented?: string[] };
|
|
51
|
+
expect(Array.isArray(health.implemented)).toBe(true);
|
|
52
|
+
|
|
53
|
+
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
54
|
+
|
|
55
|
+
const missing = [...contractKeys()].filter((k) => !served.has(k));
|
|
56
|
+
// A non-empty list means flyApply calls something mudflaps can't serve — a
|
|
57
|
+
// real fidelity gap between the applier and the pinned emulator.
|
|
58
|
+
expect(missing, `mudflaps is missing contract endpoints: ${missing.join(", ")}`).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("flyApply never depends on a mudflaps roadmap (501) endpoint", async (ctx) => {
|
|
62
|
+
if (!available) ctx.skip();
|
|
63
|
+
const res = await fetch(`${endpoint}/_mudflaps/health`);
|
|
64
|
+
const health = (await res.json()) as { unimplemented?: string[] };
|
|
65
|
+
const roadmap = new Set((health.unimplemented ?? []).map(normalizeImplemented));
|
|
66
|
+
|
|
67
|
+
for (const e of MACHINES_CONTRACT) {
|
|
68
|
+
const key = normalizeEndpoint(e.method, e.path);
|
|
69
|
+
expect(roadmap.has(key), `${e.op} → ${e.method} ${e.path} is a mudflaps roadmap endpoint`).toBe(false);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { MACHINES_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
6
|
+
|
|
7
|
+
describe("MACHINES_CONTRACT", () => {
|
|
8
|
+
test("covers the flyApply resource operations (apps, machines, leases, volumes, ips, certs, secrets)", () => {
|
|
9
|
+
for (const area of ["/machines", "/lease", "/volumes", "/ip_assignments", "/certificates", "/secrets"]) {
|
|
10
|
+
expect(MACHINES_CONTRACT.some((e) => e.path.includes(area)), area).toBe(true);
|
|
11
|
+
}
|
|
12
|
+
expect(MACHINES_CONTRACT.some((e) => e.path === "/v1/apps"), "apps").toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("every entry has a v1/apps path and a known method", () => {
|
|
16
|
+
for (const e of MACHINES_CONTRACT) {
|
|
17
|
+
expect(e.path.startsWith("/v1/apps")).toBe(true);
|
|
18
|
+
expect(["GET", "POST", "PUT", "DELETE"]).toContain(e.method);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("normalizeEndpoint collapses param names ({id} ≡ {vol} ≡ {hostname})", () => {
|
|
23
|
+
expect(normalizeEndpoint("DELETE", "/v1/apps/{app}/volumes/{id}")).toBe(
|
|
24
|
+
normalizeEndpoint("DELETE", "/v1/apps/{app}/volumes/{vol}"),
|
|
25
|
+
);
|
|
26
|
+
expect(normalizeEndpoint("GET", "/v1/apps/{app}")).toBe("GET /v1/apps/{}");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("contractKeys is the deduped normalized set", () => {
|
|
30
|
+
const keys = contractKeys();
|
|
31
|
+
expect(keys.has("POST /v1/apps")).toBe(true);
|
|
32
|
+
expect(keys.has("DELETE /v1/apps/{}/machines/{}")).toBe(true);
|
|
33
|
+
// lease acquire (POST) and release (DELETE) are distinct keys
|
|
34
|
+
expect(keys.has("POST /v1/apps/{}/machines/{}/lease")).toBe(true);
|
|
35
|
+
expect(keys.has("DELETE /v1/apps/{}/machines/{}/lease")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("every contract path segment appears in the fly-apply.ts source (drift anchor)", () => {
|
|
39
|
+
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "fly-apply.ts"), "utf-8");
|
|
40
|
+
const segments = new Set(
|
|
41
|
+
MACHINES_CONTRACT.flatMap((e) =>
|
|
42
|
+
e.path.split("/").filter((s) => s.length > 0 && !s.startsWith("{")),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
for (const seg of segments) {
|
|
46
|
+
expect(src, `path segment "${seg}" from the contract is absent from fly-apply.ts`).toContain(seg);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly Machines (flaps) API contract — the endpoint set the `flyApply` applier
|
|
3
|
+
* (./fly-apply.ts) depends on, and the mudflaps counterpart of the hand-authored
|
|
4
|
+
* Sprites contract (./sprites-contract.ts, #808 T3).
|
|
5
|
+
*
|
|
6
|
+
* Unlike Sprites, Machines *does* ship a machine-readable OpenAPI that the fly
|
|
7
|
+
* resource surface drift-checks against (docs.machines.dev, the rolling-upgrade
|
|
8
|
+
* path #813). This contract serves a different fidelity axis: it pins the exact
|
|
9
|
+
* endpoints flyApply calls so the docker-gated coverage test can prove the pinned
|
|
10
|
+
* mudflaps emulator serves them all. mudflaps carries roadmap endpoints that
|
|
11
|
+
* answer 501 (currently machines/{id}/signal, /exec, /ps) — flyApply must never
|
|
12
|
+
* depend on one; if it ever does, the coverage test fails instead of the applier
|
|
13
|
+
* silently passing against a fake that can't model the call.
|
|
14
|
+
*
|
|
15
|
+
* Param names match ./fly-apply.ts's URL builders (`{app}`, `{id}`); the coverage
|
|
16
|
+
* check normalizes param names before comparing, since mudflaps spells volume
|
|
17
|
+
* ids `{vol}` and cert hostnames `{hostname}`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One flaps endpoint the flyApply applier calls. */
|
|
21
|
+
export interface MachinesEndpoint {
|
|
22
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
23
|
+
/** Path template under the flaps base, e.g. `/v1/apps/{app}/machines/{id}`. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** The applier operation that calls it. */
|
|
26
|
+
op: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The flaps endpoints ./fly-apply.ts depends on. Keep in sync with the applier —
|
|
31
|
+
* the unit test asserts every path segment appears in fly-apply.ts.
|
|
32
|
+
*/
|
|
33
|
+
export const MACHINES_CONTRACT: readonly MachinesEndpoint[] = [
|
|
34
|
+
// Apps
|
|
35
|
+
{ method: "POST", path: "/v1/apps", op: "createApp" },
|
|
36
|
+
{ method: "GET", path: "/v1/apps/{app}", op: "getApp" },
|
|
37
|
+
{ method: "DELETE", path: "/v1/apps/{app}", op: "deleteApp" },
|
|
38
|
+
// Machines
|
|
39
|
+
{ method: "GET", path: "/v1/apps/{app}/machines", op: "listMachines" },
|
|
40
|
+
{ method: "POST", path: "/v1/apps/{app}/machines", op: "createMachine" },
|
|
41
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "updateMachine" },
|
|
42
|
+
{ method: "DELETE", path: "/v1/apps/{app}/machines/{id}", op: "destroyMachine" },
|
|
43
|
+
{ method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
|
|
44
|
+
// Leases
|
|
45
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/lease", op: "acquireLease" },
|
|
46
|
+
{ method: "DELETE", path: "/v1/apps/{app}/machines/{id}/lease", op: "releaseLease" },
|
|
47
|
+
// Volumes
|
|
48
|
+
{ method: "GET", path: "/v1/apps/{app}/volumes", op: "listVolumes" },
|
|
49
|
+
{ method: "POST", path: "/v1/apps/{app}/volumes", op: "createVolume" },
|
|
50
|
+
{ method: "DELETE", path: "/v1/apps/{app}/volumes/{id}", op: "deleteVolume" },
|
|
51
|
+
// IP assignments
|
|
52
|
+
{ method: "GET", path: "/v1/apps/{app}/ip_assignments", op: "listIps" },
|
|
53
|
+
{ method: "POST", path: "/v1/apps/{app}/ip_assignments", op: "allocateIp" },
|
|
54
|
+
{ method: "DELETE", path: "/v1/apps/{app}/ip_assignments/{ip}", op: "releaseIp" },
|
|
55
|
+
// Certificates
|
|
56
|
+
{ method: "GET", path: "/v1/apps/{app}/certificates", op: "listCerts" },
|
|
57
|
+
{ method: "POST", path: "/v1/apps/{app}/certificates", op: "addCert" },
|
|
58
|
+
{ method: "DELETE", path: "/v1/apps/{app}/certificates/{hostname}", op: "deleteCert" },
|
|
59
|
+
// Secrets
|
|
60
|
+
{ method: "GET", path: "/v1/apps/{app}/secrets", op: "listSecrets" },
|
|
61
|
+
{ method: "POST", path: "/v1/apps/{app}/secrets/{name}", op: "setSecret" },
|
|
62
|
+
{ method: "DELETE", path: "/v1/apps/{app}/secrets/{name}", op: "deleteSecret" },
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
/** Normalize a `METHOD path` key: collapse every `{param}` to `{}` so param names match. */
|
|
66
|
+
export function normalizeEndpoint(method: string, path: string): string {
|
|
67
|
+
return `${method.toUpperCase()} ${path.replace(/\{[^}]+\}/g, "{}")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The contract as a set of normalized `METHOD path` keys. */
|
|
71
|
+
export function contractKeys(): Set<string> {
|
|
72
|
+
return new Set(MACHINES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
|
|
73
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { spritesUp, spritesDown } from "./sprites-emulator";
|
|
3
|
+
import { SPRITES_CONTRACT, normalizeEndpoint, contractKeys } from "./sprites-contract";
|
|
4
|
+
|
|
5
|
+
// Fidelity check (#808 T3): every endpoint the fly sprite activities depend on
|
|
6
|
+
// (SPRITES_CONTRACT) must be served by the pinned spritzer image. Sprites has no
|
|
7
|
+
// OpenAPI to diff, so spritzer's `/_spritzer/health` implemented-paths list is
|
|
8
|
+
// the machine-readable oracle. If an activity ever calls something the emulator
|
|
9
|
+
// doesn't model, this fails instead of the tests silently passing against a
|
|
10
|
+
// partial fake. Docker required; skipped in CI unless SPRITES_DOCKER=1 (GitHub
|
|
11
|
+
// runners have Docker, so relying on absence would pull the image every run).
|
|
12
|
+
|
|
13
|
+
const CONTAINER = "chant-spritzer-contract-it";
|
|
14
|
+
const PORT = 4293;
|
|
15
|
+
|
|
16
|
+
let available = false;
|
|
17
|
+
let endpoint = "";
|
|
18
|
+
|
|
19
|
+
/** Parse a spritzer health `implemented` entry ("METHOD path (note)") to a normalized key. */
|
|
20
|
+
function normalizeImplemented(entry: string): string {
|
|
21
|
+
const stripped = entry.replace(/\s*\(.*\)\s*$/, "").trim();
|
|
22
|
+
const sp = stripped.indexOf(" ");
|
|
23
|
+
const method = stripped.slice(0, sp);
|
|
24
|
+
const path = stripped.slice(sp + 1);
|
|
25
|
+
return normalizeEndpoint(method, path);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
if (process.env.CI && !process.env.SPRITES_DOCKER) {
|
|
30
|
+
available = false;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const up = await spritesUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 });
|
|
35
|
+
endpoint = up.endpoint;
|
|
36
|
+
available = true;
|
|
37
|
+
} catch {
|
|
38
|
+
available = false;
|
|
39
|
+
}
|
|
40
|
+
}, 60_000);
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
if (available) await spritesDown({ name: CONTAINER });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("Sprites contract ⊆ spritzer implemented paths (#808 T3)", () => {
|
|
47
|
+
test("the pinned spritzer serves every endpoint the fly activities depend on", async (ctx) => {
|
|
48
|
+
if (!available) ctx.skip();
|
|
49
|
+
|
|
50
|
+
const res = await fetch(`${endpoint}/_spritzer/health`);
|
|
51
|
+
expect(res.ok).toBe(true);
|
|
52
|
+
const health = (await res.json()) as { implemented?: string[] };
|
|
53
|
+
expect(Array.isArray(health.implemented)).toBe(true);
|
|
54
|
+
|
|
55
|
+
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
56
|
+
|
|
57
|
+
const missing = [...contractKeys()].filter((k) => !served.has(k));
|
|
58
|
+
// A non-empty list means an activity calls something spritzer can't serve —
|
|
59
|
+
// a real fidelity gap between the contract and the pinned emulator.
|
|
60
|
+
expect(missing, `spritzer is missing contract endpoints: ${missing.join(", ")}`).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("each contract endpoint maps to a served path (per-activity report)", async (ctx) => {
|
|
64
|
+
if (!available) ctx.skip();
|
|
65
|
+
const res = await fetch(`${endpoint}/_spritzer/health`);
|
|
66
|
+
const health = (await res.json()) as { implemented?: string[] };
|
|
67
|
+
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
68
|
+
|
|
69
|
+
for (const e of SPRITES_CONTRACT) {
|
|
70
|
+
const key = normalizeEndpoint(e.method, e.path);
|
|
71
|
+
expect(served.has(key), `${e.activity} → ${e.method} ${e.path} not served by spritzer`).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|