Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/run_environment/circleci/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod logger;
mod oidc;
mod provider;

pub use provider::CircleCIProvider;
47 changes: 47 additions & 0 deletions src/run_environment/circleci/oidc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use std::process::Command;

use crate::prelude::*;

/// The CLI CircleCI makes available inside jobs.
const CIRCLECI_CLI: &str = "circleci";

/// Mints an OIDC token for `audience`.
///
/// The token CircleCI puts in `CIRCLE_OIDC_TOKEN` and `CIRCLE_OIDC_TOKEN_V2` cannot
/// be used instead: its audience is the id of the CircleCI organization, while
/// CodSpeed requires its own. Requesting the audience is what makes a token minted
/// for another integration unusable against CodSpeed, and vice versa.
///
/// Errors carry what the CLI itself reported, as only the first error of a chain is
/// shown outside of debug logging: callers should add their advice to that message
/// rather than wrap it.
///
/// <https://circleci.com/docs/guides/permissions-authentication/oidc-tokens-with-custom-claims/>
pub fn mint_token(audience: &str) -> Result<String> {
let claims = serde_json::json!({ "aud": audience }).to_string();

let output = Command::new(CIRCLECI_CLI)
.args(["run", "oidc", "get", "--claims", &claims])
.output()
.map_err(|error| anyhow!("Failed to run the `circleci` CLI: {error}"))?;

if !output.status.success() {
bail!(
"`circleci run oidc get` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}

// CircleCI does not mask tokens minted this way in the job output, so the token
// must not reach the logs, here or in the callers.
let token = String::from_utf8(output.stdout)
.map_err(|_| anyhow!("The OIDC token minted by CircleCI is not valid UTF-8"))?
.trim()
.to_string();

if token.is_empty() {
bail!("`circleci run oidc get` returned an empty token");
}

Ok(token)
}
169 changes: 163 additions & 6 deletions src/run_environment/circleci/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::run_environment::provider::{RunEnvironmentDetector, RunEnvironmentPro
use crate::run_environment::{RunEnvironment, RunPart};

use super::logger::CircleCILogger;
use super::oidc;

#[derive(Debug)]
pub struct CircleCIProvider {
Expand All @@ -31,6 +32,15 @@ pub struct CircleCIProvider {
node_index: u32,
/// Number of containers the job runs on, `1` when unset.
node_total: u32,

/// Whether the build runs on a pull request opened from a fork.
is_forked_pull_request: bool,

/// Whether uploads authenticate with an OIDC token minted by CircleCI.
///
/// Decided in [`RunEnvironmentProvider::check_oidc_configuration`], acted on in
/// [`RunEnvironmentProvider::set_oidc_token`].
uses_oidc: bool,
}

/// Returns the number of the pull request the build runs on, if any.
Expand All @@ -57,6 +67,15 @@ fn get_ref(pr_number: Option<u64>) -> Result<String> {
}
}

/// Returns whether the build runs on a pull request opened from a fork.
///
/// `CIRCLE_PR_NUMBER` is "only available on forked PRs", so its presence is the signal.
///
/// <https://circleci.com/docs/reference/variables/>
fn is_forked_pull_request() -> bool {
env::var("CIRCLE_PR_NUMBER").is_ok()
}
Comment thread
fargito marked this conversation as resolved.

fn get_env_number(name: &str, default: u32) -> u32 {
env::var(name)
.ok()
Expand Down Expand Up @@ -126,6 +145,8 @@ impl TryFrom<&OrchestratorConfig> for CircleCIProvider {
job_name: get_env_variable("CIRCLE_JOB")?,
node_index: get_env_number("CIRCLE_NODE_INDEX", 0),
node_total: get_env_number("CIRCLE_NODE_TOTAL", 1),
is_forked_pull_request: is_forked_pull_request(),
uses_oidc: false,
})
}
}
Expand Down Expand Up @@ -186,14 +207,68 @@ impl RunEnvironmentProvider for CircleCIProvider {
})
}

