Skip to content

Commit

Permalink
feat(cargo-shuttle): check project name available (#1279)
Browse files Browse the repository at this point in the history
* feat(cargo-shuttle): check project name available

* found the bug

* refactor endpoint, refactor prompts&hints

* use new client

* fix tests

* fix the tests again

* be very clear
  • Loading branch information
jonaro00 authored Oct 2, 2023
1 parent 854df3f commit da18b3b
Show file tree
Hide file tree
Showing 9 changed files with 123 additions and 36 deletions.
2 changes: 1 addition & 1 deletion cargo-shuttle/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ pub struct InitArgs {
#[arg(default_value = ".", value_parser = OsStringValueParser::new().try_map(parse_init_path))]
pub path: PathBuf,

/// Whether to create the environment for this project on Shuttle
/// Whether to start the container for this project on Shuttle, and claim the project name
#[arg(long)]
pub create_env: bool,
#[command(flatten)]
Expand Down
12 changes: 12 additions & 0 deletions cargo-shuttle/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ impl Client {
.context("parsing API version info")
}

pub async fn check_project_name(&self, project_name: &ProjectName) -> Result<bool> {
let url = format!("{}/projects/name/{project_name}", self.api_url);

self.client
.get(url)
.send()
.await?
.json()
.await
.context("parsing name check response")
}

pub async fn deploy(
&self,
project: &ProjectName,
Expand Down
6 changes: 2 additions & 4 deletions cargo-shuttle/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,12 @@ impl RequestContext {
/// Set the API key to the global configuration. Will persist the file.
pub fn set_api_key(&mut self, api_key: ApiKey) -> Result<()> {
self.global.as_mut().unwrap().set_api_key(api_key);
self.global.save()?;
Ok(())
self.global.save()
}

pub fn clear_api_key(&mut self) -> Result<()> {
self.global.as_mut().unwrap().clear_api_key();
self.global.save()?;
Ok(())
self.global.save()
}
/// Get the current project name.
///
Expand Down
91 changes: 67 additions & 24 deletions cargo-shuttle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
const SHUTTLE_LOGIN_URL: &str = "https://console.shuttle.rs/new-project";
const SHUTTLE_GH_ISSUE_URL: &str = "https://github.com/shuttle-hq/shuttle/issues/new/choose";
const SHUTTLE_CLI_DOCS_URL: &str = "https://docs.shuttle.rs/getting-started/shuttle-commands";
const SHUTTLE_IDLE_DOCS_URL: &str = "https://docs.shuttle.rs/getting-started/idle-projects";

pub struct Shuttle {
ctx: RequestContext,
Expand Down Expand Up @@ -179,7 +180,8 @@ impl Shuttle {
// All commands that call the API
if matches!(
args.cmd,
Command::Deploy(..)
Command::Init(..)
| Command::Deploy(..)
| Command::Status
| Command::Logs { .. }
| Command::Deployment(..)
Expand All @@ -190,7 +192,10 @@ impl Shuttle {
| Command::Project(..)
) {
let mut client = Client::new(self.ctx.api_url());
client.set_api_key(self.ctx.api_key()?);
if !matches!(args.cmd, Command::Init(..)) {
// init command will handle this by itself (log in and set key) if there is no key yet
client.set_api_key(self.ctx.api_key()?);
}
self.client = Some(client);
self.check_api_versions().await?;
}
Expand Down Expand Up @@ -323,12 +328,31 @@ impl Shuttle {
It will be hosted at ${{project_name}}.shuttleapp.rs, so choose something unique!
"
);
// TODO: Check whether the project name is still available
project_args.name = Some(
Input::with_theme(&theme)
let client = self.client.as_ref().unwrap();
loop {
// not using validate_with due to being blocking
let p: ProjectName = Input::with_theme(&theme)
.with_prompt("Project name")
.interact()?,
);
.interact()?;
match client.check_project_name(&p).await {
Ok(true) => {
println!("{} {}", "Project name already taken:".red(), p);
println!("{}", "Try a different name.".yellow());
}
Ok(false) => {
project_args.name = Some(p);
break;
}
Err(_) => {
project_args.name = Some(p);
println!(
"{}",
"Failed to check if project name is available.".yellow()
);
break;
}
}
}
println!();
}

Expand Down Expand Up @@ -418,9 +442,21 @@ impl Shuttle {
true
} else {
let should_create = Confirm::with_theme(&theme)
.with_prompt("Do you want to create the project environment on Shuttle?")
.with_prompt(format!(
r#"Claim the project name "{}" by starting a project container on Shuttle?"#,
project_args
.name
.as_ref()
.expect("to have a project name provided")
))
.default(true)
.interact()?;
if !should_create {
println!(
"Note: The project name will not be claimed until \
you start the project with `cargo shuttle project start`."
)
}
println!();
should_create
};
Expand All @@ -446,7 +482,7 @@ impl Shuttle {
printdoc!(
"
Hint: Discord bots might want to use `--idle-minutes 0` when starting the
project so that they don't go offline: https://docs.shuttle.rs/getting-started/idle-projects
project so that they don't go offline: {SHUTTLE_IDLE_DOCS_URL}
"
);
}
Expand Down Expand Up @@ -479,14 +515,15 @@ impl Shuttle {

Password::with_theme(&ColorfulTheme::default())
.with_prompt("API key")
.validate_with(|input: &String| ApiKey::parse(input).map(|_| {}))
.validate_with(|input: &String| ApiKey::parse(input).map(|_| ()))
.interact()?
}
};

let api_key = ApiKey::parse(&api_key_str)?;

self.ctx.set_api_key(api_key)?;
self.ctx.set_api_key(api_key.clone())?;
self.client.as_mut().unwrap().set_api_key(api_key);

Ok(CommandOutcome::Ok)
}
Expand Down Expand Up @@ -629,7 +666,8 @@ impl Shuttle {
deployment.id
} else {
bail!(
"Could not find a running deployment for '{proj_name}'. Try with '--latest', or pass a deployment ID manually"
"Could not find a running deployment for '{proj_name}'. \
Try with '--latest', or pass a deployment ID manually"
);
}
};
Expand Down Expand Up @@ -1516,17 +1554,6 @@ impl Shuttle {
let client = self.client.as_ref().unwrap();
let config = project::Config { idle_minutes };

if idle_minutes > 0 {
let idle_msg = format!(
"Your project will sleep if it is idle for {} minutes.",
idle_minutes
);
println!();
println!("{}", idle_msg.yellow());
println!("To change the idle time refer to the docs: https://docs.shuttle.rs/getting-started/idle-projects");
println!();
}

self.wait_with_spinner(
&[
project::State::Ready,
Expand All @@ -1548,6 +1575,16 @@ impl Shuttle {
)
})?;

if idle_minutes > 0 {
let idle_msg = format!(
"Your project will sleep if it is idle for {} minutes.",
idle_minutes
);
println!("{}", idle_msg.yellow());
println!("To change the idle time refer to the docs: {SHUTTLE_IDLE_DOCS_URL}");
println!();
}

println!("Run `cargo shuttle deploy --allow-dirty` to deploy your Shuttle service.");

Ok(CommandOutcome::Ok)
Expand Down Expand Up @@ -1614,7 +1651,13 @@ impl Shuttle {
"getting project status failed repeteadly",
)
})?;
println!("{project}");
println!(
"{project}\nIdle minutes: {}",
project
.idle_minutes
.map(|i| i.to_string())
.unwrap_or("<unknown>".to_owned())
);
}

Ok(CommandOutcome::Ok)
Expand Down
10 changes: 5 additions & 5 deletions cargo-shuttle/tests/integration/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ fn interactive_rocket_init() -> Result<(), Box<dyn std::error::Error>> {
// Partial input should be enough to match "rocket"
session.send_line("roc")?;
session.exp_string("Creating project")?;
session.exp_string("Do you want to create the project environment on Shuttle?")?;
session.exp_string("container on Shuttle?")?;
session.send("n")?;
session.flush()?;
session.exp_string("no")?;
Expand Down Expand Up @@ -262,7 +262,7 @@ fn interactive_rocket_init_manually_choose_template() -> Result<(), Box<dyn std:
// Partial input should be enough to match "rocket"
session.send_line("roc")?;
session.exp_string("Creating project")?;
session.exp_string("Do you want to create the project environment on Shuttle?")?;
session.exp_string("container on Shuttle?")?;
session.send("n")?;
session.flush()?;
session.exp_string("no")?;
Expand Down Expand Up @@ -299,7 +299,7 @@ fn interactive_rocket_init_dont_prompt_framework() -> Result<(), Box<dyn std::er
session.exp_string("Directory")?;
session.send_line(temp_dir_path.to_str().unwrap())?;
session.exp_string("Creating project")?;
session.exp_string("Do you want to create the project environment on Shuttle?")?;
session.exp_string("container on Shuttle?")?;
session.send("n")?;
session.flush()?;
session.exp_string("no")?;
Expand Down Expand Up @@ -339,7 +339,7 @@ fn interactive_rocket_init_dont_prompt_name() -> Result<(), Box<dyn std::error::
// Partial input should be enough to match "rocket"
session.send_line("roc")?;
session.exp_string("Creating project")?;
session.exp_string("Do you want to create the project environment on Shuttle?")?;
session.exp_string("container on Shuttle?")?;
session.send("n")?;
session.flush()?;
session.exp_string("no")?;
Expand Down Expand Up @@ -381,7 +381,7 @@ fn interactive_rocket_init_prompt_path_dirty_dir() -> Result<(), Box<dyn std::er
session.flush()?;
session.exp_string("yes")?;
session.exp_string("Creating project")?;
session.exp_string("Do you want to create the project environment on Shuttle?")?;
session.exp_string("container on Shuttle?")?;
session.send("n")?;
session.flush()?;
session.exp_string("no")?;
Expand Down
2 changes: 1 addition & 1 deletion common/src/models/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ impl From<ErrorKind> for ApiError {
ErrorKind::UserAlreadyExists => (StatusCode::BAD_REQUEST, "user already exists"),
ErrorKind::ProjectNotFound => (
StatusCode::NOT_FOUND,
"project not found. Run `cargo shuttle project start` to create a new project.",
"project not found. Make sure you are the owner of this project name. Run `cargo shuttle project start` to create a new project.",
),
ErrorKind::ProjectNotReady => (
StatusCode::SERVICE_UNAVAILABLE,
Expand Down
2 changes: 1 addition & 1 deletion common/src/models/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ impl Eq for State {}

impl Display for Response {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "project '{}' is {}", self.name, self.state)
write!(f, r#"Project "{}" is {}"#, self.name, self.state)
}
}

Expand Down
24 changes: 24 additions & 0 deletions gateway/src/api/latest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,29 @@ async fn get_project(
Ok(AxumJson(response))
}

#[instrument(skip(service))]
#[utoipa::path(
get,
path = "/projects/name/{project_name}",
responses(
(status = 200, description = "True if project name is taken. False if free.", body = bool),
(status = 400, description = "Invalid project name.", body = String),
(status = 500, description = "Server internal error.")
),
params(
("project_name" = String, Path, description = "The project name to check."),
)
)]
async fn check_project_name(
State(RouterState { service, .. }): State<RouterState>,
Path(project_name): Path<ProjectName>,
) -> Result<AxumJson<bool>, Error> {
service
.project_name_exists(&project_name)
.await
.map(AxumJson)
}

#[utoipa::path(
get,
path = "/projects",
Expand Down Expand Up @@ -880,6 +903,7 @@ impl ApiBuilder {
.delete(destroy_project.layer(ScopedLayer::new(vec![Scope::ProjectCreate])))
.post(create_project.layer(ScopedLayer::new(vec![Scope::ProjectCreate]))),
)
.route("/projects/name/:project_name", get(check_project_name))
.route("/projects/:project_name/*any", any(route_project))
.route("/stats/load", post(post_load).delete(delete_load))
.nest("/admin", admin_routes);
Expand Down
10 changes: 10 additions & 0 deletions gateway/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,16 @@ impl GatewayService {
.ok_or_else(|| Error::from_kind(ErrorKind::ProjectNotFound))
}

pub async fn project_name_exists(&self, project_name: &ProjectName) -> Result<bool, Error> {
Ok(
query("SELECT project_name FROM projects WHERE project_name=?1")
.bind(project_name)
.fetch_optional(&self.db)
.await?
.is_some(),
)
}

pub async fn iter_user_projects_detailed(
&self,
account_name: &AccountName,
Expand Down

0 comments on commit da18b3b

Please sign in to comment.