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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
submodules: true
- uses: ./.github/actions/install-rust
- name: Run tests
run: cargo run -- exec -m simulation,walltime,memory --skip-upload --warmup-time 0s --max-rounds 5 -- ls -la
run: cargo run -- exec -m simulation,walltime,memory --warmup-time 0s --max-rounds 5 -- sleep 1

macos-basic-run-test:
runs-on: macos-latest
Expand Down
13 changes: 12 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ serde = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }
url = "2.4.1"
sha256 = "1.4.0"
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { version = "1", features = ["macros", "rt", "signal"] }
tokio-tar = { package = "astral-tokio-tar", version = "0.6.0" }
tokio-util = "0.7.16"
md5 = "0.7.0"
Expand All @@ -43,7 +43,7 @@ simplelog = { version = "0.12.1", default-features = false, features = [
tempfile = { workspace = true }
git2 = "0.20.4"
nestify = "0.3.3"
gql_client = { git = "https://github.com/CodSpeedHQ/gql-client-rs" }
gql_client = { git = "https://github.com/CodSpeedHQ/gql-client-rs", rev = "617a889e1d9089b5aa36679f1a5aa13ad01993ae" }
serde_yaml = "0.9.34"
sysinfo = { version = "0.33.1", features = ["serde"] }
indicatif = "0.17.8"
Expand Down
258 changes: 172 additions & 86 deletions src/api_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ use std::fmt::Display;
use crate::executor::ExecutorName;
use crate::prelude::*;
use crate::run_environment::RepositoryProvider;
use crate::{cli::Cli, config::CodSpeedConfig};
use console::style;
use gql_client::{Client as GQLClient, ClientConfig};
use nestify::nest;
Expand All @@ -12,36 +11,62 @@ use serde::{Deserialize, Serialize};
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<String>,
}

impl TryFrom<(&Cli, &CodSpeedConfig)> for CodSpeedAPIClient {
type Error = Error;
fn try_from((args, codspeed_config): (&Cli, &CodSpeedConfig)) -> Result<Self> {
Ok(Self {
gql_client: build_gql_api_client(codspeed_config, args.api_url.clone(), true),
unauthenticated_gql_client: build_gql_api_client(
codspeed_config,
args.api_url.clone(),
false,
),
})
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<String>, api_url: String) -> Self {
Self {
gql_client: build_gql_api_client(token.as_deref(), api_url.clone()),
unauthenticated_gql_client: build_gql_api_client(None, api_url.clone()),
api_url,
token,
}
}

/// 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())
}

/// 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()
}

/// 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<String>) {
self.gql_client = build_gql_api_client(token.as_deref(), self.api_url.clone());
self.token = token;
}
}

