use std::fs; use std::io::{self, Read, Write}; use std::path::Path; use cli_table::format::Justify; use cli_table::{Cell, Style, Table}; use color_eyre::eyre::{self, Context}; use color_eyre::{Help, Report, Result}; use globwalk::{DirEntry, GlobWalkerBuilder}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; use time::format_description::well_known::Iso8601; use url::Url; use crate::types::{Package, PackageType}; use crate::util::make_client; use crate::Action; #[derive(Deserialize, Serialize, Debug)] struct Source { access_token: String, owner: String, url: Url, r#type: PackageType, name: String, } #[derive(Deserialize, Serialize, Debug)] struct Version { version: String, } #[derive(Deserialize, Serialize, Debug)] struct Params { version: String, globs: Vec, #[serde(default)] fail_fast: bool, #[serde(default)] r#override: bool, } #[derive(Deserialize, Serialize, Debug)] struct Config { /// Resource configuration. /// Passed verbatim from the definition in the pipeline. source: Source, /// For 'check': /// Previous version of this resource. /// Will be empty for the very first request. /// For 'in': /// The version metadata to create this as. version: Option, params: Option, } fn action_check(conf: Config) -> Result<()> { let client = make_client(&conf.source.access_token)?; let url = conf .source .url .join(&format!("api/v1/packages/{}", conf.source.owner)) .wrap_err("Invalid URL")?; let mut pkgs: Vec = client .get(url) .query(&[ ("type", conf.source.r#type.to_string()), ("q", conf.source.name), ]) .send()? .json()?; if pkgs.is_empty() { return io::stdout().write_all(b"[]").map_err(From::from); } pkgs.sort_unstable_by_key(|pkg| pkg.created_at); let table: Vec<_> = pkgs .iter() .map(|pkg| { vec![ pkg.id.cell().justify(Justify::Center), pkg.name.clone().cell(), pkg.owner.login.clone().cell(), pkg.version.clone().cell(), pkg.created_at .format(&Iso8601::DEFAULT) .unwrap_or_default() .cell(), ] }) .collect(); let table = table .table() .title(vec![ "#".cell().bold(true), "Name".cell().bold(true), "Owner".cell().bold(true), "Version".cell().bold(true), "Created At".cell().bold(true), ]) .bold(true); let _ = cli_table::print_stderr(table); let newest = pkgs .last() .expect("List of packages should not be empty.") .version .clone(); let out = if let Some(prev) = conf.version { if prev.version == newest { vec![prev] } else { vec![prev, Version { version: newest }] } } else { vec![Version { version: newest }] }; serde_json::to_writer_pretty(io::stdout(), &out) .wrap_err("Failed to write result to stdout")?; Ok(()) } fn action_out(conf: Config, dir: impl AsRef) -> Result<()> { let dir = dir .as_ref() .canonicalize() .wrap_err_with(|| format!("Invalid file path '{}'", dir.as_ref().display()))?; let Some(params) = conf.params else { eyre::bail!("Missing params"); }; if params.globs.is_empty() { eyre::bail!("`params.globs` must not be empty"); } let client = make_client(&conf.source.access_token)?; let url = conf .source .url .join(&format!( // The trailing slash is required, to make sure this entire URL is used // when `join`ing the file name later. Otherwise, the last component // would be considered a "file" part itself, and replaced by a future `join`. "api/packages/{}/{}/{}/{}/", conf.source.owner, conf.source.r#type, conf.source.name, params.version )) .wrap_err("Invalid URL")?; let walker = GlobWalkerBuilder::from_patterns(dir, ¶ms.globs) .max_depth(3) .file_type(globwalk::FileType::FILE) .build() .wrap_err("Failed to create glob walker")?; let handle_glob = |entry| { let entry: DirEntry = entry?; let name = entry.file_name(); let path = entry.path(); let url = url.join(&name.to_string_lossy()).wrap_err("Invalid url")?; if params.r#override { client .delete(url.clone()) .send() .wrap_err_with(|| format!("Failed request 'DELETE {}'", &url))?; } let data = fs::read(path) .wrap_err_with(|| format!("Failed to read package file '{}'", path.display()))?; let res = client .put(url.clone()) .body(data) .send() .wrap_err_with(|| format!("Failed request 'PUT {}'", &url))?; match res.status() { StatusCode::CREATED => { eprintln!( "Uploaded file '{}', version={}, package={}", name.to_string_lossy(), params.version, conf.source.name ); } StatusCode::BAD_REQUEST => { eyre::bail!( "Package, version or file name are invalid. package={}, version={}, file={}", conf.source.name, params.version, name.to_string_lossy(), ); } StatusCode::CONFLICT => { eyre::bail!( "File '{}' already exists. version={}, package={}", name.to_string_lossy(), params.version, conf.source.name ); } StatusCode::INTERNAL_SERVER_ERROR => { eyre::bail!( "Internal server error: {} {}", res.status(), res.text().unwrap_or_default() ); } code => { eyre::bail!("Unexpected status code {}\ntext = {:?}", code, res.text()); } } Ok::<_, Report>(()) }; let mut has_entry = false; let mut errors = Vec::new(); for entry in walker { has_entry = true; if let Err(err) = handle_glob(entry) { errors.push(err); if params.fail_fast { break; } } } if !errors.is_empty() { let mut err = eyre::eyre!("Failed to upload package with {} errors", errors.len()); for e in errors { err = err.report(e); } return Err(err); } if !has_entry { eyre::bail!("Globs didn't produce any files"); } let out = json!({ "version": { "version": params.version } }); serde_json::to_writer_pretty(io::stdout(), &out) .wrap_err("Failed to write result to stdout")?; Ok(()) } pub(crate) fn run(action: &Action) -> Result<()> { let config: Config = { let mut buf = String::new(); io::stdin() .read_to_string(&mut buf) .wrap_err("Failed to read from stdin")?; if buf.is_empty() { eyre::bail!("No data received on stdin"); } serde_json::from_str(&buf).wrap_err("Failed to parse stdin")? }; match action { Action::Check => action_check(config), Action::In { dest: _ } => Ok(()), Action::Out { src } => action_out(config, src), } }