Skip to main content

tracel_xtask/commands/
host.rs

1use std::process::Command;
2
3use crate::utils::aws::instance_system_log::stream_system_log;
4use crate::{
5    context::Context,
6    prelude::{Environment, EnvironmentName, anyhow::Context as _},
7    utils::{self, aws},
8};
9
10const SSM_SESSION_DOC: &str = "Xtask-Host-InteractiveShell";
11
12#[tracel_xtask_macros::declare_command_args(None, HostSubCommand)]
13pub struct HostCmdArgs {}
14
15impl Default for HostSubCommand {
16    fn default() -> Self {
17        HostSubCommand::Connect(HostConnectSubCmdArgs::default())
18    }
19}
20
21#[derive(clap::Args, Clone, Default, PartialEq)]
22pub struct HostConnectSubCmdArgs {
23    /// Name of the host
24    #[arg(long)]
25    pub name: String,
26
27    /// Region where the host lives
28    #[arg(long)]
29    pub region: String,
30
31    /// Login user for the SSM interactive shell
32    #[arg(long, default_value = "ubuntu")]
33    pub user: String,
34
35    /// Show instance system log instead of opening an SSM shell
36    #[arg(long)]
37    pub system_log: bool,
38}
39
40#[derive(clap::Args, Clone, Default, PartialEq)]
41pub struct HostPrivateIpSubCmdArgs {
42    /// Name of the host
43    #[arg(long)]
44    pub name: String,
45
46    /// Region where the host lives
47    #[arg(long)]
48    pub region: String,
49}
50
51pub fn handle_command(args: HostCmdArgs, env: Environment, _ctx: Context) -> anyhow::Result<()> {
52    if matches!(
53        env.name,
54        EnvironmentName::Development | EnvironmentName::Test
55    ) {
56        anyhow::bail!(
57            "'database' command not supported for environment {env}, use local docker-compose or dev DB instead."
58        );
59    }
60
61    match args.get_command() {
62        HostSubCommand::Connect(connect_args) => connect(connect_args),
63        HostSubCommand::PrivateIp(privateip_args) => private_ip(privateip_args, env),
64    }
65}
66
67fn connect(args: HostConnectSubCmdArgs) -> anyhow::Result<()> {
68    // 1) Resolve instance ID from EC2 using the Name tag
69    let describe_output = Command::new("aws")
70        .args([
71            "ec2",
72            "describe-instances",
73            "--region",
74            &args.region,
75            "--filters",
76            &format!("Name=tag:Name,Values={}", args.name),
77            "Name=instance-state-name,Values=running",
78            "--query",
79            "Reservations[0].Instances[0].InstanceId",
80            "--output",
81            "text",
82        ])
83        .output()
84        .with_context(|| {
85            format!(
86                "Describing host instance '{}' in region '{}' should succeed",
87                args.name, args.region
88            )
89        })?;
90
91    if !describe_output.status.success() {
92        let stderr = String::from_utf8_lossy(&describe_output.stderr);
93        anyhow::bail!(
94            "Describing host instance '{}' in region '{}' should succeed, but AWS CLI exited with:\n{}",
95            args.name,
96            args.region,
97            stderr
98        );
99    }
100
101    let instance_id = String::from_utf8(describe_output.stdout)
102        .context("Parsing host instance ID from AWS CLI output should succeed")?
103        .trim()
104        .to_string();
105
106    if instance_id.is_empty() || instance_id == "None" {
107        anyhow::bail!(
108            "Finding a running host instance named '{}' in region '{}' should succeed, but none were found",
109            args.name,
110            args.region
111        );
112    }
113
114    if args.system_log {
115        eprintln!(
116            "📜 Streaming system log for host '{}' (id '{}') in region '{}' — Ctrl-C to stop",
117            args.name, instance_id, args.region
118        );
119        return stream_system_log(&args.region, &instance_id);
120    }
121
122    // 2) Ensure the SSM session document is present / up to date for this user
123    aws::cli::ensure_ssm_document(SSM_SESSION_DOC, &args.region, &args.user)?;
124
125    eprintln!(
126        "🔌 Opening SSM session to host '{}' (id '{}') in region '{}' as user '{}'...",
127        args.name, instance_id, args.region, args.user
128    );
129
130    let args_vec: Vec<&str> = vec![
131        "ssm",
132        "start-session",
133        "--target",
134        instance_id.as_str(),
135        "--region",
136        args.region.as_str(),
137        "--document-name",
138        SSM_SESSION_DOC,
139    ];
140
141    utils::process::run_process(
142        "aws",
143        &args_vec,
144        None,
145        None,
146        "SSM session to host should start successfully",
147    )?;
148
149    Ok(())
150}
151
152fn private_ip(args: HostPrivateIpSubCmdArgs, _env: Environment) -> anyhow::Result<()> {
153    // 1) Ask AWS for the PrivateIpAddress of the running instance with this Name tag
154    let describe_output = Command::new("aws")
155        .args([
156            "ec2",
157            "describe-instances",
158            "--region",
159            &args.region,
160            "--filters",
161            &format!("Name=tag:Name,Values={}", args.name),
162            "Name=instance-state-name,Values=running",
163            "--query",
164            "Reservations[0].Instances[0].PrivateIpAddress",
165            "--output",
166            "text",
167        ])
168        .output()
169        .with_context(|| format!("Describing host instance '{}' should succeed", args.name))?;
170
171    if !describe_output.status.success() {
172        let stderr = String::from_utf8_lossy(&describe_output.stderr);
173        anyhow::bail!(
174            "Describing host instance '{}' should succeed, but AWS CLI exited with:\n{}",
175            args.name,
176            stderr
177        );
178    }
179
180    // 2) Parse the private IP address
181    let private_ip = String::from_utf8(describe_output.stdout)
182        .context("Parsing host private IP from AWS CLI output should succeed")?
183        .trim()
184        .to_string();
185
186    if private_ip.is_empty() || private_ip == "None" {
187        anyhow::bail!(
188            "Finding a running instance named '{}' should return a private IP address, but none were found",
189            args.name,
190        );
191    }
192
193    // 3) Print to stdout so this subcommand can be used in scripts
194    println!("{private_ip}");
195
196    Ok(())
197}