diff --git a/.gitignore b/.gitignore index e5987ad..50df52e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,13 @@ /target + +# +# Files needed when running buildomat locally with xtask. +# +/.local + +# +# Files needed when running buildomat locally without xtask. +# /config.toml /data.sqlite3 /cache diff --git a/Cargo.lock b/Cargo.lock index f2dadf4..567ea36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,6 +1158,18 @@ dependencies = [ "cc", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1400,6 +1412,16 @@ dependencies = [ "num_enum", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", +] + [[package]] name = "digest" version = "0.10.7" @@ -1583,6 +1605,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -3945,6 +3973,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -5283,6 +5317,31 @@ dependencies = [ "tempfile", ] +[[package]] +name = "xtask-setup" +version = "0.0.0" +dependencies = [ + "anyhow", + "aws-config", + "aws-runtime", + "aws-sdk-ec2", + "aws-sdk-s3", + "aws-types", + "buildomat-client", + "buildomat-common", + "dialoguer", + "dropshot", + "http 1.4.0", + "rand 0.9.2", + "reqwest", + "schemars 0.8.22", + "serde", + "serde_json", + "slog", + "tokio", + "toml 0.8.23", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 19bc453..d2bb82c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "sse", "types", "xtask", + "xtask-setup", ] resolver = "2" @@ -37,6 +38,7 @@ ansi-to-html = { version = "0.2", features = [ "lazy-init" ] } anyhow = "1" aws-config = "1" aws-credential-types = "1" +aws-runtime = "1" aws-sdk-ec2 = "1" aws-sdk-s3 = "1" aws-types = "1" @@ -45,6 +47,7 @@ bytes = "1.1" chrono = { version = "0.4", features = [ "serde" ] } debug_parser = "0.1" devinfo = { version = "0.1", features = [ "private" ] } +dialoguer = { version = "0.12.0", default-features = false } dirs-next = "2" dropshot = "0.16" futures = "0.3" diff --git a/README.md b/README.md index ce132ee..b60ba2d 100644 --- a/README.md +++ b/README.md @@ -659,6 +659,36 @@ Configuration properties supported for basic jobs include: environment running in an ephemeral virtual machine, with a reasonable set of build tools. 32GB of RAM and 200GB of disk should be available. +## Running Buildomat locally + +The `cargo xtask local` set of commands helps you run a local buildomat. + +The first thing to do is setting it up. You will need [a configured AWS +profile][aws-profile] pointing to the AWS account that will host storage and +compute, and public DNS records pointing to ports 9979 (for `buildomat-server`) +and 4021 (for `buildomat-github-server`). Once you have those, you can run the +setup: + +``` +$ cargo xtask local setup +``` + +The setup will ask which components to configure, ask you a few questions, and +create all the resources and configuration files you'll need. Afterwards, you +can start the individual components: + +* `cargo xtask local buildomat-server` +* `cargo xtask local buildomat-factory-aws` +* `cargo xtask local buildomat-github-server` + +You can also interact with the local server using the CLI: + +``` +$ cargo xtask local buildomat COMMAND +``` + +[aws-profile]: https://docs.aws.amazon.com/cli/v1/userguide/cli-chap-authentication.html + ## Licence Unless otherwise noted, all components are licenced under the [Mozilla Public diff --git a/bin/src/config.rs b/bin/src/config.rs index 5fc296f..c9af4a8 100644 --- a/bin/src/config.rs +++ b/bin/src/config.rs @@ -73,10 +73,14 @@ pub fn load(profile_name: Option<&str>) -> Result { /* * Next, locate our configuration file. */ - let mut path = dirs_next::config_dir() - .ok_or_else(|| anyhow!("could not find config directory"))?; - path.push("buildomat"); - path.push("config.toml"); + let path = if let Some(path) = std::env::var_os("BUILDOMAT_CONFIG") { + path.into() + } else { + dirs_next::config_dir() + .ok_or_else(|| anyhow!("could not find config directory"))? + .join("buildomat") + .join("config.toml") + }; let c: Config = read_file(&path).with_context(|| anyhow!("reading file {:?}", path))?; diff --git a/factory/aws/src/aws.rs b/factory/aws/src/aws.rs index 33b6ed3..9ffd346 100644 --- a/factory/aws/src/aws.rs +++ b/factory/aws/src/aws.rs @@ -146,7 +146,7 @@ async fn create_instance( .run_instances() .image_id(&target.ami) .instance_type(InstanceType::from_str(&target.instance_type)?) - .key_name(&config.aws.key) + .set_key_name(config.aws.key.clone()) .min_count(1) .max_count(1) .tag_specifications( @@ -192,7 +192,7 @@ async fn create_instance( InstanceNetworkInterfaceSpecification::builder() .subnet_id(subnet) .device_index(0) - .associate_public_ip_address(false) + .associate_public_ip_address(config.aws.public_ip) .groups(&config.aws.security_group) .build(), ) diff --git a/factory/aws/src/config.rs b/factory/aws/src/config.rs index 3337c76..67efca5 100644 --- a/factory/aws/src/config.rs +++ b/factory/aws/src/config.rs @@ -50,9 +50,11 @@ pub(crate) struct ConfigFileAws { pub vpc: String, pub subnet: ConfigFileAwsSubnets, pub tag: String, - pub key: String, + pub key: Option, pub security_group: String, pub limit_total: usize, + #[serde(default = "default_false")] + pub public_ip: bool, } impl ConfigFileAws { @@ -82,3 +84,6 @@ impl ConfigFileAwsSubnets { } } } +fn default_false() -> bool { + false +} diff --git a/github/ghtool/src/config.rs b/github/ghtool/src/config.rs index d774bec..d89807f 100644 --- a/github/ghtool/src/config.rs +++ b/github/ghtool/src/config.rs @@ -13,8 +13,6 @@ use serde::Deserialize; #[derive(Deserialize)] pub struct Config { pub id: u64, - #[allow(unused)] - pub secret: String, } pub fn load_bytes>(p: P) -> Result> { diff --git a/github/server/src/config.rs b/github/server/src/config.rs index 969a458..6444577 100644 --- a/github/server/src/config.rs +++ b/github/server/src/config.rs @@ -25,8 +25,6 @@ pub struct Buildomat { #[derive(Deserialize)] pub struct Config { pub id: u64, - #[allow(unused)] - pub secret: String, pub webhook_secret: String, pub base_url: String, pub confroot: String, diff --git a/xtask-setup/Cargo.toml b/xtask-setup/Cargo.toml new file mode 100644 index 0000000..0948d01 --- /dev/null +++ b/xtask-setup/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "xtask-setup" +edition.workspace = true +license.workspace = true +version.workspace = true + +[dependencies] +buildomat-common = { path = "../common" } +buildomat-client = { path = "../client" } + +anyhow = { workspace = true } +aws-config = { workspace = true } +aws-runtime = { workspace = true } +aws-sdk-ec2 = { workspace = true } +aws-sdk-s3 = { workspace = true } +aws-types = { workspace = true } +dialoguer = { workspace = true } +dropshot = { workspace = true } +http = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +slog = { workspace = true } +tokio = { workspace = true } +toml = { workspace = true } diff --git a/xtask-setup/src/factory_aws.rs b/xtask-setup/src/factory_aws.rs new file mode 100644 index 0000000..9f80796 --- /dev/null +++ b/xtask-setup/src/factory_aws.rs @@ -0,0 +1,508 @@ +/* + * Copyright 2026 Oxide Computer Company + */ + +use std::collections::HashMap; +use std::iter::once; + +use anyhow::{anyhow, Context as _, Result}; +use aws_config::{BehaviorVersion, Region}; +use aws_sdk_ec2::types::{ + AttributeBooleanValue, Filter, LocationType, ResourceType, Tag, + TagSpecification, +}; +use aws_sdk_ec2::Client as EC2Client; +use buildomat_client::types::{FactoryCreate, TargetCreate, TargetRedirect}; +use buildomat_client::Client as Buildomat; +use buildomat_common::genkey; +use rand::seq::IteratorRandom; +use serde::Serialize; + +use crate::server::ServerConfig; +use crate::with_api::with_api; +use crate::Context; + +const UBUNTU_RELEASE: &str = "24.04"; +const INSTANCE_TYPE: &str = "c8a.2xlarge"; +const VPC_CIDR: &str = "10.0.0.0/16"; + +pub(crate) async fn setup(ctx: &Context) -> Result<()> { + let root = ctx.root.join("factory-aws"); + if root.exists() { + std::fs::remove_dir_all(&root) + .context("failed to remove old factory-aws directory")?; + } + std::fs::create_dir_all(&root) + .context("failed to create factory-aws directory")?; + + eprintln!(); + eprintln!("buildomat-factory-aws setup"); + eprintln!("==========================="); + eprintln!(); + + /* + * The user already made a choice on where to store data by selecting an AWS + * profile and S3 bucket (which belongs to a region) when setting up the + * core server. Rather than asking again, reuse those choices. + */ + let server_config = ServerConfig::from_context(ctx)?; + let sdk_config = aws_config::defaults(BehaviorVersion::latest()) + .profile_name(&server_config.storage.profile) + .region(Region::new(server_config.storage.region.clone())) + .load() + .await; + let ec2 = EC2Client::new(&sdk_config); + + eprintln!("Configuring the AWS account to run buildomat jobs..."); + let vpc = create_vpc(ctx, &ec2).await?; + let subnet = create_subnet(ctx, &ec2, &vpc).await?; + let sg = create_security_group(ctx, &ec2, &vpc).await?; + create_internet_gateway(ctx, &ec2, &vpc).await?; + + let ami = find_ubuntu_ami(&ec2, UBUNTU_RELEASE).await?; + + eprintln!("Configuring the buildomat server to run jobs on AWS..."); + let (target_id, factory_token) = with_api(ctx, async |bmat| { + let target_name = format!("ubuntu-{UBUNTU_RELEASE}"); + let target_id = create_target(bmat, target_name).await?; + let factory_token = create_factory(bmat).await?; + Ok((target_id, factory_token)) + }) + .await?; + + std::fs::write( + root.join("config.toml"), + toml::to_string_pretty(&FactoryAwsConfig { + general: FactoryAwsGeneral { + baseurl: server_config.general.baseurl, + }, + factory: FactoryAwsFactory { token: factory_token }, + aws: FactoryAwsAws { + profile: server_config.storage.profile, + region: server_config.storage.region, + vpc, + subnet, + security_group: sg, + tag: ctx.setup_name.clone(), + public_ip: true, + limit_total: 50, + }, + target: once(( + target_id, + FactoryAwsTarget { + instance_type: INSTANCE_TYPE.into(), + root_size_gb: 200, + ami, + }, + )) + .collect(), + })? + .as_bytes(), + ) + .context("failed to write factory configuration file")?; + + std::fs::write(root.join("complete"), b"true\n") + .context("failed to mark the factory setup as complete")?; + + Ok(()) +} + +async fn create_vpc(ctx: &Context, ec2: &EC2Client) -> Result { + let name = &ctx.setup_name; + + /* + * The VPC might've been created already in an incomplete setup. + */ + let existing = ec2 + .describe_vpcs() + .filters(filter("tag:Name", name)) + .send() + .await + .with_context(|| { + format!("failed to check if a VPC named {name:?} exists") + })? + .vpcs() + .first() + .cloned(); + if let Some(existing) = existing { + return Ok(existing.vpc_id.unwrap()); + } + + Ok(ec2 + .create_vpc() + .cidr_block(VPC_CIDR) + .tag_specifications(tags(ctx, ResourceType::Vpc)) + .send() + .await + .context("failed to create VPC")? + .vpc + .unwrap() + .vpc_id + .unwrap()) +} + +async fn create_subnet( + ctx: &Context, + ec2: &EC2Client, + vpc: &str, +) -> Result { + let name = &ctx.setup_name; + + /* + * The subnet might've been created already in an incomplete setup. + */ + let existing = ec2 + .describe_subnets() + .filters(filter("tag:Name", name)) + .filters(filter("vpc-id", vpc)) + .send() + .await + .with_context(|| { + format!("failed to check if the subnet {name:?} exists") + })? + .subnets() + .first() + .cloned(); + let id = if let Some(existing) = existing { + existing.subnet_id.unwrap() + } else { + let az = pick_availability_zone(ec2, INSTANCE_TYPE).await?; + ec2.create_subnet() + .tag_specifications(tags(ctx, ResourceType::Subnet)) + .vpc_id(vpc) + .availability_zone(az) + /* + * Have this subnet cover the whole address space of the VPC, as + * we are going to have a single subnet anyway. + */ + .cidr_block(VPC_CIDR) + .send() + .await + .context("failed to create subnet")? + .subnet + .unwrap() + .subnet_id + .unwrap() + }; + + /* + * Configure the subnet to auto-assign IPv4 and IPv6 addresses, so that the + * factory can choose whether to assign an IP address or not. + */ + let booilder = AttributeBooleanValue::builder().value(true).build(); + ec2.modify_subnet_attribute() + .subnet_id(&id) + .map_public_ip_on_launch(booilder) + .send() + .await + .with_context(|| { + format!("failed to update settings of subnet {id:?}") + })?; + + Ok(id) +} + +async fn create_security_group( + ctx: &Context, + ec2: &EC2Client, + vpc: &str, +) -> Result { + let name = &ctx.setup_name; + + /* + * While we create security groups with unique names, the group might've + * been created already in an incomplete setup. + */ + let existing = ec2 + .describe_security_groups() + .filters(filter("tag:Name", name)) + .filters(filter("vpc-id", vpc)) + .send() + .await + .with_context(|| { + format!("failed to check if security group {name:?} exists") + })? + .security_groups() + .first() + .cloned(); + if let Some(existing) = existing { + return Ok(existing.group_id.unwrap()); + } + + /* + * The default security group configuration allows no inbound traffic and + * full outbound traffic, which is what we want here. No need to add rules. + */ + Ok(ec2 + .create_security_group() + .group_name(name) + .description("VMs running buildomat jobs in a local setup.") + .tag_specifications(tags(ctx, ResourceType::SecurityGroup)) + .vpc_id(vpc) + .send() + .await + .context("failed to create AWS security group")? + .group_id + .unwrap()) +} + +async fn create_internet_gateway( + ctx: &Context, + ec2: &EC2Client, + vpc: &str, +) -> Result<()> { + let name = &ctx.setup_name; + + /* + * The gateway might've been created already in an incomplete setup. + */ + let existing = ec2 + .describe_internet_gateways() + .filters(filter("tag:Name", name)) + .send() + .await + .with_context(|| { + format!("failed to check if internet gateway {name:?} exists") + })? + .internet_gateways() + .first() + .cloned(); + + let gateway = if let Some(existing) = existing { + existing + } else { + ec2.create_internet_gateway() + .tag_specifications(tags(ctx, ResourceType::InternetGateway)) + .send() + .await + .context("failed to create internet gateway")? + .internet_gateway + .unwrap() + }; + let gateway_id = gateway.internet_gateway_id.clone().unwrap(); + + /* + * When created, gateways are not attached to any VPC: attach it. + */ + if gateway.attachments().is_empty() { + ec2.attach_internet_gateway() + .vpc_id(vpc) + .internet_gateway_id(&gateway_id) + .send() + .await + .with_context(|| { + format!("failed to attach IG {gateway_id:?} to VPC {vpc:?}") + })?; + } + + /* + * Attach the internet gateway to the VPC. + */ + let route_tables = ec2 + .describe_route_tables() + .filters(filter("vpc-id", vpc)) + .into_paginator() + .send() + .collect::, _>>() + .await + .with_context(|| { + format!("failed to list route tables for VPC {vpc:?}") + })? + .into_iter() + .flat_map(|page| page.route_tables().to_vec()); + for route_table in route_tables { + let id = route_table.route_table_id().unwrap(); + if route_table + .routes() + .iter() + .all(|route| route.destination_cidr_block() != Some("0.0.0.0/0")) + { + ec2.create_route() + .route_table_id(id) + .gateway_id(&gateway_id) + .destination_cidr_block("0.0.0.0/0") + .send() + .await + .with_context(|| { + format!( + "failed to add gateway {gateway_id:?} \ + to route table {id:?}" + ) + })?; + } + } + + Ok(()) +} + +/* + * We are configuring the factory with an Ubuntu AMI by default as the user + * might not have access to an AWS account with Helios images. + * + * https://documentation.ubuntu.com/aws/aws-how-to/instances/find-ubuntu-images/ + */ +async fn find_ubuntu_ami(ec2: &EC2Client, release: &str) -> Result { + const CANONICAL_ACCOUNT: &str = "099720109477"; + + let mut images = ec2 + .describe_images() + .filters(filter( + "name", + format!("ubuntu/images/hvm-ssd*/ubuntu-*-{release}-amd64-server-*"), + )) + .owners(CANONICAL_ACCOUNT) + .into_paginator() + .send() + .collect::, _>>() + .await + .context("failed to list Ubuntu images in AWS")? + .into_iter() + .flat_map(|page| page.images().to_vec()) + .collect::>(); + + images.sort_by_key(|image| image.creation_date.clone()); + let image = images + .pop() + .ok_or_else(|| anyhow!("couldn't find any Ubuntu {release} images"))?; + assert_eq!(image.owner_id(), Some(CANONICAL_ACCOUNT)); + + Ok(image.image_id.unwrap()) +} + +/** + * AWS is annoying and not all availability zones in a region support any given + * instance type. For example, at the time of writing this, only two AZs in + * "eu-central-1" (out of three) support the "c8a.2xlarge" instance type. We + * need to learn which AZs support the instance we care about, and be careful + * later to only choose an AZ supporting it. + */ +async fn pick_availability_zone( + ec2: &EC2Client, + type_: &str, +) -> Result { + ec2.describe_instance_type_offerings() + .location_type(LocationType::AvailabilityZone) + .filters(Filter::builder().name("instance-type").values(type_).build()) + .into_paginator() + .send() + .collect::, _>>() + .await + .with_context(|| { + format!("failed to get the AZs supporting instance {type_:?}") + })? + .into_iter() + .flat_map(|page| page.instance_type_offerings().to_vec()) + .map(|ito| ito.location.unwrap()) + .choose(&mut rand::rng()) + .ok_or_else(|| anyhow!("no AZs found supporting instance {type_}")) +} + +async fn create_factory(bmat: &Buildomat) -> Result { + /* + * Buildomat doesn't have a way to retrieve or regenerate the token of an + * existing factory. Since the setup process might fail between here and + * completion, we can't use a fixed name, since executing the setup again + * would try to create a factory with a duplicate name. + */ + let name = format!("aws-{}", genkey(8)); + + Ok(bmat + .factory_create() + .body(FactoryCreate { name }) + .send() + .await + .context("failed to create the buildomat factory")? + .into_inner() + .token) +} + +async fn create_target(bmat: &Buildomat, name: String) -> Result { + /* + * Create the requested target if missing. + */ + let existing = bmat + .targets_list() + .send() + .await + .context("failed to list buildomat targets")? + .into_inner(); + let id = if let Some(local) = existing.iter().find(|t| t.name == name) { + local.id.clone() + } else { + let local = bmat + .target_create() + .body(TargetCreate { + name: name.clone(), + desc: "Target created by \"cargo xtask local setup\".".into(), + }) + .send() + .await + .context("failed to create the local buildomat target")? + .into_inner(); + local.id + }; + + /* + * Point "default" to the target we just created. + */ + if let Some(default) = existing.iter().find(|t| t.name == "default") { + bmat.target_redirect() + .target(&default.id) + .body(TargetRedirect { redirect: Some(id.clone()) }) + .send() + .await + .with_context(|| { + format!("failed to redirect target \"default\" to {name:?}") + })?; + } + + Ok(id) +} + +fn filter(name: &str, value: impl Into) -> Filter { + Filter::builder().name(name).values(value.into()).build() +} + +fn tags(ctx: &Context, resource_type: ResourceType) -> TagSpecification { + TagSpecification::builder() + .resource_type(resource_type) + .tags(Tag::builder().key("Name").value(&ctx.setup_name).build()) + .build() +} + +#[derive(Serialize)] +struct FactoryAwsConfig { + general: FactoryAwsGeneral, + factory: FactoryAwsFactory, + aws: FactoryAwsAws, + target: HashMap, +} + +#[derive(Serialize)] +struct FactoryAwsGeneral { + baseurl: String, +} + +#[derive(Serialize)] +struct FactoryAwsFactory { + token: String, +} + +#[derive(Serialize)] +struct FactoryAwsAws { + profile: String, + region: String, + vpc: String, + subnet: String, + security_group: String, + tag: String, + limit_total: u32, + public_ip: bool, +} + +#[derive(Serialize)] +struct FactoryAwsTarget { + instance_type: String, + root_size_gb: u32, + ami: String, +} diff --git a/xtask-setup/src/github_server/failure.html b/xtask-setup/src/github_server/failure.html new file mode 100644 index 0000000..53f7385 --- /dev/null +++ b/xtask-setup/src/github_server/failure.html @@ -0,0 +1,19 @@ + + + + + Invalid callback + + + +

