From 28089949c0a3491e9e104eb23e5224bb6237abf6 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Wed, 6 Nov 2024 11:09:28 -0800 Subject: [PATCH 1/9] feat: interactive custom domain creation wip: custom domains feat: interactive custom domain creation chore: remove gql.json chore: remove testing file --- src/commands/custom_domain.rs | 148 ++++++++++++++++++ src/commands/mod.rs | 2 + src/gql/mutations/mod.rs | 25 +++ .../strings/CustomDomainCreate.graphql | 33 ++++ src/gql/queries/mod.rs | 14 ++ .../strings/CustomDomainAvailable.graphql | 7 + src/gql/schema.json | 6 + src/main.rs | 3 +- 8 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/commands/custom_domain.rs create mode 100644 src/gql/mutations/strings/CustomDomainCreate.graphql create mode 100644 src/gql/queries/strings/CustomDomainAvailable.graphql diff --git a/src/commands/custom_domain.rs b/src/commands/custom_domain.rs new file mode 100644 index 000000000..ea81b8056 --- /dev/null +++ b/src/commands/custom_domain.rs @@ -0,0 +1,148 @@ +use anyhow::bail; +use regex::Regex; + +use crate::{ + controllers::project::{ensure_project_and_environment_exist, get_project}, + errors::RailwayError, + util::prompt::{prompt_options, prompt_text}, +}; + +use super::*; + +/// Add a custom domain for a service +#[derive(Parser)] +pub struct Args { + #[clap(short, long)] + domain: Option, +} + +pub async fn command(args: Args, _json: bool) -> Result<()> { + // it's too bad that we have to check twice, but I think the UX is better + // if we immediately exit if the user enters an invalid domain + if let Some(domain) = &args.domain { + if !is_valid_domain(&domain) { + bail!("Invalid domain"); + } + } + + let configs = Configs::new()?; + + let client = GQLClient::new_authorized(&configs)?; + let linked_project = configs.get_linked_project().await?; + + ensure_project_and_environment_exist(&client, &configs, &linked_project).await?; + + let project = get_project(&client, &configs, linked_project.project.clone()).await?; + + if project.services.edges.is_empty() { + return Err(RailwayError::NoServices.into()); + } + + let service = get_service(&linked_project, &project)?; + + println!("Creating custom domain for service {}...", service.name); + + let domain = match args.domain { + Some(domain) => domain, + None => prompt_text("Enter the domain")?, + }; + + if !is_valid_domain(&domain) { + bail!("Invalid domain"); + } + + let is_available = post_graphql::( + &client, + configs.get_backboard(), + queries::custom_domain_available::Variables { + domain: domain.clone(), + }, + ) + .await?; + + if !is_available.custom_domain_available.available { + bail!( + "Domain is not available:\n{}", + is_available.custom_domain_available.message + ); + } + + let input = mutations::custom_domain_create::CustomDomainCreateInput { + domain: domain.clone(), + environment_id: linked_project.environment.clone(), + project_id: linked_project.project.clone(), + service_id: service.id.clone(), + target_port: None, + }; + + let vars = mutations::custom_domain_create::Variables { input }; + + let response = + post_graphql::(&client, configs.get_backboard(), vars) + .await?; + + println!("Domain created: {}", response.custom_domain_create.domain); + + if response.custom_domain_create.status.dns_records.is_empty() { + // Should never happen (would only be possible in a backend bug) + // but just in case + bail!("No DNS records found. Please check the Railway dashboard for more information."); + } + + println!( + "To finish setting up your custom domain, add the following to the DNS records for {}:\n", + &response.custom_domain_create.status.dns_records[0].zone + ); + + // TODO: What is the maximum length of the hostlabel that railway supports? + // TODO: if the length is very long, consider checking the maximum length \ + // and then printing the table header with different spacing + println!("\tType\tHost\tValue"); + for record in response.custom_domain_create.status.dns_records { + println!( + "\t{}\t{}\t{}", + record.record_type, record.hostlabel, record.required_value, + ); + } + + println!("\nPlease be aware that DNS records can take up to 72 hours to propagate worldwide."); + + Ok(()) +} + +// Returns a reference to save on Heap allocations +fn get_service<'a>( + linked_project: &'a LinkedProject, + project: &'a queries::project::ProjectProject, +) -> Result<&'a queries::project::ProjectProjectServicesEdgesNode, anyhow::Error> { + let services = project.services.edges.iter().collect::>(); + + if services.is_empty() { + bail!(RailwayError::NoServices); + } + + if project.services.edges.len() == 1 { + return Ok(&project.services.edges[0].node); + } + + if let Some(service) = linked_project.service.clone() { + if project.services.edges.iter().any(|s| s.node.id == service) { + return Ok(&project + .services + .edges + .iter() + .find(|s| s.node.id == service) + .unwrap() + .node); + } + } + + let service = prompt_options("Select a service", services)?; + + Ok(&service.node) +} + +fn is_valid_domain(domain: &str) -> bool { + let domain_regex = Regex::new(r"^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$").unwrap(); + domain_regex.is_match(domain) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6aa5dc440..4ef6d9dcc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -29,3 +29,5 @@ pub mod up; pub mod variables; pub mod volume; pub mod whoami; + +pub mod custom_domain; diff --git a/src/gql/mutations/mod.rs b/src/gql/mutations/mod.rs index 31c90df36..95831c4cb 100644 --- a/src/gql/mutations/mod.rs +++ b/src/gql/mutations/mod.rs @@ -1,6 +1,9 @@ use graphql_client::GraphQLQuery; use serde::{Deserialize, Serialize}; type EnvironmentVariables = std::collections::BTreeMap; +use chrono::{DateTime as DateTimeType, Utc}; + +pub type DateTime = DateTimeType; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -148,3 +151,25 @@ pub struct VariableCollectionUpsert; skip_serializing_none )] pub struct ServiceCreate; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "src/gql/schema.json", + query_path = "src/gql/mutations/strings/CustomDomainCreate.graphql", + response_derives = "Debug, Serialize, Clone", + skip_serializing_none +)] +pub struct CustomDomainCreate; + +impl std::fmt::Display for custom_domain_create::DNSRecordType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DNS_RECORD_TYPE_CNAME => write!(f, "CNAME"), + Self::DNS_RECORD_TYPE_A => write!(f, "A"), + Self::DNS_RECORD_TYPE_NS => write!(f, "NS"), + Self::DNS_RECORD_TYPE_UNSPECIFIED => write!(f, "UNSPECIFIED"), + Self::UNRECOGNIZED => write!(f, "UNRECOGNIZED"), + Self::Other(s) => write!(f, "{}", s), + } + } +} diff --git a/src/gql/mutations/strings/CustomDomainCreate.graphql b/src/gql/mutations/strings/CustomDomainCreate.graphql new file mode 100644 index 000000000..55240bfbe --- /dev/null +++ b/src/gql/mutations/strings/CustomDomainCreate.graphql @@ -0,0 +1,33 @@ +mutation CustomDomainCreate($input: CustomDomainCreateInput!) { + customDomainCreate(input: $input) { + id + domain + createdAt + updatedAt + serviceId + environmentId + projectId + targetPort + status { + dnsRecords { + hostlabel + fqdn + recordType + requiredValue + currentValue + status + zone + purpose + } + cdnProvider + certificates { + issuedAt + expiresAt + domainNames + fingerprintSha256 + keyType + } + certificateStatus + } + } +} diff --git a/src/gql/queries/mod.rs b/src/gql/queries/mod.rs index dd7eb744a..47f0a18e5 100644 --- a/src/gql/queries/mod.rs +++ b/src/gql/queries/mod.rs @@ -66,6 +66,12 @@ pub struct TemplateServiceConfigIcon { pub struct Project; pub type RailwayProject = project::ProjectProject; +impl std::fmt::Display for project::ProjectProjectServicesEdges { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.node.name) + } +} + #[derive(GraphQLQuery)] #[graphql( schema_path = "src/gql/schema.json", @@ -156,3 +162,11 @@ pub struct TemplateDetail; response_derives = "Debug, Serialize, Clone" )] pub struct GitHubRepos; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "src/gql/schema.json", + query_path = "src/gql/queries/strings/CustomDomainAvailable.graphql", + response_derives = "Debug, Serialize, Clone" +)] +pub struct CustomDomainAvailable; diff --git a/src/gql/queries/strings/CustomDomainAvailable.graphql b/src/gql/queries/strings/CustomDomainAvailable.graphql new file mode 100644 index 000000000..d3fddfee0 --- /dev/null +++ b/src/gql/queries/strings/CustomDomainAvailable.graphql @@ -0,0 +1,7 @@ +query CustomDomainAvailable($domain: String!) { + customDomainAvailable(domain: $domain) { + __typename + available + message + } +} diff --git a/src/gql/schema.json b/src/gql/schema.json index 560dae20e..d62adeca8 100644 --- a/src/gql/schema.json +++ b/src/gql/schema.json @@ -157,6 +157,12 @@ { "description": null, "enumValues": [ + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "BACKUPS" + }, { "deprecationReason": null, "description": null, diff --git a/src/main.rs b/src/main.rs index 0a7cde75d..02eae6176 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,7 +58,8 @@ commands_enum!( variables, whoami, volume, - redeploy + redeploy, + custom_domain ); #[tokio::main] From a5847f54367714c492c327fca7d7958577e01f21 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Mon, 4 Nov 2024 00:59:30 -0800 Subject: [PATCH 2/9] add json output for custom domain --- src/commands/custom_domain.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/commands/custom_domain.rs b/src/commands/custom_domain.rs index ea81b8056..e22b24c8a 100644 --- a/src/commands/custom_domain.rs +++ b/src/commands/custom_domain.rs @@ -16,7 +16,7 @@ pub struct Args { domain: Option, } -pub async fn command(args: Args, _json: bool) -> Result<()> { +pub async fn command(args: Args, json: bool) -> Result<()> { // it's too bad that we have to check twice, but I think the UX is better // if we immediately exit if the user enters an invalid domain if let Some(domain) = &args.domain { @@ -40,7 +40,9 @@ pub async fn command(args: Args, _json: bool) -> Result<()> { let service = get_service(&linked_project, &project)?; - println!("Creating custom domain for service {}...", service.name); + if !json { + println!("Creating custom domain for service {}...", service.name); + } let domain = match args.domain { Some(domain) => domain, @@ -81,6 +83,11 @@ pub async fn command(args: Args, _json: bool) -> Result<()> { post_graphql::(&client, configs.get_backboard(), vars) .await?; + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + return Ok(()); + } + println!("Domain created: {}", response.custom_domain_create.domain); if response.custom_domain_create.status.dns_records.is_empty() { From f2b7f42d7ba57503207ea436d23eb567e9aae1bc Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Mon, 4 Nov 2024 23:14:39 -0800 Subject: [PATCH 3/9] made custom domain an extension of domain instead of its own command --- ...stom_domain.rs => create_custom_domain.rs} | 86 +++------- src/commands/domain.rs | 150 +++++++++++------- src/commands/mod.rs | 3 +- src/main.rs | 3 +- 4 files changed, 115 insertions(+), 127 deletions(-) rename src/commands/{custom_domain.rs => create_custom_domain.rs} (56%) diff --git a/src/commands/custom_domain.rs b/src/commands/create_custom_domain.rs similarity index 56% rename from src/commands/custom_domain.rs rename to src/commands/create_custom_domain.rs index e22b24c8a..53283111d 100644 --- a/src/commands/custom_domain.rs +++ b/src/commands/create_custom_domain.rs @@ -1,28 +1,19 @@ use anyhow::bail; +use is_terminal::IsTerminal; use regex::Regex; use crate::{ controllers::project::{ensure_project_and_environment_exist, get_project}, errors::RailwayError, - util::prompt::{prompt_options, prompt_text}, }; -use super::*; +use domain::{creating_domain_spiner, get_service}; -/// Add a custom domain for a service -#[derive(Parser)] -pub struct Args { - #[clap(short, long)] - domain: Option, -} +use super::*; -pub async fn command(args: Args, json: bool) -> Result<()> { - // it's too bad that we have to check twice, but I think the UX is better - // if we immediately exit if the user enters an invalid domain - if let Some(domain) = &args.domain { - if !is_valid_domain(&domain) { - bail!("Invalid domain"); - } +pub async fn create_custom_domain(domain: String, port: Option, json: bool) -> Result<()> { + if !is_valid_domain(&domain) { + bail!("Invalid domain"); } let configs = Configs::new()?; @@ -40,19 +31,16 @@ pub async fn command(args: Args, json: bool) -> Result<()> { let service = get_service(&linked_project, &project)?; - if !json { - println!("Creating custom domain for service {}...", service.name); - } - - let domain = match args.domain { - Some(domain) => domain, - None => prompt_text("Enter the domain")?, + let spinner = if std::io::stdout().is_terminal() && !json { + Some(creating_domain_spiner(Some(format!( + "Creating custom domain for service {}{}...", + service.name, + port.unwrap_or_default() + )))?) + } else { + None }; - if !is_valid_domain(&domain) { - bail!("Invalid domain"); - } - let is_available = post_graphql::( &client, configs.get_backboard(), @@ -63,10 +51,7 @@ pub async fn command(args: Args, json: bool) -> Result<()> { .await?; if !is_available.custom_domain_available.available { - bail!( - "Domain is not available:\n{}", - is_available.custom_domain_available.message - ); + bail!("Domain is not available:\n\t{}", domain); } let input = mutations::custom_domain_create::CustomDomainCreateInput { @@ -74,7 +59,7 @@ pub async fn command(args: Args, json: bool) -> Result<()> { environment_id: linked_project.environment.clone(), project_id: linked_project.project.clone(), service_id: service.id.clone(), - target_port: None, + target_port: port.map(|p| p as i64), }; let vars = mutations::custom_domain_create::Variables { input }; @@ -83,6 +68,10 @@ pub async fn command(args: Args, json: bool) -> Result<()> { post_graphql::(&client, configs.get_backboard(), vars) .await?; + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } + if json { println!("{}", serde_json::to_string_pretty(&response)?); return Ok(()); @@ -101,9 +90,6 @@ pub async fn command(args: Args, json: bool) -> Result<()> { &response.custom_domain_create.status.dns_records[0].zone ); - // TODO: What is the maximum length of the hostlabel that railway supports? - // TODO: if the length is very long, consider checking the maximum length \ - // and then printing the table header with different spacing println!("\tType\tHost\tValue"); for record in response.custom_domain_create.status.dns_records { println!( @@ -117,38 +103,6 @@ pub async fn command(args: Args, json: bool) -> Result<()> { Ok(()) } -// Returns a reference to save on Heap allocations -fn get_service<'a>( - linked_project: &'a LinkedProject, - project: &'a queries::project::ProjectProject, -) -> Result<&'a queries::project::ProjectProjectServicesEdgesNode, anyhow::Error> { - let services = project.services.edges.iter().collect::>(); - - if services.is_empty() { - bail!(RailwayError::NoServices); - } - - if project.services.edges.len() == 1 { - return Ok(&project.services.edges[0].node); - } - - if let Some(service) = linked_project.service.clone() { - if project.services.edges.iter().any(|s| s.node.id == service) { - return Ok(&project - .services - .edges - .iter() - .find(|s| s.node.id == service) - .unwrap() - .node); - } - } - - let service = prompt_options("Select a service", services)?; - - Ok(&service.node) -} - fn is_valid_domain(domain: &str) -> bool { let domain_regex = Regex::new(r"^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$").unwrap(); domain_regex.is_match(domain) diff --git a/src/commands/domain.rs b/src/commands/domain.rs index 47c56053a..84f36618f 100644 --- a/src/commands/domain.rs +++ b/src/commands/domain.rs @@ -2,23 +2,43 @@ use std::time::Duration; use anyhow::bail; use colored::Colorize; +use create_custom_domain::create_custom_domain; use is_terminal::IsTerminal; use queries::domains::DomainsDomains; +use serde_json::json; use crate::{ consts::TICK_STRING, controllers::project::{ensure_project_and_environment_exist, get_project}, errors::RailwayError, + util::prompt::prompt_options, }; use super::*; -/// Generates a domain for a service if there is not a railway provided domain -// Checks if the user is linked to a service, if not, it will generate a domain for the default service +/// Add a custom domain or generate a railway provided domain for a service. +/// +/// There is a maximum of 1 railway provided domain per service. #[derive(Parser)] -pub struct Args {} +pub struct Args { + /// The service to generate a domain for + #[clap(short, long)] + port: Option, + + /// Optionally, specify a custom domain to use. If not specified, a domain will be generated. + /// + /// Specifying a custom domain will also return the required DNS records + /// to add to your DNS settings + domain: Option, +} + +pub async fn command(args: Args, json: bool) -> Result<()> { + if let Some(domain) = args.domain { + create_custom_domain(domain, args.port, json).await?; + + return Ok(()); + } -pub async fn command(_args: Args, _json: bool) -> Result<()> { let configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; @@ -29,27 +49,15 @@ pub async fn command(_args: Args, _json: bool) -> Result<()> { let project = get_project(&client, &configs, linked_project.project.clone()).await?; if project.services.edges.is_empty() { - return Err(RailwayError::NoServices.into()); + bail!(RailwayError::NoServices); } - // If there is only one service, it will generate a domain for that service - let service = if project.services.edges.len() == 1 { - project.services.edges[0].node.clone().id - } else { - let Some(service) = linked_project.service.clone() else { - bail!("No service linked. Run `railway service` to link to a service"); - }; - if project.services.edges.iter().any(|s| s.node.id == service) { - service - } else { - bail!("Service not found! Run `railway service` to link to a service"); - } - }; + let service = get_service(&linked_project, &project)?; let vars = queries::domains::Variables { project_id: linked_project.project.clone(), environment_id: linked_project.environment.clone(), - service_id: service.clone(), + service_id: service.id.clone(), }; let domains = post_graphql::(&client, configs.get_backboard(), vars) @@ -57,55 +65,38 @@ pub async fn command(_args: Args, _json: bool) -> Result<()> { .domains; let domain_count = domains.service_domains.len() + domains.custom_domains.len(); - if domain_count > 0 { return print_existing_domains(&domains); } + let spinner = if std::io::stdout().is_terminal() && !json { + Some(creating_domain_spiner(None)?) + } else { + None + }; + let vars = mutations::service_domain_create::Variables { - service_id: service, + service_id: service.id.clone(), environment_id: linked_project.environment.clone(), }; + let domain = + post_graphql::(&client, configs.get_backboard(), vars) + .await? + .service_domain_create + .domain; - if std::io::stdout().is_terminal() { - let spinner = indicatif::ProgressBar::new_spinner() - .with_style( - indicatif::ProgressStyle::default_spinner() - .tick_chars(TICK_STRING) - .template("{spinner:.green} {msg}")?, - ) - .with_message("Creating domain..."); - spinner.enable_steady_tick(Duration::from_millis(100)); - - let domain = post_graphql::( - &client, - configs.get_backboard(), - vars, - ) - .await? - .service_domain_create - .domain; - + if let Some(spinner) = spinner { spinner.finish_and_clear(); + } - let formatted_domain = format!("https://{}", domain); - println!( - "Service Domain created:\nšŸš€ {}", - formatted_domain.magenta().bold() - ); - } else { - println!("Creating domain..."); - - let domain = post_graphql::( - &client, - configs.get_backboard(), - vars, - ) - .await? - .service_domain_create - .domain; + let formatted_domain = format!("https://{}", domain); + if json { + let out = json!({ + "domain": formatted_domain + }); - let formatted_domain = format!("https://{}", domain); + println!("{}", serde_json::to_string_pretty(&out)?); + } else { println!( "Service Domain created:\nšŸš€ {}", formatted_domain.magenta().bold() @@ -148,3 +139,48 @@ fn print_existing_domains(domains: &DomainsDomains) -> Result<()> { Ok(()) } + +// Returns a reference to save on Heap allocations +pub fn get_service<'a>( + linked_project: &'a LinkedProject, + project: &'a queries::project::ProjectProject, +) -> Result<&'a queries::project::ProjectProjectServicesEdgesNode, anyhow::Error> { + let services = project.services.edges.iter().collect::>(); + + if services.is_empty() { + bail!(RailwayError::NoServices); + } + + if project.services.edges.len() == 1 { + return Ok(&project.services.edges[0].node); + } + + if let Some(service) = linked_project.service.clone() { + if project.services.edges.iter().any(|s| s.node.id == service) { + return Ok(&project + .services + .edges + .iter() + .find(|s| s.node.id == service) + .unwrap() + .node); + } + } + + let service = prompt_options("Select a service", services)?; + + Ok(&service.node) +} + +pub fn creating_domain_spiner(message: Option) -> anyhow::Result { + let spinner = indicatif::ProgressBar::new_spinner() + .with_style( + indicatif::ProgressStyle::default_spinner() + .tick_chars(TICK_STRING) + .template("{spinner:.green} {msg}")?, + ) + .with_message(message.unwrap_or_else(|| "Creating domain...".to_string())); + spinner.enable_steady_tick(Duration::from_millis(100)); + + Ok(spinner) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4ef6d9dcc..6ad9e026a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,6 +6,7 @@ pub(super) use colored::Colorize; pub mod add; pub mod completion; pub mod connect; +pub mod create_custom_domain; pub mod deploy; pub mod docs; pub mod domain; @@ -29,5 +30,3 @@ pub mod up; pub mod variables; pub mod volume; pub mod whoami; - -pub mod custom_domain; diff --git a/src/main.rs b/src/main.rs index 02eae6176..0a7cde75d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,8 +58,7 @@ commands_enum!( variables, whoami, volume, - redeploy, - custom_domain + redeploy ); #[tokio::main] From 833cef0d0e7c07dddc20005badc8ce5d30b12207 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Mon, 4 Nov 2024 23:24:44 -0800 Subject: [PATCH 4/9] fix: use @ as hostlabel for root domain chore: remove regex to check if domain is valid - use the backend instead --- src/commands/create_custom_domain.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/commands/create_custom_domain.rs b/src/commands/create_custom_domain.rs index 53283111d..2649cd953 100644 --- a/src/commands/create_custom_domain.rs +++ b/src/commands/create_custom_domain.rs @@ -1,6 +1,5 @@ use anyhow::bail; use is_terminal::IsTerminal; -use regex::Regex; use crate::{ controllers::project::{ensure_project_and_environment_exist, get_project}, @@ -12,10 +11,6 @@ use domain::{creating_domain_spiner, get_service}; use super::*; pub async fn create_custom_domain(domain: String, port: Option, json: bool) -> Result<()> { - if !is_valid_domain(&domain) { - bail!("Invalid domain"); - } - let configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; @@ -92,18 +87,19 @@ pub async fn create_custom_domain(domain: String, port: Option, json: bool) println!("\tType\tHost\tValue"); for record in response.custom_domain_create.status.dns_records { + let not_empty_hostlabel = if record.hostlabel.is_empty() { + "@".into() + } else { + record.hostlabel + }; println!( "\t{}\t{}\t{}", - record.record_type, record.hostlabel, record.required_value, + record.record_type, not_empty_hostlabel, record.required_value, ); } - println!("\nPlease be aware that DNS records can take up to 72 hours to propagate worldwide."); + println!("\nNote: if the Host is \"@\", the DNS record should be created for the root of the domain."); + println!("Please be aware that DNS records can take up to 72 hours to propagate worldwide."); Ok(()) } - -fn is_valid_domain(domain: &str) -> bool { - let domain_regex = Regex::new(r"^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$").unwrap(); - domain_regex.is_match(domain) -} From ed35ec0c0f5b69c5e7527b9118172c095ba2725a Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Mon, 4 Nov 2024 23:51:04 -0800 Subject: [PATCH 5/9] feat: correct padding for custom domain output --- src/commands/create_custom_domain.rs | 63 ++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/src/commands/create_custom_domain.rs b/src/commands/create_custom_domain.rs index 2649cd953..fe0ed17a7 100644 --- a/src/commands/create_custom_domain.rs +++ b/src/commands/create_custom_domain.rs @@ -1,5 +1,6 @@ use anyhow::bail; use is_terminal::IsTerminal; +use std::cmp::max; use crate::{ controllers::project::{ensure_project_and_environment_exist, get_project}, @@ -85,21 +86,59 @@ pub async fn create_custom_domain(domain: String, port: Option, json: bool) &response.custom_domain_create.status.dns_records[0].zone ); - println!("\tType\tHost\tValue"); - for record in response.custom_domain_create.status.dns_records { - let not_empty_hostlabel = if record.hostlabel.is_empty() { - "@".into() - } else { - record.hostlabel - }; - println!( - "\t{}\t{}\t{}", - record.record_type, not_empty_hostlabel, record.required_value, - ); - } + print_domains(response.custom_domain_create.status.dns_records); println!("\nNote: if the Host is \"@\", the DNS record should be created for the root of the domain."); println!("Please be aware that DNS records can take up to 72 hours to propagate worldwide."); Ok(()) } + +fn print_domains( + domains: Vec< + mutations::custom_domain_create::CustomDomainCreateCustomDomainCreateStatusDnsRecords, + >, +) { + let (padding_type, padding_hostlabel, padding_value) = + domains + .iter() + .fold((5, 5, 5), |(max_type, max_hostlabel, max_value), d| { + ( + max(max_type, d.record_type.to_string().len()), + max(max_hostlabel, d.hostlabel.to_string().len()), + max(max_value, d.required_value.to_string().len()), + ) + }); + + // Add padding to each maximum length + let [padding_type, padding_hostlabel, padding_value] = + [padding_type + 3, padding_hostlabel + 3, padding_value + 3]; + + // Print the header with consistent padding + println!( + "\t{: Date: Tue, 5 Nov 2024 11:48:00 -0800 Subject: [PATCH 6/9] move custom_domain into domain --- src/commands/create_custom_domain.rs | 144 -------------------------- src/commands/domain.rs | 149 ++++++++++++++++++++++++--- src/commands/mod.rs | 1 - 3 files changed, 137 insertions(+), 157 deletions(-) delete mode 100644 src/commands/create_custom_domain.rs diff --git a/src/commands/create_custom_domain.rs b/src/commands/create_custom_domain.rs deleted file mode 100644 index fe0ed17a7..000000000 --- a/src/commands/create_custom_domain.rs +++ /dev/null @@ -1,144 +0,0 @@ -use anyhow::bail; -use is_terminal::IsTerminal; -use std::cmp::max; - -use crate::{ - controllers::project::{ensure_project_and_environment_exist, get_project}, - errors::RailwayError, -}; - -use domain::{creating_domain_spiner, get_service}; - -use super::*; - -pub async fn create_custom_domain(domain: String, port: Option, json: bool) -> Result<()> { - let configs = Configs::new()?; - - let client = GQLClient::new_authorized(&configs)?; - let linked_project = configs.get_linked_project().await?; - - ensure_project_and_environment_exist(&client, &configs, &linked_project).await?; - - let project = get_project(&client, &configs, linked_project.project.clone()).await?; - - if project.services.edges.is_empty() { - return Err(RailwayError::NoServices.into()); - } - - let service = get_service(&linked_project, &project)?; - - let spinner = if std::io::stdout().is_terminal() && !json { - Some(creating_domain_spiner(Some(format!( - "Creating custom domain for service {}{}...", - service.name, - port.unwrap_or_default() - )))?) - } else { - None - }; - - let is_available = post_graphql::( - &client, - configs.get_backboard(), - queries::custom_domain_available::Variables { - domain: domain.clone(), - }, - ) - .await?; - - if !is_available.custom_domain_available.available { - bail!("Domain is not available:\n\t{}", domain); - } - - let input = mutations::custom_domain_create::CustomDomainCreateInput { - domain: domain.clone(), - environment_id: linked_project.environment.clone(), - project_id: linked_project.project.clone(), - service_id: service.id.clone(), - target_port: port.map(|p| p as i64), - }; - - let vars = mutations::custom_domain_create::Variables { input }; - - let response = - post_graphql::(&client, configs.get_backboard(), vars) - .await?; - - if let Some(spinner) = spinner { - spinner.finish_and_clear(); - } - - if json { - println!("{}", serde_json::to_string_pretty(&response)?); - return Ok(()); - } - - println!("Domain created: {}", response.custom_domain_create.domain); - - if response.custom_domain_create.status.dns_records.is_empty() { - // Should never happen (would only be possible in a backend bug) - // but just in case - bail!("No DNS records found. Please check the Railway dashboard for more information."); - } - - println!( - "To finish setting up your custom domain, add the following to the DNS records for {}:\n", - &response.custom_domain_create.status.dns_records[0].zone - ); - - print_domains(response.custom_domain_create.status.dns_records); - - println!("\nNote: if the Host is \"@\", the DNS record should be created for the root of the domain."); - println!("Please be aware that DNS records can take up to 72 hours to propagate worldwide."); - - Ok(()) -} - -fn print_domains( - domains: Vec< - mutations::custom_domain_create::CustomDomainCreateCustomDomainCreateStatusDnsRecords, - >, -) { - let (padding_type, padding_hostlabel, padding_value) = - domains - .iter() - .fold((5, 5, 5), |(max_type, max_hostlabel, max_value), d| { - ( - max(max_type, d.record_type.to_string().len()), - max(max_hostlabel, d.hostlabel.to_string().len()), - max(max_value, d.required_value.to_string().len()), - ) - }); - - // Add padding to each maximum length - let [padding_type, padding_hostlabel, padding_value] = - [padding_type + 3, padding_hostlabel + 3, padding_value + 3]; - - // Print the header with consistent padding - println!( - "\t{: Result<()> { let project = get_project(&client, &configs, linked_project.project.clone()).await?; - if project.services.edges.is_empty() { - bail!(RailwayError::NoServices); - } - let service = get_service(&linked_project, &project)?; let vars = queries::domains::Variables { @@ -69,11 +64,9 @@ pub async fn command(args: Args, json: bool) -> Result<()> { return print_existing_domains(&domains); } - let spinner = if std::io::stdout().is_terminal() && !json { - Some(creating_domain_spiner(None)?) - } else { - None - }; + let spinner = (std::io::stdout().is_terminal() && !json) + .then(|| creating_domain_spiner(None)) + .and_then(|s| s.ok()); let vars = mutations::service_domain_create::Variables { service_id: service.id.clone(), @@ -144,7 +137,7 @@ fn print_existing_domains(domains: &DomainsDomains) -> Result<()> { pub fn get_service<'a>( linked_project: &'a LinkedProject, project: &'a queries::project::ProjectProject, -) -> Result<&'a queries::project::ProjectProjectServicesEdgesNode, anyhow::Error> { +) -> anyhow::Result<&'a queries::project::ProjectProjectServicesEdgesNode> { let services = project.services.edges.iter().collect::>(); if services.is_empty() { @@ -167,6 +160,10 @@ pub fn get_service<'a>( } } + if !std::io::stdout().is_terminal() { + bail!(RailwayError::NoServices); + } + let service = prompt_options("Select a service", services)?; Ok(&service.node) @@ -184,3 +181,131 @@ pub fn creating_domain_spiner(message: Option) -> anyhow::Result, json: bool) -> Result<()> { + let configs = Configs::new()?; + + let client = GQLClient::new_authorized(&configs)?; + let linked_project = configs.get_linked_project().await?; + + ensure_project_and_environment_exist(&client, &configs, &linked_project).await?; + + let project = get_project(&client, &configs, linked_project.project.clone()).await?; + + let service = get_service(&linked_project, &project)?; + + let spinner = (std::io::stdout().is_terminal() && !json) + .then(|| { + creating_domain_spiner(Some(format!( + "Creating custom domain for service {}{}...", + service.name, + port.map(|p| format!(" on port {}", p)).unwrap_or_default() + ))) + }) + .and_then(|s| s.ok()); + + let is_available = post_graphql::( + &client, + configs.get_backboard(), + queries::custom_domain_available::Variables { + domain: domain.clone(), + }, + ) + .await? + .custom_domain_available + .available; + + if !is_available { + bail!("Domain is not available:\n\t{}", domain); + } + + let vars = mutations::custom_domain_create::Variables { + input: mutations::custom_domain_create::CustomDomainCreateInput { + domain: domain.clone(), + environment_id: linked_project.environment.clone(), + project_id: linked_project.project.clone(), + service_id: service.id.clone(), + target_port: port.map(|p| p as i64), + }, + }; + + let response = + post_graphql::(&client, configs.get_backboard(), vars) + .await?; + + spinner.map(|s| s.finish_and_clear()); + + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + return Ok(()); + } + + println!("Domain created: {}", response.custom_domain_create.domain); + + if response.custom_domain_create.status.dns_records.is_empty() { + // Should never happen (would only be possible in a backend bug) + // but just in case + bail!("No DNS records found. Please check the Railway dashboard for more information."); + } + + println!( + "To finish setting up your custom domain, add the following to the DNS records for {}:\n", + &response.custom_domain_create.status.dns_records[0].zone + ); + + print_dns(response.custom_domain_create.status.dns_records); + + println!("\nNote: if the Host is \"@\", the DNS record should be created for the root of the domain."); + println!("Please be aware that DNS records can take up to 72 hours to propagate worldwide."); + + Ok(()) +} + +fn print_dns( + domains: Vec< + mutations::custom_domain_create::CustomDomainCreateCustomDomainCreateStatusDnsRecords, + >, +) { + let (padding_type, padding_hostlabel, padding_value) = + domains + .iter() + .fold((5, 5, 5), |(max_type, max_hostlabel, max_value), d| { + ( + max(max_type, d.record_type.to_string().len()), + max(max_hostlabel, d.hostlabel.to_string().len()), + max(max_value, d.required_value.to_string().len()), + ) + }); + + // Add padding to each maximum length + let [padding_type, padding_hostlabel, padding_value] = + [padding_type + 3, padding_hostlabel + 3, padding_value + 3]; + + // Print the header with consistent padding + println!( + "\t{: Date: Wed, 6 Nov 2024 11:04:27 -0800 Subject: [PATCH 7/9] feat: add service flag --- src/commands/domain.rs | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/commands/domain.rs b/src/commands/domain.rs index d30fd7635..d44310c60 100644 --- a/src/commands/domain.rs +++ b/src/commands/domain.rs @@ -10,7 +10,6 @@ use crate::{ consts::TICK_STRING, controllers::project::{ensure_project_and_environment_exist, get_project}, errors::RailwayError, - util::prompt::prompt_options, }; use super::*; @@ -24,6 +23,10 @@ pub struct Args { #[clap(short, long)] port: Option, + /// The name of the service to use + #[clap(short, long)] + service: Option, + /// Optionally, specify a custom domain to use. If not specified, a domain will be generated. /// /// Specifying a custom domain will also return the required DNS records @@ -33,7 +36,7 @@ pub struct Args { pub async fn command(args: Args, json: bool) -> Result<()> { if let Some(domain) = args.domain { - create_custom_domain(domain, args.port, json).await?; + create_custom_domain(domain, args.port, args.service, json).await?; return Ok(()); } @@ -47,7 +50,7 @@ pub async fn command(args: Args, json: bool) -> Result<()> { let project = get_project(&client, &configs, linked_project.project.clone()).await?; - let service = get_service(&linked_project, &project)?; + let service = get_service(&linked_project, &project, args.service)?; let vars = queries::domains::Variables { project_id: linked_project.project.clone(), @@ -137,6 +140,7 @@ fn print_existing_domains(domains: &DomainsDomains) -> Result<()> { pub fn get_service<'a>( linked_project: &'a LinkedProject, project: &'a queries::project::ProjectProject, + service_name: Option, ) -> anyhow::Result<&'a queries::project::ProjectProjectServicesEdgesNode> { let services = project.services.edges.iter().collect::>(); @@ -148,6 +152,19 @@ pub fn get_service<'a>( return Ok(&project.services.edges[0].node); } + if let Some(service_name) = service_name { + if let Some(service) = project + .services + .edges + .iter() + .find(|s| s.node.name == service_name) + { + return Ok(&service.node); + } + + bail!(RailwayError::ServiceNotFound(service_name)); + } + if let Some(service) = linked_project.service.clone() { if project.services.edges.iter().any(|s| s.node.id == service) { return Ok(&project @@ -160,13 +177,7 @@ pub fn get_service<'a>( } } - if !std::io::stdout().is_terminal() { - bail!(RailwayError::NoServices); - } - - let service = prompt_options("Select a service", services)?; - - Ok(&service.node) + bail!(RailwayError::NoServices); } pub fn creating_domain_spiner(message: Option) -> anyhow::Result { @@ -182,7 +193,12 @@ pub fn creating_domain_spiner(message: Option) -> anyhow::Result, json: bool) -> Result<()> { +async fn create_custom_domain( + domain: String, + port: Option, + service_name: Option, + json: bool, +) -> Result<()> { let configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; @@ -192,7 +208,7 @@ async fn create_custom_domain(domain: String, port: Option, json: bool) -> let project = get_project(&client, &configs, linked_project.project.clone()).await?; - let service = get_service(&linked_project, &project)?; + let service = get_service(&linked_project, &project, service_name)?; let spinner = (std::io::stdout().is_terminal() && !json) .then(|| { From 173fe84eb39e234b37644efecc57005360025604 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Mon, 11 Nov 2024 11:31:24 -0800 Subject: [PATCH 8/9] fix: match wording on the dashboard for custom domains --- src/commands/domain.rs | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/commands/domain.rs b/src/commands/domain.rs index d44310c60..7fbd398d1 100644 --- a/src/commands/domain.rs +++ b/src/commands/domain.rs @@ -259,20 +259,22 @@ async fn create_custom_domain( println!("Domain created: {}", response.custom_domain_create.domain); if response.custom_domain_create.status.dns_records.is_empty() { - // Should never happen (would only be possible in a backend bug) - // but just in case + // This case should be impossible, but added error handling for safety. + // + // It can only occur if the backend is not returning the correct data, + // and in that case, the post_graphql call should have already errored. bail!("No DNS records found. Please check the Railway dashboard for more information."); } println!( - "To finish setting up your custom domain, add the following to the DNS records for {}:\n", + "To finish setting up your custom domain, add the following DNS records to {}:\n", &response.custom_domain_create.status.dns_records[0].zone ); print_dns(response.custom_domain_create.status.dns_records); - println!("\nNote: if the Host is \"@\", the DNS record should be created for the root of the domain."); - println!("Please be aware that DNS records can take up to 72 hours to propagate worldwide."); + println!("\nNote: if the Name is \"@\", the DNS record should be created for the root of the domain."); + println!("*DNS changes can take up to 72 hours to propagate worldwide."); Ok(()) } @@ -282,18 +284,19 @@ fn print_dns( mutations::custom_domain_create::CustomDomainCreateCustomDomainCreateStatusDnsRecords, >, ) { - let (padding_type, padding_hostlabel, padding_value) = - domains - .iter() - .fold((5, 5, 5), |(max_type, max_hostlabel, max_value), d| { - ( - max(max_type, d.record_type.to_string().len()), - max(max_hostlabel, d.hostlabel.to_string().len()), - max(max_value, d.required_value.to_string().len()), - ) - }); + // I benchmarked this iter().fold() and it's faster than using 3x iter().map() + let (padding_type, padding_hostlabel, padding_value) = domains + .iter() + // Minimum length should be 8, but we add 3 for extra padding so 8-3 = 5 + .fold((5, 5, 5), |(max_type, max_hostlabel, max_value), d| { + ( + max(max_type, d.record_type.to_string().len()), + max(max_hostlabel, d.hostlabel.len()), + max(max_value, d.required_value.len()), + ) + }); - // Add padding to each maximum length + // Add extra minimum padding to each length let [padding_type, padding_hostlabel, padding_value] = [padding_type + 3, padding_hostlabel + 3, padding_value + 3]; @@ -301,7 +304,7 @@ fn print_dns( println!( "\t{: Date: Mon, 18 Nov 2024 12:39:34 -0800 Subject: [PATCH 9/9] move service domain creation to a separate function --- src/commands/domain.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/commands/domain.rs b/src/commands/domain.rs index 7fbd398d1..29d6eb661 100644 --- a/src/commands/domain.rs +++ b/src/commands/domain.rs @@ -19,11 +19,11 @@ use super::*; /// There is a maximum of 1 railway provided domain per service. #[derive(Parser)] pub struct Args { - /// The service to generate a domain for + /// The port to connect to the domain #[clap(short, long)] port: Option, - /// The name of the service to use + /// The name of the service to generate the domain for #[clap(short, long)] service: Option, @@ -37,10 +37,13 @@ pub struct Args { pub async fn command(args: Args, json: bool) -> Result<()> { if let Some(domain) = args.domain { create_custom_domain(domain, args.port, args.service, json).await?; - - return Ok(()); + } else { + create_service_domain(args.service, json).await?; } + Ok(()) +} +async fn create_service_domain(service_name: Option, json: bool) -> Result<()> { let configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; @@ -50,7 +53,7 @@ pub async fn command(args: Args, json: bool) -> Result<()> { let project = get_project(&client, &configs, linked_project.project.clone()).await?; - let service = get_service(&linked_project, &project, args.service)?; + let service = get_service(&linked_project, &project, service_name)?; let vars = queries::domains::Variables { project_id: linked_project.project.clone(),