Extract SecretSource trait with BootstrapSecrets and ApiSecrets impls
Replaces the SecretSource enum in main.rs with a trait and two named
structs in a dedicated secret_source module. Adding a new source (env
vars, CLI params) now means adding a struct + impl without touching
dispatch logic.

https://claude.ai/code/session_01VCCZYDCHDbr4LgadiJ6QAi
change
commit d391799c2f08083d78446db0343abe8eb1bbf61a
author Claude <noreply@anthropic.com>
date
parent d22d0b20
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 2a0be58..926befc 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,3 +1,4 @@
+mod secret_source;
 mod sink;
 
 use std::cell::RefCell;
@@ -15,7 +16,9 @@ use quire_core::ci::run::RunMeta;
 use quire_core::ci::runtime::{Runtime, RuntimeError, RuntimeEvent, RuntimeHandle};
 use quire_core::ci::transport::{ApiSession, Transport, TransportMode};
 use quire_core::fennel::FennelError;
-use quire_core::secret::{Error as SecretError, SecretRegistry, SecretString};
+use quire_core::secret::{SecretRegistry, SecretString};
+
+use crate::secret_source::{ApiSecrets, BootstrapSecrets, SecretSource as _};
 use quire_core::telemetry::{self, FmtMode, MietteLayer};
 
 /// Errors from running a job's `run_fn`. Lua errors are re-wrapped
