Skip to content
Merged
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
18 changes: 16 additions & 2 deletions src/request_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ use reqwest_middleware::{ClientBuilder as ClientWithMiddlewareBuilder, ClientWit
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};

const UPLOAD_RETRY_COUNT: u32 = 3;
const OIDC_RETRY_COUNT: u32 = 10;
const USER_AGENT: &str = "codspeed-runner";

lazy_static! {
pub static ref REQUEST_CLIENT: ClientWithMiddleware = ClientWithMiddlewareBuilder::new(
ClientBuilder::new()
.user_agent("codspeed-runner")
.user_agent(USER_AGENT)
.build()
.unwrap()
)
Expand All @@ -19,7 +21,19 @@ lazy_static! {

// Client without retry middleware for streaming uploads (can't be cloned)
pub static ref STREAMING_CLIENT: reqwest::Client = ClientBuilder::new()
.user_agent("codspeed-runner")
.user_agent(USER_AGENT)
.build()
.unwrap();

// Client with retry middleware for OIDC token requests
pub static ref OIDC_CLIENT: ClientWithMiddleware = ClientWithMiddlewareBuilder::new(
ClientBuilder::new()
.user_agent(USER_AGENT)
.build()
.unwrap()
)
.with(RetryTransientMiddleware::new_with_policy(
ExponentialBackoff::builder().build_with_max_retries(OIDC_RETRY_COUNT)
))
.build();
}
6 changes: 6 additions & 0 deletions src/run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ pub struct RunArgs {
pub upload_url: Option<String>,

/// The token to use for uploading the results,
///
/// It can be either a CodSpeed token retrieved from the repository setting
/// or an OIDC token issued by the identity provider.
#[arg(long, env = "CODSPEED_TOKEN")]
pub token: Option<String>,

Expand Down Expand Up @@ -205,6 +208,9 @@ pub async fn run(
}
debug!("Using the token from the CodSpeed configuration file");
config.set_token(codspeed_config.auth.token.clone());
} else {
// If relevant, set the OIDC token for authentication
provider.set_oidc_token(&mut config).await?;
}

let system_info = SystemInfo::new()?;
Expand Down
13 changes: 13 additions & 0 deletions src/run/run_environment/buildkite/provider.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::env;

use async_trait::async_trait;
use simplelog::SharedLogger;

use crate::prelude::*;
Expand Down Expand Up @@ -119,6 +120,7 @@ impl RunEnvironmentDetector for BuildkiteProvider {
}
}

#[async_trait(?Send)]
impl RunEnvironmentProvider for BuildkiteProvider {
fn get_repository_provider(&self) -> RepositoryProvider {
RepositoryProvider::GitHub
Expand Down Expand Up @@ -151,6 +153,17 @@ impl RunEnvironmentProvider for BuildkiteProvider {
fn get_run_provider_run_part(&self) -> Option<RunPart> {
None
}

/// For now, we do not support OIDC tokens for Buildkite
///
/// If we want to in the future, we can implement it using the Buildkite Agent CLI.
///
/// Docs:
/// - https://buildkite.com/docs/agent/v3/cli-oidc
/// - https://buildkite.com/docs/pipelines/security/oidc
async fn set_oidc_token(&self, _config: &mut Config) -> Result<()> {
Ok(())
}
}

#[cfg(test)]
Expand Down
158 changes: 151 additions & 7 deletions src/run/run_environment/github_actions/provider.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use async_trait::async_trait;
use git2::Repository;
use lazy_static::lazy_static;
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
use simplelog::SharedLogger;
use std::collections::BTreeMap;
use std::{env, fs};

use crate::prelude::*;
use crate::request_client::OIDC_CLIENT;
use crate::run::run_environment::{RunEnvironment, RunPart};
use crate::run::{
config::Config,
Expand All @@ -30,6 +33,12 @@ pub struct GitHubActionsProvider {
pub gh_data: GhData,
pub event: RunEvent,
pub repository_root_path: String,

/// Indicates whether the head repository is a fork of the base repository.
is_head_repo_fork: bool,

/// Indicates whether the repository is private.
is_repository_private: bool,
}

impl GitHubActionsProvider {
Expand All @@ -42,6 +51,11 @@ impl GitHubActionsProvider {
}
}

#[derive(Deserialize)]
struct OIDCResponse {
value: Option<String>,
}

lazy_static! {
static ref PR_REF_REGEX: Regex = Regex::new(r"^refs/pull/(?P<pr_number>\d+)/merge$").unwrap();
}
Expand All @@ -53,13 +67,18 @@ impl TryFrom<&Config> for GitHubActionsProvider {
bail!("Specifying owner and repository from CLI is not supported for Github Actions");
}
let (owner, repository) = Self::get_owner_and_repository()?;

let github_event_path = get_env_variable("GITHUB_EVENT_PATH")?;
let github_event = fs::read_to_string(github_event_path)?;
let github_event: Value =
serde_json::from_str(&github_event).expect("GITHUB_EVENT_PATH file could not be read");

let ref_ = get_env_variable("GITHUB_REF")?;
let is_pr = PR_REF_REGEX.is_match(&ref_);
let head_ref = if is_pr {
let github_event_path = get_env_variable("GITHUB_EVENT_PATH")?;
let github_event = fs::read_to_string(github_event_path)?;
let github_event: Value = serde_json::from_str(&github_event)
.expect("GITHUB_EVENT_PATH file could not be read");

let is_repository_private = github_event["repository"]["private"].as_bool().unwrap();

let (head_ref, is_head_repo_fork) = if is_pr {
let pull_request = github_event["pull_request"].as_object().unwrap();

let head_repo = pull_request["head"]["repo"].as_object().unwrap();
Expand All @@ -76,9 +95,9 @@ impl TryFrom<&Config> for GitHubActionsProvider {
} else {
pull_request["head"]["ref"].as_str().unwrap().to_owned()
};
Some(head_ref)
(Some(head_ref), is_head_repo_fork)
} else {
None
(None, false)
};

let github_event_name = get_env_variable("GITHUB_EVENT_NAME")?;
Expand Down Expand Up @@ -118,6 +137,8 @@ impl TryFrom<&Config> for GitHubActionsProvider {
}),
base_ref: get_env_variable("GITHUB_BASE_REF").ok(),
repository_root_path,
is_head_repo_fork,
is_repository_private,
})
}
}
Expand All @@ -129,6 +150,7 @@ impl RunEnvironmentDetector for GitHubActionsProvider {
}
}

