Skip to content

Commit 2e6ac41

Browse files
authored
feat: find (soon to be) invalid project names (#479)
* feat: find (soon to be) invalid project names * refactor: move logic to admin client
1 parent 6bbda80 commit 2e6ac41

File tree

7 files changed

+182
-3
lines changed

7 files changed

+182
-3
lines changed

admin/src/args.rs

+4
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ pub enum Command {
1818
/// Try to revive projects in the crashed state
1919
Revive,
2020

21+
/// Manage custom domains
2122
#[command(subcommand)]
2223
Acme(AcmeCommand),
24+
25+
/// Manage project names
26+
ProjectNames,
2327
}
2428

2529
#[derive(Subcommand, Debug)]

admin/src/client.rs

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use anyhow::{Context, Result};
22
use serde::{de::DeserializeOwned, Serialize};
3-
use shuttle_common::{models::ToJson, project::ProjectName};
3+
use shuttle_common::{
4+
models::{project, ToJson},
5+
project::ProjectName,
6+
};
47
use tracing::trace;
58

69
pub struct Client {
@@ -36,6 +39,10 @@ impl Client {
3639
self.post(&path, Some(credentials)).await
3740
}
3841

42+
pub async fn get_projects(&self) -> Result<Vec<project::AdminResponse>> {
43+
self.get("/admin/projects").await
44+
}
45+
3946
async fn post<T: Serialize, R: DeserializeOwned>(
4047
&self,
4148
path: &str,
@@ -59,4 +66,16 @@ impl Client {
5966
.await
6067
.context("failed to extract json body from post response")
6168
}
69+
70+
async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R> {
71+
reqwest::Client::new()
72+
.get(format!("{}{}", self.api_url, path))
73+
.bearer_auth(&self.api_key)
74+
.send()
75+
.await
76+
.context("failed to make post request")?
77+
.to_json()
78+
.await
79+
.context("failed to post text body from response")
80+
}
6281
}

admin/src/main.rs

+96-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ use shuttle_admin::{
44
client::Client,
55
config::get_api_key,
66
};
7-
use std::{fmt::Write, fs};
7+
use std::{
8+
collections::{hash_map::RandomState, HashMap},
9+
fmt::Write,
10+
fs,
11+
};
812
use tracing::trace;
913

1014
#[tokio::main]
@@ -46,6 +50,97 @@ async fn main() {
4650
.await
4751
.expect("to get a certificate challenge response")
4852
}
53+
Command::ProjectNames => {
54+
let projects = client
55+
.get_projects()
56+
.await
57+
.expect("to get list of projects");
58+
59+
let projects: HashMap<String, String, RandomState> = HashMap::from_iter(
60+
projects
61+
.into_iter()
62+
.map(|project| (project.project_name, project.account_name)),
63+
);
64+
65+
let mut res = String::new();
66+
67+
for (project_name, account_name) in &projects {
68+
let mut issues = Vec::new();
69+
let cleaned_name = project_name.to_lowercase();
70+
71+
// Were there any uppercase characters
72+
if &cleaned_name != project_name {
73+
// Since there were uppercase characters, will the new name clash with any existing projects
74+
if let Some(other_account) = projects.get(&cleaned_name) {
75+
if other_account == account_name {
76+
issues.push(
77+
"changing to lower case will clash with same owner".to_string(),
78+
);
79+
} else {
80+
issues.push(format!(
81+
"changing to lower case will clash with another owner: {other_account}"
82+
));
83+
}
84+
}
85+
}
86+
87+
let cleaned_underscore = cleaned_name.replace('_', "-");
88+
// Were there any underscore cleanups
89+
if cleaned_underscore != cleaned_name {
90+
// Since there were underscore cleanups, will the new name clash with any existing projects
91+
if let Some(other_account) = projects.get(&cleaned_underscore) {
92+
if other_account == account_name {
93+
issues
94+
.push("cleaning underscore will clash with same owner".to_string());
95+
} else {
96+
issues.push(format!(
97+
"cleaning underscore will clash with another owner: {other_account}"
98+
));
99+
}
100+
}
101+
}
102+
103+
let cleaned_separator_name = cleaned_underscore.trim_matches('-');
104+
// Were there any dash cleanups
105+
if cleaned_separator_name != cleaned_underscore {
106+
// Since there were dash cleanups, will the new name clash with any existing projects
107+
if let Some(other_account) = projects.get(cleaned_separator_name) {
108+
if other_account == account_name {
109+
issues.push("cleaning dashes will clash with same owner".to_string());
110+
} else {
111+
issues.push(format!(
112+
"cleaning dashes will clash with another owner: {other_account}"
113+
));
114+
}
115+
}
116+
}
117+
118+
// Are reserved words used
119+
match cleaned_separator_name {
120+
"shuttleapp" | "shuttle" => issues.push("is a reserved name".to_string()),
121+
_ => {}
122+
}
123+
124+
// Is it longer than 63 chars
125+
if cleaned_separator_name.len() > 63 {
126+
issues.push("final name is too long".to_string());
127+
}
128+
129+
// Only report of problem projects
130+
if !issues.is_empty() {
131+
writeln!(res, "{project_name}")
132+
.expect("to write name of project name having issues");
133+
134+
for issue in issues {
135+
writeln!(res, "\t- {issue}").expect("to write issue with project name");
136+
}
137+
138+
writeln!(res).expect("to write a new line");
139+
}
140+
}
141+
142+
res
143+
}
49144
};
50145

51146
println!("{res}");

common/src/models/project.rs

+6
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,9 @@ impl State {
4646
}
4747
}
4848
}
49+
50+
#[derive(Deserialize, Serialize)]
51+
pub struct AdminResponse {
52+
pub project_name: String,
53+
pub account_name: String,
54+
}

