Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: delete errored projects #1428

Merged
merged 5 commits into from Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions common/src/models/error.rs
Expand Up @@ -52,6 +52,7 @@ pub enum ErrorKind {
ProjectHasResources(Vec<String>),
ProjectHasRunningDeployment,
ProjectHasBuildingDeployment,
ProjectCorrupted,
CustomDomainNotFound,
InvalidCustomDomain,
CustomDomainAlreadyExists,
Expand Down Expand Up @@ -99,6 +100,10 @@ impl From<ErrorKind> 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 {
Expand Down
27 changes: 25 additions & 2 deletions gateway/src/api/latest.rs
Expand Up @@ -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()
Expand All @@ -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();
Expand Down Expand Up @@ -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};

Expand Down Expand Up @@ -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;
Expand Down
99 changes: 50 additions & 49 deletions gateway/src/lib.rs
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<Bearer>,
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<Bearer>,
project_name: String,
world: World,
}

impl TestProject {
Expand Down Expand Up @@ -838,6 +829,7 @@ pub mod tests {
router,
authorization,
project_name,
..
} = self;

router
Expand Down Expand Up @@ -971,6 +963,7 @@ pub mod tests {
router,
authorization,
project_name,
..
} = self;

router
Expand All @@ -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]
Expand All @@ -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<Bearer>,
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]
Expand Down