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

feat(cargo-shuttle): check project name available #1279

Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
90 changes: 66 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 @@ -153,7 +154,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 @@ -164,7 +166,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 @@ -297,12 +302,30 @@ 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);
}
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 @@ -392,9 +415,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`."
)
}
jonaro00 marked this conversation as resolved.
Show resolved Hide resolved
println!();
should_create
};
Expand All @@ -420,7 +455,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 @@ -453,14 +488,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 @@ -603,7 +639,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 @@ -1484,17 +1521,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 @@ -1516,6 +1542,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 @@ -1582,7 +1618,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 @@ -264,7 +264,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 @@ -306,7 +306,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 @@ -344,7 +344,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 @@ -385,7 +385,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 @@ -428,7 +428,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