#![cfg(test)]
use std::time::{Duration, Instant};
use futures::executor::block_on;
use superci_core::github::{self, App, Owner};
use superci_core::io::{Http, Request};
pub fn http() -> crate::aws::Blocking { crate::aws::Blocking::new() }
pub fn now_ms() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64 }
pub fn app() -> App {
let path = std::env::var("SUPERCI_LIVE_APP").expect("SUPERCI_LIVE_APP: a GitHub App's JSON");
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let pem_path = v["pem_path"].as_str().map(str::to_string).unwrap_or_else(|| std::path::Path::new(&path).with_extension("pem").to_string_lossy().into());
App { id: v["id"].as_u64().unwrap(), slug: v["slug"].as_str().unwrap_or_default().into(), pem: std::fs::read_to_string(pem_path).unwrap(),
webhook_secret: String::new(), owner: v["owner"].as_str().unwrap_or_default().into(), owner_is_org: true, host: None }
}
pub struct Idle { pub jit: String, pub id: u64, token: String, repo: String }
impl Idle {
pub fn new(name: &str) -> Idle {
let (http, app, repo) = (http(), app(), std::env::var("SUPERCI_LIVE_REPO").expect("SUPERCI_LIVE_REPO: owner/repo"));
let installs = block_on(github::installations(&http, &app, now_ms())).unwrap();
let owner = repo.split('/').next().unwrap();
let install = installs.iter().find(|i| i.account == owner).expect("the App is installed on the repository's owner");
let token = block_on(github::installation_token(&http, &app, install.id, now_ms())).unwrap();
let label = format!("superci-leaktest-{}", superci_core::crypto::random_id(8));
let (jit, id) = block_on(github::jit_config(&http, &app.api(), &token, &Owner { org: false, login: owner.into() }, &repo, name, &[&label], "/home/runner/work")).unwrap();
Idle { jit, id, token, repo }
}
pub fn status(&self) -> Option<String> {
let mut r = Request::new("GET", &format!("https://api.github.com/repos/{}/actions/runners/{}", self.repo, self.id));
r.headers.extend([("authorization".into(), format!("Bearer {}", self.token)), ("accept".into(), "application/vnd.github+json".into()), ("user-agent".into(), "superci-live".into())]);
let res = block_on(http().send(r)).ok()?;
if res.status == 404 { return None }
serde_json::from_slice::<serde_json::Value>(&res.body).ok()?["status"].as_str().map(str::to_string)
}
}
impl Drop for Idle {
fn drop(&mut self) { let _ = block_on(github::delete_runner(&http(), &github::api_base(None), &self.token, &Owner { org: false, login: String::new() }, &self.repo, self.id)); }
}
pub fn watch(what: &str, start: Instant, limit: Duration, mut look: impl FnMut() -> (String, bool)) -> Option<Duration> {
let mut last = (String::new(), Instant::now() - Duration::from_secs(120));
loop {
let (state, ended) = look();
let t = start.elapsed();
if state != last.0 || last.1.elapsed() >= Duration::from_secs(60) {
eprintln!("[{:>3}s] {what}: {state}", t.as_secs());
last = (state, Instant::now());
}
if ended { return Some(t) }
if t > limit { return None }
std::thread::sleep(Duration::from_secs(30));
}
}
mod aws_live {
use super::*;
use superci_core::aws::{self, Credentials, Launch};
fn creds() -> Credentials {
let profile = std::env::var("SUPERCI_LIVE_AWS_PROFILE").expect("SUPERCI_LIVE_AWS_PROFILE");
let file = std::fs::read_to_string(format!("{}/.aws/credentials", std::env::var("HOME").unwrap())).unwrap();
let mut section = String::new();
let (mut id, mut secret, mut token) = (None, None, None);
for line in file.lines().map(str::trim) {
if line.starts_with('[') { section = line.trim_matches(|c| c == '[' || c == ']').to_string(); continue }
if section != profile { continue }
if let Some((k, v)) = line.split_once('=') {
let v = Some(v.trim().to_string());
match k.trim() { "aws_access_key_id" => id = v, "aws_secret_access_key" => secret = v, "aws_session_token" => token = v, _ => {} }
}
}
Credentials { access_key_id: id.expect("key id"), secret_access_key: secret.expect("secret"), session_token: token, expires_at_ms: u64::MAX }
}
fn state(region: &str, c: &Credentials, id: &str) -> String {
let xml = block_on(aws::ec2(&http(), region, c, "DescribeInstances", serde_json::json!({ "InstanceId": [id] }), now_ms())).unwrap_or_default();
xml.split("<instanceState>").nth(1).and_then(|s| aws::xml_tag(s, "name")).unwrap_or("unknown").to_string()
}
#[test]
#[ignore]
fn live_aws_prices() {
let (region, c, now) = ("us-east-1", creds(), now_ms());
let day = block_on(aws::spot_cost(&http(), region, "us-east-1a", &c, "c7a.large", "linux", now - 26 * 3_600_000, now - 2 * 3_600_000, now)).unwrap();
let minute = block_on(aws::spot_cost(&http(), region, "us-east-1a", &c, "c7a.large", "linux", now - 3_600_000, now - 3_590_000, now)).unwrap();
eprintln!("c7a.large spot in us-east-1a: a day ${day:.4} (${:.4}/h on average), ten seconds billed as a minute ${minute:.6}", day / 24.0);
assert!(day > 0.0 && (minute * 60.0 - day / 24.0).abs() < day / 24.0, "a minute at about the hour's rate");
match block_on(aws::on_demand_price(&http(), &c, region, "c7a.large", "linux", now)) { Ok(p) => eprintln!("c7a.large on-demand ${p}/h (AWS lists $0.10264)"), Err(e) => eprintln!("on-demand: {e}") }
if let Ok(id) = std::env::var("SUPERCI_LIVE_AWS_INSTANCE") {
let bytes = block_on(aws::bytes_sent(&http(), region, &c, &id, now - 12 * 3_600_000, now, now)).unwrap();
eprintln!("{id} sent {:.1} MB", bytes / 1e6);
}
match block_on(aws::disk_price(&http(), &c, region, now)) { Ok(p) => eprintln!("gp3 ${p}/GB-month (AWS lists $0.08)"), Err(e) => eprintln!("gp3: {e}") }
}
#[test]
#[ignore]
fn live_aws_machine_ends_at_its_time_bound() {
let (region, c) = ("us-east-1", creds());
let idle = Idle::new(&format!("superci-leaktest-aws-{}", superci_core::crypto::random_id(6)));
let image = block_on(aws::latest_image(&http(), region, &c, "135269210855", "runs-on-v2.2-ubuntu24-full-x64-*", now_ms())).unwrap();
let user_data = aws::runner_user_data(&superci_core::io::Work::GitHub { jit: idle.jit.clone() }, 3).unwrap();
let types = ["c7a.large".to_string(), "m7a.large".into(), "c7i.large".into()];
let tags = [("superci".to_string(), "leaktest".to_string())];
let start = Instant::now();
let aws::Started { id, kind, .. } = block_on(aws::run_instance(&http(), &c, &Launch { region, network: None, image: &image, types: &types, user_data: &user_data, disk_gb: 30, disk_mibs: 125, disk_iops: 3000, clock: None, skip: &[], tags: &tags, spot: true, os: "linux", avoid: &[], timer: None, jitter_ms: 0, fleet: false, store_disk: None }, now_ms(), &mut superci_core::aws::Told::default())).unwrap();
eprintln!("launched {id} ({kind}) in {region}, bound 3 min");
let mut online = false;
let ended = watch("machine", start, Duration::from_secs(10 * 60), || {
let s = state(region, &c, &id);
let runner = idle.status().unwrap_or_else(|| "removed".into());
online |= runner == "online";
(format!("{s}, runner {runner}"), s == "shutting-down" || s == "terminated")
});
if ended.is_none() { let _ = block_on(aws::terminate_instance(&http(), region, &c, &id, now_ms())); }
let ended = ended.expect("the machine was still up after 10 minutes (terminated now)");
assert!(online, "its runner never came online: the bound was not tested on a waiting runner");
assert!(ended >= Duration::from_secs(170) && ended <= Duration::from_secs(7 * 60), "ended after {ended:?}");
}
}