@hexclave/cli 1.0.117 → 1.0.119
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/index.js +507 -484
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/config-file.ts +18 -6
- package/src/commands/deploy.test.ts +80 -15
- package/src/commands/deploy.ts +95 -94
- package/src/commands/dev.ts +5 -4
- package/src/lib/deployment-config.test.ts +144 -36
- package/src/lib/deployment-config.ts +148 -74
|
@@ -15,6 +15,17 @@ function evaluate(servicesExport: unknown, mode: "deploy" | "dev" = "deploy") {
|
|
|
15
15
|
return evaluateDeploymentConfig({ deployFilePath: DEPLOY_FILE_PATH, deploymentGroupIdExport: "test-source", deployExport, mode });
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// The same deploy file opted into a runtime with the internal `version` export.
|
|
19
|
+
function evaluateWithVersion(servicesExport: unknown, version: unknown, mode: "deploy" | "dev" = "deploy") {
|
|
20
|
+
const deployExport = (context: ServicesFunctionContext) => ({
|
|
21
|
+
services: typeof servicesExport === "function" ? (servicesExport as (ctx: ServicesFunctionContext) => unknown)(context) : servicesExport,
|
|
22
|
+
});
|
|
23
|
+
return evaluateDeploymentConfig({ deployFilePath: DEPLOY_FILE_PATH, deploymentGroupIdExport: "test-source", deployExport, versionExport: version, mode });
|
|
24
|
+
}
|
|
25
|
+
function evaluateOnGcp(servicesExport: unknown, mode: "deploy" | "dev" = "deploy") {
|
|
26
|
+
return evaluateWithVersion(servicesExport, "gcp-beta-1", mode);
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
describe("evaluateDeploymentConfig (deploy mode)", () => {
|
|
19
30
|
it("preserves __proto__ as an environment variable instead of mutating the result prototype", () => {
|
|
20
31
|
const { services } = evaluate(() => ({
|
|
@@ -96,6 +107,44 @@ describe("evaluateDeploymentConfig (deploy mode)", () => {
|
|
|
96
107
|
});
|
|
97
108
|
});
|
|
98
109
|
|
|
110
|
+
it("reads memory from the ladder its service type actually has machines for", () => {
|
|
111
|
+
const memoryOf = (service: unknown) => evaluate(() => ({ web: service })).services.get("web")?.definition.memory;
|
|
112
|
+
expect(memoryOf({ type: "serverless", ports: {}, memory: "8GB" })).toBe("8GB");
|
|
113
|
+
expect(memoryOf({ type: "server", ports: {}, memory: "4GB" })).toBe("4GB");
|
|
114
|
+
// Absent stays absent rather than being written out as the default: the
|
|
115
|
+
// backend normalizes an explicit default back out anyway, and a definition
|
|
116
|
+
// that states what it already runs must not read differently from one that
|
|
117
|
+
// says nothing.
|
|
118
|
+
expect(memoryOf({ type: "serverless", ports: {} })).toBe(undefined);
|
|
119
|
+
// The ladder is the RUNTIME's. On the default runtime a server may be 512MB (every Fly
|
|
120
|
+
// service ran on that shape before sizes existed); on GCP no 512MB machine shape exists,
|
|
121
|
+
// so the rung a serverless may use is not one a server may — and the error names the
|
|
122
|
+
// ladder for THIS service on THIS runtime.
|
|
123
|
+
expect(memoryOf({ type: "server", ports: {}, memory: "512MB" })).toBe("512MB");
|
|
124
|
+
const badGcpMemory = (service: unknown) => () => evaluateOnGcp(() => ({ web: service }));
|
|
125
|
+
expect(badGcpMemory({ type: "server", ports: {}, memory: "512MB" }))
|
|
126
|
+
.toThrow(/deploy\.services\.web\.memory must be one of "1GB", "2GB", "4GB", "8GB"/);
|
|
127
|
+
expect(evaluateOnGcp(() => ({ web: { type: "server", ports: {}, memory: "4GB" } })).services.get("web")?.definition.memory).toBe("4GB");
|
|
128
|
+
// Sizes only the builder may ask for are not service sizes.
|
|
129
|
+
const badMemory = (service: unknown) => () => evaluate(() => ({ web: service }));
|
|
130
|
+
expect(badMemory({ type: "serverless", ports: {}, memory: "32GB" })).toThrow(/must be one of/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("refuses non-canonical memory spellings but says which one to write", () => {
|
|
134
|
+
const memory = (value: unknown) => () => evaluate(() => ({ web: { type: "serverless", ports: {}, memory: value } }));
|
|
135
|
+
// Recognising a spelling is not accepting it: one canonical token per size,
|
|
136
|
+
// for the same reason a port key may not have a leading zero.
|
|
137
|
+
expect(memory("4gb")).toThrow(/Write it as "4GB"/);
|
|
138
|
+
expect(memory("4 GB")).toThrow(/Write it as "4GB"/);
|
|
139
|
+
expect(memory("4Gi")).toThrow(/Write it as "4GB"/);
|
|
140
|
+
expect(memory("4096MB")).toThrow(/Write it as "4GB"/);
|
|
141
|
+
// "Mb" is megabits, so it is called out rather than silently accepted.
|
|
142
|
+
expect(memory("512Mb")).toThrow(/megabits/);
|
|
143
|
+
// Unrecognisable values fall back to naming the ladder.
|
|
144
|
+
expect(memory("3GB")).toThrow(/must be one of/);
|
|
145
|
+
expect(memory(4096)).toThrow(/must be one of/);
|
|
146
|
+
});
|
|
147
|
+
|
|
99
148
|
it("rejects port lists it could not serve", () => {
|
|
100
149
|
const evaluatePorts = (ports: unknown) => () => evaluate(() => ({ web: { type: "serverless", ports } }));
|
|
101
150
|
// The FIELD is still required — an omitted `ports` is a forgotten line far
|
|
@@ -189,37 +238,41 @@ describe("evaluateDeploymentConfig (deploy mode)", () => {
|
|
|
189
238
|
}))).not.toThrow();
|
|
190
239
|
});
|
|
191
240
|
|
|
192
|
-
it("
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
241
|
+
it("makes a deploy dependency of every reference the runtime cannot answer up front", () => {
|
|
242
|
+
// Which references wait depends on the RUNTIME (see connectionRequiresTargetDeployed).
|
|
243
|
+
// On the default runtime a private url(<port>) and hostname() are name-derived and
|
|
244
|
+
// known before anything runs, so they produce NO edge; on GCP both are the target's
|
|
245
|
+
// runtime address, so a consumer put first would fail the deploy on an unresolved
|
|
246
|
+
// ref, and every service output orders the deploy.
|
|
247
|
+
const deployFile = ({ service }: ServicesFunctionContext) => ({
|
|
196
248
|
web: {
|
|
197
249
|
type: "serverless", public: true, ports: { 3000: { protocol: "http" } },
|
|
198
250
|
env: { API: (service("api") as any).url(8080), DB_HOST: (service("db") as any).hostname() },
|
|
199
251
|
},
|
|
200
252
|
api: { type: "serverless", ports: { 8080: { protocol: "http" }, 9090: { protocol: "http" } } },
|
|
201
253
|
db: { type: "server", ports: { 5432: { protocol: "tcp" } } },
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
254
|
+
});
|
|
255
|
+
const onFly = evaluate(deployFile);
|
|
256
|
+
expect(computeDeploymentLevels(onFly.services, onFly.runtime)).toEqual([["web", "api", "db"]]);
|
|
257
|
+
const onGcp = evaluateOnGcp(deployFile);
|
|
258
|
+
expect(computeDeploymentLevels(onGcp.services, onGcp.runtime)).toEqual([["api", "db"], ["web"]]);
|
|
259
|
+
|
|
260
|
+
// The accepted cost on GCP: two services that each read the other's address are a
|
|
261
|
+
// real cycle there, because neither can go first. Reported as one, with the way out.
|
|
262
|
+
// On the default runtime the same file deploys in one level.
|
|
263
|
+
const mutual = ({ service }: ServicesFunctionContext) => ({
|
|
264
|
+
web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { API: (service("api") as any).url(8080) } },
|
|
211
265
|
api: { type: "serverless", ports: { 8080: { protocol: "http" } }, env: { WEB_HOST: (service("web") as any).hostname() } },
|
|
212
|
-
})
|
|
213
|
-
expect(mutual).
|
|
214
|
-
|
|
215
|
-
expect(computeDeploymentLevels(mutual().services)).toEqual([["api"], ["web"]]);
|
|
266
|
+
});
|
|
267
|
+
expect(computeDeploymentLevels(evaluate(mutual).services, "fly")).toEqual([["web", "api"]]);
|
|
268
|
+
expect(() => computeDeploymentLevels(evaluateOnGcp(mutual).services, "gcp")).toThrow(/circular connection dependency/);
|
|
216
269
|
|
|
217
|
-
// A `url`
|
|
270
|
+
// A public `url` was always a dependency, and still is.
|
|
218
271
|
const publicUrl = evaluate(({ service }: ServicesFunctionContext) => ({
|
|
219
272
|
web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { API: (service("api") as any).url() } },
|
|
220
273
|
api: { type: "serverless", public: true, ports: { 8080: { protocol: "http" } } },
|
|
221
274
|
}));
|
|
222
|
-
expect(computeDeploymentLevels(publicUrl.services)).toEqual([["api"], ["web"]]);
|
|
275
|
+
expect(computeDeploymentLevels(publicUrl.services, publicUrl.runtime)).toEqual([["api"], ["web"]]);
|
|
223
276
|
});
|
|
224
277
|
|
|
225
278
|
it("leaves references to services of other deployment sources to the backend", () => {
|
|
@@ -290,17 +343,34 @@ describe("evaluateDeploymentConfig (deploy mode)", () => {
|
|
|
290
343
|
}))).toThrow("use the `hexclave` context object instead");
|
|
291
344
|
});
|
|
292
345
|
|
|
293
|
-
it("rejects a self-referential
|
|
294
|
-
//
|
|
295
|
-
// deploy
|
|
296
|
-
|
|
346
|
+
it("rejects a self-referential address the runtime cannot answer up front", () => {
|
|
347
|
+
// A public URL is produced by the service's own rollout on either runtime, so no
|
|
348
|
+
// self-reference to it can resolve before the deploy that creates it finishes.
|
|
349
|
+
const publicSelf = ({ service }: ServicesFunctionContext) => ({
|
|
297
350
|
web: { type: "serverless", public: true, ports: { 3000: { protocol: "http" } }, env: { SELF: (service("web") as any).url(3000) } },
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
351
|
+
});
|
|
352
|
+
expect(() => evaluate(publicSelf)).toThrow("cannot exist before the service is deployed");
|
|
353
|
+
expect(() => evaluateOnGcp(publicSelf)).toThrow("cannot exist before the service is deployed");
|
|
354
|
+
// A private address is name-derived on the default runtime and known in advance, so a
|
|
355
|
+
// service may name its own; on GCP nothing publishes such a record, so it blocks on the
|
|
356
|
+
// real address like any other output — and would reach the deploy as an unresolvable ref.
|
|
357
|
+
const privateSelf = ({ service }: ServicesFunctionContext) => ({
|
|
358
|
+
web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { SELF: (service("web") as any).url(3000), HOST: (service("web") as any).hostname() } },
|
|
359
|
+
});
|
|
360
|
+
expect(() => evaluate(privateSelf)).not.toThrow();
|
|
361
|
+
expect(() => evaluateOnGcp(privateSelf)).toThrow("cannot exist before the service is deployed");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("reads the internal `version` export, and refuses one it does not know", () => {
|
|
365
|
+
const deployFile = () => ({ web: { type: "serverless", ports: { 3000: { protocol: "http" } } } });
|
|
366
|
+
expect(evaluate(deployFile)).toMatchObject({ runtime: "fly", version: undefined });
|
|
367
|
+
expect(evaluateOnGcp(deployFile)).toMatchObject({ runtime: "gcp", version: "gcp-beta-1" });
|
|
368
|
+
// Refused rather than ignored: a typo in our own token must not silently deploy to the
|
|
369
|
+
// default, and a stray export in someone's file must not silently mean anything.
|
|
370
|
+
const withVersion = (version: unknown) => () => evaluateWithVersion(deployFile, version);
|
|
371
|
+
expect(withVersion("gcp")).toThrow(/unknown `version` export "gcp"/);
|
|
372
|
+
expect(withVersion("1.0.0")).toThrow(/gcp-beta-1/);
|
|
373
|
+
expect(withVersion(1)).toThrow(/must be a string/);
|
|
304
374
|
});
|
|
305
375
|
|
|
306
376
|
it("rejects assigning the whole service() return instead of an output", () => {
|
|
@@ -572,8 +642,9 @@ describe("the deployment envelope", () => {
|
|
|
572
642
|
expect(evaluateExports(7, deployExport)).toThrow("must be a string");
|
|
573
643
|
expect(evaluateExports("-nope", deployExport)).toThrow("Invalid deployment group id");
|
|
574
644
|
expect(evaluateExports("backend", deployExport)().sourceId).toBe("backend");
|
|
575
|
-
// Dots are legal:
|
|
576
|
-
//
|
|
645
|
+
// Dots are legal: a group id appears in no reference, so nothing has to
|
|
646
|
+
// parse one — and projects that predate the move of services out of
|
|
647
|
+
// hexclave.config.ts still have a stored group named after that file.
|
|
577
648
|
expect(evaluateExports("hexclave.config.ts", deployExport)().sourceId).toBe("hexclave.config.ts");
|
|
578
649
|
});
|
|
579
650
|
|
|
@@ -762,11 +833,12 @@ describe("computeDeploymentLevels", () => {
|
|
|
762
833
|
expect(computeDeploymentLevels(services)).toEqual([["web"]]);
|
|
763
834
|
});
|
|
764
835
|
|
|
765
|
-
it("does not treat a
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
836
|
+
it("does not treat a hexclave output reference as a cycle", () => {
|
|
837
|
+
// `hexclave.*` comes from the managed service, which always exists, so it must not become
|
|
838
|
+
// an edge. Self-references to a service's own address never reach here at all — they are
|
|
839
|
+
// rejected during evaluation, since no rollout can produce an address it needs first.
|
|
840
|
+
const services = build(({ hexclave }) => ({
|
|
841
|
+
web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { PROJECT_ID: (hexclave as any).projectId } },
|
|
770
842
|
}));
|
|
771
843
|
expect(computeDeploymentLevels(services)).toEqual([["web"]]);
|
|
772
844
|
});
|
|
@@ -819,3 +891,39 @@ describe("build and start commands", () => {
|
|
|
819
891
|
expect(() => evaluate(web({ runCommand: "node server.js" }))).toThrow(/unknown field "runCommand"/);
|
|
820
892
|
});
|
|
821
893
|
});
|
|
894
|
+
|
|
895
|
+
describe("the deploy export's builder", () => {
|
|
896
|
+
const evaluateDeploy = (deployRaw: unknown) => evaluateDeploymentConfig({
|
|
897
|
+
deployFilePath: DEPLOY_FILE_PATH,
|
|
898
|
+
deploymentGroupIdExport: "test-source",
|
|
899
|
+
deployExport: () => deployRaw,
|
|
900
|
+
mode: "deploy",
|
|
901
|
+
});
|
|
902
|
+
const withServices = (extra: Record<string, unknown>) => ({
|
|
903
|
+
services: { web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: {} } },
|
|
904
|
+
...extra,
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("is a sibling of services, because one machine builds them all", () => {
|
|
908
|
+
expect(evaluateDeploy(withServices({ builder: { memory: "32GB" } })).builder).toEqual({ memory: "32GB" });
|
|
909
|
+
// Its ladder starts where the service ladders stop: a builder is a transient
|
|
910
|
+
// machine sized for a build, not for an idling service.
|
|
911
|
+
expect(evaluateDeploy(withServices({ builder: { memory: "8GB" } })).builder).toEqual({ memory: "8GB" });
|
|
912
|
+
expect(() => evaluateDeploy(withServices({ builder: { memory: "512MB" } }))).toThrow(/deploy\.builder\.memory of .* must be one of "8GB", "16GB", "32GB"/);
|
|
913
|
+
expect(() => evaluateDeploy(withServices({ builder: { memory: "32gb" } }))).toThrow(/Write it as "32GB"/);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("collapses an empty builder to no opinion at all", () => {
|
|
917
|
+
// `{}` and absent both mean "let the deployment decide"; storing one as a
|
|
918
|
+
// declaration would make them read differently downstream.
|
|
919
|
+
expect(evaluateDeploy(withServices({})).builder).toBe(undefined);
|
|
920
|
+
expect(evaluateDeploy(withServices({ builder: {} })).builder).toBe(undefined);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("rejects fields neither half of the deploy export knows", () => {
|
|
924
|
+
expect(() => evaluateDeploy(withServices({ builder: { cpu: 4 } }))).toThrow(/unknown field "cpu"/);
|
|
925
|
+
expect(() => evaluateDeploy(withServices({ builder: "32GB" }))).toThrow(/must be an object/);
|
|
926
|
+
// The top-level check now names both supported fields rather than just one.
|
|
927
|
+
expect(() => evaluateDeploy(withServices({ builders: {} }))).toThrow(/unknown field "builders". Supported fields: services, builder/);
|
|
928
|
+
});
|
|
929
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Evaluates the `deploy` export of hexclave.deploy.ts.
|
|
2
2
|
//
|
|
3
|
-
// Deployments live in their OWN file, separate from hexclave.config.ts
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Deployments live in their OWN file, separate from hexclave.config.ts, which
|
|
4
|
+
// holds the project's configuration and nothing else: one Hexclave project can
|
|
5
|
+
// be deployed from several repositories, each shipping the services it owns,
|
|
6
|
+
// and each of those repositories has a deploy file of its own while only one of
|
|
7
|
+
// them (if any) owns the project's configuration.
|
|
7
8
|
//
|
|
8
9
|
// The export is a FUNCTION of the deployment context returning `{ services }`.
|
|
9
10
|
// The context is where `secret()`, `service()` and `hexclave.*` come from.
|
|
@@ -67,16 +68,18 @@ import {
|
|
|
67
68
|
MAX_VOLUME_ID_LENGTH,
|
|
68
69
|
MAX_VOLUME_SIZE_GB,
|
|
69
70
|
MIN_VOLUME_SIZE_GB,
|
|
71
|
+
BUILDER_MEMORY_SIZES,
|
|
70
72
|
SERVICE_OUTPUT_KEYS,
|
|
71
73
|
connectionRequiresTargetDeployed,
|
|
74
|
+
deploymentMemorySizesForType,
|
|
72
75
|
deploymentServiceIsBuilt,
|
|
76
|
+
suggestDeploymentMemorySize,
|
|
73
77
|
formatConnectionValue,
|
|
74
78
|
isValidDeploymentCommand,
|
|
75
79
|
parseConnectionValue,
|
|
76
80
|
parseDeploymentImageRef,
|
|
77
81
|
DEPLOYMENT_PORT_KEY_REGEX,
|
|
78
82
|
deploymentPortEntries,
|
|
79
|
-
deploymentPortEntry,
|
|
80
83
|
reservedStandardPortConflicts,
|
|
81
84
|
standardPortsHolderPort,
|
|
82
85
|
type DeploymentEnvVarDefinition,
|
|
@@ -85,6 +88,13 @@ import {
|
|
|
85
88
|
type DeploymentServiceType,
|
|
86
89
|
type DeploymentVolumeDefinition,
|
|
87
90
|
type HexclaveOutputKey,
|
|
91
|
+
type DeploymentBuilderDefinition,
|
|
92
|
+
type DeploymentMemorySize,
|
|
93
|
+
DEFAULT_DEPLOYMENT_RUNTIME,
|
|
94
|
+
deploymentPortEntry,
|
|
95
|
+
DEPLOYMENT_VERSION_TOKENS,
|
|
96
|
+
deploymentRuntimeForVersion,
|
|
97
|
+
type DeploymentRuntime,
|
|
88
98
|
} from "@hexclave/shared/dist/deployments";
|
|
89
99
|
import { PROJECT_SECRET_KEY_REGEX } from "@hexclave/shared/dist/project-secrets";
|
|
90
100
|
import fs from "node:fs";
|
|
@@ -94,14 +104,6 @@ import { CliError, errorMessage } from "./errors.js";
|
|
|
94
104
|
// The names checked (in order) when --deploy-file is not passed.
|
|
95
105
|
export const DEPLOY_FILE_CANDIDATES = ["hexclave.deploy.ts", "hexclave.deploy.js"];
|
|
96
106
|
|
|
97
|
-
/** Whether `cwd` contains a deploy file at all. */
|
|
98
|
-
export function hasDeployFile(cwd: string): boolean {
|
|
99
|
-
return DEPLOY_FILE_CANDIDATES.some((candidate) => {
|
|
100
|
-
const resolved = path.resolve(cwd, candidate);
|
|
101
|
-
return fs.existsSync(resolved) && fs.statSync(resolved).isFile();
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
107
|
/**
|
|
106
108
|
* Resolves the deploy file path: --deploy-file wins (and must exist); otherwise
|
|
107
109
|
* the first existing candidate in `cwd`. A deploy file is REQUIRED — service
|
|
@@ -329,13 +331,55 @@ export type EvaluatedServices = {
|
|
|
329
331
|
// but previously owned by this source as removed.
|
|
330
332
|
sourceId: string,
|
|
331
333
|
services: Map<string, EvaluatedService>,
|
|
334
|
+
// The machine that builds them. One per deployment, so it sits here rather
|
|
335
|
+
// than on any service. Undefined = the deployment picks the size its build
|
|
336
|
+
// shape needs.
|
|
337
|
+
builder: DeploymentBuilderDefinition | undefined,
|
|
338
|
+
// The infrastructure runtime the file's `version` export selects, and the
|
|
339
|
+
// token itself (undefined when absent). Internal: the export is undocumented,
|
|
340
|
+
// and absent means the default runtime — see DEPLOYMENT_VERSIONS.
|
|
341
|
+
runtime: DeploymentRuntime,
|
|
342
|
+
version: string | undefined,
|
|
332
343
|
};
|
|
333
344
|
|
|
334
345
|
const KNOWN_SERVICE_FIELDS = new Set([
|
|
335
|
-
"type", "public", "ports", "minInstances", "maxInstances",
|
|
346
|
+
"type", "public", "ports", "minInstances", "maxInstances", "memory",
|
|
336
347
|
"rootDirectory", "dockerfilePath", "image", "devCommand", "buildCommand", "startCommand", "persistentVolumes", "env",
|
|
337
348
|
]);
|
|
338
349
|
const KNOWN_VOLUME_FIELDS = new Set(["path", "sizeGb"]);
|
|
350
|
+
// `services` and `builder` are the two halves of a deploy file: what to run, and
|
|
351
|
+
// what to build it on.
|
|
352
|
+
const KNOWN_DEPLOY_FIELDS = new Set(["services", "builder"]);
|
|
353
|
+
const KNOWN_BUILDER_FIELDS = new Set(["memory"]);
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Reads a memory size token, refusing anything but the canonical spelling.
|
|
357
|
+
*
|
|
358
|
+
* `available` is the ladder for this particular slot — a "server" has no 512MB
|
|
359
|
+
* shape and the builder starts where the services stop — so the message names
|
|
360
|
+
* what this thing can be rather than every token that exists.
|
|
361
|
+
*
|
|
362
|
+
* Near-misses get a "did you mean": `"4gb"`, `"4 GB"` and `"4Gi"` are all
|
|
363
|
+
* refused, but they are what somebody types first, and a bare list of seven
|
|
364
|
+
* tokens leaves the reader to spot the difference by eye.
|
|
365
|
+
*/
|
|
366
|
+
function readMemoryField(
|
|
367
|
+
raw: unknown,
|
|
368
|
+
at: string,
|
|
369
|
+
available: readonly DeploymentMemorySize[],
|
|
370
|
+
): DeploymentMemorySize | undefined {
|
|
371
|
+
if (raw === undefined) return undefined;
|
|
372
|
+
const options = available.map((size) => JSON.stringify(size)).join(", ");
|
|
373
|
+
if (typeof raw !== "string") {
|
|
374
|
+
throw new CliError(`${at} must be one of ${options} (got ${JSON.stringify(raw)}). Write the size with its unit, e.g. \`memory: "4GB"\`.`);
|
|
375
|
+
}
|
|
376
|
+
if ((available as readonly string[]).includes(raw)) return raw as DeploymentMemorySize;
|
|
377
|
+
const suggestion = suggestDeploymentMemorySize(raw);
|
|
378
|
+
if (suggestion !== null && (available as readonly string[]).includes(suggestion)) {
|
|
379
|
+
throw new CliError(`${at} is ${JSON.stringify(raw)}. Write it as ${JSON.stringify(suggestion)} — sizes have one spelling each, in MB or GB (note the capital B; "Mb" is megabits).`);
|
|
380
|
+
}
|
|
381
|
+
throw new CliError(`${at} must be one of ${options} (got ${JSON.stringify(raw)}).`);
|
|
382
|
+
}
|
|
339
383
|
|
|
340
384
|
function readOptionalIntegerField(record: Record<string, unknown>, serviceId: string, field: string): number | undefined {
|
|
341
385
|
const value = record[field];
|
|
@@ -434,11 +478,11 @@ function evaluatePorts(serviceId: string, isPublic: boolean, portsRaw: unknown):
|
|
|
434
478
|
|
|
435
479
|
const entries = deploymentPortEntries(ports);
|
|
436
480
|
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
// (HTTP); a raw TCP stream carries neither, so the edge accepts
|
|
440
|
-
// and then drops it. Lifting this needs a dedicated
|
|
441
|
-
// a
|
|
481
|
+
// PLATFORM LIMITATION: a public service is all-HTTP. Public traffic arrives
|
|
482
|
+
// through one shared Application Load Balancer that tells services apart by SNI
|
|
483
|
+
// (TLS) or Host (HTTP); a raw TCP stream carries neither, so the edge accepts
|
|
484
|
+
// the connection and then drops it. Lifting this needs a dedicated public
|
|
485
|
+
// address per service, which is a cost decision rather than a code change.
|
|
442
486
|
const tcpPorts = entries.filter((entry) => entry.protocol === "tcp");
|
|
443
487
|
if (isPublic && tcpPorts.length > 0) {
|
|
444
488
|
throw new CliError(`deploy.services.${serviceId} is \`public: true\` but declares the "tcp" port${tcpPorts.length === 1 ? "" : "s"} ${tcpPorts.map((entry) => entry.port).join(", ")}. Raw TCP carries no SNI or Host header, so a shared public address cannot tell which service a connection is for. Keep the service private and reach it with service(${JSON.stringify(serviceId)}).hostname() and the port number, or move the TCP ports to their own service.`);
|
|
@@ -469,6 +513,28 @@ function evaluatePorts(serviceId: string, isPublic: boolean, portsRaw: unknown):
|
|
|
469
513
|
return ports;
|
|
470
514
|
}
|
|
471
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Reads the deploy export's `builder`.
|
|
518
|
+
*
|
|
519
|
+
* Returns undefined when the file says nothing OR when it declares an empty
|
|
520
|
+
* object: both mean "no opinion", and storing `{}` would make an empty
|
|
521
|
+
* declaration look different from an absent one to every reader downstream.
|
|
522
|
+
*/
|
|
523
|
+
function evaluateBuilder(deployFilePath: string, builderRaw: unknown): DeploymentBuilderDefinition | undefined {
|
|
524
|
+
if (builderRaw === undefined) return undefined;
|
|
525
|
+
if (builderRaw === null || typeof builderRaw !== "object" || Array.isArray(builderRaw)) {
|
|
526
|
+
throw new CliError(`deploy.builder of ${deployFilePath} must be an object, e.g. \`builder: { memory: "32GB" }\`.`);
|
|
527
|
+
}
|
|
528
|
+
const record = builderRaw as Record<string, unknown>;
|
|
529
|
+
for (const field of Object.keys(record)) {
|
|
530
|
+
if (!KNOWN_BUILDER_FIELDS.has(field)) {
|
|
531
|
+
throw new CliError(`deploy.builder of ${deployFilePath} has an unknown field ${JSON.stringify(field)}. Known fields: ${[...KNOWN_BUILDER_FIELDS].join(", ")}.`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const memory = readMemoryField(record.memory, `deploy.builder.memory of ${deployFilePath}`, BUILDER_MEMORY_SIZES);
|
|
535
|
+
return memory === undefined ? undefined : { memory };
|
|
536
|
+
}
|
|
537
|
+
|
|
472
538
|
function evaluatePersistentVolumes(serviceId: string, volumesRaw: unknown): Record<string, DeploymentVolumeDefinition> | undefined {
|
|
473
539
|
if (volumesRaw === undefined || volumesRaw === null) return undefined;
|
|
474
540
|
if (typeof volumesRaw !== "object" || Array.isArray(volumesRaw)) {
|
|
@@ -602,18 +668,19 @@ const EXAMPLE_DEPLOYMENT_EXPORT = ` export const deploymentGroupId = "my-app";
|
|
|
602
668
|
*/
|
|
603
669
|
export function evaluateDeploymentConfig(options: {
|
|
604
670
|
deployFilePath: string,
|
|
605
|
-
// The file's `deploymentGroupId` export — the deployment group id.
|
|
606
|
-
// a deploy file; the caller passes CONFIG_FILE_DEPLOYMENT_SOURCE_ID for
|
|
607
|
-
// deployments declared in hexclave.config.ts, which has no id of its own.
|
|
671
|
+
// The file's `deploymentGroupId` export — the deployment group id.
|
|
608
672
|
deploymentGroupIdExport: unknown,
|
|
609
673
|
// The file's `id` export, which is what this used to be called. Passed so a
|
|
610
674
|
// file still using the old name fails with the rename instead of the
|
|
611
675
|
// less helpful "no `deploymentGroupId` export".
|
|
612
676
|
legacyIdExport?: unknown,
|
|
613
677
|
deployExport: unknown,
|
|
678
|
+
// The file's `version` export, if any: an internal token selecting the
|
|
679
|
+
// infrastructure runtime. Absent is the default.
|
|
680
|
+
versionExport?: unknown,
|
|
614
681
|
mode: "deploy" | "dev",
|
|
615
682
|
}): EvaluatedServices {
|
|
616
|
-
const { deployFilePath, deploymentGroupIdExport, legacyIdExport, deployExport, mode } = options;
|
|
683
|
+
const { deployFilePath, deploymentGroupIdExport, legacyIdExport, deployExport, versionExport, mode } = options;
|
|
617
684
|
const deployFileDirectory = path.dirname(deployFilePath);
|
|
618
685
|
|
|
619
686
|
// `id` was the old name for this export. Refuse the file outright rather than
|
|
@@ -639,6 +706,18 @@ export function evaluateDeploymentConfig(options: {
|
|
|
639
706
|
throw new CliError(`Invalid deployment group id ${JSON.stringify(sourceId)} in ${deployFilePath}. Ids must be at most ${MAX_DEPLOYMENT_SOURCE_ID_LENGTH} characters and contain only letters, numbers, underscores, dots, and hyphens (not starting with a dot or hyphen).`);
|
|
640
707
|
}
|
|
641
708
|
|
|
709
|
+
// Refused loudly rather than ignored, in both directions: a typo in one of our
|
|
710
|
+
// own tokens must not silently deploy to the default runtime, and a stray
|
|
711
|
+
// `version` in someone's deploy file must not silently mean anything.
|
|
712
|
+
const version = versionExport === undefined ? undefined : versionExport;
|
|
713
|
+
if (version !== undefined && typeof version !== "string") {
|
|
714
|
+
throw new CliError(`The \`version\` export of ${deployFilePath} must be a string (got ${typeof version}). Remove it unless you were given a value for it.`);
|
|
715
|
+
}
|
|
716
|
+
const runtime = deploymentRuntimeForVersion(version);
|
|
717
|
+
if (runtime === null) {
|
|
718
|
+
throw new CliError(`The deploy file ${deployFilePath} has an unknown \`version\` export ${JSON.stringify(version)}. Remove it, or use one of: ${DEPLOYMENT_VERSION_TOKENS.join(", ")}.`);
|
|
719
|
+
}
|
|
720
|
+
|
|
642
721
|
if (deployExport === undefined) {
|
|
643
722
|
throw new CliError(`The deploy file ${deployFilePath} has no \`deploy\` export. Add one, e.g.:\n${EXAMPLE_DEPLOYMENT_EXPORT}`);
|
|
644
723
|
}
|
|
@@ -673,10 +752,13 @@ export function evaluateDeploymentConfig(options: {
|
|
|
673
752
|
}
|
|
674
753
|
const deployRecord = deployRaw as Record<string, unknown>;
|
|
675
754
|
for (const field of Object.keys(deployRecord)) {
|
|
676
|
-
if (field
|
|
677
|
-
throw new CliError(`The \`deploy\` export of ${deployFilePath} returned an unknown field ${JSON.stringify(field)}.
|
|
755
|
+
if (!KNOWN_DEPLOY_FIELDS.has(field)) {
|
|
756
|
+
throw new CliError(`The \`deploy\` export of ${deployFilePath} returned an unknown field ${JSON.stringify(field)}. Supported fields: ${[...KNOWN_DEPLOY_FIELDS].join(", ")}.`);
|
|
678
757
|
}
|
|
679
758
|
}
|
|
759
|
+
// The builder is one machine per deployment, so it is read once here rather
|
|
760
|
+
// than per service.
|
|
761
|
+
const builder = evaluateBuilder(deployFilePath, deployRecord.builder);
|
|
680
762
|
const servicesRaw = deployRecord.services;
|
|
681
763
|
if (servicesRaw === undefined) {
|
|
682
764
|
throw new CliError(`The \`deploy\` export of ${deployFilePath} returned no \`services\`. Add them, e.g.:\n${EXAMPLE_DEPLOYMENT_EXPORT}`);
|
|
@@ -704,7 +786,7 @@ export function evaluateDeploymentConfig(options: {
|
|
|
704
786
|
}
|
|
705
787
|
if (!(DEPLOYMENT_SERVICE_TYPES as readonly unknown[]).includes(record.type)) {
|
|
706
788
|
throw new CliError(record.type === undefined
|
|
707
|
-
? `deploy.services.${serviceId} has no \`type\`. Add \`type: "server"\` (single
|
|
789
|
+
? `deploy.services.${serviceId} has no \`type\`. Add \`type: "server"\` (single always-on instance, may have persistentVolumes, paid plan) or \`type: "serverless"\` (scales out, stops on scale-down).`
|
|
708
790
|
: `deploy.services.${serviceId}.type must be ${DEPLOYMENT_SERVICE_TYPES.map((knownType: string) => JSON.stringify(knownType)).join(" or ")} (got ${JSON.stringify(record.type)}).`);
|
|
709
791
|
}
|
|
710
792
|
const serviceType = record.type as DeploymentServiceType;
|
|
@@ -807,6 +889,10 @@ export function evaluateDeploymentConfig(options: {
|
|
|
807
889
|
}
|
|
808
890
|
}
|
|
809
891
|
|
|
892
|
+
// The ladder is the service type's own: a "server" runs on whole machines
|
|
893
|
+
// and has no 512MB shape to run on.
|
|
894
|
+
const memory = readMemoryField(record.memory, `deploy.services.${serviceId}.memory`, deploymentMemorySizesForType(serviceType, runtime));
|
|
895
|
+
|
|
810
896
|
// A volume is local disk on a single host and attaches to at most one
|
|
811
897
|
// instance, so only a "server" can hold one.
|
|
812
898
|
const persistentVolumes = evaluatePersistentVolumes(serviceId, record.persistentVolumes);
|
|
@@ -839,6 +925,12 @@ export function evaluateDeploymentConfig(options: {
|
|
|
839
925
|
// A "serverless" defaults to 0 (scale to zero) downstream.
|
|
840
926
|
min_instances: serviceType === "server" ? minInstances ?? 1 : minInstances,
|
|
841
927
|
max_instances: maxInstances,
|
|
928
|
+
// Left undefined when the file says nothing, rather than written out
|
|
929
|
+
// like `public` above: the default is applied at deploy time, and a
|
|
930
|
+
// definition that states the value it already runs at must hash the
|
|
931
|
+
// same as one that omits it — otherwise adding `memory: "1GB"` to a
|
|
932
|
+
// running server would replace the machine for no change at all.
|
|
933
|
+
memory,
|
|
842
934
|
// Stored/displayed as a config-directory-relative posix path ("." for
|
|
843
935
|
// the config directory itself) — an absolute local path would be
|
|
844
936
|
// meaningless (and leak local filesystem layout) server-side.
|
|
@@ -955,30 +1047,27 @@ export function evaluateDeploymentConfig(options: {
|
|
|
955
1047
|
}
|
|
956
1048
|
}
|
|
957
1049
|
|
|
958
|
-
// A service referencing its
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
1050
|
+
// A service referencing an address of its OWN that its rollout produces can never be
|
|
1051
|
+
// satisfied: the reference would have to resolve before the deploy that creates it has
|
|
1052
|
+
// finished. Which addresses those are depends on the runtime (see
|
|
1053
|
+
// connectionRequiresTargetDeployed): on the default runtime a private address is
|
|
1054
|
+
// name-derived and known before anything runs, so only a public url is a cycle; on GCP
|
|
1055
|
+
// every service output is. Caught here rather than at deploy time, where it surfaced as
|
|
1056
|
+
// "blocked on unresolved refs" after the upload — and where no retry could ever clear it.
|
|
963
1057
|
for (const [serviceId, service] of services) {
|
|
964
1058
|
for (const [envVarKey, value] of Object.entries(service.env)) {
|
|
965
1059
|
if (value.kind !== "connection") continue;
|
|
966
1060
|
const parsed = parseConnectionValue(value.reference);
|
|
967
|
-
if (parsed === null || parsed.serviceId !== serviceId
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
if (
|
|
972
|
-
|
|
973
|
-
const port = parsed.port === null
|
|
974
|
-
? ports.filter((entry) => entry.protocol === "http")[0]
|
|
975
|
-
: ports.find((entry) => entry.port === parsed.port);
|
|
976
|
-
if (port === undefined) continue;
|
|
977
|
-
throw new CliError(`deploy.services.${serviceId}.env.${envVarKey} connects to the service's own public URL (port ${port.port}), which cannot exist before the service does. Use service("${serviceId}").hostname() for its own address.`);
|
|
1061
|
+
if (parsed === null || parsed.serviceId !== serviceId) continue;
|
|
1062
|
+
const targetIsPublic = parsed.port === null || deploymentPortEntry(service.definition.ports, parsed.port) === null
|
|
1063
|
+
? null
|
|
1064
|
+
: service.definition.public === true;
|
|
1065
|
+
if (!connectionRequiresTargetDeployed(runtime, parsed.outputKey, parsed.port, targetIsPublic)) continue;
|
|
1066
|
+
throw new CliError(`deploy.services.${serviceId}.env.${envVarKey} connects to the service's own ${parsed.outputKey}, which cannot exist before the service is deployed. ${runtime === "fly" && parsed.outputKey === "url" ? `Use service("${serviceId}").hostname() for its own address.` : "Set it as a plain env var, or have the service read its own address at runtime."}`);
|
|
978
1067
|
}
|
|
979
1068
|
}
|
|
980
1069
|
|
|
981
|
-
return { sourceId, services };
|
|
1070
|
+
return { sourceId, services, builder, runtime, version };
|
|
982
1071
|
}
|
|
983
1072
|
|
|
984
1073
|
/**
|
|
@@ -987,7 +1076,7 @@ export function evaluateDeploymentConfig(options: {
|
|
|
987
1076
|
* services within one level are independent of each other. Throws on circular
|
|
988
1077
|
* dependencies, naming the cycle.
|
|
989
1078
|
*/
|
|
990
|
-
export function computeDeploymentLevels(services: Map<string, EvaluatedService
|
|
1079
|
+
export function computeDeploymentLevels(services: Map<string, EvaluatedService>, runtime: DeploymentRuntime = DEFAULT_DEPLOYMENT_RUNTIME): string[][] {
|
|
991
1080
|
const dependencies = new Map<string, Set<string>>();
|
|
992
1081
|
for (const [serviceId, service] of services) {
|
|
993
1082
|
const serviceDependencies = new Set<string>();
|
|
@@ -1000,19 +1089,16 @@ export function computeDeploymentLevels(services: Map<string, EvaluatedService>)
|
|
|
1000
1089
|
// connections to services of ANOTHER deployment source are resolved
|
|
1001
1090
|
// against already-deployed state, since that source deploys on its own
|
|
1002
1091
|
// schedule and is not part of this deploy at all.
|
|
1003
|
-
// Self-references never create a deploy edge:
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1092
|
+
// Self-references never create a deploy edge: every self-reference to an address output
|
|
1093
|
+
// is rejected outright above, and the outputs that remain do not depend on the service
|
|
1094
|
+
// having deployed. Adding a self-edge here would make computeDeploymentLevels report a
|
|
1095
|
+
// circular-dependency error where the check above gives the specific reason.
|
|
1007
1096
|
//
|
|
1008
|
-
// The same reasoning excludes DETERMINISTIC cross-service references
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
// service's URL is built from the deterministic hostname, so it resolves
|
|
1014
|
-
// before the target exists. A target this file does not define is not part
|
|
1015
|
-
// of this deploy either way, so it never becomes an edge.
|
|
1097
|
+
// The same reasoning excludes DETERMINISTIC cross-service references, which
|
|
1098
|
+
// the runtime decides (see connectionRequiresTargetDeployed): counting them
|
|
1099
|
+
// would serialize independent deploys and reject mutually-wired services as
|
|
1100
|
+
// circular. A target this file does not define is not part of this deploy
|
|
1101
|
+
// either way, so it never becomes an edge.
|
|
1016
1102
|
const target = services.get(targetServiceId);
|
|
1017
1103
|
if (target === undefined) continue;
|
|
1018
1104
|
// Null when the named port is not one the target declares — the reference
|
|
@@ -1021,7 +1107,7 @@ export function computeDeploymentLevels(services: Map<string, EvaluatedService>)
|
|
|
1021
1107
|
const targetIsPublic = parsed.port === null || deploymentPortEntry(target.definition.ports, parsed.port) === null
|
|
1022
1108
|
? null
|
|
1023
1109
|
: target.definition.public === true;
|
|
1024
|
-
if (!connectionRequiresTargetDeployed(parsed.outputKey, parsed.port, targetIsPublic)) continue;
|
|
1110
|
+
if (!connectionRequiresTargetDeployed(runtime, parsed.outputKey, parsed.port, targetIsPublic)) continue;
|
|
1025
1111
|
if (targetServiceId !== HEXCLAVE_SERVICE_ID && targetServiceId !== serviceId) {
|
|
1026
1112
|
serviceDependencies.add(targetServiceId);
|
|
1027
1113
|
}
|
|
@@ -1154,9 +1240,9 @@ function resolveHexclaveOutputFromSessionEnv(envVarKey: string, outputKey: Hexcl
|
|
|
1154
1240
|
}
|
|
1155
1241
|
|
|
1156
1242
|
/**
|
|
1157
|
-
* Loads a TypeScript/JavaScript module via jiti
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1243
|
+
* Loads a TypeScript/JavaScript module via jiti, reporting an unloadable file as
|
|
1244
|
+
* a CliError rather than whatever jiti threw. `description` is how the file is
|
|
1245
|
+
* named in that error.
|
|
1160
1246
|
*/
|
|
1161
1247
|
async function importModule(filePath: string, description: string): Promise<Record<string, unknown>> {
|
|
1162
1248
|
const { createJiti } = await import("jiti");
|
|
@@ -1174,20 +1260,8 @@ async function importModule(filePath: string, description: string): Promise<Reco
|
|
|
1174
1260
|
* the first (`id`) comes back too, so evaluation can name the rename instead of
|
|
1175
1261
|
* reporting a missing export.
|
|
1176
1262
|
*/
|
|
1177
|
-
export async function importDeployModule(deployFilePath: string): Promise<{ deploymentGroupId: unknown, legacyId: unknown, deploy: unknown }> {
|
|
1263
|
+
export async function importDeployModule(deployFilePath: string): Promise<{ deploymentGroupId: unknown, legacyId: unknown, deploy: unknown, version: unknown }> {
|
|
1178
1264
|
const module = await importModule(deployFilePath, "deploy file");
|
|
1179
|
-
return { deploymentGroupId: module.deploymentGroupId, legacyId: module.id, deploy: module.deploy };
|
|
1265
|
+
return { deploymentGroupId: module.deploymentGroupId, legacyId: module.id, deploy: module.deploy, version: module.version };
|
|
1180
1266
|
}
|
|
1181
1267
|
|
|
1182
|
-
/**
|
|
1183
|
-
* Loads a config file (hexclave.config.ts) and returns its `config` export, plus
|
|
1184
|
-
* a `deploy` export if it has one — a project small enough to keep its
|
|
1185
|
-
* services in the config file may declare them there instead of in a deploy
|
|
1186
|
-
* file. Those services belong to a deployment group named after the file (the
|
|
1187
|
-
* config file has no `deploymentGroupId` export of its own), so they can coexist
|
|
1188
|
-
* with the deploy files of other repositories deploying into the same project.
|
|
1189
|
-
*/
|
|
1190
|
-
export async function importConfigModule(configPath: string): Promise<{ config: unknown, deploy: unknown }> {
|
|
1191
|
-
const module = await importModule(configPath, "config file");
|
|
1192
|
-
return { config: module.config, deploy: module.deploy };
|
|
1193
|
-
}
|