From 4ea6774da56adf679078abbc25494af3e9afe4a7 Mon Sep 17 00:00:00 2001 From: fargito Date: Fri, 28 Aug 2026 09:31:02 +0200 Subject: [PATCH] feat(run-environment): log which authentication method the run uses The runner resolves how it authenticates from the environment rather than being told, so nothing in its output distinguished a job uploading with an OIDC token from one falling back to tokenless because the workflow forgot `id-token: write`. Print the method, and a link to the run environment's authentication docs, alongside the upload it describes. Name the method from where the token actually came from instead of guessing: `CodSpeedAPIClient` now holds an `Authentication` carrying the token together with the source that produced it, so `--token` / `CODSPEED_TOKEN` and the token `codspeed auth login` persists are no longer reported alike. Log it after the OIDC token is minted rather than before the benchmarks run. Minting can fail and fall back to a tokenless upload, so only by then is the method settled, and the line names the token the upload really carries. A token GitLab CI issues through `id_tokens` arrives in `CODSPEED_TOKEN`, exactly where a static CodSpeed token would, and only the upload endpoint can tell the two apart. It is reported as the `CODSPEED_TOKEN` it came from. Collect the documentation links these messages share into constants, so the uploader's 401 hint and the OIDC guidance stop repeating URLs the new authentication line would have to know as well. The Buildkite 401 hint gains the link it was missing. `CodSpeedAPIClient::new` takes an `Authentication`, and `with_token` and `set_token` become `with_authentication` and `set_authentication`. Callers pass the same tokens through the matching variant; no request changes. Closes COD-3406 Co-Authored-By: Claude Opus 5 (1M context) --- src/api_client.rs | 101 ++++++++++++------ src/cli/auth.rs | 8 +- src/cli/mod.rs | 16 +-- src/executor/orchestrator.rs | 17 +++ src/run_environment/circleci/provider.rs | 23 ++-- .../github_actions/provider.rs | 24 ++--- src/run_environment/interfaces.rs | 43 ++++++++ src/upload/uploader.rs | 19 ++-- 8 files changed, 180 insertions(+), 71 deletions(-) diff --git a/src/api_client.rs b/src/api_client.rs index ef4be1b2a..8ef8f2868 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -2,7 +2,7 @@ use std::fmt::Display; use crate::executor::ExecutorName; use crate::prelude::*; -use crate::run_environment::RepositoryProvider; +use crate::run_environment::{RepositoryProvider, RunEnvironment}; use console::style; use gql_client::{Client as GQLClient, ClientConfig}; use nestify::nest; @@ -12,50 +12,87 @@ pub struct CodSpeedAPIClient { gql_client: GQLClient, unauthenticated_gql_client: GQLClient, api_url: String, - /// The token this client authenticates with. Exposed so downstream - /// consumers (the uploader's `Authorization` header, the executor's - /// `CODSPEED_OAUTH_TOKEN` env injection) don't have to thread the - /// token separately from the client. - token: Option, + authentication: Authentication, +} + +/// How the runner authenticates the uploads of a run, and the token it does so +/// with. +/// +/// The runner cannot always tell which kind of token it was handed: GitLab CI +/// passes the OIDC token it issues through `CODSPEED_TOKEN`, exactly where a +/// static CodSpeed token would be, and only the API can tell the two apart. So +/// these variants name where the token came from, not what it turned out to be. +/// +/// Deliberately not `Debug`: every variant but one holds a credential. +#[derive(Clone)] +pub enum Authentication { + /// An OIDC token the runner minted from the run environment. Replaced before + /// every upload, since a token expires an hour after it is minted. + Oidc(String), + /// The token the run was given through `--token` / `CODSPEED_TOKEN`. + RunToken(String), + /// A token obtained through `codspeed auth login`: either the one persisted + /// for the selected profile, or one passed through `--oauth-token` / + /// `CODSPEED_OAUTH_TOKEN`. + CliLogin(String), + /// No token at all. CodSpeed matches the upload to the job by looking for the + /// run hash the runner prints, which only works for public repositories. + Tokenless, +} + +impl Authentication { + pub fn token(&self) -> Option<&str> { + match self { + Authentication::Oidc(token) + | Authentication::RunToken(token) + | Authentication::CliLogin(token) => Some(token), + Authentication::Tokenless => None, + } + } + + /// How to name it in the runner output. + pub fn label(&self, run_environment: &RunEnvironment) -> String { + match self { + Authentication::Oidc(_) => format!("OIDC token minted by {run_environment}"), + Authentication::RunToken(_) => "token from `CODSPEED_TOKEN`".to_owned(), + Authentication::CliLogin(_) => "token from `codspeed auth login`".to_owned(), + Authentication::Tokenless => { + "tokenless, supported for public repositories only".to_owned() + } + } + } } impl CodSpeedAPIClient { - /// Build a client authenticated with `token` (when `Some`). - /// - /// The CLI resolves the effective token at construction time, so - /// callers downstream (the uploader, the executor's env injection, - /// every GraphQL caller) just consume it from the client through - /// [`Self::token`] and don't have to thread the token separately. - pub fn new(token: Option, api_url: String) -> Self { + pub fn new(authentication: Authentication, api_url: String) -> Self { Self { - gql_client: build_gql_api_client(token.as_deref(), api_url.clone()), + gql_client: build_gql_api_client(authentication.token(), api_url.clone()), unauthenticated_gql_client: build_gql_api_client(None, api_url.clone()), api_url, - token, + authentication, } } - /// Returns a client that uses `token` for authentication, regardless of - /// the token this client was built with. - pub fn with_token(&self, token: String) -> Self { - Self::new(Some(token), self.api_url.clone()) + /// A copy of this client, authenticating differently. + pub fn with_authentication(&self, authentication: Authentication) -> Self { + Self::new(authentication, self.api_url.clone()) } - /// The token this client currently authenticates with, if any. - /// - /// Note: this is not necessarily the token the client was built with — - /// in CI with OIDC, [`Self::set_token`] is called before each upload to - /// rotate the credentials. See [`crate::run_environment::RunEnvironmentProvider::refresh_token`]. pub fn token(&self) -> Option<&str> { - self.token.as_deref() + self.authentication.token() + } + + /// How this client authenticates. Not necessarily how it was built: with + /// OIDC the token is rotated before every upload, in + /// [`crate::run_environment::RunEnvironmentProvider::set_oidc_token`]. + pub fn authentication(&self) -> &Authentication { + &self.authentication } - /// Replace the token this client uses for authenticated GraphQL - /// requests and that the uploader pulls for its `Authorization` - /// header. The single mutation point for the credentials. - pub fn set_token(&mut self, token: Option) { - self.gql_client = build_gql_api_client(token.as_deref(), self.api_url.clone()); - self.token = token; + /// The single mutation point for the credentials. + pub fn set_authentication(&mut self, authentication: Authentication) { + self.gql_client = build_gql_api_client(authentication.token(), self.api_url.clone()); + self.authentication = authentication; } } @@ -560,6 +597,6 @@ impl CodSpeedAPIClient { /// Create a test API client with a custom URL for use in tests #[cfg(test)] pub fn create_test_client_with_url(api_url: String) -> Self { - Self::new(None, api_url) + Self::new(Authentication::Tokenless, api_url) } } diff --git a/src/cli/auth.rs b/src/cli/auth.rs index b56087b29..07905dc7a 100644 --- a/src/cli/auth.rs +++ b/src/cli/auth.rs @@ -2,8 +2,9 @@ use std::io::Read; use std::time::Duration; use crate::api_client::{ - CodSpeedAPIClient, RepositoryOverviewPayload, SessionAndRepositoryOverviewError, - SessionAndRepositoryOverviewVars, SessionError, SessionPayload, + Authentication, CodSpeedAPIClient, RepositoryOverviewPayload, + SessionAndRepositoryOverviewError, SessionAndRepositoryOverviewVars, SessionError, + SessionPayload, }; use crate::cli::run::helpers::{ ParsedRepository, find_repository_root, parse_repository_from_remote, @@ -111,7 +112,8 @@ async fn login( }; // Validate the token before persisting - let api_client_with_token = api_client.with_token(token.clone()); + let api_client_with_token = + api_client.with_authentication(Authentication::CliLogin(token.clone())); api_client_with_token .session() .await diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5487b45b1..2a9218ddc 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -16,7 +16,7 @@ pub(crate) use shared::*; use std::path::PathBuf; use crate::{ - api_client::CodSpeedAPIClient, + api_client::{Authentication, CodSpeedAPIClient}, config::{CodSpeedConfig, ConfigOverrides}, executor::helpers::command::CommandBuilder, local_logger::{CODSPEED_U8_COLOR_CODE, init_local_logger}, @@ -254,14 +254,18 @@ fn load_config(cli: &Cli) -> Result { /// 2. `--oauth-token` / `CODSPEED_OAUTH_TOKEN` and the persisted CLI /// token from the selected profile. fn build_api_client(cli: &Cli, config: &CodSpeedConfig) -> CodSpeedAPIClient { - let explicit = match &cli.command { + let run_token = match &cli.command { Commands::Run(args) => args.shared.token.clone(), Commands::Exec(args) => args.shared.token.clone(), _ => None, }; - let token = match explicit { - Some(token) => Some(token), - None => config.auth.token.clone(), + let authentication = match run_token { + Some(token) => Authentication::RunToken(token), + None => config + .auth + .token + .clone() + .map_or(Authentication::Tokenless, Authentication::CliLogin), }; - CodSpeedAPIClient::new(token, config.api_url.clone()) + CodSpeedAPIClient::new(authentication, config.api_url.clone()) } diff --git a/src/executor/orchestrator.rs b/src/executor/orchestrator.rs index bf33c71b3..ca2dbdf4f 100644 --- a/src/executor/orchestrator.rs +++ b/src/executor/orchestrator.rs @@ -174,6 +174,18 @@ impl Orchestrator { Ok(()) } + fn log_authentication(&self, api_client: &CodSpeedAPIClient) { + let run_environment = self.provider.get_run_environment(); + + info!( + "Authentication: {}", + api_client.authentication().label(&run_environment) + ); + if let Some(url) = run_environment.authentication_docs_url() { + info!("Learn more at {url}"); + } + } + /// Resolve the profile folder for a given run part. /// /// - Single run part + user-specified folder: use as-is @@ -257,6 +269,11 @@ impl Orchestrator { // OIDC tokens can expire quickly, so refresh just before each upload self.provider.set_oidc_token(api_client).await?; + if run_part_index == 0 { + // After the mint, so this names the token the upload actually uses + self.log_authentication(api_client); + } + if total_runs > 1 { info!("Uploading results {}/{total_runs}", run_part_index + 1); } diff --git a/src/run_environment/circleci/provider.rs b/src/run_environment/circleci/provider.rs index f666e1431..485a7de73 100644 --- a/src/run_environment/circleci/provider.rs +++ b/src/run_environment/circleci/provider.rs @@ -5,13 +5,16 @@ use async_trait::async_trait; use serde_json::Value; use simplelog::SharedLogger; -use crate::api_client::CodSpeedAPIClient; +use crate::api_client::{Authentication, CodSpeedAPIClient}; use crate::cli::run::helpers::{ GitRemote, find_repository_root, get_env_variable, parse_git_remote, }; use crate::executor::config::OrchestratorConfig; use crate::prelude::*; -use crate::run_environment::interfaces::{RepositoryProvider, RunEnvironmentMetadata, RunEvent}; +use crate::run_environment::interfaces::{ + CIRCLECI_AUTHENTICATION_DOCS_URL, CIRCLECI_OIDC_DOCS_URL, RepositoryProvider, + RunEnvironmentMetadata, RunEvent, +}; use crate::run_environment::provider::{RunEnvironmentDetector, RunEnvironmentProvider}; use crate::run_environment::{RunEnvironment, RunPart}; @@ -222,11 +225,11 @@ impl RunEnvironmentProvider for CircleCIProvider { fn check_oidc_configuration(&mut self, api_client: &CodSpeedAPIClient) -> Result<()> { if api_client.token().is_some() { if !self.is_forked_pull_request { - announcement!( + announcement!(format!( "You can now authenticate your CircleCI jobs using OpenID Connect (OIDC) tokens instead of `CODSPEED_TOKEN` secrets.\n\ This makes integrating and authenticating jobs safer and simpler.\n\ - Learn more at https://codspeed.io/docs/integrations/ci/circleci/configuration#oidc-recommended\n" - ); + Learn more at {CIRCLECI_OIDC_DOCS_URL}\n" + )); } return Ok(()); @@ -236,7 +239,7 @@ impl RunEnvironmentProvider for CircleCIProvider { bail!( "Pull requests opened from a fork cannot authenticate with OIDC on CircleCI.\n\ Set `CODSPEED_TOKEN` for this job instead.\n\ - See https://codspeed.io/docs/integrations/ci/circleci/configuration#authentication" + See {CIRCLECI_AUTHENTICATION_DOCS_URL}" ); } @@ -245,7 +248,7 @@ impl RunEnvironmentProvider for CircleCIProvider { "{error}\n\ Unable to mint an OIDC token for authentication. \ Set `CODSPEED_TOKEN` for this job instead.\n\ - See https://codspeed.io/docs/integrations/ci/circleci/configuration#oidc-recommended" + See {CIRCLECI_OIDC_DOCS_URL}" ); } @@ -266,7 +269,7 @@ impl RunEnvironmentProvider for CircleCIProvider { let token = oidc::mint_token(self.get_oidc_audience())?; debug!("Minted an OIDC token to authenticate the upload"); - api_client.set_token(Some(token)); + api_client.set_authentication(Authentication::Oidc(token)); Ok(()) } @@ -498,7 +501,9 @@ mod tests { fn api_client(token: Option<&str>) -> CodSpeedAPIClient { CodSpeedAPIClient::new( - token.map(str::to_string), + token.map_or(Authentication::Tokenless, |token| { + Authentication::RunToken(token.to_string()) + }), "https://gql.codspeed.io/".to_string(), ) } diff --git a/src/run_environment/github_actions/provider.rs b/src/run_environment/github_actions/provider.rs index 81667f65b..4a411db2b 100644 --- a/src/run_environment/github_actions/provider.rs +++ b/src/run_environment/github_actions/provider.rs @@ -9,13 +9,13 @@ use simplelog::SharedLogger; use std::collections::BTreeMap; use std::{env, fs}; -use crate::api_client::CodSpeedAPIClient; +use crate::api_client::{Authentication, CodSpeedAPIClient}; use crate::cli::run::helpers::{find_repository_root, get_env_variable}; use crate::executor::config::OrchestratorConfig; use crate::prelude::*; use crate::request_client::OIDC_CLIENT; use crate::run_environment::interfaces::{ - RepositoryProvider, RunEnvironmentMetadata, RunEvent, Sender, + GITHUB_ACTIONS_OIDC_DOCS_URL, RepositoryProvider, RunEnvironmentMetadata, RunEvent, Sender, }; use crate::run_environment::provider::{RunEnvironmentDetector, RunEnvironmentProvider}; use crate::run_environment::{RunEnvironment, RunPart}; @@ -289,11 +289,11 @@ impl RunEnvironmentProvider for GitHubActionsProvider { fn check_oidc_configuration(&mut self, api_client: &CodSpeedAPIClient) -> Result<()> { // Check if a static token is already set if api_client.token().is_some() { - announcement!( + announcement!(format!( "You can now authenticate your CI workflows using OpenID Connect (OIDC) tokens instead of `CODSPEED_TOKEN` secrets.\n\ This makes integrating and authenticating jobs safer and simpler.\n\ - Learn more at https://codspeed.io/docs/integrations/ci/github-actions/configuration#oidc-recommended\n" - ); + Learn more at {GITHUB_ACTIONS_OIDC_DOCS_URL}\n" + )); return Ok(()); } @@ -313,15 +313,15 @@ impl RunEnvironmentProvider for GitHubActionsProvider { bail!( "Unable to retrieve OIDC token for authentication.\n\ Make sure your workflow has the `id-token: write` permission set.\n\ - See https://codspeed.io/docs/integrations/ci/github-actions/configuration#oidc-recommended" + See {GITHUB_ACTIONS_OIDC_DOCS_URL}" ) } - announcement!( + announcement!(format!( "You can now authenticate your CI workflows using OpenID Connect (OIDC).\n\ This makes integrating and authenticating jobs safer and simpler.\n\ - Learn more at https://codspeed.io/docs/integrations/ci/github-actions/configuration#oidc-recommended\n" - ); + Learn more at {GITHUB_ACTIONS_OIDC_DOCS_URL}\n" + )); return Ok(()); } @@ -366,14 +366,14 @@ impl RunEnvironmentProvider for GitHubActionsProvider { Err(_) => None, }; - if token.is_some() { + if let Some(token) = token { debug!("Successfully retrieved OIDC token for authentication."); - api_client.set_token(token); + api_client.set_authentication(Authentication::Oidc(token)); } else if self.is_repository_private { bail!( "Unable to retrieve OIDC token for authentication. \n\ Make sure your workflow has the `id-token: write` permission set. \n\ - See https://codspeed.io/docs/integrations/ci/github-actions/configuration#oidc-recommended" + See {GITHUB_ACTIONS_OIDC_DOCS_URL}" ) } else { warn!("Failed to retrieve OIDC token for authentication."); diff --git a/src/run_environment/interfaces.rs b/src/run_environment/interfaces.rs index 3dbf15b6f..e24ab2cd8 100644 --- a/src/run_environment/interfaces.rs +++ b/src/run_environment/interfaces.rs @@ -33,6 +33,49 @@ pub enum RunEnvironment { Local, } +impl fmt::Display for RunEnvironment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + RunEnvironment::GithubActions => "GitHub Actions", + RunEnvironment::GitlabCi => "GitLab CI", + RunEnvironment::Buildkite => "Buildkite", + RunEnvironment::Circleci => "CircleCI", + RunEnvironment::Local => "Local", + }; + write!(f, "{name}") + } +} + +/// Authentication documentation, defined once so the messages that quote these +/// pages cannot drift apart. +pub const GITHUB_ACTIONS_AUTHENTICATION_DOCS_URL: &str = + "https://codspeed.io/docs/integrations/ci/github-actions/configuration#authentication"; +pub const GITHUB_ACTIONS_OIDC_DOCS_URL: &str = + "https://codspeed.io/docs/integrations/ci/github-actions/configuration#oidc-recommended"; +pub const GITLAB_CI_AUTHENTICATION_DOCS_URL: &str = + "https://codspeed.io/docs/integrations/ci/gitlab-ci/configuration#authentication"; +pub const CIRCLECI_AUTHENTICATION_DOCS_URL: &str = + "https://codspeed.io/docs/integrations/ci/circleci/configuration#authentication"; +pub const CIRCLECI_OIDC_DOCS_URL: &str = + "https://codspeed.io/docs/integrations/ci/circleci/configuration#oidc-recommended"; +pub const BUILDKITE_DOCS_URL: &str = "https://codspeed.io/docs/integrations/ci/buildkite"; + +impl RunEnvironment { + /// The authentication section of this run environment's documentation. + /// + /// Buildkite covers its token as a setup step rather than in a section, so + /// its guide page stands in. Local runs have nothing to link. + pub fn authentication_docs_url(&self) -> Option<&'static str> { + match self { + RunEnvironment::GithubActions => Some(GITHUB_ACTIONS_AUTHENTICATION_DOCS_URL), + RunEnvironment::GitlabCi => Some(GITLAB_CI_AUTHENTICATION_DOCS_URL), + RunEnvironment::Circleci => Some(CIRCLECI_AUTHENTICATION_DOCS_URL), + RunEnvironment::Buildkite => Some(BUILDKITE_DOCS_URL), + RunEnvironment::Local => None, + } + } +} + #[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct RunEnvironmentMetadata { diff --git a/src/upload/uploader.rs b/src/upload/uploader.rs index 63348dd70..c3a7139b6 100644 --- a/src/upload/uploader.rs +++ b/src/upload/uploader.rs @@ -148,24 +148,25 @@ async fn retrieve_upload_data( .map(|body| body.error) .unwrap_or(text); if status == StatusCode::UNAUTHORIZED { - let additional_message = match upload_metadata.run_environment { + let run_environment = &upload_metadata.run_environment; + let additional_message = match run_environment { RunEnvironment::GithubActions => { - "Check that the workflow is correctly authenticated. View more at https://codspeed.io/docs/integrations/ci/github-actions/configuration#authentication" + "Check that the workflow is correctly authenticated." } - RunEnvironment::GitlabCi => { - "Check that the CI job is correctly authenticated. View more at https://codspeed.io/docs/integrations/ci/gitlab-ci/configuration#authentication" - } - RunEnvironment::Circleci => { - "Check that the CI job is correctly authenticated. View more at https://codspeed.io/docs/integrations/ci/circleci/configuration#authentication" + RunEnvironment::GitlabCi | RunEnvironment::Circleci => { + "Check that the CI job is correctly authenticated." } RunEnvironment::Buildkite => { - "Check that CODSPEED_TOKEN is set and has the correct value" + "Check that CODSPEED_TOKEN is set and has the correct value." } RunEnvironment::Local => { - "Run `codspeed auth login` to authenticate the CLI" + "Run `codspeed auth login` to authenticate the CLI." } }; error_message.push_str(&format!("\n\n{additional_message}")); + if let Some(url) = run_environment.authentication_docs_url() { + error_message.push_str(&format!(" View more at {url}")); + } } debug!(