From 0a7a27305bf5cdc5743f2519daa0283c9c525567 Mon Sep 17 00:00:00 2001 From: Paulo Cabral Date: Mon, 24 Jun 2024 22:02:57 -0300 Subject: [PATCH] Deploy template from CLI --- .gitignore | 1 + src/commands/deploy.rs | 265 ++++++++++++++++++ src/commands/mod.rs | 1 + src/gql/mutations/strings/TemplateDeploy.rs | 80 ++++++ src/gql/queries/mod.rs | 2 + .../queries/strings/TemplateDetail.graphql | 4 +- src/main.rs | 1 + 7 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/commands/deploy.rs create mode 100644 src/gql/mutations/strings/TemplateDeploy.rs diff --git a/.gitignore b/.gitignore index 3bd8281cc..8078d8fef 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules bin/railway .envrc .direnv/* +.env diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs new file mode 100644 index 000000000..80d7e6d98 --- /dev/null +++ b/src/commands/deploy.rs @@ -0,0 +1,265 @@ +use anyhow::bail; +use is_terminal::IsTerminal; +use serde::Deserialize; +use std::{collections::BTreeMap, collections::HashMap, time::Duration}; + +use crate::{consts::TICK_STRING, mutations::TemplateVolume, util::prompt::prompt_text}; + +use super::*; + +/// Provisions a template into your project +#[derive(Parser)] +pub struct Args { + /// The code of the template to deploy + #[arg(short, long)] + template: Vec, + /// The "{key}={value}" environment variable pair to set the template variables + /// + /// To specify the variable for a single service prefix it with "{service}." + /// Example: + /// + /// ```bash + /// railway deploy -t postgres -v "MY_SPECIAL_ENV_VAR=1" -v "Backend.Port=3000" + /// ``` + #[arg(short, long)] + variable: Vec, +} + +pub async fn command(args: Args, _json: bool) -> Result<()> { + let configs = Configs::new()?; + + let client = GQLClient::new_authorized(&configs)?; + let linked_project = configs.get_linked_project().await?; + + let templates = if args.template.is_empty() { + if !std::io::stdout().is_terminal() { + bail!("No template specified"); + } + vec![prompt_text("Select template to deploy")?] + } else { + args.template + }; + + if templates.is_empty() { + bail!("No template selected"); + } + + let variables: HashMap = args + .variable + .iter() + .map(|v| match v.split('=').collect::>().as_slice() { + [key, value, ..] => (key.trim().to_owned(), value.trim().to_owned()), + [key] => (key.trim().to_owned(), String::new()), + [] => (String::new(), String::new()), + }) + .filter(|(_, value)| !value.is_empty()) + .collect(); + + for template in templates { + if std::io::stdout().is_terminal() { + fetch_and_create( + &client, + &configs, + template.clone(), + &linked_project, + &variables, + ) + .await?; + } else { + println!("Creating {}...", template); + fetch_and_create(&client, &configs, template, &linked_project, &variables).await?; + } + } + + Ok(()) +} +/// fetch database details via `TemplateDetail` +/// create database via `TemplateDeploy` +async fn fetch_and_create( + client: &reqwest::Client, + configs: &Configs, + template: String, + linked_project: &LinkedProject, + vars: &HashMap, +) -> Result<(), anyhow::Error> { + let details = post_graphql::( + client, + configs.get_backboard(), + queries::template_detail::Variables { + code: template.clone(), + }, + ) + .await?; + + let config = DeserializedEnvironment::deserialize( + &details.template.serialized_config.unwrap_or_default(), + )?; + + let mut services: Vec = + Vec::with_capacity(config.services.len()); + + for (id, s) in &config.services { + let mut variables = BTreeMap::new(); + for (key, variable) in &s.variables { + let value = if let Some(value) = + variable.default_value.as_ref().filter(|v| !v.is_empty()) + { + value.clone() + } else if let Some(value) = vars.get(&format!("{}.{key}", s.name)) { + value.clone() + } else if let Some(value) = vars.get(key) { + value.clone() + } else if !variable.is_optional.unwrap_or_default() { + prompt_text(&format!( + "Environment Variable {key} for service {} is required, please set a value:\n{}", + s.name, + variable.description.as_deref().map(|d| format!(" *{d}*\n")).unwrap_or_default(), + ))? + } else { + continue; + }; + variables.insert(key.clone(), value); + } + + let volumes: Vec<_> = s + .volume_mounts + .values() + .map(|volume| TemplateVolume { + mount_path: volume.mount_path.clone(), + name: None, + }) + .collect(); + + services.push(mutations::template_deploy::TemplateDeployService { + commit: None, + has_domain: s.networking.as_ref().map(|n| !n.service_domains.is_empty()), + healthcheck_path: s.deploy.as_ref().and_then(|d| d.healthcheck_path.clone()), + id: Some(id.clone()), + is_private: None, + name: Some(s.name.clone()), + owner: None, + root_directory: match &s.source { + Some(DeserializedServiceSource::Image { .. }) => None, + Some(DeserializedServiceSource::Repo { root_directory, .. }) => { + root_directory.clone() + } + None => None, + }, + service_icon: s.icon.clone(), + service_name: s.name.clone(), + start_command: s.deploy.as_ref().and_then(|d| d.start_command.clone()), + tcp_proxy_application_port: s + .networking + .as_ref() + .and_then(|n| n.tcp_proxies.keys().next().map(|k| k.parse::())) + .transpose()?, + template: match &s.source { + Some(DeserializedServiceSource::Image { image }) => image.clone(), + Some(DeserializedServiceSource::Repo { repo, .. }) => repo.clone(), + None => s.name.clone(), + }, + variables: (!variables.is_empty()).then_some(variables), + volumes: (!volumes.is_empty()).then_some(volumes), + }); + } + + let vars = mutations::template_deploy::Variables { + project_id: linked_project.project.clone(), + environment_id: linked_project.environment.clone(), + services, + template_code: template.clone(), + }; + + let spinner = indicatif::ProgressBar::new_spinner() + .with_style( + indicatif::ProgressStyle::default_spinner() + .tick_chars(TICK_STRING) + .template("{spinner:.green} {msg}")?, + ) + .with_message(format!("Creating {template}...")); + spinner.enable_steady_tick(Duration::from_millis(100)); + post_graphql::(client, configs.get_backboard(), vars).await?; + spinner.finish_with_message(format!("Created {template}")); + Ok(()) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeserializedServiceNetworking { + #[serde(default)] + service_domains: HashMap, + #[serde(default)] + tcp_proxies: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeserializedServiceVolumeMount { + mount_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeserializedServiceVariable { + #[serde(default)] + default_value: Option, + #[serde(default)] + description: Option, + #[serde(default)] + is_optional: Option, + // TODO: check how ${{secret()}} works with templates + // #[serde(default)] + // generator: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeserializedServiceDeploy { + healthcheck_path: Option, + start_command: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum DeserializedServiceSource { + Image { + image: String, + }, + #[serde(rename_all = "camelCase")] + Repo { + root_directory: Option, + repo: String, + // branch: Option, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeserializedService { + // #[serde(default)] + // build: serde_json::Value, + #[serde(default)] + deploy: Option, + + #[serde(default)] + icon: Option, + name: String, + + #[serde(default)] + networking: Option, + + #[serde(default)] + source: Option, + + #[serde(default)] + variables: HashMap, + #[serde(default)] + volume_mounts: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeserializedEnvironment { + #[serde(default)] + services: HashMap, +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 75a18b152..6aa5dc440 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 deploy; pub mod docs; pub mod domain; pub mod down; diff --git a/src/gql/mutations/strings/TemplateDeploy.rs b/src/gql/mutations/strings/TemplateDeploy.rs new file mode 100644 index 000000000..438cb3eb7 --- /dev/null +++ b/src/gql/mutations/strings/TemplateDeploy.rs @@ -0,0 +1,80 @@ +#![allow(clippy::all, warnings)] +pub struct TemplateDeploy; +pub mod template_deploy { + #![allow(dead_code)] + use std::result::Result; + pub const OPERATION_NAME: &str = "TemplateDeploy"; + pub const QUERY : & str = "mutation TemplateDeploy($projectId: String!, $environmentId: String!, $services: [TemplateDeployService!]!, $templateCode: String!) {\n templateDeploy(\n input: {projectId: $projectId, environmentId: $environmentId, services: $services, templateCode: $templateCode}\n ) {\n projectId\n workflowId\n }\n}" ; + use super::*; + use serde::{Deserialize, Serialize}; + #[allow(dead_code)] + type Boolean = bool; + #[allow(dead_code)] + type Float = f64; + #[allow(dead_code)] + type Int = i64; + #[allow(dead_code)] + type ID = String; + type ServiceVariables = super::ServiceVariables; + type TemplateVolume = super::TemplateVolume; + #[derive(Serialize)] + pub struct TemplateDeployService { + pub commit: Option, + #[serde(rename = "hasDomain")] + pub has_domain: Option, + #[serde(rename = "healthcheckPath")] + pub healthcheck_path: Option, + pub id: Option, + #[serde(rename = "isPrivate")] + pub is_private: Option, + pub name: Option, + pub owner: Option, + #[serde(rename = "rootDirectory")] + pub root_directory: Option, + #[serde(rename = "serviceIcon")] + pub service_icon: Option, + #[serde(rename = "serviceName")] + pub service_name: String, + #[serde(rename = "startCommand")] + pub start_command: Option, + #[serde(rename = "tcpProxyApplicationPort")] + pub tcp_proxy_application_port: Option, + pub template: String, + pub variables: Option, + pub volumes: Option>, + } + #[derive(Serialize)] + pub struct Variables { + #[serde(rename = "projectId")] + pub project_id: String, + #[serde(rename = "environmentId")] + pub environment_id: String, + pub services: Vec, + #[serde(rename = "templateCode")] + pub template_code: String, + } + impl Variables {} + #[derive(Deserialize)] + pub struct ResponseData { + #[serde(rename = "templateDeploy")] + pub template_deploy: TemplateDeployTemplateDeploy, + } + #[derive(Deserialize)] + pub struct TemplateDeployTemplateDeploy { + #[serde(rename = "projectId")] + pub project_id: String, + #[serde(rename = "workflowId")] + pub workflow_id: Option, + } +} +impl graphql_client::GraphQLQuery for TemplateDeploy { + type Variables = template_deploy::Variables; + type ResponseData = template_deploy::ResponseData; + fn build_query(variables: Self::Variables) -> ::graphql_client::QueryBody { + graphql_client::QueryBody { + variables, + query: template_deploy::QUERY, + operation_name: template_deploy::OPERATION_NAME, + } + } +} diff --git a/src/gql/queries/mod.rs b/src/gql/queries/mod.rs index be1539054..ea9659ff2 100644 --- a/src/gql/queries/mod.rs +++ b/src/gql/queries/mod.rs @@ -139,6 +139,8 @@ pub struct Domains; )] pub struct ProjectToken; +pub type SerializedTemplateConfig = serde_json::Value; + #[derive(GraphQLQuery)] #[graphql( schema_path = "src/gql/schema.graphql", diff --git a/src/gql/queries/strings/TemplateDetail.graphql b/src/gql/queries/strings/TemplateDetail.graphql index 15e1c305c..f5c930071 100644 --- a/src/gql/queries/strings/TemplateDetail.graphql +++ b/src/gql/queries/strings/TemplateDetail.graphql @@ -1,5 +1,7 @@ query TemplateDetail($code: String!) { template(code: $code) { + id + serializedConfig services { edges { node { @@ -9,4 +11,4 @@ query TemplateDetail($code: String!) { } } } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 466ebfaa7..320aa09e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ commands_enum!( add, completion, connect, + deploy, domain, docs, down,