/// CircleCI requires a static `CODSPEED_TOKEN`. We don't yet support OIDC
/// tokens here (could be added via `CIRCLE_OIDC_TOKEN_V2`:
/// <https://circleci.com/docs/openid-connect-tokens>), so this just enforces
/// token presence.
/// Decide how the uploads of this job authenticate.
///
/// A static `CODSPEED_TOKEN` is used as is when there is one. Otherwise the runner
/// mints an OIDC token itself, which rules out a forked pull request: CircleCI
/// issues a forked build a token that names the fork, and CodSpeed only accepts
/// uploads to the repository its token names.
///
/// Whether the job can mint at all is settled here by minting a token and throwing
/// it away. Nothing cheaper answers the question: an image may carry no `circleci`
/// CLI, or one too old to know `run oidc get`, and only the CLI knows whether the
/// job is allowed to issue tokens. Asking now rather than at upload time is what
/// keeps an unauthenticated job from being told so only once its benchmarks have
/// run.
fn check_oidc_configuration(&mut self, api_client: &CodSpeedAPIClient) -> Result<()> {
if api_client.token().is_none() {
bail!("Token authentication is required for CircleCI");
if api_client.token().is_some() {
if !self.is_forked_pull_request {
announcement!(
"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"
);
}

return Ok(());
}

if self.is_forked_pull_request {
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"
);
}

if let Err(error) = oidc::mint_token(self.get_oidc_audience()) {
bail!(
"{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"
);
}

self.uses_oidc = true;

Ok(())
}

/// Mint the OIDC token authenticating the upload that follows.
///
/// A token expires an hour after it is minted, which a run can outlast, so each
/// upload gets its own rather than reusing the one of a previous call.
async fn set_oidc_token(&self, api_client: &mut CodSpeedAPIClient) -> Result<()> {
if !self.uses_oidc {
return Ok(());
}

let token = oidc::mint_token(self.get_oidc_audience())?;

debug!("Minted an OIDC token to authenticate the upload");
api_client.set_token(Some(token));

Ok(())
}
}
Expand Down Expand Up @@ -421,4 +496,86 @@ mod tests {
},
);
}

fn api_client(token: Option<&str>) -> CodSpeedAPIClient {
CodSpeedAPIClient::new(
token.map(str::to_string),
"https://gql.codspeed.io/".to_string(),
)
}

/// Whether the runner mints a token is not decided from the environment alone: it is
/// decided by trying to mint one, which only a CircleCI job can do. Only the cases
/// settled before that attempt are asserted here.
#[test]
fn test_static_token_is_used_as_is() {
with_vars(
[
("CIRCLECI", Some("true")),
("CIRCLE_BRANCH", Some("main")),
(
"CIRCLE_REPOSITORY_URL",
Some("git@github.com:my-org/adrien-python-test.git"),
),
("CIRCLE_WORKING_DIRECTORY", Some("/home/circleci/project")),
(
"CIRCLE_WORKFLOW_ID",
Some("8d8f0b2a-1f3e-4b6a-9c2d-0f1e2a3b4c5d"),
),
("CIRCLE_JOB", Some("benchmarks")),
],
|| {
let config = OrchestratorConfig {
..OrchestratorConfig::test()
};
let mut provider = CircleCIProvider::try_from(&config).unwrap();

provider
.check_oidc_configuration(&api_client(Some("a-static-token")))
.unwrap();

assert!(!provider.uses_oidc);
},
);
}

#[test]
fn test_forked_pull_request_requires_a_static_token() {
with_vars(
[
("CIRCLECI", Some("true")),
("CIRCLE_BRANCH", Some("pull/22")),
("CIRCLE_PR_NUMBER", Some("22")),
(
"CIRCLE_REPOSITORY_URL",
Some("git@github.com:my-org/adrien-python-test.git"),
),
("CIRCLE_WORKING_DIRECTORY", Some("/home/circleci/project")),
(
"CIRCLE_WORKFLOW_ID",
Some("8d8f0b2a-1f3e-4b6a-9c2d-0f1e2a3b4c5d"),
),
("CIRCLE_JOB", Some("benchmarks")),
],
|| {
let config = OrchestratorConfig {
..OrchestratorConfig::test()
};
let mut provider = CircleCIProvider::try_from(&config).unwrap();

let error = provider
.check_oidc_configuration(&api_client(None))
.unwrap_err();

assert_eq!(
error.to_string(),
"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"
);

assert!(!provider.uses_oidc);
},
);
}
}
6 changes: 4 additions & 2 deletions src/upload/uploader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ async fn retrieve_upload_data(
RunEnvironment::GitlabCi => {
"Check that the CI job is correctly authenticated. View more at https://codspeed.io/docs/integrations/ci/gitlab-ci/configuration#authentication"
}
// TODO: support OIDC for CircleCI
RunEnvironment::Buildkite | RunEnvironment::Circleci => {
RunEnvironment::Circleci => {
"Check that the CI job is correctly authenticated. View more at https://codspeed.io/docs/integrations/ci/circleci/configuration#authentication"
}
RunEnvironment::Buildkite => {
Comment thread
fargito marked this conversation as resolved.
"Check that CODSPEED_TOKEN is set and has the correct value"
}
RunEnvironment::Local => {
Expand Down