diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ee1c3c2..baddb80 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,25 +1,18 @@ mod instrumentation; use color_eyre::eyre::{eyre, WrapErr}; -use reqwest::{header::HeaderMap, StatusCode}; use std::{ path::{Path, PathBuf}, process::ExitCode, - str::FromStr, }; -use tokio::io::AsyncWriteExt; -use uuid::Uuid; use crate::{ - error::Error, - flake_info::{check_flake_evaluates, get_flake_metadata, get_flake_outputs, get_flake_tarball}, - graphql::{GithubGraphqlDataQuery, GithubGraphqlDataResult}, - release_metadata::{ReleaseMetadata, RevisionInfo}, - Visibility, + build_http_client, + github::{get_actions_id_bearer_token, graphql::GithubGraphqlDataQuery}, + push::push_new_release, + release_metadata::RevisionInfo, }; -const DEFAULT_ROLLING_PREFIX: &str = "0.1"; - #[derive(Debug, clap::Parser)] #[clap(version)] pub(crate) struct FlakeHubPushCli { @@ -250,10 +243,6 @@ impl clap::builder::TypedValueParser for U64ToNoneParser { } } -fn build_http_client() -> reqwest::ClientBuilder { - reqwest::Client::builder().user_agent("flakehub-push") -} - impl FlakeHubPushCli { #[tracing::instrument( name = "flakehub_push" @@ -469,405 +458,3 @@ impl FlakeHubPushCli { Ok(ExitCode::SUCCESS) } } - -#[tracing::instrument( - skip_all, - fields( - repository = %repository, - upload_name = tracing::field::Empty, - mirror = %mirror, - tag = tracing::field::Empty, - source = tracing::field::Empty, - mirrored = tracing::field::Empty, - ) -)] -#[allow(clippy::too_many_arguments)] -async fn push_new_release( - host: &str, - upload_bearer_token: &str, - flake_root: &Path, - subdir: &Path, - revision_info: RevisionInfo, - repository: &str, - upload_name: String, - mirror: bool, - visibility: Visibility, - tag: Option, - rolling: bool, - rolling_minor: Option, - github_graphql_data_result: GithubGraphqlDataResult, - extra_labels: Vec, - spdx_expression: Option, - error_if_release_conflicts: bool, - include_output_paths: bool, -) -> color_eyre::Result<()> { - let span = tracing::Span::current(); - span.record("upload_name", tracing::field::display(upload_name.clone())); - - let rolling_prefix_or_tag = match (rolling_minor.as_ref(), tag) { - (Some(_), _) if !rolling => { - return Err(eyre!( - "You must enable `rolling` to upload a release with a specific `rolling-minor`." - )); - } - (Some(minor), _) => format!("0.{minor}"), - (None, _) if rolling => DEFAULT_ROLLING_PREFIX.to_string(), - (None, Some(tag)) => { - let version_only = tag.strip_prefix('v').unwrap_or(&tag); - // Ensure the version respects semver - semver::Version::from_str(version_only).wrap_err_with(|| eyre!("Failed to parse version `{tag}` as semver, see https://semver.org/ for specifications"))?; - tag - } - (None, None) => { - return Err(eyre!("Could not determine tag or rolling minor version, `--tag`, `GITHUB_REF_NAME`, or `--rolling-minor` must be set")); - } - }; - - tracing::info!("Preparing release of {upload_name}/{rolling_prefix_or_tag}"); - - let tempdir = tempfile::Builder::new() - .prefix("flakehub_push") - .tempdir() - .wrap_err("Creating tempdir")?; - - let flake_dir = flake_root.join(subdir); - - check_flake_evaluates(&flake_dir) - .await - .wrap_err("Checking flake evaluates")?; - let flake_metadata = get_flake_metadata(&flake_dir) - .await - .wrap_err("Getting flake metadata")?; - tracing::debug!("Got flake metadata: {:?}", flake_metadata); - - // FIXME: bail out if flake_metadata denotes a dirty tree. - - let flake_locked_url = flake_metadata - .get("url") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - eyre!("Could not get `url` attribute from `nix flake metadata --json` output") - })?; - tracing::debug!("Locked URL = {}", flake_locked_url); - let flake_metadata_value_path = flake_metadata - .get("path") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - eyre!("Could not get `path` attribute from `nix flake metadata --json` output") - })?; - let flake_metadata_value_resolved_dir = flake_metadata - .pointer("/resolved/dir") - .and_then(serde_json::Value::as_str); - - let flake_outputs = get_flake_outputs(flake_locked_url, include_output_paths).await?; - tracing::debug!("Got flake outputs: {:?}", flake_outputs); - - let source = match flake_metadata_value_resolved_dir { - Some(flake_metadata_value_resolved_dir) => { - Path::new(flake_metadata_value_path).join(flake_metadata_value_resolved_dir) - } - None => PathBuf::from(flake_metadata_value_path), - }; - span.record("source", tracing::field::display(source.clone().display())); - tracing::debug!("Found source"); - - if flake_dir.join("flake.lock").exists() { - let output = tokio::process::Command::new("nix") - .arg("flake") - .arg("metadata") - .arg("--json") - .arg("--no-update-lock-file") - .arg(&flake_dir) - .output() - .await - .wrap_err_with(|| { - eyre!( - "Failed to execute `nix flake metadata --json --no-update-lock-file {}`", - flake_dir.display() - ) - })?; - - if !output.status.success() { - let command = format!( - "nix flake metadata --json --no-update-lock-file {}", - flake_dir.display(), - ); - let msg = format!( - "\ - Failed to execute command `{command}`{maybe_status} \n\ - stdout: {stdout}\n\ - stderr: {stderr}\n\ - ", - stdout = String::from_utf8_lossy(&output.stdout), - stderr = String::from_utf8_lossy(&output.stderr), - maybe_status = if let Some(status) = output.status.code() { - format!(" with status {status}") - } else { - String::new() - } - ); - return Err(eyre!(msg))?; - } - } - - let last_modified = if let Some(last_modified) = flake_metadata.get("lastModified") { - last_modified.as_u64().ok_or_else(|| { - eyre!("`nix flake metadata --json` does not have a integer `lastModified` field") - })? - } else { - return Err(eyre!( - "`nix flake metadata` did not return a `lastModified` attribute" - )); - }; - tracing::debug!("lastModified = {}", last_modified); - - let flake_tarball = get_flake_tarball(&source, last_modified) - .await - .wrap_err("Making release tarball")?; - - let flake_tarball_len: usize = flake_tarball.len(); - let flake_tarball_hash = { - let mut context = ring::digest::Context::new(&ring::digest::SHA256); - context.update(&flake_tarball); - context.finish() - }; - let flake_tarball_hash_base64 = { - // TODO: Use URL_SAFE_NO_PAD - use base64::{engine::general_purpose::STANDARD, Engine as _}; - STANDARD.encode(flake_tarball_hash) - }; - tracing::debug!( - flake_tarball_len, - flake_tarball_hash_base64, - "Got tarball metadata" - ); - - let flake_tarball_path = tempdir.path().join("release.tar.gz"); - let mut tempfile = tokio::fs::File::create(&flake_tarball_path) - .await - .wrap_err("Creating release.tar.gz")?; - tempfile - .write_all(&flake_tarball) - .await - .wrap_err("Writing compressed tarball to tempfile")?; - - let release_metadata = ReleaseMetadata::build( - &source, - subdir, - revision_info, - flake_metadata, - flake_outputs, - upload_name.clone(), - mirror, - visibility, - github_graphql_data_result, - extra_labels, - spdx_expression, - ) - .await - .wrap_err("Building release metadata")?; - - let flakehub_client = build_http_client().build()?; - - let rolling_minor_with_postfix_or_tag = if rolling_minor.is_some() || rolling { - format!( - "{rolling_prefix_or_tag}.{}+rev-{}", - release_metadata.commit_count, release_metadata.revision - ) - } else { - rolling_prefix_or_tag.to_string() // This will always be the tag since `self.rolling_prefix` was empty. - }; - - let release_metadata_post_url = format!( - "{host}/upload/{upload_name}/{rolling_minor_with_postfix_or_tag}/{flake_tarball_len}/{flake_tarball_hash_base64}" - ); - tracing::debug!( - url = release_metadata_post_url, - "Computed release metadata POST URL" - ); - - let flakehub_headers = { - let mut header_map = HeaderMap::new(); - - header_map.insert( - reqwest::header::CONTENT_TYPE, - reqwest::header::HeaderValue::from_str("application/json").unwrap(), - ); - header_map.insert( - reqwest::header::HeaderName::from_static("ngrok-skip-browser-warning"), - reqwest::header::HeaderValue::from_str("please").unwrap(), - ); - header_map - }; - - let release_metadata_post_response = flakehub_client - .post(release_metadata_post_url) - .bearer_auth(upload_bearer_token) - .headers(flakehub_headers.clone()) - .json(&release_metadata) - .send() - .await - .wrap_err("Sending release metadata")?; - - let release_metadata_post_response_status = release_metadata_post_response.status(); - tracing::trace!( - status = tracing::field::display(release_metadata_post_response_status), - "Got release metadata POST response" - ); - - match release_metadata_post_response_status { - StatusCode::OK => (), - StatusCode::CONFLICT => { - tracing::info!( - "Release for revision `{revision}` of {upload_name}/{rolling_prefix_or_tag} already exists; flakehub-push will not upload it again", - revision = release_metadata.revision - ); - if error_if_release_conflicts { - return Err(Error::Conflict { - upload_name, - rolling_prefix_or_tag, - })?; - } else { - return Ok(()); - } - } - StatusCode::UNAUTHORIZED => { - let body = &release_metadata_post_response.bytes().await?; - let message = serde_json::from_slice::(body)?; - - return Err(Error::Unauthorized(message))?; - } - _ => { - let body = &release_metadata_post_response.bytes().await?; - let message = serde_json::from_slice::(body)?; - return Err(eyre!( - "\ - Status {release_metadata_post_response_status} from metadata POST\n\ - {}\ - ", - message - )); - } - } - - #[derive(serde::Deserialize)] - struct Result { - s3_upload_url: String, - uuid: Uuid, - } - - let release_metadata_post_result: Result = release_metadata_post_response - .json() - .await - .wrap_err("Decoding release metadata POST response")?; - - let tarball_put_response = flakehub_client - .put(release_metadata_post_result.s3_upload_url) - .headers({ - let mut header_map = HeaderMap::new(); - header_map.insert( - reqwest::header::CONTENT_LENGTH, - reqwest::header::HeaderValue::from_str(&format!("{}", flake_tarball_len)).unwrap(), - ); - header_map.insert( - reqwest::header::HeaderName::from_static("x-amz-checksum-sha256"), - reqwest::header::HeaderValue::from_str(&flake_tarball_hash_base64).unwrap(), - ); - header_map.insert( - reqwest::header::CONTENT_TYPE, - reqwest::header::HeaderValue::from_str("application/gzip").unwrap(), - ); - header_map - }) - .body(flake_tarball) - .send() - .await - .wrap_err("Sending tarball PUT")?; - - let tarball_put_response_status = tarball_put_response.status(); - tracing::trace!( - status = tracing::field::display(release_metadata_post_response_status), - "Got tarball PUT response" - ); - if !tarball_put_response_status.is_success() { - return Err(eyre!( - "Got {tarball_put_response_status} status from PUT request" - )); - } - - // Make the release we just uploaded visible. - let publish_post_url = format!("{host}/publish/{}", release_metadata_post_result.uuid); - tracing::debug!(url = publish_post_url, "Computed publish POST URL"); - - let publish_response = flakehub_client - .post(publish_post_url) - .bearer_auth(upload_bearer_token) - .headers(flakehub_headers) - .send() - .await - .wrap_err("Publishing release")?; - - let publish_response_status = publish_response.status(); - tracing::trace!( - status = tracing::field::display(publish_response_status), - "Got publish POST response" - ); - - if publish_response_status != 200 { - return Err(eyre!( - "\ - Status {publish_response_status} from publish POST\n\ - {}\ - ", - String::from_utf8_lossy(&publish_response.bytes().await.unwrap()) - )); - } - - tracing::info!( - "Successfully released new version of {upload_name}/{rolling_minor_with_postfix_or_tag}" - ); - - Ok(()) -} - -#[tracing::instrument(skip_all)] -async fn get_actions_id_bearer_token() -> color_eyre::Result { - let actions_id_token_request_token = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN") - // We do want to preserve the whitespace here - .wrap_err("\ -No `ACTIONS_ID_TOKEN_REQUEST_TOKEN` found, `flakehub-push` requires a JWT. To provide this, add `permissions` to your job, eg: - -# ... -jobs: - example: - runs-on: ubuntu-latest - permissions: - id-token: write # Authenticate against FlakeHub - contents: read - steps: - - uses: actions/checkout@v3 - # ...\n\ - ")?; - let actions_id_token_request_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL").wrap_err("`ACTIONS_ID_TOKEN_REQUEST_URL` required if `ACTIONS_ID_TOKEN_REQUEST_TOKEN` is also present")?; - let actions_id_token_client = build_http_client().build()?; - let response = actions_id_token_client - .get(format!( - "{actions_id_token_request_url}&audience=api.flakehub.com" - )) - .bearer_auth(actions_id_token_request_token) - .send() - .await - .wrap_err("Getting Actions ID bearer token")?; - - let response_json: serde_json::Value = response - .json() - .await - .wrap_err("Getting JSON from Actions ID bearer token response")?; - - let response_bearer_token = response_json - .get("value") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| eyre!("Getting value from Actions ID bearer token response"))?; - - Ok(response_bearer_token.to_string()) -} diff --git a/src/graphql/github_schema.graphql b/src/github/graphql/github_schema.graphql similarity index 100% rename from src/graphql/github_schema.graphql rename to src/github/graphql/github_schema.graphql diff --git a/src/graphql/mod.rs b/src/github/graphql/mod.rs similarity index 95% rename from src/graphql/mod.rs rename to src/github/graphql/mod.rs index d560603..596726a 100644 --- a/src/graphql/mod.rs +++ b/src/github/graphql/mod.rs @@ -10,8 +10,8 @@ const MAX_NUM_EXTRA_TOPICS: i64 = 20; #[derive(GraphQLQuery)] #[graphql( - schema_path = "src/graphql/github_schema.graphql", - query_path = "src/graphql/query/github_graphql_data_query.graphql", + schema_path = "src/github/graphql/github_schema.graphql", + query_path = "src/github/graphql/query/github_graphql_data_query.graphql", response_derives = "Debug", variables_derives = "Debug" )] @@ -40,7 +40,7 @@ impl GithubGraphqlDataQuery { }; let query = GithubGraphqlDataQuery::build_query(variables); let reqwest_response = reqwest_client - .post(crate::graphql::GITHUB_ENDPOINT) + .post(GITHUB_ENDPOINT) .bearer_auth(bearer_token) .json(&query) .send() @@ -49,7 +49,7 @@ impl GithubGraphqlDataQuery { let response_status = reqwest_response.status(); let response: graphql_client::Response< - ::ResponseData, + ::ResponseData, > = reqwest_response .json() .await diff --git a/src/graphql/query/github_graphql_data_query.graphql b/src/github/graphql/query/github_graphql_data_query.graphql similarity index 100% rename from src/graphql/query/github_graphql_data_query.graphql rename to src/github/graphql/query/github_graphql_data_query.graphql diff --git a/src/github/mod.rs b/src/github/mod.rs new file mode 100644 index 0000000..d8c5f98 --- /dev/null +++ b/src/github/mod.rs @@ -0,0 +1,47 @@ +pub(crate) mod graphql; + +use color_eyre::eyre::{eyre, WrapErr}; + +use crate::build_http_client; + +#[tracing::instrument(skip_all)] +pub(crate) async fn get_actions_id_bearer_token() -> color_eyre::Result { + let actions_id_token_request_token = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + // We do want to preserve the whitespace here + .wrap_err("\ +No `ACTIONS_ID_TOKEN_REQUEST_TOKEN` found, `flakehub-push` requires a JWT. To provide this, add `permissions` to your job, eg: + +# ... +jobs: + example: + runs-on: ubuntu-latest + permissions: + id-token: write # Authenticate against FlakeHub + contents: read + steps: + - uses: actions/checkout@v3 + # ...\n\ + ")?; + let actions_id_token_request_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL").wrap_err("`ACTIONS_ID_TOKEN_REQUEST_URL` required if `ACTIONS_ID_TOKEN_REQUEST_TOKEN` is also present")?; + let actions_id_token_client = build_http_client().build()?; + let response = actions_id_token_client + .get(format!( + "{actions_id_token_request_url}&audience=api.flakehub.com" + )) + .bearer_auth(actions_id_token_request_token) + .send() + .await + .wrap_err("Getting Actions ID bearer token")?; + + let response_json: serde_json::Value = response + .json() + .await + .wrap_err("Getting JSON from Actions ID bearer token response")?; + + let response_bearer_token = response_json + .get("value") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| eyre!("Getting value from Actions ID bearer token response"))?; + + Ok(response_bearer_token.to_string()) +} diff --git a/src/main.rs b/src/main.rs index 918b824..39c6830 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,8 @@ use error::Error; mod cli; mod error; mod flake_info; -mod graphql; +mod github; +mod push; mod release_metadata; #[tokio::main] @@ -66,3 +67,7 @@ impl Display for Visibility { } } } + +pub(crate) fn build_http_client() -> reqwest::ClientBuilder { + reqwest::Client::builder().user_agent("flakehub-push") +} diff --git a/src/push.rs b/src/push.rs new file mode 100644 index 0000000..d584a0b --- /dev/null +++ b/src/push.rs @@ -0,0 +1,379 @@ +use color_eyre::eyre::{eyre, WrapErr}; +use reqwest::{header::HeaderMap, StatusCode}; +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; +use tokio::io::AsyncWriteExt; +use uuid::Uuid; + +use crate::{ + build_http_client, + error::Error, + flake_info::{check_flake_evaluates, get_flake_metadata, get_flake_outputs, get_flake_tarball}, + github::graphql::GithubGraphqlDataResult, + release_metadata::{ReleaseMetadata, RevisionInfo}, + Visibility, +}; + +const DEFAULT_ROLLING_PREFIX: &str = "0.1"; + +#[tracing::instrument( + skip_all, + fields( + repository = %repository, + upload_name = tracing::field::Empty, + mirror = %mirror, + tag = tracing::field::Empty, + source = tracing::field::Empty, + mirrored = tracing::field::Empty, + ) +)] +#[allow(clippy::too_many_arguments)] +pub(crate) async fn push_new_release( + host: &str, + upload_bearer_token: &str, + flake_root: &Path, + subdir: &Path, + revision_info: RevisionInfo, + repository: &str, + upload_name: String, + mirror: bool, + visibility: Visibility, + tag: Option, + rolling: bool, + rolling_minor: Option, + github_graphql_data_result: GithubGraphqlDataResult, + extra_labels: Vec, + spdx_expression: Option, + error_if_release_conflicts: bool, + include_output_paths: bool, +) -> color_eyre::Result<()> { + let span = tracing::Span::current(); + span.record("upload_name", tracing::field::display(upload_name.clone())); + + let rolling_prefix_or_tag = match (rolling_minor.as_ref(), tag) { + (Some(_), _) if !rolling => { + return Err(eyre!( + "You must enable `rolling` to upload a release with a specific `rolling-minor`." + )); + } + (Some(minor), _) => format!("0.{minor}"), + (None, _) if rolling => DEFAULT_ROLLING_PREFIX.to_string(), + (None, Some(tag)) => { + let version_only = tag.strip_prefix('v').unwrap_or(&tag); + // Ensure the version respects semver + semver::Version::from_str(version_only).wrap_err_with(|| eyre!("Failed to parse version `{tag}` as semver, see https://semver.org/ for specifications"))?; + tag + } + (None, None) => { + return Err(eyre!("Could not determine tag or rolling minor version, `--tag`, `GITHUB_REF_NAME`, or `--rolling-minor` must be set")); + } + }; + + tracing::info!("Preparing release of {upload_name}/{rolling_prefix_or_tag}"); + + let tempdir = tempfile::Builder::new() + .prefix("flakehub_push") + .tempdir() + .wrap_err("Creating tempdir")?; + + let flake_dir = flake_root.join(subdir); + + check_flake_evaluates(&flake_dir) + .await + .wrap_err("Checking flake evaluates")?; + let flake_metadata = get_flake_metadata(&flake_dir) + .await + .wrap_err("Getting flake metadata")?; + tracing::debug!("Got flake metadata: {:?}", flake_metadata); + + // FIXME: bail out if flake_metadata denotes a dirty tree. + + let flake_locked_url = flake_metadata + .get("url") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + eyre!("Could not get `url` attribute from `nix flake metadata --json` output") + })?; + tracing::debug!("Locked URL = {}", flake_locked_url); + let flake_metadata_value_path = flake_metadata + .get("path") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + eyre!("Could not get `path` attribute from `nix flake metadata --json` output") + })?; + let flake_metadata_value_resolved_dir = flake_metadata + .pointer("/resolved/dir") + .and_then(serde_json::Value::as_str); + + let flake_outputs = get_flake_outputs(flake_locked_url, include_output_paths).await?; + tracing::debug!("Got flake outputs: {:?}", flake_outputs); + + let source = match flake_metadata_value_resolved_dir { + Some(flake_metadata_value_resolved_dir) => { + Path::new(flake_metadata_value_path).join(flake_metadata_value_resolved_dir) + } + None => PathBuf::from(flake_metadata_value_path), + }; + span.record("source", tracing::field::display(source.clone().display())); + tracing::debug!("Found source"); + + if flake_dir.join("flake.lock").exists() { + let output = tokio::process::Command::new("nix") + .arg("flake") + .arg("metadata") + .arg("--json") + .arg("--no-update-lock-file") + .arg(&flake_dir) + .output() + .await + .wrap_err_with(|| { + eyre!( + "Failed to execute `nix flake metadata --json --no-update-lock-file {}`", + flake_dir.display() + ) + })?; + + if !output.status.success() { + let command = format!( + "nix flake metadata --json --no-update-lock-file {}", + flake_dir.display(), + ); + let msg = format!( + "\ + Failed to execute command `{command}`{maybe_status} \n\ + stdout: {stdout}\n\ + stderr: {stderr}\n\ + ", + stdout = String::from_utf8_lossy(&output.stdout), + stderr = String::from_utf8_lossy(&output.stderr), + maybe_status = if let Some(status) = output.status.code() { + format!(" with status {status}") + } else { + String::new() + } + ); + return Err(eyre!(msg))?; + } + } + + let last_modified = if let Some(last_modified) = flake_metadata.get("lastModified") { + last_modified.as_u64().ok_or_else(|| { + eyre!("`nix flake metadata --json` does not have a integer `lastModified` field") + })? + } else { + return Err(eyre!( + "`nix flake metadata` did not return a `lastModified` attribute" + )); + }; + tracing::debug!("lastModified = {}", last_modified); + + let flake_tarball = get_flake_tarball(&source, last_modified) + .await + .wrap_err("Making release tarball")?; + + let flake_tarball_len: usize = flake_tarball.len(); + let flake_tarball_hash = { + let mut context = ring::digest::Context::new(&ring::digest::SHA256); + context.update(&flake_tarball); + context.finish() + }; + let flake_tarball_hash_base64 = { + // TODO: Use URL_SAFE_NO_PAD + use base64::{engine::general_purpose::STANDARD, Engine as _}; + STANDARD.encode(flake_tarball_hash) + }; + tracing::debug!( + flake_tarball_len, + flake_tarball_hash_base64, + "Got tarball metadata" + ); + + let flake_tarball_path = tempdir.path().join("release.tar.gz"); + let mut tempfile = tokio::fs::File::create(&flake_tarball_path) + .await + .wrap_err("Creating release.tar.gz")?; + tempfile + .write_all(&flake_tarball) + .await + .wrap_err("Writing compressed tarball to tempfile")?; + + let release_metadata = ReleaseMetadata::build( + &source, + subdir, + revision_info, + flake_metadata, + flake_outputs, + upload_name.clone(), + mirror, + visibility, + github_graphql_data_result, + extra_labels, + spdx_expression, + ) + .await + .wrap_err("Building release metadata")?; + + let flakehub_client = build_http_client().build()?; + + let rolling_minor_with_postfix_or_tag = if rolling_minor.is_some() || rolling { + format!( + "{rolling_prefix_or_tag}.{}+rev-{}", + release_metadata.commit_count, release_metadata.revision + ) + } else { + rolling_prefix_or_tag.to_string() // This will always be the tag since `self.rolling_prefix` was empty. + }; + + let release_metadata_post_url = format!( + "{host}/upload/{upload_name}/{rolling_minor_with_postfix_or_tag}/{flake_tarball_len}/{flake_tarball_hash_base64}" + ); + tracing::debug!( + url = release_metadata_post_url, + "Computed release metadata POST URL" + ); + + let flakehub_headers = { + let mut header_map = HeaderMap::new(); + + header_map.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_str("application/json").unwrap(), + ); + header_map.insert( + reqwest::header::HeaderName::from_static("ngrok-skip-browser-warning"), + reqwest::header::HeaderValue::from_str("please").unwrap(), + ); + header_map + }; + + let release_metadata_post_response = flakehub_client + .post(release_metadata_post_url) + .bearer_auth(upload_bearer_token) + .headers(flakehub_headers.clone()) + .json(&release_metadata) + .send() + .await + .wrap_err("Sending release metadata")?; + + let release_metadata_post_response_status = release_metadata_post_response.status(); + tracing::trace!( + status = tracing::field::display(release_metadata_post_response_status), + "Got release metadata POST response" + ); + + match release_metadata_post_response_status { + StatusCode::OK => (), + StatusCode::CONFLICT => { + tracing::info!( + "Release for revision `{revision}` of {upload_name}/{rolling_prefix_or_tag} already exists; flakehub-push will not upload it again", + revision = release_metadata.revision + ); + if error_if_release_conflicts { + return Err(Error::Conflict { + upload_name, + rolling_prefix_or_tag, + })?; + } else { + return Ok(()); + } + } + StatusCode::UNAUTHORIZED => { + let body = &release_metadata_post_response.bytes().await?; + let message = serde_json::from_slice::(body)?; + + return Err(Error::Unauthorized(message))?; + } + _ => { + let body = &release_metadata_post_response.bytes().await?; + let message = serde_json::from_slice::(body)?; + return Err(eyre!( + "\ + Status {release_metadata_post_response_status} from metadata POST\n\ + {}\ + ", + message + )); + } + } + + #[derive(serde::Deserialize)] + struct Result { + s3_upload_url: String, + uuid: Uuid, + } + + let release_metadata_post_result: Result = release_metadata_post_response + .json() + .await + .wrap_err("Decoding release metadata POST response")?; + + let tarball_put_response = flakehub_client + .put(release_metadata_post_result.s3_upload_url) + .headers({ + let mut header_map = HeaderMap::new(); + header_map.insert( + reqwest::header::CONTENT_LENGTH, + reqwest::header::HeaderValue::from_str(&format!("{}", flake_tarball_len)).unwrap(), + ); + header_map.insert( + reqwest::header::HeaderName::from_static("x-amz-checksum-sha256"), + reqwest::header::HeaderValue::from_str(&flake_tarball_hash_base64).unwrap(), + ); + header_map.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_str("application/gzip").unwrap(), + ); + header_map + }) + .body(flake_tarball) + .send() + .await + .wrap_err("Sending tarball PUT")?; + + let tarball_put_response_status = tarball_put_response.status(); + tracing::trace!( + status = tracing::field::display(release_metadata_post_response_status), + "Got tarball PUT response" + ); + if !tarball_put_response_status.is_success() { + return Err(eyre!( + "Got {tarball_put_response_status} status from PUT request" + )); + } + + // Make the release we just uploaded visible. + let publish_post_url = format!("{host}/publish/{}", release_metadata_post_result.uuid); + tracing::debug!(url = publish_post_url, "Computed publish POST URL"); + + let publish_response = flakehub_client + .post(publish_post_url) + .bearer_auth(upload_bearer_token) + .headers(flakehub_headers) + .send() + .await + .wrap_err("Publishing release")?; + + let publish_response_status = publish_response.status(); + tracing::trace!( + status = tracing::field::display(publish_response_status), + "Got publish POST response" + ); + + if publish_response_status != 200 { + return Err(eyre!( + "\ + Status {publish_response_status} from publish POST\n\ + {}\ + ", + String::from_utf8_lossy(&publish_response.bytes().await.unwrap()) + )); + } + + tracing::info!( + "Successfully released new version of {upload_name}/{rolling_minor_with_postfix_or_tag}" + ); + + Ok(()) +} diff --git a/src/release_metadata.rs b/src/release_metadata.rs index 992700d..7f39f28 100644 --- a/src/release_metadata.rs +++ b/src/release_metadata.rs @@ -2,7 +2,7 @@ use color_eyre::eyre::{eyre, WrapErr}; use std::{collections::HashSet, path::Path}; use crate::{ - graphql::{GithubGraphqlDataResult, MAX_LABEL_LENGTH, MAX_NUM_TOTAL_LABELS}, + github::graphql::{GithubGraphqlDataResult, MAX_LABEL_LENGTH, MAX_NUM_TOTAL_LABELS}, Visibility, };