gateway/src/api/latest.rs

+15
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,20 @@ async fn request_acme_certificate(
256256
Ok("certificate created".to_string())
257257
}
258258

259+
async fn get_projects(
260+
_: Admin,
261+
Extension(service): Extension<Arc<GatewayService>>,
262+
) -> Result<AxumJson<Vec<project::AdminResponse>>, Error> {
263+
let projects = service
264+
.iter_projects_detailed()
265+
.await?
266+
.into_iter()
267+
.map(Into::into)
268+
.collect();
269+
270+
Ok(AxumJson(projects))
271+
}
272+
259273
#[derive(Clone)]
260274
pub(crate) struct RouterState {
261275
pub service: Arc<GatewayService>,
@@ -293,6 +307,7 @@ impl ApiBuilder {
293307
"/admin/acme/request/:project_name/:fqdn",
294308
post(request_acme_certificate),
295309
)
310+
.route("/admin/projects", get(get_projects))
296311
.layer(Extension(acme))
297312
.layer(Extension(resolver));
298313
self

gateway/src/lib.rs

+15
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,21 @@ impl<'de> Deserialize<'de> for AccountName {
185185
}
186186
}
187187

188+
#[derive(Debug, Clone, PartialEq, Eq)]
189+
pub struct ProjectDetails {
190+
pub project_name: ProjectName,
191+
pub account_name: AccountName,
192+
}
193+
194+
impl From<ProjectDetails> for shuttle_common::models::project::AdminResponse {
195+
fn from(project: ProjectDetails) -> Self {
196+
Self {
197+
project_name: project.project_name.to_string(),
198+
account_name: project.account_name.to_string(),
199+
}
200+
}
201+
}
202+
188203
pub trait DockerContext: Send + Sync {
189204
fn docker(&self) -> &Docker;
190205

gateway/src/service.rs

+26-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use crate::args::ContextArgs;
2929
use crate::auth::{Key, Permissions, ScopedUser, User};
3030
use crate::project::Project;
3131
use crate::task::TaskBuilder;
32-
use crate::{AccountName, DockerContext, Error, ErrorKind, ProjectName};
32+
use crate::{AccountName, DockerContext, Error, ErrorKind, ProjectDetails, ProjectName};
3333

3434
pub static MIGRATIONS: Migrator = sqlx::migrate!("./migrations");
3535
static PROXY_CLIENT: Lazy<ReverseProxy<HttpConnector<GaiResolver>>> =
@@ -525,6 +525,20 @@ impl GatewayService {
525525
Ok(custom_domain)
526526
}
527527

528+
pub async fn iter_projects_detailed(
529+
&self,
530+
) -> Result<impl Iterator<Item = ProjectDetails>, Error> {
531+
let iter = query("SELECT project_name, account_name FROM projects")
532+
.fetch_all(&self.db)
533+
.await?
534+
.into_iter()
535+
.map(|row| ProjectDetails {
536+
project_name: row.try_get("project_name").unwrap(),
537+
account_name: row.try_get("account_name").unwrap(),
538+
});
539+
Ok(iter)
540+
}
541+
528542
pub fn context(&self) -> GatewayContext {
529543
self.provider.context()
530544
}
@@ -642,6 +656,17 @@ pub mod tests {
642656
assert!(creating_same_project_name(&project, &matrix));
643657

644658
assert_eq!(svc.find_project(&matrix).await.unwrap(), project);
659+
assert_eq!(
660+
svc.iter_projects_detailed()
661+
.await
662+
.unwrap()
663+
.next()
664+
.expect("to get one project with its user"),
665+
ProjectDetails {
666+
project_name: matrix.clone(),
667+
account_name: neo.clone(),
668+
}
669+
);
645670

646671
let mut work = svc
647672
.new_task()

0 commit comments

Comments
 (0)