Error: the parameters are incorrect.

+ + + diff --git a/xtask-setup/src/github_server/index.html b/xtask-setup/src/github_server/index.html new file mode 100644 index 0000000..7be5093 --- /dev/null +++ b/xtask-setup/src/github_server/index.html @@ -0,0 +1,21 @@ + + + + + Create GitHub App for buildomat + + + +
+ + +
+ + diff --git a/xtask-setup/src/github_server/mod.rs b/xtask-setup/src/github_server/mod.rs new file mode 100644 index 0000000..6fde2c5 --- /dev/null +++ b/xtask-setup/src/github_server/mod.rs @@ -0,0 +1,366 @@ +/* + * Copyright 2026 Oxide Computer Company + */ + +use std::sync::{Arc, Mutex}; + +use anyhow::{bail, Context as _, Result}; +use buildomat_client::types::UserCreate; +use buildomat_client::Client as Buildomat; +use buildomat_common::genkey; +use dialoguer::Input; +use dropshot::{ + endpoint, ApiDescription, Body, ConfigDropshot, HttpError, Query, + RequestContext, ServerBuilder, +}; +use http::header::{CONTENT_TYPE, USER_AGENT}; +use http::{HeaderValue, Response}; +use slog::{o, Discard, Logger}; +use tokio::sync::oneshot; + +use crate::server::ServerConfig; +use crate::with_api::with_api; +use crate::Context; + +pub(crate) async fn setup(ctx: &Context) -> Result<()> { + let root = ctx.root.join("github-server"); + if root.exists() { + std::fs::remove_dir_all(&root) + .context("failed to remove old github-server directory")?; + } + std::fs::create_dir_all(&root) + .context("failed to create github-server directory")?; + std::fs::create_dir_all(root.join("etc")) + .context("failed to create github-server/etc directory")?; + std::fs::create_dir_all(root.join("var")) + .context("failed to create github-server/var directory")?; + + let server_config = ServerConfig::from_context(ctx)?; + + println!(); + println!("buildomat-github-server setup"); + println!("============================="); + + let base_url = get_base_url(ctx)?; + let app = create_github_app(&base_url, &ctx.setup_name).await?; + let slug = &app.slug; + + println!("Make sure to install the newly created app on your account:"); + println!(); + println!(" https://github.com/settings/apps/{slug}/installations"); + println!(); + + let user_token = with_api(ctx, async |bmat| { + let user_token = create_user(bmat).await?; + Ok(user_token) + }) + .await?; + + /* + * Write the configuration using the app. + */ + std::fs::write( + root.join("etc").join("app.toml"), + toml::to_string_pretty(&Config { + id: app.id, + webhook_secret: app.webhook_secret, + base_url, + confroot: ".github/buildomat".into(), + allow_owners: vec![app.owner.login], + buildomat: ConfigBuildomat { + url: server_config.general.baseurl, + token: user_token, + }, + sqlite: ConfigSqlite {}, + })?, + )?; + std::fs::write(root.join("etc").join("privkey.pem"), app.pem)?; + + /* + * The GitHub server requires the SQLite database to be already present. + */ + std::fs::write(root.join("var/data.sqlite3"), b"") + .context("failed ot create empty database file")?; + + std::fs::write(root.join("complete"), b"true\n") + .context("failed to mark the GitHub server setup as complete")?; + + Ok(()) +} + +fn get_base_url(ctx: &Context) -> Result { + println!(); + println!("Buildomat's GitHub integration needs a PUBLICLY ACCESSIBLE "); + println!("domain name that proxies requests to localhost on port 4021."); + println!(); + println!("The domain name needs to be working RIGHT NOW for the setup"); + println!("to finish, as automated GitHub App creation needs a server."); + println!(); + + Ok(Input::with_theme(&ctx.input_theme) + .with_prompt("Domain name") + .validate_with(|url: &String| -> _ { + if !url.starts_with("http://") && !url.starts_with("https://") { + Err("missing http:// or https:// at the start") + } else { + Ok(()) + } + }) + .interact()? + .trim_end_matches('/') + .into()) +} + +async fn create_user(bmat: &Buildomat) -> Result { + /* + * Buildomat doesn't have a way to retrieve or regenerate the token of an + * existing user. Since the setup process might fail between here and + * completion, we can't use a fixed name, since executing the setup again + * would try to create an user with a duplicate name. + */ + let name = format!("github-{}", genkey(8)); + + let user = bmat + .user_create() + .body(UserCreate { name }) + .send() + .await + .context("failed to create buildomat user for the GitHub server")? + .into_inner(); + + for privilege in [ + "admin.job.read", + "admin.target.read", + "admin.user.read", + "admin.worker.read", + "delegate", + ] { + bmat.user_privilege_grant() + .user(&user.id) + .privilege(privilege) + .send() + .await + .context("failed to grant privilege to the user")?; + } + + Ok(user.token) +} + +/** + * GitHub doesn't really have a good API to programmatically GitHub Apps. + * + * One of the options is to build a URL containing the app configuration as + * query parameters, and instruct users to click on it. This will pre-fill the + * app creation form, but then it's the user's responsibility to gather the app + * ID, to generate a private key, and to download it. + * + * The other option is what GitHub calls the "manifest flow": users click a + * button in their browser that sends a POST request to GitHub, which logs the + * user in and shows them a page letting them choose the app name. Once they + * choose the app name, GitHub creates an exchange token and redirects the user + * browser to a callback URL. That exchange token can be programmatically + * exchanged for the app ID and private key. + * + * The manifest flow is a better UX, but requires a web server to be stood up + * (to be able to serve the POST form and accept the calback). Normally this + * would be a non-starter for CLI applications, but we already need the user to + * setup a public DNS record pointing to their local machine. We can thus spin + * up this temporary server on the same port used by buildomat-github-server. + */ +async fn create_github_app( + base_url: &str, + app_name: &str, +) -> Result { + let mut api = ApiDescription::new(); + api.register(setup_page)?; + api.register(callback_page)?; + + let (callback_code_tx, callback_code_rx) = oneshot::channel(); + let ctx = Arc::new(CreationServerState { + base_url: base_url.into(), + app_name: app_name.into(), + callback_code_tx: Mutex::new(Some(callback_code_tx)), + /* + * This secret will be included in the "state" query parameter passed to + * GitHub: it will be sent back in the callback, to ensure the callback + * belongs to request we just sent. + */ + secret: genkey(64), + }); + + let mut server = ServerBuilder::new(api, ctx, Logger::root(Discard, o!())) + .config(ConfigDropshot { + /* + * This must be the same port as buildomat-github-server. + */ + bind_address: ([0, 0, 0, 0], 4021).into(), + ..ConfigDropshot::default() + }) + .start() + .context("failed to start the server")?; + + println!(); + println!("To configure the GitHub App used by buildomat, open this URL:"); + println!(); + println!(" {base_url}/__setup"); + println!(); + + /* + * Spin the web server down as soon as we receive a callback. + */ + let callback_code = tokio::select! { + _ = &mut server => bail!("server exited without receiving a callback"), + code = callback_code_rx => { + let _ = server.close().await; + code? + } + }; + + /* + * Exchange the code we received in the callback with the app private key. + */ + Ok(reqwest::Client::new() + .post(format!( + "https://api.github.com/app-manifests/{callback_code}/conversions" + )) + .header(USER_AGENT, "oxidecomputer/buildomat local setup") + .header("X-GitHub-Api-Version", "2026-03-10") + .send() + .await + .and_then(|resp| resp.error_for_status()) + .context("failed to ask GitHub to finish creating the GitHub App")? + .json() + .await + .context("failed to deserialize response from GitHub")?) +} + +struct CreationServerState { + secret: String, + base_url: String, + app_name: String, + callback_code_tx: Mutex>>, +} + +#[endpoint { + method = GET, + path = "/__setup", +}] +async fn setup_page( + rqctx: RequestContext>, +) -> Result, HttpError> { + let ctx = rqctx.context(); + let manifest = serde_json::json!({ + "name": ctx.app_name, + "url": ctx.base_url, + "hook_attributes": { + "url": format!("{}/webhook", ctx.base_url), + }, + "redirect_url": format!("{}/__setup/callback", ctx.base_url), + "public": false, + "default_permissions": { + "checks": "write", + "contents": "read", + "metadata": "read", + "pull_requests": "read", + "members": "read", + }, + "default_events": [ + "check_run", + "check_suite", + "create", + "delete", + "public", + "pull_request", + "push", + "repository", + ], + }) + .to_string() + /* + * Basic escaping to ensure we don't mess up the HTML. All of the inputs we + * deal with are trusted, so we don't care about proper escaping. + */ + .replace('"', """); + + let body = include_str!("./index.html") + .replace("{{secret}}", &rqctx.context().secret) + .replace("{{manifest}}", &manifest); + + let mut response = Response::new(Body::from(body)); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); + Ok(response) +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CallbackQuery { + state: String, + code: String, +} + +#[endpoint { + method = GET, + path = "/__setup/callback" +}] +async fn callback_page( + rqctx: RequestContext>, + query: Query, +) -> Result, HttpError> { + let ctx = rqctx.context(); + let query = query.into_inner(); + + let body = if query.state == ctx.secret { + /* + * We might receive another callback before we can properly shut down + * the HTTP server. In that case, discard the new callback. + */ + if let Some(tx) = ctx.callback_code_tx.lock().unwrap().take() { + let _ = tx.send(query.code); + } + include_str!("./success.html") + } else { + include_str!("./failure.html") + }; + + let mut response = Response::new(Body::from(body)); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); + Ok(response) +} + +#[derive(serde::Deserialize)] +struct CreateAppResponse { + id: u64, + slug: String, + owner: CreateAppOwner, + webhook_secret: String, + pem: String, +} + +#[derive(serde::Deserialize)] +struct CreateAppOwner { + login: String, +} + +#[derive(serde::Serialize)] +struct Config { + id: u64, + webhook_secret: String, + base_url: String, + confroot: String, + allow_owners: Vec, + + buildomat: ConfigBuildomat, + sqlite: ConfigSqlite, +} + +#[derive(serde::Serialize)] +struct ConfigBuildomat { + url: String, + token: String, +} + +#[derive(serde::Serialize)] +struct ConfigSqlite {} diff --git a/xtask-setup/src/github_server/success.html b/xtask-setup/src/github_server/success.html new file mode 100644 index 0000000..0f7c148 --- /dev/null +++ b/xtask-setup/src/github_server/success.html @@ -0,0 +1,19 @@ + + + + + Buildomat GitHub App created! + + + +

