diff --git a/app/src/components/settings/hosts/AdminBundleModal.tsx b/app/src/components/settings/hosts/AdminBundleModal.tsx index 6630be5..2707669 100644 --- a/app/src/components/settings/hosts/AdminBundleModal.tsx +++ b/app/src/components/settings/hosts/AdminBundleModal.tsx @@ -29,6 +29,7 @@ export default function AdminBundleModal({ ownerId, network, reason, + onSaved, onClose, }: { connectionId: string; @@ -40,6 +41,8 @@ export default function AdminBundleModal({ network?: NetworkChoice; /** Why the bundle is needed (e.g. the refusal that led here). */ reason?: string; + /** Called with the folder once a bundle was actually written. */ + onSaved?: (folder: string) => void; onClose: () => void; }) { const askEnvironment = kind === "environment" && !environment; @@ -63,7 +66,10 @@ export default function AdminBundleModal({ ownerId, network: picked && "network" in picked ? picked.network : network, }); - if (folder) setSaved(folder); + if (folder) { + setSaved(folder); + onSaved?.(folder); + } } catch (e) { setError(toAwsError(e)); } finally { diff --git a/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx b/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx index 6003815..307442b 100644 --- a/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx +++ b/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx @@ -45,7 +45,7 @@ describe("DeployEnvironmentModal", () => { handlers.clear(); }); - it("the public network mode is the default and says it costs nothing", () => { + it("the public network mode is the default and says it costs nothing", async () => { const props = renderModal(); expect(screen.getByRole("radio", { name: "Public" })).toHaveAttribute("aria-checked", "true"); expect(screen.getByText("$0 — tasks get a public IP, no inbound access")).toBeInTheDocument(); @@ -56,9 +56,10 @@ describe("DeployEnvironmentModal", () => { expect(screen.getByLabelText("Name")).toHaveValue("main"); fireEvent.click(screen.getByRole("button", { name: "Deploy" })); expect(props.onDeploy).toHaveBeenCalledWith("main", { mode: "public" }); + await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); - it("validates the name with the backend's rule", () => { + it("validates the name with the backend's rule", async () => { const props = renderModal({ scan: scan({ environments: [env({ name: "main" })] }) }); const deploy = screen.getByRole("button", { name: "Deploy" }); expect(screen.getByText(/already exists/)).toBeInTheDocument(); @@ -71,9 +72,10 @@ describe("DeployEnvironmentModal", () => { expect(deploy).toBeEnabled(); fireEvent.click(deploy); expect(props.onDeploy).toHaveBeenCalledWith("team-2", { mode: "public" }); + await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); - it("an existing network needs a VPC and two distinct subnets", () => { + it("an existing network needs a VPC and two distinct subnets", async () => { const props = renderModal(); fireEvent.click(screen.getByRole("radio", { name: "Existing VPC" })); const deploy = screen.getByRole("button", { name: "Deploy" }); @@ -91,6 +93,7 @@ describe("DeployEnvironmentModal", () => { subnet_ids: ["subnet-0123abcd", "subnet-4567abcd"], assign_public_ip: true, }); + await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); it("keeps the dialog open and shows the server's refusal", async () => { @@ -111,9 +114,9 @@ describe("DeployEnvironmentModal", () => { expect(screen.queryByLabelText("Name")).toBeNull(); expect(screen.queryByRole("button", { name: "Deploy" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" })); - await waitFor(() => - expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" }), - ); + // Running (op id known) is the visible sign the invoke resolved. + expect(await screen.findByText("Running")).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" }); emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, events: [tofuEvent("Creating boundary")] })); expect(screen.getByText("Creating boundary")).toBeInTheDocument(); emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "Apply complete!" } })); @@ -121,7 +124,29 @@ describe("DeployEnvironmentModal", () => { expect(props.onDetect).toHaveBeenCalled(); }); - it("opens the boundary bundle when creating the boundary is denied", async () => { + it("offers the boundary bundle when the boundary run fails, with its summary as the reason", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "deploy_aws_permissions_boundary") return "b1"; + if (cmd === "save_aws_admin_bundle") return "/tmp/b"; + throw new Error(`unexpected ${cmd}`); + }); + const props = renderModal({ scan: scan({ boundary: "no" }) }); + fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" })); + expect(await screen.findByText("Running")).toBeInTheDocument(); + const summary = "Error: creating IAM Policy (triple-c-permissions-boundary): AccessDenied: not authorized to perform iam:CreatePolicy"; + emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: false, exit_code: 1, summary } })); + expect(props.onDetect).toHaveBeenCalled(); + // Offered, not opened. + expect(screen.queryByRole("dialog", { name: "Permissions boundary bundle" })).toBeNull(); + expect(screen.queryByLabelText("Name")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Get the boundary bundle for an administrator" })); + const dialog = screen.getByRole("dialog", { name: "Permissions boundary bundle" }); + expect(within(dialog).getByRole("alert")).toHaveTextContent(summary); + fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" })); + expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument(); + }); + + it("opens the boundary bundle when the boundary command itself is denied", async () => { invoke.mockImplementation(async (cmd: string) => { if (cmd === "deploy_aws_permissions_boundary") throw { kind: "access_denied", action: "iam:CreatePolicy", message: "denied iam:CreatePolicy" }; if (cmd === "save_aws_admin_bundle") return "/tmp/b"; @@ -142,9 +167,10 @@ describe("DeployEnvironmentModal", () => { network: null, }), ); + expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument(); }); - it("repairs a named environment and warns that a different network replaces it", () => { + it("repairs a named environment and warns that a different network replaces it", async () => { const props = renderModal({ repair: "dev" }); expect(screen.getByRole("dialog", { name: "Repair dev" })).toBeInTheDocument(); expect(screen.queryByLabelText("Name")).toBeNull(); @@ -152,5 +178,6 @@ describe("DeployEnvironmentModal", () => { fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" })); fireEvent.click(screen.getByRole("button", { name: "Repair" })); expect(props.onDeploy).toHaveBeenCalledWith("dev", { mode: "private-nat" }); + await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); }); diff --git a/app/src/components/settings/hosts/DeployEnvironmentModal.tsx b/app/src/components/settings/hosts/DeployEnvironmentModal.tsx index 6fccbea..c061795 100644 --- a/app/src/components/settings/hosts/DeployEnvironmentModal.tsx +++ b/app/src/components/settings/hosts/DeployEnvironmentModal.tsx @@ -6,7 +6,7 @@ import Button from "../../ui/Button"; import Field, { inputClass } from "../../ui/Field"; import Modal from "../../ui/Modal"; import AdminBundleModal from "./AdminBundleModal"; -import DeployProgress, { useDeployRuns } from "./DeployProgress"; +import DeployProgress, { runKey, useDeployRuns } from "./DeployProgress"; import NetworkFields from "./NetworkFields"; import { BOUNDARY_STATE_KEY, @@ -51,7 +51,8 @@ export default function DeployEnvironmentModal({ if (result?.ok) setBoundaryDone(true); onDetect(); }); - const boundaryRun = boundary.runs[BOUNDARY_STATE_KEY]; + const boundaryRun = boundary.runs[runKey(region, BOUNDARY_STATE_KEY)]; + const boundaryFailed = boundaryRun?.done && boundaryRun.result && !boundaryRun.result.ok ? boundaryRun.result : null; if (bundle) { return ( @@ -137,6 +138,24 @@ export default function DeployEnvironmentModal({

)} {boundaryRun && } + {boundaryFailed && ( +
+

+ Creating the boundary failed. If it was refused, an account administrator can create it from a + bundle. +

+ +
+ )} ) : (
diff --git a/app/src/components/settings/hosts/DeployProgress.test.tsx b/app/src/components/settings/hosts/DeployProgress.test.tsx new file mode 100644 index 0000000..0e4e17c --- /dev/null +++ b/app/src/components/settings/hosts/DeployProgress.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { runKey, useDeployRuns } from "./DeployProgress"; +import type { DeployProgress } from "../../../lib/types"; +import { progress, tofuEvent } from "./testkit"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +type Handler = (e: { payload: DeployProgress }) => void; +const handlers = new Set(); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (_name: string, handler: Handler) => { + handlers.add(handler); + return () => handlers.delete(handler); + }), +})); +function emit(p: DeployProgress) { + act(() => { + for (const h of [...handlers]) h({ payload: p }); + }); +} + +const KEY = "environments/main/terraform.tfstate"; + +describe("useDeployRuns", () => { + beforeEach(() => handlers.clear()); + + it("tracks runs for the same state key in two regions independently", async () => { + const onDone = vi.fn(); + const { result } = renderHook(() => useDeployRuns(onDone)); + await act(async () => { + await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => "eu"); + }); + await act(async () => { + await result.current.start(KEY, "us-east-1", "Deploy of main", async () => "us"); + }); + expect(handlers.size).toBe(2); + emit(progress({ op_id: "eu", state_key: KEY, events: [tofuEvent("eu line")] })); + emit(progress({ op_id: "us", state_key: KEY, events: [tofuEvent("us line")] })); + emit(progress({ op_id: "us", state_key: KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "ok" } })); + + const eu = result.current.runs[runKey("eu-west-1", KEY)]; + const us = result.current.runs[runKey("us-east-1", KEY)]; + expect(eu.events.map((e) => e.message)).toEqual(["eu line"]); + expect(eu.done).toBe(false); + expect(us.events.map((e) => e.message)).toEqual(["us line"]); + expect(us.done).toBe(true); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onDone).toHaveBeenCalledWith({ stateKey: KEY, region: "us-east-1", result: { ok: true, exit_code: 0, summary: "ok" } }); + // The finished run's listener is gone; the other region's is still there. + expect(handlers.size).toBe(1); + }); + + it("removes its progress listeners on unmount", async () => { + const { result, unmount } = renderHook(() => useDeployRuns()); + await act(async () => { + await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => "a"); + await result.current.start("boundary/terraform.tfstate", "eu-west-1", "Boundary", async () => "b"); + }); + expect(handlers.size).toBe(2); + unmount(); + expect(handlers.size).toBe(0); + }); + + it("a rejected start removes its listener and returns the AWS error", async () => { + const { result } = renderHook(() => useDeployRuns()); + let err: unknown; + await act(async () => { + err = await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => { + throw { kind: "other", message: "A deployment is already running" }; + }); + }); + expect(err).toEqual({ kind: "other", message: "A deployment is already running" }); + expect(handlers.size).toBe(0); + expect(result.current.runs).toEqual({}); + }); +}); diff --git a/app/src/components/settings/hosts/DeployProgress.tsx b/app/src/components/settings/hosts/DeployProgress.tsx index 7f61ae6..bd51d1b 100644 --- a/app/src/components/settings/hosts/DeployProgress.tsx +++ b/app/src/components/settings/hosts/DeployProgress.tsx @@ -28,13 +28,18 @@ export interface DeployRun { type Subscription = { unlisten: (() => void) | null }; +/** Runs are tracked per region and state key: the same key exists in every region. */ +export const runKey = (region: string, stateKey: string) => `${region}:${stateKey}`; + /** - * Runs keyed by state key. `start` subscribes to `aws-deploy-progress` before + * Runs keyed by `runKey(region, stateKey)`. `start` subscribes to `aws-deploy-progress` before * invoking, keeps every event for the state key it started until the op id * is known (the backend may emit just before the invoke resolves), then keeps * only that op's events. Resolves to the command's error, or null once started. */ -export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOutcome | null }) => void) { +export function useDeployRuns( + onDone?: (run: { stateKey: string; region: string; result: TofuOutcome | null }) => void, +) { const [runs, setRuns] = useState>({}); const subs = useRef(new Map()); const mounted = useRef(true); @@ -53,25 +58,26 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut const start = useCallback( async (stateKey: string, region: string, label: string, run: () => Promise): Promise => { - subs.current.get(stateKey)?.unlisten?.(); + const k = runKey(region, stateKey); + subs.current.get(k)?.unlisten?.(); const sub: Subscription = { unlisten: null }; - subs.current.set(stateKey, sub); - const current = () => mounted.current && subs.current.get(stateKey) === sub; + subs.current.set(k, sub); + const current = () => mounted.current && subs.current.get(k) === sub; const stop = () => { sub.unlisten?.(); sub.unlisten = null; - if (subs.current.get(stateKey) === sub) subs.current.delete(stateKey); + if (subs.current.get(k) === sub) subs.current.delete(k); }; let opId: string | null = null; const early: ProgressPayload[] = []; const apply = (p: ProgressPayload) => { setRuns((prev) => { - const r = prev[stateKey]; + const r = prev[k]; if (!r || r.opId !== p.op_id || r.done) return prev; return { ...prev, - [stateKey]: { + [k]: { ...r, events: p.events.length ? [...r.events, ...p.events] : r.events, done: p.done, @@ -82,13 +88,13 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut }); if (p.done) { stop(); - onDoneRef.current?.({ stateKey, result: p.result ?? null }); + onDoneRef.current?.({ stateKey, region, result: p.result ?? null }); } }; setRuns((prev) => ({ ...prev, - [stateKey]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null }, + [k]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null }, })); const unlisten = await listen(AWS_DEPLOY_PROGRESS_EVENT, ({ payload }) => { @@ -108,7 +114,7 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut stop(); if (mounted.current) { setRuns((prev) => { - const { [stateKey]: _gone, ...rest } = prev; + const { [k]: _gone, ...rest } = prev; return rest; }); } @@ -116,17 +122,18 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut } if (!current()) return null; const id = opId; - setRuns((prev) => (prev[stateKey] ? { ...prev, [stateKey]: { ...prev[stateKey], opId: id } } : prev)); + setRuns((prev) => (prev[k] ? { ...prev, [k]: { ...prev[k], opId: id } } : prev)); for (const p of early.splice(0)) if (p.op_id === id) apply(p); return null; }, [], ); - const dismiss = useCallback((stateKey: string) => { + /** `key` is a `runKey`. */ + const dismiss = useCallback((key: string) => { setRuns((prev) => { - if (!prev[stateKey]?.done) return prev; - const { [stateKey]: _gone, ...rest } = prev; + if (!prev[key]?.done) return prev; + const { [key]: _gone, ...rest } = prev; return rest; }); }, []); diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx index 937f8af..f6f37dc 100644 --- a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx +++ b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx @@ -131,6 +131,7 @@ describe("EnvironmentsPanel", () => { fireEvent.click(within(row).getByRole("button", { name: "Destroy dev" })); const dialog = screen.getByRole("dialog", { name: "Destroy dev" }); + expect(within(dialog).getByText(/deployment is incomplete \(no valid environment record\)/)).toBeInTheDocument(); const confirm = within(dialog).getByRole("button", { name: "Destroy" }); expect(confirm).toBeDisabled(); fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "de" } }); @@ -248,7 +249,10 @@ describe("EnvironmentsPanel", () => { await open(); fireEvent.click(await screen.findByRole("button", { name: "Join dev" })); const dialog = screen.getByRole("dialog"); + // Opening the bundle is not a request yet. + expect(screen.queryByText("Waiting for an admin to onboard you")).toBeNull(); fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" })); + expect(await within(dialog).findByText("/home/me/onboard-dev")).toBeInTheDocument(); await waitFor(() => expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", { connectionId: "h1", @@ -316,6 +320,95 @@ describe("EnvironmentsPanel", () => { expect(screen.getByText((_, el) => el?.textContent === long && el.tagName === "PRE")).toBeInTheDocument(); }); + it("a workspace user who closes the bundle without saving is not waiting", async () => { + backend(() => scan({ environments: [env()] })); + render(); + await open(); + fireEvent.click(await screen.findByRole("button", { name: "Join dev" })); + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" })); + expect(screen.queryByText("Waiting for an admin to onboard you")).toBeNull(); + expect(within(screen.getByTestId("env-dev")).getByRole("button", { name: "Join dev" })).toHaveTextContent("Join"); + }); + + it("never offers Destroy on a ready environment", async () => { + backend(() => scan({ environments: [env({ actions: ["join", "update"] })] })); + render(); + await open(); + const row = await screen.findByTestId("env-dev"); + expect(within(row).getByText("Ready")).toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Destroy dev" })).toBeNull(); + expect(within(row).queryByRole("button", { name: "Unlock dev" })).toBeNull(); + }); + + it("after a region change, the old region's rows can't be acted on, and actions use the new region", async () => { + let resolveUs: (s: EnvironmentScan) => void = () => {}; + invoke.mockImplementation(async (cmd: string, args: { region?: string }) => { + if (cmd === "detect_aws_environments") { + if (args.region === "us-east-1") return new Promise((r) => (resolveUs = r)); + return scan({ environments: [env({ state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], info: null, onboarding: null })] }); + } + if (cmd === "update_aws_environment") return "u1"; + throw new Error(`unexpected ${cmd}`); + }); + render(); + await open(); + expect(await screen.findByRole("button", { name: "Destroy dev" })).toBeEnabled(); + fireEvent.change(screen.getByLabelText("Region"), { target: { value: "us-east-1" } }); + // Before the new scan answers, nothing from eu-west-1 can be clicked. + expect(screen.queryByTestId("env-dev")).toBeNull(); + expect(screen.queryByRole("button", { name: "Destroy dev" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Deploy a new environment" })).toBeNull(); + await act(async () => + resolveUs(scan({ region: "us-east-1", environments: [env({ state: { state: "update_available" }, actions: ["update"], onboarding: { state: "joined" } })] })), + ); + fireEvent.click(await screen.findByRole("button", { name: "Update dev" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("update_aws_environment", { connectionId: "h1", region: "us-east-1", name: "dev" }), + ); + expect(await screen.findByText("Running")).toBeInTheDocument(); + }); + + it("disables row actions while detection is in flight", async () => { + let calls = 0; + let release: () => void = () => {}; + invoke.mockImplementation(async (cmd: string) => { + if (cmd !== "detect_aws_environments") throw new Error(`unexpected ${cmd}`); + calls += 1; + const found = scan({ environments: [env({ actions: ["join", "update"], onboarding: { state: "joined" } })] }); + if (calls === 1) return found; + return new Promise((r) => (release = () => r(found))); + }); + render(); + await open(); + expect(await screen.findByRole("button", { name: "Update dev" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Detect again" })); + expect(screen.getByRole("button", { name: "Update dev" })).toBeDisabled(); + await act(async () => release()); + expect(screen.getByRole("button", { name: "Update dev" })).toBeEnabled(); + }); + + it("a done that arrives before the invoke resolves is shown and re-detects", async () => { + backend( + () => scan({ environments: [env({ state: { state: "update_available" }, actions: ["update"], onboarding: { state: "joined" } })] }), + async (cmd) => { + if (cmd === "update_aws_environment") { + emit(progress({ op_id: "u1", events: [tofuEvent("Refreshing state")] })); + emit(progress({ op_id: "u1", events: [], done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "No changes." } })); + return "u1"; + } + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + fireEvent.click(await screen.findByRole("button", { name: "Update dev" })); + expect(await screen.findByText(/Finished: No changes\./)).toBeInTheDocument(); + expect(screen.getByText("Refreshing state")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Cancel deployment" })).toBeNull(); + await waitFor(() => expect(detectCalls()).toBe(2)); + expect(handlers.size).toBe(0); + }); + it("routes a detection failure through the AWS error copy", async () => { backend(() => { throw { kind: "state_bucket_taken", bucket: "triple-c-tfstate-123456789012-eu-west-1" }; diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.tsx index d2cb958..bec40ad 100644 --- a/app/src/components/settings/hosts/EnvironmentsPanel.tsx +++ b/app/src/components/settings/hosts/EnvironmentsPanel.tsx @@ -26,7 +26,7 @@ import Modal from "../../ui/Modal"; import StatusIndicator from "../../ui/StatusIndicator"; import AdminBundleModal from "./AdminBundleModal"; import DeployEnvironmentModal from "./DeployEnvironmentModal"; -import DeployProgress, { useDeployRuns, type DeployRun } from "./DeployProgress"; +import DeployProgress, { runKey, useDeployRuns, type DeployRun } from "./DeployProgress"; import PolicyFiles from "./PolicyFiles"; import { COMMON_REGIONS, @@ -98,7 +98,9 @@ function TypedConfirmModal({ ); } -type Bundle = { kind: ModuleKind; environment?: string; ownerId?: string }; +type Bundle = { kind: ModuleKind; region: string; environment?: string; ownerId?: string }; +/** A row-level key: the same environment name can exist in every region. */ +const rowKey = (region: string, name: string) => `${region}:${name}`; /** The environment a run's state key belongs to, if any. */ function runEnvironment(stateKey: string): string | null { @@ -129,8 +131,8 @@ export default function EnvironmentsPanel({ const [waiting, setWaiting] = useState>(new Set()); const [deployOpen, setDeployOpen] = useState(false); const [repairOf, setRepairOf] = useState(null); - const [destroyOf, setDestroyOf] = useState(null); - const [unlockOf, setUnlockOf] = useState(null); + const [destroyOf, setDestroyOf] = useState<{ name: string; region: string } | null>(null); + const [unlockOf, setUnlockOf] = useState<{ env: DetectedEnvironment; region: string } | null>(null); const [bundle, setBundle] = useState(null); const [copyNote, setCopyNote] = useState(null); const request = useRef(0); @@ -162,16 +164,18 @@ export default function EnvironmentsPanel({ if (open) void detect(); }, [open, detect]); + /** Start a run in `runRegion` (always the region of the scan the action came from). */ const launch = useCallback( - async (envName: string, stateKey: string, label: string, run: () => Promise) => { - setRowErrors(({ [envName]: _gone, ...rest }) => rest); - setPending((p) => ({ ...p, [envName]: true })); - const err = await start(stateKey, region, label, run); - setPending(({ [envName]: _gone, ...rest }) => rest); - if (err) setRowErrors((r) => ({ ...r, [envName]: err })); + async (runRegion: string, envName: string, stateKey: string, label: string, run: () => Promise) => { + const rk = rowKey(runRegion, envName); + setRowErrors(({ [rk]: _gone, ...rest }) => rest); + setPending((p) => ({ ...p, [rk]: true })); + const err = await start(stateKey, runRegion, label, run); + setPending(({ [rk]: _gone, ...rest }) => rest); + if (err) setRowErrors((r) => ({ ...r, [rk]: err })); return err; }, - [start, region], + [start], ); // Reattach to a deploy running on this machine (a re-follow replays its whole log). @@ -179,10 +183,12 @@ export default function EnvironmentsPanel({ if (!scan) return; for (const e of scan.environments) { const key = environmentStateKey(e.name); - const seen = `${scan.region}:${key}`; - if (e.actions.includes("follow") && !runs[key] && !followed.current.has(seen)) { + const seen = runKey(scan.region, key); + if (e.actions.includes("follow") && !runs[seen] && !followed.current.has(seen)) { followed.current.add(seen); - void launch(e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, scan.region, key)); + void launch(scan.region, e.name, key, `Deploy of ${e.name}`, () => + followAwsDeployment(connection.id, scan.region, key), + ); } } }, [scan, runs, launch, connection.id]); @@ -200,49 +206,55 @@ export default function EnvironmentsPanel({ } }; - const deploy = (name: string, network: NetworkChoice) => - launch(name, environmentStateKey(name), `Deploy of ${name}`, () => - deployAwsEnvironment(connection.id, region, name, network), + // Rows (and every action on them) belong to the scan they came from. After a + // region change the old scan's rows are hidden, and while any detection is in + // flight their actions are disabled; actions always target `scan.region`. + const current = scan && scan.region === region ? scan : null; + const stale = detecting || !current; + + const deploy = (runRegion: string, name: string, network: NetworkChoice) => + launch(runRegion, name, environmentStateKey(name), `Deploy of ${name}`, () => + deployAwsEnvironment(connection.id, runRegion, name, network), ); - const repair = (name: string, network?: NetworkChoice) => - launch(name, environmentStateKey(name), `Repair of ${name}`, () => - repairAwsEnvironment(connection.id, region, name, network), + const repair = (runRegion: string, name: string, network?: NetworkChoice) => + launch(runRegion, name, environmentStateKey(name), `Repair of ${name}`, () => + repairAwsEnvironment(connection.id, runRegion, name, network), ); - const join = (e: DetectedEnvironment) => { - if (!scan) return; + const join = (found: EnvironmentScan, e: DetectedEnvironment) => { if (isAdmin) { - void launch(e.name, onboardingStateKey(e.name, scan.owner_key), `Joining ${e.name}`, () => - onboardAwsUser(connection.id, region, e.name), + void launch(found.region, e.name, onboardingStateKey(e.name, found.owner_key), `Joining ${e.name}`, () => + onboardAwsUser(connection.id, found.region, e.name), ); } else { - setWaiting((w) => new Set(w).add(e.name)); - setBundle({ kind: "user_onboard", environment: e.name, ownerId: scan.owner_id }); + setBundle({ kind: "user_onboard", region: found.region, environment: e.name, ownerId: found.owner_id }); } }; const rowRuns = (name: string) => - Object.values(runs).filter((r) => r.region === region && runEnvironment(r.stateKey) === name); - const shown = new Set(scan?.environments.map((e) => e.name) ?? []); + Object.values(runs).filter((r) => r.region === current?.region && runEnvironment(r.stateKey) === name); + const shown = new Set(current?.environments.map((e) => e.name) ?? []); const looseRuns = Object.values(runs).filter( - (r) => r.region !== region || !shown.has(runEnvironment(r.stateKey) ?? ""), + (r) => r.region !== current?.region || !shown.has(runEnvironment(r.stateKey) ?? ""), ); const progress = (r: DeployRun) => ( dismiss(r.stateKey)} + onDismiss={() => dismiss(runKey(r.region, r.stateKey))} /> ); - const envRow = (e: DetectedEnvironment) => { + const envRow = (found: EnvironmentScan, e: DetectedEnvironment) => { + const at = found.region; const status = stateStatus(e.state); const lock = stateLock(e.state); const has = (a: DetectedEnvironment["actions"][number]) => e.actions.includes(a); const onboarding = e.onboarding?.state; - const busy = !!pending[e.name]; - const rowError = rowErrors[e.name]; + const busy = stale || !!pending[rowKey(at, e.name)]; + const rowError = rowErrors[rowKey(at, e.name)]; + const isWaiting = waiting.has(rowKey(at, e.name)); return (
  • The environment's record can't be used: {e.state.reason}
  • )} - {!isAdmin && waiting.has(e.name) && onboarding !== "joined" && ( + {!isAdmin && isWaiting && onboarding !== "joined" && (
    Waiting for an admin to onboard you
    )}
    {has("join") && onboarding !== "joined" && onboarding !== "in_progress" && ( - )} {has("follow") && ( @@ -288,7 +300,7 @@ export default function EnvironmentsPanel({ aria-label={`Follow ${e.name}`} onClick={() => { const key = environmentStateKey(e.name); - void launch(e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, region, key)); + void launch(at, e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, at, key)); }} > Follow @@ -300,8 +312,8 @@ export default function EnvironmentsPanel({ disabled={busy} aria-label={`Update ${e.name}`} onClick={() => - void launch(e.name, environmentStateKey(e.name), `Update of ${e.name}`, () => - updateAwsEnvironment(connection.id, region, e.name), + void launch(at, e.name, environmentStateKey(e.name), `Update of ${e.name}`, () => + updateAwsEnvironment(connection.id, at, e.name), ) } > @@ -313,18 +325,18 @@ export default function EnvironmentsPanel({ size="sm" disabled={busy} aria-label={`Repair ${e.name}`} - onClick={() => (e.info ? void repair(e.name) : setRepairOf(e.name))} + onClick={() => (e.info ? void repair(at, e.name) : setRepairOf(e.name))} > Repair )} {has("unlock") && ( - )} {has("destroy") && ( - )} @@ -340,7 +352,7 @@ export default function EnvironmentsPanel({ ); }; - const nothingFound = !!scan && scan.environments.length === 0; + const nothingFound = !!current && current.environments.length === 0; return (
    @@ -376,29 +388,36 @@ export default function EnvironmentsPanel({ {awsErrorMessage(detectError)}

    )} - {scan && isAdmin && scan.boundary === "no" && ( + {current && isAdmin && current.boundary === "no" && (

    This account has no Triple-C permissions boundary yet; deploying creates it first.

    )} - {scan && scan.environments.length > 0 &&
      {scan.environments.map(envRow)}
    } + {current && current.environments.length > 0 && ( +
      {current.environments.map((e) => envRow(current, e))}
    + )} {nothingFound && (

    - No Triple-C environment in {scan.region}. + No Triple-C environment in {current.region}. {!isAdmin && " Deploying one needs an environment admin: attach the environment-admin policies to an identity, or send an admin a bundle to deploy."} {!tier && " Run Check to find out whether you can deploy one yourself."}

    )} - {scan && isAdmin && ( - )} {nothingFound && !isAdmin && (
    - {copyNote && ( @@ -411,47 +430,49 @@ export default function EnvironmentsPanel({ {looseRuns.map(progress)}
    )} - {scan && (deployOpen || repairOf) && ( + {current && (deployOpen || repairOf) && ( { setDeployOpen(false); setRepairOf(null); }} - onDeploy={repairOf ? (name, network) => repair(name, network) : deploy} + onDeploy={(name, network) => + repairOf ? repair(current.region, name, network) : deploy(current.region, name, network) + } onDetect={() => void detect()} /> )} {destroyOf && ( setDestroyOf(null)} onConfirm={() => - launch(destroyOf, environmentStateKey(destroyOf), `Destroy of ${destroyOf}`, () => - destroyIncompleteAwsEnvironment(connection.id, region, destroyOf), + launch(destroyOf.region, destroyOf.name, environmentStateKey(destroyOf.name), `Destroy of ${destroyOf.name}`, () => + destroyIncompleteAwsEnvironment(connection.id, destroyOf.region, destroyOf.name), ) } >

    - {destroyOf} never finished deploying, so OpenTofu removes everything it created for it. This can't be - undone. + This environment's deployment is incomplete (no valid environment record). OpenTofu removes everything + it created for {destroyOf.name} in {destroyOf.region}. This can't be undone.

    )} {unlockOf && ( setUnlockOf(null)} onConfirm={async () => { try { - await unlockAwsState(connection.id, region, environmentStateKey(unlockOf.name)); + await unlockAwsState(connection.id, unlockOf.region, environmentStateKey(unlockOf.env.name)); } catch (e) { return toAwsError(e); } @@ -460,7 +481,7 @@ export default function EnvironmentsPanel({ }} > {(() => { - const lock = stateLock(unlockOf.state); + const lock = stateLock(unlockOf.env.state); return lock ? (

    Held by {lock.who || "unknown"} since{" "} @@ -478,10 +499,16 @@ export default function EnvironmentsPanel({ {bundle && ( { + if (bundle.kind === "user_onboard" && bundle.environment) { + const rk = rowKey(bundle.region, bundle.environment); + setWaiting((w) => new Set(w).add(rk)); + } + }} onClose={() => setBundle(null)} /> )}