fix(aws): environments panel acts on the scanned region; runs keyed per region

Rows and actions belong to the scan they came from: a region change hides
the old rows, detection in flight disables actions, and every action and
confirmation targets scan.region. Runs, pending state and errors are keyed
by region as well as state key. A boundary run that fails in the apply now
offers the boundary bundle with its summary; waiting-for-admin shows only
after a bundle is saved; clearer Destroy copy. Tests for each, and no act()
warnings in the new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 22:56:36 -08:00
1 parent cf9b5eab3a
commit bc23fc2dee
7 files changed
+347 -91

No files matched your search

@@ -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 {
@@ -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());
});
});
@@ -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({
</p>
)}
{boundaryRun && <DeployProgress run={boundaryRun} connectionId={connectionId} />}
{boundaryFailed && (
<div className="space-y-1.5">
<p>
Creating the boundary failed. If it was refused, an account administrator can create it from a
bundle.
</p>
<Button
size="md"
onClick={() =>
setBundle({
reason: boundaryFailed.summary ?? `OpenTofu exited with code ${boundaryFailed.exit_code}`,
})
}
>
Get the boundary bundle for an administrator
</Button>
</div>
)}
</div>
) : (
<div className="space-y-4">
@@ -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<Handler>();
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({});
});
});
@@ -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<Record<string, DeployRun>>({});
const subs = useRef(new Map<string, Subscription>());
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<string>): Promise<AwsError | null> => {
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<ProgressPayload>(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;
});
}, []);
@@ -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(<EnvironmentsPanel connection={conn} tier="workspace_user" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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<EnvironmentScan>((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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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<EnvironmentScan>((r) => (release = () => r(found)));
});
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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" };
@@ -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<Set<string>>(new Set());
const [deployOpen, setDeployOpen] = useState(false);
const [repairOf, setRepairOf] = useState<string | null>(null);
const [destroyOf, setDestroyOf] = useState<string | null>(null);
const [unlockOf, setUnlockOf] = useState<DetectedEnvironment | null>(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<Bundle | null>(null);
const [copyNote, setCopyNote] = useState<string | null>(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<string>) => {
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<string>) => {
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) => (
<DeployProgress
key={`${r.region}:${r.stateKey}`}
key={runKey(r.region, r.stateKey)}
run={r}
connectionId={connection.id}
onDismiss={() => 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 (
<li
key={e.name}
@@ -271,14 +283,14 @@ export default function EnvironmentsPanel({
{e.state.state === "unreadable_record" && (
<div className="text-[var(--error)]">The environment's record can't be used: {e.state.reason}</div>
)}
{!isAdmin && waiting.has(e.name) && onboarding !== "joined" && (
{!isAdmin && isWaiting && onboarding !== "joined" && (
<div className="text-[var(--text-secondary)]">Waiting for an admin to onboard you</div>
)}
</div>
<div className="flex gap-1.5 flex-wrap">
{has("join") && onboarding !== "joined" && onboarding !== "in_progress" && (
<Button size="sm" variant="primary" disabled={busy} aria-label={`Join ${e.name}`} onClick={() => join(e)}>
{!isAdmin && waiting.has(e.name) ? "Show onboarding bundle" : "Join"}
<Button size="sm" variant="primary" disabled={busy} aria-label={`Join ${e.name}`} onClick={() => join(found, e)}>
{!isAdmin && isWaiting ? "Show onboarding bundle" : "Join"}
</Button>
)}
{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
</Button>
)}
{has("unlock") && (
<Button size="sm" variant="danger" disabled={busy} aria-label={`Unlock ${e.name}`} onClick={() => setUnlockOf(e)}>
<Button size="sm" variant="danger" disabled={busy} aria-label={`Unlock ${e.name}`} onClick={() => setUnlockOf({ env: e, region: at })}>
Unlock
</Button>
)}
{has("destroy") && (
<Button size="sm" variant="danger" disabled={busy} aria-label={`Destroy ${e.name}`} onClick={() => setDestroyOf(e.name)}>
<Button size="sm" variant="danger" disabled={busy} aria-label={`Destroy ${e.name}`} onClick={() => setDestroyOf({ name: e.name, region: at })}>
Destroy
</Button>
)}
@@ -340,7 +352,7 @@ export default function EnvironmentsPanel({
);
};
const nothingFound = !!scan && scan.environments.length === 0;
const nothingFound = !!current && current.environments.length === 0;
return (
<div className="pt-1.5 space-y-2">
@@ -376,29 +388,36 @@ export default function EnvironmentsPanel({
{awsErrorMessage(detectError)}
</p>
)}
{scan && isAdmin && scan.boundary === "no" && (
{current && isAdmin && current.boundary === "no" && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
This account has no Triple-C permissions boundary yet; deploying creates it first.
</p>
)}
{scan && scan.environments.length > 0 && <ul className="space-y-1.5">{scan.environments.map(envRow)}</ul>}
{current && current.environments.length > 0 && (
<ul className="space-y-1.5">{current.environments.map((e) => envRow(current, e))}</ul>
)}
{nothingFound && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
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."}
</p>
)}
{scan && isAdmin && (
<Button size="md" variant={nothingFound ? "primary" : "secondary"} onClick={() => setDeployOpen(true)}>
{current && isAdmin && (
<Button
size="md"
variant={nothingFound ? "primary" : "secondary"}
disabled={stale}
onClick={() => setDeployOpen(true)}
>
Deploy a new environment
</Button>
)}
{nothingFound && !isAdmin && (
<div className="space-y-2">
<PolicyFiles tier="environment_admin" label="Show the environment-admin policies" onCopy={copy} />
<Button size="md" onClick={() => setBundle({ kind: "environment" })}>
<Button size="md" disabled={stale} onClick={() => setBundle({ kind: "environment", region: current.region })}>
Request an environment from an admin…
</Button>
{copyNote && (
@@ -411,47 +430,49 @@ export default function EnvironmentsPanel({
{looseRuns.map(progress)}
</div>
)}
{scan && (deployOpen || repairOf) && (
{current && (deployOpen || repairOf) && (
<DeployEnvironmentModal
connectionId={connection.id}
region={region}
scan={scan}
region={current.region}
scan={current}
repair={repairOf ?? undefined}
onClose={() => {
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 && (
<TypedConfirmModal
title={`Destroy ${destroyOf}`}
name={destroyOf}
title={`Destroy ${destroyOf.name}`}
name={destroyOf.name}
confirmLabel="Destroy"
onClose={() => 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),
)
}
>
<p>
{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.
</p>
</TypedConfirmModal>
)}
{unlockOf && (
<TypedConfirmModal
title={`Unlock ${unlockOf.name}`}
name={unlockOf.name}
title={`Unlock ${unlockOf.env.name}`}
name={unlockOf.env.name}
confirmLabel="Unlock"
onClose={() => 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 ? (
<p>
Held by <span className="font-mono text-[var(--text-primary)]">{lock.who || "unknown"}</span> since{" "}
@@ -478,10 +499,16 @@ export default function EnvironmentsPanel({
{bundle && (
<AdminBundleModal
connectionId={connection.id}
region={region}
region={bundle.region}
kind={bundle.kind}
environment={bundle.environment}
ownerId={bundle.ownerId}
onSaved={() => {
if (bundle.kind === "user_onboard" && bundle.environment) {
const rk = rowKey(bundle.region, bundle.environment);
setWaiting((w) => new Set(w).add(rk));
}
}}
onClose={() => setBundle(null)}
/>
)}