aws_throwaway/
ec2_instance.rs

1use crate::Aws;
2use crate::ssh::SshConnection;
3use anyhow::{Context, Result, anyhow};
4use serde::{Deserialize, Serialize};
5use std::net::{IpAddr, Ipv4Addr};
6use std::time::Duration;
7use tokio::{net::TcpStream, time::Instant};
8
9/// Represents a currently running EC2 instance and provides various methods for interacting with the instance.
10///
11/// This type implements serde Serialize/Deserialize to allow you to save and restore the instance from disk.
12/// After restoring Ec2Instance in this way you need to call the [`Ec2Instance::init`] method.
13#[derive(Serialize, Deserialize)]
14pub struct Ec2Instance {
15    pub(crate) aws_id: String,
16    connect_ip: IpAddr,
17    public_ip: Option<IpAddr>,
18    private_ip: IpAddr,
19    client_private_key: String,
20    host_public_key_bytes: Vec<u8>,
21    host_public_key: String,
22    #[serde(skip)]
23    ssh: Option<SshConnection>,
24    network_interfaces: Vec<NetworkInterface>,
25}
26
27#[derive(Serialize, Deserialize)]
28pub struct NetworkInterface {
29    pub private_ipv4: Ipv4Addr,
30    pub device_index: i32,
31}
32
33impl Ec2Instance {
34    /// Use this address to connect to this instance from outside of AWS
35    pub fn public_ip(&self) -> Option<IpAddr> {
36        self.public_ip
37    }
38
39    /// Use this address to connect to this instance from within AWS
40    pub fn private_ip(&self) -> IpAddr {
41        self.private_ip
42    }
43
44    /// Use this address to get the private or public IP that aws-throwaway is using to ssh to the instance.
45    /// Whether or not this is public is decided by [`crate::AwsBuilder::use_public_addresses`].
46    ///
47    /// You should use this address if you want to connect to the instance from your local machine
48    pub fn connect_ip(&self) -> IpAddr {
49        self.connect_ip
50    }
51
52    /// List of all network interfaces attached to this instance.
53    /// Includes the primary interface that has the ip returned by [`Ec2Instance::private_ip`] as well as all other interfaces attached to this instance at the time it was created.
54    pub fn network_interfaces(&self) -> &[NetworkInterface] {
55        &self.network_interfaces
56    }
57
58    /// Use this as the private key of your machine when connecting to this instance
59    pub fn client_private_key(&self) -> &str {
60        &self.client_private_key
61    }
62
63    /// Use this for authenticating a host programmatically
64    pub fn host_public_key_bytes(&self) -> &[u8] {
65        &self.host_public_key_bytes
66    }
67
68    /// Insert this into your known_hosts file to avoid errors due to unknown fingerprints
69    pub fn openssh_known_hosts_line(&self) -> String {
70        format!("{} {}", &self.connect_ip, &self.host_public_key)
71    }
72
73    /// Returns an object that allows commands to be sent over ssh
74    pub fn ssh(&self) -> &SshConnection {
75        self.ssh
76            .as_ref()
77            .expect("Make sure to call `Ec2Instance::init` after deserializing `Ec2Instance`")
78    }
79
80    /// Get a list of commands that the user can paste into bash to manually open an ssh connection to this instance.
81    pub fn ssh_instructions(&self) -> String {
82        format!(
83            r#"```
84chmod 700 key 2> /dev/null || true
85echo '{}' > key
86echo '{}' > known_hosts
87chmod 400 key
88TERM=xterm ssh -i key ubuntu@{} -o "UserKnownHostsFile known_hosts"
89```"#,
90            self.client_private_key(),
91            self.openssh_known_hosts_line(),
92            self.connect_ip
93        )
94    }
95
96    /// Delete this instance.
97    /// Prefer using [`Aws::cleanup_resources`] at end of runtime as it will automatically destroy all resources, not just this one instance.
98    /// However this method can be useful when you have a single instance that you would like to terminate before the rest.
99    ///
100    /// `Aws` instance must be passed in manually here since `Ec2Instance`s can be deserialized when there is no `Aws` instance.
101    pub async fn terminate(self, aws: &Aws) {
102        aws.terminate_instance(self).await;
103    }
104
105    /// It is gauranteed that public_ip will be Some if use_public_address is true
106    #[allow(clippy::too_many_arguments)]
107    pub(crate) async fn new(
108        aws_id: String,
109        connect_ip: IpAddr,
110        public_ip: Option<IpAddr>,
111        private_ip: IpAddr,
112        host_public_key_bytes: Vec<u8>,
113        host_public_key: String,
114        client_private_key: &str,
115        network_interfaces: Vec<NetworkInterface>,
116    ) -> Self {
117        loop {
118            let start = Instant::now();
119            // We retry many times before we are able to succesfully make an ssh connection.
120            // Each error is expected and so is logged as a `info!` that describes the underlying startup process that is supposed to cause the error.
121            // A numbered comment is left before each `info!` to demonstrate the order each error occurs in.
122            match tokio::time::timeout(
123                Duration::from_secs(10),
124                TcpStream::connect((connect_ip, 22)),
125            )
126            .await
127            {
128                Err(_) => {
129                    // 1.
130                    tracing::info!(
131                        "Timed out connecting to {connect_ip} over ssh, the host is probably not accessible yet, retrying"
132                    );
133                    continue;
134                }
135                Ok(Err(e)) => {
136                    // 2.
137                    tracing::info!(
138                        "failed to connect to {connect_ip}:22, the host probably hasnt started their ssh service yet, retrying, error was {e}"
139                    );
140                    tokio::time::sleep_until(start + Duration::from_secs(1)).await;
141                    continue;
142                }
143                Ok(Ok(stream)) => {
144                    match SshConnection::new(
145                        stream,
146                        connect_ip,
147                        host_public_key_bytes.clone(),
148                        client_private_key,
149                    )
150                    .await
151                    {
152                        Err(err) => {
153                            // 3.
154                            tracing::info!(
155                                "Failed to make ssh connection to server, the host has probably not run its user-data script yet, retrying, error was: {err:?}"
156                            );
157                            tokio::time::sleep_until(start + Duration::from_secs(1)).await;
158                            continue;
159                        }
160                        // 4. Then finally we have a working ssh connection.
161                        Ok(ssh) => {
162                            break Ec2Instance {
163                                aws_id,
164                                connect_ip,
165                                ssh: Some(ssh),
166                                public_ip,
167                                private_ip,
168                                host_public_key_bytes,
169                                host_public_key,
170                                client_private_key: client_private_key.to_owned(),
171                                network_interfaces,
172                            };
173                        }
174                    };
175                }
176            };
177        }
178    }
179
180    /// After deserializing [`Ec2Instance`] this method must be called to recreate the ssh connection.
181    ///
182    /// No need to call after creating via [`crate::Aws::create_ec2_instance`]
183    pub async fn init(&mut self) -> Result<()> {
184        let connect_ip = self.connect_ip;
185
186        // We use a drastically simplifed initialization approach here compared to `Ec2Instance::new`.
187        // Since we can assume that the server has either already started up or is now terminated we
188        // avoid retries and tailor our error messages in order to provide better error reporting.
189        let stream =
190            tokio::time::timeout(Duration::from_secs(5), TcpStream::connect((connect_ip, 22)))
191                .await
192                .map_err(|_| anyhow!("Timed out connecting to {connect_ip}:22"))?
193                .with_context(|| format!("Failed to connect to {connect_ip}:22"))?;
194
195        let ssh = SshConnection::new(
196            stream,
197            connect_ip,
198            self.host_public_key_bytes.clone(),
199            &self.client_private_key,
200        )
201        .await
202        .with_context(|| format!("Failed to make ssh connection to {connect_ip}:22"))?;
203        self.ssh = Some(ssh);
204
205        Ok(())
206    }
207}