/** * fly (Machines API / "flaps") native applier — #739. * * Peer of `gcpApply` (#706): a direct-REST applier that does its own diff, * owned-only prune, and mutation waiting. GCP has no server-side declarative * apply, and neither does flaps, so this loop is hand-rolled — GET-then- * create/update per resource, poll `/wait` after each mutation, and prune only * what chant owns. Unlike GCP's long-running operations, flaps gates mutations * behind machine leases (a nonce echoed in the `fly-machine-lease-nonce` * header), so the tricky paths here are the lease-conflict retry and waiting on * the correct new `instance_id` after an update. * * Input is the #738 serializer's output: a JSON object keyed by entity name, * each value a single flaps create request `{ endpoint, method, body }` (app → * `/v1/apps`, machine → `/v1/apps/{app}/machines`). A machine endpoint may carry * a literal `{app}` placeholder when the stack declares more than one app; it is * resolved from the owning app at apply time. * * D1: direct REST, no flyctl, no state file. D2: owned-only prune via the * `managed-by: chant` machine metadata. D3: endpoint override via * `FLY_FLAPS_BASE_URL` (or an explicit `endpoint` arg), so the same code points * at real Fly or at mudflaps (`:4280`). */ import { type ApplyResult } from "@intentius/chant/apply"; /** Default flaps host when neither an `endpoint` arg nor `FLY_FLAPS_BASE_URL` is set. */ export declare const DEFAULT_FLAPS_BASE_URL = "https://api.machines.dev"; /** Header carrying a lease nonce on a mutating request, matching fly-go/flaps. */ export declare const LEASE_NONCE_HEADER = "fly-machine-lease-nonce"; /** One flaps REST call as emitted by the #738 serializer. */ export interface FlapsRequest { endpoint: string; method: string; body: Record; /** * D7: apply-only resources (Secrets) are POSTed but never read back for a * diff — flaps returns only a digest, never the value. `flyApply` always * POSTs these and excludes them from any drift/diff. */ applyOnly?: boolean; } /** The serializer's whole output: entity name → flaps create request. */ export type FlyPlan = Record; /** The subset of a live flaps machine the applier reads back. */ export interface FlapsMachine { id: string; name: string; state: string; instance_id: string; config?: { metadata?: Record; } & Record; } /** * Resolve the flaps base URL (D3): an explicit `endpoint` arg wins, then the * `FLY_FLAPS_BASE_URL` env, then the real-Fly default. The trailing slash is * stripped so `${base}/v1/...` never doubles up. Pure. */ export declare function resolveEndpoint(args?: { endpoint?: string; }, env?: NodeJS.ProcessEnv): string; /** Parse the serializer's JSON output into a plan. Pure. */ export declare function parsePlan(content: string): FlyPlan; /** True when a request creates an App (`POST /v1/apps`). Pure. */ export declare function isAppRequest(req: FlapsRequest): boolean; /** True when a request creates a Machine (`.../machines`). Pure. */ export declare function isMachineRequest(req: FlapsRequest): boolean; /** The app segment of a machine endpoint, or the literal `{app}` placeholder. Pure. */ export declare function machineAppSegment(endpoint: string): string; /** True when a request creates a Volume (`.../volumes`). Pure. */ export declare function isVolumeRequest(req: FlapsRequest): boolean; /** True when a request assigns an IP (`.../ip_assignments`). Pure. */ export declare function isIpRequest(req: FlapsRequest): boolean; /** True when a request creates a Certificate (`.../certificates`). Pure. */ export declare function isCertRequest(req: FlapsRequest): boolean; /** True when a request sets a Secret (`.../secrets/{name}`). Pure. */ export declare function isSecretRequest(req: FlapsRequest): boolean; /** * The app segment of any app-scoped resource endpoint (volumes, ip_assignments, * certificates, secrets), or the literal `{app}` placeholder. Pure. */ export declare function resourceAppSegment(endpoint: string): string; /** The secret name segment of a `.../secrets/{name}` endpoint. Pure. */ export declare function secretNameSegment(endpoint: string): string; /** * Resolve a machine's owning app: the endpoint segment as-is, unless it is the * `{app}` placeholder the serializer leaves when it can't decide — then fall * back to the stack's sole app. Throws when neither settles it. Pure. */ export declare function resolveApp(segment: string, soleApp: string | undefined): string; /** The `app_name` an app request creates. Pure. */ export declare function appNameFromRequest(req: FlapsRequest): string; /** * True when a machine's live metadata carries chant's ownership marker (D2). * Pure. Reads the key convention the fly lexicon declares via the #686 seam * (`FLY_METADATA_OWNERSHIP_KEYS`) through core's `hasOwnershipMarker`, so the * prune filter and the serializer's stamp can never drift apart — the marker * key lives in exactly one place. */ export declare function isChantOwned(metadata: Record | null | undefined): boolean; /** Structural equality over two config values, order-insensitive. Pure. */ export declare function configEqual(a: unknown, b: unknown): boolean; /** * The lease-conflict retry decision. A 409 whose body mentions a lease is a * stale/lost nonce (the `lease_currently_held` acquire envelope, or the "machine * is leased" gate on a mutation) — the caller should re-acquire and retry once. * A 409 that is not lease-shaped (e.g. "app already exists") is not retried. Pure. */ export declare function isLeaseConflict(status: number, text: string): boolean; /** * Injectable HTTP client — mirrors gcp-apply's `GcpHttp`, extended with a * per-call `headers` map so the applier can carry the `fly-machine-lease-nonce` * header on a mutation (and so tests can assert it). Tests inject a fake; the * default hits `fetch`. */ export type FlyHttp = (method: string, url: string, body?: unknown, headers?: Record, signal?: AbortSignal) => Promise<{ status: number; text: string; }>; /** * Default `fetch`-based client. Sends `Authorization: Bearer ` when a * token is set (real Fly); mudflaps ignores it. The token defaults to * `FLY_API_TOKEN` at call time. */ export declare function defaultFlyHttp(token?: string): FlyHttp; /** Wait-loop tuning. Defaults suit real flaps; tests shrink the interval. */ export interface WaitOpts { /** Server-side long-poll cap per request (clamped to 60s by flaps). */ timeoutSecs?: number; /** Delay between re-polls after a non-terminal response. */ intervalMs?: number; /** Overall client deadline across re-polls. */ deadlineMs?: number; } export interface ApplyCtx { base: string; } /** * Acquire a lease, retrying once on a lease-conflict 409. The retry is what * lets a stale/lost hold clear (the prior lease expired or was released) before * the mutation goes out with a fresh nonce. Returns the nonce. */ export declare function acquireLease(ctx: ApplyCtx, app: string, id: string, http: FlyHttp, signal?: AbortSignal): Promise; /** Best-effort lease release. A cleared lease (post-destroy) 404s — ignored. */ export declare function releaseLease(ctx: ApplyCtx, app: string, id: string, nonce: string, http: FlyHttp, signal?: AbortSignal): Promise; /** * Run a lease-gated mutation: acquire → mutate (nonce in the header) → release. * On a lease-conflict 409 from the mutation (a stale/lost nonce), re-acquire a * fresh nonce and retry the mutation once. `mutate` receives the nonce so the * caller can put it in the header; it returns the raw response. Returns the * final mutation response. */ export declare function withLease(ctx: ApplyCtx, app: string, id: string, http: FlyHttp, signal: AbortSignal | undefined, mutate: (nonce: string) => Promise<{ status: number; text: string; }>): Promise<{ status: number; text: string; }>; /** * Poll `GET .../wait` until the machine reaches `state` at the given `version` * (its new `instance_id`). flaps clamps its own timeout to 60s and answers 408 * on expiry, so the client re-polls until its own deadline. A destroyed+reaped * machine satisfies a `state=destroyed` wait (flaps returns `ok` on the missing * machine). `http` and the interval are injectable so tests avoid real waits. */ export declare function waitForMachine(ctx: ApplyCtx, app: string, id: string, version: string, http: FlyHttp, signal?: AbortSignal, opts?: WaitOpts & { state?: string; }): Promise; /** Create the app if absent (idempotent). A create-time conflict means it exists. */ export declare function applyApp(ctx: ApplyCtx, req: FlapsRequest, http: FlyHttp, signal?: AbortSignal): Promise<{ app: string; created: boolean; }>; /** List an app's live machines. */ export declare function listMachines(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise; /** * Reconcile one machine: create it when absent; when present, update it only if * its config drifted (else no-op). A create/update is followed by a wait on the * machine's new `instance_id`. Updates go through a lease. The declared machine * is identified by name (falling back to the plan entity name), so re-applying * an unchanged machine is a no-op. */ export declare function applyMachine(ctx: ApplyCtx, app: string, entityName: string, req: FlapsRequest, http: FlyHttp, signal?: AbortSignal, opts?: WaitOpts): Promise<{ action: "created" | "updated" | "noop"; id: string; name: string; }>; /** Lease → destroy → wait for the machine to be reaped. */ export declare function destroyMachine(ctx: ApplyCtx, app: string, id: string, http: FlyHttp, signal?: AbortSignal, opts?: WaitOpts): Promise; /** Delete an app (idempotent; a 404 means it is already gone). */ export declare function deleteApp(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise<{ app: string; deleted: boolean; }>; /** * Owned-only prune (D2): for one app, destroy the chant-owned machines whose * name is not in `keep`. An unmarked machine (no `managed-by: chant`) is never * touched, so it survives an apply that would otherwise delete it. Machines * already tearing down are skipped. */ export declare function pruneMachines(ctx: ApplyCtx, app: string, keep: Set, http: FlyHttp, signal?: AbortSignal, opts?: WaitOpts): Promise>; /** A live volume as flaps lists it. */ export interface FlapsVolume { id: string; name: string; state?: string; } /** A live IP assignment as flaps lists it. */ export interface FlapsIp { ip: string; shared?: boolean; } /** A live certificate as flaps lists it. */ export interface FlapsCert { hostname: string; } /** A live secret as flaps lists it (digest only, never the value). */ export interface FlapsSecret { name: string; } /** * The declared identity of an IP: its `type` collapses to a family key the * live-list can be mapped back to (shared v4 / dedicated v4 / v6). This lets a * re-apply of the same declared type be a no-op even though the address is * server-allocated. Pure. */ export declare function ipType(shared: boolean | undefined, address: string): string; /** The declared IP family from a request body's `type`. Pure. */ export declare function declaredIpType(type: unknown): string; /** List an app's live volumes (flaps returns a bare array). */ export declare function listVolumes(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise; /** List an app's live IP assignments (`{ ips: [...] }`). */ export declare function listIps(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise; /** List an app's live certificates (`{ certificates: [...] }`). */ export declare function listCerts(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise; /** List an app's live secrets (`{ secrets: [...] }`; digests only, never values). */ export declare function listSecrets(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise; /** Create a volume if absent (idempotent by name). Machines that mount it apply after. */ export declare function applyVolume(ctx: ApplyCtx, app: string, entityName: string, req: FlapsRequest, http: FlyHttp, signal?: AbortSignal): Promise<{ action: "created" | "noop"; name: string; }>; /** Assign an IP if the declared type is not already present (idempotent by family). */ export declare function applyIp(ctx: ApplyCtx, app: string, req: FlapsRequest, http: FlyHttp, signal?: AbortSignal): Promise<{ action: "created" | "noop"; type: string; }>; /** Create a certificate if absent (idempotent by hostname). */ export declare function applyCert(ctx: ApplyCtx, app: string, req: FlapsRequest, http: FlyHttp, signal?: AbortSignal): Promise<{ action: "created" | "noop"; hostname: string; }>; /** * Set a secret (D7, apply-only): always POST, never read back for a diff. flaps * returns only a digest, so there is nothing to compare — every apply re-sets it. */ export declare function applySecret(ctx: ApplyCtx, app: string, name: string, req: FlapsRequest, http: FlyHttp, signal?: AbortSignal): Promise<{ action: "set"; name: string; }>; /** App-scoped prune (D2): destroy volumes the plan no longer declares (by name). */ export declare function pruneVolumes(ctx: ApplyCtx, app: string, keep: Set, http: FlyHttp, signal?: AbortSignal): Promise>; /** App-scoped prune (D2): release IP assignments whose type the plan no longer declares. */ export declare function pruneIps(ctx: ApplyCtx, app: string, keep: Set, http: FlyHttp, signal?: AbortSignal): Promise>; /** App-scoped prune (D2): destroy certificates whose hostname the plan no longer declares. */ export declare function pruneCerts(ctx: ApplyCtx, app: string, keep: Set, http: FlyHttp, signal?: AbortSignal): Promise>; /** * App-scoped prune (D2) for apply-only secrets: a declared-then-removed secret * is still prunable by name, even though it never enters a drift/diff. */ export declare function pruneSecrets(ctx: ApplyCtx, app: string, keep: Set, http: FlyHttp, signal?: AbortSignal): Promise>; export interface FlyApplyArgs { /** Path to the #738 serializer's JSON output (entity name → flaps request). */ planPath: string; /** flaps endpoint override (D3). Default: `FLY_FLAPS_BASE_URL` env, else real Fly. */ endpoint?: string; /** Bearer token for real Fly. Default: `FLY_API_TOKEN`. mudflaps ignores it. */ token?: string; /** * Prune (D2): destroy declared-then-removed resources. Machines are owned-only * (chant metadata marker); an unmarked machine is never touched. The * metadata-less types (volumes/ips/certs/secrets) are app-scoped: anything the * plan no longer declares under a managed app is removed. Destructive — off by * default. */ prune?: boolean; /** Wait-loop tuning (mainly for tests). */ wait?: WaitOpts; } /** * The native fly applier (#739, #741): read the serialized plan and apply it * straight to flaps in dependency order — app → volumes → machines → ips → * certificates → secrets — then optionally prune. Machines prune owned-only via * the metadata marker (D2); the metadata-less types (volumes/ips/certs/secrets) * prune app-scoped: everything the plan no longer declares under a managed app. * Secrets are apply-only (D7): set, never read back for a diff. `http` is * injectable for tests. */ /** * Normalize a flyApply result into core's apply envelope (#1446). * * fly returns eleven arrays because it applies six entity classes and prunes * five. That detail is the applier's own and stays; this projects it onto the * shared tri-state so a caller can read any applier's result the same way. * * `notAttempted` is always empty for fly, and that is not an oversight: an entry * the applier cannot classify now throws (#1457) rather than being skipped, * because the serializer produced it and the two being out of sync is a bug in * the lexicon, not a resource the user declined. */ export declare function toApplyResult(result: { apps: Array<{ app: string; created: boolean; }>; machines: Array<{ app: string; name: string; action: "created" | "updated" | "noop"; }>; volumes: Array<{ app: string; name: string; action: "created" | "noop"; }>; ips: Array<{ app: string; type: string; action: "created" | "noop"; }>; certs: Array<{ app: string; hostname: string; action: "created" | "noop"; }>; secrets: Array<{ app: string; name: string; }>; pruned: Array<{ app: string; name: string; id: string; }>; prunedVolumes: Array<{ app: string; name: string; id: string; }>; prunedIps: Array<{ app: string; address: string; }>; prunedCerts: Array<{ app: string; hostname: string; }>; prunedSecrets: Array<{ app: string; name: string; }>; }): ApplyResult; export declare function flyApply(args: FlyApplyArgs, signal?: AbortSignal, http?: FlyHttp): Promise<{ apps: Array<{ app: string; created: boolean; }>; machines: Array<{ app: string; name: string; action: "created" | "updated" | "noop"; }>; volumes: Array<{ app: string; name: string; action: "created" | "noop"; }>; ips: Array<{ app: string; type: string; action: "created" | "noop"; }>; certs: Array<{ app: string; hostname: string; action: "created" | "noop"; }>; secrets: Array<{ app: string; name: string; }>; pruned: Array<{ app: string; name: string; id: string; }>; prunedVolumes: Array<{ app: string; name: string; id: string; }>; prunedIps: Array<{ app: string; address: string; }>; prunedCerts: Array<{ app: string; hostname: string; }>; prunedSecrets: Array<{ app: string; name: string; }>; }>; /** * The inverse of {@link flyApply}: destroy the machines the plan declares, then * delete the apps (dependents before their app). Idempotent — already-absent * resources are a no-op. `http` is injectable for tests. */ export declare function flyDelete(args: FlyApplyArgs, signal?: AbortSignal, http?: FlyHttp): Promise<{ machines: Array<{ app: string; name: string; }>; apps: Array<{ app: string; deleted: boolean; }>; }>; //# sourceMappingURL=fly-apply.d.ts.map