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}