#[async_trait(?Send)]
impl RunEnvironmentProvider for GitHubActionsProvider {
fn get_repository_provider(&self) -> RepositoryProvider {
RepositoryProvider::GitHub
Expand Down Expand Up @@ -236,6 +258,99 @@ impl RunEnvironmentProvider for GitHubActionsProvider {
.to_string();
Ok(commit_hash)
}

/// Set the OIDC token for GitHub Actions if necessary
///
/// ## Logic
/// - If the user has explicitly set a token in the configuration (i.e. "static token"), do not override it, but display an info message.
/// - Otherwise, check if the necessary environment variables are set to use OIDC.
/// - Then attempt to request an OIDC token.
///
/// If environment variables are not set, this could be because:
/// - The user has misconfigured the workflow (missing `id-token` permission)
/// - The run is from a public fork, in which case GitHub Actions does not provide these environment variables for security reasons.
///
///
/// ## Notes
/// Retrieving the token requires that the workflow has the `id-token` permission enabled.
///
/// Docs:
/// - https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-with-reusable-workflows
/// - https://docs.github.com/en/actions/concepts/security/openid-connect
/// - https://docs.github.com/en/actions/reference/security/oidc#methods-for-requesting-the-oidc-token
async fn set_oidc_token(&self, config: &mut Config) -> Result<()> {
// Check if a static token is already set
if config.token.is_some() {
info!(
"CodSpeed now supports OIDC tokens for authentication.\n\
Benefit from enhanced security by adding the `id-token: write` permission to your workflow and removing the static token from your configuration.\n\
Learn more at https://codspeed.io/docs/integrations/ci/github-actions#openid-connect-oidc-authentication"
);

return Ok(());
}

// The `ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variable is set when the `id-token` permission is granted, which is necessary to authenticate with OIDC.
let request_token = get_env_variable("ACTIONS_ID_TOKEN_REQUEST_TOKEN").ok();
let request_url = get_env_variable("ACTIONS_ID_TOKEN_REQUEST_URL").ok();

if request_token.is_none() || request_url.is_none() {
// If the run is from a fork, it is expected that these environment variables are not set.
// We will fall back to tokenless authentication in this case.
if self.is_head_repo_fork {
return Ok(());
}

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#openid-connect-oidc-authentication"
)
}

info!(
"CodSpeed now supports OIDC tokens for authentication.\n\
Benefit from enhanced security and faster processing times by adding the `id-token: write` permission to your workflow.\n\
Learn more at https://codspeed.io/docs/integrations/ci/github-actions#openid-connect-oidc-authentication"
);

return Ok(());
}

let request_url = request_url.unwrap();
let request_url = format!("{request_url}&audience={}", self.get_oidc_audience());
let request_token = request_token.unwrap();

let token = match OIDC_CLIENT
.get(request_url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {request_token}"))
.send()
.await
{
Ok(response) => match response.json::<OIDCResponse>().await {
Ok(oidc_response) => oidc_response.value,
Err(_) => None,
},
Err(_) => None,
};

if token.is_some() {
debug!("Successfully retrieved OIDC token for authentication.");
config.set_token(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#openid-connect-oidc-authentication"
)
} else {
warn!("Failed to retrieve OIDC token for authentication.");
}

Ok(())
}
}