@@ -237,12 +240,14 @@ fn main() -> miette::Result<()> {
             let miette_layer = MietteLayer::new();
             telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
 
-            let source = if transport.mode == TransportMode::Api {
-                SecretSource::Api(transport.session.clone())
+            let registry = if transport.mode == TransportMode::Api {
+                ApiSecrets {
+                    session: transport.session.clone(),
+                }
+                .into_registry()
             } else {
-                SecretSource::Bootstrap(secrets)
+                BootstrapSecrets { secrets }.into_registry()
             };
-            let registry = source.into_registry();
 
             run_pipeline(
                 cli.workspace,
@@ -359,66 +364,6 @@ fn load_bootstrap(
     Ok((bootstrap.git_dir, bootstrap.meta, secrets, bootstrap.sentry))
 }
 
-/// How this run resolves secret values.
-///
-/// `Bootstrap` reads the revealed values baked into the bootstrap file
-/// by the orchestrator — the current default. `Api` ignores those values
-/// and fetches each secret on demand from quire-server instead.
-///
-/// Once the `Api` path is validated in production, `Bootstrap` will be
-/// removed and the bootstrap file will stop carrying secret values.
-enum SecretSource {
-    Bootstrap(HashMap<String, SecretString>),
-    Api(ApiSession),
-}
-
-impl SecretSource {
-    fn into_registry(self) -> SecretRegistry {
-        match self {
-            Self::Bootstrap(secrets) => SecretRegistry::from(secrets),
-            Self::Api(session) => SecretRegistry::from(HashMap::new())
-                .with_fallback(move |name| Self::fetch_from_api(&session, name)),
-        }
-    }
-
-    /// Fetch a single secret from quire-server.
-    ///
-    /// Uses [`tokio::runtime::Handle::block_on`] to drive the async HTTP
-    /// call from synchronous Lua callback context. Requires the caller to
-    /// be on a thread that has entered a Tokio runtime (`rt.enter()` in
-    /// `main` satisfies this).
-    fn fetch_from_api(session: &ApiSession, name: &str) -> quire_core::secret::Result<String> {
-        let url = format!(
-            "{}/api/runs/{}/secrets/{}",
-            session.server_url, session.run_id, name
-        );
-        let token = session.auth_token.clone();
-        let name_owned = name.to_string();
-
-        tokio::runtime::Handle::current().block_on(async move {
-            let resp = reqwest::Client::new()
-                .get(&url)
-                .bearer_auth(&token)
-                .send()
-                .await
-                .map_err(|e| SecretError::Resolve(e.to_string()))?;
-
-            let status = resp.status();
-            if status.is_success() {
-                resp.text()
-                    .await
-                    .map_err(|e| SecretError::Resolve(e.to_string()))
-            } else if status == reqwest::StatusCode::NOT_FOUND {
-                Err(SecretError::UnknownSecret(name_owned))
-            } else {
-                Err(SecretError::Resolve(format!(
-                    "secret API returned {status} for {name_owned:?}"
-                )))
-            }
-        })
-    }
-}
-
 fn run_pipeline(
     workspace: PathBuf,
     mut sink: Box<dyn EventSink>,
diff --git a/quire-ci/src/secret_source.rs b/quire-ci/src/secret_source.rs
new file mode 100644
index 0000000..2a6afa1
--- /dev/null
+++ b/quire-ci/src/secret_source.rs
@@ -0,0 +1,81 @@
+use std::collections::HashMap;
+
+use quire_core::ci::transport::ApiSession;
+use quire_core::secret::{Error as SecretError, SecretRegistry, SecretString};
+
+/// Determines how this run resolves secret values into a [`SecretRegistry`].
+///
+/// Each variant is a separate named type so new sources (env vars, CLI
+/// parameters, etc.) can be added without touching the dispatch logic.
+pub trait SecretSource {
+    fn into_registry(self) -> SecretRegistry;
+}
+
+/// Reads revealed secret values that were baked into the bootstrap file by
+/// the orchestrator. The current default for orchestrated runs.
+///
+/// Once the [`ApiSecrets`] path is validated in production, this type and
+/// the corresponding values in the bootstrap file will be removed.
+pub struct BootstrapSecrets {
+    pub secrets: HashMap<String, SecretString>,
+}
+
+impl SecretSource for BootstrapSecrets {
+    fn into_registry(self) -> SecretRegistry {
+        SecretRegistry::from(self.secrets)
+    }
+}
+
+/// Fetches each secret on demand from quire-server via
+/// `GET /api/runs/{run_id}/secrets/{name}`.
+///
+/// The bootstrap secrets map is ignored in this path; only run metadata
+/// travels via the bootstrap file.
+pub struct ApiSecrets {
+    pub session: ApiSession,
+}
+
+impl SecretSource for ApiSecrets {
+    fn into_registry(self) -> SecretRegistry {
+        let session = self.session;
+        SecretRegistry::from(HashMap::new())
+            .with_fallback(move |name| fetch_from_api(&session, name))
+    }
+}
+
+/// Fetch a single secret from quire-server.
+///
+/// Uses [`tokio::runtime::Handle::block_on`] to drive the async HTTP call
+/// from synchronous Lua callback context. Requires the caller to be on a
+/// thread that has entered a Tokio runtime (`rt.enter()` in `main`
+/// satisfies this).
+fn fetch_from_api(session: &ApiSession, name: &str) -> quire_core::secret::Result<String> {
+    let url = format!(
+        "{}/api/runs/{}/secrets/{}",
+        session.server_url, session.run_id, name
+    );
+    let token = session.auth_token.clone();
+    let name_owned = name.to_string();
+
+    tokio::runtime::Handle::current().block_on(async move {
+        let resp = reqwest::Client::new()
+            .get(&url)
+            .bearer_auth(&token)
+            .send()
+            .await
+            .map_err(|e| SecretError::Resolve(e.to_string()))?;
+
+        let status = resp.status();
+        if status.is_success() {
+            resp.text()
+                .await
+                .map_err(|e| SecretError::Resolve(e.to_string()))
+        } else if status == reqwest::StatusCode::NOT_FOUND {
+            Err(SecretError::UnknownSecret(name_owned))
+        } else {
+            Err(SecretError::Resolve(format!(
+                "secret API returned {status} for {name_owned:?}"
+            )))
+        }
+    })
+}