diff --git a/admin/src/args.rs b/admin/src/args.rs index e29936754..46603f425 100644 --- a/admin/src/args.rs +++ b/admin/src/args.rs @@ -18,8 +18,12 @@ pub enum Command { /// Try to revive projects in the crashed state Revive, + /// Manage custom domains #[command(subcommand)] Acme(AcmeCommand), + + /// Manage project names + ProjectNames, } #[derive(Subcommand, Debug)] diff --git a/admin/src/client.rs b/admin/src/client.rs index 19f3e5468..e37ae4d40 100644 --- a/admin/src/client.rs +++ b/admin/src/client.rs @@ -1,6 +1,9 @@ use anyhow::{Context, Result}; use serde::{de::DeserializeOwned, Serialize}; -use shuttle_common::{models::ToJson, project::ProjectName}; +use shuttle_common::{ + models::{project, ToJson}, + project::ProjectName, +}; use tracing::trace; pub struct Client { @@ -36,6 +39,10 @@ impl Client { self.post(&path, Some(credentials)).await } + pub async fn get_projects(&self) -> Result> { + self.get("/admin/projects").await + } + async fn post( &self, path: &str, @@ -59,4 +66,16 @@ impl Client { .await .context("failed to extract json body from post response") } + + async fn get(&self, path: &str) -> Result { + reqwest::Client::new() + .get(format!("{}{}", self.api_url, path)) + .bearer_auth(&self.api_key) + .send() + .await + .context("failed to make post request")? + .to_json() + .await + .context("failed to post text body from response") + } } diff --git a/admin/src/main.rs b/admin/src/main.rs index 6127076d7..ccf763ca6 100644 --- a/admin/src/main.rs +++ b/admin/src/main.rs @@ -4,7 +4,11 @@ use shuttle_admin::{ client::Client, config::get_api_key, }; -use std::{fmt::Write, fs}; +use std::{ + collections::{hash_map::RandomState, HashMap}, + fmt::Write, + fs, +}; use tracing::trace; #[tokio::main] @@ -46,6 +50,97 @@ async fn main() { .await .expect("to get a certificate challenge response") } + Command::ProjectNames => { + let projects = client + .get_projects() + .await + .expect("to get list of projects"); + + let projects: HashMap = HashMap::from_iter( + projects + .into_iter() + .map(|project| (project.project_name, project.account_name)), + ); + + let mut res = String::new(); + + for (project_name, account_name) in &projects { + let mut issues = Vec::new(); + let cleaned_name = project_name.to_lowercase(); + + // Were there any uppercase characters + if &cleaned_name != project_name { + // Since there were uppercase characters, will the new name clash with any existing projects + if let Some(other_account) = projects.get(&cleaned_name) { + if other_account == account_name { + issues.push( + "changing to lower case will clash with same owner".to_string(), + ); + } else { + issues.push(format!( + "changing to lower case will clash with another owner: {other_account}" + )); + } + } + } + + let cleaned_underscore = cleaned_name.replace('_', "-"); + // Were there any underscore cleanups + if cleaned_underscore != cleaned_name { + // Since there were underscore cleanups, will the new name clash with any existing projects + if let Some(other_account) = projects.get(&cleaned_underscore) { + if other_account == account_name { + issues + .push("cleaning underscore will clash with same owner".to_string()); + } else { + issues.push(format!( + "cleaning underscore will clash with another owner: {other_account}" + )); + } + } + } + + let cleaned_separator_name = cleaned_underscore.trim_matches('-'); + // Were there any dash cleanups + if cleaned_separator_name != cleaned_underscore { + // Since there were dash cleanups, will the new name clash with any existing projects + if let Some(other_account) = projects.get(cleaned_separator_name) { + if other_account == account_name { + issues.push("cleaning dashes will clash with same owner".to_string()); + } else { + issues.push(format!( + "cleaning dashes will clash with another owner: {other_account}" + )); + } + } + } + + // Are reserved words used + match cleaned_separator_name { + "shuttleapp" | "shuttle" => issues.push("is a reserved name".to_string()), + _ => {} + } + + // Is it longer than 63 chars + if cleaned_separator_name.len() > 63 { + issues.push("final name is too long".to_string()); + } + + // Only report of problem projects + if !issues.is_empty() { + writeln!(res, "{project_name}") + .expect("to write name of project name having issues"); + + for issue in issues { + writeln!(res, "\t- {issue}").expect("to write issue with project name"); + } + + writeln!(res).expect("to write a new line"); + } + } + + res + } }; println!("{res}"); diff --git a/common/src/models/project.rs b/common/src/models/project.rs index 12cf1cfc8..171c4e46a 100644 --- a/common/src/models/project.rs +++ b/common/src/models/project.rs @@ -46,3 +46,9 @@ impl State { } } } + +#[derive(Deserialize, Serialize)] +pub struct AdminResponse { + pub project_name: String, + pub account_name: String, +} diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index 1581f473f..a76981496 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -256,6 +256,20 @@ async fn request_acme_certificate( Ok("certificate created".to_string()) } +async fn get_projects( + _: Admin, + Extension(service): Extension>, +) -> Result>, Error> { + let projects = service + .iter_projects_detailed() + .await? + .into_iter() + .map(Into::into) + .collect(); + + Ok(AxumJson(projects)) +} + #[derive(Clone)] pub(crate) struct RouterState { pub service: Arc, @@ -293,6 +307,7 @@ impl ApiBuilder { "/admin/acme/request/:project_name/:fqdn", post(request_acme_certificate), ) + .route("/admin/projects", get(get_projects)) .layer(Extension(acme)) .layer(Extension(resolver)); self diff --git a/gateway/src/lib.rs b/gateway/src/lib.rs index 1b2cbd657..a747bc673 100644 --- a/gateway/src/lib.rs +++ b/gateway/src/lib.rs @@ -185,6 +185,21 @@ impl<'de> Deserialize<'de> for AccountName { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectDetails { + pub project_name: ProjectName, + pub account_name: AccountName, +} + +impl From for shuttle_common::models::project::AdminResponse { + fn from(project: ProjectDetails) -> Self { + Self { + project_name: project.project_name.to_string(), + account_name: project.account_name.to_string(), + } + } +} + pub trait DockerContext: Send + Sync { fn docker(&self) -> &Docker; diff --git a/gateway/src/service.rs b/gateway/src/service.rs index 0134a6e7d..ed32cd884 100644 --- a/gateway/src/service.rs +++ b/gateway/src/service.rs @@ -29,7 +29,7 @@ use crate::args::ContextArgs; use crate::auth::{Key, Permissions, ScopedUser, User}; use crate::project::Project; use crate::task::TaskBuilder; -use crate::{AccountName, DockerContext, Error, ErrorKind, ProjectName}; +use crate::{AccountName, DockerContext, Error, ErrorKind, ProjectDetails, ProjectName}; pub static MIGRATIONS: Migrator = sqlx::migrate!("./migrations"); static PROXY_CLIENT: Lazy>> = @@ -525,6 +525,20 @@ impl GatewayService { Ok(custom_domain) } + pub async fn iter_projects_detailed( + &self, + ) -> Result, Error> { + let iter = query("SELECT project_name, account_name FROM projects") + .fetch_all(&self.db) + .await? + .into_iter() + .map(|row| ProjectDetails { + project_name: row.try_get("project_name").unwrap(), + account_name: row.try_get("account_name").unwrap(), + }); + Ok(iter) + } + pub fn context(&self) -> GatewayContext { self.provider.context() } @@ -642,6 +656,17 @@ pub mod tests { assert!(creating_same_project_name(&project, &matrix)); assert_eq!(svc.find_project(&matrix).await.unwrap(), project); + assert_eq!( + svc.iter_projects_detailed() + .await + .unwrap() + .next() + .expect("to get one project with its user"), + ProjectDetails { + project_name: matrix.clone(), + account_name: neo.clone(), + } + ); let mut work = svc .new_task()