diff --git a/crates/turbo-trace/src/main.rs b/crates/turbo-trace/src/main.rs index be8c5cd79857c..5c5f10473ede9 100644 --- a/crates/turbo-trace/src/main.rs +++ b/crates/turbo-trace/src/main.rs @@ -30,7 +30,7 @@ fn main() -> Result<(), PathError> { .map(|f| AbsoluteSystemPathBuf::from_unknown(&abs_cwd, f)) .collect(); - let tracer = Tracer::new(abs_cwd, files, args.ts_config)?; + let tracer = Tracer::new(abs_cwd, files, args.ts_config); let result = tracer.trace(); diff --git a/crates/turborepo-lib/src/config/env.rs b/crates/turborepo-lib/src/config/env.rs index 84601bf9f1da8..1f81dba3b0640 100644 --- a/crates/turborepo-lib/src/config/env.rs +++ b/crates/turborepo-lib/src/config/env.rs @@ -189,60 +189,7 @@ impl ResolvedConfigurationOptions for EnvVars { } } -const VERCEL_ARTIFACTS_MAPPING: &[(&str, &str)] = [ - ("vercel_artifacts_token", "token"), - ("vercel_artifacts_owner", "team_id"), -] -.as_slice(); - -pub struct OverrideEnvVars<'a> { - environment: &'a HashMap, - output_map: HashMap<&'static str, String>, -} - -impl<'a> OverrideEnvVars<'a> { - pub fn new(environment: &'a HashMap) -> Result { - let vercel_artifacts_mapping: HashMap<_, _> = - VERCEL_ARTIFACTS_MAPPING.iter().copied().collect(); - - let output_map = map_environment(vercel_artifacts_mapping, environment)?; - Ok(Self { - environment, - output_map, - }) - } - - fn ui(&self) -> Option { - let value = self - .environment - .get(OsStr::new("ci")) - .or_else(|| self.environment.get(OsStr::new("no_color")))?; - match truth_env_var(value.to_str()?)? { - true => Some(UIMode::Stream), - false => None, - } - } -} - -impl<'a> ResolvedConfigurationOptions for OverrideEnvVars<'a> { - fn get_configuration_options( - &self, - _existing_config: &ConfigurationOptions, - ) -> Result { - let ui = self.ui(); - let output = ConfigurationOptions { - team_id: self.output_map.get("team_id").cloned(), - token: self.output_map.get("token").cloned(), - api_url: None, - ui, - ..Default::default() - }; - - Ok(output) - } -} - -fn truth_env_var(s: &str) -> Option { +pub fn truth_env_var(s: &str) -> Option { match s { "true" | "1" => Some(true), "false" | "0" => Some(false), @@ -251,7 +198,13 @@ fn truth_env_var(s: &str) -> Option { } fn map_environment<'a>( + // keys are environment variable names + // values are properties of ConfigurationOptions we want to store the + // values in mapping: HashMap<&str, &'a str>, + + // keys are environment variable names + // values are the values of those environment variables environment: &HashMap, ) -> Result, Error> { let mut output_map = HashMap::new(); @@ -395,30 +348,4 @@ mod test { assert!(!config.run_summary()); assert!(!config.allow_no_turbo_json()); } - - #[test] - fn test_override_env_setting() { - let mut env: HashMap = HashMap::new(); - - let vercel_artifacts_token = "correct-horse-battery-staple"; - let vercel_artifacts_owner = "bobby_tables"; - - env.insert( - "vercel_artifacts_token".into(), - vercel_artifacts_token.into(), - ); - env.insert( - "vercel_artifacts_owner".into(), - vercel_artifacts_owner.into(), - ); - env.insert("ci".into(), "1".into()); - - let config = OverrideEnvVars::new(&env) - .unwrap() - .get_configuration_options(&ConfigurationOptions::default()) - .unwrap(); - assert_eq!(vercel_artifacts_token, config.token.unwrap()); - assert_eq!(vercel_artifacts_owner, config.team_id.unwrap()); - assert_eq!(Some(UIMode::Stream), config.ui); - } } diff --git a/crates/turborepo-lib/src/config/mod.rs b/crates/turborepo-lib/src/config/mod.rs index c7ad713ef48ed..a32b592d7eb24 100644 --- a/crates/turborepo-lib/src/config/mod.rs +++ b/crates/turborepo-lib/src/config/mod.rs @@ -1,5 +1,6 @@ mod env; mod file; +mod override_env; mod turbo_json; use std::{collections::HashMap, ffi::OsString, io}; @@ -7,10 +8,11 @@ use std::{collections::HashMap, ffi::OsString, io}; use camino::{Utf8Path, Utf8PathBuf}; use convert_case::{Case, Casing}; use derive_setters::Setters; -use env::{EnvVars, OverrideEnvVars}; +use env::EnvVars; use file::{AuthFile, ConfigFile}; use merge::Merge; use miette::{Diagnostic, NamedSource, SourceSpan}; +use override_env::OverrideEnvVars; use serde::Deserialize; use struct_iterable::Iterable; use thiserror::Error; @@ -219,11 +221,14 @@ pub struct ConfigurationOptions { #[serde(alias = "teamslug")] #[serde(alias = "TeamSlug")] #[serde(alias = "TEAMSLUG")] + /// corresponds to env var TURBO_TEAM pub(crate) team_slug: Option, #[serde(alias = "teamid")] #[serde(alias = "TeamId")] #[serde(alias = "TEAMID")] + /// corresponds to env var TURBO_TEAMID pub(crate) team_id: Option, + /// corresponds to env var TURBO_TOKEN pub(crate) token: Option, pub(crate) signature: Option, pub(crate) preflight: Option, @@ -467,9 +472,9 @@ impl TurborepoConfigBuilder { // These are ordered from highest to lowest priority let sources: [Box; 7] = [ - Box::new(override_env_var_config), Box::new(&self.override_config), Box::new(env_var_config), + Box::new(override_env_var_config), Box::new(local_config), Box::new(global_auth), Box::new(global_config), @@ -561,22 +566,16 @@ mod test { vercel_artifacts_owner.into(), ); - let override_config = ConfigurationOptions { - token: Some("unseen".into()), - team_id: Some("unseen".into()), - ..Default::default() - }; - let builder = TurborepoConfigBuilder { repo_root, - override_config, + override_config: Default::default(), global_config_path: Some(global_config_path), environment: Some(env), }; let config = builder.build().unwrap(); - assert_eq!(config.team_id().unwrap(), vercel_artifacts_owner); - assert_eq!(config.token().unwrap(), vercel_artifacts_token); + assert_eq!(config.team_id().unwrap(), turbo_teamid); + assert_eq!(config.token().unwrap(), turbo_token); assert_eq!(config.spaces_id().unwrap(), "my-spaces-id"); } diff --git a/crates/turborepo-lib/src/config/override_env.rs b/crates/turborepo-lib/src/config/override_env.rs new file mode 100644 index 0000000000000..9ae4f1f0b3b54 --- /dev/null +++ b/crates/turborepo-lib/src/config/override_env.rs @@ -0,0 +1,446 @@ +use std::{ + collections::HashMap, + ffi::{OsStr, OsString}, +}; + +use super::{env::truth_env_var, ConfigurationOptions, Error, ResolvedConfigurationOptions}; +use crate::turbo_json::UIMode; + +/* +Hi! If you're new here: +1. The general pattern is that: + - ConfigurationOptions.token corresponds to TURBO_TOKEN or VERCEL_ARTIFACTS_TOKEN + - ConfigurationOptions.team_id corresponds to TURBO_TEAMID or VERCEL_ARTIFACTS_OWNER + - ConfigurationOptions.team_slug corresponds to TURBO_TEAM +1. We're ultimately poking around the env vars looking for _pairs_ that make sense. + Since we presume that users are the only ones sending TURBO_* and Vercel is the only one sending VERCEL_*, we can make some assumptions. Namely, we assume that if we have one of VERCEL_ARTIFACTS_OWNER or VERCEL_ARTIFACTS_TOKEN we will always have both. +1. Watch out for mixing up `TURBO_TEAM` and `TURBO_TEAMID`. Same for ConfigurationOptions.team_id and ConfigurationOptions.team_slug. +*/ + +/// these correspond directly to the environment variables that this module +/// needs to do it's work +#[allow(non_snake_case)] +#[derive(Default, Debug, PartialEq)] +struct Input { + TURBO_TEAM: Option, + TURBO_TEAMID: Option, + TURBO_TOKEN: Option, + VERCEL_ARTIFACTS_OWNER: Option, + VERCEL_ARTIFACTS_TOKEN: Option, +} + +impl Input { + fn new() -> Self { + Self::default() + } +} + +impl<'a> TryFrom<&'a HashMap> for Input { + type Error = Error; + + fn try_from(environment: &'a HashMap) -> Result { + let get_value = |key: &str| -> Result, Error> { + let Some(value) = environment.get(OsStr::new(key)) else { + return Ok(None); + }; + let value = value + .to_str() + .ok_or_else(|| Error::Encoding(key.to_ascii_uppercase()))?; + Ok(Some(value.to_string())) + }; + Ok(Self { + TURBO_TEAM: get_value("turbo_team")?, + TURBO_TEAMID: get_value("turbo_teamid")?, + TURBO_TOKEN: get_value("turbo_token")?, + VERCEL_ARTIFACTS_OWNER: get_value("vercel_artifacts_owner")?, + VERCEL_ARTIFACTS_TOKEN: get_value("vercel_artifacts_token")?, + }) + } +} + +// this is an internal structure (that's a partial of ConfigurationOptions) that +// we use to store +struct Output { + /// maps to ConfigurationOptions.team_id + team_id: Option, + // maps to ConfigurationOptions.team_slug + team_slug: Option, + // maps to ConfigurationOptions.token + token: Option, +} + +impl Output { + fn new() -> Self { + Self { + team_id: None, + team_slug: None, + token: None, + } + } +} + +impl From for Output { + fn from(input: Input) -> Self { + // TURBO_TEAMID+TURBO_TOKEN or TURBO_TEAM+TURBO_TOKEN + if input.TURBO_TOKEN.is_some() + && (input.TURBO_TEAMID.is_some() || input.TURBO_TEAM.is_some()) + { + Output { + team_id: input.TURBO_TEAMID, + team_slug: input.TURBO_TEAM, + token: input.TURBO_TOKEN, + } + } + // if there's both Vercel items, we use those next + else if input.VERCEL_ARTIFACTS_TOKEN.is_some() && input.VERCEL_ARTIFACTS_OWNER.is_some() { + Output { + team_id: input.VERCEL_ARTIFACTS_OWNER, + team_slug: input.TURBO_TEAM, /* this may or may not be Some, but if it is we can + * pass it along too */ + token: input.VERCEL_ARTIFACTS_TOKEN, + } + } + // from this point below, there's no token we can do anything with + // ------------------------------------------------ + else { + Output { + // prefer TURBO_TEAMID to VERCEL_ARTIFACTS_OWNER + team_id: input.TURBO_TEAMID.or(input.VERCEL_ARTIFACTS_OWNER), + // No alternative source for team_slug so always use TURBO_TEAM + team_slug: input.TURBO_TEAM, + token: None, + } + } + } +} + +pub struct OverrideEnvVars<'a> { + environment: &'a HashMap, + output: Output, +} + +impl<'a> OverrideEnvVars<'a> { + pub fn new(environment: &'a HashMap) -> Result { + let input = Input::try_from(environment)?; + let output = Output::from(input); + + Ok(Self { + environment, + output, + }) + } + + fn ui(&self) -> Option { + let value = self + .environment + .get(OsStr::new("ci")) + .or_else(|| self.environment.get(OsStr::new("no_color")))?; + truth_env_var(value.to_str()?)?.then_some(UIMode::Stream) + } +} + +impl<'a> ResolvedConfigurationOptions for OverrideEnvVars<'a> { + fn get_configuration_options( + &self, + _existing_config: &ConfigurationOptions, + ) -> Result { + let output = ConfigurationOptions { + team_id: self.output.team_id.clone(), + token: self.output.token.clone(), + team_slug: self.output.team_slug.clone(), + ui: self.ui(), + ..Default::default() + }; + Ok(output) + } +} + +#[cfg(test)] +mod test { + use super::*; + + const VERCEL_ARTIFACTS_OWNER: &str = "valueof:VERCEL_ARTIFACTS_OWNER"; + const VERCEL_ARTIFACTS_TOKEN: &str = "valueof:VERCEL_ARTIFACTS_TOKEN"; + const TURBO_TEAMID: &str = "valueof:TURBO_TEAMID"; + const TURBO_TEAM: &str = "valueof:TURBO_TEAM"; + const TURBO_TOKEN: &str = "valueof:TURBO_TOKEN"; + + struct TestCase { + input: Input, + output: Output, + reason: &'static str, + } + + impl TestCase { + fn new() -> Self { + Self { + input: Input::new(), + output: Output::new(), + reason: "missing", + } + } + + fn reason(mut self, reason: &'static str) -> Self { + self.reason = reason; + self + } + + #[allow(non_snake_case)] + fn VERCEL_ARTIFACTS_OWNER(mut self) -> Self { + self.input.VERCEL_ARTIFACTS_OWNER = Some(VERCEL_ARTIFACTS_OWNER.into()); + self + } + + #[allow(non_snake_case)] + fn VERCEL_ARTIFACTS_TOKEN(mut self) -> Self { + self.input.VERCEL_ARTIFACTS_TOKEN = Some(VERCEL_ARTIFACTS_TOKEN.into()); + self + } + + #[allow(non_snake_case)] + fn TURBO_TEAMID(mut self) -> Self { + self.input.TURBO_TEAMID = Some(TURBO_TEAMID.into()); + self + } + + #[allow(non_snake_case)] + fn TURBO_TEAM(mut self) -> Self { + self.input.TURBO_TEAM = Some(TURBO_TEAM.into()); + self + } + + #[allow(non_snake_case)] + fn TURBO_TOKEN(mut self) -> Self { + self.input.TURBO_TOKEN = Some(TURBO_TOKEN.into()); + self + } + + fn team_id(mut self, value: &str) -> Self { + self.output.team_id = Some(value.into()); + self + } + + fn team_slug(mut self, value: &str) -> Self { + self.output.team_slug = Some(value.into()); + self + } + + fn token(mut self, value: &str) -> Self { + self.output.token = Some(value.into()); + self + } + } + + #[test] + fn test_all_the_combos() { + let cases: &[TestCase] = &[ + // + // Get nothing back + // ------------------------------ + TestCase::new().reason("no env vars set"), + TestCase::new() + .reason("just VERCEL_ARTIFACTS_TOKEN") + .VERCEL_ARTIFACTS_TOKEN(), + TestCase::new().reason("just TURBO_TOKEN").TURBO_TOKEN(), + // + // When 3rd Party Wins with all three + // ------------------------------ + TestCase::new() + .reason("we can use all of TURBO_TEAM, TURBO_TEAMID, and TURBO_TOKEN") + .TURBO_TEAM() + .TURBO_TEAMID() + .TURBO_TOKEN() + .team_id(TURBO_TEAMID) + .team_slug(TURBO_TEAM) + .token(TURBO_TOKEN), + TestCase::new() + .reason("if we have a 3rd party trifecta, that wins, even against a Vercel Pair") + .TURBO_TEAM() + .TURBO_TEAMID() + .TURBO_TOKEN() + .VERCEL_ARTIFACTS_OWNER() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(TURBO_TEAMID) + .team_slug(TURBO_TEAM) + .token(TURBO_TOKEN), + TestCase::new() + .reason("a 3rd party trifecta wins against a partial Vercel (just artifacts token)") + .TURBO_TEAM() + .TURBO_TEAMID() + .TURBO_TOKEN() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(TURBO_TEAMID) + .team_slug(TURBO_TEAM) + .token(TURBO_TOKEN), + TestCase::new() + .reason("a 3rd party trifecta wins against a partial Vercel (just artifacts owner)") + .TURBO_TEAM() + .TURBO_TEAMID() + .TURBO_TOKEN() + .VERCEL_ARTIFACTS_OWNER() + .team_id(TURBO_TEAMID) + .team_slug(TURBO_TEAM) + .token(TURBO_TOKEN), + // + // When 3rd Party Wins with team_slug + // ------------------------------ + TestCase::new() + .reason("golden path for 3rd party, not deployed on Vercel") + .TURBO_TEAM() + .TURBO_TOKEN() + .team_slug(TURBO_TEAM) + .token(TURBO_TOKEN), + TestCase::new() + .reason( + "a TURBO_TEAM+TURBO_TOKEN pair wins against an incomplete Vercel (just \ + artifacts token)", + ) + .TURBO_TEAM() + .TURBO_TOKEN() + .VERCEL_ARTIFACTS_TOKEN() // disregarded + .team_slug(TURBO_TEAM) + .token(TURBO_TOKEN), + TestCase::new() + .reason("golden path for 3rd party, deployed on Vercel") + .TURBO_TEAM() + .TURBO_TOKEN() + .VERCEL_ARTIFACTS_OWNER() // normally this would map to team_id, but not with a complete 3rd party pair + .VERCEL_ARTIFACTS_TOKEN() + .team_slug(TURBO_TEAM) + .token(TURBO_TOKEN), + // + // When 3rd Party Wins with team_id + // ------------------------------ + TestCase::new() + .reason("if they pass a TURBO_TEAMID and a TURBO_TOKEN, we use them") + .TURBO_TEAMID() + .TURBO_TOKEN() + .team_id(TURBO_TEAMID) + .token(TURBO_TOKEN), + TestCase::new() + .reason("a TURBO_TEAMID+TURBO_TOKEN pair will also win against a Vercel pair") + .TURBO_TEAMID() + .TURBO_TOKEN() + .VERCEL_ARTIFACTS_OWNER() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(TURBO_TEAMID) + .token(TURBO_TOKEN), + TestCase::new() + .reason( + "a TURBO_TEAMID+TURBO_TOKEN pair wins against an incomplete Vercel (just \ + artifacts token)", + ) + .TURBO_TEAMID() + .TURBO_TOKEN() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(TURBO_TEAMID) + .token(TURBO_TOKEN), + // + // When Vercel Wins + // ------------------------------ + TestCase::new() + .reason("golden path on Vercel zero config") + .VERCEL_ARTIFACTS_OWNER() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(VERCEL_ARTIFACTS_OWNER) + .token(VERCEL_ARTIFACTS_TOKEN), + TestCase::new() + .reason("Vercel wins: disregard just TURBO_TOKEN") + .TURBO_TOKEN() + .VERCEL_ARTIFACTS_OWNER() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(VERCEL_ARTIFACTS_OWNER) + .token(VERCEL_ARTIFACTS_TOKEN), + TestCase::new() + .reason("Vercel wins: TURBO_TEAM can join in the fun if it wants") + .TURBO_TEAM() + .VERCEL_ARTIFACTS_OWNER() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(VERCEL_ARTIFACTS_OWNER) + .team_slug(TURBO_TEAM) + .token(VERCEL_ARTIFACTS_TOKEN), + TestCase::new() + .reason("Vercel wins: disregard just TURBO_TEAMID") + .TURBO_TEAMID() + .VERCEL_ARTIFACTS_OWNER() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(VERCEL_ARTIFACTS_OWNER) + .token(VERCEL_ARTIFACTS_TOKEN), + TestCase::new() + .reason("Vercel wins if TURBO_TOKEN is missing") + .TURBO_TEAM() + .TURBO_TEAMID() + .VERCEL_ARTIFACTS_OWNER() + .VERCEL_ARTIFACTS_TOKEN() + .team_id(VERCEL_ARTIFACTS_OWNER) + .team_slug(TURBO_TEAM) + .token(VERCEL_ARTIFACTS_TOKEN), + // + // Just get a team_id + // ------------------------------ + TestCase::new() + .reason("just VERCEL_ARTIFACTS_OWNER") + .VERCEL_ARTIFACTS_OWNER() + .team_id(VERCEL_ARTIFACTS_OWNER), + TestCase::new() + .reason("just TURBO_TEAMID") + .TURBO_TEAMID() + .team_id(TURBO_TEAMID), + // + // Just get a team_slug + // ------------------------------ + TestCase::new() + .reason("just TURBO_TEAM") + .TURBO_TEAM() + .team_slug(TURBO_TEAM), + // + // just team_slug and team_id + // ------------------------------ + TestCase::new() + .reason("if we just have TURBO_TEAM+TURBO_TEAMID, that's ok") + .TURBO_TEAM() + .TURBO_TEAMID() + .team_slug(TURBO_TEAM) + .team_id(TURBO_TEAMID), + // + // just set team_id and team_slug + // ------------------------------ + TestCase::new() + .reason("if we just have a TURBO_TEAM and TURBO_TEAMID we can use them both") + .TURBO_TEAM() + .TURBO_TEAMID() + .team_id(TURBO_TEAMID) + .team_slug(TURBO_TEAM), + ]; + + for case in cases { + let mut env: HashMap = HashMap::new(); + + if let Some(value) = &case.input.TURBO_TEAM { + env.insert("turbo_team".into(), value.into()); + } + if let Some(value) = &case.input.TURBO_TEAMID { + env.insert("turbo_teamid".into(), value.into()); + } + if let Some(value) = &case.input.TURBO_TOKEN { + env.insert("turbo_token".into(), value.into()); + } + if let Some(value) = &case.input.VERCEL_ARTIFACTS_OWNER { + env.insert("vercel_artifacts_owner".into(), value.into()); + } + if let Some(value) = &case.input.VERCEL_ARTIFACTS_TOKEN { + env.insert("vercel_artifacts_token".into(), value.into()); + } + + let actual_input = Input::try_from(&env).unwrap(); + assert_eq!(case.input, actual_input); + + let config = OverrideEnvVars::new(&env).unwrap(); + let reason = case.reason; + + assert_eq!(case.output.team_id, config.output.team_id, "{reason}"); + assert_eq!(case.output.team_slug, config.output.team_slug, "{reason}"); + assert_eq!(case.output.token, config.output.token, "{reason}"); + } + } +}