You can now go back to the terminal.

+ + + diff --git a/xtask-setup/src/main.rs b/xtask-setup/src/main.rs new file mode 100644 index 0000000..0ef63cb --- /dev/null +++ b/xtask-setup/src/main.rs @@ -0,0 +1,141 @@ +/* + * Copyright 2026 Oxide Computer Company + */ + +mod factory_aws; +mod github_server; +mod server; +mod with_api; + +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::Stdio; + +use anyhow::{bail, Context as _, Result}; +use buildomat_common::genkey; +use dialoguer::theme::ColorfulTheme; +use dialoguer::MultiSelect; +use tokio::process::Command; + +#[tokio::main] +async fn main() -> Result<()> { + let root = find_cargo_workspace_root().await?.join(".local"); + std::fs::create_dir_all(&root)?; + + /* + * Some parts of the setup process spawn the server in the background to + * make API calls to it. Build it ahead of time. + */ + eprintln!("Building the buildomat server..."); + run(cargo().args(["build", "-p", "buildomat-server"])).await?; + eprintln!(); + + /* + * A random "setup name", and it will be used to distinguish this local + * setup from others (for example as the GitHub App name, or in AWS tags). + */ + let setup_name_file = root.join("setup-name"); + let setup_name = if setup_name_file.exists() { + std::fs::read_to_string(&setup_name_file) + .context("failed to read the setup name file")? + .trim() + .to_string() + } else { + let setup_name = format!("bmat-local-{}", genkey(8)); + std::fs::write(&setup_name_file, format!("{setup_name}\n")) + .context("failed to write the setup name file")?; + setup_name + }; + + let mut input_theme = ColorfulTheme::default(); + input_theme.success_prefix = dialoguer::console::style(String::new()); + + let ctx = Context { root, setup_name, input_theme }; + + let mut functions: Vec>>>>> = + Vec::new(); + let mut selector = MultiSelect::with_theme(&ctx.input_theme).with_prompt( + "Select which components to initialize ( to toggle)", + ); + if !ctx.root.join("server").join("complete").exists() { + selector = selector.item_checked("buildomat-server", true); + functions.push(Some(Box::pin(server::setup(&ctx)))); + } + if !ctx.root.join("factory-aws").join("complete").exists() { + selector = selector.item_checked("buildomat-factory-aws", true); + functions.push(Some(Box::pin(factory_aws::setup(&ctx)))); + } + if !ctx.root.join("github-server").join("complete").exists() { + selector = selector.item_checked("buildomat-github-server", true); + functions.push(Some(Box::pin(github_server::setup(&ctx)))); + } + if functions.is_empty() { + eprintln!("Everything is already setup!"); + } else { + for idx in selector.interact()? { + functions.get_mut(idx).and_then(|f| f.take()).unwrap().await?; + } + } + + Ok(()) +} + +struct Context { + root: PathBuf, + setup_name: String, + input_theme: ColorfulTheme, +} + +async fn find_cargo_workspace_root() -> Result { + #[derive(serde::Deserialize)] + struct CargoMetadata { + workspace_root: PathBuf, + } + + let metadata = run_stdout(cargo().args(["metadata", "--format-version=1"])) + .await + .context("failed to retrieve the workspace root from Cargo")?; + Ok(serde_json::from_str::(&metadata) + .context("invalid output of \"cargo metadata\"")? + .workspace_root) +} + +async fn run(command: &mut Command) -> Result<()> { + let name = command.as_std().get_program().to_os_string(); + + let status = command + .spawn() + .with_context(|| format!("failed to invoke \"{}\"", name.display()))? + .wait() + .await + .with_context(|| format!("failed to invoke \"{}\"", name.display()))?; + if !status.success() { + bail!("\"{}\" failed with {status}", name.display()); + } + + Ok(()) +} + +async fn run_stdout(command: &mut Command) -> Result { + let name = command.as_std().get_program().to_os_string(); + command.stdout(Stdio::piped()); + + let output = command + .spawn() + .with_context(|| format!("failed to invoke \"{}\"", name.display()))? + .wait_with_output() + .await + .with_context(|| format!("failed to invoke \"{}\"", name.display()))?; + if !output.status.success() { + bail!("\"{}\" failed with {}", name.display(), output.status); + } + + Ok(String::from_utf8(output.stdout).with_context(|| { + format!("\"{}\" emitted non-UTF-8 data to stdout", name.display()) + })?) +} + +fn cargo() -> Command { + Command::new(std::env::var_os("CARGO").expect("not running under Cargo")) +} diff --git a/xtask-setup/src/server.rs b/xtask-setup/src/server.rs new file mode 100644 index 0000000..87f75da --- /dev/null +++ b/xtask-setup/src/server.rs @@ -0,0 +1,376 @@ +/* + * Copyright 2026 Oxide Computer Company + */ + +use std::collections::HashMap; + +use anyhow::{Context as _, Result}; +use aws_config::BehaviorVersion; +use aws_runtime::env_config::file::EnvConfigFiles; +use aws_sdk_s3::types::{ + AbortIncompleteMultipartUpload, BucketLifecycleConfiguration, + BucketLocationConstraint, CreateBucketConfiguration, ExpirationStatus, + LifecycleRule, LifecycleRuleFilter, PublicAccessBlockConfiguration, +}; +use aws_types::os_shim_internal::{Env, Fs}; +use buildomat_client::types::UserCreate; +use buildomat_client::Client as Buildomat; +use buildomat_common::genkey; +use dialoguer::{Input, Select}; + +use crate::with_api::with_api; +use crate::Context; +use std::path::Path; + +pub(crate) async fn setup(ctx: &Context) -> Result<()> { + let root = ctx.root.join("server"); + if root.exists() { + std::fs::remove_dir_all(&root) + .context("failed to remove old server directory")?; + } + std::fs::create_dir_all(&root) + .context("failed to create server directory")?; + + eprintln!(); + eprintln!("buildomat-server setup"); + eprintln!("======================"); + + let aws = get_aws(ctx).await?; + let baseurl = get_base_url(ctx)?; + + /* + * Write the configuration file used by the server. + */ + let config = ServerConfig { + admin: ServerConfigAdmin { token: genkey(64), hold: false }, + general: ServerConfigGeneral { baseurl }, + storage: ServerConfigStorage { + profile: aws.profile, + bucket: aws.bucket, + prefix: ctx.setup_name.clone(), + region: aws.region, + }, + job: ServerConfigJob { max_runtime: 60 * 60 }, + sqlite: ServerConfigSqlite {}, + }; + std::fs::write(root.join("config.toml"), toml::to_string_pretty(&config)?) + .context("failed to write the server configuration file")?; + + /* + * Create the database file, as the server errors out if it's missing. + */ + std::fs::create_dir_all(root.join("data")) + .context("failed to create the data dir")?; + std::fs::write(root.join("data/data.sqlite3"), b"") + .context("failed to create the database")?; + + with_api(ctx, async |bmat| { + /* + * Create the user that will be used by the CLI. This needs to be done + * within with_api() as it issues API calls to the buildomat server. + */ + create_cli_user(&root, bmat, &config).await?; + Ok(()) + }) + .await?; + + /* + * Mark the server setup as complete. + */ + std::fs::write(root.join("complete"), b"true\n") + .context("failed to mark the server setup as complete")?; + + Ok(()) +} + +fn get_base_url(ctx: &Context) -> Result { + eprintln!(); + eprintln!("Buildomat needs a PUBLICLY ACCESSIBLE domain name that proxies"); + eprintln!("requests to localhost on port 9979."); + eprintln!(); + + Ok(Input::with_theme(&ctx.input_theme) + .with_prompt("Domain name") + .validate_with(|url: &String| -> _ { + if !url.starts_with("http://") && !url.starts_with("https://") { + Err("missing http:// or https:// at the start") + } else { + Ok(()) + } + }) + .interact()?) +} + +async fn get_aws(ctx: &Context) -> Result { + eprintln!(); + eprintln!("Buildomat needs access to an AWS account to store data in an"); + eprintln!("S3 bucket, plus run VMs if you choose to use the AWS factory."); + eprintln!(); + + /* + * List the AWS profiles configured on the local machine. + */ + let fs = Fs::real(); + let env = Env::real(); + let ecf = EnvConfigFiles::default(); + let mut profiles = aws_config::profile::load(&fs, &env, &ecf, None) + .await? + .profiles() + .map(|p| p.to_string()) + .collect::>(); + profiles.sort(); + + /* + * Let the user choose the profile they want. + */ + let selected = Select::with_theme(&ctx.input_theme) + .with_prompt("Select the AWS profile to use") + .default(profiles.iter().position(|p| p == "default").unwrap_or(0)) + .items(&profiles) + .interact()?; + let profile = profiles.remove(selected); + + /* + * List S3 buckets available in the account. + */ + let config_global = aws_config::defaults(BehaviorVersion::latest()) + .profile_name(&profile) + .load() + .await; + let s3_global = aws_sdk_s3::Client::new(&config_global); + let buckets = s3_global + .list_buckets() + .into_paginator() + .send() + .collect::, _>>() + .await + .context("failed to list S3 buckets in the account")? + .iter() + .flat_map(|response| response.buckets()) + .map(|bucket| bucket.name().unwrap().to_string()) + .collect::>(); + + /* + * Let the user choose the S3 bucket they want. + */ + let selected = Select::with_theme(&ctx.input_theme) + .with_prompt("Select the S3 bucket (you can reuse existing ones)") + .default(1) + .item("Create a new bucket.") + .items(&buckets) + .interact()?; + let (bucket, region) = if selected == 0 { + create_s3_bucket(ctx, &profile).await? + } else { + let bucket = buckets[selected - 1].clone(); + + /* + * Locate the region of the bucket, we'll use it as the region of everything + * else too. We need to do some post-processing on it as AWS documents a + * null result to be "us-east-1" (checks out given the availability), and + * "EU" to be "eu-west-1". Yay legacy. + */ + let loc = s3_global + .get_bucket_location() + .bucket(&bucket) + .send() + .await + .context("failed to get the location of the selected S3 bucket")?; + let region = match loc.location_constraint() { + Some(BucketLocationConstraint::Eu) => "eu-west-1".to_string(), + Some(region) => region.to_string(), + None => "us-east-1".to_string(), + }; + (bucket, region) + }; + + Ok(Aws { profile, region, bucket }) +} + +async fn create_s3_bucket( + ctx: &Context, + profile: &str, +) -> Result<(String, String)> { + let name: String = Input::with_theme(&ctx.input_theme) + .with_prompt("Name of the bucket to create") + .interact()?; + + /* + * We *could* offer a dropdown of all regions, but that is going to be a + * long list for little purpose. Instead, pick some reasonable choices. + * + * Note that if you decide to add us-east-1 or eu-west-1 to the list (don't) + * you will need to update the code below to set the location constraint of + * the bucket, as AWS treats those regions in a special way. + */ + let regions = ["us-west-2", "us-east-2", "eu-central-1"]; + let region_idx = Select::with_theme(&ctx.input_theme) + .with_prompt("Region to create the bucket into") + .default(0) + .items(®ions) + .interact()?; + let region = regions[region_idx]; + + let config_regional = aws_config::defaults(BehaviorVersion::latest()) + .profile_name(profile) + .region(region) + .load() + .await; + let s3_regional = aws_sdk_s3::Client::new(&config_regional); + + /* + * Create a bucket with some defaults. + */ + s3_regional + .create_bucket() + .bucket(&name) + .create_bucket_configuration( + CreateBucketConfiguration::builder() + .location_constraint(region.parse()?) + .build(), + ) + .send() + .await + .context("failed to create the S3 bucket")?; + s3_regional + .put_public_access_block() + .bucket(&name) + .public_access_block_configuration( + PublicAccessBlockConfiguration::builder() + .block_public_acls(true) + .block_public_policy(true) + .ignore_public_acls(true) + .restrict_public_buckets(true) + .build(), + ) + .send() + .await + .context("failed to restrict public access to the bucket")?; + s3_regional + .put_bucket_lifecycle_configuration() + .bucket(&name) + .lifecycle_configuration( + BucketLifecycleConfiguration::builder() + .rules( + LifecycleRule::builder() + .id("abort-multipart-uploads") + .status(ExpirationStatus::Enabled) + .filter( + LifecycleRuleFilter::builder().prefix("").build(), + ) + .abort_incomplete_multipart_upload( + AbortIncompleteMultipartUpload::builder() + .days_after_initiation(7) + .build(), + ) + .build()?, + ) + .build()?, + ) + .send() + .await + .context("failed to set a lifecycle rule for the bucket")?; + + Ok((name, region.to_string())) +} + +async fn create_cli_user( + root: &Path, + bmat: &Buildomat, + config: &ServerConfig, +) -> Result<()> { + let user = bmat + .user_create() + .body(UserCreate { name: "cli".into() }) + .send() + .await + .context("failed to create cli user")?; + + let cli_config = CliConfig { + default_profile: "local".into(), + profile: [( + "local".to_string(), + CliConfigProfile { + url: "http://127.0.0.1:9979".into(), + secret: user.token.clone(), + admin_token: config.admin.token.clone(), + }, + )] + .into_iter() + .collect(), + }; + + std::fs::write( + root.join("cli-config.toml"), + toml::to_string(&cli_config)?.as_bytes(), + ) + .context("failed to write the CLI config")?; + Ok(()) +} + +#[derive(Debug)] +struct Aws { + profile: String, + region: String, + bucket: String, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct ServerConfig { + pub(crate) admin: ServerConfigAdmin, + pub(crate) general: ServerConfigGeneral, + pub(crate) storage: ServerConfigStorage, + pub(crate) job: ServerConfigJob, + pub(crate) sqlite: ServerConfigSqlite, +} + +impl ServerConfig { + pub(crate) fn from_context(ctx: &Context) -> Result { + Ok(toml::from_str( + &std::fs::read_to_string( + ctx.root.join("server").join("config.toml"), + ) + .context("failed to find the buildomat-server config")?, + )?) + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct ServerConfigAdmin { + pub(crate) token: String, + pub(crate) hold: bool, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct ServerConfigGeneral { + pub(crate) baseurl: String, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct ServerConfigStorage { + pub(crate) profile: String, + pub(crate) bucket: String, + pub(crate) prefix: String, + pub(crate) region: String, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct ServerConfigJob { + pub(crate) max_runtime: u64, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct ServerConfigSqlite {} + +#[derive(serde::Serialize)] +struct CliConfig { + default_profile: String, + profile: HashMap, +} + +#[derive(serde::Serialize)] +struct CliConfigProfile { + url: String, + secret: String, + admin_token: String, +} diff --git a/xtask-setup/src/with_api.rs b/xtask-setup/src/with_api.rs new file mode 100644 index 0000000..cad2f20 --- /dev/null +++ b/xtask-setup/src/with_api.rs @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Oxide Computer Company + */ + +use std::io::Write as _; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; + +use anyhow::{bail, Context as _, Result}; +use buildomat_client::Client; +use tokio::io::{AsyncBufReadExt as _, BufReader}; +use tokio::process::{ChildStderr, ChildStdout}; +use tokio::sync::oneshot; + +use crate::server::ServerConfig; +use crate::{cargo, Context}; + +/** + * Some parts of the setup need to issue API calls to buildomat-server. + * + * This function spawns a server in the background, waits for it to listen for + * requests, and then invokes the closure passing a configured API client to it. + * The server is cleaned up afterwards. + */ +pub(crate) async fn with_api(ctx: &Context, f: F) -> Result +where + F: AsyncFnOnce(&Client) -> Result, +{ + let mut cmd = cargo(); + cmd.args(["run", "-q", "-p", "buildomat-server", "--"]); + cmd.args(["-f", "config.toml"]); + cmd.current_dir(ctx.root.join("server")); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut server = cmd.spawn().context("failed to start buildomat server")?; + + let (listening_tx, listening_rx) = oneshot::channel(); + let output = Arc::new(Mutex::new(Vec::new())); + tokio::spawn(log_until_listening( + server.stdout.take().unwrap(), + server.stderr.take().unwrap(), + listening_tx, + output.clone(), + )); + + tokio::select! { + /* + * Good: the server started listening for connections. + */ + _ = listening_rx => {} + /* + * Bad: the server died before it started listening for connections. + */ + exit = server.wait() => { + let exit = exit.context("failed to join the buildomat server")?; + eprintln!(); + eprintln!("buildomat server logs:"); + std::io::stderr().write_all(&output.lock().unwrap())?; + eprintln!(); + bail!("buildomat server failed with {exit}"); + } + } + + let config = ServerConfig::from_context(ctx)?; + let client = buildomat_client::ClientBuilder::new("http://127.0.0.1:9979") + .bearer_token(&config.admin.token) + .build() + .context("failed to create the buildomat client")?; + + let result = f(&client).await; + server.kill().await?; + if result.is_err() { + eprintln!(); + eprintln!("buildomat server logs:"); + std::io::stderr().write_all(&output.lock().unwrap())?; + eprintln!(); + } + result +} + +async fn log_until_listening( + stdout: ChildStdout, + stderr: ChildStderr, + complete: oneshot::Sender<()>, + output: Arc>>, +) { + #[derive(serde::Deserialize)] + struct Log<'a> { + msg: &'a str, + } + + let mut out = BufReader::new(stdout); + let mut err = BufReader::new(stderr); + let mut out_buf = Vec::new(); + let mut err_buf = Vec::new(); + let mut complete = Some(complete); + loop { + tokio::select! { + _ = out.read_until(b'\n', &mut out_buf) => { + if let Ok(Log { msg }) = serde_json::from_slice(&out_buf) { + /* + * The "listening" message is emitted by dropshot. It's not + * super pretty, but it works great in practice. + */ + if msg == "listening" { + if let Some(complete) = complete.take() { + let _ = complete.send(()); + } + } + } + output.lock().unwrap().extend_from_slice(&out_buf); + out_buf.clear(); + } + _ = err.read_until(b'\n', &mut err_buf) => { + output.lock().unwrap().extend_from_slice(&err_buf); + err_buf.clear(); + } + } + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae29689..1419c44 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,11 +4,12 @@ use std::cmp::Ordering; use std::io::{Seek, Write}; -use std::path::PathBuf; +use std::os::unix::process::CommandExt as _; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use tempfile::NamedTempFile; -use anyhow::{bail, Result}; +use anyhow::{bail, Context as _, Result}; +use tempfile::NamedTempFile; pub trait OutputExt { fn info(&self) -> String; @@ -69,7 +70,7 @@ fn openapi() -> Result<()> { }; std::fs::remove_file(&buildomat_client_tmp).ok(); - let status = Command::new(env!("CARGO")) + let status = cargo() .arg("run") .arg("-p") .arg("buildomat-server") @@ -256,7 +257,7 @@ fn crates() -> Result<()> { t }; - let res = Command::new(env!("CARGO")) + let res = cargo() .arg("tree") .arg("--depth") .arg("0") @@ -351,14 +352,233 @@ fn crates() -> Result<()> { Ok(()) } +fn local_setup() -> Result<()> { + /* + * This command is implemented as a separate crate because it depends on the + * AWS SDK, reqwest and dropshot. Adding all of those dependencies to xtask + * would unreasonably slow down compilation. + */ + Err(cargo().args(["run", "--bin", "xtask-setup"]).exec().into()) +} + +fn local_buildomat() -> Result<()> { + let local = local_setup_root_for("server")?; + Err(cargo() + .args(["run", "--bin", "buildomat", "--"]) + .args(std::env::args_os().skip(3).collect::>()) + .env("BUILDOMAT_CONFIG", local.join("cli-config.toml")) + .exec() + .into()) +} + +fn local_build_linux_agent(dest: &Path) -> Result<()> { + eprintln!("building the agent for Linux..."); + let status = cargo() + .args(["build", "--bin", "buildomat-agent"]) + .arg("--release") + .arg("--target=x86_64-unknown-linux-musl") + .status()?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + std::fs::copy( + PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join( + "../target/x86_64-unknown-linux-musl/release/buildomat-agent", + ), + dest, + )?; + + Ok(()) +} + +fn local_build_illumos_agent(dest: &Path) -> Result<()> { + eprintln!("building the agent for illumos..."); + let status = cargo() + .args(["build", "--bin", "buildomat-agent"]) + .arg("--release") + .arg("--target=x86_64-unknown-illumos") + .status()?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + std::fs::copy( + PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()) + .join("../target/x86_64-unknown-illumos/release/buildomat-agent"), + dest, + )?; + + Ok(()) +} + +fn local_buildomat_server() -> Result<()> { + let local = local_setup_root_for("server")?; + + /* + * The server requires the agent binary to be present in the current working + * directory, so that it can serve it to workers. To avoid confusion when + * making changes to the agent but forgetting to recompile it, we compile it + * on each build (changes not touching the agent should be instant anyway). + * + * We are unconditionally building the x86_64-unknown-linux-musl variant of + * the agent. Linux is what the AWS factory configures to run, and the musl + * variant prevents issues with older glibcs or building on NixOS. + * + * When the illumos target is installed, we also build the illumos agent. + */ + local_build_linux_agent(&local.join("buildomat-agent-linux"))?; + if is_target_installed("x86_64-unknown-illumos") { + local_build_illumos_agent(&local.join("buildomat-agent"))?; + } + + eprintln!(); + eprintln!("running the server..."); + Err(cargo() + .args(["run", "--bin", "buildomat-server", "--"]) + /* + * The server requires both an explicit configuration file and to be + * executed from the correct working directory. + */ + .arg("-f") + .arg(&local.join("config.toml")) + .current_dir(&local) + .exec() + .into()) +} + +fn local_buildomat_factory_aws() -> Result<()> { + let local = local_setup_root_for("factory-aws")?; + Err(cargo() + .args(["run", "--bin", "buildomat-factory-aws", "--"]) + .arg("-f") + .arg(&local.join("config.toml")) + .exec() + .into()) +} + +fn local_buildomat_github_server() -> Result<()> { + let local = local_setup_root_for("github-server")?; + Err(cargo() + .args(["run", "--bin", "buildomat-github-server", "--"]) + .current_dir(&local) + .exec() + .into()) +} + +fn local_setup_root_for(component: &str) -> Result { + let env = std::env::var_os("CARGO_MANIFEST_DIR") + .context("xtask is not running under Cargo")?; + let root = PathBuf::from(env) + .join("..") + .join(".local") + .join(component) + .canonicalize()?; + + if !root.exists() { + bail!("{component} is not ready, run \"cargo xtask local setup\""); + } + Ok(root) +} + +fn local() -> Result<()> { + subcommands( + 2, + &[ + ("setup", "initialize the local environment", local_setup), + ("buildomat", "run the CLI", local_buildomat), + ("buildomat-server", "run the server", local_buildomat_server), + ( + "buildomat-factory-aws", + "run jobs on AWS", + local_buildomat_factory_aws, + ), + ( + "buildomat-github-server", + "integrate with GitHub", + local_buildomat_github_server, + ), + ], + ) +} + fn main() -> Result<()> { - match std::env::args().nth(1).as_deref() { - Some("openapi") => openapi(), - Some("build-linux-agent") => build_agent(AgentBuild::Linux), - Some("build-agent") => build_agent(AgentBuild::Helios), - Some("crates") => crates(), - Some(_) | None => { - bail!("do not know how to do that"); + subcommands( + 1, + &[ + ("openapi", "regenerate the server openapi.json", openapi), + ( + "build-linux-agent", + "start a buildomat job to build the agent on Linux", + || build_agent(AgentBuild::Linux), + ), + ( + "build-agent", + "start a buildomat job to build the agent on Helios", + || build_agent(AgentBuild::Helios), + ), + ("crates", "list the crates in the workspace", crates), + ("local", "run buildomat locally", local), + ], + ) +} + +/* + * Exceedingly simple command line parser with support for xtask, only + * supporting -h/--help and subcommands. We only need those features right now + * so pulling a library doesn't make sense. If we reach a point of needing more + * complex argument parsing we should replace this with a proper library. + */ +fn subcommands( + level: usize, + commands: &[(&str, &str, fn() -> Result<()>)], +) -> Result<()> { + let mut cmd = std::env::args().nth(level); + + /* + * Treat --help/-h as no subcommand, which shows the help message. + */ + if cmd.as_deref() == Some("--help") || cmd.as_deref() == Some("-h") { + cmd = None; + } + + for (candidate, _, function) in commands { + if cmd.as_deref() == Some(*candidate) { + return function(); } } + + let padding = commands.iter().map(|c| c.0.len()).max().unwrap() + 3; + eprintln!("available subcommands:"); + for (command, description, _) in commands { + eprintln!("- {command: Command { + Command::new(std::env::var_os("CARGO").expect("not running under Cargo")) +} + +fn is_target_installed(target: &str) -> bool { + let output = Command::new("rustc") + .arg("--print=sysroot") + .output() + .expect("failed to invoke \"rustc --print=sysroot\""); + if !output.status.success() { + panic!("\"rustc --print=sysroot\" exited with {}", output.status); + } + + let sysroot = PathBuf::from( + String::from_utf8(output.stdout) + .expect("non-UTF-8 sysroot") + .trim_end_matches('\n'), + ); + + sysroot.join("lib").join("rustlib").join(target).is_dir() }