Skip to content

Commit

Permalink
Deploy template from CLI
Browse files Browse the repository at this point in the history
  • Loading branch information
paulocsanz committed Jun 25, 2024
1 parent ef47ec7 commit 0a7a273
Show file tree
Hide file tree
Showing 7 changed files with 353 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules
bin/railway
.envrc
.direnv/*
.env
265 changes: 265 additions & 0 deletions src/commands/deploy.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// 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<String>,
}

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<String, String> = args
.variable
.iter()
.map(|v| match v.split('=').collect::<Vec<_>>().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<String, String>,
) -> Result<(), anyhow::Error> {
let details = post_graphql::<queries::TemplateDetail, _>(
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<mutations::template_deploy::TemplateDeployService> =
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::<i64>()))
.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::<mutations::TemplateDeploy, _>(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<String, serde_json::Value>,
#[serde(default)]
tcp_proxies: HashMap<String, serde_json::Value>,
}

#[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<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
is_optional: Option<bool>,
// TODO: check how ${{secret()}} works with templates
// #[serde(default)]
// generator: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeserializedServiceDeploy {
healthcheck_path: Option<String>,
start_command: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum DeserializedServiceSource {
Image {
image: String,
},
#[serde(rename_all = "camelCase")]
Repo {
root_directory: Option<String>,
repo: String,
// branch: Option<String>,
},
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeserializedService {
// #[serde(default)]
// build: serde_json::Value,
#[serde(default)]
deploy: Option<DeserializedServiceDeploy>,

#[serde(default)]
icon: Option<String>,
name: String,

#[serde(default)]
networking: Option<DeserializedServiceNetworking>,

#[serde(default)]
source: Option<DeserializedServiceSource>,

#[serde(default)]
variables: HashMap<String, DeserializedServiceVariable>,
#[serde(default)]
volume_mounts: HashMap<String, DeserializedServiceVolumeMount>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeserializedEnvironment {
#[serde(default)]
services: HashMap<String, DeserializedService>,
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
80 changes: 80 additions & 0 deletions src/gql/mutations/strings/TemplateDeploy.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(rename = "hasDomain")]
pub has_domain: Option<Boolean>,
#[serde(rename = "healthcheckPath")]
pub healthcheck_path: Option<String>,
pub id: Option<String>,
#[serde(rename = "isPrivate")]
pub is_private: Option<Boolean>,
pub name: Option<String>,
pub owner: Option<String>,
#[serde(rename = "rootDirectory")]
pub root_directory: Option<String>,
#[serde(rename = "serviceIcon")]
pub service_icon: Option<String>,
#[serde(rename = "serviceName")]
pub service_name: String,
#[serde(rename = "startCommand")]
pub start_command: Option<String>,
#[serde(rename = "tcpProxyApplicationPort")]
pub tcp_proxy_application_port: Option<Int>,
pub template: String,
pub variables: Option<ServiceVariables>,
pub volumes: Option<Vec<TemplateVolume>>,
}
#[derive(Serialize)]
pub struct Variables {
#[serde(rename = "projectId")]
pub project_id: String,
#[serde(rename = "environmentId")]
pub environment_id: String,
pub services: Vec<TemplateDeployService>,
#[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<String>,
}
}
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<Self::Variables> {
graphql_client::QueryBody {
variables,
query: template_deploy::QUERY,
operation_name: template_deploy::OPERATION_NAME,
}
}
}
2 changes: 2 additions & 0 deletions src/gql/queries/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/gql/queries/strings/TemplateDetail.graphql
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
query TemplateDetail($code: String!) {
template(code: $code) {
id
serializedConfig
services {
edges {
node {
Expand All @@ -9,4 +11,4 @@ query TemplateDetail($code: String!) {
}
}
}
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ commands_enum!(
add,
completion,
connect,
deploy,
domain,
docs,
down,
Expand Down

0 comments on commit 0a7a273

Please sign in to comment.