diff --git a/src/api_client.rs b/src/api_client.rs index ef4be1b2..8ef8f286 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 b56087b2..07905dc7 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 5487b45b..2a9218dd 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 bf33c71b..ca2dbdf4 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 f666e143..485a7de7 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 81667f65..4a411db2 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 3dbf15b6..e24ab2cd 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 63348dd7..c3a7139b 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!(