#[cfg(test)]
Expand Down Expand Up @@ -271,6 +386,16 @@ mod tests {
("GITHUB_ACTOR", Some("actor")),
("GITHUB_BASE_REF", Some("main")),
("GITHUB_EVENT_NAME", Some("push")),
(
"GITHUB_EVENT_PATH",
Some(
format!(
"{}/src/run/run_environment/github_actions/samples/push-event.json",
env!("CARGO_MANIFEST_DIR")
)
.as_str(),
),
),
("GITHUB_JOB", Some("job")),
("GITHUB_REF", Some("refs/heads/main")),
("GITHUB_REPOSITORY", Some("owner/repository")),
Expand Down Expand Up @@ -298,6 +423,8 @@ mod tests {
github_actions_provider.sender.as_ref().unwrap().id,
"1234567890"
);
assert!(!github_actions_provider.is_head_repo_fork);
assert!(!github_actions_provider.is_repository_private);
},
)
}
Expand Down Expand Up @@ -338,6 +465,9 @@ mod tests {
..Config::test()
};
let github_actions_provider = GitHubActionsProvider::try_from(&config).unwrap();
assert!(!github_actions_provider.is_head_repo_fork);
assert!(github_actions_provider.is_repository_private);

let run_environment_metadata = github_actions_provider
.get_run_environment_metadata()
.unwrap();
Expand Down Expand Up @@ -391,6 +521,9 @@ mod tests {
..Config::test()
};
let github_actions_provider = GitHubActionsProvider::try_from(&config).unwrap();
assert!(github_actions_provider.is_head_repo_fork);
assert!(!github_actions_provider.is_repository_private);

let run_environment_metadata = github_actions_provider
.get_run_environment_metadata()
.unwrap();
Expand Down Expand Up @@ -471,6 +604,9 @@ mod tests {
..Config::test()
};
let github_actions_provider = GitHubActionsProvider::try_from(&config).unwrap();
assert!(!github_actions_provider.is_head_repo_fork);
assert!(github_actions_provider.is_repository_private);

let run_environment_metadata = github_actions_provider
.get_run_environment_metadata()
.unwrap();
Expand Down Expand Up @@ -503,6 +639,8 @@ mod tests {
},
event: RunEvent::Push,
repository_root_path: "/home/work/my-repo".into(),
is_head_repo_fork: false,
is_repository_private: false,
};

let run_part = github_actions_provider.get_run_provider_run_part().unwrap();
Expand Down Expand Up @@ -545,6 +683,8 @@ mod tests {
},
event: RunEvent::Push,
repository_root_path: "/home/work/my-repo".into(),
is_head_repo_fork: false,
is_repository_private: false,
};

let run_part = github_actions_provider.get_run_provider_run_part().unwrap();
Expand Down Expand Up @@ -596,6 +736,8 @@ mod tests {
},
event: RunEvent::Push,
repository_root_path: "/home/work/my-repo".into(),
is_head_repo_fork: false,
is_repository_private: false,
};

let run_part = github_actions_provider.get_run_provider_run_part().unwrap();
Expand Down Expand Up @@ -645,6 +787,8 @@ mod tests {
},
event: RunEvent::Push,
repository_root_path: "/home/work/my-repo".into(),
is_head_repo_fork: false,
is_repository_private: false,
};

let run_part = github_actions_provider.get_run_provider_run_part().unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@
},
"sha": "24809d9fca9ad0808a777bcbd807ecd5ec8a9100"
}
},
"repository": {
"private": false
}
}
3 changes: 3 additions & 0 deletions src/run/run_environment/github_actions/samples/pr-event.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@
},
"sha": "24809d9fca9ad0808a777bcbd807ecd5ec8a9100"
}
},
"repository": {
"private": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"repository": {
"private": false
}
}
Loading