diff --git a/console/src/deploy.ts b/console/src/deploy.ts index 4c0f6cf..8e2b56e 100644 --- a/console/src/deploy.ts +++ b/console/src/deploy.ts @@ -13,7 +13,7 @@ // after a confirmed successful provision, never before or speculatively. import type { Source } from "./source"; -import { appendMember, appendFleetBlock } from "./fleetToml"; +import { appendMember, appendFleetBlock, fleetBlockExists } from "./fleetToml"; import { appendK8sFleetBlock } from "./fleetsK8sToml"; type Invoke = (cmd: string, args?: Record) => Promise; @@ -488,12 +488,35 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null // Step 1 (new-fleet only): collect the fleet identity, then reveal the // shared Compose step — 7.5.1's "Next: first instance →". - identityForm.addEventListener("submit", (ev) => { + identityForm.addEventListener("submit", async (ev) => { ev.preventDefault(); - if (!nameInput.value.trim()) { + const fleetName = nameInput.value.trim(); + if (!fleetName) { setStatus(identityStatusEl, "fleet name is required", "err"); return; } + // Reject a colliding name here, before Step 2 provisions anything — + // appendFleetBlock/appendK8sFleetBlock always append a brand-new + // `[fleet.]` block, so reusing an existing name would otherwise + // only surface as a duplicate-key TOML parse error *after* the instance + // was already deployed (it has no partial/merge fallback; "add instance + // to an existing k8s fleet" isn't wired through this wizard yet either — + // see the isK8s check in the deploy submit handler below). + const isK8s = providerSel.value === "k8s"; + try { + const current = await (isK8s ? deps.source.k8sFleetConfig() : deps.source.fleetConfig()); + if (fleetBlockExists(current.text, fleetName)) { + setStatus( + identityStatusEl, + `a fleet named "${fleetName}" already exists — use "Add instance" on that fleet instead`, + "err", + ); + return; + } + } catch (e) { + setStatus(identityStatusEl, `fleet name check unavailable: ${errText(e)}`, "err"); + return; + } identityForm.hidden = true; composeSection.hidden = false; if (composeHeading) composeHeading.textContent = "Step 2 — first instance"; diff --git a/console/src/fleetToml.test.ts b/console/src/fleetToml.test.ts index f3d1c19..50b6527 100644 --- a/console/src/fleetToml.test.ts +++ b/console/src/fleetToml.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { appendMember, appendFleetBlock } from "./fleetToml"; +import { appendMember, appendFleetBlock, fleetBlockExists } from "./fleetToml"; describe("appendMember", () => { const text = `default_cluster = "oab" @@ -51,6 +51,26 @@ members = ["oab-default-mira-1"] }); }); +describe("fleetBlockExists", () => { + const text = `default_cluster = "oab" + +[fleet.oab-prod-orca] +members = ["oab-default-agent-1"] +`; + + it("is true when a [fleet.] block is present", () => { + expect(fleetBlockExists(text, "oab-prod-orca")).toBe(true); + }); + + it("is false when the name isn't present", () => { + expect(fleetBlockExists(text, "no-such-fleet")).toBe(false); + }); + + it("is false against an empty file", () => { + expect(fleetBlockExists("", "oab-prod-orca")).toBe(false); + }); +}); + describe("appendFleetBlock", () => { it("appends a new [fleet.] block with the given fields", () => { const out = appendFleetBlock("default_cluster = \"oab\"\n", { diff --git a/console/src/fleetToml.ts b/console/src/fleetToml.ts index dc4a348..89695be 100644 --- a/console/src/fleetToml.ts +++ b/console/src/fleetToml.ts @@ -32,6 +32,16 @@ function findFleetBlock( return { start, end, headerEnd }; } +// Whether a `[fleet.]` block already exists — used by the "New fleet" +// wizard (deploy.ts) to reject a colliding name *before* provisioning an +// instance, rather than discovering the collision only when appendFleetBlock/ +// appendK8sFleetBlock's blind append produces a second `[fleet.]` +// header and the resulting TOML fails to parse (studio: duplicate-key crash +// after the instance was already deployed). +export function fleetBlockExists(text: string, name: string): boolean { + return findFleetBlock(text, name) !== null; +} + // Append `member` to an existing fleet's `members = [...]` array (single-line // TOML array — the only form fleets.toml is written in today, per the ADR's // mockups). A no-op if the member is already listed or the fleet isn't found.