use colored::ColoredString; use serde::Serialize; use crate::commands::output::fields::{print_field, print_service_environment_context}; use crate::controllers::{ outbound_networking::{self, FeatureAction, Ipv6Status, StaticIpStatus}, project::{ServiceContext, resolve_service_context}, }; use super::*; const FIELD_LABEL_WIDTH: usize = 20; const STAGED_IPV6_MESSAGE: &str = "Commit staged environment changes to trigger redeploy: railway environment edit"; /// Manage outbound networking for a service #[derive(Parser)] #[clap( after_help = "Examples:\n\n railway outbound-network status --service api\n railway outbound-network static-ip enable --service api\n railway outbound-network static-ip status --service api --json\n railway outbound-network static-ip disable --service api\n railway outbound-network ipv6 enable --service api\n railway outbound-network ipv6 status --service api --json\n railway outbound-network ipv6 disable --service api\n\nAutomation notes:\n Static Outbound IP changes require a redeploy before outbound traffic changes.\n Outbound IPv6 changes are staged; commit them with `railway environment edit` to trigger redeploy." )] pub struct Args { #[clap(subcommand)] command: Commands, /// Service name or ID (defaults to linked service) #[clap(short, long, global = true)] service: Option, /// Environment to use (defaults to linked environment) #[clap(short, long, global = true)] environment: Option, /// Project ID to use (defaults to linked project) #[clap(short = 'p', long, value_name = "PROJECT_ID", global = true)] project: Option, /// Output in JSON format #[clap(long, global = true)] json: bool, } #[derive(Parser)] enum Commands { /// Show outbound networking status Status, /// Manage Static Outbound IPs #[clap(name = "static-ip")] StaticIp(StaticIpArgs), /// Manage Outbound IPv6 Ipv6(Ipv6Args), } #[derive(Parser)] struct StaticIpArgs { #[clap(subcommand)] command: StaticIpCommands, } #[derive(Parser)] enum StaticIpCommands { /// Show Static Outbound IP status Status, /// Enable Static Outbound IPs Enable, /// Disable Static Outbound IPs Disable, } #[derive(Parser)] struct Ipv6Args { #[clap(subcommand)] command: Ipv6Commands, } #[derive(Parser)] enum Ipv6Commands { /// Show Outbound IPv6 status Status, /// Stage enabling Outbound IPv6 Enable, /// Stage disabling Outbound IPv6 Disable, } pub async fn command(args: Args) -> Result<()> { let Args { command, service, environment, project, json, } = args; crate::util::reporter::set_mode(json); match command { Commands::Status => status(project, service, environment, json).await?, Commands::StaticIp(args) => match args.command { StaticIpCommands::Status => { static_ip_status(project, service, environment, json).await? } StaticIpCommands::Enable => { static_ip_enable(project, service, environment, json).await? } StaticIpCommands::Disable => { static_ip_disable(project, service, environment, json).await? } }, Commands::Ipv6(args) => match args.command { Ipv6Commands::Status => ipv6_status(project, service, environment, json).await?, Ipv6Commands::Enable => ipv6_stage(project, service, environment, true, json).await?, Ipv6Commands::Disable => ipv6_stage(project, service, environment, false, json).await?, }, } Ok(()) } async fn status( project: Option, service: Option, environment: Option, json: bool, ) -> Result<()> { let ctx = resolve_service_context(project, service, environment).await?; let (static_ip, ipv6) = tokio::try_join!( outbound_networking::fetch_static_ip_status( &ctx.client, &ctx.configs, &ctx.environment_id, &ctx.service_id, ), outbound_networking::fetch_ipv6_status( &ctx.client, &ctx.configs, &ctx.environment_id, &ctx.service_id, ) )?; let output = OutboundNetworkingOutput::new(&ctx, static_ip, ipv6); if json { println!("{}", serde_json::to_string_pretty(&output)?); } else { print_outbound_networking_status(&output); } Ok(()) } async fn static_ip_status( project: Option, service: Option, environment: Option, json: bool, ) -> Result<()> { let ctx = resolve_service_context(project, service, environment).await?; let static_ip = outbound_networking::fetch_static_ip_status( &ctx.client, &ctx.configs, &ctx.environment_id, &ctx.service_id, ) .await?; let output = StaticIpStatusOutput::new(&ctx, static_ip); if json { println!("{}", serde_json::to_string_pretty(&output)?); } else { print_static_ip_status(&output); } Ok(()) } async fn static_ip_enable( project: Option, service: Option, environment: Option, json: bool, ) -> Result<()> { let ctx = resolve_service_context(project, service, environment).await?; let (static_ip, action) = outbound_networking::enable_static_ips( &ctx.client, &ctx.configs, &ctx.environment_id, &ctx.service_id, ) .await?; let output = StaticIpActionOutput::new(&ctx, action, static_ip); if json { println!("{}", serde_json::to_string_pretty(&output)?); } else { print_static_ip_action(&output); } Ok(()) } async fn static_ip_disable( project: Option, service: Option, environment: Option, json: bool, ) -> Result<()> { let ctx = resolve_service_context(project, service, environment).await?; let (static_ip, action) = outbound_networking::disable_static_ips( &ctx.client, &ctx.configs, &ctx.environment_id, &ctx.service_id, ) .await?; let output = StaticIpActionOutput::new(&ctx, action, static_ip); if json { println!("{}", serde_json::to_string_pretty(&output)?); } else { print_static_ip_action(&output); } Ok(()) } async fn ipv6_status( project: Option, service: Option, environment: Option, json: bool, ) -> Result<()> { let ctx = resolve_service_context(project, service, environment).await?; let ipv6 = outbound_networking::fetch_ipv6_status( &ctx.client, &ctx.configs, &ctx.environment_id, &ctx.service_id, ) .await?; let output = Ipv6StatusOutput::new(&ctx, ipv6); if json { println!("{}", serde_json::to_string_pretty(&output)?); } else { print_ipv6_status(&output); } Ok(()) } async fn ipv6_stage( project: Option, service: Option, environment: Option, value: bool, json: bool, ) -> Result<()> { let ctx = resolve_service_context(project, service, environment).await?; let (ipv6, action) = outbound_networking::stage_ipv6( &ctx.client, &ctx.configs, &ctx.environment_id, &ctx.service_id, value, ) .await?; let output = Ipv6ActionOutput::new(&ctx, action, ipv6); if json { println!("{}", serde_json::to_string_pretty(&output)?); } else { print_ipv6_action(&output); } Ok(()) } fn print_outbound_networking_status(output: &OutboundNetworkingOutput) { println!("{}", "Outbound networking".bold()); println!(); print_service_environment_context( &output.service.name, &output.environment.name, FIELD_LABEL_WIDTH, ); println!(); print_static_ip_fields(&output.static_ip); println!(); print_ipv6_fields(&output.ipv6); } fn print_static_ip_status(output: &StaticIpStatusOutput) { println!("{}", "Static Outbound IPs".bold()); println!(); print_service_environment_context( &output.service.name, &output.environment.name, FIELD_LABEL_WIDTH, ); println!(); print_static_ip_fields(&output.static_ip); } fn print_ipv6_status(output: &Ipv6StatusOutput) { println!("{}", "Outbound IPv6".bold()); println!(); print_service_environment_context( &output.service.name, &output.environment.name, FIELD_LABEL_WIDTH, ); println!(); print_ipv6_fields(&output.ipv6); } fn print_static_ip_action(output: &StaticIpActionOutput) { if !output.changed { println!( "Static Outbound IPs are already {} for service {} in environment {}.", if output.enabled { "enabled" } else { "disabled" }, output.service.name.bold(), output.environment.name.bold() ); return; } println!( "{} Static Outbound IPs for service {} in environment {}.", past_tense(output.action.action), output.service.name.bold(), output.environment.name.bold() ); if output.lifecycle.redeploy_required { println!( "Redeploy required before outbound traffic {} these IPs.", if output.enabled { "uses" } else { "stops using" } ); } if output.enabled { println!(); print_static_ip_table(&output.static_ip); } } fn print_ipv6_action(output: &Ipv6ActionOutput) { if !output.changed { if output.ipv6.pending_value == Some(output.enabled) { println!( "Outbound IPv6 is already staged to be {} for service {} in environment {}.", if output.enabled { "enabled" } else { "disabled" }, output.service.name.bold(), output.environment.name.bold() ); } else { println!( "Outbound IPv6 is already {} for service {} in environment {}.", if output.enabled { "enabled" } else { "disabled" }, output.service.name.bold(), output.environment.name.bold() ); } return; } if output.lifecycle.staged { if output.enabled { println!( "Staged Outbound IPv6 for service {} in environment {}.", output.service.name.bold(), output.environment.name.bold() ); } else { println!( "Staged disabling Outbound IPv6 for service {} in environment {}.", output.service.name.bold(), output.environment.name.bold() ); } println!("{STAGED_IPV6_MESSAGE}"); } else { println!( "Cleared staged Outbound IPv6 change for service {} in environment {}.", output.service.name.bold(), output.environment.name.bold() ); } } fn print_static_ip_fields(static_ip: &StaticIpStatus) { print_field( "Static IPs:", &status_label(static_ip.enabled), FIELD_LABEL_WIDTH, ); if static_ip.enabled && static_ip.high_availability { print_field( "High Availability:", &"enabled".green().bold(), FIELD_LABEL_WIDTH, ); } if static_ip.enabled { println!(); print_static_ip_table(static_ip); } } fn print_ipv6_fields(ipv6: &Ipv6Status) { print_field( "Outbound IPv6:", &status_label(ipv6.enabled), FIELD_LABEL_WIDTH, ); if let Some(value) = ipv6.pending_value { print_field( "Pending:", &(if value { "enable" } else { "disable" }) .to_string() .yellow(), FIELD_LABEL_WIDTH, ); print_field("Message:", &STAGED_IPV6_MESSAGE, FIELD_LABEL_WIDTH); } } fn print_static_ip_table(static_ip: &StaticIpStatus) { if static_ip.ips.is_empty() { return; } let ip_width = static_ip .ips .iter() .map(|ip| ip.ipv4.len()) .max() .unwrap_or("IP Address".len()) .max("IP Address".len()) + 3; let region_width = static_ip .ips .iter() .map(|ip| ip.region.len()) .max() .unwrap_or("Region".len()) .max("Region".len()) + 3; println!( "{: ColoredString { if enabled { "enabled".green().bold() } else { "disabled".yellow().bold() } } fn past_tense(action: &str) -> &'static str { match action { "enable" => "Enabled", "disable" => "Disabled", _ => "Updated", } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ResourceRef { id: String, name: String, } impl ResourceRef { fn service(ctx: &ServiceContext) -> Self { Self { id: ctx.service_id.clone(), name: ctx.service_name.clone(), } } fn environment(ctx: &ServiceContext) -> Self { Self { id: ctx.environment_id.clone(), name: ctx.environment_name.clone(), } } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct OutboundNetworkingOutput { service: ResourceRef, environment: ResourceRef, static_ip: StaticIpStatus, ipv6: Ipv6Status, } impl OutboundNetworkingOutput { fn new(ctx: &ServiceContext, static_ip: StaticIpStatus, ipv6: Ipv6Status) -> Self { Self { service: ResourceRef::service(ctx), environment: ResourceRef::environment(ctx), static_ip, ipv6, } } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct StaticIpStatusOutput { service: ResourceRef, environment: ResourceRef, static_ip: StaticIpStatus, } impl StaticIpStatusOutput { fn new(ctx: &ServiceContext, static_ip: StaticIpStatus) -> Self { Self { service: ResourceRef::service(ctx), environment: ResourceRef::environment(ctx), static_ip, } } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct Ipv6StatusOutput { service: ResourceRef, environment: ResourceRef, ipv6: Ipv6Status, } impl Ipv6StatusOutput { fn new(ctx: &ServiceContext, ipv6: Ipv6Status) -> Self { Self { service: ResourceRef::service(ctx), environment: ResourceRef::environment(ctx), ipv6, } } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct StaticIpActionOutput { service: ResourceRef, environment: ResourceRef, #[serde(flatten)] action: FeatureAction, static_ip: StaticIpStatus, } impl StaticIpActionOutput { fn new(ctx: &ServiceContext, action: FeatureAction, static_ip: StaticIpStatus) -> Self { Self { service: ResourceRef::service(ctx), environment: ResourceRef::environment(ctx), action, static_ip, } } } impl std::ops::Deref for StaticIpActionOutput { type Target = FeatureAction; fn deref(&self) -> &Self::Target { &self.action } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct Ipv6ActionOutput { service: ResourceRef, environment: ResourceRef, #[serde(flatten)] action: FeatureAction, ipv6: Ipv6Status, } impl Ipv6ActionOutput { fn new(ctx: &ServiceContext, action: FeatureAction, ipv6: Ipv6Status) -> Self { Self { service: ResourceRef::service(ctx), environment: ResourceRef::environment(ctx), action, ipv6, } } } impl std::ops::Deref for Ipv6ActionOutput { type Target = FeatureAction; fn deref(&self) -> &Self::Target { &self.action } } #[cfg(test)] mod tests { use super::*; use crate::controllers::outbound_networking::Lifecycle; use clap::Parser; #[test] fn parses_subcommands() { assert!(matches!( Args::parse_from(["outbound-network", "status"]).command, Commands::Status )); assert!(matches!( Args::parse_from(["outbound-network", "static-ip", "status"]).command, Commands::StaticIp(StaticIpArgs { command: StaticIpCommands::Status }) )); assert!(matches!( Args::parse_from(["outbound-network", "static-ip", "enable"]).command, Commands::StaticIp(StaticIpArgs { command: StaticIpCommands::Enable }) )); assert!(matches!( Args::parse_from(["outbound-network", "static-ip", "disable"]).command, Commands::StaticIp(StaticIpArgs { command: StaticIpCommands::Disable }) )); assert!(matches!( Args::parse_from(["outbound-network", "ipv6", "enable"]).command, Commands::Ipv6(Ipv6Args { command: Ipv6Commands::Enable }) )); assert!(matches!( Args::parse_from([ "outbound-network", "--service", "api", "--environment", "production", "--project", "project-id", "--json", "ipv6", "disable" ]) .command, Commands::Ipv6(Ipv6Args { command: Ipv6Commands::Disable }) )); } #[test] fn action_output_has_lifecycle_shape() { let output = serde_json::json!({ "feature": "staticIp", "action": "enable", "enabled": true, "changed": true, "lifecycle": Lifecycle::direct(true), }); assert_eq!(output["lifecycle"]["mode"], "direct"); assert_eq!(output["lifecycle"]["staged"], false); assert_eq!(output["lifecycle"]["committed"], true); assert_eq!(output["lifecycle"]["redeployRequired"], true); assert_eq!(output["lifecycle"]["redeployTriggered"], false); let output = serde_json::json!({ "feature": "ipv6", "action": "enable", "enabled": true, "changed": true, "lifecycle": Lifecycle::environment_patch(true), }); assert_eq!(output["lifecycle"]["mode"], "environmentPatch"); assert_eq!(output["lifecycle"]["staged"], true); assert_eq!(output["lifecycle"]["committed"], false); assert_eq!(output["lifecycle"]["redeployRequired"], false); assert_eq!(output["lifecycle"]["redeployTriggered"], false); } }