diff --git a/Cargo.lock b/Cargo.lock index ea6c373..936e8f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -319,6 +319,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -664,6 +670,7 @@ dependencies = [ "crossterm 0.29.0", "derive_deref", "directories", + "docker_credential", "eyre", "firecrest_client", "futures", @@ -675,6 +682,7 @@ dependencies = [ "lazy_static", "libc", "nom 8.0.0", + "oci-distribution", "open", "openidconnect", "pretty_assertions", @@ -1230,6 +1238,17 @@ dependencies = [ "const-random", ] +[[package]] +name = "docker_credential" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + [[package]] name = "document-features" version = "0.2.11" @@ -2676,6 +2695,15 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-auth" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822" +dependencies = [ + "memchr", +] + [[package]] name = "http-body" version = "1.0.1" @@ -3187,6 +3215,21 @@ dependencies = [ "serde", ] +[[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common", + "digest", + "hmac", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "keyring" version = "3.6.3" @@ -3716,6 +3759,42 @@ dependencies = [ "memchr", ] +[[package]] +name = "oci-distribution" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95a2c51531af0cb93761f66094044ca6ea879320bccd35ab747ff3fcab3f422" +dependencies = [ + "bytes", + "chrono", + "futures-util", + "http", + "http-auth", + "jwt", + "lazy_static", + "olpc-cjson", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "unicase", +] + +[[package]] +name = "olpc-cjson" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" +dependencies = [ + "serde", + "serde_json", + "unicode-normalization", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -4527,12 +4606,14 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -5665,6 +5746,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -6129,6 +6211,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.81" diff --git a/coman/.config/config.toml b/coman/.config/config.toml index 9fa67e8..951cb21 100644 --- a/coman/.config/config.toml +++ b/coman/.config/config.toml @@ -1,5 +1,5 @@ [cscs] -system = "daint" +current_system = "daint" image = "ubuntu" @@ -17,3 +17,11 @@ edf_file_template = """ image = "{{edf_image}}" mounts = ["${SCRATCH}:/scratch"] """ + +[cscs.systems] + +[cscs.systems.daint] +architecture = ["arm64"] + +[cscs.systems.eiger] +architecture = ["amd64"] diff --git a/coman/Cargo.toml b/coman/Cargo.toml index db6885d..e7d6721 100644 --- a/coman/Cargo.toml +++ b/coman/Cargo.toml @@ -65,6 +65,8 @@ tabled = { version = "0.20.0", features = ["macros"] } nom = "8.0.0" tera = "1.20.1" inquire = "0.9.1" +oci-distribution = "0.11.0" +docker_credential = "1.3.2" [build-dependencies] anyhow = "1.0.90" diff --git a/coman/src/app/user_events.rs b/coman/src/app/user_events.rs index 5c7b3e1..2ec285a 100644 --- a/coman/src/app/user_events.rs +++ b/coman/src/app/user_events.rs @@ -11,7 +11,6 @@ pub enum UserEvent { Cscs(CscsEvent), Error(String), Info(String), - None, // this is mainly used to return a nop result that keeps a port alive, as returning no Event stops the port } impl PartialEq for UserEvent { diff --git a/coman/src/config.rs b/coman/src/config.rs index 8f0c7d8..1fc7ccc 100644 --- a/coman/src/config.rs +++ b/coman/src/config.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] // Remove this once you start using the code -use std::{env, path::PathBuf}; +use std::{collections::HashMap, env, path::PathBuf}; use color_eyre::Result; use directories::ProjectDirs; @@ -9,6 +9,11 @@ use serde::{Deserialize, Serialize}; const DEFAULT_CONFIG_TOML: &str = include_str!("../.config/config.toml"); +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct SystemDescription { + pub architecture: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct AppConfig { #[serde(default)] @@ -20,7 +25,7 @@ pub struct AppConfig { #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct CscsConfig { #[serde(default)] - pub system: String, + pub current_system: String, #[serde(default)] pub name: Option, #[serde(default)] @@ -31,7 +36,11 @@ pub struct CscsConfig { pub edf_file_template: String, #[serde(default)] pub command: Vec, + + #[serde(default)] + pub systems: HashMap, } + #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Config { #[serde(default, flatten)] diff --git a/coman/src/cscs/api_client.rs b/coman/src/cscs/api_client.rs index e7522ae..6b22906 100644 --- a/coman/src/cscs/api_client.rs +++ b/coman/src/cscs/api_client.rs @@ -74,6 +74,7 @@ impl From for FileSystem { #[derive(Debug, Eq, Clone, PartialEq, PartialOrd, Ord, Display)] pub enum JobStatus { + Pending, Running, Finished, Cancelled, @@ -96,6 +97,7 @@ impl From for Job { "FAILED" => JobStatus::Failed, "COMPLETED" => JobStatus::Finished, "CANCELLED" => JobStatus::Cancelled, + "PENDING" => JobStatus::Pending, other => panic!("got job status: {}", other), }, user: value.user.unwrap_or("".to_string()), diff --git a/coman/src/cscs/handlers.rs b/coman/src/cscs/handlers.rs index 4801c12..acf154f 100644 --- a/coman/src/cscs/handlers.rs +++ b/coman/src/cscs/handlers.rs @@ -6,8 +6,8 @@ use crate::{ cscs::{ api_client::{CscsApi, FileSystemType, Job, JobDetail, System}, oauth2::{ - CLIENT_ID_SECRET_NAME, CLIENT_SECRET_SECRET_NAME, - client_credentials_login, finish_cscs_device_login, start_cscs_device_login, + CLIENT_ID_SECRET_NAME, CLIENT_SECRET_SECRET_NAME, client_credentials_login, + finish_cscs_device_login, start_cscs_device_login, }, }, util::{ @@ -61,17 +61,22 @@ pub async fn cscs_job_list() -> Result> { Ok(access_token) => { let api_client = CscsApi::new(access_token.0).unwrap(); let config = Config::new().unwrap(); - api_client.list_jobs(&config.cscs.system, Some(true)).await + api_client + .list_jobs(&config.cscs.current_system, Some(true)) + .await } Err(e) => Err(e), } } + pub async fn cscs_job_details(job_id: i64) -> Result> { match get_access_token().await { Ok(access_token) => { let api_client = CscsApi::new(access_token.0).unwrap(); let config = Config::new().unwrap(); - api_client.get_job(&config.cscs.system, job_id).await + api_client + .get_job(&config.cscs.current_system, job_id) + .await } Err(e) => Err(e), } @@ -86,9 +91,9 @@ pub async fn cscs_start_job( Ok(access_token) => { let api_client = CscsApi::new(access_token.0).unwrap(); let config = Config::new().unwrap(); - let user_info = api_client.get_userinfo(&config.cscs.system).await?; - let system = api_client.get_system(&config.cscs.system).await?; - let scratch = match system { + let user_info = api_client.get_userinfo(&config.cscs.current_system).await?; + let current_system = api_client.get_system(&config.cscs.current_system).await?; + let scratch = match current_system { Some(system) => PathBuf::from( system .file_systems @@ -101,7 +106,7 @@ pub async fn cscs_start_job( None => { return Err(eyre!( "couldn't get system description for {}", - config.cscs.system + config.cscs.current_system )); } }; @@ -113,21 +118,43 @@ pub async fn cscs_start_job( let environment_path = base_path.join("environment.toml"); let environment_template = config.cscs.edf_file_template; tera.add_raw_template("environment.toml", &environment_template)?; + + let docker_image = image.unwrap_or(config.cscs.image.try_into()?); + let meta = docker_image.inspect().await?; + if let Some(system_info) = config.cscs.systems.get(&config.cscs.current_system) { + let mut compatible = false; + for sys_platform in system_info.architecture.iter() { + if meta.platforms.contains(&sys_platform.clone().into()) { + compatible = true; + } + } + + if !compatible { + return Err(eyre!( + "System {} only supports images with architecture(s) '{}' but the supplied image is for architecture(s) '{}'", + config.cscs.current_system, + system_info.architecture.join(","), + meta.platforms + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(",") + )); + } + } + let mut context = tera::Context::new(); - context.insert( - "edf_image", - &image.unwrap_or(config.cscs.image.try_into()?).to_edf(), - ); + context.insert("edf_image", &docker_image.to_edf()); let environment_file = tera.render("environment.toml", &context)?; api_client - .mkdir(&config.cscs.system, base_path.clone()) + .mkdir(&config.cscs.current_system, base_path.clone()) .await?; api_client - .chmod(&config.cscs.system, base_path.clone(), "700") + .chmod(&config.cscs.current_system, base_path.clone(), "700") .await?; api_client .upload( - &config.cscs.system, + &config.cscs.current_system, environment_path.clone(), environment_file.into_bytes(), ) @@ -150,7 +177,7 @@ pub async fn cscs_start_job( let script = tera.render("script.sh", &context)?; api_client .upload( - &config.cscs.system, + &config.cscs.current_system, script_path.clone(), script.into_bytes(), ) @@ -158,7 +185,7 @@ pub async fn cscs_start_job( // start job api_client - .start_job(&config.cscs.system, &name, script_path) + .start_job(&config.cscs.current_system, &name, script_path) .await?; Ok(()) } diff --git a/coman/src/main.rs b/coman/src/main.rs index f60c7db..10a23f8 100644 --- a/coman/src/main.rs +++ b/coman/src/main.rs @@ -45,6 +45,7 @@ extern crate tuirealm; #[tokio::main] async fn main() -> Result<()> { set_global_service_name(env!("CARGO_PKG_NAME")); + crate::logging::init()?; let args = Cli::parse(); match args.command { Some(command) => match command { @@ -75,7 +76,6 @@ async fn main() -> Result<()> { fn run_tui() -> Result<()> { crate::errors::init()?; - crate::logging::init()?; //we initialize the terminal early so the panic handler that restores the terminal is correctly set up let adapter = CrosstermTerminalAdapter::new()?; let bridge = TerminalBridge::init(adapter).expect("Cannot initialize terminal"); diff --git a/coman/src/util/types.rs b/coman/src/util/types.rs index a9c9ed9..67f1544 100644 --- a/coman/src/util/types.rs +++ b/coman/src/util/types.rs @@ -1,5 +1,6 @@ use color_eyre::{Report, Result}; -use eyre::eyre; +use docker_credential::{CredentialRetrievalError, DockerCredential}; +use eyre::{Context, eyre}; use nom::{ IResult, Parser, branch::alt, @@ -9,8 +10,37 @@ use nom::{ multi::separated_list1, sequence::{preceded, terminated}, }; -use std::str::FromStr; +use oci_distribution::{ + Client, Reference, + client::{ClientConfig, ClientProtocol}, + manifest::OciManifest, + secrets::RegistryAuth, +}; +use std::{collections::HashSet, fmt::Display, str::FromStr}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash, strum::Display)] +pub enum OciPlatform { + #[allow(non_camel_case_types)] + arm64, + #[allow(non_camel_case_types)] + amd64, + Other, +} + +impl From for OciPlatform { + fn from(value: String) -> Self { + match value.as_str() { + "arm64" => Self::arm64, + "amd64" => Self::amd64, + _ => Self::Other, + } + } +} +#[derive(Debug, Clone, PartialEq, PartialOrd)] +pub struct DockerImageMeta { + pub platforms: Vec, +} #[derive(Debug, Clone, PartialEq, PartialOrd)] pub struct DockerImageUrl { registry: Option, @@ -39,6 +69,55 @@ impl DockerImageUrl { .unwrap_or_default() ) } + + pub async fn inspect(&self) -> Result { + let client = Client::new(ClientConfig { + protocol: ClientProtocol::Https, + ..Default::default() + }); + let reference = self.to_string().parse()?; + let auth = docker_auth(&reference)?; + let (manifest, _) = client.pull_manifest(&reference, &auth).await?; + match manifest { + OciManifest::Image(oci_image_manifest) => { + // it's not clear what is returned in this case, I never hit this in my testing. + // leaving the dbg statement so if a user ever hits this, we can ask for logs and figure it out. + let _ = dbg!(oci_image_manifest); + Err(eyre!( + "didn't get image index for image, plain manifest does not contain platform data" + )) + } + OciManifest::ImageIndex(oci_image_index) => { + let mut platforms: HashSet = HashSet::new(); + platforms.extend(oci_image_index.manifests.into_iter().map(|m| { + m.platform + .map(|p| p.architecture) + .unwrap_or("".to_owned()) + .into() + })); + Ok(DockerImageMeta { + platforms: platforms.into_iter().collect(), + }) + } + } + } +} + +fn docker_auth(reference: &Reference) -> Result { + let server = reference + .resolve_registry() + .strip_suffix('/') + .unwrap_or_else(|| reference.resolve_registry()); + match docker_credential::get_credential(server) { + Ok(DockerCredential::UsernamePassword(username, password)) => { + Ok(RegistryAuth::Basic(username, password)) + } + Ok(DockerCredential::IdentityToken(_)) => Ok(RegistryAuth::Anonymous), // id tokens are not supported + Err(CredentialRetrievalError::ConfigNotFound) + | Err(CredentialRetrievalError::NoCredentialConfigured) + | Err(CredentialRetrievalError::ConfigReadError) => Ok(RegistryAuth::Anonymous), + Err(e) => Err(e).wrap_err("couldn't get docker credentials"), + } } type DockerParseType<'a> = IResult< @@ -90,3 +169,22 @@ impl TryFrom for DockerImageUrl { DockerImageUrl::from_str(&value) } } + +impl Display for DockerImageUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(registry) = self.registry.as_ref() { + write!(f, "{}/", registry)?; + } + + write!(f, "{}", self.image)?; + + if let Some(tag) = self.tag.as_ref() { + write!(f, ":{}", tag)?; + } + + if let Some(digest) = self.digest.as_ref() { + write!(f, "@sha256:{}", digest)?; + } + Ok(()) + } +} diff --git a/firecrest_client/src/compute_api.rs b/firecrest_client/src/compute_api.rs index 17892fb..1230c69 100644 --- a/firecrest_client/src/compute_api.rs +++ b/firecrest_client/src/compute_api.rs @@ -39,8 +39,6 @@ pub async fn post_compute_system_job( }, }; let body_json = serde_json::to_string(&body)?; - let body_json = dbg!(body_json); - let response = client .post( format!("compute/{system_name}/jobs").as_str(), diff --git a/firecrest_client/src/types.rs b/firecrest_client/src/types.rs index bb2145e..0b65507 100644 --- a/firecrest_client/src/types.rs +++ b/firecrest_client/src/types.rs @@ -1,7 +1,7 @@ //! AUTO-GENERATED CODE - DO NOT EDIT! //! //! FirecREST -//! Source: /tmp/.tmpHCBTUy.json +//! Source: /tmp/.tmp13MkxM.json //! Version: 2.4.0 //! Generated by `oas3-gen v0.9.0` //!