diff --git a/Cargo.lock b/Cargo.lock index b8ef7f44..ff190e07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,9 +346,9 @@ checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" @@ -702,6 +702,12 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dunce" version = "1.0.5" @@ -1097,6 +1103,7 @@ dependencies = [ "harmont-cloud", "hex", "hm-config", + "hm-core", "hm-dsl-engine", "hm-exec", "hm-pipeline-ir", @@ -1112,6 +1119,7 @@ dependencies = [ "rand 0.8.6", "reqwest", "schemars 0.8.22", + "secrecy", "semver", "serde", "serde_json", @@ -1137,9 +1145,9 @@ dependencies = [ [[package]] name = "harmont-cloud" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a55df2e5c3f98237b9949e4a75b7def80210a6a3ff798ac55566d81137e0c44" +checksum = "5d7dc914e40dc611e16fd53b968a2d98a542ee73597a823a7ee5e689bd3b3343" dependencies = [ "base64", "bytes", @@ -1158,9 +1166,9 @@ dependencies = [ [[package]] name = "harmont-cloud-raw" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "365765cdef9c7aa758bb409ae312f599d63e6984dd69f21af946181e09433ab3" +checksum = "24b79f84275fece58fad7fd1a96bfe5cea12484edd61e3819383d13086d9da46" dependencies = [ "bytes", "chrono", @@ -1225,12 +1233,28 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" name = "hm-config" version = "0.0.0-dev" dependencies = [ - "anyhow", "derive_more", "figment", "hm-util", "serde", + "smart-default", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml", +] + +[[package]] +name = "hm-core" +version = "0.0.0-dev" +dependencies = [ + "hm-config", + "hm-util", + "nix", + "secrecy", + "serde", "tempfile", + "thiserror 2.0.18", "tokio", "toml", ] @@ -1257,17 +1281,21 @@ version = "0.0.0-dev" dependencies = [ "anyhow", "async-trait", + "bytes", "chrono", "daggy", + "dotenvy", "flate2", "futures", "futures-util", "harmont-cloud", + "hm-core", "hm-pipeline-ir", "hm-plugin-protocol", "hm-util", "hm-vm", "ignore", + "secrecy", "serde", "serde_json", "tar", @@ -1303,10 +1331,16 @@ dependencies = [ "harmont-cloud", "harmont-cloud-raw", "hm-config", + "hm-core", "hm-exec", "hm-plugin-protocol", "hm-render", + "percent-encoding", + "reqwest", + "secrecy", + "serde", "serde_json", + "tempfile", "tokio", "tracing", "uuid", @@ -1348,6 +1382,7 @@ name = "hm-util" version = "0.0.0-dev" dependencies = [ "anyhow", + "derive_more", "dirs", "tempfile", "tokio", @@ -1994,6 +2029,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2763,6 +2810,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/Cargo.toml b/Cargo.toml index b3397710..b8250e5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/hm", "crates/hm-config", + "crates/hm-core", "crates/hm-plugin-protocol", "crates/hm-pipeline-ir", "crates/hm-util", @@ -15,6 +16,7 @@ members = [ default-members = [ "crates/hm", "crates/hm-config", + "crates/hm-core", "crates/hm-plugin-protocol", "crates/hm-pipeline-ir", "crates/hm-util", @@ -37,6 +39,7 @@ hm-plugin-cloud = { path = "crates/hm-plugin-cloud", version = "0.0.0-dev" hm-pipeline-ir = { path = "crates/hm-pipeline-ir", version = "0.0.0-dev" } hm-util = { path = "crates/hm-util", version = "0.0.0-dev" } hm-config = { path = "crates/hm-config", version = "0.0.0-dev" } +hm-core = { path = "crates/hm-core", version = "0.0.0-dev" } hm-dsl-engine = { path = "crates/hm-dsl-engine", version = "0.0.0-dev" } hm-render = { path = "crates/hm-render", version = "0.0.0-dev" } hm-vm = { path = "crates/hm-vm", version = "0.0.0-dev" } diff --git a/crates/hm-config/Cargo.toml b/crates/hm-config/Cargo.toml index 557fb149..cfe18d2c 100644 --- a/crates/hm-config/Cargo.toml +++ b/crates/hm-config/Cargo.toml @@ -11,8 +11,9 @@ hm-util = { workspace = true } derive_more = { workspace = true } figment = { workspace = true } serde = { workspace = true } +smart-default = { workspace = true } toml = "0.8" -anyhow = "1" +thiserror = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/hm-config/src/creds.rs b/crates/hm-config/src/creds.rs deleted file mode 100644 index 7008256d..00000000 --- a/crates/hm-config/src/creds.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! File-backed credential store at `~/.config/hm/credentials.toml`. -//! -//! Replaces the OS keyring as the sole backend. The file is written with -//! mode 0o600 (parent dir 0o700) via [`hm_util::os::fs::blocking::write_atomic_restricted`]. -//! Keyed by `(service, account)` to match the host-fn ABI plugins use. - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::path::PathBuf; - -#[derive(Debug, Default, Serialize, Deserialize)] -struct CredentialFile { - #[serde(default)] - entries: BTreeMap>, -} - -fn path() -> Result { - let dir = hm_util::dirs::hm_config_dir().context("could not determine config directory")?; - Ok(dir.join("credentials.toml")) -} - -fn load() -> CredentialFile { - let Ok(p) = path() else { - return CredentialFile::default(); - }; - let Ok(contents) = std::fs::read_to_string(&p) else { - return CredentialFile::default(); - }; - toml::from_str(&contents).unwrap_or_default() -} - -fn save(file: &CredentialFile) -> Result<()> { - let p = path()?; - let serialized = toml::to_string_pretty(file).context("serializing credentials")?; - hm_util::os::fs::blocking::write_atomic_restricted( - &p, - serialized.as_bytes(), - hm_util::os::fs::FileMode(0o600), - hm_util::os::fs::DirMode(0o700), - ) - .with_context(|| format!("writing {}", p.display()))?; - Ok(()) -} - -/// Read a credential for `(service, account)`. Returns `None` when the -/// file is missing, unreadable, or the entry is absent. -#[must_use] -pub fn get(service: &str, account: &str) -> Option { - load().entries.get(service)?.get(account).cloned() -} - -/// Write a credential. Silently no-ops on I/O failure so plugin callers -/// match the prior keyring-backed best-effort semantics. -pub fn set(service: &str, account: &str, secret: &str) { - let mut f = load(); - f.entries - .entry(service.to_string()) - .or_default() - .insert(account.to_string(), secret.to_string()); - let _ = save(&f); -} - -/// Credential `service` name for the cloud bearer token (account = API base URL). -pub const CLOUD_SERVICE: &str = "harmont-cloud"; - -/// Resolve the cloud bearer token for `api_base`. -/// -/// Priority: `HM_API_TOKEN` env (non-empty) first, then the stored -/// credential keyed by `(CLOUD_SERVICE, api_base)`. Returns `None` when -/// neither is present, so the caller can produce a clear "not logged in" error. -#[must_use] -pub fn cloud_token(api_base: &str) -> Option { - if let Ok(t) = std::env::var("HM_API_TOKEN") - && !t.is_empty() - { - return Some(t); - } - get(CLOUD_SERVICE, api_base) -} - -/// Persist the cloud bearer token for `api_base`. -/// -/// Silently no-ops on I/O failure (matches the best-effort semantics of -/// the underlying [`set`] call). -pub fn set_cloud_token(api_base: &str, token: &str) { - set(CLOUD_SERVICE, api_base, token); -} - -/// Remove any stored cloud bearer token for `api_base`. -/// -/// Silently no-ops if the entry is absent or the write fails. -pub fn forget_cloud_token(api_base: &str) { - delete(CLOUD_SERVICE, api_base); -} - -/// Remove a credential. Silently no-ops if the entry is absent or the -/// underlying write fails. -pub fn delete(service: &str, account: &str) { - let mut f = load(); - let now_empty = f.entries.get_mut(service).is_some_and(|svc| { - svc.remove(account); - svc.is_empty() - }); - if now_empty { - f.entries.remove(service); - } - let _ = save(&f); -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, unsafe_code)] -mod tests { - use super::*; - - fn with_home(f: F) { - let tmp = tempfile::tempdir().unwrap(); - let prev = std::env::var_os("HOME"); - // SAFETY: tests are single-threaded for env mutation by Cargo. - unsafe { - std::env::set_var("HOME", tmp.path()); - } - f(); - unsafe { - if let Some(v) = prev { - std::env::set_var("HOME", v); - } else { - std::env::remove_var("HOME"); - } - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn round_trip() { - with_home(|| { - assert_eq!(get("svc", "acct"), None); - set("svc", "acct", "shh"); - assert_eq!(get("svc", "acct").as_deref(), Some("shh")); - delete("svc", "acct"); - assert_eq!(get("svc", "acct"), None); - }); - } -} diff --git a/crates/hm-config/src/lib.rs b/crates/hm-config/src/lib.rs index 7bd5fd2f..22d162f6 100644 --- a/crates/hm-config/src/lib.rs +++ b/crates/hm-config/src/lib.rs @@ -1,20 +1,50 @@ -//! Layered (project/user/env) configuration and credential storage for the -//! `hm` CLI. Shared between the `hm` binary and `hm-plugin-cloud` so both sides -//! resolve config and credentials through one source of truth. +//! Layered (project/user/env) configuration for the `hm` CLI. Shared between +//! the `hm` binary and `hm-plugin-cloud` so both sides resolve config through +//! one source of truth. +//! +//! Credentials live in `hm-core` (`hm_core::Sys::creds`), not here. use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; use figment::{ Figment, providers::{Env, Format, Serialized, Toml}, }; use serde::{Deserialize, Serialize}; - -pub mod creds; +use smart_default::SmartDefault; pub const DEFAULT_API_URL: &str = "https://api.harmont.dev"; +/// Errors produced while loading configuration. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum LoadError { + /// Figment failed to merge/extract a config layer (malformed TOML, type mismatch, …). + #[error(transparent)] + Figment(#[from] figment::Error), +} + +/// Errors produced while persisting configuration. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SaveError { + /// Platform config directory (`~/.config/hm` or equivalent) could not be resolved. + #[error("could not determine config directory")] + ConfigDirUnavailable, + + /// TOML serialization of a [`Config`] failed. + #[error("serializing config")] + Serialize(#[from] toml::ser::Error), + + /// Atomic write of a config file failed. + #[error("writing {}", .path.display())] + Write { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + /// Execution backend for `hm run`. /// /// Closed set parsed at the config boundary so invalid values are rejected at @@ -64,10 +94,11 @@ pub fn app_url(api: &str, override_url: Option<&str>) -> String { api.to_string() } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)] #[non_exhaustive] pub struct CloudConfig { pub org: Option, + #[default(DEFAULT_API_URL.to_owned())] pub api_url: String, /// Org-global pipeline slug to submit builds to directly (set by `hm run` /// after registering a remoteless directory). When present, cloud runs @@ -75,32 +106,14 @@ pub struct CloudConfig { pub pipeline: Option, } -impl Default for CloudConfig { - fn default() -> Self { - Self { - org: None, - api_url: DEFAULT_API_URL.to_owned(), - pipeline: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SmartDefault)] #[non_exhaustive] pub struct Preferences { + #[default("human".to_owned())] pub format: String, pub auto_watch: bool, } -impl Default for Preferences { - fn default() -> Self { - Self { - format: "human".to_owned(), - auto_watch: false, - } - } -} - #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub struct Config { @@ -115,12 +128,10 @@ pub struct Config { impl Config { /// XDG-aware user config path (`~/.config/hm/config.toml`). /// - /// # Errors - /// - /// Returns an error if the platform config directory cannot be determined. - pub fn user_config_path() -> Result { - let dir = hm_util::dirs::hm_config_dir().context("could not determine config directory")?; - Ok(dir.join("config.toml")) + /// Returns `None` if the platform config directory cannot be determined. + #[must_use] + pub fn user_config_path() -> Option { + hm_util::dirs::hm_config_dir().map(|d| d.join("config.toml")) } /// Project-level config path: `/.hm/config.toml`. @@ -131,15 +142,17 @@ impl Config { /// Load configuration with full layering: defaults -> user file -> project file -> env. /// + /// `project_config_path` is the path to the project-level config file + /// (typically `.hm/config.toml`), not the project root. Pass `None` to + /// skip the project layer (user + env only). When the user config path + /// cannot be resolved, the user layer is skipped as well. + /// /// # Errors /// - /// Returns an error if the user config path cannot be determined or - /// figment extraction fails (malformed TOML, type mismatches). - pub fn load(project_root: Option<&Path>) -> Result { - let user_path = Self::user_config_path()?; - let project_path = project_root.map(Self::project_config_path); - Self::load_from_paths(Some(&user_path), project_path.as_deref()) - .context("loading configuration") + /// Returns [`LoadError`] if figment extraction fails (malformed TOML, type + /// mismatches). + pub fn load(project_config_path: Option<&Path>) -> Result { + Self::load_from_paths(Self::user_config_path().as_deref(), project_config_path) } /// Testable core: build a `Config` from explicit file paths. @@ -154,8 +167,12 @@ impl Config { /// /// # Errors /// - /// Returns an error if figment extraction fails (malformed TOML, type mismatches). - pub fn load_from_paths(user_path: Option<&Path>, project_path: Option<&Path>) -> Result { + /// Returns [`LoadError`] if figment extraction fails (malformed TOML, type + /// mismatches). + pub fn load_from_paths( + user_path: Option<&Path>, + project_path: Option<&Path>, + ) -> Result { let mut figment = Figment::new().merge(Serialized::defaults(Self::default())); if let Some(p) = user_path { @@ -176,25 +193,31 @@ impl Config { /// /// # Errors /// - /// Returns an error if TOML serialization fails or the atomic write fails. - pub fn save_to(&self, path: &Path) -> Result<()> { - let serialized = toml::to_string_pretty(self).context("serializing config")?; + /// Returns [`SaveError`] if TOML serialization fails or the atomic write + /// fails. + pub fn save_to(&self, path: &Path) -> Result<(), SaveError> { + let serialized = toml::to_string_pretty(self)?; hm_util::os::fs::blocking::write_atomic_restricted( path, serialized.as_bytes(), hm_util::os::fs::FileMode(0o644), hm_util::os::fs::DirMode(0o700), ) - .with_context(|| format!("writing {}", path.display())) + .map_err(|source| SaveError::Write { + path: path.to_path_buf(), + source, + }) } /// Save to user-level config path (`~/.config/hm/config.toml`). /// /// # Errors /// - /// Returns an error if the path cannot be determined or the write fails. - pub fn save_user(&self) -> Result<()> { - self.save_to(&Self::user_config_path()?) + /// Returns [`SaveError::ConfigDirUnavailable`] if the path cannot be + /// determined, or any other [`SaveError`] if the write fails. + pub fn save_user(&self) -> Result<(), SaveError> { + let path = Self::user_config_path().ok_or(SaveError::ConfigDirUnavailable)?; + self.save_to(&path) } } @@ -215,251 +238,3 @@ fn hm_alias_env() -> Env { }) .split(".") } - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use std::io::Write as _; - use std::sync::{Mutex, MutexGuard}; - - /// Serializes every test that resolves config through `load_*`. - /// - /// All `load_*` paths merge the process environment as their top layer, so - /// a test that sets `HM_*` (via `figment::Jail`, which mutates the - /// real process env for the duration of its closure) would otherwise leak - /// into a concurrently-running file-layering test. Holding this lock for - /// the whole body of any env-or-load test makes them mutually exclusive. - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - fn env_guard() -> MutexGuard<'static, ()> { - ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - #[test] - fn app_url_maps_prod_api_to_app() { - assert_eq!(app_url(DEFAULT_API_URL, None), "https://app.harmont.dev"); - } - - #[test] - fn app_url_override_wins_and_trims_trailing_slash() { - assert_eq!( - app_url(DEFAULT_API_URL, Some("http://localhost:5173/")), - "http://localhost:5173" - ); - } - - #[test] - fn app_url_empty_override_is_ignored() { - assert_eq!( - app_url(DEFAULT_API_URL, Some(" ")), - "https://app.harmont.dev" - ); - } - - #[test] - fn app_url_falls_back_to_api_for_unmapped_host() { - assert_eq!( - app_url("http://localhost:4000", None), - "http://localhost:4000" - ); - // http api. → http app. - assert_eq!(app_url("http://api.dev.test/", None), "http://app.dev.test"); - } - - #[test] - fn default_config_values() { - let cfg = Config::default(); - assert_eq!(cfg.backend, Backend::Docker); - assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); - assert!(cfg.cloud.org.is_none()); - assert!(cfg.cloud.pipeline.is_none()); - assert_eq!(cfg.preferences.format, "human"); - assert!(!cfg.preferences.auto_watch); - } - - #[test] - fn deserialize_full_toml() { - let toml_str = r#" -[cloud] -org = "acme" -api_url = "https://custom.api" -pipeline = "acme/web" - -[preferences] -format = "json" -auto_watch = true -"#; - let cfg: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(cfg.cloud.org.as_deref(), Some("acme")); - assert_eq!(cfg.cloud.api_url, "https://custom.api"); - assert_eq!(cfg.cloud.pipeline.as_deref(), Some("acme/web")); - assert_eq!(cfg.preferences.format, "json"); - assert!(cfg.preferences.auto_watch); - } - - #[test] - fn deserialize_sparse_toml() { - let _g = env_guard(); - let toml_str = r#" -[cloud] -org = "sparse-co" -"#; - let mut f = tempfile::NamedTempFile::new().unwrap(); - f.write_all(toml_str.as_bytes()).unwrap(); - - let cfg = Config::load_from_paths(Some(f.path()), None).unwrap(); - assert_eq!(cfg.cloud.org.as_deref(), Some("sparse-co")); - assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); - assert_eq!(cfg.preferences.format, "human"); - assert!(!cfg.preferences.auto_watch); - } - - #[test] - fn deserialize_empty_toml() { - let _g = env_guard(); - let mut f = tempfile::NamedTempFile::new().unwrap(); - f.write_all(b"").unwrap(); - - let cfg = Config::load_from_paths(Some(f.path()), None).unwrap(); - assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); - assert!(cfg.cloud.org.is_none()); - assert_eq!(cfg.preferences.format, "human"); - assert!(!cfg.preferences.auto_watch); - } - - #[test] - fn figment_project_overrides_user() { - let _g = env_guard(); - let user_toml = r#" -[cloud] -org = "user-org" -api_url = "https://user.api" - -[preferences] -format = "json" -"#; - let project_toml = r#" -[cloud] -org = "project-org" -"#; - - let mut user_file = tempfile::NamedTempFile::new().unwrap(); - user_file.write_all(user_toml.as_bytes()).unwrap(); - - let mut project_file = tempfile::NamedTempFile::new().unwrap(); - project_file.write_all(project_toml.as_bytes()).unwrap(); - - let cfg = - Config::load_from_paths(Some(user_file.path()), Some(project_file.path())).unwrap(); - - assert_eq!(cfg.cloud.org.as_deref(), Some("project-org")); - assert_eq!(cfg.cloud.api_url, "https://user.api"); - assert_eq!(cfg.preferences.format, "json"); - } - - #[test] - fn backend_display_matches_wire_strings() { - assert_eq!(Backend::Docker.to_string(), "docker"); - assert_eq!(Backend::Cloud.to_string(), "cloud"); - } - - #[test] - fn backend_defaults_docker_and_parses_and_layers() { - let _g = env_guard(); - // default - assert_eq!(Config::default().backend, Backend::Docker); - - // user file sets cloud; project file sets docker -> project wins. - let mut user_file = tempfile::NamedTempFile::new().unwrap(); - user_file.write_all(br#"backend = "cloud""#).unwrap(); - - let mut project_file = tempfile::NamedTempFile::new().unwrap(); - project_file.write_all(br#"backend = "docker""#).unwrap(); - - let cfg = - Config::load_from_paths(Some(user_file.path()), Some(project_file.path())).unwrap(); - assert_eq!(cfg.backend, Backend::Docker); - - // user file alone parses "cloud". - let cfg_user = Config::load_from_paths(Some(user_file.path()), None).unwrap(); - assert_eq!(cfg_user.backend, Backend::Cloud); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn save_and_reload_roundtrip() { - let _g = env_guard(); - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("config.toml"); - let cfg = Config { - cloud: CloudConfig { - org: Some("saved-org".into()), - pipeline: Some("saved-org/web".into()), - ..CloudConfig::default() - }, - ..Config::default() - }; - cfg.save_to(&path).unwrap(); - - let loaded = Config::load_from_paths(Some(&path), None).unwrap(); - assert_eq!(loaded.cloud.org.as_deref(), Some("saved-org")); - assert_eq!(loaded.cloud.pipeline.as_deref(), Some("saved-org/web")); - assert_eq!(loaded.cloud.api_url, DEFAULT_API_URL); - assert_eq!(loaded.preferences.format, "human"); - } - - #[test] - #[allow(clippy::result_large_err)] // figment::Error is the Jail closure's error type. - fn hm_env_overrides_cloud_keys() { - let _g = env_guard(); - // `Jail` isolates env mutation from concurrently-running tests. - figment::Jail::expect_with(|jail| { - jail.set_env("HM_ORG", "env-org"); - jail.set_env("HM_API_URL", "https://env.api"); - - let cfg = Config::load_from_paths(None, None).unwrap(); - assert_eq!(cfg.cloud.org.as_deref(), Some("env-org")); - assert_eq!(cfg.cloud.api_url, "https://env.api"); - Ok(()) - }); - } - - #[test] - #[allow(clippy::result_large_err)] // figment::Error is the Jail closure's error type. - fn hm_env_overrides_user_file() { - let _g = env_guard(); - // Env is the highest-precedence layer: it wins over a user file. - figment::Jail::expect_with(|jail| { - jail.set_env("HM_ORG", "env-org"); - - jail.create_file( - "config.toml", - "[cloud]\norg = \"file-org\"\napi_url = \"https://file.api\"\n", - )?; - let user = jail.directory().join("config.toml"); - - let cfg = Config::load_from_paths(Some(&user), None).unwrap(); - assert_eq!(cfg.cloud.org.as_deref(), Some("env-org")); - // Unset env keys still come from the file. - assert_eq!(cfg.cloud.api_url, "https://file.api"); - Ok(()) - }); - } - - #[test] - fn figment_missing_files_still_resolve() { - let _g = env_guard(); - let nonexistent_user = Path::new("/tmp/harmont-test-nonexistent-user/config.toml"); - let nonexistent_project = Path::new("/tmp/harmont-test-nonexistent-project/config.toml"); - - let cfg = - Config::load_from_paths(Some(nonexistent_user), Some(nonexistent_project)).unwrap(); - - assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); - assert!(cfg.cloud.org.is_none()); - assert_eq!(cfg.preferences.format, "human"); - assert!(!cfg.preferences.auto_watch); - } -} diff --git a/crates/hm-core/Cargo.toml b/crates/hm-core/Cargo.toml new file mode 100644 index 00000000..9beba055 --- /dev/null +++ b/crates/hm-core/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "hm-core" +version = "0.0.0-dev" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Core system types (workspace, credentials, process identity) for the hm CLI." + +[lib] +path = "lib.rs" + +[dependencies] +hm-config = { workspace = true } +hm-util = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +toml = "0.8" +secrecy = "0.10.3" +nix = { version = "0.30", default-features = false, features = ["user"] } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/hm-core/lib.rs b/crates/hm-core/lib.rs new file mode 100644 index 00000000..c1a4cf21 --- /dev/null +++ b/crates/hm-core/lib.rs @@ -0,0 +1,7 @@ +//! Core system types for the hm CLI (process identity, credentials, …). + +pub mod sys; +pub mod workspace; + +pub use sys::{LoadingError as SysLoadingError, Sys}; +pub use workspace::{LoadError as WorkspaceLoadError, Workspace}; diff --git a/crates/hm-core/sys.rs b/crates/hm-core/sys.rs new file mode 100644 index 00000000..80b0f35c --- /dev/null +++ b/crates/hm-core/sys.rs @@ -0,0 +1,113 @@ +//! Global utility for fetching system-relevant information on `hm`. +//! +//! [`Sys`] is the process-level handle: credentials (and later system config) +//! for the invoking user. Project-scoped state lives elsewhere (workspace). +//! +//! We define the system to mean the scope of the current user. This includes the user's +//! configuration directory, etc. + +pub mod creds; +pub mod env; + +use std::io; +use std::path::PathBuf; + +use hm_util::path::{AbsPath, AbsPathBuf}; +use thiserror::Error; + +use creds::Creds; + +/// Failure loading process-level [`Sys`] state. +#[derive(Debug, Error)] +pub enum LoadingError { + /// Platform config directory could not be resolved. + #[error("could not determine config directory")] + ConfigDirUnavailable, + + /// Failed to create the config directory. + #[error("failed to create {}: {source}", .path.display())] + CreateDir { + path: PathBuf, + #[source] + source: io::Error, + }, + + /// Credentials file failed security checks or parse. + /// + /// Transparent: `#[from]` already makes this a `#[source]`, so wrapping the + /// text here too would print the inner message twice. + #[error(transparent)] + Creds(#[from] creds::LoadingError), +} + +/// Process-level hm state (credentials, …). +#[derive(Debug)] +pub struct Sys { + /// Absolute path to `~/.config/hm` (user config root). + hm_dir: AbsPathBuf, + + creds: Creds, +} + +impl Sys { + /// Return the current user name (best-effort). + /// + /// Prefers `$USER`, then `$LOGNAME`, then a `uid N` fallback so ownership + /// error messages always have something printable. + #[must_use] + pub fn whoami() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_else(|_| format!("uid {}", Self::whoami_id())) + } + + /// Return the current real user id. + #[must_use] + pub fn whoami_id() -> u32 { + nix::unistd::Uid::current().as_raw() + } + + /// Absolute path to the `~/.config/hm` directory. + #[must_use] + pub fn hm_dir(&self) -> AbsPath<'_> { + self.hm_dir.as_abs_path() + } + + /// Load process-level system state (credentials, …). + /// + /// Ensures `~/.config/hm` exists, then loads `credentials.toml` from it. + /// + /// # Errors + /// + /// Returns [`LoadingError`] when the config directory cannot be resolved or + /// created, or credentials fail to load. + pub fn load() -> Result { + let hm_dir = hm_util::dirs::hm_config_dir() + .and_then(AbsPathBuf::new) + .ok_or(LoadingError::ConfigDirUnavailable)?; + + if !hm_dir.exists() { + std::fs::create_dir_all(hm_dir.as_abs_path().as_path()).map_err(|source| { + LoadingError::CreateDir { + path: hm_dir.clone().into_path_buf(), + source, + } + })?; + } + + let creds_path = hm_dir.join("credentials.toml"); + let creds = Creds::load(creds_path.as_abs_path().as_path())?; + Ok(Self { hm_dir, creds }) + } + + /// Loaded credentials. + #[must_use] + pub const fn creds(&self) -> &Creds { + &self.creds + } + + /// Mutable access to credentials (for `set` / `remove`). + pub const fn creds_mut(&mut self) -> &mut Creds { + &mut self.creds + } +} diff --git a/crates/hm-core/sys/creds.rs b/crates/hm-core/sys/creds.rs new file mode 100644 index 00000000..0a66237d --- /dev/null +++ b/crates/hm-core/sys/creds.rs @@ -0,0 +1,187 @@ +//! Credentials to access the Harmont cloud. +//! +//! The cloud issues a single **user-scoped** bearer: `claim_token` / +//! `redeem_code` return one token, and it lists every organization the user +//! belongs to. So there is exactly one credential to store, not a map. +//! +//! A second API base (localhost, staging) is served by the `HM_API_TOKEN` +//! environment variable, which overrides the stored value on resolve. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::sys; + +use super::Sys; + +/// Failure loading a credentials file. +#[derive(Debug, Error)] +pub enum LoadingError { + /// File exists but is owned by someone other than the current user. + #[error( + "{user} does not own {path}. In order for this to be safe, {user} must own {path}", + user = .0, + path = .1.display() + )] + FileNotOwned(String, PathBuf), + + /// File exists but is not mode `0o600`. + #[error( + "{path} is not chmod 600. In order for this to be secure, {path} must be chmod 0o600", + path = .0.display() + )] + FileNotExclusivelyOwned(PathBuf), + + /// Credentials file could not be read. + #[error("failed to read {}: {source}", .path.display())] + Read { + path: PathBuf, + #[source] + source: io::Error, + }, + + /// Credentials file was not valid TOML / shape. + #[error("failed to parse {}: {source}", .path.display())] + Parse { + path: PathBuf, + #[source] + source: toml::de::Error, + }, +} + +/// On-disk TOML shape: a plain string (serde). +#[derive(Debug, Default, Serialize, Deserialize)] +struct CredsDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + token: Option, +} + +impl From<&Creds> for CredsDto { + fn from(creds: &Creds) -> Self { + Self { + token: creds + .token + .as_ref() + .map(|s| s.expose_secret().to_owned()), + } + } +} + +/// The stored Harmont Cloud bearer token. +/// +/// Remembers the path it was loaded from so [`Self::set`] / [`Self::clear`] +/// can flush. +#[derive(Debug, Clone)] +pub struct Creds { + /// Path used for flush (the credentials file, which may not exist yet). + path: PathBuf, + /// The stored bearer, if the user has logged in. + token: Option, +} + +impl Creds { + /// Load credentials from `path`. + /// + /// If the file does not exist, returns empty credentials bound to that + /// path (so a later [`Self::set`] can create it). If the file exists, it + /// must pass ownership and mode checks before being parsed. + /// + /// # Errors + /// + /// Returns [`LoadingError`] when the file is insecure, unreadable, or + /// malformed. + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self { + path: path.to_path_buf(), + token: None, + }); + } + + Self::validate_secure(path)?; + + let contents = fs::read_to_string(path).map_err(|source| LoadingError::Read { + path: path.to_path_buf(), + source, + })?; + let dto: CredsDto = toml::from_str(&contents).map_err(|source| LoadingError::Parse { + path: path.to_path_buf(), + source, + })?; + + Ok(Self { + path: path.to_path_buf(), + token: dto.token.map(SecretString::from), + }) + } + + /// Resolve the bearer token. + /// + /// Priority: a non-empty `HM_API_TOKEN` environment variable, then the + /// stored value. Returns an owned [`SecretString`] so the env override + /// does not need a place in the store. + #[must_use] + pub fn token(&self) -> Option { + sys::env::hm_api_token() + .clone() + .or_else(|| self.token.clone()) + } + + /// Store the bearer and best-effort flush to disk. + /// + /// Flush failures are discarded (best-effort persist). + pub fn set(&mut self, secret: SecretString) { + self.token = Some(secret); + let _ = self.flush(); + } + + /// Drop the stored bearer and best-effort flush. + /// + /// Note this only clears the *stored* value; a `HM_API_TOKEN` in the + /// environment still wins on the next [`Self::token`]. + pub fn clear(&mut self) { + self.token = None; + let _ = self.flush(); + } + + /// Write the current state to the credentials path (mode `0o600`). + /// + /// Uses [`hm_util::os::fs::blocking::write_atomic_restricted`]; requires a + /// current tokio runtime. + fn flush(&self) -> io::Result<()> { + let dto = CredsDto::from(self); + let serialized = toml::to_string_pretty(&dto) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + hm_util::os::fs::blocking::write_atomic_restricted( + &self.path, + serialized.as_bytes(), + hm_util::os::fs::FileMode(0o600), + hm_util::os::fs::DirMode(0o700), + ) + } + + /// Ensure `path` is owned by the current user and mode `0o600`. + fn validate_secure(path: &Path) -> Result<(), LoadingError> { + use std::os::unix::fs::MetadataExt; + + let meta = fs::metadata(path).map_err(|source| LoadingError::Read { + path: path.to_path_buf(), + source, + })?; + + if meta.uid() != Sys::whoami_id() { + return Err(LoadingError::FileNotOwned(Sys::whoami(), path.to_path_buf())); + } + + if meta.mode() & 0o777 != 0o600 { + return Err(LoadingError::FileNotExclusivelyOwned(path.to_path_buf())); + } + + Ok(()) + } +} diff --git a/crates/hm-core/sys/env.rs b/crates/hm-core/sys/env.rs new file mode 100644 index 00000000..db1cd49f --- /dev/null +++ b/crates/hm-core/sys/env.rs @@ -0,0 +1,22 @@ +use std::sync::OnceLock; + +use secrecy::SecretString; + +static HM_API_TOKEN: OnceLock> = OnceLock::new(); + +/// The `HM_API_TOKEN` override, if set to a non-empty value. +/// +/// An empty `HM_API_TOKEN` is treated as unset: CI commonly exports an unset +/// secret as the empty string, and honoring it would send an empty bearer +/// (a 401) instead of falling back to the stored credential. +/// +/// Read once per process — `hm` is a CLI, so the environment does not change +/// under us. +pub fn hm_api_token() -> &'static Option { + HM_API_TOKEN.get_or_init(|| { + std::env::var("HM_API_TOKEN") + .ok() + .filter(|t| !t.is_empty()) + .map(SecretString::from) + }) +} diff --git a/crates/hm-core/workspace.rs b/crates/hm-core/workspace.rs new file mode 100644 index 00000000..17c57e86 --- /dev/null +++ b/crates/hm-core/workspace.rs @@ -0,0 +1,97 @@ +//! Global utility for managing the workspace. + +use std::path::{Path, PathBuf}; + +use hm_config::Config; +use hm_util::path::{AbsPath, AbsPathBuf}; +use thiserror::Error; + +/// Failure loading a directory as a [`Workspace`]. +#[derive(Debug, Error)] +pub enum LoadError { + #[error("{} does not appear to be a valid path", .0.display())] + InvalidPath(PathBuf), + #[error("{} is not a harmont workspace. harmont workspaces must have a `.hm/` directory", .0.display())] + InvalidWorkspace(PathBuf), + #[error(transparent)] + Config(#[from] hm_config::LoadError), +} + +/// Utility for accessing well-known paths inside a workspace. +/// +/// Construct with [`Workspace::load`] on a **specific** project root (the +/// directory that contains `.hm/`). Callers that need parent-directory +/// discovery should walk first (e.g. [`hm_util::dirs::find_project_root`]) +/// and only then call [`Workspace::load`]. +#[derive(Debug)] +pub struct Workspace { + /// Absolute path to the `.hm` directory. + hm_dir: AbsPathBuf, + + /// The loaded configuration for this workspace. + /// + /// Note that this will also pull in the user config as required. In other words, this will + /// include the `~/.config/hm/config.toml` configuration as well as the overlayed workspace + /// `.hm/config.toml`. + config: Config, +} + +impl Workspace { + /// Attempt to load the given directory as a workspace, if it appears to be one. + /// + /// We label a workspace any directory which has a `.hm` directory within it. + /// This does **not** walk parent directories; pass the project root itself. + /// + /// # Errors + /// + /// Returns [`LoadError`] when the path is missing/not absolute, has no + /// `.hm/` directory, or the layered config cannot be loaded. + pub fn load(workspace_path: &Path) -> Result { + if !workspace_path.exists() { + return Err(LoadError::InvalidPath(workspace_path.to_path_buf())); + } + + let hm_dir = workspace_path.join(".hm"); + if !hm_dir.is_dir() { + return Err(LoadError::InvalidWorkspace(workspace_path.to_path_buf())); + } + + let hm_dir = AbsPathBuf::new(hm_dir) + .ok_or_else(|| LoadError::InvalidPath(workspace_path.to_path_buf()))?; + let config = Config::load(Some(&hm_dir.join("config.toml")))?; + Ok(Self { hm_dir, config }) + } + + /// Absolute path to the workspace root (parent of `.hm/`). + #[must_use] + pub fn path(&self) -> AbsPath<'_> { + self.hm_dir + .as_abs_path() + .parent() + .expect("`.hm` always has a parent") + } + + /// Path to the `.hm` directory. + #[must_use] + pub fn hm_dir(&self) -> AbsPath<'_> { + self.hm_dir.as_abs_path() + } + + /// Layered configuration for this workspace (user + project + env). + #[must_use] + pub const fn config(&self) -> &Config { + &self.config + } + + /// Returns the path to the `.env` file at the top level of this workspace. + #[must_use] + pub fn env_file_path(&self) -> AbsPathBuf { + self.path().join(".env") + } + + /// Path to the `.hm/secrets` secrets file. + #[must_use] + pub fn secrets_path(&self) -> AbsPathBuf { + self.hm_dir().join("secrets") + } +} diff --git a/crates/hm-dsl-engine/harmont-py/harmont/__init__.py b/crates/hm-dsl-engine/harmont-py/harmont/__init__.py index 9448277f..af986347 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/__init__.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/__init__.py @@ -40,6 +40,7 @@ from ._pipeline import pipeline_to_json from ._python import python from ._rust import RustProject, rust +from ._secret import SecretRef, secrets from ._step import Step, scratch, wait from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests from ._toolchain import apt_base @@ -58,6 +59,7 @@ from .types import Pipeline if TYPE_CHECKING: + from collections.abc import Mapping from datetime import timedelta @@ -237,7 +239,7 @@ def sh( cwd: str | None = None, label: str | None = None, cache: CachePolicy | None = None, - env: dict[str, str] | None = None, + env: Mapping[str, str | SecretRef] | None = None, image: str | None = None, key: str | None = None, ) -> Step: @@ -309,6 +311,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: "JsProject", "Pipeline", "RustProject", + "SecretRef", "Step", "Target", "apt_base", @@ -330,6 +333,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: "python", "rust", "scratch", + "secrets", "sh", "target", "timeout", diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_decorator.py b/crates/hm-dsl-engine/harmont-py/harmont/_decorator.py index e9d77872..b33302c3 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_decorator.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_decorator.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from ._secret import SecretRef from .triggers import Trigger _SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$") @@ -33,7 +34,7 @@ def pipeline( name: str | None = None, triggers: tuple[Trigger, ...] | list[Trigger] = (), allow_manual: bool = True, - env: dict[str, str] | None = None, + env: dict[str, str | SecretRef] | None = None, timeout: str | int | None = None, ) -> Callable[[Callable[..., Any]], Callable[[], Any]]: """Register a function as a CI pipeline (decorator form). diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py b/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py index 38b25ea1..9a5e8ff5 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py @@ -15,6 +15,7 @@ from ._duration import parse_duration from ._keys import resolve_keys +from ._secret import SecretRef from .cache import ( CacheCompose, CacheForever, @@ -37,7 +38,7 @@ def pipeline( leaves: list[Step] | tuple[Step, ...], *, - env: dict[str, str] | None = None, + env: dict[str, str | SecretRef] | None = None, timeout: str | int | None = None, ) -> dict[str, Any]: """Top-level factory. Returns a JSON-shaped dict (version "0"). @@ -66,7 +67,7 @@ def pipeline( def _lower_to_graph( leaves: list[Step], *, - env: dict[str, str] | None = None, + env: dict[str, str | SecretRef] | None = None, ) -> dict[str, Any]: """Walk back via `parent`, topo-sort, emit petgraph-serde graph dict. @@ -131,7 +132,7 @@ def _lower_to_graph( step_dict["runner_args"] = s.runner_args # Baseline env for non-interactive operation inside VMs/containers. - merged_env: dict[str, str] = { + merged_env: dict[str, str | SecretRef] = { "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb", } @@ -140,7 +141,21 @@ def _lower_to_graph( if s.env: merged_env.update(s.env) - nodes.append({"step": step_dict, "env": merged_env}) + # Split the merged env: literal strings stay as env; SecretRefs become + # an env-var-name -> secret-name map resolved at run time. The + # node-level `secrets` map is authoritative; the step-level copy + # mirrors the same merged map so consumers that read either see it. + literal_env: dict[str, str] = {} + secret_refs: dict[str, str] = {} + for var, val in merged_env.items(): + if isinstance(val, SecretRef): + secret_refs[var] = val.name + else: + literal_env[var] = val + + step_dict["secrets"] = secret_refs + + nodes.append({"step": step_dict, "env": literal_env, "secrets": secret_refs}) # builds_in edge from parent. parent_key = _resolved_parent_key(s, keys) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_registry.py b/crates/hm-dsl-engine/harmont-py/harmont/_registry.py index 0eb5c7b8..5bf06f59 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_registry.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_registry.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from ._secret import SecretRef from .triggers import Trigger @@ -21,7 +22,7 @@ class PipelineRegistration: name: str triggers: tuple[Trigger, ...] allow_manual: bool - env: dict[str, str] | None + env: dict[str, str | SecretRef] | None fn: Callable[[], object] timeout: str | int | None = None diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_secret.py b/crates/hm-dsl-engine/harmont-py/harmont/_secret.py new file mode 100644 index 00000000..2e7851c9 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/harmont/_secret.py @@ -0,0 +1,40 @@ +"""Secret references for the Harmont pipeline SDK. + +`hm.secrets["NAME"]` returns a SecretRef — a *reference* to a secret, never its +value. The value is resolved at run time (locally from .env + the process +environment; in the cloud from the org/pipeline secret store). Use it anywhere +an env value is accepted: `hm.sh("deploy", env={"TOKEN": hm.secrets["DEPLOY_TOKEN"]})`. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +_SECRET_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +@dataclass(frozen=True) +class SecretRef: + """A reference to a stored secret, identified by name.""" + + name: str + + def __repr__(self) -> str: # never leaks a value — there is none to leak + return f"SecretRef({self.name!r})" + + +class _Secrets: + """Subscript accessor exposed as `hm.secrets`.""" + + def __getitem__(self, name: str) -> SecretRef: + if not isinstance(name, str) or not _SECRET_NAME.match(name): + msg = ( + f"secret name {name!r} is invalid: names must match " + r"[A-Za-z_][A-Za-z0-9_]* (letters, digits, underscores; no leading digit)." + ) + raise ValueError(msg) + return SecretRef(name) + + +secrets = _Secrets() diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_step.py b/crates/hm-dsl-engine/harmont-py/harmont/_step.py index 2fb8e090..b3aa4bff 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_step.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_step.py @@ -11,6 +11,9 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from collections.abc import Mapping + + from ._secret import SecretRef from .cache import CachePolicy @@ -33,7 +36,7 @@ class Step: continue_on_failure: bool = False label: str | None = None cache: CachePolicy | None = None - env: dict[str, str] | None = None + env: dict[str, str | SecretRef] | None = None timeout_seconds: int | None = None image: str | None = None """Local-mode Docker base image override for this step. Ignored when @@ -60,7 +63,7 @@ def sh( cwd: str | None = None, label: str | None = None, cache: CachePolicy | None = None, - env: dict[str, str] | None = None, + env: Mapping[str, str | SecretRef] | None = None, image: str | None = None, runner: str | None = None, runner_args: dict[str, Any] | None = None, @@ -115,7 +118,7 @@ def sh( parent=self, label=label, cache=cache, - env=env, + env=dict(env) if env is not None else None, image=effective_image, runner=runner, runner_args=runner_args, diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_secrets.py b/crates/hm-dsl-engine/harmont-py/tests/test_secrets.py new file mode 100644 index 00000000..49939634 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/tests/test_secrets.py @@ -0,0 +1,43 @@ +import json + +import harmont as hm + + +def _nodes(p): + return json.loads(hm.pipeline_to_json(p))["graph"]["nodes"] + + +def test_secret_ref_is_repr_safe(): + ref = hm.secrets["DEPLOY_TOKEN"] + assert isinstance(ref, hm.SecretRef) + assert ref.name == "DEPLOY_TOKEN" + assert "DEPLOY_TOKEN" in repr(ref) + + +def test_env_secret_ref_lowers_into_secrets_map(): + p = hm.pipeline( + [hm.sh("deploy", env={"TOKEN": hm.secrets["DEPLOY_TOKEN"], "CI": "true"})], + ) + node = _nodes(p)[0] + # NOTE: the current DSL also seeds baseline env (DEBIAN_FRONTEND, TERM). + # Assert our keys are present rather than equality on the whole dict. + assert node["env"]["CI"] == "true" + assert "TOKEN" not in node["env"] + assert node["secrets"] == {"TOKEN": "DEPLOY_TOKEN"} + assert node["step"]["secrets"] == {"TOKEN": "DEPLOY_TOKEN"} + + +def test_pipeline_level_secret_merges_under_step(): + p = hm.pipeline( + [hm.sh("deploy", env={"TOKEN": hm.secrets["PIPELINE_TOKEN"]})], + env={"TOKEN": hm.secrets["GLOBAL_TOKEN"], "SHARED": hm.secrets["SHARED_SECRET"]}, + ) + node = _nodes(p)[0] + assert node["secrets"] == {"TOKEN": "PIPELINE_TOKEN", "SHARED": "SHARED_SECRET"} + + +def test_invalid_secret_name_rejected(): + import pytest + + with pytest.raises(ValueError, match="secret name"): + hm.secrets["not valid!"] diff --git a/crates/hm-exec/Cargo.toml b/crates/hm-exec/Cargo.toml index a697b34b..13b5c150 100644 --- a/crates/hm-exec/Cargo.toml +++ b/crates/hm-exec/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true description = "Pluggable CI execution backends (local VM + cloud) for the hm CLI." [dependencies] +hm-core = { workspace = true } hm-plugin-protocol = { workspace = true } hm-pipeline-ir = { workspace = true } hm-util = { workspace = true } @@ -29,7 +30,10 @@ tar = "0.4" flate2 = "1" ignore = "0.4" tempfile = "3" +dotenvy = "0.15" daggy = { workspace = true } +secrecy = "0.10.3" +bytes = "1.12.1" [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/crates/hm-exec/src/cloud/backend.rs b/crates/hm-exec/src/cloud/backend.rs index 0ec75ccd..ddc1674a 100644 --- a/crates/hm-exec/src/cloud/backend.rs +++ b/crates/hm-exec/src/cloud/backend.rs @@ -36,7 +36,7 @@ pub struct CloudBackend { api_base: String, /// Dashboard (SPA) base used to build the human-clickable watch URL. This /// is the `app.` host, NOT `api.` — a link built from `api_base` lands on - /// raw JSON. Resolved via [`hm_config::app_url`] at the call site. + /// raw JSON. Resolved via `hm_config::app_url` at the call site. app_base: String, org: String, } diff --git a/crates/hm-exec/src/cloud/mod.rs b/crates/hm-exec/src/cloud/mod.rs index b4b0c491..7e8f528c 100644 --- a/crates/hm-exec/src/cloud/mod.rs +++ b/crates/hm-exec/src/cloud/mod.rs @@ -3,3 +3,4 @@ pub mod watch; // pub: hm-plugin-cloud's `build watch`/`job log` verbs reuse it mod backend; pub use backend::CloudBackend; +mod secrets; diff --git a/crates/hm-exec/src/cloud/secrets.rs b/crates/hm-exec/src/cloud/secrets.rs new file mode 100644 index 00000000..7727ad13 --- /dev/null +++ b/crates/hm-exec/src/cloud/secrets.rs @@ -0,0 +1,26 @@ +//! Secrets provider used when running harmont in the cloud. +use crate::domain::secrets; + +/// Secret provider which injects secrets when running in the cloud. +/// +/// Each step must announce which secrets it requires, and the cloud will only inject those secrets +/// into the executor. Note that no other process, except the executor, will be able to access the +/// secret, and you must **manually** inject the secret. +/// +/// A naive approach would be to inject secrets directly into the environment, but that risks +/// leaking environments to processes which may not deserve those secrets, so we avoid doing that +/// here. +#[derive(Debug, Clone)] +pub(crate) struct CloudProvider; + +impl secrets::Provider for CloudProvider { + fn get(&self, _secret: &secrets::KeyRef<'_>) -> Option<&secrets::Value> { + // TODO(markovejnovic): Implement + None + } + + fn list(&self) -> impl Iterator> { + // TODO(markovejnovic): Implement + std::iter::empty() + } +} diff --git a/crates/hm-exec/src/domain/mod.rs b/crates/hm-exec/src/domain/mod.rs new file mode 100644 index 00000000..4da7b077 --- /dev/null +++ b/crates/hm-exec/src/domain/mod.rs @@ -0,0 +1,2 @@ +//! Facilities for generic, shared definitions of hm-exec utilities. +pub(crate) mod secrets; diff --git a/crates/hm-exec/src/domain/secrets/mod.rs b/crates/hm-exec/src/domain/secrets/mod.rs new file mode 100644 index 00000000..f7c675ee --- /dev/null +++ b/crates/hm-exec/src/domain/secrets/mod.rs @@ -0,0 +1,68 @@ +//! Module which exposes executor operations on secrets. +use std::borrow::Borrow; + +use secrecy::{ExposeSecret, SecretString}; + +/// Key reference to a secret. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct KeyRef<'a>(&'a str); + +impl<'a> KeyRef<'a> { + pub(crate) fn new(s: &'a str) -> Self { + Self(s) + } + + pub(crate) fn as_str(&self) -> &str { + self.0 + } +} + +/// Stored key. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct KeyBuf(String); + +impl KeyBuf { + pub(crate) fn new(s: String) -> Self { + Self(s) + } +} + +impl AsRef for KeyBuf { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Borrow for KeyBuf { + fn borrow(&self) -> &str { + &self.0 + } +} + +/// Value reference to a secret. +#[derive(Debug, Clone)] +pub(crate) struct Value(SecretString); + +impl Value { + pub(crate) fn new(s: impl Into) -> Self { + Self(SecretString::from(s.into())) + } + + pub(crate) fn expose(&self) -> &str { + self.0.expose_secret() + } +} + +/// Collection of multiple secrets. +/// +/// Normally, this is associated with a step and is injected into a step. +pub(crate) struct Bundle; + +/// A generic provider which exposes secrets for different executors in harmont-cli. +pub(crate) trait Provider { + /// Retrieve the given secret by key. + fn get(&self, secret: &KeyRef<'_>) -> Option<&Value>; + + /// List all available key references. + fn list(&self) -> impl Iterator>; +} diff --git a/crates/hm-exec/src/lib.rs b/crates/hm-exec/src/lib.rs index 4072613f..4bb0e8b3 100644 --- a/crates/hm-exec/src/lib.rs +++ b/crates/hm-exec/src/lib.rs @@ -26,6 +26,8 @@ //! `HarmontClient` and injects it; `hm` owns credential loading. #![forbid(unsafe_code)] +mod domain; + mod error; pub use error::{BackendError, Result}; diff --git a/crates/hm-exec/src/local/cache.rs b/crates/hm-exec/src/local/cache.rs index 3d609db7..5be45d08 100644 --- a/crates/hm-exec/src/local/cache.rs +++ b/crates/hm-exec/src/local/cache.rs @@ -53,6 +53,7 @@ mod tests { cache, runner: None, runner_args: None, + secrets: None, } } diff --git a/crates/hm-exec/src/local/mod.rs b/crates/hm-exec/src/local/mod.rs index a9286543..4cb54d86 100644 --- a/crates/hm-exec/src/local/mod.rs +++ b/crates/hm-exec/src/local/mod.rs @@ -10,7 +10,9 @@ mod cache; mod events; pub mod runner; mod scheduler; +mod secret_resolver; mod source; +mod secrets; pub use backend::LocalBackend; pub(crate) use runner::RunnerRegistry; // intra-crate: local/backend.rs via crate::local:: diff --git a/crates/hm-exec/src/local/runner/vm.rs b/crates/hm-exec/src/local/runner/vm.rs index 325634fc..bb9caf31 100644 --- a/crates/hm-exec/src/local/runner/vm.rs +++ b/crates/hm-exec/src/local/runner/vm.rs @@ -66,7 +66,9 @@ impl StepRunner for VmRunner { } } -#[tracing::instrument(skip(vm, ctx), fields(step_key = %input.step.key))] +// `input` is skipped: its `env` map holds resolved secret values, which must +// never be Debug-recorded into a span field. Only the safe `step_key` is logged. +#[tracing::instrument(skip(vm, ctx, input), fields(step_key = %input.step.key))] async fn run_step_vm(vm: &HmVm, ctx: &StepContext, input: ExecutorInput) -> Result { let policy = match &input.cache_lookup { CacheDecision::Hit { tag } | CacheDecision::MissBuildAs { tag } => { diff --git a/crates/hm-exec/src/local/scheduler.rs b/crates/hm-exec/src/local/scheduler.rs index 5d49f8d9..2373e49c 100644 --- a/crates/hm-exec/src/local/scheduler.rs +++ b/crates/hm-exec/src/local/scheduler.rs @@ -42,6 +42,7 @@ use uuid::Uuid; use hm_pipeline_ir::{EdgeKind, PipelineGraph, Transition}; use crate::local::runner::{RunnerRegistry, StepContext}; +use crate::local::secret_resolver::{MissingSecret, SecretResolver, resolve_step_env}; use crate::local::source::build_archive_bytes; use crate::{BuildOutcome, BuildStatus, StepResultSummary, StepStatus}; @@ -134,6 +135,11 @@ pub(crate) async fn run( .map_err(|e| crate::BackendError::Local(format!("{e:#}")))?; let archive_id = archives.register(archive_bytes); + // Build the secret resolver once for the whole run, from the project's + // `.env` overlaid by the live process env. Shared across every concurrent + // step task; each step resolves its own `secrets` references against it. + let secret_resolver = Arc::new(SecretResolver::from_project_dir(&repo_root)); + let run_ctx = StepContext { event_bus: bus.clone(), archives: archives.clone(), @@ -186,6 +192,7 @@ pub(crate) async fn run( let bus = bus.clone(); let cancel = cancel.clone(); let run_ctx = run_ctx.clone(); + let secret_resolver = secret_resolver.clone(); let fut: StepFuture = async move { // Await all predecessors. @@ -244,6 +251,7 @@ pub(crate) async fn run( bus, cancel, keep_going, + secret_resolver, ) .await { @@ -386,7 +394,9 @@ async fn execute_step( bus: Arc, cancel: CancellationToken, keep_going: bool, + secret_resolver: Arc, ) -> anyhow::Result { + let secret_refs = transition.secrets; let step_wire = transition.step; let step_key = step_wire.key.clone(); let display_name = step_wire.label.clone().unwrap_or_else(|| { @@ -400,7 +410,6 @@ async fn execute_step( format!("{truncated}…") } }); - let env_map = transition.env; let step_id = Uuid::new_v4(); bus.emit(BuildEvent::StepQueued { @@ -411,6 +420,53 @@ async fn execute_step( display_name: display_name.clone(), }); + // Resolve secret references into the step's env before it reaches the + // container. A reference that can't be resolved is fatal: we fail the + // step (and, unless --keep-going, cancel siblings) with an actionable + // message rather than silently injecting an empty value. + let env_map = match resolve_step_env(transition.env, &secret_refs, &secret_resolver) { + Ok(env) => env, + Err(MissingSecret { + env_var, + secret_name, + }) => { + let message = format!( + "secret \"{secret_name}\" (referenced by env var \"{env_var}\") was not found.\n\ + Set it in a `.env` file in your project directory or export it in your shell, then re-run.\n \ + e.g. echo '{secret_name}=...' >> .env" + ); + bus.emit(BuildEvent::StepEnd { + step_id, + exit_code: 1, + duration_ms: 0, + snapshot: None, + }); + bus.emit(BuildEvent::ChainFailed { + chain_idx: chain_id, + failed_step_id: step_id, + failed_step_key: step_key.clone(), + exit_code: 1, + message, + ts: chrono::Utc::now(), + }); + if !keep_going { + cancel.cancel(); + } + return Ok(StepOutcome { + exit_code: 1, + snapshot: None, + summary: Some(StepResultSummary { + step_id, + key: step_key.clone(), + status: StepStatus::Failed, + exit_code: Some(1), + duration_ms: 0, + }), + failed_or_skipped: true, + }); + } + }; + // Compute the cache lookup for the runner. The runner (VmRunner) // handles cache hit/miss internally via ImageRegistry. let cache_tag = cache::stable_cache_tag(&step_wire); diff --git a/crates/hm-exec/src/local/secret_resolver.rs b/crates/hm-exec/src/local/secret_resolver.rs new file mode 100644 index 00000000..ffdfd773 --- /dev/null +++ b/crates/hm-exec/src/local/secret_resolver.rs @@ -0,0 +1,121 @@ +//! Local secret resolution for `hm run`. +//! +//! A pipeline node carries a `secrets` map (env-var-name -> secret-name) +//! alongside its literal `env`. For a local run those references are +//! resolved to values from a project `.env` file, overlaid by the live +//! process environment (process env wins). A reference that cannot be +//! resolved is a hard, fail-fast error — we never inject an empty value. + +use std::collections::BTreeMap; +use std::path::Path; + +/// Resolves secret references for a local `hm run` from a project `.env` file +/// overlaid by the process environment (process env wins). +#[derive(Debug, Clone)] +pub(crate) struct SecretResolver { + dotenv: BTreeMap, + proc_env: BTreeMap, +} + +/// A secret reference that could not be resolved from any source. +/// +/// Carries both the referencing env var and the missing secret name so the +/// surfaced error can name precisely what failed and how to fix it. +#[derive(Debug, Clone)] +pub(crate) struct MissingSecret { + pub env_var: String, + pub secret_name: String, +} + +impl SecretResolver { + /// Construct a resolver from explicit `.env` and process-env maps. + /// + /// Test-only constructor; production code builds via + /// [`SecretResolver::from_project_dir`]. + #[cfg(test)] + pub(crate) const fn new( + dotenv: BTreeMap, + proc_env: BTreeMap, + ) -> Self { + Self { dotenv, proc_env } + } + + /// Parse `/.env` if present; read the live process env. + /// + /// A missing or unreadable `.env` is not an error — secrets may all come + /// from the process environment. Malformed lines in `.env` are skipped + /// (via `flatten`) rather than aborting the run; a referenced secret that + /// ends up unresolved still fails fast at [`Self::resolve`]. + pub(crate) fn from_project_dir(dir: &Path) -> Self { + let mut dotenv = BTreeMap::new(); + let path = dir.join(".env"); + if path.exists() + && let Ok(iter) = dotenvy::from_path_iter(&path) + { + for (k, v) in iter.flatten() { + dotenv.insert(k, v); + } + } + Self { + dotenv, + proc_env: std::env::vars().collect(), + } + } + + /// Look up a secret by name. Process env wins over `.env`. + pub(crate) fn get(&self, name: &str) -> Option { + self.proc_env + .get(name) + .or_else(|| self.dotenv.get(name)) + .cloned() + } + + /// Resolve env-var -> secret-name refs into env-var -> value. Fails fast on + /// the first missing secret (`BTreeMap` iteration order is stable, so the + /// reported missing secret is deterministic). + /// + /// # Errors + /// Returns [`MissingSecret`] for the first reference whose secret name is + /// not found in either the process env or the `.env` file. + pub(crate) fn resolve( + &self, + refs: &BTreeMap, + ) -> Result, MissingSecret> { + let mut out = BTreeMap::new(); + for (env_var, secret_name) in refs { + match self.get(secret_name) { + Some(v) => { + out.insert(env_var.clone(), v); + } + None => { + return Err(MissingSecret { + env_var: env_var.clone(), + secret_name: secret_name.clone(), + }); + } + } + } + Ok(out) + } +} + +/// Merge resolved secrets into a step's literal env map. +/// +/// `env` is the step's already-merged literal environment; `secrets` is the +/// node's env-var -> secret-name reference map. On success the resolved +/// secret values are layered on top of `env` (a secret reference wins over a +/// same-named literal, matching the IR's "secrets resolved at run time" +/// contract) and the merged map is returned. +/// +/// # Errors +/// Returns a [`MissingSecret`] when any referenced secret is unresolved, so +/// the caller can abort the step rather than silently inject an empty value. +pub(crate) fn resolve_step_env( + mut env: BTreeMap, + secrets: &BTreeMap, + resolver: &SecretResolver, +) -> Result, MissingSecret> { + let values = resolver.resolve(secrets)?; + env.extend(values); + Ok(env) +} diff --git a/crates/hm-exec/src/local/secrets.rs b/crates/hm-exec/src/local/secrets.rs new file mode 100644 index 00000000..64e94097 --- /dev/null +++ b/crates/hm-exec/src/local/secrets.rs @@ -0,0 +1,53 @@ +//! Secrets provider used when running harmont locally. +use std::collections::BTreeMap; + +use hm_util::path::AbsPathBuf; + +use hm_core::Workspace; + +use crate::domain::secrets; + +/// Secret provider which fetches values from the local .env files. +/// +/// A future impleemntation could support some sort of local store, to support, perhaps `hm secrets +/// store` and `hm secrets load`. +#[derive(Debug, Clone)] +pub(crate) struct LocalProvider { + /// Stores the actual secrets locally. + secret_store: BTreeMap, +} + +impl LocalProvider { + /// Load the local provider given the path to the workspace. + /// + /// The local provider reads the workspace .env file and the `.hm/secrets` file. Values in the + /// latter override the values in the former. + pub(super) fn load(workspace: &Workspace) -> Self { + // We first try to load secrets from the .env file, and then we patch those with the + // .hm/secrets.toml file which takes priority. + let mut secret_store = BTreeMap::new(); + + let mut load_from_file = |path: AbsPathBuf| { + if path.exists() && let Ok(iter) = dotenvy::from_path_iter(&path) { + for (k, v) in iter.flatten() { + secret_store.insert(secrets::KeyBuf::new(k), secrets::Value::new(v)); + } + } + }; + + load_from_file(workspace.env_file_path()); + load_from_file(workspace.secrets_path()); + + Self { secret_store } + } +} + +impl secrets::Provider for LocalProvider { + fn get(&self, secret: &secrets::KeyRef<'_>) -> Option<&secrets::Value> { + self.secret_store.get(secret.as_str()) + } + + fn list(&self) -> impl Iterator> { + self.secret_store.keys().map(|k| secrets::KeyRef::new(k.as_ref())) + } +} diff --git a/crates/hm-pipeline-ir/src/graph.rs b/crates/hm-pipeline-ir/src/graph.rs index 8d9ad13a..84620a89 100644 --- a/crates/hm-pipeline-ir/src/graph.rs +++ b/crates/hm-pipeline-ir/src/graph.rs @@ -28,6 +28,10 @@ pub struct CommandStep { /// Per-step environment variables merged on top of the pipeline env. #[serde(default)] pub env: Option>, + /// Per-step secret references, merged on top of pipeline secrets. + /// Maps env-var-name -> secret-name. Values are resolved at run time, never here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secrets: Option>, /// Maximum wall-clock seconds before the step is killed. /// /// `NonZeroU32`: a `0`-second budget is rejected at the wire boundary. @@ -63,6 +67,9 @@ pub struct Cache { pub struct Transition { pub step: CommandStep, pub env: BTreeMap, + /// Merged secret references (pipeline + per-step). env-var-name -> secret-name. + #[serde(default)] + pub secrets: BTreeMap, } /// Edge label in the pipeline DAG. diff --git a/crates/hm-pipeline-ir/tests/graph_serde.rs b/crates/hm-pipeline-ir/tests/graph_serde.rs index 52251dbc..79d944fe 100644 --- a/crates/hm-pipeline-ir/tests/graph_serde.rs +++ b/crates/hm-pipeline-ir/tests/graph_serde.rs @@ -23,8 +23,10 @@ fn transition_round_trips() { cache: None, runner: None, runner_args: None, + secrets: None, }, env: BTreeMap::from([("FOO".into(), "bar".into())]), + secrets: BTreeMap::new(), }; let json = serde_json::to_string(&nw).unwrap(); let back: Transition = serde_json::from_str(&json).unwrap(); @@ -32,6 +34,36 @@ fn transition_round_trips() { assert_eq!(back.env.get("FOO").unwrap(), "bar"); } +#[test] +fn command_step_round_trips_secrets() { + let json = r#"{ + "key": "deploy", + "cmd": "./deploy.sh", + "env": { "CI": "true" }, + "secrets": { "TOKEN": "DEPLOY_TOKEN" } + }"#; + let step: hm_pipeline_ir::CommandStep = serde_json::from_str(json).unwrap(); + let secrets = step.secrets.expect("secrets present"); + assert_eq!(secrets.get("TOKEN").map(String::as_str), Some("DEPLOY_TOKEN")); + + // Absent secrets stays None and is omitted on re-serialize. + let bare: hm_pipeline_ir::CommandStep = + serde_json::from_str(r#"{"key":"k","cmd":"c"}"#).unwrap(); + assert!(bare.secrets.is_none()); + assert!(!serde_json::to_string(&bare).unwrap().contains("secrets")); +} + +#[test] +fn transition_round_trips_secrets() { + let json = r#"{ + "step": { "key": "deploy", "cmd": "./deploy.sh" }, + "env": {}, + "secrets": { "TOKEN": "DEPLOY_TOKEN" } + }"#; + let t: hm_pipeline_ir::Transition = serde_json::from_str(json).unwrap(); + assert_eq!(t.secrets.get("TOKEN").map(String::as_str), Some("DEPLOY_TOKEN")); +} + #[test] fn edge_kind_serializes_as_snake_case() { assert_eq!( diff --git a/crates/hm-pipeline-ir/tests/snapshots/graph_serde__pipeline_graph_snapshot.snap b/crates/hm-pipeline-ir/tests/snapshots/graph_serde__pipeline_graph_snapshot.snap index 4a02d56c..3e0cc9e5 100644 --- a/crates/hm-pipeline-ir/tests/snapshots/graph_serde__pipeline_graph_snapshot.snap +++ b/crates/hm-pipeline-ir/tests/snapshots/graph_serde__pipeline_graph_snapshot.snap @@ -18,6 +18,7 @@ expression: json "nodes": [ { "env": {}, + "secrets": {}, "step": { "cache": null, "cmd": "echo a", @@ -30,6 +31,7 @@ expression: json }, { "env": {}, + "secrets": {}, "step": { "cache": null, "cmd": "echo b", @@ -42,6 +44,7 @@ expression: json }, { "env": {}, + "secrets": {}, "step": { "cache": null, "cmd": "echo c", diff --git a/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap b/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap index 83725012..16b2c4b3 100644 --- a/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap +++ b/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap @@ -47,6 +47,16 @@ expression: schema "type": "string" } }, + "secrets": { + "description": "Per-step secret references, merged on top of pipeline secrets. Maps env-var-name -> secret-name. Values are resolved at run time, never here.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, "timeout_seconds": { "description": "Maximum wall-clock seconds before the step is killed.\n\n`NonZeroU32`: a `0`-second budget is rejected at the wire boundary.", "default": null, diff --git a/crates/hm-plugin-cloud/Cargo.toml b/crates/hm-plugin-cloud/Cargo.toml index 97d20a07..65e35fa7 100644 --- a/crates/hm-plugin-cloud/Cargo.toml +++ b/crates/hm-plugin-cloud/Cargo.toml @@ -14,9 +14,11 @@ path = "src/lib.rs" [dependencies] harmont-cloud = { workspace = true } harmont-cloud-raw = { workspace = true } +serde = { workspace = true } hm-exec = { workspace = true } hm-render = { workspace = true } hm-config = { workspace = true } +hm-core = { workspace = true } hm-plugin-protocol = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } @@ -24,10 +26,16 @@ uuid = { workspace = true } clap = { version = "4", features = ["derive"] } anyhow = "1" base64 = "0.22" +secrecy = "0.10.3" +percent-encoding = "2.3" tokio = { version = "1", features = ["net", "rt", "time", "sync", "macros"] } webbrowser = "1" dialoguer = "0.11" tracing = { workspace = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2", "json"] } + +[dev-dependencies] +tempfile = "3" [lints] workspace = true diff --git a/crates/hm-plugin-cloud/src/auth/login.rs b/crates/hm-plugin-cloud/src/auth/login.rs index e148e720..4082fd06 100644 --- a/crates/hm-plugin-cloud/src/auth/login.rs +++ b/crates/hm-plugin-cloud/src/auth/login.rs @@ -13,8 +13,9 @@ use std::collections::BTreeMap; use std::time::Duration; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use harmont_cloud::{HarmontClient, HarmontError}; +use secrecy::SecretString; use crate::settings; @@ -28,7 +29,10 @@ pub(crate) async fn run(env: &BTreeMap, paste: bool) -> Result<( login_loopback(&client, &app).await? }; - hm_config::creds::set_cloud_token(&api, &token); + hm_core::Sys::load() + .context("loading credentials")? + .creds_mut() + .set(SecretString::from(token.clone())); // Confirm by reading back the authenticated user. let authed = HarmontClient::with_base_url(token, &api); diff --git a/crates/hm-plugin-cloud/src/auth/logout.rs b/crates/hm-plugin-cloud/src/auth/logout.rs index 9cd5d788..7999c83f 100644 --- a/crates/hm-plugin-cloud/src/auth/logout.rs +++ b/crates/hm-plugin-cloud/src/auth/logout.rs @@ -2,13 +2,16 @@ use std::collections::BTreeMap; -use anyhow::Result; +use anyhow::{Context, Result}; use crate::settings; pub(crate) async fn run(_env: &BTreeMap) -> Result<()> { let (_client, api) = settings::anon_client()?; - hm_config::creds::forget_cloud_token(&api); + hm_core::Sys::load() + .context("loading credentials")? + .creds_mut() + .clear(); tracing::info!("logged out of {api}"); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/cli.rs b/crates/hm-plugin-cloud/src/cli.rs index bb2b519a..37167cd6 100644 --- a/crates/hm-plugin-cloud/src/cli.rs +++ b/crates/hm-plugin-cloud/src/cli.rs @@ -68,10 +68,46 @@ pub enum CloudCommand { /// Manage credits, top-ups, and usage. #[command(subcommand)] Billing(BillingCommand), + /// Manage org- and pipeline-scoped CI secrets. + #[command(subcommand)] + Secret(SecretCommand), /// Submit the local pipeline to the cloud and watch its build. Run(verbs::run::RunArgs), } +#[derive(Debug, Clone, Subcommand)] +pub enum SecretCommand { + /// Set (create or overwrite) a secret. The value is write-only — the + /// API never returns it, so neither does this CLI. + Set { + /// Secret name (e.g. `DEPLOY_TOKEN`). + name: String, + /// The secret value. Pass `-` to read the value from stdin. + /// Omit when using `--from-file`. + value: Option, + /// Read the value from a file (a single trailing newline is trimmed). + #[arg(long, value_name = "PATH")] + from_file: Option, + /// Scope to a pipeline instead of the whole organization. + #[arg(long, value_name = "SLUG")] + pipeline: Option, + }, + /// List secret names (and timestamps). Never prints values. + List { + /// Scope to a pipeline instead of the whole organization. + #[arg(long, value_name = "SLUG")] + pipeline: Option, + }, + /// Remove a secret. + Rm { + /// Secret name to remove. + name: String, + /// Scope to a pipeline instead of the whole organization. + #[arg(long, value_name = "SLUG")] + pipeline: Option, + }, +} + #[derive(Debug, Clone, Subcommand)] pub enum OrgCommand { /// Set the active organization. @@ -213,6 +249,7 @@ pub async fn dispatch_command(command: CloudCommand, env: BTreeMap verbs::build::run(&env, cmd).await, CloudCommand::Job(cmd) => verbs::job::run(&env, cmd).await, CloudCommand::Billing(cmd) => verbs::billing::run(&env, cmd).await, + CloudCommand::Secret(cmd) => verbs::secret::run(&env, cmd).await, CloudCommand::Run(args) => verbs::run::run(&env, args).await, }; match result { diff --git a/crates/hm-plugin-cloud/src/settings.rs b/crates/hm-plugin-cloud/src/settings.rs index d3a2a2bc..598a79da 100644 --- a/crates/hm-plugin-cloud/src/settings.rs +++ b/crates/hm-plugin-cloud/src/settings.rs @@ -1,18 +1,31 @@ //! Cloud client builders for the `hm cloud` verbs. //! -//! Config and credentials are owned by the shared [`hm_config`] crate: +//! Config and credentials come from two shared crates: //! -//! - layered config (user `~/.config/hm/config.toml` + project -//! `.hm/config.toml` + `HM_*` env) supplies the API base -//! (`cloud.api_url`) and the active org (`cloud.org`); -//! - bearer tokens live in `hm_config::creds`, keyed by API base, with -//! `HM_API_TOKEN` taking precedence. +//! - [`hm_config`] supplies layered config (user `~/.config/hm/config.toml` + +//! project `.hm/config.toml` + `HM_*` env): the API base (`cloud.api_url`) +//! and the active org (`cloud.org`); +//! - [`hm_core::Sys`] owns the single bearer token, with `HM_API_TOKEN` taking +//! precedence. //! //! This module only assembles an SDK client from that config; it does not own //! any config or credential storage of its own. use anyhow::{Context, Result}; use harmont_cloud::HarmontClient; +use secrecy::{ExposeSecret, SecretString}; + +/// Resolve the bearer token, or the shared "not logged in" error. +/// +/// Every authenticated path routes through here so the not-logged-in text +/// lives in one place. +fn token() -> Result { + hm_core::Sys::load() + .context("loading credentials")? + .creds() + .token() + .context("not logged in — run `hm cloud login` or set HM_API_TOKEN") +} /// Resolved cloud context for the `hm cloud` verbs. #[derive(Debug, Clone)] @@ -45,9 +58,7 @@ impl ResolvedCtx { pub fn client() -> Result<(HarmontClient, ResolvedCtx)> { let cfg = hm_config::Config::load(None).context("loading config")?; // user + env layering let api = cfg.cloud.api_url.clone(); - let token = hm_config::creds::cloud_token(&api) - .context("not logged in — run `hm cloud login` or set HM_API_TOKEN")?; - let client = HarmontClient::with_base_url(token, &api); + let client = HarmontClient::with_base_url(token()?.expose_secret(), &api); Ok(( client, ResolvedCtx { @@ -57,6 +68,30 @@ pub fn client() -> Result<(HarmontClient, ResolvedCtx)> { )) } +/// Resolve `(api base, bearer token, org slug)` for verbs that make raw +/// HTTP calls instead of going through the generated SDK client (e.g. the +/// secret verbs, whose endpoints the generated client doesn't carry yet). +/// +/// Shares the exact config/credential/org resolution — and the +/// not-logged-in and org-missing error strings — with [`client`] and +/// [`ResolvedCtx::org`], so those messages live in one place. +/// +/// # Errors +/// +/// Returns an error if config can't be loaded, no token is available, or no +/// organization is configured. +pub fn raw_org_ctx() -> Result<(String, SecretString, String)> { + let cfg = hm_config::Config::load(None).context("loading config")?; + let api = cfg.cloud.api_url.clone(); + let token = token()?; + let org = ResolvedCtx { + api: api.clone(), + org: cfg.cloud.org, + } + .org()?; + Ok((api, token, org)) +} + /// An anonymous client (for the login flow) + the resolved API base. /// /// # Errors diff --git a/crates/hm-plugin-cloud/src/verbs/mod.rs b/crates/hm-plugin-cloud/src/verbs/mod.rs index f8a9a693..d6e5c43c 100644 --- a/crates/hm-plugin-cloud/src/verbs/mod.rs +++ b/crates/hm-plugin-cloud/src/verbs/mod.rs @@ -8,3 +8,4 @@ pub(crate) mod job; pub(crate) mod org; pub(crate) mod pipeline; pub(crate) mod run; +pub(crate) mod secret; diff --git a/crates/hm-plugin-cloud/src/verbs/secret.rs b/crates/hm-plugin-cloud/src/verbs/secret.rs new file mode 100644 index 00000000..9a388d14 --- /dev/null +++ b/crates/hm-plugin-cloud/src/verbs/secret.rs @@ -0,0 +1,468 @@ +//! `hm cloud secret set|list|rm` — manage org- and pipeline-scoped CI +//! secrets. +//! +//! Secret *values* are write-only: the API never returns them, so `list` +//! shows names and timestamps only. The CLI mirrors that — it never prints +//! a value back, even the one it just set. +//! +//! The published `harmont-cloud` SDK does not (yet) expose secret +//! operations, so these verbs make the authenticated HTTP calls directly, +//! reusing the same config/credential resolution the SDK-backed verbs use +//! (`hm_config` for the API base + active org, `hm_core::Sys` for the bearer +//! token). No new auth stack — just a thin reqwest call for the endpoints the +//! generated client doesn't carry. + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use secrecy::{ExposeSecret, SecretString}; + +use crate::cli::SecretCommand; + +/// Where the secret value comes from on `set`. +/// +/// Exactly one source is allowed; the resolver rejects none and ambiguity. +#[derive(Debug, Clone)] +struct ValueSources<'a> { + /// The positional `VALUE` argument, if given. `Some("-")` means stdin. + positional: Option<&'a str>, + /// The `--from-file ` argument, if given. + from_file: Option<&'a Path>, +} + +/// Build the REST path for the secrets collection, scoped to the org or to +/// a pipeline within it. +/// +/// - org scope: `/api/v0/organizations/{org}/secrets` +/// - pipeline scope: `/api/v0/organizations/{org}/pipelines/{pipeline}/secrets` +fn secrets_path(org: &str, pipeline: Option<&str>) -> String { + match pipeline { + Some(p) => format!("/api/v0/organizations/{org}/pipelines/{p}/secrets"), + None => format!("/api/v0/organizations/{org}/secrets"), + } +} + +/// Append a URL-path-escaped secret name to a collection path. +fn secret_item_path(collection: &str, name: &str) -> String { + format!("{collection}/{}", utf8_percent_encode(name, NON_ALPHANUMERIC)) +} + +/// Resolve the secret value from exactly one of: positional `VALUE`, +/// `--from-file`, or stdin (`VALUE` == `-`). +/// +/// `read_stdin` is injected so the non-stdin branches are unit-testable; in +/// production it reads the real stdin. +/// +/// Trailing newline handling: a single trailing `\n` (or `\r\n`) is trimmed +/// from file and stdin sources, since editors and `echo` append one and a +/// secret almost never wants it. The positional `VALUE` is taken verbatim. +/// +/// # Errors +/// +/// Returns an error if no source is given, if both a positional value and +/// `--from-file` are given (ambiguous), or if the file/stdin read fails. +fn resolve_value( + sources: &ValueSources<'_>, + read_stdin: impl FnOnce() -> Result, +) -> Result { + match (sources.positional, sources.from_file) { + (Some(_), Some(_)) => bail!( + "ambiguous secret value: pass either VALUE or --from-file, not both\n \u{2192} drop one source" + ), + (None, None) => bail!( + "no secret value: pass VALUE, `-` to read stdin, or --from-file \n \u{2192} e.g. `hm cloud secret set TOKEN abc123` or `--from-file ./token.txt`" + ), + (Some("-"), None) => { + let raw = read_stdin().context("reading secret value from stdin")?; + Ok(trim_one_trailing_newline(&raw).to_string()) + } + (Some(v), None) => Ok(v.to_string()), + (None, Some(path)) => { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("reading secret value from {}", path.display()))?; + Ok(trim_one_trailing_newline(&raw).to_string()) + } + } +} + +/// Strip a single trailing `\n` or `\r\n`, leaving any other whitespace +/// intact (a secret may legitimately contain interior or leading spaces). +fn trim_one_trailing_newline(s: &str) -> &str { + s.strip_suffix('\n') + .map(|s| s.strip_suffix('\r').unwrap_or(s)) + .unwrap_or(s) +} + +/// Entry point dispatched from `cli::dispatch_command`. +pub(crate) async fn run(_env: &BTreeMap, cmd: SecretCommand) -> Result<()> { + // Share config/credential/org resolution — and its error strings — with + // the SDK-backed verbs (`settings::client` / `ResolvedCtx::org`). These + // verbs need the raw token + API base for direct reqwest calls, so they + // use `raw_org_ctx` rather than building an SDK client. + let (api, token, org) = crate::settings::raw_org_ctx()?; + + match cmd { + SecretCommand::Set { + name, + value, + from_file, + pipeline, + } => { + set( + &api, + &token, + &org, + pipeline.as_deref(), + &name, + value.as_deref(), + from_file.as_deref(), + ) + .await + } + SecretCommand::List { pipeline } => { + list(&api, &token, &org, pipeline.as_deref()).await + } + SecretCommand::Rm { name, pipeline } => { + rm(&api, &token, &org, pipeline.as_deref(), &name).await + } + } +} + +/// Build an authenticated reqwest client carrying the bearer token. +fn http_client(token: &SecretString) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + let mut auth = + reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token.expose_secret())) + .context("API token contains characters invalid for an Authorization header")?; + auth.set_sensitive(true); + headers.insert(reqwest::header::AUTHORIZATION, auth); + reqwest::Client::builder() + .default_headers(headers) + .build() + .context("building HTTP client") +} + +/// Turn a non-success response into a readable error in the house shape +/// (`\n → `), honoring the status semantics from PRINCIPLES.md. +/// +/// The cloud dispatcher (`cli::dispatch_command`) is anyhow-based and maps +/// every error to the generic runtime exit code, so this cannot itself +/// select the semantic auth/API exit codes (3/5). What it *can* do — and +/// does — is make the 401 message text match the existing not-logged-in +/// path (`settings::client` / `raw_org_ctx`) so an expired/absent token +/// reads consistently regardless of where it's detected, and add a `→` fix +/// line. See the module-level note and [`from_status`]. +async fn into_api_error(resp: reqwest::Response) -> anyhow::Error { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + from_status(status, &body) +} + +/// Pure status-to-error mapping, split out from [`into_api_error`] so it is +/// unit-testable without spinning up an HTTP server. +fn from_status(status: reqwest::StatusCode, body: &str) -> anyhow::Error { + use reqwest::StatusCode; + + let server_message = || { + let parsed: serde_json::Value = + serde_json::from_str(body).unwrap_or(serde_json::Value::Null); + let obj = parsed.get("error").cloned().unwrap_or(parsed); + obj.get("message") + .and_then(|m| m.as_str()) + .map(ToOwned::to_owned) + }; + + match status { + // Match the not-logged-in text used by `settings::raw_org_ctx` so an + // expired/rejected token reads the same wherever it surfaces. + StatusCode::UNAUTHORIZED => anyhow::anyhow!( + "not logged in — your token was rejected\n \u{2192} run `hm cloud login`" + ), + StatusCode::NOT_FOUND => anyhow::anyhow!( + "secret or scope not found\n \u{2192} check the secret name and `--pipeline` scope, or `hm cloud secret list`" + ), + _ => { + let detail = server_message().unwrap_or_else(|| { + if body.is_empty() { + status.as_str().to_owned() + } else { + body.to_owned() + } + }); + anyhow::anyhow!("API error ({}): {detail}", status.as_u16()) + } + } +} + +async fn set( + api: &str, + token: &SecretString, + org: &str, + pipeline: Option<&str>, + name: &str, + value: Option<&str>, + from_file: Option<&Path>, +) -> Result<()> { + let resolved = resolve_value( + &ValueSources { + positional: value, + from_file, + }, + read_stdin, + )?; + + let url = format!("{api}{}", secrets_path(org, pipeline)); + let client = http_client(token)?; + let resp = client + .post(&url) + .json(&serde_json::json!({ "name": name, "value": resolved })) + .send() + .await + .context("sending request")?; + if !resp.status().is_success() { + return Err(into_api_error(resp).await); + } + let scope = pipeline.map_or_else(|| format!("org {org}"), |p| format!("pipeline {p}")); + tracing::info!("set secret {name} ({scope})"); + Ok(()) +} + +async fn list(api: &str, token: &SecretString, org: &str, pipeline: Option<&str>) -> Result<()> { + #[derive(serde::Deserialize)] + struct Secret { + name: String, + updated_at: Option, + } + #[derive(serde::Deserialize)] + struct Listing { + secrets: Vec, + } + + let url = format!("{api}{}", secrets_path(org, pipeline)); + let client = http_client(token)?; + let resp = client.get(&url).send().await.context("sending request")?; + if !resp.status().is_success() { + return Err(into_api_error(resp).await); + } + let listing: Listing = resp.json().await.context("decoding response")?; + if listing.secrets.is_empty() { + tracing::info!("No secrets."); + return Ok(()); + } + for s in &listing.secrets { + match &s.updated_at { + Some(ts) => tracing::info!("{:<32} {ts}", s.name), + None => tracing::info!("{}", s.name), + } + } + Ok(()) +} + +async fn rm( + api: &str, + token: &SecretString, + org: &str, + pipeline: Option<&str>, + name: &str, +) -> Result<()> { + let collection = secrets_path(org, pipeline); + let url = format!("{api}{}", secret_item_path(&collection, name)); + let client = http_client(token)?; + let resp = client.delete(&url).send().await.context("sending request")?; + if !resp.status().is_success() { + return Err(into_api_error(resp).await); + } + tracing::info!("removed secret {name}"); + Ok(()) +} + +/// Read all of stdin to a `String`. +fn read_stdin() -> Result { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("reading stdin")?; + Ok(buf) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn path_org_scope() { + assert_eq!( + secrets_path("acme", None), + "/api/v0/organizations/acme/secrets" + ); + } + + #[test] + fn path_pipeline_scope() { + assert_eq!( + secrets_path("acme", Some("ci")), + "/api/v0/organizations/acme/pipelines/ci/secrets" + ); + } + + #[test] + fn item_path_appends_encoded_name() { + let coll = secrets_path("acme", None); + assert_eq!( + secret_item_path(&coll, "DEPLOY_TOKEN"), + "/api/v0/organizations/acme/secrets/DEPLOY_TOKEN" + ); + } + + #[test] + fn item_path_escapes_funny_names() { + let coll = secrets_path("acme", Some("ci")); + assert_eq!( + secret_item_path(&coll, "a/b c"), + "/api/v0/organizations/acme/pipelines/ci/secrets/a%2Fb%20c" + ); + } + + fn no_stdin() -> Result { + bail!("stdin must not be read in this branch") + } + + #[test] + fn resolve_positional_verbatim() { + let sources = ValueSources { + positional: Some("hunter2"), + from_file: None, + }; + assert_eq!(resolve_value(&sources, no_stdin).unwrap(), "hunter2"); + } + + #[test] + fn resolve_positional_keeps_interior_spaces() { + let sources = ValueSources { + positional: Some(" spaced value "), + from_file: None, + }; + // Positional is verbatim — no trimming at all. + assert_eq!( + resolve_value(&sources, no_stdin).unwrap(), + " spaced value " + ); + } + + #[test] + fn resolve_from_file_trims_one_trailing_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token.txt"); + std::fs::write(&path, "abc123\n").unwrap(); + let sources = ValueSources { + positional: None, + from_file: Some(&path), + }; + assert_eq!(resolve_value(&sources, no_stdin).unwrap(), "abc123"); + } + + #[test] + fn resolve_from_file_trims_crlf() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token.txt"); + std::fs::write(&path, "abc123\r\n").unwrap(); + let sources = ValueSources { + positional: None, + from_file: Some(&path), + }; + assert_eq!(resolve_value(&sources, no_stdin).unwrap(), "abc123"); + } + + #[test] + fn resolve_from_file_keeps_interior_newlines() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("key.pem"); + std::fs::write(&path, "line1\nline2\n").unwrap(); + let sources = ValueSources { + positional: None, + from_file: Some(&path), + }; + assert_eq!(resolve_value(&sources, no_stdin).unwrap(), "line1\nline2"); + } + + #[test] + fn resolve_stdin_dash_trims_trailing_newline() { + let sources = ValueSources { + positional: Some("-"), + from_file: None, + }; + let got = resolve_value(&sources, || Ok("from-stdin\n".to_string())).unwrap(); + assert_eq!(got, "from-stdin"); + } + + #[test] + fn resolve_errors_when_no_source() { + let sources = ValueSources { + positional: None, + from_file: None, + }; + let err = resolve_value(&sources, no_stdin).unwrap_err(); + assert!(err.to_string().contains("no secret value")); + } + + #[test] + fn resolve_errors_when_ambiguous() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token.txt"); + std::fs::write(&path, "abc").unwrap(); + let sources = ValueSources { + positional: Some("inline"), + from_file: Some(&path), + }; + let err = resolve_value(&sources, no_stdin).unwrap_err(); + assert!(err.to_string().contains("ambiguous")); + } + + #[test] + fn resolve_missing_file_errors() { + let sources = ValueSources { + positional: None, + from_file: Some(Path::new("/nonexistent/secret/path/xyz")), + }; + assert!(resolve_value(&sources, no_stdin).is_err()); + } + + #[test] + fn special_chars_are_encoded() { + let coll = secrets_path("acme", None); + assert_eq!( + secret_item_path(&coll, "DEPLOY-TOKEN_v2.0~rc"), + "/api/v0/organizations/acme/secrets/DEPLOY%2DTOKEN%5Fv2%2E0%7Erc" + ); + } + + #[test] + fn error_401_matches_not_logged_in_text() { + let err = from_status(reqwest::StatusCode::UNAUTHORIZED, ""); + let msg = err.to_string(); + // Consistent with the `settings::raw_org_ctx` not-logged-in path. + assert!(msg.contains("not logged in"), "got: {msg}"); + assert!(msg.contains("hm cloud login"), "got: {msg}"); + } + + #[test] + fn error_404_is_not_found_with_hint() { + let err = from_status(reqwest::StatusCode::NOT_FOUND, ""); + let msg = err.to_string(); + assert!(msg.contains("not found"), "got: {msg}"); + assert!(msg.contains('\u{2192}'), "expected a fix hint, got: {msg}"); + } + + #[test] + fn error_other_surfaces_status_and_server_message() { + let body = r#"{"error":{"message":"name already taken"}}"#; + let err = from_status(reqwest::StatusCode::CONFLICT, body); + let msg = err.to_string(); + assert!(msg.contains("409"), "got: {msg}"); + assert!(msg.contains("name already taken"), "got: {msg}"); + } +} diff --git a/crates/hm-plugin-protocol/tests/round_trip.rs b/crates/hm-plugin-protocol/tests/round_trip.rs index 0c0f763e..e8a189f1 100644 --- a/crates/hm-plugin-protocol/tests/round_trip.rs +++ b/crates/hm-plugin-protocol/tests/round_trip.rs @@ -36,6 +36,7 @@ fn executor_input_round_trip() { cache: None, runner: Some("docker".into()), runner_args: None, + secrets: None, }, workspace_archive_id: ArchiveId(Uuid::nil()), env: Default::default(), diff --git a/crates/hm-util/Cargo.toml b/crates/hm-util/Cargo.toml index 55ed84a6..da244b38 100644 --- a/crates/hm-util/Cargo.toml +++ b/crates/hm-util/Cargo.toml @@ -8,6 +8,7 @@ description = "Shared OS and filesystem utilities for Harmont crates." [dependencies] anyhow = { workspace = true } +derive_more = { workspace = true } dirs = "6" tempfile = "3" tokio = { version = "1", features = ["rt", "rt-multi-thread", "fs", "io-util"] } diff --git a/crates/hm-util/src/dirs.rs b/crates/hm-util/src/dirs.rs index ccdcebc7..2bbd7d88 100644 --- a/crates/hm-util/src/dirs.rs +++ b/crates/hm-util/src/dirs.rs @@ -14,12 +14,12 @@ use crate::os::dirs as platform; /// `~/.config/hm/` — user config root (`config.toml`, `credentials.toml`). pub fn hm_config_dir() -> Option { - platform::config_dir().map(|c| c.join("hm")) + platform::config_dir().map(|c| c.join("hm").into_path_buf()) } /// `~/.cache/hm/` — local build cache root (regenerable). pub fn hm_cache_dir() -> Option { - platform::cache_dir().map(|c| c.join("hm")) + platform::cache_dir().map(|c| c.join("hm").into_path_buf()) } /// `~/.cache/hm/workspaces/` — COW workspace cache root. diff --git a/crates/hm-util/src/lib.rs b/crates/hm-util/src/lib.rs index c5284c52..a950e2c7 100644 --- a/crates/hm-util/src/lib.rs +++ b/crates/hm-util/src/lib.rs @@ -1,2 +1,3 @@ pub mod dirs; pub mod os; +pub mod path; diff --git a/crates/hm-util/src/os/dirs.rs b/crates/hm-util/src/os/dirs.rs index 9027ad3a..31f22911 100644 --- a/crates/hm-util/src/os/dirs.rs +++ b/crates/hm-util/src/os/dirs.rs @@ -8,24 +8,24 @@ //! primitives consistent and our paths predictable; it is deliberate, not an //! oversight. Revisit only if honoring the XDG env vars becomes a real need. -use std::path::PathBuf; +use crate::path::AbsPathBuf; -pub(crate) fn home_dir() -> Option { - dirs::home_dir() +pub(crate) fn home_dir() -> Option { + dirs::home_dir().and_then(AbsPathBuf::new) } -pub(crate) fn config_dir() -> Option { +pub(crate) fn config_dir() -> Option { if cfg!(windows) { - dirs::config_dir() + dirs::config_dir().and_then(AbsPathBuf::new) } else { - home_dir().map(|h| h.join(".config")) + Some(home_dir()?.join(".config")) } } -pub(crate) fn cache_dir() -> Option { +pub(crate) fn cache_dir() -> Option { if cfg!(windows) { - dirs::cache_dir() + dirs::cache_dir().and_then(AbsPathBuf::new) } else { - home_dir().map(|h| h.join(".cache")) + Some(home_dir()?.join(".cache")) } } diff --git a/crates/hm-util/src/path/abs_path.rs b/crates/hm-util/src/path/abs_path.rs new file mode 100644 index 00000000..4acc1aac --- /dev/null +++ b/crates/hm-util/src/path/abs_path.rs @@ -0,0 +1,150 @@ +//! A path that is guaranteed to be absolute. +//! +//! [`AbsPath`] / [`AbsPathBuf`] mirror the [`Path`] / [`PathBuf`] split from +//! `std`, adding the invariant that the path is absolute. + +use std::borrow::Borrow; +use std::ffi::OsStr; +use std::fmt; +use std::path::{Components, Path, PathBuf}; + +/// A borrowed path that is guaranteed to be absolute. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(derive_more::AsRef)] +#[as_ref(forward)] +pub struct AbsPath<'a>(&'a Path); + +impl<'a> AbsPath<'a> { + /// Wrap `path` if it is absolute, otherwise return `None`. + pub fn new + ?Sized>(path: &'a P) -> Option { + let p = path.as_ref(); + if p.is_absolute() { + Some(Self(p)) + } else { + None + } + } + + /// Join a path component onto this absolute path. + /// + /// The result is always absolute: if `tail` is absolute it replaces `self` + /// (same as [`Path::join`]); otherwise it is appended. + #[must_use] + pub fn join>(&self, tail: P) -> AbsPathBuf { + AbsPathBuf(self.0.join(tail)) + } + + /// Return the parent directory, if any, as an [`AbsPath`]. + #[must_use] + pub fn parent(&self) -> Option> { + self.0.parent().map(|p| Self(p)) + } + + /// The final component of this path, if any. + #[must_use] + pub fn file_name(&self) -> Option<&OsStr> { + self.0.file_name() + } + + /// An iterator over the components of this path. + #[must_use] + pub fn components(&self) -> Components<'_> { + self.0.components() + } + + /// Tests whether this path points to an existing entity on disk. + #[must_use] + pub fn exists(&self) -> bool { + self.0.exists() + } + + /// Tests whether this path points to a directory. + #[must_use] + pub fn is_dir(&self) -> bool { + self.0.is_dir() + } + + /// Tests whether this path points to a regular file. + #[must_use] + pub fn is_file(&self) -> bool { + self.0.is_file() + } + + /// Yield the underlying [`Path`]. + #[must_use] + pub fn as_path(&self) -> &Path { + self.0 + } + + /// Convert to an owned [`AbsPathBuf`]. + #[must_use] + pub fn to_abs_path_buf(&self) -> AbsPathBuf { + AbsPathBuf(self.0.to_path_buf()) + } +} + +impl fmt::Display for AbsPath<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0.display(), f) + } +} + +impl<'a> From<&'a AbsPathBuf> for AbsPath<'a> { + fn from(buf: &'a AbsPathBuf) -> Self { + buf.as_abs_path() + } +} + +/// An owned path that is guaranteed to be absolute. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(derive_more::Deref, derive_more::AsRef, derive_more::Into)] +#[deref(forward)] +#[as_ref(forward)] +pub struct AbsPathBuf(PathBuf); + +impl AbsPathBuf { + /// Wrap `path` if it is absolute, otherwise return `None`. + pub fn new(path: PathBuf) -> Option { + if path.is_absolute() { + Some(Self(path)) + } else { + None + } + } + + /// Join a path component onto this absolute path. + /// + /// The result is always absolute: if `tail` is absolute it replaces `self` + /// (same as [`Path::join`]); otherwise it is appended. + /// + /// This inherent method shadows [`Path::join`] from the [`Deref`] target so + /// the absolute invariant is preserved without re-wrapping. + #[must_use] + pub fn join>(&self, tail: P) -> AbsPathBuf { + AbsPathBuf(self.0.join(tail)) + } + + /// Borrow as an [`AbsPath`]. + #[must_use] + pub fn as_abs_path(&self) -> AbsPath<'_> { + AbsPath(self.0.as_path()) + } + + /// Consume and return the inner [`PathBuf`]. + #[must_use] + pub fn into_path_buf(self) -> PathBuf { + self.0 + } +} + +impl Borrow for AbsPathBuf { + fn borrow(&self) -> &Path { + self.0.as_path() + } +} + +impl fmt::Display for AbsPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0.display(), f) + } +} diff --git a/crates/hm-util/src/path/mod.rs b/crates/hm-util/src/path/mod.rs new file mode 100644 index 00000000..e9ab8da0 --- /dev/null +++ b/crates/hm-util/src/path/mod.rs @@ -0,0 +1,5 @@ +pub mod abs_path; +pub mod rel_path; + +pub use abs_path::{AbsPath, AbsPathBuf}; +pub use rel_path::{RelPath, RelPathBuf}; diff --git a/crates/hm-util/src/path/rel_path.rs b/crates/hm-util/src/path/rel_path.rs new file mode 100644 index 00000000..dfd770d1 --- /dev/null +++ b/crates/hm-util/src/path/rel_path.rs @@ -0,0 +1,111 @@ +//! A path that is guaranteed to be relative. +//! +//! [`RelPath`] / [`RelPathBuf`] mirror the [`Path`] / [`PathBuf`] split from +//! `std`, adding the invariant that the path is relative. + +use std::borrow::Borrow; +use std::ffi::OsStr; +use std::fmt; +use std::path::{Components, Path, PathBuf}; + +/// A borrowed path that is guaranteed to be relative. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(derive_more::AsRef)] +#[as_ref(forward)] +pub struct RelPath<'a>(&'a Path); + +impl<'a> RelPath<'a> { + /// Wrap `path` if it is relative, otherwise return `None`. + pub fn new + ?Sized>(path: &'a P) -> Option { + let p = path.as_ref(); + if p.is_relative() { + Some(Self(p)) + } else { + None + } + } + + /// Append another path segment. + #[must_use] + pub fn join>(&self, tail: P) -> PathBuf { + self.0.join(tail) + } + + /// The final component of this path, if any. + #[must_use] + pub fn file_name(&self) -> Option<&OsStr> { + self.0.file_name() + } + + /// An iterator over the components of this path. + pub fn components(&self) -> Components<'_> { + self.0.components() + } + + /// Yield the underlying [`Path`]. + #[must_use] + pub const fn as_path(&self) -> &Path { + self.0 + } + + /// Convert to an owned [`RelPathBuf`]. + #[must_use] + pub fn to_rel_path_buf(&self) -> RelPathBuf { + RelPathBuf(self.0.to_path_buf()) + } +} + +impl fmt::Display for RelPath<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0.display(), f) + } +} + +impl<'a> From<&'a RelPathBuf> for RelPath<'a> { + fn from(buf: &'a RelPathBuf) -> Self { + buf.as_rel_path() + } +} + +/// An owned path that is guaranteed to be relative. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(derive_more::Deref, derive_more::AsRef, derive_more::Into)] +#[deref(forward)] +#[as_ref(forward)] +pub struct RelPathBuf(PathBuf); + +impl RelPathBuf { + /// Wrap `path` if it is relative, otherwise return `None`. + #[must_use] + pub fn new(path: PathBuf) -> Option { + if path.is_relative() { + Some(Self(path)) + } else { + None + } + } + + /// Borrow as a [`RelPath`]. + #[must_use] + pub fn as_rel_path(&self) -> RelPath<'_> { + RelPath(self.0.as_path()) + } + + /// Consume and return the inner [`PathBuf`]. + #[must_use] + pub fn into_path_buf(self) -> PathBuf { + self.0 + } +} + +impl Borrow for RelPathBuf { + fn borrow(&self) -> &Path { + self.0.as_path() + } +} + +impl fmt::Display for RelPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0.display(), f) + } +} diff --git a/crates/hm/Cargo.toml b/crates/hm/Cargo.toml index 370de3a4..73df36d6 100644 --- a/crates/hm/Cargo.toml +++ b/crates/hm/Cargo.toml @@ -60,6 +60,8 @@ hm-pipeline-ir = { workspace = true } hm-plugin-protocol = { workspace = true } hm-util = { workspace = true } hm-config = { workspace = true } +hm-core = { workspace = true } +secrecy = "0.10.3" hm-plugin-cloud = { workspace = true } harmont-cloud = { workspace = true } hm-vm = { workspace = true, features = ["docker-backend"] } diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index 7ea088ff..b1cd2875 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -97,13 +97,19 @@ pub struct CacheRestoreArgs { /// Dispatch a parsed CLI command to the appropriate handler. Returns an exit code. /// +/// [`RunContext`] is loaded only for [`Command::Run`] — other verbs do not +/// require a project workspace (e.g. `hm cloud login`, `hm version`). +/// /// # Errors /// /// Returns an error if the dispatched handler fails. -pub async fn dispatch(command: Command, ctx: RunContext) -> Result { +pub async fn dispatch(command: Command, cli: &Cli) -> Result { match command { Command::Init(args) => crate::commands::init::handle(args).await.map(|()| 0), - Command::Run(args) => crate::commands::run::handle(args, ctx).await, + Command::Run(args) => { + let ctx = RunContext::from_cli(cli)?; + crate::commands::run::handle(args, ctx).await + } Command::Pipelines(args) => crate::cli::pipelines::run(args).await.map(|()| 0), Command::Render(args) => crate::cli::render::run(args).await.map(|()| 0), Command::Cache(cmd) => match cmd { diff --git a/crates/hm/src/commands/init.rs b/crates/hm/src/commands/init.rs index 2987f400..dbf9ee88 100644 --- a/crates/hm/src/commands/init.rs +++ b/crates/hm/src/commands/init.rs @@ -97,9 +97,10 @@ fn prompt_skills() -> Result { /// /// Silently returns `Ok(())` on any user-cancellation (Esc, Ctrl-C on a prompt). async fn prompt_cloud_registration(dir: &std::path::Path) -> Result<()> { - let cfg = hm_config::Config::load(None).unwrap_or_default(); - let api_url = &cfg.cloud.api_url; - let is_logged_in = hm_config::creds::cloud_token(api_url).is_some(); + let is_logged_in = hm_core::Sys::load() + .ok() + .and_then(|sys| sys.creds().token()) + .is_some(); if !is_logged_in { let want_login = dialoguer::Confirm::new() diff --git a/crates/hm/src/commands/init_templates/python.py b/crates/hm/src/commands/init_templates/python.py index 6b122046..9c616a34 100644 --- a/crates/hm/src/commands/init_templates/python.py +++ b/crates/hm/src/commands/init_templates/python.py @@ -21,4 +21,6 @@ def ci(project: hm.Target[PythonToolchain]) -> tuple[hm.Step, ...]: project.lint(), project.fmt(), project.typecheck(), + # Reference a secret stored on your org or pipeline (set it with `hm secret set`): + # hm.sh("deploy", env={"DEPLOY_TOKEN": hm.secrets["DEPLOY_TOKEN"]}), ) diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index c3c4e1ae..d89427dc 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use anyhow::{Context, Result}; +use secrecy::ExposeSecret as _; use hm_dsl_engine::{DslEngine, detect}; @@ -17,7 +18,7 @@ use crate::error::{ErrorCategory, HmError}; /// Backend resolution (flag wins over config): /// - `--backend ` → that backend (`cloud`, `docker`, …) /// - `--cloud` → `cloud` (deprecated alias) -/// - neither → `ctx.config.backend` (figment-layered, default `docker`) +/// - neither → `ctx.workspace.config().backend` (figment-layered, default `docker`) /// /// This is a THIN driver over the `hm-exec` backends: it builds an /// [`hm_exec::ExecutionBackend`], renders the pipeline to v0 IR once, starts @@ -44,7 +45,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { None } }) - .unwrap_or_else(|| ctx.config.backend.to_string()); + .unwrap_or_else(|| ctx.workspace.config().backend.to_string()); // 2. Cloud needs auth + org resolution up front — fail fast on a missing // token before any render work. We resolve the credentials here but @@ -53,14 +54,18 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // missing/ambiguous pipeline argument fails with a helpful message // instead of a daemon-connection error. let cloud_creds = if backend_name == "cloud" { - let api_url = ctx.config.cloud.api_url.clone(); - let token = hm_config::creds::cloud_token(&api_url).context( + let api_url = ctx.workspace.config().cloud.api_url.clone(); + let token = hm_core::Sys::load() + .context("loading credentials")? + .creds() + .token() + .context( "`hm run --backend cloud` requires authentication — run `hm cloud login` or set HM_API_TOKEN", )?; let org = args .org .clone() - .or_else(|| ctx.config.cloud.org.clone()) + .or_else(|| ctx.workspace.config().cloud.org.clone()) .context("no organization — pass --org or set `[cloud] org = \"…\"` in .hm/config.toml or ~/.config/hm/config.toml")?; Some((api_url, token, org)) } else if backend_name != "docker" { @@ -88,7 +93,8 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { let mut autocreate_client: Option<(harmont_cloud::HarmontClient, String)> = None; let backend: Box = if let Some((api_url, token, org)) = cloud_creds { - let client = harmont_cloud::HarmontClient::with_base_url(token, &api_url); + let client = + harmont_cloud::HarmontClient::with_base_url(token.expose_secret(), &api_url); autocreate_client = Some((client.clone(), org.clone())); // The watch link must point at the dashboard (app.) host, not the // API host — a link built from `api_url` lands on raw JSON. @@ -154,7 +160,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // interactively now and its slug persisted. A worktree WITH a remote falls // through to the repo-identity submit + get-or-create fallback below. if let Some((client, org)) = autocreate_client.as_ref() { - if let Some(slug) = ctx.config.cloud.pipeline.clone() { + if let Some(slug) = ctx.workspace.config().cloud.pipeline.clone() { req.cloud_pipeline_slug = Some(slug); } else if req.source.repo_name.is_none() { let default_branch = diff --git a/crates/hm/src/context.rs b/crates/hm/src/context.rs index 82194497..95fc6a47 100644 --- a/crates/hm/src/context.rs +++ b/crates/hm/src/context.rs @@ -1,20 +1,38 @@ use std::io::IsTerminal; -use anyhow::{Context, Result}; - use crate::cli::Cli; -use crate::config::Config; +use hm_core::Workspace; use hm_render::OutputMode; +use thiserror::Error; + +/// Failure building a [`RunContext`]. +#[derive(Debug, Error)] +pub enum Error { + /// Process current directory could not be resolved. + #[error("cannot determine current directory")] + CurrentDir(#[source] std::io::Error), + + /// No directory containing `.hm/` was found above the process cwd. + #[error( + "no harmont workspace found\n → run from a directory that contains `.hm/`, or initialize one with `hm init`" + )] + NotFound, -/// Runtime context that bundles resolved config and output preferences. + /// A discovered project root failed [`Workspace::load`]. + #[error(transparent)] + Workspace(#[from] hm_core::WorkspaceLoadError), +} + +/// Runtime context for commands that operate on a harmont project workspace. /// /// After the plan-4 cloud-plugin cutover this is intentionally thin: /// API client, credential store, and active-org resolution moved into -/// `hm-plugin-cloud`. The host context retains the config file (for -/// future use) and the output mode. +/// `hm-plugin-cloud`. Project config and well-known paths live on +/// [`Self::workspace`]; output mode is host-owned. #[derive(Debug)] pub struct RunContext { - pub config: Config, + /// Loaded project workspace (paths + layered config). + pub workspace: Workspace, /// Output mode for the residual built-in verbs (the legacy global /// `--format` flag was retired in plan 3; per-subcommand `--format` /// is the only currently-wired source, so this defaults to human). @@ -24,13 +42,21 @@ pub struct RunContext { impl RunContext { /// Build a [`RunContext`] from parsed CLI args. /// + /// Discovers the project root by walking up from the current directory + /// ([`hm_util::dirs::find_project_root`]), then loads a [`Workspace`] for + /// that exact path. Walk-up is intentionally separate from + /// [`Workspace::load`]. + /// /// # Errors /// - /// Returns an error if the config file is unreadable or malformed. - pub fn from_cli(cli: &Cli) -> Result { - let start_dir = std::env::current_dir().context("cannot determine current directory")?; - let project_root = hm_util::dirs::find_project_root(&start_dir); - let config = Config::load(project_root.as_deref())?; + /// Returns [`Error`] if the current directory cannot be determined, no + /// project root is found, or workspace load fails. + pub fn from_cli(cli: &Cli) -> Result { + let start_dir = std::env::current_dir().map_err(Error::CurrentDir)?; + // Walk-up is separate from Workspace::load (which only validates the + // exact path). + let root = hm_util::dirs::find_project_root(&start_dir).ok_or(Error::NotFound)?; + let workspace = Workspace::load(&root)?; let output = OutputMode::Human { // Single source of truth for the color/TTY rule (still honors --no-color). @@ -39,6 +65,6 @@ impl RunContext { interactive: std::io::stdout().is_terminal(), }; - Ok(Self { config, output }) + Ok(Self { workspace, output }) } } diff --git a/crates/hm/src/lib.rs b/crates/hm/src/lib.rs index 846b85da..40e2b342 100644 --- a/crates/hm/src/lib.rs +++ b/crates/hm/src/lib.rs @@ -20,9 +20,6 @@ pub mod commands; /// keep resolving. The layered config + credential store now live in /// `hm-config` so `hm-plugin-cloud` can share them. pub use hm_config as config; -/// Re-export the credential store under the historical -/// `harmont_cli::creds_store` path. -pub use hm_config::creds as creds_store; pub mod context; pub mod error; pub(crate) mod signal; diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index 84e9c93c..25b2a88f 100644 --- a/crates/hm/src/main.rs +++ b/crates/hm/src/main.rs @@ -11,7 +11,6 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use harmont_cli::cli::{self, Cli}; -use harmont_cli::context::RunContext; use harmont_cli::error::{self, HmError}; #[tokio::main] @@ -90,8 +89,7 @@ async fn main() { async fn run(args: Cli) -> Result { let command = args.command.clone(); - let ctx = RunContext::from_cli(&args)?; - cli::dispatch(command, ctx).await + cli::dispatch(command, &args).await } fn build_chrome_layer( diff --git a/crates/hm/tests/cmd_cloud_gate.rs b/crates/hm/tests/cmd_cloud_gate.rs index 92018d72..2ffda6e4 100644 --- a/crates/hm/tests/cmd_cloud_gate.rs +++ b/crates/hm/tests/cmd_cloud_gate.rs @@ -45,5 +45,34 @@ fn cloud_help_lists_real_subcommands_without_waitlist_text() { .stdout(contains("login")) .stdout(contains("whoami")) .stdout(contains("build")) + .stdout(contains("secret")) .stdout(predicates::str::contains("not yet available").not()); } + +/// `hm cloud secret --help` must advertise the three verbs. +#[test] +fn cloud_secret_help_lists_verbs() { + Command::cargo_bin("hm") + .unwrap() + .args(["cloud", "secret", "--help"]) + .assert() + .success() + .stdout(contains("set")) + .stdout(contains("list")) + .stdout(contains("rm")); +} + +/// An unauthenticated `hm cloud secret list` must fail fast with the +/// login hint (proves the verb is wired through the shared auth path). +#[test] +fn cloud_secret_list_unauthed_fails_with_login_hint() { + let tmp = tempfile::tempdir().unwrap(); + Command::cargo_bin("hm") + .unwrap() + .args(["cloud", "secret", "list"]) + .env("HOME", tmp.path()) + .env_remove("HM_API_TOKEN") + .assert() + .failure() + .stderr(contains("not logged in")); +} diff --git a/tests/e2e/fixtures/python/cmake-advanced.json b/tests/e2e/fixtures/python/cmake-advanced.json index b1ccf9b3..dfdc1f7d 100644 --- a/tests/e2e/fixtures/python/cmake-advanced.json +++ b/tests/e2e/fixtures/python/cmake-advanced.json @@ -36,6 +36,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -45,7 +46,8 @@ "cmd": "apt-get update && apt-get install -y cmake build-essential pkg-config ninja-build ccache clang-format clang-tidy clang-18 lld-18", "image": "ubuntu:24.04", "key": "apt-base", - "label": ":cmake: apt-base" + "label": ":cmake: apt-base", + "secrets": {} } }, { @@ -54,6 +56,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -61,7 +64,8 @@ }, "cmd": "cmake --version && ninja --version && ccache --version && clang-18 --version", "key": "verify", - "label": ":cmake: verify" + "label": ":cmake: verify", + "secrets": {} } }, { @@ -70,6 +74,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -79,7 +84,8 @@ }, "cmd": "cd . && cmake -S . -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=20 && cmake --build build --parallel $(nproc)", "key": "build", - "label": ":cmake: build" + "label": ":cmake: build", + "secrets": {} } }, { @@ -88,10 +94,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cmake --build ./build --parallel $(nproc) && ctest --test-dir ./build --output-on-failure --parallel $(nproc)", "key": "test", - "label": ":cmake: test" + "label": ":cmake: test", + "secrets": {} } }, { @@ -100,10 +108,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd . && run-clang-tidy -p build", "key": "lint", - "label": ":cmake: lint" + "label": ":cmake: lint", + "secrets": {} } }, { @@ -112,10 +122,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd . && find . -not -path './build/*' \\( -name '*.c' -o -name '*.h' -o -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' \\) | xargs clang-format --dry-run --Werror", "key": "fmt", - "label": ":cmake: fmt" + "label": ":cmake: fmt", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/python/kitchen-sink.json b/tests/e2e/fixtures/python/kitchen-sink.json index e1fcb4ea..0c43d356 100644 --- a/tests/e2e/fixtures/python/kitchen-sink.json +++ b/tests/e2e/fixtures/python/kitchen-sink.json @@ -51,6 +51,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -60,7 +61,8 @@ "cmd": "apt-get update && apt-get install -y cmake build-essential pkg-config ninja-build ccache clang-format clang-tidy", "image": "ubuntu:24.04", "key": "fdcc2fd62363", - "label": ":cmake: apt-base" + "label": ":cmake: apt-base", + "secrets": {} } }, { @@ -69,6 +71,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -76,7 +79,8 @@ }, "cmd": "cmake --version && ninja --version && ccache --version", "key": "verify", - "label": ":cmake: verify" + "label": ":cmake: verify", + "secrets": {} } }, { @@ -85,6 +89,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -94,7 +99,8 @@ }, "cmd": "cd infra/agent && cmake -S . -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache && cmake --build build --parallel $(nproc)", "key": "build", - "label": ":cmake: build" + "label": ":cmake: build", + "secrets": {} } }, { @@ -103,10 +109,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cmake --build infra/agent/build --parallel $(nproc) && ctest --test-dir infra/agent/build --output-on-failure --parallel $(nproc)", "key": "431f35b84318", - "label": ":cmake: test" + "label": ":cmake: test", + "secrets": {} } }, { @@ -115,10 +123,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd infra/agent && find . -not -path './build/*' \\( -name '*.c' -o -name '*.h' -o -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' \\) | xargs clang-format --dry-run --Werror", "key": "fmt", - "label": ":cmake: fmt" + "label": ":cmake: fmt", + "secrets": {} } }, { @@ -127,6 +137,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -136,7 +147,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", "image": "ubuntu:24.04", "key": "c8d9fda86ff3", - "label": ":python: apt-base" + "label": ":python: apt-base", + "secrets": {} } }, { @@ -145,6 +157,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -152,7 +165,8 @@ }, "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version", "key": "uv-install", - "label": ":python: uv-install" + "label": ":python: uv-install", + "secrets": {} } }, { @@ -161,6 +175,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -171,7 +186,8 @@ }, "cmd": "cd services/web && uv sync --all-extras", "key": "uv-sync", - "label": ":python: uv-sync" + "label": ":python: uv-sync", + "secrets": {} } }, { @@ -180,10 +196,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/web && uv run pytest", "key": "239c4926568b", - "label": ":python: test" + "label": ":python: test", + "secrets": {} } }, { @@ -192,10 +210,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/web && uv run ruff check .", "key": "lint", - "label": ":python: lint" + "label": ":python: lint", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/python/monorepo-ci.json b/tests/e2e/fixtures/python/monorepo-ci.json index ec0ef385..cfd0ac95 100644 --- a/tests/e2e/fixtures/python/monorepo-ci.json +++ b/tests/e2e/fixtures/python/monorepo-ci.json @@ -81,6 +81,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -90,7 +91,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates git", "image": "ubuntu:24.04", "key": "334b29e96b76", - "label": ":go: apt-base" + "label": ":go: apt-base", + "secrets": {} } }, { @@ -99,6 +101,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -106,7 +109,8 @@ }, "cmd": "curl -fsSL https://go.dev/dl/go1.23.2.linux-amd64.tar.gz -o /tmp/go.tgz && rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz && ln -sf /usr/local/go/bin/go /usr/local/bin/go && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt && go version", "key": "e0b494124562", - "label": ":go: install" + "label": ":go: install", + "secrets": {} } }, { @@ -115,10 +119,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go build ./...", "key": "6f9493b7219f", - "label": ":go: build" + "label": ":go: build", + "secrets": {} } }, { @@ -127,10 +133,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go test ./...", "key": "1ad6d86b2c0a", - "label": ":go: test" + "label": ":go: test", + "secrets": {} } }, { @@ -139,10 +147,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go vet ./...", "key": "vet", - "label": ":go: vet" + "label": ":go: vet", + "secrets": {} } }, { @@ -151,6 +161,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -160,7 +171,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", "image": "ubuntu:24.04", "key": "c8d9fda86ff3", - "label": ":python: apt-base" + "label": ":python: apt-base", + "secrets": {} } }, { @@ -169,6 +181,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -176,7 +189,8 @@ }, "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version", "key": "uv-install", - "label": ":python: uv-install" + "label": ":python: uv-install", + "secrets": {} } }, { @@ -185,6 +199,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -195,7 +210,8 @@ }, "cmd": "cd services/ml && uv sync --all-extras", "key": "uv-sync", - "label": ":python: uv-sync" + "label": ":python: uv-sync", + "secrets": {} } }, { @@ -204,10 +220,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run pytest", "key": "847020e744bc", - "label": ":python: test" + "label": ":python: test", + "secrets": {} } }, { @@ -216,10 +234,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run ruff check .", "key": "6c48498afb84", - "label": ":python: lint" + "label": ":python: lint", + "secrets": {} } }, { @@ -228,10 +248,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run ty check .", "key": "typecheck", - "label": ":python: typecheck" + "label": ":python: typecheck", + "secrets": {} } }, { @@ -240,6 +262,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -249,7 +272,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates", "image": "ubuntu:24.04", "key": "3c2cfedcad46", - "label": ":node: apt-base" + "label": ":node: apt-base", + "secrets": {} } }, { @@ -258,6 +282,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -265,7 +290,8 @@ }, "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", "key": "ed974519b390", - "label": ":node: install" + "label": ":node: install", + "secrets": {} } }, { @@ -274,6 +300,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -283,7 +310,8 @@ }, "cmd": "cd web && npm ci", "key": "deps", - "label": ":node: deps" + "label": ":node: deps", + "secrets": {} } }, { @@ -292,10 +320,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run build", "key": "a94a0f84e711", - "label": ":node: build" + "label": ":node: build", + "secrets": {} } }, { @@ -304,10 +334,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run test", "key": "d2438adde70d", - "label": ":node: test" + "label": ":node: test", + "secrets": {} } }, { @@ -316,10 +348,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run lint", "key": "74c52c9e5ef6", - "label": ":node: lint" + "label": ":node: lint", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/python/rust-release.json b/tests/e2e/fixtures/python/rust-release.json index 4f9cc69d..1052dccc 100644 --- a/tests/e2e/fixtures/python/rust-release.json +++ b/tests/e2e/fixtures/python/rust-release.json @@ -41,6 +41,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -50,7 +51,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential pkg-config libssl-dev", "image": "ubuntu:24.04", "key": "apt-base", - "label": ":rust: apt-base" + "label": ":rust: apt-base", + "secrets": {} } }, { @@ -59,6 +61,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -66,7 +69,8 @@ }, "cmd": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy,rustfmt && . $HOME/.cargo/env && rustc --version && cargo --version", "key": "rustup", - "label": ":rust: rustup" + "label": ":rust: rustup", + "secrets": {} } }, { @@ -75,10 +79,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo build --locked", "key": "build", - "label": ":rust: build" + "label": ":rust: build", + "secrets": {} } }, { @@ -87,10 +93,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo test --locked", "key": "test", - "label": ":rust: test" + "label": ":rust: test", + "secrets": {} } }, { @@ -99,10 +107,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets --locked -- -D warnings", "key": "clippy", - "label": ":rust: clippy" + "label": ":rust: clippy", + "secrets": {} } }, { @@ -111,10 +121,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --all --check", "key": "fmt", - "label": ":rust: fmt" + "label": ":rust: fmt", + "secrets": {} } }, { @@ -124,10 +136,12 @@ "RUSTDOCFLAGS": "-D warnings", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps --locked", "key": "doc", - "label": ":rust: doc" + "label": ":rust: doc", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/python/zig-node-polyglot.json b/tests/e2e/fixtures/python/zig-node-polyglot.json index 0ddbebff..286e23f6 100644 --- a/tests/e2e/fixtures/python/zig-node-polyglot.json +++ b/tests/e2e/fixtures/python/zig-node-polyglot.json @@ -61,6 +61,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -70,7 +71,8 @@ "cmd": "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils", "image": "ubuntu:24.04", "key": "base", - "label": ":apt: base" + "label": ":apt: base", + "secrets": {} } }, { @@ -79,6 +81,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -86,7 +89,8 @@ }, "cmd": "curl -fsSL https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz -o /tmp/zig.tar.xz && rm -rf /usr/local/zig && mkdir -p /usr/local/zig && tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1 && ln -sf /usr/local/zig/zig /usr/local/bin/zig && zig version", "key": "3083a531d11a", - "label": ":zig: install" + "label": ":zig: install", + "secrets": {} } }, { @@ -95,10 +99,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-a && zig build", "key": "zig-a-build", - "label": ":zig: zig-a build" + "label": ":zig: zig-a build", + "secrets": {} } }, { @@ -107,10 +113,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-a && zig build test", "key": "zig-a-test", - "label": ":zig: zig-a test" + "label": ":zig: zig-a test", + "secrets": {} } }, { @@ -119,10 +127,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-b && zig build", "key": "zig-b-build", - "label": ":zig: zig-b build" + "label": ":zig: zig-b build", + "secrets": {} } }, { @@ -131,10 +141,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-b && zig build test", "key": "zig-b-test", - "label": ":zig: zig-b test" + "label": ":zig: zig-b test", + "secrets": {} } }, { @@ -143,6 +155,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -150,7 +163,8 @@ }, "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", "key": "a8f0d9d99460", - "label": ":node: install" + "label": ":node: install", + "secrets": {} } }, { @@ -159,6 +173,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -168,7 +183,8 @@ }, "cmd": "cd web && npm ci", "key": "deps", - "label": ":node: deps" + "label": ":node: deps", + "secrets": {} } }, { @@ -177,10 +193,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run build", "key": "build", - "label": ":node: build" + "label": ":node: build", + "secrets": {} } }, { @@ -189,10 +207,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run test", "key": "test", - "label": ":node: test" + "label": ":node: test", + "secrets": {} } }, { @@ -201,10 +221,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run lint", "key": "lint", - "label": ":node: lint" + "label": ":node: lint", + "secrets": {} } } ]