diff --git a/common/src/models/error.rs b/common/src/models/error.rs index 45f2aac7b..877f0a8a0 100644 --- a/common/src/models/error.rs +++ b/common/src/models/error.rs @@ -52,6 +52,7 @@ pub enum ErrorKind { ProjectHasResources(Vec), ProjectHasRunningDeployment, ProjectHasBuildingDeployment, + ProjectCorrupted, CustomDomainNotFound, InvalidCustomDomain, CustomDomainAlreadyExists, @@ -99,6 +100,10 @@ impl From for ApiError { StatusCode::BAD_REQUEST, "Project currently has a deployment that is busy building. Use `cargo shuttle deployment list` to see it and wait for it to finish" ), + ErrorKind::ProjectCorrupted => ( + StatusCode::BAD_REQUEST, + "Tried to get project into a ready state for deletion but failed. Please reach out to Shuttle support for help." + ), ErrorKind::ProjectHasResources(resources) => { let resources = resources.join(", "); return Self { diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index ead17591b..4c492c514 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -327,8 +327,9 @@ async fn delete_project( let project_id = Ulid::from_string(&project.project_id).expect("stored project id to be a valid ULID"); - // Try to startup a destroyed project first - if project.state.is_destroyed() { + // Try to startup destroyed or errored projects + let project_deletable = project.state.is_ready() || project.state.is_stopped(); + if !(project_deletable) { let handle = state .service .new_task() @@ -339,6 +340,12 @@ async fn delete_project( // Wait for the project to be ready handle.await; + + let new_state = state.service.find_project(&project_name).await?; + + if !new_state.state.is_ready() { + return Err(Error::from_kind(ErrorKind::ProjectCorrupted)); + } } let service = state.service.clone(); @@ -1113,6 +1120,7 @@ pub mod tests { use tower::Service; use super::*; + use crate::project::ProjectError; use crate::service::GatewayService; use crate::tests::{RequestBuilderExt, TestProject, World}; @@ -1441,6 +1449,21 @@ pub mod tests { ); } + #[test_context(TestProject)] + #[tokio::test] + async fn api_delete_project_that_is_errored(project: &mut TestProject) { + project + .update_state(Project::Errored(ProjectError::internal( + "Mr. Anderson is here", + ))) + .await; + + assert_eq!( + project.router_call(Method::DELETE, "/delete").await, + StatusCode::OK + ); + } + #[tokio::test(flavor = "multi_thread")] async fn status() { let world = World::new().await; diff --git a/gateway/src/lib.rs b/gateway/src/lib.rs index 5e08504aa..3de23f854 100644 --- a/gateway/src/lib.rs +++ b/gateway/src/lib.rs @@ -284,7 +284,7 @@ pub mod tests { use shuttle_common::models::{project, service}; use shuttle_common_tests::resource_recorder::start_mocked_resource_recorder; use sqlx::sqlite::SqliteConnectOptions; - use sqlx::SqlitePool; + use sqlx::{query, SqlitePool}; use test_context::AsyncTestContext; use tokio::sync::mpsc::channel; use tokio::time::sleep; @@ -293,6 +293,7 @@ pub mod tests { use crate::acme::AcmeClient; use crate::api::latest::ApiBuilder; use crate::args::{ContextArgs, StartArgs, UseTls}; + use crate::project::Project; use crate::proxy::UserServiceBuilder; use crate::service::{ContainerSettings, GatewayService, MIGRATIONS}; use crate::worker::Worker; @@ -761,22 +762,12 @@ pub mod tests { } } - /// Make it easy to perform common requests against the router for testing purposes - #[async_trait] - pub trait RouterExt { - /// Create a project and put it in the ready state - async fn create_project( - &mut self, - authorization: &Authorization, - project_name: &str, - ) -> TestProject; - } - /// Helper struct to wrap a bunch of commands to run against a test project pub struct TestProject { router: Router, authorization: Authorization, project_name: String, + world: World, } impl TestProject { @@ -838,6 +829,7 @@ pub mod tests { router, authorization, project_name, + .. } = self; router @@ -971,6 +963,7 @@ pub mod tests { router, authorization, project_name, + .. } = self; router @@ -988,6 +981,24 @@ pub mod tests { .await .unwrap(); } + + /// Puts the project in a new state + pub async fn update_state(&self, state: Project) { + let TestProject { + project_name, + world, + .. + } = self; + + let state = sqlx::types::Json(state); + + query("UPDATE projects SET project_state = ?1 WHERE project_name = ?2") + .bind(&state) + .bind(project_name) + .execute(&world.pool) + .await + .expect("test to update project state"); + } } #[async_trait] @@ -997,54 +1008,44 @@ pub mod tests { let mut router = world.router().await; let authorization = world.create_authorization_bearer("neo"); + let project_name = "matrix"; - router.create_project(&authorization, "matrix").await - } - - async fn teardown(mut self) { - let dangling = !self.is_missing().await; - - if dangling { - self.router_call(Method::DELETE, "/delete").await; - eprintln!("test left a dangling project which you might need to clean manually"); - } - } - } - - #[async_trait] - impl RouterExt for Router { - async fn create_project( - &mut self, - authorization: &Authorization, - project_name: &str, - ) -> TestProject { - let authorization = authorization.clone(); - - self.call( - Request::builder() - .method("POST") - .uri(format!("/projects/{project_name}")) - .header("Content-Type", "application/json") - .body("{\"idle_minutes\": 3}".into()) - .unwrap() - .with_header(&authorization), - ) - .map_ok(|resp| { - assert_eq!(resp.status(), StatusCode::OK); - }) - .await - .unwrap(); + router + .call( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_name}")) + .header("Content-Type", "application/json") + .body("{\"idle_minutes\": 3}".into()) + .unwrap() + .with_header(&authorization), + ) + .map_ok(|resp| { + assert_eq!(resp.status(), StatusCode::OK); + }) + .await + .unwrap(); let mut this = TestProject { authorization, project_name: project_name.to_string(), - router: self.clone(), + router, + world, }; this.wait_for_state(project::State::Ready).await; this } + + async fn teardown(mut self) { + let dangling = !self.is_missing().await; + + if dangling { + self.router_call(Method::DELETE, "/delete").await; + eprintln!("test left a dangling project which you might need to clean manually"); + } + } } #[tokio::test]