use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use runner_manager_domain::attempt::{AttemptOutcome, AttemptState, FailureReason};
use runner_manager_domain::model::{AttemptId, ScaleTarget, StartMode};
use runner_manager_domain::policy::ScalePolicy;
use runner_manager_domain::store::{SqliteStore, Store};
use runner_manager_platform::secrets::{PlatformSecretStore, SecretScope, SecretStore};
use runner_manager_testkit::fixtures;
use secrecy::SecretString;
use crate::cli_chains::action::{Action, AttemptKind, Seed, Target, TargetKey};
use crate::cli_chains::ids::CaseId;
use crate::cli_chains::model::Installation;
use crate::cli_chains::values::{Anchor, PathValue, Resolver, Scope};
use crate::support::{self, FakeGithub};
pub const SERVICE_TAG_VARIABLE: &str = "RUNNER_MANAGER_SERVICE_NAME_TAG";
pub const DATABASE: [&str; 2] = ["config", "runner-manager.sqlite3"];
pub const INVOCATION_TIMEOUT: Duration = Duration::from_secs(120);
pub const OCCUPIED_CONTENTS: &str = "an existing file, not a directory\n";
#[must_use]
pub fn platform_tokens() -> (&'static str, &'static str) {
let os = match std::env::consts::OS {
"windows" => "win",
"macos" => "osx",
other => other,
};
let arch = match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "arm64",
"arm" => "arm",
other => other,
};
(os, arch)
}
#[derive(Debug, Clone)]
pub struct RealResolver {
pub data: PathBuf,
pub roots: PathBuf,
pub os: &'static str,
pub arch: &'static str,
}
impl RealResolver {
#[must_use]
pub fn absolute(&self, value: PathValue) -> Option<PathBuf> {
let (anchor, parts) = value.location()?;
let mut path = match anchor {
Anchor::Roots => self.roots.clone(),
Anchor::Data => self.data.clone(),
};
for part in parts {
path.push(part);
}
Some(path)
}
#[must_use]
pub fn identify(&self, reported: &str) -> Option<PathValue> {
PathValue::ALL.into_iter().find(|value| {
self.absolute(*value)
.is_some_and(|expected| same_path(&expected, Path::new(reported)))
})
}
#[must_use]
pub fn symbolic_label(&self, label: &str) -> String {
let suffix = format!("-{}-{}", self.os, self.arch);
match label.strip_suffix(&suffix) {
Some(stem) if stem.starts_with("rm-") => format!("{stem}-<os>-<arch>"),
_ => label.to_string(),
}
}
#[must_use]
pub fn resolve_text(&self, text: &str) -> String {
text.replace("<os>", self.os)
.replace("<arch>", self.arch)
.replace("<OS>", &self.os.to_ascii_uppercase())
.replace("<ARCH>", &self.arch.to_ascii_uppercase())
.replace("<roots>", &self.roots.to_string_lossy())
.replace("<data>", &self.data.to_string_lossy())
}
}
impl Resolver for RealResolver {
fn path(&self, value: PathValue) -> String {
self.absolute(value).map_or_else(
|| value.symbolic(),
|path| path.to_string_lossy().into_owned(),
)
}
fn derived_label(&self, host_label: &str) -> String {
format!("rm-{host_label}-{}-{}", self.os, self.arch)
}
}
#[must_use]
pub fn same_path(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
#[derive(Debug, Clone)]
pub struct Invocation {
pub argv: Vec<String>,
pub code: i32,
pub stdout: String,
pub stderr: String,
pub requests: Vec<String>,
pub elapsed: Duration,
}
pub struct Scenario {
_temporary: tempfile::TempDir,
pub root: PathBuf,
pub data: PathBuf,
pub roots: PathBuf,
pub cwd: PathBuf,
pub github: FakeGithub,
pub installation: Installation,
pub tag: String,
pub resolver: RealResolver,
}
impl Scenario {
#[must_use]
pub fn new(case: CaseId, installation: Installation) -> Self {
let temporary = tempfile::Builder::new()
.prefix(&format!("clichains-{case}-"))
.tempdir()
.expect("a temporary scenario directory");
let root = if cfg!(windows) {
temporary.path().to_path_buf()
} else {
std::fs::canonicalize(temporary.path()).expect("the scenario directory resolves")
};
let data = root.join("data");
let roots = root.join("roots");
let cwd = root.join("cwd");
std::fs::create_dir(&roots).expect("the scratch roots directory");
std::fs::write(roots.join("occupied.txt"), OCCUPIED_CONTENTS)
.expect("the scratch occupied file");
std::fs::create_dir(&cwd).expect("the child working directory");
let github = FakeGithub::start();
github.with_device_code().with_approval();
let specs = installation.specs();
if specs.is_empty() {
github.with_no_installations();
} else {
let repositories: Vec<Vec<&str>> = specs
.iter()
.map(|spec| spec.repositories.iter().map(String::as_str).collect())
.collect();
let listed: Vec<support::Installation<'_>> = specs
.iter()
.zip(&repositories)
.map(|(spec, repositories)| support::Installation {
id: spec.id,
account: spec.account,
account_type: "Organization",
selection: "selected",
repositories,
})
.collect();
github.with_installations(&listed);
}
let tag = format!("clichains-{case}-{}", std::process::id());
let (os, arch) = platform_tokens();
Self {
_temporary: temporary,
resolver: RealResolver {
data: data.clone(),
roots: roots.clone(),
os,
arch,
},
root,
data,
roots,
cwd,
github,
installation,
tag,
}
}
pub fn run_action(&self, action: &Action) -> Invocation {
self.invoke(&action.argv(&self.resolver))
}
fn invoke(&self, arguments: &[String]) -> Invocation {
let mut command = support::runner_manager_against(&self.data, &self.github);
command.env(SERVICE_TAG_VARIABLE, &self.tag);
command.current_dir(&self.cwd);
command.timeout(INVOCATION_TIMEOUT);
command.args(arguments);
let already_seen = self.github.seen().len();
let started = Instant::now();
let outcome = support::run(command);
let elapsed = started.elapsed();
let requests = self.github.seen().split_off(already_seen);
let mut argv = vec![
"--data-dir".to_string(),
self.data.to_string_lossy().into_owned(),
];
argv.extend(arguments.iter().cloned());
Invocation {
argv,
code: outcome.code,
stdout: outcome.stdout,
stderr: outcome.stderr,
requests,
elapsed,
}
}
#[must_use]
pub fn database_path(&self) -> PathBuf {
let mut path = self.data.clone();
for part in DATABASE {
path.push(part);
}
path
}
pub fn store(&self) -> Result<Option<SqliteStore>, String> {
let path = self.database_path();
if !path.exists() {
return Ok(None);
}
SqliteStore::open(&path)
.map(Some)
.map_err(|error| format!("cannot open {}: {error}", path.display()))
}
pub fn recorded_start_mode(&self) -> Result<StartMode, String> {
let Some(store) = self.store()? else {
return Ok(StartMode::default());
};
let hosts = store
.hosts()
.map_err(|error| format!("cannot read hosts: {error}"))?;
Ok(hosts
.first()
.map_or_else(StartMode::default, |host| host.service_start_mode))
}
pub fn secret_store(&self) -> Result<PlatformSecretStore, String> {
let scope = SecretScope::for_start_mode(self.recorded_start_mode()?);
PlatformSecretStore::rooted_at(scope, &self.data)
.map_err(|error| format!("cannot resolve the rooted secret store: {error}"))
}
pub fn apply_seed(&self, seed: &Seed) -> Result<(), String> {
match seed {
Seed::Credential => {
let document = format!(r#"{{"access_token":"{}"}}"#, support::fixture_token());
self.secret_store()?
.store(&SecretString::from(document))
.map_err(|error| format!("cannot seed the credential: {error}"))
}
Seed::Attempt { target, kind } => {
let store = self.require_store()?;
let policy = policy_for(&store, *target)?;
let state = match kind {
AttemptKind::Active => AttemptState::Busy,
AttemptKind::AwaitingCleanup => AttemptState::Failed,
AttemptKind::Cleaned => AttemptState::Cleaned,
};
let mut builder = fixtures::attempt()
.id(AttemptId::new_random())
.policy_id(policy.id)
.state(state);
match kind {
AttemptKind::Active => {
builder = builder.github_runner_id(73).process_id(4242);
}
AttemptKind::AwaitingCleanup | AttemptKind::Cleaned => {
builder = builder
.outcome(AttemptOutcome::failed(FailureReason::ProcessStartFailed));
}
}
store
.record_attempt(&builder.build())
.map_err(|error| format!("cannot journal the seeded attempt: {error}"))
}
Seed::RepairRequired(target) => {
let store = self.require_store()?;
let mut policy = policy_for(&store, *target)?;
let expected = policy.revision();
policy
.repair_required()
.map_err(|error| format!("cannot mark repair_required: {error}"))?;
store
.update_policy(&policy, expected)
.map_err(|error| format!("cannot store repair_required: {error}"))
}
Seed::Drain(target) => {
let store = self.require_store()?;
let mut policy = policy_for(&store, *target)?;
let expected = policy.revision();
policy
.request_disable()
.map_err(|error| format!("cannot start a drain: {error}"))?;
store
.update_policy(&policy, expected)
.map_err(|error| format!("cannot store the drain: {error}"))
}
Seed::PackageCache => {
let cache = self.data.join("state").join("packages");
std::fs::create_dir_all(&cache)
.and_then(|()| std::fs::write(cache.join("seeded-package.marker"), "seed\n"))
.map_err(|error| format!("cannot seed the package cache: {error}"))
}
}
}
fn require_store(&self) -> Result<SqliteStore, String> {
self.store()?
.ok_or_else(|| "a seed needs a policy, but no database exists yet".to_string())
}
}
pub fn policy_for(store: &dyn Store, target: Target) -> Result<ScalePolicy, String> {
let key = target
.key()
.ok_or_else(|| format!("{} is not a valid target", target.token()))?;
let policies = store
.policies()
.map_err(|error| format!("cannot read policies: {error}"))?;
policies
.into_iter()
.find(|policy| key_of(&policy.target) == key)
.ok_or_else(|| format!("no stored policy for {key}"))
}
#[must_use]
pub fn key_of(target: &ScaleTarget) -> TargetKey {
let scope = match target {
ScaleTarget::Repository(_) => Scope::Repository,
ScaleTarget::Organization(_) => Scope::Organization,
};
TargetKey {
scope,
slug: target.slug().to_ascii_lowercase(),
}
}