Skip to content

Commit

Permalink
feat: find (soon to be) invalid project names (#479)
Browse files Browse the repository at this point in the history
* feat: find (soon to be) invalid project names

* refactor: move logic to admin client
  • Loading branch information
chesedo authored Dec 8, 2022
1 parent 6bbda80 commit 2e6ac41
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 3 deletions.
4 changes: 4 additions & 0 deletions admin/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
21 changes: 20 additions & 1 deletion admin/src/client.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -36,6 +39,10 @@ impl Client {
self.post(&path, Some(credentials)).await
}

pub async fn get_projects(&self) -> Result<Vec<project::AdminResponse>> {
self.get("/admin/projects").await
}

async fn post<T: Serialize, R: DeserializeOwned>(
&self,
path: &str,
Expand All @@ -59,4 +66,16 @@ impl Client {
.await
.context("failed to extract json body from post response")
}

async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
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")
}
}
97 changes: 96 additions & 1 deletion admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<String, String, RandomState> = 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}");
Expand Down
6 changes: 6 additions & 0 deletions common/src/models/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@ impl State {
}
}
}

#[derive(Deserialize, Serialize)]
pub struct AdminResponse {
pub project_name: String,
pub account_name: String,
}
15 changes: 15 additions & 0 deletions gateway/src/api/latest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,20 @@ async fn request_acme_certificate(
Ok("certificate created".to_string())
}

async fn get_projects(
_: Admin,
Extension(service): Extension<Arc<GatewayService>>,
) -> Result<AxumJson<Vec<project::AdminResponse>>, 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<GatewayService>,
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectDetails> 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;

Expand Down
27 changes: 26 additions & 1 deletion gateway/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReverseProxy<HttpConnector<GaiResolver>>> =
Expand Down Expand Up @@ -525,6 +525,20 @@ impl GatewayService {
Ok(custom_domain)
}

pub async fn iter_projects_detailed(
&self,
) -> Result<impl Iterator<Item = ProjectDetails>, 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()
}
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 2e6ac41

Please sign in to comment.