use std::{collections::HashMap, fs::File, io::Write, path::Path};
use anyhow::{Context, Result, bail};
use flate2::{Compression, write::GzEncoder};
use ignore::WalkBuilder;
use reqwest::{blocking::Client, header};
use serde::Deserialize;
use serde_json::Value;
use tar::Builder;
use termcolor::{ColorChoice, StandardStream, WriteColor};
use crate::{constants::get_platform_management_api_url, core::hmac::AuthMode};
#[derive(Debug, Deserialize)]
pub(crate) struct UploadUrlResponse {
#[serde(rename = "uploadUrl")]
pub upload_url: String,
#[serde(rename = "codeSourceUrl")]
pub code_source_url: String,
}
pub(crate) fn create_app_tarball(
app_root: &Path,
modules_path: &Path,
output_path: &Path,
) -> Result<()> {
let tar_gz = File::create(output_path)
.with_context(|| format!("Failed to create tarball file at {:?}", output_path))?;
let enc = GzEncoder::new(tar_gz, Compression::default());
let mut tar = Builder::new(enc);
let walker = WalkBuilder::new(modules_path)
.hidden(false) .git_ignore(true) .git_global(true) .git_exclude(true) .require_git(false) .add_custom_ignore_filename(".flignore") .build();
let mut file_count = 0;
for entry in walker {
let entry = entry.with_context(|| "Failed to read directory entry")?;
let path = entry.path();
if path.starts_with(app_root.join(".git")) {
continue;
}
if path.components().any(|c| c.as_os_str() == "node_modules") {
continue;
}
if path
.file_name()
.and_then(|n| n.to_str())
.map_or(false, |n| n.starts_with(".env"))
{
continue;
}
if path == output_path {
continue;
}
if path.is_file() {
let relative_path = path
.strip_prefix(app_root)
.with_context(|| format!("Failed to get relative path for {:?}", path))?;
tar.append_path_with_name(path, relative_path)
.with_context(|| format!("Failed to add {:?} to tarball", relative_path))?;
file_count += 1;
}
}
tar.finish().with_context(|| "Failed to finalize tarball")?;
if file_count == 0 {
bail!("No files found to package. Check your .gitignore settings.");
}
Ok(())
}
pub(crate) fn get_presigned_upload_url(
application_id: &str,
version: &str,
auth_mode: &AuthMode,
) -> Result<UploadUrlResponse> {
use crate::core::http_client;
let (url, sign_path) = if auth_mode.is_hmac() {
(
format!(
"{}/releases/internal/upload-url",
get_platform_management_api_url()
),
"/internal/upload-url",
)
} else {
(
format!("{}/releases/upload-url", get_platform_management_api_url()),
"/upload-url",
)
};
let request_body = serde_json::json!({
"applicationId": application_id,
"version": version
});
let response =
http_client::post_with_auth_and_sign_path(auth_mode, &url, sign_path, request_body)
.with_context(|| "Failed to request upload URL from platform")?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
bail!(
"Failed to get upload URL: {} (Status: {})",
error_body,
status
);
}
response
.json::<UploadUrlResponse>()
.with_context(|| "Failed to parse upload URL response")
}
pub(crate) fn upload_to_s3(file_path: &Path, presigned_url: &str) -> Result<()> {
let client = Client::new();
let file = File::open(file_path)
.with_context(|| format!("Failed to open tarball file {:?}", file_path))?;
let file_size = file
.metadata()
.with_context(|| format!("Failed to get metadata for file {:?}", file_path))?
.len();
let response = client
.put(presigned_url)
.body(file)
.header(header::CONTENT_TYPE, "application/gzip")
.header(header::CONTENT_LENGTH, file_size)
.send()
.with_context(|| "Failed to upload tarball to S3")?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
bail!(
"Failed to upload to S3: {} (Status: {})",
error_body,
status
);
}
let mut stdout = StandardStream::stdout(ColorChoice::Always);
log_info!(stdout, "Uploaded {} bytes to S3", file_size);
Ok(())
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenApiUploadUrlEntry {
#[serde(rename = "uploadUrl")]
pub upload_url: String,
#[serde(rename = "s3Key")]
pub s3_key: String,
}
pub(crate) fn get_openapi_upload_urls(
application_id: &str,
version: &str,
service_names: &[String],
auth_mode: &AuthMode,
) -> Result<HashMap<String, OpenApiUploadUrlEntry>> {
use crate::core::http_client;
let (url, sign_path) = if auth_mode.is_hmac() {
(
format!(
"{}/releases/internal/openapi-upload-urls",
get_platform_management_api_url()
),
"/internal/openapi-upload-urls",
)
} else {
(
format!(
"{}/releases/openapi-upload-urls",
get_platform_management_api_url()
),
"/openapi-upload-urls",
)
};
let request_body = serde_json::json!({
"applicationId": application_id,
"version": version,
"serviceNames": service_names
});
let response =
http_client::post_with_auth_and_sign_path(auth_mode, &url, sign_path, request_body)
.with_context(|| "Failed to request OpenAPI upload URLs from platform")?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
bail!(
"Failed to get OpenAPI upload URLs: {} (Status: {})",
error_body,
status
);
}
let wrapper: serde_json::Value = response
.json()
.with_context(|| "Failed to parse OpenAPI upload URL response")?;
let urls_value = wrapper
.get("urls")
.ok_or_else(|| anyhow::anyhow!("Missing 'urls' field in response"))?;
serde_json::from_value(urls_value.clone())
.with_context(|| "Failed to parse OpenAPI upload URLs from response")
}
pub(crate) fn upload_json_to_s3(json_value: &Value, presigned_url: &str) -> Result<()> {
let client = Client::new();
let json_bytes =
serde_json::to_vec(json_value).with_context(|| "Failed to serialize JSON for S3 upload")?;
let response = client
.put(presigned_url)
.body(json_bytes.clone())
.header(header::CONTENT_TYPE, "application/json")
.header(header::CONTENT_LENGTH, json_bytes.len())
.send()
.with_context(|| "Failed to upload JSON to S3")?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
bail!(
"Failed to upload JSON to S3: {} (Status: {})",
error_body,
status
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs::{self, File};
use flate2::read::GzDecoder;
use tar::Archive;
use tempfile::TempDir;
use super::create_app_tarball;
#[test]
fn create_app_tarball_includes_generated_rust_and_typescript_sdks() {
let temp_dir = TempDir::new().unwrap();
let app_root = temp_dir.path();
fs::create_dir_all(app_root.join(".forklaunch")).unwrap();
fs::create_dir_all(app_root.join("crates").join("accounts").join("src")).unwrap();
fs::create_dir_all(
app_root
.join("sdks")
.join("rust")
.join("accounts-sdk")
.join("src"),
)
.unwrap();
fs::create_dir_all(app_root.join("sdks").join("typescript").join("src")).unwrap();
fs::create_dir_all(app_root.join("target").join("debug")).unwrap();
fs::create_dir_all(app_root.join("node_modules").join("ignored")).unwrap();
fs::write(app_root.join(".gitignore"), "target\n").unwrap();
fs::write(app_root.join("Cargo.toml"), "[workspace]\n").unwrap();
fs::write(
app_root
.join("crates")
.join("accounts")
.join("src")
.join("lib.rs"),
"pub fn accounts() {}\n",
)
.unwrap();
fs::write(
app_root
.join("sdks")
.join("rust")
.join("accounts-sdk")
.join("src")
.join("lib.rs"),
"pub struct AccountsClient;\n",
)
.unwrap();
fs::write(
app_root
.join("sdks")
.join("typescript")
.join("src")
.join("index.ts"),
"export const sdk = {};\n",
)
.unwrap();
fs::write(
app_root.join("target").join("debug").join("ignored"),
"ignored",
)
.unwrap();
fs::write(
app_root
.join("node_modules")
.join("ignored")
.join("package.json"),
"{}",
)
.unwrap();
let tarball_path = app_root.join(".forklaunch").join("release-code.tar.gz");
create_app_tarball(app_root, app_root, &tarball_path).unwrap();
let tarball = File::open(tarball_path).unwrap();
let decoder = GzDecoder::new(tarball);
let mut archive = Archive::new(decoder);
let mut entries = archive
.entries()
.unwrap()
.map(|entry| entry.unwrap().path().unwrap().to_string_lossy().to_string())
.collect::<Vec<_>>();
entries.sort();
assert!(entries.contains(&"Cargo.toml".to_string()));
assert!(entries.contains(&"crates/accounts/src/lib.rs".to_string()));
assert!(entries.contains(&"sdks/rust/accounts-sdk/src/lib.rs".to_string()));
assert!(entries.contains(&"sdks/typescript/src/index.ts".to_string()));
assert!(!entries.iter().any(|entry| entry.starts_with("target/")));
assert!(
!entries
.iter()
.any(|entry| entry.starts_with("node_modules/"))
);
}
}