diff --git a/src/commands/add.rs b/src/commands/add.rs index 5cd05137b..82dca8300 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -1,53 +1,337 @@ use anyhow::bail; use is_terminal::IsTerminal; -use std::collections::HashMap; -use strum::IntoEnumIterator; +use std::{ + collections::{BTreeMap, HashMap}, + time::Duration, +}; +use strum::{Display, EnumIs, EnumIter, IntoEnumIterator}; use crate::{ + consts::TICK_STRING, controllers::{database::DatabaseType, project::ensure_project_and_environment_exist}, - util::prompt::prompt_multi_options, + util::prompt::{ + fake_select, prompt_multi_options, prompt_options, prompt_text, + prompt_text_with_placeholder_disappear, prompt_text_with_placeholder_if_blank, + }, }; use super::*; -/// Provision a database into your project +/// Add a service to your project #[derive(Parser)] pub struct Args { /// The name of the database to add #[arg(short, long, value_enum)] database: Vec, + + /// The name of the service to create (leave blank for randomly generated) + #[clap(short, long)] + service: Option>, + + /// The repo to link to the service + #[clap(short, long)] + repo: Option, + + /// The docker image to link to the service + #[clap(short, long)] + image: Option, + + /// The "{key}={value}" environment variable pair to set the service variables. + /// Example: + /// + /// railway add --service --variables "MY_SPECIAL_ENV_VAR=1" --variables "BACKEND_PORT=3000" + #[clap(short, long)] + variables: Vec, } pub async fn command(args: Args, _json: bool) -> Result<()> { - let configs = Configs::new()?; + let mut 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 databases = if args.database.is_empty() { - if !std::io::stdout().is_terminal() { - bail!("No database specified"); + let type_of_create = if !args.database.is_empty() { + fake_select("What do you need?", "Database"); + CreateKind::Database(args.database) + } else if args.repo.is_some() { + fake_select("What do you need?", "GitHub Repo"); + CreateKind::GithubRepo { + repo: prompt_repo(args.repo)?, + variables: prompt_variables(args.variables)?, + name: prompt_name(args.service)?, + } + } else if args.image.is_some() { + fake_select("What do you need?", "Docker Image"); + CreateKind::DockerImage { + image: prompt_image(args.image)?, + variables: prompt_variables(args.variables)?, + name: prompt_name(args.service)?, + } + } else if args.service.is_some() { + fake_select("What do you need?", "Empty Service"); + CreateKind::EmptyService { + name: prompt_name(args.service)?, + variables: prompt_variables(args.variables)?, } - prompt_multi_options("Select databases to add", DatabaseType::iter().collect())? } else { - args.database + let need = prompt_options("What do you need?", CreateKind::iter().collect())?; + match need { + CreateKind::Database(_) => CreateKind::Database(prompt_database()?), + CreateKind::EmptyService { .. } => CreateKind::EmptyService { + name: prompt_name(args.service)?, + variables: prompt_variables(args.variables)?, + }, + CreateKind::GithubRepo { .. } => { + let repo = prompt_repo(args.repo)?; + let variables = prompt_variables(args.variables)?; + CreateKind::GithubRepo { + repo, + variables, + name: prompt_name(args.service)?, + } + } + CreateKind::DockerImage { .. } => { + let image = prompt_image(args.image)?; + let variables = prompt_variables(args.variables)?; + CreateKind::DockerImage { + image, + variables, + name: prompt_name(args.service)?, + } + } + } }; - if databases.is_empty() { - bail!("No database selected"); + match type_of_create { + CreateKind::Database(databases) => { + for db in databases { + deploy::fetch_and_create( + &client, + &configs, + db.to_slug().to_string(), + &linked_project, + &HashMap::new(), + ) + .await?; + } + } + CreateKind::DockerImage { + image, + variables, + name, + } => { + create_service( + name, + &linked_project, + &client, + &mut configs, + None, + Some(image), + variables, + ) + .await?; + } + CreateKind::GithubRepo { + repo, + variables, + name, + } => { + create_service( + name, + &linked_project, + &client, + &mut configs, + Some(repo), + None, + variables, + ) + .await?; + } + CreateKind::EmptyService { name, variables } => { + create_service( + name, + &linked_project, + &client, + &mut configs, + None, + None, + variables, + ) + .await?; + } } + Ok(()) +} - for db in databases { - deploy::fetch_and_create( - &client, - &configs, - db.to_slug().to_string(), - &linked_project, - &HashMap::new(), - ) - .await?; +fn prompt_database() -> Result, anyhow::Error> { + if !std::io::stdout().is_terminal() { + bail!("No database specified"); + } + prompt_multi_options("Select databases to add", DatabaseType::iter().collect()) +} + +fn prompt_repo(repo: Option) -> Result { + if let Some(repo) = repo { + fake_select("Enter a repo", &repo); + return Ok(repo); + } + + prompt_text_with_placeholder_disappear("Enter a repo", "/") +} + +fn prompt_image(image: Option) -> Result { + if let Some(image) = image { + fake_select("Enter an image", &image); + return Ok(image); } + prompt_text("Enter an image") +} +fn prompt_name(service: Option>) -> Result> { + if let Some(name) = service { + if let Some(name) = name { + fake_select("Enter a service name", &name); + Ok(Some(name)) + } else { + fake_select("Enter a service name", ""); + Ok(None) + } + } else if std::io::stdout().is_terminal() { + return Ok(Some(prompt_text_with_placeholder_if_blank( + "Enter a service name", + "", + "", + )?) + .filter(|s| !s.trim().is_empty())); + } else { + fake_select("Enter a service name", ""); + Ok(None) + } +} + +fn prompt_variables(variables: Vec) -> Result>> { + if !std::io::stdout().is_terminal() && variables.is_empty() { + fake_select("Enter a variable", ""); + return Ok(None); + } + if variables.is_empty() { + let mut variables = BTreeMap::::new(); + loop { + let v = prompt_text_with_placeholder_disappear( + "Enter a variable", + "", + )?; + if v.trim().is_empty() { + break; + } + let mut split = v.split('=').peekable(); + if split.peek().is_none() { + continue; + } + let key = split.next().unwrap().trim().to_owned(); + if split.peek().is_none() { + continue; + } + let value = split.collect::>().join("=").trim().to_owned(); + variables.insert(key, value); + } + return Ok(if variables.is_empty() { + None + } else { + Some(variables) + }); + } + let variables: BTreeMap = variables + .iter() + .filter_map(|v| { + let mut split = v.split('='); + let key = split.next()?.trim().to_owned(); + let value = split.collect::>().join("=").trim().to_owned(); + if value.is_empty() { + None + } else { + fake_select("Enter a variable", &format!("{}={}", key, value)); + Some((key, value)) + } + }) + .collect(); + Ok(Some(variables)) +} + +type Variables = Option>; + +async fn create_service( + service: Option, + linked_project: &LinkedProject, + client: &reqwest::Client, + configs: &mut Configs, + repo: Option, + image: Option, + variables: Variables, +) -> Result<(), anyhow::Error> { + let spinner = indicatif::ProgressBar::new_spinner() + .with_style( + indicatif::ProgressStyle::default_spinner() + .tick_chars(TICK_STRING) + .template("{spinner:.green} {msg}")?, + ) + .with_message("Creating service..."); + spinner.enable_steady_tick(Duration::from_millis(100)); + let source = mutations::service_create::ServiceSourceInput { repo, image }; + let branch = if let Some(repo) = &source.repo { + let repos = post_graphql::( + client, + &configs.get_backboard(), + queries::git_hub_repos::Variables {}, + ) + .await? + .github_repos; + let repo = repos + .iter() + .find(|r| r.full_name == *repo) + .ok_or(anyhow::anyhow!("repo not found"))?; + Some(repo.default_branch.clone()) + } else { + None + }; + let vars = mutations::service_create::Variables { + name: service, + project_id: linked_project.project.clone(), + environment_id: linked_project.environment.clone(), + source: Some(source), + variables, + branch, + }; + let s = + post_graphql::(client, &configs.get_backboard(), vars).await?; + configs.link_service(s.service_create.id)?; + configs.write()?; + spinner.finish_with_message(format!( + "Succesfully created the service \"{}\" and linked to it", + s.service_create.name.blue() + )); Ok(()) } + +#[derive(Debug, Clone, EnumIter, Display, EnumIs)] +enum CreateKind { + #[strum(to_string = "GitHub Repo")] + GithubRepo { + repo: String, + variables: Variables, + name: Option, + }, + #[strum(to_string = "Database")] + Database(Vec), + #[strum(to_string = "Docker Image")] + DockerImage { + image: String, + variables: Variables, + name: Option, + }, + #[strum(to_string = "Empty Service")] + EmptyService { + name: Option, + variables: Variables, + }, +} diff --git a/src/commands/docs.rs b/src/commands/docs.rs index da00e0a09..2149c6751 100644 --- a/src/commands/docs.rs +++ b/src/commands/docs.rs @@ -1,6 +1,3 @@ -use anyhow::bail; -use is_terminal::IsTerminal; - use crate::{ consts::NON_INTERACTIVE_FAILURE, interact_or, util::prompt::prompt_confirm_with_default, }; diff --git a/src/commands/environment.rs b/src/commands/environment.rs index 5caa8362b..027ea31a1 100644 --- a/src/commands/environment.rs +++ b/src/commands/environment.rs @@ -1,12 +1,10 @@ use std::fmt::Display; -use anyhow::bail; -use is_terminal::IsTerminal; - use crate::{ controllers::project::get_project, errors::RailwayError, interact_or, util::prompt::prompt_options, }; +use anyhow::bail; use super::{queries::project::ProjectProjectEnvironmentsEdgesNode, *}; diff --git a/src/commands/login.rs b/src/commands/login.rs index 170d930aa..2033ec183 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -7,12 +7,10 @@ use crate::{ use super::*; -use anyhow::bail; use colored::Colorize; use http_body_util::Full; use hyper::{body::Bytes, server::conn::http1, service::service_fn, Request, Response}; use hyper_util::rt::tokio::TokioIo; -use is_terminal::IsTerminal; use rand::Rng; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; diff --git a/src/commands/open.rs b/src/commands/open.rs index 3a4b1bcb1..566eaf43d 100644 --- a/src/commands/open.rs +++ b/src/commands/open.rs @@ -1,6 +1,3 @@ -use anyhow::bail; -use is_terminal::IsTerminal; - use crate::{ consts::NON_INTERACTIVE_FAILURE, controllers::project::ensure_project_and_environment_exist, interact_or, diff --git a/src/gql/mutations/mod.rs b/src/gql/mutations/mod.rs index 8edd50cd1..31c90df36 100644 --- a/src/gql/mutations/mod.rs +++ b/src/gql/mutations/mod.rs @@ -139,3 +139,12 @@ pub struct DeploymentRedeploy; skip_serializing_none )] pub struct VariableCollectionUpsert; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "src/gql/schema.json", + query_path = "src/gql/mutations/strings/ServiceCreate.graphql", + response_derives = "Debug, Serialize, Clone", + skip_serializing_none +)] +pub struct ServiceCreate; diff --git a/src/gql/mutations/strings/ServiceCreate.graphql b/src/gql/mutations/strings/ServiceCreate.graphql new file mode 100644 index 000000000..c1429aec6 --- /dev/null +++ b/src/gql/mutations/strings/ServiceCreate.graphql @@ -0,0 +1,8 @@ +mutation ServiceCreate($name: String, $projectId: String!, $environmentId: String!, $source: ServiceSourceInput, $branch: String, $variables: EnvironmentVariables) { + serviceCreate( + input: {name: $name, projectId: $projectId, environmentId: $environmentId, source: $source, variables: $variables, branch: $branch} + ) { + id + name + } +} \ No newline at end of file diff --git a/src/gql/queries/mod.rs b/src/gql/queries/mod.rs index f84e551af..dd7eb744a 100644 --- a/src/gql/queries/mod.rs +++ b/src/gql/queries/mod.rs @@ -148,3 +148,11 @@ pub type SerializedTemplateConfig = serde_json::Value; response_derives = "Debug, Serialize, Clone" )] pub struct TemplateDetail; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "src/gql/schema.json", + query_path = "src/gql/queries/strings/GitHubRepos.graphql", + response_derives = "Debug, Serialize, Clone" +)] +pub struct GitHubRepos; diff --git a/src/gql/queries/strings/GitHubRepos.graphql b/src/gql/queries/strings/GitHubRepos.graphql new file mode 100644 index 000000000..7ae3a03dd --- /dev/null +++ b/src/gql/queries/strings/GitHubRepos.graphql @@ -0,0 +1,6 @@ +query GitHubRepos { + githubRepos { + fullName + defaultBranch + } +} \ No newline at end of file diff --git a/src/gql/schema.json b/src/gql/schema.json index c2ee760f3..560dae20e 100644 --- a/src/gql/schema.json +++ b/src/gql/schema.json @@ -3492,6 +3492,12 @@ "isDeprecated": false, "name": "HEALTHCHECK" }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "MIGRATE_VOLUMES" + }, { "deprecationReason": null, "description": null, @@ -8501,6 +8507,12 @@ { "description": "A thing that can be measured on Railway.", "enumValues": [ + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "BACKUP_USAGE_GB" + }, { "deprecationReason": null, "description": null, @@ -13726,6 +13738,127 @@ } } }, + { + "args": [ + { + "defaultValue": null, + "description": "The id of the volume instance to create a backup of", + "name": "volumeInstanceId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "Create backup of a volume instance", + "isDeprecated": false, + "name": "volumeInstanceBackupCreate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkflowId", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": "The volume instance's backup id", + "name": "volumeInstanceBackupId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "defaultValue": null, + "description": "The volume instance's id", + "name": "volumeInstanceId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "Deletes volume instance backup", + "isDeprecated": false, + "name": "volumeInstanceBackupDelete", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkflowId", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": "The id of the backup to be restored from", + "name": "volumeInstanceBackupId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "defaultValue": null, + "description": "The id of the volume instance to be restored from", + "name": "volumeInstanceId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "Restore a volume instance from a backup", + "isDeprecated": false, + "name": "volumeInstanceBackupRestore", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WorkflowId", + "ofType": null + } + } + }, { "args": [ { @@ -18949,6 +19082,12 @@ { "description": null, "enumValues": [ + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "BACKUP_USAGE" + }, { "deprecationReason": null, "description": null, @@ -22445,7 +22584,18 @@ } }, { - "args": [], + "args": [ + { + "defaultValue": null, + "description": null, + "name": "projectId", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], "deprecationReason": null, "description": "List available regions", "isDeprecated": false, @@ -23715,6 +23865,45 @@ } } }, + { + "args": [ + { + "defaultValue": null, + "description": "The id of the volume instance to list the backups of", + "name": "volumeInstanceId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "List backups of a volume instance", + "isDeprecated": false, + "name": "volumeInstanceBackupList", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "VolumeInstanceBackup", + "ofType": null + } + } + } + } + }, { "args": [ { @@ -26068,6 +26257,18 @@ "name": "String", "ofType": null } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "teamId", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } } ], "inputFields": null, @@ -32508,6 +32709,22 @@ } } }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "isOverLimit", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, @@ -35166,6 +35383,73 @@ "name": "VolumeInstance", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "createdAt", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "referencedMB", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "usedMB", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "VolumeInstanceBackup", + "possibleTypes": null + }, { "description": null, "enumValues": null, @@ -35713,6 +35997,29 @@ "name": "WithdrawalStatusType", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "workflowId", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "WorkflowId", + "possibleTypes": null + }, { "description": null, "enumValues": null, diff --git a/src/macros.rs b/src/macros.rs index 1eadfee53..1ddacb442 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -27,13 +27,18 @@ macro_rules! commands_enum { ); } +use is_terminal::IsTerminal; + +pub fn is_stdout_terminal() -> bool { + std::io::stdout().is_terminal() +} + /// Ensure running in a terminal or bail with the provided message #[macro_export] macro_rules! interact_or { ($message:expr) => { - if !std::io::stdout().is_terminal() { - use anyhow::bail; - bail!($message); + if !$crate::macros::is_stdout_terminal() { + ::anyhow::bail!($message); } }; } diff --git a/src/util/prompt.rs b/src/util/prompt.rs index 5f4e3d0fa..9d0ac1ced 100644 --- a/src/util/prompt.rs +++ b/src/util/prompt.rs @@ -20,6 +20,35 @@ pub fn prompt_text(message: &str) -> Result { .context("Failed to prompt for options") } +pub fn prompt_text_with_placeholder_if_blank( + message: &str, + placeholder: &str, + blank_message: &str, +) -> Result { + let select = inquire::Text::new(message); + select + .with_render_config(Configs::get_render_config()) + .with_placeholder(placeholder) + .with_formatter(&|input: &str| { + if input.is_empty() { + String::from(blank_message) + } else { + input.to_string() + } + }) + .prompt() + .context("Failed to prompt for options") +} + +pub fn prompt_text_with_placeholder_disappear(message: &str, placeholder: &str) -> Result { + let select = inquire::Text::new(message); + select + .with_render_config(Configs::get_render_config()) + .with_placeholder(placeholder) + .prompt() + .context("Failed to prompt for options") +} + pub fn prompt_confirm_with_default(message: &str, default: bool) -> Result { let confirm = inquire::Confirm::new(message); confirm