From 48a4b6dcdd473d19d8d64e7009e82eb836c66ea0 Mon Sep 17 00:00:00 2001 From: Daniel Voogsgerd Date: Sat, 5 Apr 2025 13:30:13 +0200 Subject: [PATCH] feat: Add support for alternate download locations --- brane-ctl/src/cli.rs | 25 ++- brane-ctl/src/download.rs | 147 ++++++----------- brane-ctl/src/errors.rs | 17 +- brane-ctl/src/main.rs | 4 +- brane-ctl/src/spec.rs | 27 +-- brane-shr/src/utilities.rs | 329 ++++++++++++++++++++++++++++++++++++- 6 files changed, 409 insertions(+), 140 deletions(-) diff --git a/brane-ctl/src/cli.rs b/brane-ctl/src/cli.rs index 369abb19..5e917c89 100644 --- a/brane-ctl/src/cli.rs +++ b/brane-ctl/src/cli.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use brane_cfg::proxy::ProxyProtocol; use brane_ctl::spec::{ - API_DEFAULT_VERSION, DownloadServicesSubcommand, GenerateBackendSubcommand, GenerateCertsSubcommand, GenerateNodeSubcommand, InclusiveRange, - Pair, PolicyInputLanguage, ResolvableNodeKind, StartSubcommand, VersionFix, + API_DEFAULT_VERSION, GenerateBackendSubcommand, GenerateCertsSubcommand, GenerateNodeSubcommand, ImageGroup, InclusiveRange, Pair, + PolicyInputLanguage, ResolvableNodeKind, StartSubcommand, VersionFix, }; use brane_tsk::docker::ClientVersion; use clap::{Parser, Subcommand}; @@ -15,6 +15,8 @@ use specifications::arch::Arch; use specifications::package::Capability; use specifications::version::Version; +const DEFAULT_DOCKER_HOST: &str = "/var/run/docker.sock"; + /***** ARGUMENTS *****/ /// Defines the toplevel arguments for the `branectl` tool. #[derive(Debug, Parser)] @@ -190,10 +192,7 @@ pub(crate) enum DownloadSubcommand { global = true, help = "The processor architecture for which to download the images. Specify '$LOCAL' to use the architecture of the current machine." )] - arch: Arch, - /// The version of the services to download. - #[clap(short, long, default_value=env!("CARGO_PKG_VERSION"), global=true, help="The version of the images to download from GitHub. You can specify 'latest' to download the latest version (but that might be incompatible with this CTL version)")] - version: Version, + arch: Arch, /// Whether to overwrite existing images or not. #[clap( short = 'F', @@ -202,11 +201,19 @@ pub(crate) enum DownloadSubcommand { help = "If given, will overwrite services that are already there. Otherwise, these are not overwritten. Note that regardless, a \ download will still be performed." )] - force: bool, + force: bool, + + /// The path of the Docker socket. + #[clap(long, default_value = DEFAULT_DOCKER_HOST, env="DOCKER_HOST", help = "The path of the Docker socket to connect to.")] + docker_socket: PathBuf, + /// The client version to connect with. + #[clap(long, default_value=API_DEFAULT_VERSION.as_str(), env="DOCKER_API_VERSION", help="The client version to connect to the Docker instance with.")] + docker_client_version: ClientVersion, /// Whether to download the central or the worker VMs. - #[clap(subcommand)] - kind: DownloadServicesSubcommand, + /// TODO: Enhance docs + #[clap(help = "The collection of images")] + kind: String, }, } diff --git a/brane-ctl/src/download.rs b/brane-ctl/src/download.rs index 26798ce8..6bfe1fa1 100644 --- a/brane-ctl/src/download.rs +++ b/brane-ctl/src/download.rs @@ -19,7 +19,8 @@ use std::io::Write as _; use std::path::{Component, Path, PathBuf}; use brane_shr::fs::{DownloadSecurity, download_file_async, move_path_async, unarchive_async}; -use brane_tsk::docker::{Docker, DockerOptions, ImageSource, connect_local, ensure_image, save_image}; +use brane_shr::utilities::{ContainerImageSource, RepositoryRelease, create_dir_with_cachedirtag}; +use brane_tsk::docker::{ClientVersion, Docker, DockerOptions, ImageSource, connect_local, ensure_image, save_image}; use console::{Style, style}; use enum_debug::EnumDebug as _; use log::{debug, info, warn}; @@ -29,7 +30,7 @@ use specifications::version::Version; use tempfile::TempDir; pub use crate::errors::DownloadError as Error; -use crate::spec::DownloadServicesSubcommand; +use crate::spec::ImageGroup; /***** CONSTANTS *****/ @@ -153,6 +154,8 @@ async fn download_brane_services(address: impl AsRef, path: impl AsRef, arch: Arch, - version: Version, force: bool, - kind: DownloadServicesSubcommand, + docker_client: PathBuf, + docker_version: ClientVersion, + identifier: String, ) -> Result<(), Error> { let path: &Path = path.as_ref(); - info!("Downloading {} service images...", kind.variant()); + let image_source = + ContainerImageSource::from_identifier(&identifier, arch).map_err(|source| Error::InvalidDownloadIdentifier { identifier, source })?; - // Fix the missing directories, if any. - if !path.exists() { - // We are paralyzed if the user told us not to do anything - if !fix_dirs { - return Err(Error::DirNotFound { what: "output", path: path.into() }); - } + info!("Downloading images"); - // Else, generate the directory tree one-by-one. We place a CACHEDIR.TAG in the highest one we create. - let mut first: bool = true; - let mut stack: PathBuf = PathBuf::new(); - for comp in path.components() { - match comp { - Component::RootDir => { - stack = PathBuf::from("/"); - continue; - }, - Component::Prefix(comp) => { - stack = PathBuf::from(comp.as_os_str()); - continue; - }, - - Component::CurDir => continue, - Component::ParentDir => { - stack.pop(); - continue; - }, - Component::Normal(comp) => { - stack.push(comp); - if !stack.exists() { - // Create the directory first - fs::create_dir(&stack).map_err(|source| Error::DirCreateError { what: "output", path: stack.clone(), source })?; - - // Then create the CACHEDIR.TAG if we haven't already - if first { - let tag_path: PathBuf = stack.join("CACHEDIR.TAG"); - let mut handle: File = - File::create(&tag_path).map_err(|source| Error::CachedirTagCreate { path: tag_path.clone(), source })?; - handle.write( - b"Signature: 8a477f597d28d172789f06886806bc55\n# This file is a cache directory tag created by BRANE's `branectl`.\n# For information about cache directory tags, see:\n# https://www.brynosaurus.com/cachedir/\n", - ).map_err(|source| Error::CachedirTagWrite { path: tag_path, source })?; - first = false; - } - } - continue; - }, - } - } + // We are paralyzed if the user told us not to do anything + if !path.exists() && !fix_dirs { + return Err(Error::DirNotFound { what: "output", path: path.into() }); } + if !path.is_dir() { return Err(Error::DirNotADir { what: "output", path: path.into() }); } - // Now match on what we are downloading - match &kind { - DownloadServicesSubcommand::Central => { - // Resolve the address to use - let address: String = if version.is_latest() { - format!("https://github.com/braneframework/brane/releases/latest/download/instance-{}.tar.gz", arch.brane()) - } else { - format!("https://github.com/braneframework/brane/releases/download/v{}/instance-{}.tar.gz", version, arch.brane()) - }; - debug!("Will download from: {}", address); - - // Hand it over the shared code - download_brane_services(address, path, format!("instance-{}", arch.brane()), force).await?; - }, - - DownloadServicesSubcommand::Worker => { - // Resolve the address to use - let address: String = if version.is_latest() { - format!("https://github.com/braneframework/brane/releases/latest/download/worker-instance-{}.tar.gz", arch.brane()) - } else { - format!("https://github.com/braneframework/brane/releases/download/v{}/worker-instance-{}.tar.gz", version, arch.brane()) - }; - debug!("Will download from: {}", address); - - // Hand it over the shared code - download_brane_services(address, path, format!("worker-instance-{}", arch.brane()), force).await?; - }, + create_dir_with_cachedirtag(path)?; - DownloadServicesSubcommand::Auxillary { socket, client_version } => { + match image_source { + ContainerImageSource::RegistryImage(registry_image) => { // Attempt to connect to the local Docker daemon. - let docker: Docker = connect_local(DockerOptions { socket: socket.clone(), version: *client_version }) + let docker: Docker = connect_local(DockerOptions { socket: docker_client.clone(), version: docker_version }) .map_err(|source| Error::DockerConnectError { source })?; - // Download the pre-determined set of auxillary images - for (name, image) in AUXILLARY_DOCKER_IMAGES { - // We can skip it if it already exists - let image_path: PathBuf = path.join(format!("{name}.tar")); - if !force && image_path.exists() { - debug!("Image '{}' already exists (skipping)", image_path.display()); - continue; - } + let name = registry_image.image.clone(); + let image = registry_image.identifier(); + // Download the pre-determined set of auxillary images + // We can skip it if it already exists + let image_path: PathBuf = path.join(format!("{name}.tar")); + if !force && image_path.exists() { + println!("Image '{}' already exists (skipping)", image_path.display()); + } else { // Make sure the image is pulled - println!("Downloading auxillary image {}...", style(image).bold().green()); - ensure_image(&docker, Image::new(name, None::<&str>, None::<&str>), ImageSource::Registry(image.into())) + println!("Downloading auxillary image {}...", style(&image).bold().green()); + ensure_image(&docker, Image::new(name.clone(), None::<&str>, None::<&str>), ImageSource::Registry(image.clone())) .await - .map_err(|source| Error::PullError { name: name.into(), image: image.into(), source })?; + .map_err(|source| Error::PullError { name: name.clone(), image: image.clone(), source })?; // Save the image to the correct path - println!("Exporting auxillary image {}...", style(name).bold().green()); - save_image(&docker, Image::from(image), &image_path).await.map_err(|source| Error::SaveError { - name: name.into(), - image: image.into(), + println!("Exporting auxillary image {}...", style(&name).bold().green()); + save_image(&docker, Image::from(image.clone()), &image_path).await.map_err(|source| Error::SaveError { + name: name.clone(), + image: image.clone(), path: image_path, source, })?; } }, + ContainerImageSource::RepositoryRelease(artifact) => { + let address = &artifact.url(); + + let archive_dir = address + .rsplit_once('/') + .ok_or_else(|| Error::InvalidDownloadUrl { url: address.clone() })? + .1 + .strip_suffix(".tar.gz") + .ok_or_else(|| Error::InvalidDownloadUrl { url: address.clone() })? + .to_owned(); + + debug!("Will download from: {}", address); + + // Hand it over the shared code + download_brane_services(address, path, archive_dir, force).await?; + }, } // Done! - println!("Successfully downloaded {} services to {}", kind.variant().to_string().to_lowercase(), style(path.display()).bold().green()); + // println!("Successfully downloaded {} services to {}", kind.variant().to_string().to_lowercase(), style(path.display()).bold().green()); Ok(()) } diff --git a/brane-ctl/src/errors.rs b/brane-ctl/src/errors.rs index b6fb207c..ba413736 100644 --- a/brane-ctl/src/errors.rs +++ b/brane-ctl/src/errors.rs @@ -18,6 +18,7 @@ use std::process::{Command, ExitStatus}; use brane_cfg::node::NodeKind; use brane_shr::formatters::Capitalizeable; +use brane_shr::utilities::{ContainerImageSourceError, CreateDirWithCacheTagError}; use brane_tsk::docker::ImageSource; use console::style; use enum_debug::EnumDebug as _; @@ -32,22 +33,14 @@ use specifications::version::Version; /// Note: we box `brane_shr::fs::Error` to avoid the error enum growing too large (see `clippy::result_large_err`). #[derive(Debug, thiserror::Error)] pub enum DownloadError { - /// Failed to create a new CACHEDIR.TAG - #[error("Failed to create CACHEDIR.TAG file '{}'", path.display())] - CachedirTagCreate { path: PathBuf, source: std::io::Error }, - /// Failed to write to a new CACHEDIR.TAG - #[error("Failed to write to CACHEDIR.TAG file '{}'", path.display())] - CachedirTagWrite { path: PathBuf, source: std::io::Error }, - + #[error("Could not create directory with CacheDir.Tag")] + CreateDirWithCacheDirTagError(#[from] CreateDirWithCacheTagError), /// The given directory does not exist. #[error("{} directory '{}' not found", what.capitalize(), path.display())] DirNotFound { what: &'static str, path: PathBuf }, /// The given directory exists but is not a directory. #[error("{} directory '{}' exists but is not a directory", what.capitalize(), path.display())] DirNotADir { what: &'static str, path: PathBuf }, - /// Could not create a new directory at the given location. - #[error("Failed to create {} directory '{}'", what, path.display())] - DirCreateError { what: &'static str, path: PathBuf, source: std::io::Error }, /// Failed to create a temporary directory. #[error("Failed to create a temporary directory")] @@ -77,6 +70,10 @@ pub enum DownloadError { /// Failed to save a pulled image. #[error("Failed to save image '{}' to '{}'", name, path.display())] SaveError { name: String, image: String, path: PathBuf, source: brane_tsk::docker::Error }, + #[error("{url} is not a valid download url")] + InvalidDownloadUrl { url: String }, + #[error("Invalid download identifier: {identifier}")] + InvalidDownloadIdentifier { identifier: String, source: ContainerImageSourceError }, } diff --git a/brane-ctl/src/main.rs b/brane-ctl/src/main.rs index ee47acc9..2ecbf89b 100644 --- a/brane-ctl/src/main.rs +++ b/brane-ctl/src/main.rs @@ -57,9 +57,9 @@ async fn main() { // Now match on the command match args.subcommand { CtlSubcommand::Download(subcommand) => match *subcommand { - DownloadSubcommand::Services { fix_dirs, path, arch, version, force, kind } => { + DownloadSubcommand::Services { fix_dirs, path, arch, force, docker_socket, docker_client_version, kind } => { // Run the subcommand - if let Err(err) = download::services(fix_dirs, path, arch, version, force, kind).await { + if let Err(err) = download::services(fix_dirs, path, arch, force, docker_socket, docker_client_version, kind).await { error!("{}", err.trace()); std::process::exit(1); } diff --git a/brane-ctl/src/spec.rs b/brane-ctl/src/spec.rs index 143d5d31..bcab4288 100644 --- a/brane-ctl/src/spec.rs +++ b/brane-ctl/src/spec.rs @@ -19,7 +19,7 @@ use std::str::FromStr; use brane_cfg::node::NodeKind; use brane_tsk::docker::{ClientVersion, ImageSource}; -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use enum_debug::EnumDebug; use specifications::address::Address; use specifications::version::Version; @@ -300,30 +300,17 @@ pub struct LogsOpts { pub compose_verbose: bool, } - - -/// A bit awkward here, but defines the subcommand for downloading service images from the repo. -#[derive(Debug, EnumDebug, Subcommand)] -pub enum DownloadServicesSubcommand { +#[derive(Clone, Debug, EnumDebug, ValueEnum)] +pub enum ImageGroup { /// Download the services for a central node. - #[clap(name = "central", about = "Downloads the central node services (brane-api, brane-drv, brane-plr, brane-prx)")] + #[clap(name = "central")] Central, /// Download the services for a worker node. - #[clap(name = "worker", about = "Downloads the worker node services (brane-reg, brane-job, brane-prx)")] + #[clap(name = "worker")] Worker, /// Download the auxillary services for the central node. - #[clap( - name = "auxillary", - about = "Downloads the auxillary services for the central node. Note that most of these are actually downloaded using Docker." - )] - Auxillary { - /// The path of the Docker socket. - #[clap(short, long, default_value = "/var/run/docker.sock", help = "The path of the Docker socket to connect to.")] - socket: PathBuf, - /// The client version to connect with. - #[clap(short, long, default_value=API_DEFAULT_VERSION.as_str(), help="The client version to connect to the Docker instance with.")] - client_version: ClientVersion, - }, + #[clap(name = "auxillary")] + Auxillary, } /// A bit awkward here, but defines the generate subcommand for the node file. This basically defines the possible kinds of nodes to generate. diff --git a/brane-shr/src/utilities.rs b/brane-shr/src/utilities.rs index d10bfebc..14cf27d9 100644 --- a/brane-shr/src/utilities.rs +++ b/brane-shr/src/utilities.rs @@ -13,13 +13,15 @@ // use std::borrow::Cow; -use std::fs::{self, DirEntry, ReadDir}; +use std::fs::{self, DirEntry, File, ReadDir}; use std::future::Future; -use std::path::{Path, PathBuf}; +use std::io::Write as _; +use std::path::{Component, Path, PathBuf}; use humanlog::{DebugMode, HumanLogger}; use log::{debug, warn}; -use regex::Regex; +use regex::{Regex, RegexSet}; +use specifications::arch::Arch; use specifications::container::ContainerInfo; use specifications::data::{AssetInfo, DataIndex, DataInfo}; use specifications::package::{PackageIndex, PackageInfo}; @@ -418,8 +420,272 @@ where } } +#[derive(Debug, thiserror::Error)] +pub enum CreateDirWithCacheTagError { + /// Failed to create a new CACHEDIR.TAG + #[error("Failed to create CACHEDIR.TAG file '{}'", path.display())] + CachedirTagCreate { path: PathBuf, source: std::io::Error }, + /// Failed to write to a new CACHEDIR.TAG + #[error("Failed to write to CACHEDIR.TAG file '{}'", path.display())] + CachedirTagWrite { path: PathBuf, source: std::io::Error }, + /// Could not create a new directory at the given location. + #[error("Failed to create {} directory '{}'", what, path.display())] + DirCreate { what: &'static str, path: PathBuf, source: std::io::Error }, +} + +pub fn create_dir_with_cachedirtag(path: impl AsRef) -> Result<(), CreateDirWithCacheTagError> { + let path = path.as_ref(); + + // Fix the missing directories, if any. + if !path.exists() { + // Else, generate the directory tree one-by-one. We place a CACHEDIR.TAG in the highest one we create. + let mut first: bool = true; + let mut stack: PathBuf = PathBuf::new(); + for comp in path.components() { + match comp { + Component::RootDir => { + stack = PathBuf::from("/"); + continue; + }, + Component::Prefix(comp) => { + stack = PathBuf::from(comp.as_os_str()); + continue; + }, + + Component::CurDir => continue, + Component::ParentDir => { + stack.pop(); + continue; + }, + Component::Normal(comp) => { + stack.push(comp); + if !stack.exists() { + // Create the directory first + fs::create_dir(&stack).map_err(|source| CreateDirWithCacheTagError::DirCreate { + what: "output", + path: stack.clone(), + source, + })?; + + // Then create the CACHEDIR.TAG if we haven't already + if first { + let tag_path: PathBuf = stack.join("CACHEDIR.TAG"); + let mut handle: File = File::create(&tag_path) + .map_err(|source| CreateDirWithCacheTagError::CachedirTagCreate { path: tag_path.clone(), source })?; + handle.write( + b"Signature: 8a477f597d28d172789f06886806bc55\n# This file is a cache directory tag created by BRANE's `branectl`.\n# For information about cache directory tags, see:\n# https://www.brynosaurus.com/cachedir/\n", + ).map_err(|source| CreateDirWithCacheTagError::CachedirTagWrite { path: tag_path, source })?; + first = false; + } + } + continue; + }, + } + } + } + + Ok(()) +} +#[derive(Debug, Eq, PartialEq)] +pub enum ContainerImageSource { + RegistryImage(RegistryImage), + RepositoryRelease(RepositoryRelease), +} + +#[derive(Debug, Eq, PartialEq)] +pub struct RepositoryRelease { + pub platform: RepositoryReleasePlatform, + pub namespace: String, + pub repository: String, + pub artifact: String, + pub version: String, + pub arch: Arch, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct RegistryImage { + pub platform: RegistryImagePlatform, + pub namespace: String, + pub image: String, + pub version: String, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum RepositoryReleasePlatform { + GitHub, + GitLab, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum RegistryImagePlatform { + DockerHub, + GitHubContainerRegistry, +} + + +#[derive(Debug, thiserror::Error, Eq, PartialEq)] +pub enum ContainerImageSourceError { + #[error("Could not get any capture group")] + NoMatch, + #[error("Unknown platform: {platform}")] + UnknownPlatform { platform: String }, +} + +impl ContainerImageSource { + pub fn from_identifier(identifier: &str, arch: Arch) -> Result { + let set = RegexSet::new([REPOSITORYRELEASE_REGEX, REGISTRY_REGEX]).unwrap(); + + let matches: Vec<_> = set.matches(identifier).into_iter().map(|index| &set.patterns()[index]).collect(); + + if matches.is_empty() { + return Err(ContainerImageSourceError::NoMatch); + } + + if matches.len() > 1 { + log::warn!("Image source was ambigious, assuming our first match"); + } + + Ok(match matches[0].as_str() { + REGISTRY_REGEX => ContainerImageSource::RegistryImage(RegistryImage::from_identifier(identifier, arch)?), + REPOSITORYRELEASE_REGEX => ContainerImageSource::RepositoryRelease(RepositoryRelease::from_identifier(identifier, arch)?), + + _ => unreachable!(), + }) + } +} + +// FIXME: choose right charset for all capture groups +const REGISTRY_REGEX: &str = + r"^(?:(?docker|dockerhub|dh|githubregistry|ghcr):)?(?:(?[A-Za-z0-9]+)/)?(?[A-Za-z]+)(?::(?[a-z0-9.]+))?$"; +const REPOSITORYRELEASE_REGEX: &str = + r"^(?:(?github|gitlab):)?(?:(?:(?[A-Za-z0-9]+)/)?(?[A-Za-z]+)/)?(?[A-Za-z0-9\-_]+)(?::(?[a-z0-9.]+))?$"; + +impl RegistryImage { + pub fn from_identifier(identifier: &str, arch: Arch) -> Result { + // Unwrap is possibly because Regex::new is not input-dependent and can only create compile-time errors + let re = Regex::new(REGISTRY_REGEX).unwrap(); + + let Some(capture) = re.captures(identifier) else { + return Err(ContainerImageSourceError::NoMatch); + }; + + let Some(image) = capture.name("image") else { + unreachable!( + "Artifact is not an optional capture group, so having no artifact in the identifier will result in a \ + ContainerImageSourceError::NoCapture instead of a None match option here." + ) + }; + + let platform = match capture.name("platform") { + Some(platform_match) => { + let platform = platform_match.as_str().to_owned(); + match platform.as_str() { + "docker" | "dockerhub" | "dh" => RegistryImagePlatform::DockerHub, + "githubregistry" | "ghcr" => RegistryImagePlatform::GitHubContainerRegistry, + _ => return Err(ContainerImageSourceError::UnknownPlatform { platform }), + } + }, + None => RegistryImagePlatform::DockerHub, + }; + + Ok(Self { + platform, + namespace: match capture.name("owner") { + Some(owner) => owner.as_str().to_owned(), + None => String::from("braneframework"), + }, + image: image.as_str().to_owned(), + version: match capture.name("version") { + Some(version) => version.as_str().to_owned(), + None => String::from("latest"), + }, + }) + } + + pub fn identifier(&self) -> String { + match self.platform { + RegistryImagePlatform::DockerHub => { + format!("{namespace}/{image}:{version}", namespace = self.namespace, image = self.image, version = self.version) + }, + RegistryImagePlatform::GitHubContainerRegistry => { + format!("ghrc.io/{namespace}/{image}:{version}", namespace = self.namespace, image = self.image, version = self.version) + }, + } + } +} + +impl RepositoryRelease { + pub fn from_identifier(identifier: &str, arch: Arch) -> Result { + // Unwrap is possibly because Regex::new is not input-dependent and can only create compile-time errors + let re = Regex::new(REPOSITORYRELEASE_REGEX).unwrap(); + + let Some(capture) = re.captures(identifier) else { + return Err(ContainerImageSourceError::NoMatch); + }; + + let Some(artifact) = capture.name("artifact") else { + unreachable!( + "Artifact is not an optional capture group, so having no artifact in the identifier will result in a \ + ContainerImageSourceError::NoCapture instead of a None match option here." + ) + }; + + let platform = match capture.name("platform") { + Some(platform_match) => { + let platform = platform_match.as_str().to_owned(); + match platform.as_str() { + "github" => RepositoryReleasePlatform::GitHub, + "gitlab" => RepositoryReleasePlatform::GitLab, + _ => return Err(ContainerImageSourceError::UnknownPlatform { platform }), + } + }, + None => RepositoryReleasePlatform::GitHub, + }; + + Ok(Self { + platform, + namespace: match capture.name("owner") { + Some(owner) => owner.as_str().to_owned(), + None => String::from("braneframework"), + }, + repository: match capture.name("repo") { + Some(repository) => repository.as_str().to_owned(), + None => String::from("brane"), + }, + artifact: artifact.as_str().to_owned(), + version: match capture.name("version") { + Some(version) => version.as_str().to_owned(), + None => String::from("latest"), + }, + arch, + }) + } + + pub fn url(&self) -> String { + match self.platform { + RepositoryReleasePlatform::GitHub => { + format!( + "https://github.com/{namespace}/{repository}/releases/download/{version}/{filename}", + namespace = self.namespace, + repository = self.repository, + version = self.version, + filename = format_args!("{artifact}-{arch}.tar.gz", artifact = self.artifact, arch = self.arch.brane()) + ) + }, + RepositoryReleasePlatform::GitLab => { + format!( + "https://gitlab.com/{namespace}/{project}/-/releases/{version}/downloads/{filename}", + namespace = self.namespace, + project = self.repository, + version = self.version, + filename = format_args!("{artifact}-{arch}.tar.gz", artifact = self.artifact, arch = self.arch.brane()) + ) + }, + } + } +} /***** ADDRESS CHECKING *****/ @@ -502,4 +768,61 @@ mod tests { let url = ensure_http_schema("https://localhost", false).unwrap(); assert_eq!(url, "https://localhost"); } + + #[test] + fn test_repository_release_from_identifier() { + assert_eq!( + RepositoryRelease::from_identifier("worker"), + Ok(RepositoryRelease { + platform: RepositoryReleasePlatform::GitHub, + namespace: String::from("braneframework"), + repository: String::from("brane"), + artifact: String::from("worker"), + version: String::from("latest"), + }) + ); + + assert_eq!( + RepositoryRelease::from_identifier("worker:1.2.3"), + Ok(RepositoryRelease { + platform: RepositoryReleasePlatform::GitHub, + namespace: String::from("braneframework"), + repository: String::from("brane"), + artifact: String::from("worker"), + version: String::from("1.2.3"), + }) + ); + assert_eq!( + RepositoryRelease::from_identifier("github:worker"), + Ok(RepositoryRelease { + platform: RepositoryReleasePlatform::GitHub, + namespace: String::from("braneframework"), + repository: String::from("brane"), + artifact: String::from("worker"), + version: String::from("latest"), + }) + ); + assert_eq!( + RepositoryRelease::from_identifier("DanielVoogsgerd/brane/worker:nightly"), + Ok(RepositoryRelease { + platform: RepositoryReleasePlatform::GitHub, + namespace: String::from("DanielVoogsgerd"), + repository: String::from("brane"), + artifact: String::from("worker"), + version: String::from("nightly"), + }) + ); + assert_eq!( + RepositoryRelease::from_identifier("gitlab:lut99/brane/worker:2.0.0"), + Ok(RepositoryRelease { + platform: RepositoryReleasePlatform::GitLab, + namespace: String::from("lut99"), + repository: String::from("brane"), + artifact: String::from("worker"), + version: String::from("2.0.0"), + }) + ); + assert_eq!(RepositoryRelease::from_identifier("github:braneframework/brane/"), Err(ContainerImageSourceError::NoMatch)); + assert_eq!(RepositoryRelease::from_identifier("github:braneframework/brane/:latest"), Err(ContainerImageSourceError::NoMatch)); + } }