diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index fccb468..f54fc5d 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1230,8 +1230,10 @@ fn validate_aws_update( }) { return Err( - "This project still has AWS resources (a task, its data's access point or task \ - definitions) where it runs now; remove the workspace's AWS resources first" + "This project still has AWS resources (a task, its saved files' access point or task \ + definitions) where it runs now; remove the workspace's AWS resources first — \ + including the access point (\"Also remove the workspace's file-system access \ + point\"), which stays when the saved files are kept" .into(), ); } @@ -1691,13 +1693,26 @@ pub async fn remove_aws_resources( ProjectHost::for_project(&project) .remove_aws_resources(&project, &settings, discard_data) .await?; - state.projects_store.set_aws_runtime(&project_id, None, None, None)?; + state + .projects_store + .set_aws_runtime(&project_id, None, access_point_after_removal(&project, discard_data), None)?; state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id)) } +/// The access point a removal leaves stored: kept when the saved files were +/// kept (it is still there, a later start reuses it and a later removal +/// with `discard_data` deletes it; and while it is stored the project can't +/// move away from it — [`validate_aws_update`]), gone when they were not. +fn access_point_after_removal(project: &Project, discard_data: bool) -> Option { + if discard_data { + return None; + } + project.aws.as_ref().and_then(|a| a.access_point_id.clone()) +} + fn check_aws_resources_removable(project: &Project) -> Result<(), String> { if !project.is_aws() { return Err("This project does not run on AWS".into()); @@ -2671,6 +2686,30 @@ mod tests { assert!(validate_aws_update(&running, &edited, &hosts).is_ok()); } + #[test] + fn a_removal_that_keeps_the_saved_files_keeps_their_access_point_and_the_project_in_place() { + let hosts = vec![host("h1")]; + let mut stored = aws_project(); + stored.aws.as_mut().unwrap().access_point_id = Some("fsap-1".into()); + assert_eq!(access_point_after_removal(&stored, false), Some("fsap-1".into())); + assert_eq!(access_point_after_removal(&stored, true), None); + // What the store holds after a keep-the-files removal still blocks a move. + let mut after = stored.clone(); + let a = after.aws.as_mut().unwrap(); + a.task_arn = None; + a.task_definition_arn = None; + a.access_point_id = access_point_after_removal(&stored, false); + let mut local = after.clone(); + local.host_id = None; + local.aws_placement = None; + local.aws = None; + let e = validate_aws_update(&after, &local, &hosts).unwrap_err(); + assert!(e.contains("access point"), "{e}"); + // After a discarding removal it can move. + after.aws.as_mut().unwrap().access_point_id = access_point_after_removal(&stored, true); + assert!(validate_aws_update(&after, &local, &hosts).is_ok()); + } + #[test] fn a_project_with_aws_resources_cannot_be_moved_off_its_host() { let hosts = vec![host("h1"), host("h2")]; diff --git a/app/src/components/projects/home/config/AwsConfig.test.tsx b/app/src/components/projects/home/config/AwsConfig.test.tsx index a3c7783..bdcca36 100644 --- a/app/src/components/projects/home/config/AwsConfig.test.tsx +++ b/app/src/components/projects/home/config/AwsConfig.test.tsx @@ -165,6 +165,21 @@ describe("HostSection", () => { await waitFor(() => expect(removeAwsResources).toHaveBeenCalledWith(p.id, true)); }); + it("keeps a project whose kept files' access point remains on AWS until it is removed", () => { + const p = awsProject(); + p.aws = { ...p.aws!, access_point_id: "fsap-1", task_arn: null, task_definition_arn: null }; + renderHost(p); + const move = screen.getByRole("button", { name: "Run on this machine instead" }); + expect(move).toHaveAttribute("aria-disabled", "true"); + expect(move).toHaveAccessibleDescription(/access point is still on AWS/); + expect(screen.getByRole("button", { name: "Remove AWS resources…" })).not.toHaveAttribute( + "aria-disabled", + "true", + ); + fireEvent.click(screen.getByRole("button", { name: "Remove AWS resources…" })); + expect(within(screen.getByRole("dialog")).getByText(/access point\s+stays too/)).toBeInTheDocument(); + }); + it("keeps the AWS resource buttons locked while the project is in Error (its task may live)", () => { const p = awsProject({ status: "error" }); p.aws = { ...p.aws!, task_arn: "arn:task" }; diff --git a/app/src/components/projects/home/config/HostSection.tsx b/app/src/components/projects/home/config/HostSection.tsx index 348ea29..45b530e 100644 --- a/app/src/components/projects/home/config/HostSection.tsx +++ b/app/src/components/projects/home/config/HostSection.tsx @@ -58,6 +58,8 @@ export default function HostSection({ project, save, disabled }: Props) { const saveAws = (patch: Partial) => save({ aws: { ...aws, ...patch } }); const hasResources = !!(aws.task_arn || aws.access_point_id || aws.task_definition_arn); + // A removal that kept the saved files keeps their access point stored. + const onlyAccessPoint = !!aws.access_point_id && !aws.task_arn && !aws.task_definition_arn; // Not `disabled` (which also allows Error): on AWS, Error can mean a live task. const stopped = project.status === "stopped"; @@ -343,7 +345,9 @@ export default function HostSection({ project, save, disabled }: Props) { unavailableReason={ !stopped ? STILL_RUNNING - : "Remove the workspace's AWS resources first, so nothing is left running or billed." + : onlyAccessPoint + ? "The saved files' access point is still on AWS. Remove the AWS resources with \u201cAlso remove the workspace\u2019s file-system access point\u201d first." + : "Remove the workspace's AWS resources first, so nothing is left running or billed." } onClick={() => void save({ host_id: null, aws_placement: null, aws: null })} > @@ -421,7 +425,9 @@ function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClo Also remove the workspace’s file-system access point The files stay on your own EFS file system (only your workspace role can read - them) until an administrator offboards you. + them) until an administrator offboards you. Left unchecked, the access point + stays too: the next start here reuses it, and the project can’t move to + another host until it is removed.