fn build_gql_api_client(
codspeed_config: &CodSpeedConfig,
api_url: String,
with_auth: bool,
) -> GQLClient {
let headers = if with_auth && codspeed_config.auth.token.is_some() {
let mut headers = std::collections::HashMap::new();
headers.insert(
"Authorization".to_string(),
codspeed_config.auth.token.clone().unwrap(),
);
headers
} else {
Default::default()
fn build_gql_api_client(token: Option<&str>, api_url: String) -> GQLClient {
let headers = match token {
Some(token) => {
let mut headers = std::collections::HashMap::new();
headers.insert("Authorization".to_string(), token.to_owned());
headers
}
None => Default::default(),
};

GQLClient::new_with_config(ClientConfig {
Expand Down Expand Up @@ -283,47 +308,141 @@ nest! {
}
}

#[derive(Serialize, Clone)]
nest! {
#[derive(Debug, Deserialize, Serialize, Clone)]*
#[serde(rename_all = "camelCase")]*
pub struct SessionPayload {
pub user: Option<pub struct SessionUser {
pub login: String,
pub provider: RepositoryProvider,
}>,
}
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetRepositoryVars {
struct SessionData {
session: SessionPayload,
}

/// Outcome of [`CodSpeedAPIClient::session`]. The CLI distinguishes
/// "no/expired token" from any other error so it can render a clear message.
pub enum SessionError {
/// Token is missing or no longer valid.
Unauthenticated,
/// Anything else (network, server error, etc).
Other(anyhow::Error),
}

#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RepositoryOverviewPayload {
pub owner: String,
pub name: String,
pub provider: RepositoryProvider,
pub has_write_access: bool,
}

nest! {
#[derive(Debug, Deserialize, Serialize, Clone)]*
#[serde(rename_all = "camelCase")]*
struct GetRepositoryData {
repository_overview: Option<pub struct GetRepositoryPayload {
pub id: String,
}>,
user: Option<pub struct GetRepositoryUser {
pub id: String,
}>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SessionAndRepositoryOverviewVars {
pub owner: String,
pub name: String,
pub provider: Option<RepositoryProvider>,
}

nest! {
#[derive(Debug, Deserialize, Serialize)]*
#[serde(rename_all = "camelCase")]*
struct CurrentUserData {
user: Option<pub struct CurrentUserPayload {
pub login: String,
pub provider: RepositoryProvider,
}>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct SessionAndRepositoryOverviewData {
session: SessionPayload,
repository_overview: Option<RepositoryOverviewPayload>,
}

pub struct SessionAndRepositoryOverview {
pub session: SessionPayload,
pub repository_overview: Option<RepositoryOverviewPayload>,
}

/// Outcome of [`CodSpeedAPIClient::session_and_repository_overview`]. A
/// missing repository is folded into the success path (`repository_overview`
/// becomes `None`); only a missing/expired token or a transport-level
/// failure surfaces here.
pub enum SessionAndRepositoryOverviewError {
Unauthenticated,
Other(anyhow::Error),
}

impl CodSpeedAPIClient {
pub async fn get_current_user(&self) -> Result<Option<CurrentUserPayload>> {
/// Introspect the token currently configured on this client.
///
/// Returns the linked user (when applicable). Used to verify a token's
/// validity without conflating it with repository-level access checks —
/// those are done with [`Self::session_and_repository_overview`].
pub async fn session(&self) -> std::result::Result<SessionPayload, SessionError> {
let response = self
.gql_client
.query_unwrap::<CurrentUserData>(include_str!("queries/CurrentUser.gql"))
.query_unwrap::<SessionData>(include_str!("queries/Session.gql"))
.await;
match response {
Ok(data) => Ok(data.user),
Err(err) => bail!("Failed to get current user: {err}"),
Ok(data) => Ok(data.session),
Err(err) if err.contains_error_code("UNAUTHENTICATED") => {
Err(SessionError::Unauthenticated)
}
Err(err) => Err(SessionError::Other(anyhow!(
"Failed to validate token: {err}"
))),
}
}

/// Validate the token and look up a candidate repository in one
/// round-trip. Used by `auth status` (with a detected git remote) and
/// the up-front check in `run`/`exec`.
///
/// `repositoryOverview` is nullable in the schema, so a missing
/// repository surfaces as `repository_overview: None` on the success
/// path. The server still returns a `REPOSITORY_NOT_FOUND` error in
/// that case to avoid leaking existence info, but the partial-data
/// payload carries the `session` field — we deserialize it from the
/// error and treat the call as successful for the session's purposes.
pub async fn session_and_repository_overview(
&self,
vars: SessionAndRepositoryOverviewVars,
) -> std::result::Result<SessionAndRepositoryOverview, SessionAndRepositoryOverviewError> {
let response = self
.gql_client
.query_with_vars_unwrap::<
SessionAndRepositoryOverviewData,
SessionAndRepositoryOverviewVars,
>(
include_str!("queries/SessionAndRepositoryOverview.gql"),
vars,
)
.await;
match response {
Ok(data) => Ok(SessionAndRepositoryOverview {
session: data.session,
repository_overview: data.repository_overview,
}),
Err(err) if err.contains_error_code("UNAUTHENTICATED") => {
Err(SessionAndRepositoryOverviewError::Unauthenticated)
}
Err(err) if err.contains_error_code("REPOSITORY_NOT_FOUND") => {
match err.data::<SessionAndRepositoryOverviewData>() {
Some(Ok(data)) => Ok(SessionAndRepositoryOverview {
session: data.session,
repository_overview: None,
}),
Some(Err(decode_err)) => Err(SessionAndRepositoryOverviewError::Other(
anyhow!("Failed to deserialize partial response data: {decode_err}"),
)),
None => Err(SessionAndRepositoryOverviewError::Other(anyhow!(
"Server returned REPOSITORY_NOT_FOUND without partial data: {err}"
))),
}
}
Err(err) => Err(SessionAndRepositoryOverviewError::Other(anyhow!(
"Failed to validate token and repository: {err}"
))),
}
}

Expand Down Expand Up @@ -423,38 +542,6 @@ impl CodSpeedAPIClient {
Err(err) => bail!("Failed to get or create project repository: {err}"),
}
}

/// Check if a repository exists in CodSpeed.
/// Returns Some(payload) if the repository exists, None otherwise.
pub async fn get_repository(
&self,
vars: GetRepositoryVars,
) -> Result<Option<GetRepositoryPayload>> {
let response = self
.gql_client
.query_with_vars_unwrap::<GetRepositoryData, GetRepositoryVars>(
include_str!("queries/GetRepository.gql"),
vars.clone(),
)
.await;
match response {
Ok(response) => {
if response.user.is_none() {
bail!(
"Your session has expired, please login again using `codspeed auth login`"
);
}
Ok(response.repository_overview)
}
Err(err) if err.contains_error_code("REPOSITORY_NOT_FOUND") => Ok(None),
Err(err) if err.contains_error_code("UNAUTHENTICATED") => {
bail!("Your session has expired, please login again using `codspeed auth login`")
}
Err(err) => {
bail!("Failed to get repository: {err}")
}
}
}
}

impl CodSpeedAPIClient {
Expand All @@ -467,7 +554,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 {
let codspeed_config = CodSpeedConfig::default();
Self::try_from((&Cli::test_with_url(api_url), &codspeed_config)).unwrap()
Self::new(None, api_url)
}
}
Loading