use crate::acl; use crate::buildtool::BuildExecutor; use std::collections::{HashMap, hash_map::Entry}; use std::fmt; use std::fs::File; use std::io::Read; use std::marker::PhantomData; use std::path::{Path, PathBuf}; use hubcaps::{Credentials, Github, InstallationTokenGenerator, JWTCredentials}; use rustls_pki_types::pem::PemObject as _; use serde::de::{self, Deserializer}; use tracing::{debug, error, info, warn}; /// Main tickborg configuration #[derive(serde::Serialize, serde::Deserialize, Debug)] pub struct Config { /// Configuration for the webhook receiver pub github_webhook_receiver: Option, /// Configuration for the logapi receiver pub log_api_config: Option, /// Configuration for the evaluation filter pub evaluation_filter: Option, /// Configuration for the GitHub comment filter pub github_comment_filter: Option, /// Configuration for the GitHub comment poster pub github_comment_poster: Option, /// Configuration for the mass rebuilder pub mass_rebuilder: Option, /// Configuration for the builder pub builder: Option, /// Configuration for the push filter pub push_filter: Option, /// Configuration for the log message collector pub log_message_collector: Option, /// Configuration for the stats server pub stats: Option, pub runner: RunnerConfig, pub checkout: CheckoutConfig, pub build: BuildConfig, pub github_app: Option, } /// Configuration for the webhook receiver #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct GithubWebhookConfig { /// Listen host/port pub listen: String, /// Path to the GitHub webhook secret pub webhook_secret_file: String, /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, } fn default_logs_path() -> String { "/var/log/tickborg".into() } fn default_serve_root() -> String { "https://logs.tickborg.projecttick.net/logfile".into() } /// Configuration for logapi #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct LogApiConfig { /// Listen host/port pub listen: String, #[serde(default = "default_logs_path")] pub logs_path: String, #[serde(default = "default_serve_root")] pub serve_root: String, } /// Configuration for the evaluation filter #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct EvaluationFilter { /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, } /// Configuration for the GitHub comment filter #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct GithubCommentFilter { /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, } /// Configuration for the GitHub comment poster #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct GithubCommentPoster { /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, } /// Configuration for the mass rebuilder #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct MassRebuilder { /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, } /// Configuration for the builder #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct Builder { /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, } /// Configuration for the push filter #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct PushFilter { /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, /// Default projects/attrs to build when no changed projects are detected. /// If empty and no projects detected, push builds are skipped. #[serde(default)] pub default_attrs: Vec, } /// Configuration for the log message collector #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct LogMessageCollector { /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, /// Path where the logs reside pub logs_path: String, } /// Configuration for the stats exporter #[derive(serde::Serialize, serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct Stats { /// RabbitMQ broker to connect to pub rabbitmq: RabbitMqConfig, } /// Configures the connection to a RabbitMQ instance #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] #[serde(deny_unknown_fields)] pub struct RabbitMqConfig { /// Whether or not to use SSL pub ssl: bool, /// Hostname to conenct to pub host: String, /// Virtual host to use (defaults to /) pub virtualhost: Option, /// Username to connect with pub username: String, /// File to read the user password from. Contents are automatically stripped pub password_file: PathBuf, } #[derive(serde::Serialize, serde::Deserialize, Debug)] pub struct BuildConfig { #[serde(deserialize_with = "deserialize_one_or_many")] pub system: Vec, pub build_timeout_seconds: u16, /// Additional environment variables for build commands pub extra_env: Option>, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] pub struct GithubAppConfig { pub app_id: u64, pub private_key: PathBuf, pub oauth_client_id: String, pub oauth_client_secret_file: PathBuf, } const fn default_instance() -> u8 { 1 } #[derive(serde::Serialize, serde::Deserialize, Debug)] pub struct RunnerConfig { #[serde(default = "default_instance")] pub instance: u8, pub identity: String, /// List of GitHub repos we feel responsible for pub repos: Option>, /// Whether to use the `trusted_users` field or just allow everyone #[serde(default = "Default::default")] pub disable_trusted_users: bool, /// List of users who are allowed to build on less sandboxed platforms pub trusted_users: Option>, /// If true, will create its own queue attached to the build job /// exchange. This means that builders with this enabled will /// trigger duplicate replies to the request for this /// architecture. /// /// This should only be turned on for development. pub build_all_jobs: Option, } #[derive(serde::Serialize, serde::Deserialize, Debug)] pub struct CheckoutConfig { pub root: String, } impl Config { pub fn whoami(&self) -> String { format!("{}-{}", self.runner.identity, self.build.system.join(",")) } pub fn acl(&self) -> acl::Acl { let repos = self .runner .repos .clone() .expect("fetching config's runner.repos"); let trusted_users = if self.runner.disable_trusted_users { None } else { Some( self.runner .trusted_users .clone() .expect("fetching config's runner.trusted_users"), ) }; acl::Acl::new(repos, trusted_users) } pub fn github(&self) -> Github { let token = std::fs::read_to_string( self.github_app .clone() .expect("No GitHub app configured") .oauth_client_secret_file, ) .expect("Couldn't read from GitHub app token"); let token = token.trim(); Github::new( "github.com/Project-Tick/tickborg", Credentials::Client( self.github_app .clone() .expect("No GitHub app configured") .oauth_client_id, token.to_owned(), ), ) .expect("Unable to create a github client instance") } pub fn github_app_vendingmachine(&self) -> GithubAppVendingMachine { GithubAppVendingMachine { conf: self.github_app.clone().unwrap(), id_cache: HashMap::new(), client_cache: HashMap::new(), } } pub fn build_executor(&self) -> BuildExecutor { if self.build.build_timeout_seconds < 300 { error!(?self.build.build_timeout_seconds, "Please set build_timeout_seconds to at least 300"); panic!(); } BuildExecutor::new( self.build.build_timeout_seconds, ) } } impl RabbitMqConfig { pub fn as_uri(&self) -> Result { let password = std::fs::read_to_string(&self.password_file).inspect_err(|_| { error!( "Unable to read RabbitMQ password file at {:?}", self.password_file ); })?; let uri = format!( "{}://{}:{}@{}/{}", if self.ssl { "amqps" } else { "amqp" }, self.username, password, self.host, self.virtualhost.clone().unwrap_or_else(|| "/".to_owned()), ); Ok(uri) } } pub fn load(filename: &Path) -> Config { let mut file = File::open(filename).unwrap(); let mut contents = String::new(); file.read_to_string(&mut contents).unwrap(); let deserialized: Config = serde_json::from_str(&contents).unwrap(); deserialized } pub struct GithubAppVendingMachine { conf: GithubAppConfig, id_cache: HashMap<(String, String), Option>, client_cache: HashMap, } impl GithubAppVendingMachine { fn useragent(&self) -> &'static str { "github.com/Project-Tick/tickborg (app)" } fn jwt(&self) -> JWTCredentials { let pem = rustls_pki_types::PrivatePkcs1KeyDer::from_pem_file(&self.conf.private_key) .expect("Unable to read private key"); let private_key_der = pem.secret_pkcs1_der().to_vec(); JWTCredentials::new(self.conf.app_id, private_key_der) .expect("Unable to create JWTCredentials") } async fn install_id_for_repo(&mut self, owner: &str, repo: &str) -> Option { let useragent = self.useragent(); let jwt = self.jwt(); let key = (owner.to_owned(), repo.to_owned()); match self.id_cache.entry(key) { Entry::Occupied(entry) => *entry.get(), Entry::Vacant(entry) => { info!("Looking up install ID for {}/{}", owner, repo); let lookup_gh = Github::new(useragent, Credentials::JWT(jwt)).unwrap(); let v = match lookup_gh.app().find_repo_installation(owner, repo).await { Ok(install_id) => { debug!("Received install ID {:?}", install_id); Some(install_id.id) } Err(e) => { warn!("Error during install ID lookup: {:?}", e); None } }; *entry.insert(v) } } } pub async fn for_repo<'a>(&'a mut self, owner: &str, repo: &str) -> Option<&'a Github> { let useragent = self.useragent(); let jwt = self.jwt(); let install_id = self.install_id_for_repo(owner, repo).await?; Some(self.client_cache.entry(install_id).or_insert_with(|| { Github::new( useragent, Credentials::InstallationToken(InstallationTokenGenerator::new(install_id, jwt)), ) .expect("Unable to create a github client instance") })) } } // Copied from https://stackoverflow.com/a/43627388 fn deserialize_one_or_many<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { struct StringOrVec(PhantomData>); impl<'de> de::Visitor<'de> for StringOrVec { type Value = Vec; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("string or list of strings") } fn visit_str(self, value: &str) -> Result where E: de::Error, { Ok(vec![value.to_owned()]) } fn visit_seq(self, visitor: S) -> Result where S: de::SeqAccess<'de>, { serde::de::Deserialize::deserialize(de::value::SeqAccessDeserializer::new(visitor)) } } deserializer.deserialize_any(StringOrVec(PhantomData)) }