@intentius/chant-lexicon-fly 0.90.0 → 0.93.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 +27 -0
- package/dist/components/capability-plugin.d.ts +1 -0
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/fly-release.d.ts +195 -0
- package/dist/components/fly-release.d.ts.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/fly-site.d.ts +86 -0
- package/dist/composites/fly-site.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +11 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/op/activities/fly-apply.d.ts +8 -22
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-release-step.d.ts +24 -0
- package/dist/op/activities/fly-release-step.d.ts.map +1 -0
- package/dist/op/activities/fly-rollback-step.d.ts +25 -0
- package/dist/op/activities/fly-rollback-step.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +8 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +192 -0
- package/dist/op/activities/machine-release.d.ts.map +1 -0
- package/dist/op/activities/machines-contract.d.ts +8 -0
- package/dist/op/activities/machines-contract.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.d.ts +55 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -0
- package/dist/op/activities/machines-local-cli.d.ts +20 -0
- package/dist/op/activities/machines-local-cli.d.ts.map +1 -0
- package/dist/op/activities/machines-local.d.ts +107 -0
- package/dist/op/activities/machines-local.d.ts.map +1 -0
- package/dist/op/activities/sprite-fs.d.ts +12 -3
- package/dist/op/activities/sprite-fs.d.ts.map +1 -1
- package/dist/op/activities/sprite-service-converge.d.ts +107 -0
- package/dist/op/activities/sprite-service-converge.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +18 -1
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts +29 -9
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +62 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/builders.d.ts +45 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/release-metadata.d.ts +48 -0
- package/dist/release-metadata.d.ts.map +1 -0
- package/dist/release-store.d.ts +36 -0
- package/dist/release-store.d.ts.map +1 -0
- package/dist/skills/chant-fly-ops.md +100 -0
- package/dist/skills/chant-fly-sprites.md +2 -1
- package/package.json +11 -4
- package/src/components/capability-plugin.ts +11 -3
- package/src/components/fly-release.test.ts +378 -0
- package/src/components/fly-release.ts +458 -0
- package/src/components/index.ts +16 -0
- package/src/composites/catalog.test.ts +2 -1
- package/src/composites/catalog.ts +98 -0
- package/src/composites/fly-site.test.ts +66 -0
- package/src/composites/fly-site.ts +140 -0
- package/src/describe-resources.test.ts +18 -3
- package/src/describe-resources.ts +69 -5
- package/src/index.ts +20 -0
- package/src/op/activities/fly-apply.ts +15 -3
- package/src/op/activities/fly-release-step.ts +32 -0
- package/src/op/activities/fly-rollback-step.ts +33 -0
- package/src/op/activities/index.ts +48 -0
- package/src/op/activities/machine-release.integration.test.ts +113 -0
- package/src/op/activities/machine-release.test.ts +138 -0
- package/src/op/activities/machine-release.ts +429 -0
- package/src/op/activities/machines-contract.docker.integration.test.ts +10 -1
- package/src/op/activities/machines-contract.test.ts +11 -1
- package/src/op/activities/machines-contract.ts +17 -0
- package/src/op/activities/machines-fake.ts +167 -0
- package/src/op/activities/machines-local-cli.ts +73 -0
- package/src/op/activities/machines-local.test.ts +225 -0
- package/src/op/activities/machines-local.ts +437 -0
- package/src/op/activities/sprite-exec-options.docker.integration.test.ts +70 -0
- package/src/op/activities/sprite-fs.test.ts +55 -0
- package/src/op/activities/sprite-fs.ts +14 -5
- package/src/op/activities/sprite-service-converge.test.ts +268 -0
- package/src/op/activities/sprite-service-converge.ts +268 -0
- package/src/op/activities/sprites-fake.ts +51 -8
- package/src/op/activities/sprites.integration.test.ts +30 -0
- package/src/op/activities/sprites.test.ts +20 -0
- package/src/op/activities/sprites.ts +50 -10
- package/src/op/activity-contracts.test.ts +71 -0
- package/src/op/activity-contracts.ts +67 -0
- package/src/op/builders.ts +57 -0
- package/src/release-metadata.ts +72 -0
- package/src/release-store.ts +70 -0
- package/src/skills/chant-fly-ops.md +100 -0
- package/src/skills/chant-fly-sprites.md +2 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlySite composite: a Fly app that serves one app's releases.
|
|
3
|
+
*
|
|
4
|
+
* It declares the App, the Machine that serves (behind Fly's proxy on 443 and
|
|
5
|
+
* 80), and, when asked for, a Volume the app's data lives on, mounted into the
|
|
6
|
+
* Machine, a public IP, and the app's Secrets. It is the fly lexicon's home
|
|
7
|
+
* for chud's `FlySite` and `ChudLocalSite` sites (ws-056, #2809): a repo that
|
|
8
|
+
* `chant workspace upgrade` takes off the chud lexicon declares its Fly site
|
|
9
|
+
* with it, so `chant workspace graph --composites` lists the site as a
|
|
10
|
+
* composite instance, and a component that names `FlySite` in its
|
|
11
|
+
* `composites` deploys it.
|
|
12
|
+
*
|
|
13
|
+
* The Machine is the one a release ships to: `chant build --lexicon fly`
|
|
14
|
+
* serializes the site into a plan with exactly one Machine, so the
|
|
15
|
+
* `fly-release` steps (a component's deploy, or an Op's `flyRelease`) find it
|
|
16
|
+
* without being told its name. `image` is what the Machine runs until a
|
|
17
|
+
* release puts its own image, or its files and start command, on it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
21
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
22
|
+
import { Fly } from "../pseudo";
|
|
23
|
+
import {
|
|
24
|
+
App,
|
|
25
|
+
IPAddress,
|
|
26
|
+
Machine,
|
|
27
|
+
MachineConfig,
|
|
28
|
+
MachineGuest,
|
|
29
|
+
MachineMount,
|
|
30
|
+
MachinePort,
|
|
31
|
+
MachineService,
|
|
32
|
+
Secret,
|
|
33
|
+
Volume,
|
|
34
|
+
} from "../generated/index";
|
|
35
|
+
|
|
36
|
+
export interface FlySiteProps {
|
|
37
|
+
/** The App's name. Fly app names are global. */
|
|
38
|
+
app: string;
|
|
39
|
+
/** The owning org (default: `Fly.OrgSlug`, which the build resolves from `FLY_ORG`). */
|
|
40
|
+
org?: string | Declarable;
|
|
41
|
+
/** Region of the Machine and the Volume (default: `Fly.Region`, from `FLY_REGION`). */
|
|
42
|
+
region?: string | Declarable;
|
|
43
|
+
/** The Machine's name (default: "web"). */
|
|
44
|
+
machine?: string;
|
|
45
|
+
/** The image the Machine runs until a release replaces it. */
|
|
46
|
+
image: string;
|
|
47
|
+
/** The port the app listens on inside the Machine (default: 8080). Fly's proxy sends 443 (TLS) and 80 to it. */
|
|
48
|
+
port?: number;
|
|
49
|
+
/** The Machine's env. */
|
|
50
|
+
env?: Record<string, string>;
|
|
51
|
+
/** Guest CPU kind (default: "shared"). */
|
|
52
|
+
cpuKind?: string;
|
|
53
|
+
/** Guest CPUs (default: 1). */
|
|
54
|
+
cpus?: number;
|
|
55
|
+
/** Guest memory in MB (default: 256). */
|
|
56
|
+
memoryMb?: number;
|
|
57
|
+
/** A Volume for the app's data, mounted at `path`. It outlives releases. Omitted: no Volume. */
|
|
58
|
+
volume?: { name: string; sizeGb: number; path: string };
|
|
59
|
+
/** A public IP of this type (for example "shared_v4"). Omitted: none. */
|
|
60
|
+
ip?: "shared_v4" | "v4" | "v6";
|
|
61
|
+
/**
|
|
62
|
+
* The app's Secrets, by name. An undefined value declares the Secret
|
|
63
|
+
* without a value, so the release checks the app already has it.
|
|
64
|
+
*/
|
|
65
|
+
secrets?: Record<string, string | undefined>;
|
|
66
|
+
/** Per-member defaults for fine-grained overrides. */
|
|
67
|
+
defaults?: {
|
|
68
|
+
app?: Partial<Record<string, unknown>>;
|
|
69
|
+
machine?: Partial<Record<string, unknown>>;
|
|
70
|
+
volume?: Partial<Record<string, unknown>>;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `APP_SECRET` -> `appSecret`: a Secret's member name, never one of the other members'. */
|
|
75
|
+
function memberName(secret: string): string {
|
|
76
|
+
const words = secret.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
77
|
+
const name = words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join("") || "secret";
|
|
78
|
+
return ["app", "machine", "volume", "ip"].includes(name) ? `${name}Secret` : name;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a FlySite composite. Returns the App and the Machine, and the
|
|
83
|
+
* Volume, IP and Secrets it was asked for.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* import { FlySite } from "@intentius/chant-lexicon-fly";
|
|
88
|
+
*
|
|
89
|
+
* export const flySite = FlySite({
|
|
90
|
+
* app: "notes",
|
|
91
|
+
* region: "iad",
|
|
92
|
+
* image: "node:22-slim",
|
|
93
|
+
* env: { PORT: "8080", APP_DATA: "/data" },
|
|
94
|
+
* volume: { name: "data", sizeGb: 1, path: "/data" },
|
|
95
|
+
* ip: "shared_v4",
|
|
96
|
+
* secrets: { APP_SECRET: process.env.APP_SECRET || undefined },
|
|
97
|
+
* });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export const FlySite = Composite((props: FlySiteProps) => {
|
|
101
|
+
const { port = 8080, cpuKind = "shared", cpus = 1, memoryMb = 256, defaults: defs } = props;
|
|
102
|
+
const region = props.region ?? Fly.Region;
|
|
103
|
+
|
|
104
|
+
const app = new App(mergeDefaults({ name: props.app, org_slug: props.org ?? Fly.OrgSlug } as Record<string, unknown>, defs?.app) as ConstructorParameters<typeof App>[0]);
|
|
105
|
+
|
|
106
|
+
const volume = props.volume
|
|
107
|
+
? new Volume(mergeDefaults({ name: props.volume.name, region, size_gb: props.volume.sizeGb } as Record<string, unknown>, defs?.volume) as ConstructorParameters<typeof Volume>[0])
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
const config = new MachineConfig({
|
|
111
|
+
image: props.image,
|
|
112
|
+
guest: new MachineGuest({ cpu_kind: cpuKind, cpus, memory_mb: memoryMb }),
|
|
113
|
+
...(props.volume ? { mounts: [new MachineMount({ volume: props.volume.name, path: props.volume.path })] } : {}),
|
|
114
|
+
services: [
|
|
115
|
+
new MachineService({
|
|
116
|
+
protocol: "tcp",
|
|
117
|
+
internal_port: port,
|
|
118
|
+
ports: [new MachinePort({ port: 443, handlers: ["tls", "http"] }), new MachinePort({ port: 80, handlers: ["http"] })],
|
|
119
|
+
}),
|
|
120
|
+
],
|
|
121
|
+
...(props.env ? { env: props.env } : {}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const machine = new Machine(mergeDefaults({ name: props.machine ?? "web", region, config } as Record<string, unknown>, defs?.machine) as ConstructorParameters<typeof Machine>[0]);
|
|
125
|
+
|
|
126
|
+
const ip = props.ip ? new IPAddress({ type: props.ip }) : undefined;
|
|
127
|
+
|
|
128
|
+
const secrets: Record<string, Declarable> = {};
|
|
129
|
+
for (const [name, value] of Object.entries(props.secrets ?? {})) {
|
|
130
|
+
secrets[memberName(name)] = new Secret({ name, value } as ConstructorParameters<typeof Secret>[0]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
app,
|
|
135
|
+
machine,
|
|
136
|
+
...(volume ? { volume } : {}),
|
|
137
|
+
...(ip ? { ip } : {}),
|
|
138
|
+
...secrets,
|
|
139
|
+
};
|
|
140
|
+
}, "FlySite");
|
|
@@ -126,13 +126,28 @@ describe("describeResources: live machines → ResourceMetadata verdicts", () =>
|
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
test("with no readable plan, declared entities are unobserved rather than absent (#1089)", async () => {
|
|
129
|
+
// Nothing a plan can be rebuilt from (a Secret keys no read): no plan at all.
|
|
130
|
+
const secrets = entities([["token", "Fly::Machines::Secret"]]);
|
|
129
131
|
const res = await describeResources(
|
|
130
|
-
{ environment: "prod", buildOutput: "", entityNames: ["
|
|
132
|
+
{ environment: "prod", buildOutput: "", entityNames: ["token"], entities: secrets, endpoint: ENDPOINT },
|
|
131
133
|
fakeHttp([]),
|
|
132
134
|
);
|
|
133
135
|
expect(res.resources).toEqual({});
|
|
134
|
-
expect(res.unobserved?.
|
|
135
|
-
|
|
136
|
+
expect(res.unobserved?.token?.reason).toBe("read-failed");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("with no build output, the plan is rebuilt from the declared entities (status --live, #2736)", async () => {
|
|
140
|
+
const declared = new Map([
|
|
141
|
+
["shopApp", { entityType: "Fly::Machines::App", props: { name: APP, org_slug: "personal" } as Record<string, unknown> }],
|
|
142
|
+
["web", { entityType: "Fly::Machines::Machine", props: { name: "web" } as Record<string, unknown> }],
|
|
143
|
+
]);
|
|
144
|
+
const res = await describeResources(
|
|
145
|
+
{ environment: "prod", buildOutput: "", entityNames: ["shopApp", "web"], entities: declared, endpoint: ENDPOINT },
|
|
146
|
+
fakeHttp([liveMachine("web", "nginx:1", { id: "m-web" })]),
|
|
147
|
+
);
|
|
148
|
+
expect(res.resources.web?.physicalId).toBe("m-web");
|
|
149
|
+
expect(res.resources.shopApp?.physicalId).toBe(APP);
|
|
150
|
+
expect(res.unobserved ?? {}).toEqual({});
|
|
136
151
|
});
|
|
137
152
|
|
|
138
153
|
test("the owned filter records a withheld declared machine as `filtered`, not absent (#1089)", async () => {
|
|
@@ -31,6 +31,7 @@ import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@int
|
|
|
31
31
|
import { normalizeObservation, observation, unobservedAll } from "@intentius/chant/observation";
|
|
32
32
|
import { readOwnership } from "@intentius/chant/ownership";
|
|
33
33
|
import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
|
|
34
|
+
import { readMachineRelease } from "./release-metadata";
|
|
34
35
|
import {
|
|
35
36
|
buildChangeSet,
|
|
36
37
|
renderChangeSet,
|
|
@@ -99,6 +100,59 @@ function serializedName(req: FlapsRequest, entityName: string): string {
|
|
|
99
100
|
return typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/** The release attributes of a Machine whose metadata names one (#2736). */
|
|
104
|
+
function releaseAttributes(metadata: Record<string, string> | undefined): Record<string, unknown> {
|
|
105
|
+
const release = readMachineRelease(metadata);
|
|
106
|
+
return release ? { digest: release.digest, release } : {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A string prop, or undefined. */
|
|
110
|
+
function stringProp(props: Record<string, unknown>, key: string): string | undefined {
|
|
111
|
+
const v = props[key];
|
|
112
|
+
return typeof v === "string" && v ? v : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A plan rebuilt from declared entities (#2736), for a caller that has the
|
|
117
|
+
* entities and no serialized build. It holds only what a read is keyed by:
|
|
118
|
+
* each App's name, and each Machine, Volume, IP and Certificate with the App it
|
|
119
|
+
* belongs to (an `app` prop naming it, else the sole declared App). Returns ""
|
|
120
|
+
* when there are no entities to rebuild from.
|
|
121
|
+
*/
|
|
122
|
+
export function planFromEntities(
|
|
123
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
|
|
124
|
+
): string {
|
|
125
|
+
const plan: FlyPlan = {};
|
|
126
|
+
const apps: string[] = [];
|
|
127
|
+
for (const [name, { entityType, props }] of entities) {
|
|
128
|
+
if (entityType !== TYPE.app) continue;
|
|
129
|
+
const appName = stringProp(props, "name") ?? name;
|
|
130
|
+
apps.push(appName);
|
|
131
|
+
plan[name] = { endpoint: "/v1/apps", method: "POST", body: { app_name: appName } };
|
|
132
|
+
}
|
|
133
|
+
const soleApp = apps.length === 1 ? apps[0] : "{app}";
|
|
134
|
+
const owning = (props: Record<string, unknown>): string => {
|
|
135
|
+
const app = props.app;
|
|
136
|
+
if (typeof app === "string" && app) return app;
|
|
137
|
+
const ref = app && typeof app === "object" ? (app as { props?: Record<string, unknown> }).props : undefined;
|
|
138
|
+
return (ref && stringProp(ref, "name")) ?? soleApp;
|
|
139
|
+
};
|
|
140
|
+
for (const [name, { entityType, props }] of entities) {
|
|
141
|
+
const app = owning(props);
|
|
142
|
+
const base = `/v1/apps/${app}`;
|
|
143
|
+
if (entityType === TYPE.machine) {
|
|
144
|
+
plan[name] = { endpoint: `${base}/machines`, method: "POST", body: { name: stringProp(props, "name") ?? name } };
|
|
145
|
+
} else if (entityType === TYPE.volume) {
|
|
146
|
+
plan[name] = { endpoint: `${base}/volumes`, method: "POST", body: { name: stringProp(props, "name") ?? name } };
|
|
147
|
+
} else if (entityType === TYPE.ip) {
|
|
148
|
+
plan[name] = { endpoint: `${base}/ip_assignments`, method: "POST", body: { type: props.type } };
|
|
149
|
+
} else if (entityType === TYPE.cert) {
|
|
150
|
+
plan[name] = { endpoint: `${base}/certificates`, method: "POST", body: { hostname: props.hostname } };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return Object.keys(plan).length ? JSON.stringify(plan) : "";
|
|
154
|
+
}
|
|
155
|
+
|
|
102
156
|
/**
|
|
103
157
|
* List live Fly resources over flaps and return them keyed by chant entity name,
|
|
104
158
|
* each tagged with an ownership verdict the core change set reads to decide
|
|
@@ -116,10 +170,13 @@ export async function describeResources(
|
|
|
116
170
|
const result: Record<string, ResourceMetadata> = {};
|
|
117
171
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
118
172
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
173
|
+
// `chant components status --live` hands over the declared entities with no
|
|
174
|
+
// serialized build (#2736): the plan is rebuilt from their props, enough to
|
|
175
|
+
// key each read by. Without either there is nothing to key a read by, so
|
|
176
|
+
// chant never asks flaps anything — every declared entity is unobserved, not
|
|
177
|
+
// absent (#1089). Returning an empty map here used to plan a create for each.
|
|
178
|
+
const buildOutput = options.buildOutput || planFromEntities(options.entities);
|
|
179
|
+
if (!buildOutput) {
|
|
123
180
|
return observation(
|
|
124
181
|
result,
|
|
125
182
|
unobservedAll(options.entities.keys(), "read-failed", "no fly plan in the build output to read back", options.entities),
|
|
@@ -128,7 +185,7 @@ export async function describeResources(
|
|
|
128
185
|
|
|
129
186
|
let plan: FlyPlan;
|
|
130
187
|
try {
|
|
131
|
-
plan = parsePlan(
|
|
188
|
+
plan = parsePlan(buildOutput);
|
|
132
189
|
} catch (err) {
|
|
133
190
|
return observation(
|
|
134
191
|
result,
|
|
@@ -318,11 +375,18 @@ export async function describeResources(
|
|
|
318
375
|
// boundary types (volumes/ips/certs) get their verdict inferred at the
|
|
319
376
|
// app boundary and carry no marker of their own — absent, not guessed.
|
|
320
377
|
marker: readOwnership(m.config?.metadata, FLY_METADATA_OWNERSHIP_KEYS),
|
|
378
|
+
// The release the Machine serves (#2736): `digest` is the identity
|
|
379
|
+
// attribute core's status join compares with the release ledger
|
|
380
|
+
// (LIVE_IDENTITY_ATTRIBUTES); `release` carries the rest of what its
|
|
381
|
+
// metadata records. The commit is reported inside `release`, not as
|
|
382
|
+
// the `gitSha` identity: a rollback serves an earlier commit under the
|
|
383
|
+
// digest the ledger records again, so only the digest is the join.
|
|
321
384
|
attributes: pruneUndefined({
|
|
322
385
|
app,
|
|
323
386
|
machineName: m.name,
|
|
324
387
|
instanceId: m.instance_id,
|
|
325
388
|
config: m.config,
|
|
389
|
+
...releaseAttributes(m.config?.metadata),
|
|
326
390
|
}),
|
|
327
391
|
};
|
|
328
392
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,12 @@ export { Fly, Region, OrgSlug, AppName, PseudoParameter } from "./pseudo";
|
|
|
16
16
|
// Ownership marker convention (machine config.metadata keys)
|
|
17
17
|
export { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
|
|
18
18
|
|
|
19
|
+
// The release a Machine serves, in its metadata (#2736): read by
|
|
20
|
+
// describeResources and compared with the release ledger by
|
|
21
|
+
// `chant components status --live`.
|
|
22
|
+
export { RELEASE_METADATA_KEYS, readMachineRelease, withReleaseMetadata } from "./release-metadata";
|
|
23
|
+
export type { MachineRelease } from "./release-metadata";
|
|
24
|
+
|
|
19
25
|
// Deploy Op composite + typed step builders (#744). `flyDeploy` returns a
|
|
20
26
|
// `boot → build → flyApply → wait → teardown` Op; the step builders wrap the
|
|
21
27
|
// generic `activity()` so the fly activities resolve by name without a core change.
|
|
@@ -26,6 +32,10 @@ export type { FlyDeployOpts, FlyApplyStepOpts, FlapsStepOpts } from "./composite
|
|
|
26
32
|
export { FlyOtelCollector } from "./composites/fly-otel-collector";
|
|
27
33
|
export type { FlyOtelCollectorProps } from "./composites/fly-otel-collector";
|
|
28
34
|
|
|
35
|
+
// A Fly app that serves one app's releases: App, Machine, and its Volume, IP and Secrets (#2809, ws-056).
|
|
36
|
+
export { FlySite } from "./composites/fly-site";
|
|
37
|
+
export type { FlySiteProps } from "./composites/fly-site";
|
|
38
|
+
|
|
29
39
|
// Sprite Op step builders. chant #1288 Stage 2: these author
|
|
30
40
|
// `activity("spriteCreate", ...)` steps with authoring-time types derived
|
|
31
41
|
// from this lexicon's own `Sprite*Args` interfaces (`./op/builders.ts`),
|
|
@@ -58,11 +68,21 @@ export {
|
|
|
58
68
|
spriteServiceStop,
|
|
59
69
|
spriteServiceDelete,
|
|
60
70
|
spriteServiceLogs,
|
|
71
|
+
spriteServicesObserve,
|
|
72
|
+
spriteServiceRestart,
|
|
61
73
|
spriteTaskCreate,
|
|
62
74
|
spriteTaskRefresh,
|
|
63
75
|
spriteTaskRelease,
|
|
64
76
|
spritesUp,
|
|
65
77
|
spritesDown,
|
|
78
|
+
flyMachineRelease,
|
|
79
|
+
flyMachineExec,
|
|
80
|
+
flyMachineRestart,
|
|
81
|
+
flyMachineStop,
|
|
82
|
+
flyMachineVerify,
|
|
83
|
+
flyMachineRestore,
|
|
84
|
+
flyRelease,
|
|
85
|
+
flyRollback,
|
|
66
86
|
} from "./op/builders";
|
|
67
87
|
|
|
68
88
|
// Generated resources — export everything from generated index.
|
|
@@ -797,7 +797,14 @@ export async function pruneSecrets(
|
|
|
797
797
|
|
|
798
798
|
export interface FlyApplyArgs {
|
|
799
799
|
/** Path to the #738 serializer's JSON output (entity name → flaps request). */
|
|
800
|
-
planPath
|
|
800
|
+
planPath?: string;
|
|
801
|
+
/**
|
|
802
|
+
* The plan itself, in place of `planPath` (#2736): a caller that changes the
|
|
803
|
+
* declared plan before applying it (the release a Machine serves, stamped
|
|
804
|
+
* into its metadata) passes it here rather than writing a file that may
|
|
805
|
+
* hold Secret values.
|
|
806
|
+
*/
|
|
807
|
+
plan?: FlyPlan;
|
|
801
808
|
/** flaps endpoint override (D3). Default: `FLY_FLAPS_BASE_URL` env, else real Fly. */
|
|
802
809
|
endpoint?: string;
|
|
803
810
|
/** Bearer token for real Fly. Default: `FLY_API_TOKEN`. mudflaps ignores it. */
|
|
@@ -835,6 +842,11 @@ export interface FlyApplyArgs {
|
|
|
835
842
|
* because the serializer produced it and the two being out of sync is a bug in
|
|
836
843
|
* the lexicon, not a resource the user declined.
|
|
837
844
|
*/
|
|
845
|
+
function readPlanFile(planPath: string | undefined): FlyPlan {
|
|
846
|
+
if (!planPath) throw new Error("flyApply needs a plan: pass planPath (the fly build output) or plan");
|
|
847
|
+
return parsePlan(readFileSync(planPath, "utf8"));
|
|
848
|
+
}
|
|
849
|
+
|
|
838
850
|
export function toApplyResult(result: {
|
|
839
851
|
apps: Array<{ app: string; created: boolean }>;
|
|
840
852
|
machines: Array<{ app: string; name: string; action: "created" | "updated" | "noop" }>;
|
|
@@ -892,7 +904,7 @@ export async function flyApply(
|
|
|
892
904
|
prunedCerts: Array<{ app: string; hostname: string }>;
|
|
893
905
|
prunedSecrets: Array<{ app: string; name: string }>;
|
|
894
906
|
}> {
|
|
895
|
-
const plan =
|
|
907
|
+
const plan = args.plan ?? readPlanFile(args.planPath);
|
|
896
908
|
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
897
909
|
const opts = args.wait ?? {};
|
|
898
910
|
|
|
@@ -1046,7 +1058,7 @@ export async function flyDelete(
|
|
|
1046
1058
|
signal?: AbortSignal,
|
|
1047
1059
|
http: FlyHttp = defaultFlyHttp(args.token),
|
|
1048
1060
|
): Promise<{ machines: Array<{ app: string; name: string }>; apps: Array<{ app: string; deleted: boolean }> }> {
|
|
1049
|
-
const plan =
|
|
1061
|
+
const plan = args.plan ?? readPlanFile(args.planPath);
|
|
1050
1062
|
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
1051
1063
|
const opts = args.wait ?? {};
|
|
1052
1064
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flyRelease`: the `fly-release` capability (../../components/fly-release.ts)
|
|
3
|
+
* as an Op activity (#2782), so an Op's ship phase runs the same site steps a
|
|
4
|
+
* component's deploy does: upload and start, each migration once per
|
|
5
|
+
* environment under a receipt in chant's lifecycle receipt store, verify, and
|
|
6
|
+
* restore on a failure after the Machine changed.
|
|
7
|
+
*
|
|
8
|
+
* An Op has no environment of its own, so the step names the one it ships to
|
|
9
|
+
* (`environment`); the receipts and the recorded Machine configs are that
|
|
10
|
+
* environment's, as they are for the component.
|
|
11
|
+
*
|
|
12
|
+
* Retrying a release that already serves changes nothing: the apply finds the
|
|
13
|
+
* Machine's config unchanged, and every migration's receipt matches.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { FlyReleaseInput, FlyReleaseOutput } from "../../components/fly-release";
|
|
17
|
+
|
|
18
|
+
export interface FlyReleaseArgs extends FlyReleaseInput {
|
|
19
|
+
/** The environment the release ships to: its receipts and recorded configs. (`env` is the Machine's env, as for the capability.) */
|
|
20
|
+
environment: string;
|
|
21
|
+
/** The component the release belongs to, for attribution. Default `app`. */
|
|
22
|
+
component?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type FlyReleaseResult = FlyReleaseOutput;
|
|
26
|
+
|
|
27
|
+
export async function flyRelease(args: FlyReleaseArgs): Promise<FlyReleaseResult> {
|
|
28
|
+
const { environment, component, ...input } = args;
|
|
29
|
+
if (!environment) throw new Error("flyRelease: name the environment the release ships to (environment)");
|
|
30
|
+
const { flyReleaseCapability } = await import("../../components/fly-release");
|
|
31
|
+
return flyReleaseCapability.run({ env: environment, component: component ?? "app" }, input);
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flyRollback`: the `fly-rollback` capability (../../components/fly-release.ts)
|
|
3
|
+
* as an Op activity (#2800), so a rollback Op puts an earlier release back on
|
|
4
|
+
* the Machine the way the release Op's `flyRelease` put it there.
|
|
5
|
+
*
|
|
6
|
+
* It restores the Machine config `fly-release` recorded for the release it
|
|
7
|
+
* goes back to (files included), in the environment `environment` names, and
|
|
8
|
+
* checks that the Machine is started with that release. Given `source`, the
|
|
9
|
+
* archive is read only once it hashes to its digest, before any flaps call,
|
|
10
|
+
* and the recorded config must carry exactly that tree, or nothing changes.
|
|
11
|
+
* A release with no recorded config is refused by name.
|
|
12
|
+
*
|
|
13
|
+
* Running it again once the Machine serves the release changes nothing.
|
|
14
|
+
* Migrations are not undone: the data stays where the later release left it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { FlyRollbackInput, FlyRollbackOutput } from "../../components/fly-release";
|
|
18
|
+
|
|
19
|
+
export interface FlyRollbackArgs extends FlyRollbackInput {
|
|
20
|
+
/** The environment whose recorded Machine configs are read. */
|
|
21
|
+
environment: string;
|
|
22
|
+
/** The component the release belongs to, for attribution. Default `app`. */
|
|
23
|
+
component?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type FlyRollbackResult = FlyRollbackOutput;
|
|
27
|
+
|
|
28
|
+
export async function flyRollback(args: FlyRollbackArgs): Promise<FlyRollbackResult> {
|
|
29
|
+
const { environment, component, ...input } = args;
|
|
30
|
+
if (!environment) throw new Error("flyRollback: name the environment whose release it restores (environment)");
|
|
31
|
+
const { flyRollbackCapability } = await import("../../components/fly-release");
|
|
32
|
+
return flyRollbackCapability.run({ env: environment, component: component ?? "app" }, input);
|
|
33
|
+
}
|
|
@@ -35,6 +35,36 @@ export {
|
|
|
35
35
|
} from "./fly-apply";
|
|
36
36
|
export type { FlyApplyArgs, FlyPlan, FlapsRequest, FlapsMachine, FlyHttp, WaitOpts, ApplyCtx } from "./fly-apply";
|
|
37
37
|
|
|
38
|
+
// The release a Machine serves and the site steps over the Machines API
|
|
39
|
+
// (#2736, ws-056): upload and start, exec (migrations), restart, stop, verify,
|
|
40
|
+
// restore. `loadActivities(["fly"])` binds these by name; the `fly-release`
|
|
41
|
+
// and `fly-rollback` capabilities compose them.
|
|
42
|
+
export {
|
|
43
|
+
flyMachineRelease,
|
|
44
|
+
flyMachineExec,
|
|
45
|
+
flyMachineRestart,
|
|
46
|
+
flyMachineStop,
|
|
47
|
+
flyMachineVerify,
|
|
48
|
+
flyMachineRestore,
|
|
49
|
+
} from "./machine-release";
|
|
50
|
+
// The fly-release capability as an Op step (#2782): a release Op's ship phase.
|
|
51
|
+
export { flyRelease } from "./fly-release-step";
|
|
52
|
+
export type { FlyReleaseArgs, FlyReleaseResult } from "./fly-release-step";
|
|
53
|
+
// The fly-rollback capability as an Op step (#2800): a rollback Op's restore.
|
|
54
|
+
export { flyRollback } from "./fly-rollback-step";
|
|
55
|
+
export type { FlyRollbackArgs, FlyRollbackResult } from "./fly-rollback-step";
|
|
56
|
+
export type {
|
|
57
|
+
MachineRelease,
|
|
58
|
+
FlyMachineReleaseArgs,
|
|
59
|
+
FlyMachineReleaseResult,
|
|
60
|
+
MachineFile,
|
|
61
|
+
FlyMachineExecArgs,
|
|
62
|
+
FlyMachineExecResult,
|
|
63
|
+
FlyMachineStateArgs,
|
|
64
|
+
FlyMachineVerifyArgs,
|
|
65
|
+
FlyMachineRestoreArgs,
|
|
66
|
+
} from "./machine-release";
|
|
67
|
+
|
|
38
68
|
// mudflaps (Fly Machines API emulator) lifecycle — boots/tears down the local
|
|
39
69
|
// flaps target flyApply is exercised against.
|
|
40
70
|
export {
|
|
@@ -120,6 +150,24 @@ export type {
|
|
|
120
150
|
SpriteServiceLogsResult,
|
|
121
151
|
} from "./sprite-services";
|
|
122
152
|
|
|
153
|
+
// A sprite's services as resources a ConvergeOp observes and converges (#2778):
|
|
154
|
+
// the observer step and the restart a converge rule dispatches.
|
|
155
|
+
export {
|
|
156
|
+
spriteServicesObserve,
|
|
157
|
+
spriteServiceRestart,
|
|
158
|
+
declaredServices,
|
|
159
|
+
findSpriteEnv,
|
|
160
|
+
parseServicesList,
|
|
161
|
+
probeHealth,
|
|
162
|
+
} from "./sprite-service-converge";
|
|
163
|
+
export type {
|
|
164
|
+
DeclaredSpriteService,
|
|
165
|
+
SpriteServicesObserveArgs,
|
|
166
|
+
SpriteServicesObserveResult,
|
|
167
|
+
SpriteServiceRestartArgs,
|
|
168
|
+
SpriteServiceRestartResult,
|
|
169
|
+
} from "./sprite-service-converge";
|
|
170
|
+
|
|
123
171
|
// Sprite filesystem activities (#848) — imperative file I/O over the fs API.
|
|
124
172
|
// `loadActivities(["fly"])` binds these; the step builders live in core.
|
|
125
173
|
export {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { flapsUp, flapsDown } from "./flaps";
|
|
6
|
+
import { defaultFlyHttp, deleteApp } from "./fly-apply";
|
|
7
|
+
import { findMachine } from "./machine-release";
|
|
8
|
+
import { readMachineRelease } from "../../release-metadata";
|
|
9
|
+
import { createFlyReleaseCapability, createFlyRollbackCapability } from "../../components/fly-release";
|
|
10
|
+
import { memoryMachineConfigStore } from "../../release-store";
|
|
11
|
+
import { describeResources } from "../../describe-resources";
|
|
12
|
+
import type { EffectReceiptRef } from "@intentius/chant/op/receipt-store";
|
|
13
|
+
|
|
14
|
+
// A release, a migration, a second release and a rollback against a live
|
|
15
|
+
// mudflaps container (#2736): the `fly-release` / `fly-rollback` capabilities
|
|
16
|
+
// over the real Machines wire protocol, then describeResources reading the
|
|
17
|
+
// release back the way `chant components status --live` does. Docker is
|
|
18
|
+
// required; the suite skips in CI unless a job opts in with FLY_IT=1, like
|
|
19
|
+
// ./fly-apply.integration.test.ts. No Fly credentials: mudflaps ignores tokens.
|
|
20
|
+
|
|
21
|
+
const APP = "chant-release-it";
|
|
22
|
+
const CONTAINER = "chant-mudflaps-release-it";
|
|
23
|
+
const PORT = 4284;
|
|
24
|
+
const WAIT = { intervalMs: 50, timeoutSecs: 5, deadlineMs: 30_000 };
|
|
25
|
+
const http = defaultFlyHttp();
|
|
26
|
+
|
|
27
|
+
let endpoint = "";
|
|
28
|
+
let available = false;
|
|
29
|
+
let tmp = "";
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
if (process.env.CI && !process.env.FLY_IT) return;
|
|
33
|
+
tmp = mkdtempSync(join(tmpdir(), "fly-release-it-"));
|
|
34
|
+
try {
|
|
35
|
+
endpoint = (await flapsUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 })).endpoint;
|
|
36
|
+
available = true;
|
|
37
|
+
} catch {
|
|
38
|
+
available = false;
|
|
39
|
+
}
|
|
40
|
+
}, 60_000);
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
if (available) {
|
|
44
|
+
await deleteApp({ base: endpoint }, APP, http).catch(() => undefined);
|
|
45
|
+
await flapsDown({ name: CONTAINER });
|
|
46
|
+
}
|
|
47
|
+
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("fly-release against mudflaps", () => {
|
|
51
|
+
test("release, migrate, release again, roll back; the Machine's metadata names each release", async (ctx) => {
|
|
52
|
+
if (!available) ctx.skip();
|
|
53
|
+
const planPath = join(tmp, "fly.json");
|
|
54
|
+
writeFileSync(
|
|
55
|
+
planPath,
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } },
|
|
58
|
+
web: {
|
|
59
|
+
endpoint: `/v1/apps/${APP}/machines`,
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: { name: "web", region: "iad", config: { image: "nginx:1", metadata: { "managed-by": "chant" } } },
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
const configs = memoryMachineConfigStore();
|
|
66
|
+
const receipts = new Map<string, string>();
|
|
67
|
+
const deps = {
|
|
68
|
+
http,
|
|
69
|
+
configStore: () => configs,
|
|
70
|
+
receiptStore: () => ({
|
|
71
|
+
read: async (r: EffectReceiptRef) => receipts.get(r.name),
|
|
72
|
+
write: async (r: EffectReceiptRef, v: string) => void receipts.set(r.name, v),
|
|
73
|
+
}),
|
|
74
|
+
headCommit: async () => "1111111111111111111111111111111111111111",
|
|
75
|
+
log: () => undefined,
|
|
76
|
+
};
|
|
77
|
+
const release = createFlyReleaseCapability(deps);
|
|
78
|
+
const rollback = createFlyRollbackCapability(deps);
|
|
79
|
+
const c = { env: "it", component: "web" };
|
|
80
|
+
const common = { plan: planPath, endpoint, wait: WAIT, verify: { intervalMs: 100, timeoutMs: 10_000 } };
|
|
81
|
+
|
|
82
|
+
const one = await release.run(c, { ...common, digest: "sha256:one", image: "nginx:1", migrations: [{ name: "001.sql", command: "echo migrate 001" }] });
|
|
83
|
+
expect(one.migrations).toEqual([{ name: "001.sql", fired: true }]);
|
|
84
|
+
const two = await release.run(c, { ...common, digest: "sha256:two", image: "nginx:2", migrations: [{ name: "001.sql", command: "echo migrate 001" }] });
|
|
85
|
+
expect(two.migrations).toEqual([{ name: "001.sql", fired: false }]);
|
|
86
|
+
expect(two.previous?.digest).toBe("sha256:one");
|
|
87
|
+
|
|
88
|
+
let m = await findMachine({ base: endpoint }, APP, "web", http);
|
|
89
|
+
expect(readMachineRelease(m?.config?.metadata)).toMatchObject({ digest: "sha256:two", previousDigest: "sha256:one" });
|
|
90
|
+
expect(m?.config?.image).toBe("nginx:2");
|
|
91
|
+
|
|
92
|
+
const observed = await describeResources(
|
|
93
|
+
{
|
|
94
|
+
environment: "it",
|
|
95
|
+
buildOutput: "",
|
|
96
|
+
entityNames: ["app", "web"],
|
|
97
|
+
entities: new Map([
|
|
98
|
+
["app", { entityType: "Fly::Machines::App", props: { name: APP } }],
|
|
99
|
+
["web", { entityType: "Fly::Machines::Machine", props: { name: "web" } }],
|
|
100
|
+
]),
|
|
101
|
+
endpoint,
|
|
102
|
+
},
|
|
103
|
+
http,
|
|
104
|
+
);
|
|
105
|
+
expect((observed.resources.web?.attributes as { digest?: string }).digest).toBe("sha256:two");
|
|
106
|
+
|
|
107
|
+
const back = await rollback.run(c, { plan: planPath, endpoint, wait: WAIT, verify: { intervalMs: 100, timeoutMs: 10_000 } });
|
|
108
|
+
expect(back.digest).toBe("sha256:one");
|
|
109
|
+
m = await findMachine({ base: endpoint }, APP, "web", http);
|
|
110
|
+
expect(m?.config?.image).toBe("nginx:1");
|
|
111
|
+
expect(readMachineRelease(m?.config?.metadata)?.digest).toBe("sha256:one");
|
|
112
|
+
}, 120_000);
|
|
113
|
+
});
|