UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

344 lines (342 loc) • 12.1 kB
import { BosConfigSchema } from "../types.mjs"; import { fetchBosConfigFromFastKv, fetchConfigHistory, fetchDeployManifests } from "../fastkv.mjs"; import { MISSING_CONFIG_MESSAGE } from "../config.mjs"; import { colors, icons } from "../utils/theme.mjs"; import { buildAndPushImage, deployImageToRailway, hasDocker, isValidImageDigest, resolveDeployImagePlan, resolveImageRef } from "../image-deploy.mjs"; import { openResolution } from "../resolution/session.mjs"; import { BosDepsTag } from "./shared.mjs"; import { buildRollbackPayload, summarizeSlotPins, verifyRollbackSnapshot } from "../rollback.mjs"; import { publishToFastKv } from "../publish.mjs"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { Context } from "effect"; import process from "node:process"; //#region src/commands/deploy.ts function registerDeploy(builder) { return { publish: builder.publish.handler(async ({ input, context }) => { const session = Context.get(context["effect/context"], BosDepsTag).session; if (!session?.config) return { status: "error", registryUrl: "", error: MISSING_CONFIG_MESSAGE }; const result = await publishToFastKv({ bosConfig: session.config, runtimeConfig: session.runtime, configDir: session.root, env: input.env, build: false, dryRun: input.dryRun, verbose: input.verbose, packages: "all", network: input.network, privateKey: input.privateKey, wallet: input.wallet, registry: input.registry }); return { status: result.status, registryUrl: result.registryUrl, txHash: result.txHash, error: result.error, built: result.built, skipped: result.skipped, deployResults: result.deployResults }; }), rollback: builder.rollback.handler(async ({ input, context }) => { const session = Context.get(context["effect/context"], BosDepsTag).session; if (!session) return { status: "error", registryUrl: "", error: MISSING_CONFIG_MESSAGE }; const { account, domain } = session.config ?? {}; if (!account || !domain) return { status: "error", registryUrl: "", error: "authored config must define account and domain to roll back" }; let history; try { history = await fetchConfigHistory({ accountId: account, gatewayId: domain, registry: input.registry, limit: input.limit }); } catch (error) { return { status: "error", registryUrl: "", error: `Failed to fetch publish history: ${error instanceof Error ? error.message : String(error)}` }; } if (history.length === 0) return { status: "error", registryUrl: "", error: `No publish history for ${account}/${domain} — nothing to roll back to` }; const historyEntries = history.map((entry) => ({ blockHeight: entry.blockHeight, blockTimestamp: new Date(Number(entry.blockTimestampNs.slice(0, 13))).toISOString(), ...entry.txHash ? { txHash: entry.txHash } : {}, summary: summarizeSlotPins(entry.value) })); if (input.listOnly) return { status: "list", registryUrl: "", history: historyEntries }; const target = input.version !== void 0 ? history.find((entry) => { const iso = new Date(Number(entry.blockTimestampNs.slice(0, 13))).toISOString(); return String(entry.blockHeight) === input.version || iso.startsWith(input.version); }) : input.previous ? history[1] : void 0; if (!target) return { status: "error", registryUrl: "", history: historyEntries, error: input.version ? `No history entry matches "${input.version}" — pick a block height from the listing` : "Specify --version <block-height> or --previous (interactive selection happens CLI-side)" }; let liveValue; try { liveValue = await fetchBosConfigFromFastKv(`bos://${account}/${domain}`, input.registry); } catch { liveValue = void 0; } if (liveValue && JSON.stringify(liveValue) === JSON.stringify(target.value)) return { status: "error", registryUrl: "", error: "The selected snapshot is identical to the live config — nothing to do" }; const verification = await verifyRollbackSnapshot(target.value); const slotChecks = verification.slots.map((check) => ({ slot: check.slot, ok: check.ok, ...check.reason ? { reason: check.reason } : {} })); if (!verification.ok && verification.verifiable) return { status: "error", registryUrl: "", verification: slotChecks, error: "Refusing to roll back — a pinned slot's bytes are gone or no longer match their SRI" }; if (!verification.verifiable && !input.force) return { status: "error", registryUrl: "", error: "Snapshot predates version manifests and cannot be verified — its bytes were overwritten in place. Re-run with --force to publish it anyway." }; const parsedTarget = BosConfigSchema.parse(target.value); const payload = buildRollbackPayload(parsedTarget, (/* @__PURE__ */ new Date()).toISOString()); const result = await publishToFastKv({ bosConfig: payload, runtimeConfig: session.runtime, configDir: session.root, env: input.env, build: false, dryRun: input.dryRun, verbose: input.verbose, packages: "all", network: input.network, privateKey: input.privateKey, wallet: input.wallet, registry: input.registry }); return { status: result.status, registryUrl: result.registryUrl, txHash: result.txHash, error: result.error, history: historyEntries, ...slotChecks.length > 0 ? { verification: slotChecks } : {} }; }), deploy: builder.deploy.handler(async ({ input, context }) => { const session = Context.get(context["effect/context"], BosDepsTag).session; if (!session?.config) return { status: "error", registryUrl: "", error: MISSING_CONFIG_MESSAGE }; if (input.statusList) { if (!session.config?.account || !session.config.domain) return { status: "error", registryUrl: "", error: "authored config must define account and domain to list deploy manifests" }; try { return { status: "list", registryUrl: "", history: (await fetchDeployManifests({ accountId: session.config.account, gatewayId: session.config.domain, registry: input.registry })).map((entry) => ({ key: entry.key, blockHeight: entry.blockHeight, blockTimestamp: new Date(Number(entry.blockTimestampNs.slice(0, 13))).toISOString(), ...entry.value?.txHash ? { txHash: entry.value.txHash } : {}, ...entry.value?.publishedAt ? { publishedAt: entry.value.publishedAt } : {} })) }; } catch (error) { return { status: "error", registryUrl: "", error: `Failed to list deploy manifests: ${error instanceof Error ? error.message : String(error)}` }; } } const imageDigestInput = input.imageDigest ?? process.env.BOS_IMAGE_DIGEST; if (imageDigestInput && !isValidImageDigest(imageDigestInput)) return { status: "error", registryUrl: "", error: `Invalid image digest "${imageDigestInput}" — expected sha256:<64 hex characters> (--image-digest / BOS_IMAGE_DIGEST)` }; const result = await publishToFastKv({ bosConfig: session.config, runtimeConfig: session.runtime, configDir: session.root, env: input.env, build: input.build, dryRun: input.dryRun, verbose: input.verbose, packages: input.packages, network: input.network, privateKey: input.privateKey, registry: input.registry, fullUpload: input.fullUpload }); if (result.status === "error") return { status: "error", registryUrl: result.registryUrl, txHash: result.txHash, built: result.built, skipped: result.skipped, error: result.error, deployResults: result.deployResults }; if (result.status === "dry-run") return { status: "dry-run", registryUrl: result.registryUrl, built: result.built, skipped: result.skipped }; let nextSession = session; if (result.publishConfig) { const opened = await openResolution({ cwd: session.root }); if (opened) nextSession = opened; } const configPublished = { registryUrl: result.registryUrl, txHash: result.txHash, fingerprint: result.fingerprint, slotPins: result.slotPins, built: result.built, skipped: result.skipped, deployResults: result.deployResults }; let image; let imageDigest; let service; const imagePlan = resolveDeployImagePlan({ imageRef: resolveImageRef({ ciImage: imageDigestInput ? void 0 : nextSession.config?.ci?.image, repository: nextSession.config?.repository, env: process.env }), imageDigest: imageDigestInput, hasDocker: await hasDocker(), hasDockerfile: existsSync(join(session.root, "Dockerfile")) }); if (imagePlan.kind === "skip") { console.log(); console.log(colors.yellow(` Image skipped: ${imagePlan.reason}`)); } else if (imagePlan.kind === "prebuilt") { image = imagePlan.image; imageDigest = imagePlan.digest; console.log(); console.log(colors.green(` ${icons.ok} Using pre-pushed image ${imagePlan.image}@${imagePlan.digest.slice(0, 19)}…`)); } else { console.log(); try { const imageResult = await buildAndPushImage({ image: imagePlan.image, configDir: session.root, verbose: input.verbose }); image = imageResult.image; imageDigest = imageResult.digest; console.log(colors.green(` ${icons.ok} Image pushed ${imageResult.image}:${imageResult.tag}${imageResult.digest ? ` (${imageResult.digest.slice(0, 19)}…)` : ""}`)); } catch (error) { const message = error instanceof Error ? error.message : String(error); const imageError = message.includes("not found") || message.includes("ENOENT") ? "Docker not found — install Docker to build the runtime image" : `Image build/push failed: ${message}`; console.log(colors.yellow(` ${imageError}`)); return { status: "published", ...configPublished, error: `Config published but ${imageError}` }; } } if (process.env.RAILWAY_TOKEN) { const railwayService = input.service ?? nextSession.config?.ci?.railway?.service; if (!railwayService) { console.log(); console.log(colors.yellow(" Railway deploy skipped: ci.railway.service is not configured in bos.config.json")); return { status: "published", ...configPublished, image, error: "Config published but Railway deploy failed: ci.railway.service is not configured in bos.config.json" }; } if (!image || !imageDigest) { console.log(); console.log(colors.yellow(" Railway deploy skipped: no runtime image digest — set ci.image (or pass BOS_IMAGE_DIGEST) and make the image available")); return { status: "published", ...configPublished, image, service: railwayService, error: "Config published but Railway deploy requires a runtime image (set ci.image, or pass BOS_IMAGE_DIGEST for a pre-pushed image)" }; } service = railwayService; try { await deployImageToRailway({ image, digest: imageDigest, service: railwayService, configDir: session.root }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const railError = message.includes("not found") || message.includes("ENOENT") ? "Railway CLI not found. Install it: npm i -g @railway/cli" : `Railway deploy failed: ${message}`; console.log(colors.yellow(` ${railError}`)); return { status: "published", ...configPublished, image, service, error: `Config published but ${railError}` }; } } else { console.log(); console.log(colors.yellow(" Railway deploy skipped (RAILWAY_TOKEN not set)")); } return { status: "deployed", ...configPublished, image, service }; }) }; } //#endregion export { registerDeploy }; //# sourceMappingURL=deploy.mjs.map