@intentius/chant-lexicon-fly 0.124.31 → 0.124.33
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/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.js +143 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-lexicon.js +38 -0
- package/dist/codegen/generate-typescript.js +45 -0
- package/dist/codegen/generate.js +82 -0
- package/dist/codegen/naming.js +33 -0
- package/dist/codegen/package.js +35 -0
- package/dist/components/capability-plugin.js +26 -0
- package/dist/components/fly-release.js +335 -0
- package/dist/components/index.js +10 -0
- package/dist/components/run-agent.js +133 -0
- package/dist/composites/catalog.js +176 -0
- package/dist/composites/fly-deploy.js +91 -0
- package/dist/composites/fly-otel-collector.js +66 -0
- package/dist/composites/fly-site.js +80 -0
- package/dist/coverage.js +29 -0
- package/dist/describe-resources.js +443 -0
- package/dist/detect.js +63 -0
- package/dist/export-resources.js +81 -0
- package/dist/generated/index.js +44 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/import/generator.js +208 -0
- package/dist/import/live-export.js +81 -0
- package/dist/import/parser.js +157 -0
- package/dist/index.js +40 -0
- package/dist/integrity.json +3 -3
- package/dist/lint/audit-catalog.js +28 -0
- package/dist/lint/audit-lineage.js +5 -0
- package/dist/lint/post-synth/fly-helpers.js +24 -0
- package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
- package/dist/lint/post-synth/index.js +6 -0
- package/dist/lint/rules/guest-sizing.js +92 -0
- package/dist/lint/rules/index.js +12 -0
- package/dist/lint/rules/no-secret-literals.js +58 -0
- package/dist/lint/rules/valid-region.js +52 -0
- package/dist/lsp/completions.js +12 -0
- package/dist/lsp/hover.js +12 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +113 -0
- package/dist/okf/types/Machine.md +1 -1
- package/dist/op/activities/box-services.js +88 -0
- package/dist/op/activities/emulator-images.js +32 -0
- package/dist/op/activities/flaps.js +34 -0
- package/dist/op/activities/fly-apply.js +911 -0
- package/dist/op/activities/fly-release-step.js +21 -0
- package/dist/op/activities/fly-rollback-step.js +22 -0
- package/dist/op/activities/index.js +53 -0
- package/dist/op/activities/machine-release.js +236 -0
- package/dist/op/activities/machines-contract.js +81 -0
- package/dist/op/activities/machines-fake.js +203 -0
- package/dist/op/activities/machines-local-cli.js +85 -0
- package/dist/op/activities/machines-local.js +395 -0
- package/dist/op/activities/progress.js +10 -0
- package/dist/op/activities/sprite-config.js +409 -0
- package/dist/op/activities/sprite-fs.js +131 -0
- package/dist/op/activities/sprite-service-converge.js +224 -0
- package/dist/op/activities/sprite-services.js +174 -0
- package/dist/op/activities/sprite-tasks.js +57 -0
- package/dist/op/activities/sprites-contract.js +87 -0
- package/dist/op/activities/sprites-emulator.js +72 -0
- package/dist/op/activities/sprites-fake.js +547 -0
- package/dist/op/activities/sprites.js +515 -0
- package/dist/op/activity-contracts.js +82 -0
- package/dist/op/builders.js +157 -0
- package/dist/ownership.js +29 -0
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +298 -0
- package/dist/pseudo.js +26 -0
- package/dist/reference-catalog.js +37 -0
- package/dist/release-metadata.js +59 -0
- package/dist/release-store.js +46 -0
- package/dist/serializer.js +299 -0
- package/dist/spec/fetch.js +32 -0
- package/dist/spec/parse.js +260 -0
- package/dist/teardown.js +194 -0
- package/dist/types/index.d.ts +33 -2
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +33 -0
- package/package.json +11 -11
- package/src/codegen/package.ts +2 -2
- package/src/generated/index.d.ts +33 -2
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
export async function flyRelease(args) {
|
|
16
|
+
const { environment, component, ...input } = args;
|
|
17
|
+
if (!environment)
|
|
18
|
+
throw new Error("flyRelease: name the environment the release ships to (environment)");
|
|
19
|
+
const { flyReleaseCapability } = await import("../../components/fly-release.js");
|
|
20
|
+
return flyReleaseCapability.run({ env: environment, component: component ?? "app" }, input);
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
export async function flyRollback(args) {
|
|
17
|
+
const { environment, component, ...input } = args;
|
|
18
|
+
if (!environment)
|
|
19
|
+
throw new Error("flyRollback: name the environment whose release it restores (environment)");
|
|
20
|
+
const { flyRollbackCapability } = await import("../../components/fly-release.js");
|
|
21
|
+
return flyRollbackCapability.run({ env: environment, component: component ?? "app" }, input);
|
|
22
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fly Op activities — resolved by the core activity registry when a project's
|
|
3
|
+
* `chant.config.ts` lists the `fly` lexicon. Contributes the native flaps
|
|
4
|
+
* applier (`flyApply`), which speaks the Fly Machines REST API directly (no
|
|
5
|
+
* flyctl, no state file), and the mudflaps emulator lifecycle it is tested
|
|
6
|
+
* against.
|
|
7
|
+
*/
|
|
8
|
+
export { flyApply, toApplyResult, flyDelete, applyApp, applyMachine, destroyMachine, deleteApp, listMachines, listVolumes, listIps, listCerts, listSecrets, checkSecretExists, pruneMachines, waitForMachine, acquireLease, releaseLease, withLease, resolveEndpoint, parsePlan, isAppRequest, isMachineRequest, isVolumeRequest, isIpRequest, isCertRequest, isSecretRequest, isValuelessSecret, machineAppSegment, resourceAppSegment, secretNameSegment, ipType, declaredIpType, resolveApp, appNameFromRequest, isChantOwned, configEqual, isLeaseConflict, defaultFlyHttp, DEFAULT_FLAPS_BASE_URL, LEASE_NONCE_HEADER, } from "./fly-apply.js";
|
|
9
|
+
// The release a Machine serves and the site steps over the Machines API
|
|
10
|
+
// (#2736, ws-056): upload and start, exec (migrations), restart, stop, verify,
|
|
11
|
+
// restore. `loadActivities(["fly"])` binds these by name; the `fly-release`
|
|
12
|
+
// and `fly-rollback` capabilities compose them.
|
|
13
|
+
export { flyMachineRelease, flyMachineExec, flyMachineRestart, flyMachineStop, flyMachineVerify, flyMachineRestore, } from "./machine-release.js";
|
|
14
|
+
// The fly-release capability as an Op step (#2782): a release Op's ship phase.
|
|
15
|
+
export { flyRelease } from "./fly-release-step.js";
|
|
16
|
+
// The fly-rollback capability as an Op step (#2800): a rollback Op's restore.
|
|
17
|
+
export { flyRollback } from "./fly-rollback-step.js";
|
|
18
|
+
// mudflaps (Fly Machines API emulator) lifecycle — boots/tears down the local
|
|
19
|
+
// flaps target flyApply is exercised against.
|
|
20
|
+
export { flapsUp, flapsDown, flapsRunCommand, flapsRmCommand, flapsExistsCommand, flapsHealthUrl, flapsEndpoint, } from "./flaps.js";
|
|
21
|
+
// Sprites (sprites.dev) — the other Fly product: imperative, checkpointable
|
|
22
|
+
// sandbox activities. `loadActivities(["fly"])` provides these; the fake lives
|
|
23
|
+
// in `sprites-fake.ts` and is imported only by tests (not an activity). Unlike
|
|
24
|
+
// Machines, Sprites have no desired state to reconcile — they are runtime
|
|
25
|
+
// primitives driven inside an Op, with checkpoint-as-compensation as the
|
|
26
|
+
// headline capability.
|
|
27
|
+
export { spriteCreate, spriteExec, spriteCheckpoint, spriteRestore, listCheckpoints, spriteDestroy, spriteDelete, spriteUrl, resolveSpritesEndpoint, resolveSpritesToken, defaultSpritesHttp, spriteCreateBody, parseCreateResponse, accumulateExecFrames, parseCheckpointNdjson, pickCheckpointByComment, splitCommand, spriteExecWsUrl, DEFAULT_SPRITES_BASE_URL, } from "./sprites.js";
|
|
28
|
+
// Sprite Services activities (#2711) — imperative create/get/list/start/stop/
|
|
29
|
+
// delete/logs for one background service at a time, the single-service
|
|
30
|
+
// primitives underneath `spriteApplyServices`'s batch reconcile.
|
|
31
|
+
// `loadActivities(["fly"])` binds these; the step builders live in core.
|
|
32
|
+
export { spriteServiceCreate, spriteServiceGet, spriteServiceList, spriteServiceStart, spriteServiceStop, spriteServiceDelete, spriteServiceLogs, spriteServiceCreateBody, parseServiceLogNdjson, } from "./sprite-services.js";
|
|
33
|
+
// A sprite's services as resources a ConvergeOp observes and converges (#2778):
|
|
34
|
+
// the observer step and the restart a converge rule dispatches.
|
|
35
|
+
export { spriteServicesObserve, spriteServiceRestart, declaredServices, resolveDeclaredServices, findSpriteEnv, parseServicesList, probeHealth, } from "./sprite-service-converge.js";
|
|
36
|
+
// Sprite filesystem activities (#848) — imperative file I/O over the fs API.
|
|
37
|
+
// `loadActivities(["fly"])` binds these; the step builders live in core.
|
|
38
|
+
export { spriteWriteFile, spriteReadFile, spriteListDir, spriteRemove, spriteFsUrl, defaultSpritesRawHttp, } from "./sprite-fs.js";
|
|
39
|
+
// Sprite config reconcile activities (#849) — the desired-state a Sprite carries:
|
|
40
|
+
// its outbound network policy and its background services. `loadActivities(["fly"])`
|
|
41
|
+
// binds these; the step builders live in core. Apply-activities, not a declarable
|
|
42
|
+
// resource — a Sprite has no build→plan→apply pipeline.
|
|
43
|
+
export { spriteApplyNetworkPolicy, spriteApplyServices, validateNetworkRules, networkRulesEqual, validateServices, serviceConfigEqual, parseServiceDefinitions, listedServiceDiffers, spriteEnvCreateArgs, } from "./sprite-config.js";
|
|
44
|
+
// A box's declared services (#2880), read from its box block with `box: true`.
|
|
45
|
+
export { boxServices, expandServiceCommand, inStartOrder, serviceCommandArgv } from "./box-services.js";
|
|
46
|
+
// Sprite keep-alive Tasks activities (#847) — a hold that stops a Sprite pausing
|
|
47
|
+
// mid-session. `loadActivities(["fly"])` binds these; step builders live in core.
|
|
48
|
+
export { spriteTaskCreate, spriteTaskRefresh, spriteTaskRelease, spriteTasksUrl, spriteTaskUrl, } from "./sprite-tasks.js";
|
|
49
|
+
// spritzer (the Sprites API emulator) Docker lifecycle — the twin of mudflaps
|
|
50
|
+
// above. `spritesUp`/`spritesDown` resolve by name so an Op can boot/tear down
|
|
51
|
+
// the emulator as a modeled step; the sprite activities target it via
|
|
52
|
+
// SPRITES_BASE_URL.
|
|
53
|
+
export { spritesUp, spritesDown, spritesRunCommand, spritesRmCommand, spritesExistsCommand, spritesHealthUrl, spritesEndpoint, } from "./sprites-emulator.js";
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release a Fly Machine serves, and the Machines activities that put one
|
|
3
|
+
* there (#2736, ws-056).
|
|
4
|
+
*
|
|
5
|
+
* chud's `Chud::FlySite` ran a site as one App served by one Machine and kept
|
|
6
|
+
* the release in the Machine's metadata. That moves here: the App and the
|
|
7
|
+
* Machine are this lexicon's own resources, and a release is the declared
|
|
8
|
+
* Machine with {@link RELEASE_METADATA_KEYS} stamped into its
|
|
9
|
+
* `config.metadata`. Because the record is on the Machine itself, any checkout
|
|
10
|
+
* can read which release is serving (`describeResources` reports it, and
|
|
11
|
+
* `chant components status --live` compares it with the release ledger).
|
|
12
|
+
*
|
|
13
|
+
* The site steps are activities over the Machines API, each usable from an
|
|
14
|
+
* Op by name and composed by the `fly-release` and `fly-rollback` capabilities
|
|
15
|
+
* (../../components/fly-release.ts):
|
|
16
|
+
*
|
|
17
|
+
* - {@link flyMachineRelease}: upload and start. Apply the declared plan with
|
|
18
|
+
* the Machine serving the release (its image or env overridden when the
|
|
19
|
+
* release says so), and report the release and config it replaced.
|
|
20
|
+
* - {@link flyMachineExec}: run a command inside the Machine (a migration).
|
|
21
|
+
* - {@link flyMachineRestart}, {@link flyMachineStop}: under a lease, then
|
|
22
|
+
* wait for the Machine to settle.
|
|
23
|
+
* - {@link flyMachineVerify}: the Machine is started with this release in its
|
|
24
|
+
* metadata, and, given a URL, its health endpoint answers.
|
|
25
|
+
* - {@link flyMachineRestore}: put a recorded Machine config back (restore on
|
|
26
|
+
* a failed release, and rollback).
|
|
27
|
+
*
|
|
28
|
+
* Every activity takes the flaps endpoint and token the way `flyApply` does
|
|
29
|
+
* (`FLY_FLAPS_BASE_URL`, `FLY_API_TOKEN`), so the same code reaches mudflaps
|
|
30
|
+
* or a real Fly org, and an injectable `http` for tests (./machines-fake.ts).
|
|
31
|
+
*/
|
|
32
|
+
import { appNameFromRequest, defaultFlyHttp, flyApply, isAppRequest, isMachineRequest, listMachines, machineAppSegment, resolveApp, resolveEndpoint, waitForMachine, withLease, LEASE_NONCE_HEADER, } from "./fly-apply.js";
|
|
33
|
+
import { parsePlan } from "./fly-apply.js";
|
|
34
|
+
import { readFileSync } from "node:fs";
|
|
35
|
+
import { readMachineRelease, withReleaseMetadata } from "../../release-metadata.js";
|
|
36
|
+
export { RELEASE_METADATA_KEYS, readMachineRelease, withReleaseMetadata } from "../../release-metadata.js";
|
|
37
|
+
/**
|
|
38
|
+
* The Machine a release targets: the plan's one Machine, or the one `machine`
|
|
39
|
+
* names (an entity name or a Machine name). Pure.
|
|
40
|
+
*/
|
|
41
|
+
export function releaseTarget(plan, machine) {
|
|
42
|
+
const appNames = Object.values(plan).filter(isAppRequest).map(appNameFromRequest);
|
|
43
|
+
const soleApp = appNames.length === 1 ? appNames[0] : undefined;
|
|
44
|
+
const machines = Object.entries(plan)
|
|
45
|
+
.filter(([, req]) => isMachineRequest(req))
|
|
46
|
+
.map(([entity, request]) => ({
|
|
47
|
+
entity,
|
|
48
|
+
request,
|
|
49
|
+
name: typeof request.body.name === "string" && request.body.name ? request.body.name : entity,
|
|
50
|
+
}));
|
|
51
|
+
const picked = machine ? machines.filter((m) => m.entity === machine || m.name === machine) : machines;
|
|
52
|
+
if (picked.length !== 1) {
|
|
53
|
+
throw new Error(machine
|
|
54
|
+
? `the fly plan declares no Machine named "${machine}" (declared: ${machines.map((m) => m.entity).join(", ") || "none"})`
|
|
55
|
+
: `the fly plan declares ${machines.length} Machines (${machines.map((m) => m.entity).join(", ")}): name the one that serves the release`);
|
|
56
|
+
}
|
|
57
|
+
const [m] = picked;
|
|
58
|
+
return { app: resolveApp(machineAppSegment(m.request.endpoint), soleApp), entity: m.entity, name: m.name, request: m.request };
|
|
59
|
+
}
|
|
60
|
+
const TERMINAL = new Set(["destroyed", "destroying"]);
|
|
61
|
+
/** The live Machine named `name` (or with id `name`) in `app`; undefined when there is none. */
|
|
62
|
+
export async function findMachine(ctx, app, name, http, signal) {
|
|
63
|
+
const live = (await listMachines(ctx, app, http, signal)).filter((m) => !TERMINAL.has(m.state));
|
|
64
|
+
return live.find((m) => m.name === name) ?? live.find((m) => m.id === name);
|
|
65
|
+
}
|
|
66
|
+
async function mustFindMachine(ctx, app, name, http, signal) {
|
|
67
|
+
const m = await findMachine(ctx, app, name, http, signal);
|
|
68
|
+
if (!m)
|
|
69
|
+
throw new Error(`no machine ${name} on app ${app}`);
|
|
70
|
+
return m;
|
|
71
|
+
}
|
|
72
|
+
const machinePath = (ctx, app, id) => `${ctx.base}/v1/apps/${encodeURIComponent(app)}/machines/${encodeURIComponent(id)}`;
|
|
73
|
+
/**
|
|
74
|
+
* Upload and start: apply the declared plan (the App, its Volumes, IPs,
|
|
75
|
+
* Secrets) with the Machine serving `release`. The Machine is updated in place
|
|
76
|
+
* under a lease, so the release serves from here on. A Machine that already
|
|
77
|
+
* serves this release keeps the `previousDigest` it shipped with, so running a
|
|
78
|
+
* release twice never makes it its own predecessor.
|
|
79
|
+
*/
|
|
80
|
+
export async function flyMachineRelease(args, signal, http = defaultFlyHttp(args.token)) {
|
|
81
|
+
const plan = args.plan ?? parsePlan(readFileSync(requirePlanPath(args.planPath), "utf8"));
|
|
82
|
+
const target = releaseTarget(plan, args.machine);
|
|
83
|
+
const ctx = { base: resolveEndpoint(args) };
|
|
84
|
+
const live = await findMachine(ctx, target.app, target.name, http, signal).catch(() => undefined);
|
|
85
|
+
const liveConfig = live?.config;
|
|
86
|
+
const liveRelease = readMachineRelease(liveConfig?.metadata);
|
|
87
|
+
const previousDigest = liveRelease && liveRelease.digest !== args.release.digest ? liveRelease.digest : liveRelease?.previousDigest ?? args.release.previousDigest;
|
|
88
|
+
const release = { ...args.release, ...(previousDigest ? { previousDigest } : {}) };
|
|
89
|
+
if (!previousDigest)
|
|
90
|
+
delete release.previousDigest;
|
|
91
|
+
const declared = (target.request.body.config ?? {});
|
|
92
|
+
const declaredFiles = declared.files ?? [];
|
|
93
|
+
const releasePaths = new Set((args.files ?? []).map((f) => f.guest_path));
|
|
94
|
+
const cmd = typeof args.cmd === "string" ? ["sh", "-c", args.cmd] : args.cmd;
|
|
95
|
+
const config = withReleaseMetadata({
|
|
96
|
+
...declared,
|
|
97
|
+
...(args.image ? { image: args.image } : {}),
|
|
98
|
+
...(args.env ? { env: { ...(declared.env ?? {}), ...args.env } } : {}),
|
|
99
|
+
...(args.files ? { files: [...declaredFiles.filter((f) => !releasePaths.has(f.guest_path)), ...args.files] } : {}),
|
|
100
|
+
...(cmd ? { init: { ...(declared.init ?? {}), cmd } } : {}),
|
|
101
|
+
}, release);
|
|
102
|
+
const request = { ...target.request, body: { ...target.request.body, config } };
|
|
103
|
+
const applied = await flyApply({ plan: { ...plan, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait }, signal, http);
|
|
104
|
+
const action = applied.machines.find((m) => m.app === target.app && m.name === target.name)?.action ?? "noop";
|
|
105
|
+
const now = await mustFindMachine(ctx, target.app, target.name, http, signal);
|
|
106
|
+
return {
|
|
107
|
+
app: target.app,
|
|
108
|
+
machine: { id: now.id, name: target.name },
|
|
109
|
+
action,
|
|
110
|
+
release,
|
|
111
|
+
config,
|
|
112
|
+
previous: live ? { ...(liveRelease ? { release: liveRelease } : {}), config: liveConfig ?? {} } : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function requirePlanPath(planPath) {
|
|
116
|
+
if (!planPath)
|
|
117
|
+
throw new Error("pass planPath (the fly build output) or plan");
|
|
118
|
+
return planPath;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Run a command inside the Machine (the Machines API's exec). A non-zero exit
|
|
122
|
+
* throws, with the command's output, so the step fails rather than going on
|
|
123
|
+
* over a half-migrated database.
|
|
124
|
+
*/
|
|
125
|
+
export async function flyMachineExec(args, signal, http = defaultFlyHttp(args.token)) {
|
|
126
|
+
const ctx = { base: resolveEndpoint(args) };
|
|
127
|
+
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
128
|
+
const command = typeof args.command === "string" ? ["sh", "-c", args.command] : args.command;
|
|
129
|
+
const res = await http("POST", `${machinePath(ctx, args.app, m.id)}/exec`, { command, timeout: args.timeoutSecs ?? 120 }, undefined, signal);
|
|
130
|
+
if (res.status >= 300)
|
|
131
|
+
throw new Error(`exec on ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
132
|
+
let out = {};
|
|
133
|
+
try {
|
|
134
|
+
out = JSON.parse(res.text);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new Error(`exec on ${args.app}/${m.id} answered no exec result: ${res.text}`);
|
|
138
|
+
}
|
|
139
|
+
const result = { exitCode: out.exit_code ?? 0, stdout: out.stdout ?? "", stderr: out.stderr ?? "" };
|
|
140
|
+
if (result.exitCode !== 0) {
|
|
141
|
+
const said = [result.stderr, result.stdout].filter(Boolean).join("\n").trim().slice(-2000);
|
|
142
|
+
throw new Error(`${command.join(" ")} exited ${result.exitCode} on ${args.app}/${m.id}${said ? `: ${said}` : ""}`);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
async function transition(args, verb, signal, http) {
|
|
147
|
+
const ctx = { base: resolveEndpoint(args) };
|
|
148
|
+
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
149
|
+
const state = verb === "stop" ? "stopped" : "started";
|
|
150
|
+
if (verb === "stop" && m.state !== "started")
|
|
151
|
+
return { id: m.id, state: m.state };
|
|
152
|
+
const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) => http("POST", `${machinePath(ctx, args.app, m.id)}/${verb}`, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal));
|
|
153
|
+
if (res.status >= 300)
|
|
154
|
+
throw new Error(`${verb} of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
155
|
+
await waitForMachine(ctx, args.app, m.id, "", http, signal, { ...(args.wait ?? {}), state });
|
|
156
|
+
return { id: m.id, state };
|
|
157
|
+
}
|
|
158
|
+
/** Restart the Machine under a lease (the app starts again on migrated data), and wait for it to be started. */
|
|
159
|
+
export async function flyMachineRestart(args, signal, http = defaultFlyHttp(args.token)) {
|
|
160
|
+
return transition(args, "restart", signal, http);
|
|
161
|
+
}
|
|
162
|
+
/** Stop the Machine under a lease, and wait for it to be stopped. A Machine that is not started is left alone. */
|
|
163
|
+
export async function flyMachineStop(args, signal, http = defaultFlyHttp(args.token)) {
|
|
164
|
+
return transition(args, "stop", signal, http);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The Machine is started and its metadata names release `digest`. Given a
|
|
168
|
+
* `url`, its health endpoint answers 2xx too, and when that answer is JSON
|
|
169
|
+
* naming a `revision` or `digest`, it is this release's (the commit or the
|
|
170
|
+
* digest). Retries until `timeoutMs`, then throws the last reason.
|
|
171
|
+
*/
|
|
172
|
+
export async function flyMachineVerify(args, signal, http = defaultFlyHttp(args.token), fetchImpl = fetch) {
|
|
173
|
+
const ctx = { base: resolveEndpoint(args) };
|
|
174
|
+
const deadline = Date.now() + (args.timeoutMs ?? 120000);
|
|
175
|
+
const interval = args.intervalMs ?? 1000;
|
|
176
|
+
let why = "";
|
|
177
|
+
for (;;) {
|
|
178
|
+
if (signal?.aborted)
|
|
179
|
+
throw new Error("flyMachineVerify aborted");
|
|
180
|
+
try {
|
|
181
|
+
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
182
|
+
if (m.state !== "started")
|
|
183
|
+
throw new Error(`machine ${args.app}/${m.id} is ${m.state}`);
|
|
184
|
+
const release = readMachineRelease(m.config?.metadata);
|
|
185
|
+
if (release?.digest !== args.digest) {
|
|
186
|
+
throw new Error(`machine ${args.app}/${m.id} serves ${release?.digest ?? "no release"}, expected ${args.digest}`);
|
|
187
|
+
}
|
|
188
|
+
if (args.url) {
|
|
189
|
+
const url = `${args.url.replace(/\/$/, "")}${args.healthPath ?? "/health"}`;
|
|
190
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(5000) });
|
|
191
|
+
if (!res.ok)
|
|
192
|
+
throw new Error(`${url} answered ${res.status}`);
|
|
193
|
+
let body;
|
|
194
|
+
try {
|
|
195
|
+
body = (await res.json());
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
body = undefined;
|
|
199
|
+
}
|
|
200
|
+
const reported = typeof body?.revision === "string" ? body.revision : typeof body?.digest === "string" ? body.digest : undefined;
|
|
201
|
+
if (reported !== undefined && reported !== release.gitSha && reported !== release.digest) {
|
|
202
|
+
throw new Error(`${url} reports ${reported}, expected ${release.gitSha ?? release.digest}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { id: m.id, release };
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
why = err instanceof Error ? err.message : String(err);
|
|
209
|
+
}
|
|
210
|
+
if (Date.now() + interval > deadline)
|
|
211
|
+
throw new Error(`verify ${args.app}/${args.machine}: ${why}`);
|
|
212
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Put a recorded Machine config back: update the Machine to exactly `config`
|
|
217
|
+
* under a lease and wait for it to be started. Used on a failed release (the
|
|
218
|
+
* config it replaced) and by a rollback (the config an earlier release applied).
|
|
219
|
+
*/
|
|
220
|
+
export async function flyMachineRestore(args, signal, http = defaultFlyHttp(args.token)) {
|
|
221
|
+
const ctx = { base: resolveEndpoint(args) };
|
|
222
|
+
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
223
|
+
const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) => http("POST", machinePath(ctx, args.app, m.id), { name: m.name, config: args.config }, { [LEASE_NONCE_HEADER]: nonce }, signal));
|
|
224
|
+
if (res.status >= 300)
|
|
225
|
+
throw new Error(`restore of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
226
|
+
let instance = "";
|
|
227
|
+
try {
|
|
228
|
+
instance = JSON.parse(res.text).instance_id ?? "";
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
instance = "";
|
|
232
|
+
}
|
|
233
|
+
await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
|
|
234
|
+
const release = readMachineRelease(args.config.metadata);
|
|
235
|
+
return { id: m.id, ...(release ? { release } : {}) };
|
|
236
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
* The flaps endpoints ./fly-apply.ts depends on. Keep in sync with the applier —
|
|
21
|
+
* the unit test asserts every path segment appears in fly-apply.ts.
|
|
22
|
+
*/
|
|
23
|
+
export const MACHINES_CONTRACT = [
|
|
24
|
+
// Apps
|
|
25
|
+
{ method: "POST", path: "/v1/apps", op: "createApp" },
|
|
26
|
+
{ method: "GET", path: "/v1/apps/{app}", op: "getApp" },
|
|
27
|
+
{ method: "DELETE", path: "/v1/apps/{app}", op: "deleteApp" },
|
|
28
|
+
// Machines
|
|
29
|
+
{ method: "GET", path: "/v1/apps/{app}/machines", op: "listMachines" },
|
|
30
|
+
{ method: "POST", path: "/v1/apps/{app}/machines", op: "createMachine" },
|
|
31
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "updateMachine" },
|
|
32
|
+
// A running machine is stopped before the destroy (#3115); `?force=true` is a
|
|
33
|
+
// query, so it stays out of the path template.
|
|
34
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/stop", op: "stopForDestroy" },
|
|
35
|
+
{ method: "DELETE", path: "/v1/apps/{app}/machines/{id}", op: "destroyMachine" },
|
|
36
|
+
{ method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
|
|
37
|
+
// Leases
|
|
38
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/lease", op: "acquireLease" },
|
|
39
|
+
{ method: "DELETE", path: "/v1/apps/{app}/machines/{id}/lease", op: "releaseLease" },
|
|
40
|
+
// Volumes
|
|
41
|
+
{ method: "GET", path: "/v1/apps/{app}/volumes", op: "listVolumes" },
|
|
42
|
+
{ method: "POST", path: "/v1/apps/{app}/volumes", op: "createVolume" },
|
|
43
|
+
{ method: "DELETE", path: "/v1/apps/{app}/volumes/{id}", op: "deleteVolume" },
|
|
44
|
+
// IP assignments
|
|
45
|
+
{ method: "GET", path: "/v1/apps/{app}/ip_assignments", op: "listIps" },
|
|
46
|
+
{ method: "POST", path: "/v1/apps/{app}/ip_assignments", op: "allocateIp" },
|
|
47
|
+
{ method: "DELETE", path: "/v1/apps/{app}/ip_assignments/{ip}", op: "releaseIp" },
|
|
48
|
+
// Certificates
|
|
49
|
+
{ method: "GET", path: "/v1/apps/{app}/certificates", op: "listCerts" },
|
|
50
|
+
// Created at /acme: flaps has no POST on the list path (#3114).
|
|
51
|
+
{ method: "POST", path: "/v1/apps/{app}/certificates/acme", op: "addCert" },
|
|
52
|
+
{ method: "DELETE", path: "/v1/apps/{app}/certificates/{hostname}", op: "deleteCert" },
|
|
53
|
+
// Secrets
|
|
54
|
+
{ method: "GET", path: "/v1/apps/{app}/secrets", op: "listSecrets" },
|
|
55
|
+
{ method: "POST", path: "/v1/apps/{app}/secrets/{name}", op: "setSecret" },
|
|
56
|
+
{ method: "DELETE", path: "/v1/apps/{app}/secrets/{name}", op: "deleteSecret" },
|
|
57
|
+
];
|
|
58
|
+
/** Normalize a `METHOD path` key: collapse every `{param}` to `{}` so param names match. */
|
|
59
|
+
export function normalizeEndpoint(method, path) {
|
|
60
|
+
return `${method.toUpperCase()} ${path.replace(/\{[^}]+\}/g, "{}")}`;
|
|
61
|
+
}
|
|
62
|
+
/** The contract as a set of normalized `METHOD path` keys. */
|
|
63
|
+
export function contractKeys() {
|
|
64
|
+
return new Set(MACHINES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The flaps endpoints the release activities (./machine-release.ts, #2736)
|
|
68
|
+
* add beyond the applier's: getting one Machine back, exec (a migration),
|
|
69
|
+
* restart and stop. The applier's own contract above stays exec-free; these
|
|
70
|
+
* are the site steps', and the docker-gated coverage test holds the pinned
|
|
71
|
+
* mudflaps to them the same way.
|
|
72
|
+
*/
|
|
73
|
+
export const MACHINE_RELEASE_CONTRACT = [
|
|
74
|
+
{ method: "GET", path: "/v1/apps/{app}/machines", op: "findMachine" },
|
|
75
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "flyMachineRestore" },
|
|
76
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/exec", op: "flyMachineExec" },
|
|
77
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/restart", op: "flyMachineRestart" },
|
|
78
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/stop", op: "flyMachineStop" },
|
|
79
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/lease", op: "withLease" },
|
|
80
|
+
{ method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
|
|
81
|
+
];
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory Fly Machines (flaps) API, as a {@link FlyHttp} (#2736).
|
|
3
|
+
*
|
|
4
|
+
* The twin of ./sprites-fake.ts for the Machines side: the endpoints the
|
|
5
|
+
* applier (./fly-apply.ts) and the release activities (./machine-release.ts)
|
|
6
|
+
* call, answered from memory with the response shapes flaps and mudflaps use.
|
|
7
|
+
* Unit tests inject it where the default client would reach the network; the
|
|
8
|
+
* docker-gated tests run the same activities against the pinned mudflaps.
|
|
9
|
+
* It runs nothing; ./machines-local.ts is the opt-in mode that runs each
|
|
10
|
+
* started Machine's files and command on this host (#2831).
|
|
11
|
+
*
|
|
12
|
+
* Not an activity: nothing in ./index.ts exports it, so `loadActivities`
|
|
13
|
+
* never binds it.
|
|
14
|
+
*/
|
|
15
|
+
const json = (status, body) => ({ status, text: body === undefined ? "" : JSON.stringify(body) });
|
|
16
|
+
/** Fly's certificate-list page size: 25 when no `limit` is given, 500 at most. */
|
|
17
|
+
export const CERT_PAGE_DEFAULT = 25;
|
|
18
|
+
export const CERT_PAGE_MAX = 500;
|
|
19
|
+
// The fake's cursor is an offset in disguise; callers must treat it as opaque.
|
|
20
|
+
const offsetCursor = (offset) => Buffer.from(`certs:${offset}`).toString("base64url");
|
|
21
|
+
const cursorOffset = (cursor) => {
|
|
22
|
+
const m = /^certs:(\d+)$/.exec(Buffer.from(cursor, "base64url").toString());
|
|
23
|
+
return m ? Number(m[1]) : undefined;
|
|
24
|
+
};
|
|
25
|
+
let seq = 0;
|
|
26
|
+
const nextId = (prefix) => `${prefix}${(++seq).toString(16).padStart(10, "0")}`;
|
|
27
|
+
/**
|
|
28
|
+
* A fresh in-memory flaps. Like the real API, a bare DELETE on a machine that
|
|
29
|
+
* is not stopped, suspended, failed or created answers 412; `?force=true`
|
|
30
|
+
* destroys it in any state.
|
|
31
|
+
*/
|
|
32
|
+
export function createMachinesFake(options = {}) {
|
|
33
|
+
const apps = new Set();
|
|
34
|
+
const machines = new Map();
|
|
35
|
+
const certs = new Map();
|
|
36
|
+
const execs = [];
|
|
37
|
+
const calls = [];
|
|
38
|
+
const settle = options.settle ?? (() => "started");
|
|
39
|
+
const list = (app) => machines.get(app) ?? [];
|
|
40
|
+
const find = (app, id) => list(app).find((m) => m.id === id);
|
|
41
|
+
const http = async (method, url, body) => {
|
|
42
|
+
const { pathname, searchParams } = new URL(url);
|
|
43
|
+
calls.push(`${method} ${pathname}`);
|
|
44
|
+
const seg = pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
45
|
+
// seg: ["v1", "apps", app?, kind?, id?, action?]
|
|
46
|
+
if (seg[0] !== "v1" || seg[1] !== "apps")
|
|
47
|
+
return json(404, { error: "not found" });
|
|
48
|
+
const app = seg[2];
|
|
49
|
+
const b = (body ?? {});
|
|
50
|
+
if (!app) {
|
|
51
|
+
if (method === "POST") {
|
|
52
|
+
const name = String(b.app_name);
|
|
53
|
+
if (apps.has(name))
|
|
54
|
+
return json(409, { error: "app exists" });
|
|
55
|
+
apps.add(name);
|
|
56
|
+
return json(201, { name, status: "deployed" });
|
|
57
|
+
}
|
|
58
|
+
return json(200, [...apps].map((name) => ({ name })));
|
|
59
|
+
}
|
|
60
|
+
if (seg.length === 3) {
|
|
61
|
+
if (method === "GET")
|
|
62
|
+
return apps.has(app) ? json(200, { name: app, status: "deployed" }) : json(404, { error: "app not found" });
|
|
63
|
+
if (method === "DELETE") {
|
|
64
|
+
apps.delete(app);
|
|
65
|
+
machines.delete(app);
|
|
66
|
+
certs.delete(app);
|
|
67
|
+
return json(202, undefined);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!apps.has(app))
|
|
71
|
+
return json(404, { error: "app not found" });
|
|
72
|
+
const kind = seg[3];
|
|
73
|
+
if (kind === "machines") {
|
|
74
|
+
const id = seg[4];
|
|
75
|
+
const action = seg[5];
|
|
76
|
+
if (!id) {
|
|
77
|
+
if (method === "GET")
|
|
78
|
+
return json(200, list(app));
|
|
79
|
+
if (method === "POST") {
|
|
80
|
+
const m = {
|
|
81
|
+
id: nextId("m"),
|
|
82
|
+
name: String(b.name ?? ""),
|
|
83
|
+
state: "created",
|
|
84
|
+
instance_id: nextId("I"),
|
|
85
|
+
config: structuredClone((b.config ?? {})),
|
|
86
|
+
};
|
|
87
|
+
m.state = settle(m);
|
|
88
|
+
machines.set(app, [...list(app), m]);
|
|
89
|
+
return json(200, m);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const m = id ? find(app, id) : undefined;
|
|
93
|
+
if (!m)
|
|
94
|
+
return action === "wait" ? json(200, { ok: true }) : json(404, { error: "machine not found" });
|
|
95
|
+
if (!action) {
|
|
96
|
+
if (method === "GET")
|
|
97
|
+
return json(200, m);
|
|
98
|
+
if (method === "POST") {
|
|
99
|
+
m.config = structuredClone((b.config ?? {}));
|
|
100
|
+
m.instance_id = nextId("I");
|
|
101
|
+
m.state = settle(m);
|
|
102
|
+
return json(200, m);
|
|
103
|
+
}
|
|
104
|
+
if (method === "DELETE") {
|
|
105
|
+
// Real flaps destroys a running machine only with ?force=true and
|
|
106
|
+
// answers a bare DELETE with 412 (#3115). mudflaps does not (yet).
|
|
107
|
+
if (searchParams.get("force") !== "true" && !["stopped", "suspended", "failed", "created"].includes(m.state)) {
|
|
108
|
+
return json(412, {
|
|
109
|
+
error: "failed_precondition: unable to destroy machine, not currently stopped, suspended, failed or created",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
machines.set(app, list(app).filter((x) => x !== m));
|
|
113
|
+
return json(200, { ok: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (action === "lease") {
|
|
117
|
+
if (method === "POST")
|
|
118
|
+
return json(200, { data: { nonce: nextId("n") } });
|
|
119
|
+
return json(200, { ok: true });
|
|
120
|
+
}
|
|
121
|
+
if (action === "wait") {
|
|
122
|
+
const want = searchParams.get("state") ?? "started";
|
|
123
|
+
return json(200, { ok: m.state === want });
|
|
124
|
+
}
|
|
125
|
+
if (action === "restart" || action === "start") {
|
|
126
|
+
m.state = "started";
|
|
127
|
+
return json(200, { ok: true });
|
|
128
|
+
}
|
|
129
|
+
if (action === "stop") {
|
|
130
|
+
m.state = "stopped";
|
|
131
|
+
return json(200, { ok: true });
|
|
132
|
+
}
|
|
133
|
+
if (action === "exec" && method === "POST") {
|
|
134
|
+
const command = b.command ?? String(b.cmd ?? "").split(" ");
|
|
135
|
+
execs.push({ app, id: m.id, command });
|
|
136
|
+
return json(200, (await options.exec?.(app, m, command)) ?? { exit_code: 0, stdout: "", stderr: "" });
|
|
137
|
+
}
|
|
138
|
+
return json(404, { error: `no ${method} ${action}` });
|
|
139
|
+
}
|
|
140
|
+
if (kind === "ip_assignments")
|
|
141
|
+
return method === "GET" ? json(200, { ips: [] }) : json(200, {});
|
|
142
|
+
if (kind === "certificates")
|
|
143
|
+
return certificates(app, method, seg.slice(4), b, searchParams);
|
|
144
|
+
if (kind === "volumes" || kind === "secrets")
|
|
145
|
+
return method === "GET" ? json(200, []) : json(200, {});
|
|
146
|
+
return json(404, { error: "not found" });
|
|
147
|
+
};
|
|
148
|
+
// Certificates, as the real API routes them (#3114): listed at
|
|
149
|
+
// `.../certificates`, created only at `.../certificates/acme`, read and
|
|
150
|
+
// deleted at `.../certificates/{hostname}`. A POST to the bare list path is
|
|
151
|
+
// the 404 flaps gives, so an applier that posts there fails here too.
|
|
152
|
+
// The list pages like Fly's (#3224): `limit` defaults to 25 and is capped at
|
|
153
|
+
// 500, and `next_cursor` is present while certificates remain. mudflaps
|
|
154
|
+
// returns the whole list in one page (INTENTIUS/mudflaps#71).
|
|
155
|
+
function certificates(app, method, rest, b, q) {
|
|
156
|
+
const hostnames = certs.get(app) ?? [];
|
|
157
|
+
const detail = (hostname) => ({ hostname, acme_requested: true, configured: false, status: "pending" });
|
|
158
|
+
if (rest.length === 0) {
|
|
159
|
+
if (method !== "GET")
|
|
160
|
+
return { status: 404, text: "404 page not found" };
|
|
161
|
+
const limit = Math.min(CERT_PAGE_MAX, Math.max(1, Number(q.get("limit")) || CERT_PAGE_DEFAULT));
|
|
162
|
+
const cursor = q.get("cursor");
|
|
163
|
+
const from = cursor === null ? 0 : cursorOffset(cursor);
|
|
164
|
+
if (from === undefined)
|
|
165
|
+
return json(400, { error: `invalid cursor ${cursor}` });
|
|
166
|
+
const page = hostnames.slice(from, from + limit);
|
|
167
|
+
const more = from + limit < hostnames.length;
|
|
168
|
+
return json(200, {
|
|
169
|
+
certificates: page.map(detail),
|
|
170
|
+
...(more ? { next_cursor: offsetCursor(from + limit) } : {}),
|
|
171
|
+
total_count: hostnames.length,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (rest.length === 1 && rest[0] === "acme" && method === "POST") {
|
|
175
|
+
const hostname = typeof b.hostname === "string" ? b.hostname : "";
|
|
176
|
+
if (!hostname)
|
|
177
|
+
return json(422, { error: "hostname is required" });
|
|
178
|
+
if (hostnames.includes(hostname))
|
|
179
|
+
return json(422, { error: `certificate ${hostname} already exists` });
|
|
180
|
+
certs.set(app, [...hostnames, hostname]);
|
|
181
|
+
return json(201, detail(hostname));
|
|
182
|
+
}
|
|
183
|
+
if (rest.length === 1 && (method === "GET" || method === "DELETE")) {
|
|
184
|
+
const hostname = rest[0];
|
|
185
|
+
if (!hostnames.includes(hostname))
|
|
186
|
+
return json(404, { error: "certificate not found" });
|
|
187
|
+
if (method === "GET")
|
|
188
|
+
return json(200, detail(hostname));
|
|
189
|
+
certs.set(app, hostnames.filter((h) => h !== hostname));
|
|
190
|
+
return json(204, undefined);
|
|
191
|
+
}
|
|
192
|
+
return { status: 404, text: "404 page not found" };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
http,
|
|
196
|
+
apps,
|
|
197
|
+
machines,
|
|
198
|
+
certs,
|
|
199
|
+
execs,
|
|
200
|
+
calls,
|
|
201
|
+
machine: (app, name) => list(app).find((m) => m.name === name && !["destroyed", "destroying"].includes(m.state)),
|
|
202
|
+
};
|
|
203
|
+
}
|