use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use docker_credential::DockerCredential;
use futures_util::future;
use futures_util::stream::{self, StreamExt, TryStreamExt};
use itertools::Itertools;
use oci_distribution::{
client::ImageLayer, config::ConfigFile, manifest::OciImageManifest, secrets::RegistryAuth,
token_cache::RegistryTokenType, Reference, RegistryOperation,
};
use reqwest::Url;
use spin_common::sha256;
use spin_common::ui::quoted_path;
use spin_common::url::parse_file_url;
use spin_loader::cache::Cache;
use spin_loader::FilesMountStrategy;
use spin_locked_app::locked::{ContentPath, ContentRef, LockedApp};
use tokio::fs;
use walkdir::WalkDir;
use crate::auth::AuthConfig;
pub const SPIN_APPLICATION_MEDIA_TYPE: &str = "application/vnd.fermyon.spin.application.v1+config";
pub const DATA_MEDIATYPE: &str = "application/vnd.wasm.content.layer.v1+data";
pub const ARCHIVE_MEDIATYPE: &str = "application/vnd.wasm.content.bundle.v1.tar+gzip";
const WASM_LAYER_MEDIA_TYPE: &str = "application/vnd.wasm.content.layer.v1+wasm";
const CONFIG_FILE: &str = "config.json";
const LATEST_TAG: &str = "latest";
const MANIFEST_FILE: &str = "manifest.json";
const SPIN_OCI_ARCHIVE_LAYERS_OPT: &str = "SPIN_OCI_ARCHIVE_LAYERS";
const MAX_PARALLEL_PULL: usize = 16;
const MAX_LAYER_COUNT: usize = 500;
const DEFAULT_CONTENT_REF_INLINE_MAX_SIZE: usize = 128;
const DEFAULT_TOKEN_EXPIRATION_SECS: usize = 300;
enum AssemblyMode {
Simple,
Archive,
}
pub struct Client {
pub cache: Cache,
oci: oci_distribution::Client,
pub opts: ClientOpts,
}
#[derive(Clone)]
pub struct ClientOpts {
pub content_ref_inline_max_size: usize,
}
#[derive(Debug, PartialEq)]
pub enum InferPredefinedAnnotations {
All,
None,
}
impl Client {
pub async fn new(insecure: bool, cache_root: Option<PathBuf>) -> Result<Self> {
let client = oci_distribution::Client::new(Self::build_config(insecure));
let cache = Cache::new(cache_root).await?;
let opts = ClientOpts {
content_ref_inline_max_size: DEFAULT_CONTENT_REF_INLINE_MAX_SIZE,
};
Ok(Self {
oci: client,
cache,
opts,
})
}
pub async fn push(
&mut self,
manifest_path: &Path,
reference: impl AsRef<str>,
annotations: Option<BTreeMap<String, String>>,
infer_annotations: InferPredefinedAnnotations,
) -> Result<Option<String>> {
let reference: Reference = reference
.as_ref()
.parse()
.with_context(|| format!("cannot parse reference {}", reference.as_ref()))?;
let auth = Self::auth(&reference).await?;
let working_dir = tempfile::tempdir()?;
let locked = spin_loader::from_file(
manifest_path,
FilesMountStrategy::Copy(working_dir.path().into()),
None,
)
.await?;
self.push_locked_core(locked, auth, reference, annotations, infer_annotations)
.await
}
pub async fn push_locked(
&mut self,
locked: LockedApp,
reference: impl AsRef<str>,
annotations: Option<BTreeMap<String, String>>,
infer_annotations: InferPredefinedAnnotations,
) -> Result<Option<String>> {
let reference: Reference = reference
.as_ref()
.parse()
.with_context(|| format!("cannot parse reference {}", reference.as_ref()))?;
let auth = Self::auth(&reference).await?;
self.push_locked_core(locked, auth, reference, annotations, infer_annotations)
.await
}
async fn push_locked_core(
&mut self,
locked: LockedApp,
auth: RegistryAuth,
reference: Reference,
annotations: Option<BTreeMap<String, String>>,
infer_annotations: InferPredefinedAnnotations,
) -> Result<Option<String>> {
let mut locked_app = locked.clone();
let mut layers = self
.assemble_layers(&mut locked_app, AssemblyMode::Simple)
.await
.context("could not assemble layers for locked application")?;
if std::env::var(SPIN_OCI_ARCHIVE_LAYERS_OPT).is_ok() || layers.len() > MAX_LAYER_COUNT - 1
{
locked_app = locked.clone();
layers = self
.assemble_layers(&mut locked_app, AssemblyMode::Archive)
.await
.context("could not assemble archive layers for locked application")?;
}
let annotations = all_annotations(&locked_app, annotations, infer_annotations);
let locked_config_layer = ImageLayer::new(
serde_json::to_vec(&locked_app).context("could not serialize locked config")?,
SPIN_APPLICATION_MEDIA_TYPE.to_string(),
None,
);
let config_layer_digest = locked_config_layer.sha256_digest().clone();
layers.push(locked_config_layer);
let mut labels = HashMap::new();
labels.insert(
"com.fermyon.spin.lockedAppDigest".to_string(),
config_layer_digest,
);
let cfg = oci_distribution::config::Config {
labels: Some(labels),
..Default::default()
};
let oci_config_file = ConfigFile {
architecture: oci_distribution::config::Architecture::Wasm,
os: oci_distribution::config::Os::Wasip1,
config: Some(cfg),
..Default::default()
};
let oci_config =
oci_distribution::client::Config::oci_v1_from_config_file(oci_config_file, None)?;
let manifest = OciImageManifest::build(&layers, &oci_config, annotations);
let response = self
.oci
.push(&reference, &layers, oci_config, &auth, Some(manifest))
.await
.map(|push_response| push_response.manifest_url)
.context("cannot push Spin application")?;
tracing::info!("Pushed {:?}", response);
let digest = digest_from_url(&response);
Ok(digest)
}
async fn assemble_layers(
&mut self,
locked: &mut LockedApp,
assembly_mode: AssemblyMode,
) -> Result<Vec<ImageLayer>> {
let mut layers = Vec::new();
let mut components = Vec::new();
for mut c in locked.clone().components {
let source = c
.clone()
.source
.content
.source
.context("component loaded from disk should contain a file source")?;
let source = parse_file_url(source.as_str())?;
let layer = Self::wasm_layer(&source).await?;
c.source.content = self.content_ref_for_layer(&layer);
layers.push(layer);
let mut deps = BTreeMap::default();
for (dep_name, mut dep) in c.dependencies {
let source = dep
.source
.content
.source
.context("dependency loaded from disk should contain a file source")?;
let source = parse_file_url(source.as_str())?;
let layer = Self::wasm_layer(&source).await?;
dep.source.content = self.content_ref_for_layer(&layer);
deps.insert(dep_name, dep);
layers.push(layer);
}
c.dependencies = deps;
let mut files = Vec::new();
for f in c.files {
let source = f
.content
.source
.context("file mount loaded from disk should contain a file source")?;
let source = parse_file_url(source.as_str())?;
match assembly_mode {
AssemblyMode::Archive => self
.push_archive_layer(&source, &mut files, &mut layers)
.await
.context(format!(
"cannot push archive layer for source {}",
quoted_path(&source)
))?,
AssemblyMode::Simple => self
.push_file_layers(&source, &mut files, &mut layers)
.await
.context(format!(
"cannot push file layers for source {}",
quoted_path(&source)
))?,
}
}
c.files = files;
components.push(c);
}
locked.components = components;
locked.metadata.remove("origin");
layers = layers.into_iter().unique().collect();
Ok(layers)
}
async fn push_archive_layer(
&mut self,
source: &PathBuf,
files: &mut Vec<ContentPath>,
layers: &mut Vec<ImageLayer>,
) -> Result<()> {
for entry in WalkDir::new(source) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let rel_path = entry.path().strip_prefix(source).unwrap();
tracing::trace!("Adding asset {rel_path:?} to component files list");
let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
let content = self.content_ref_for_layer(&layer);
files.push(ContentPath {
content,
path: rel_path.into(),
});
}
tracing::trace!("Adding archive layer for all files in source {:?}", &source);
let working_dir = tempfile::tempdir()?;
let archive_path = crate::utils::archive(source, &working_dir.into_path())
.await
.context(format!(
"Unable to create compressed archive for source {:?}",
source
))?;
let layer = Self::data_layer(archive_path.as_path(), ARCHIVE_MEDIATYPE.to_string()).await?;
layers.push(layer);
Ok(())
}
async fn push_file_layers(
&mut self,
source: &PathBuf,
files: &mut Vec<ContentPath>,
layers: &mut Vec<ImageLayer>,
) -> Result<()> {
tracing::trace!("Adding new layer per file under source {:?}", source);
for entry in WalkDir::new(source) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let rel_path = entry.path().strip_prefix(source).unwrap();
let rel_path = portable_path(rel_path);
tracing::trace!("Adding new layer for asset {rel_path:?}");
let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
let content = self.content_ref_for_layer(&layer);
let content_inline = content.inline.is_some();
files.push(ContentPath {
content,
path: rel_path,
});
let skip_layer = content_inline;
if !skip_layer {
layers.push(layer);
}
}
Ok(())
}
pub async fn pull(&mut self, reference: &str) -> Result<()> {
let reference: Reference = reference.parse().context("cannot parse reference")?;
let auth = Self::auth(&reference).await?;
let (manifest, digest) = self.oci.pull_image_manifest(&reference, &auth).await?;
let manifest_json = serde_json::to_string(&manifest)?;
tracing::debug!("Pulled manifest: {}", manifest_json);
let m = self.manifest_path(&reference.to_string()).await?;
fs::write(&m, &manifest_json).await?;
let mut cfg_bytes = Vec::new();
self.oci
.pull_blob(&reference, &manifest.config, &mut cfg_bytes)
.await?;
self.write_locked_app_config(&reference.to_string(), &cfg_bytes)
.await
.context("unable to write locked app config to cache")?;
stream::iter(manifest.layers)
.map(|layer| {
let this = &self;
let reference = reference.clone();
async move {
if this.cache.wasm_file(&layer.digest).is_ok()
|| this.cache.data_file(&layer.digest).is_ok()
{
tracing::debug!("Layer {} already exists in cache", &layer.digest);
return anyhow::Ok(());
}
tracing::debug!("Pulling layer {}", &layer.digest);
let mut bytes = Vec::with_capacity(layer.size.try_into()?);
this.oci.pull_blob(&reference, &layer, &mut bytes).await?;
match layer.media_type.as_str() {
SPIN_APPLICATION_MEDIA_TYPE => {
this.write_locked_app_config(&reference.to_string(), &bytes)
.await
.with_context(|| "unable to write locked app config to cache")?;
}
WASM_LAYER_MEDIA_TYPE => {
this.cache.write_wasm(&bytes, &layer.digest).await?;
}
ARCHIVE_MEDIATYPE => {
unpack_archive_layer(&this.cache, &bytes, &layer.digest).await?;
}
_ => {
this.cache.write_data(&bytes, &layer.digest).await?;
}
}
Ok(())
}
})
.buffer_unordered(MAX_PARALLEL_PULL)
.try_for_each(future::ok)
.await?;
tracing::info!("Pulled {}@{}", reference, digest);
Ok(())
}
async fn manifest_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
let reference: Reference = reference
.as_ref()
.parse()
.context("cannot parse OCI reference")?;
let p = self
.cache
.manifests_dir()
.join(fs_safe_segment(reference.registry()))
.join(reference.repository())
.join(reference.tag().unwrap_or(LATEST_TAG));
if !p.is_dir() {
fs::create_dir_all(&p).await.with_context(|| {
format!("cannot create directory {} for OCI manifest", p.display())
})?;
}
Ok(p.join(MANIFEST_FILE))
}
pub async fn lockfile_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
let reference: Reference = reference
.as_ref()
.parse()
.context("cannot parse reference")?;
let p = self
.cache
.manifests_dir()
.join(fs_safe_segment(reference.registry()))
.join(reference.repository())
.join(reference.tag().unwrap_or(LATEST_TAG));
if !p.is_dir() {
fs::create_dir_all(&p)
.await
.context("cannot find configuration object for reference")?;
}
Ok(p.join(CONFIG_FILE))
}
async fn write_locked_app_config(
&self,
reference: impl AsRef<str>,
bytes: impl AsRef<[u8]>,
) -> Result<()> {
let cfg = std::str::from_utf8(bytes.as_ref())?;
tracing::debug!("Pulled config: {}", cfg);
let c = self.lockfile_path(reference).await?;
fs::write(&c, &cfg).await.map_err(anyhow::Error::from)
}
async fn wasm_layer(file: &Path) -> Result<ImageLayer> {
tracing::trace!("Reading wasm module from {:?}", file);
Ok(ImageLayer::new(
fs::read(file)
.await
.with_context(|| format!("cannot read wasm module {}", quoted_path(file)))?,
WASM_LAYER_MEDIA_TYPE.to_string(),
None,
))
}
async fn data_layer(file: &Path, media_type: String) -> Result<ImageLayer> {
tracing::trace!("Reading data file from {:?}", file);
Ok(ImageLayer::new(
fs::read(&file)
.await
.with_context(|| format!("cannot read file {}", quoted_path(file)))?,
media_type,
None,
))
}
fn content_ref_for_layer(&self, layer: &ImageLayer) -> ContentRef {
ContentRef {
inline: (layer.data.len() <= self.opts.content_ref_inline_max_size)
.then(|| layer.data.to_vec()),
digest: Some(layer.sha256_digest()),
..Default::default()
}
}
pub async fn login(
server: impl AsRef<str>,
username: impl AsRef<str>,
password: impl AsRef<str>,
) -> Result<()> {
let registry = registry_from_input(server);
Self::validate_credentials(®istry, &username, &password).await?;
let mut auth = AuthConfig::load_default().await?;
auth.insert(registry, username, password)?;
auth.save_default().await
}
pub async fn insert_token(
&mut self,
reference: &Reference,
op: RegistryOperation,
token: RegistryTokenType,
) {
self.oci.tokens.insert(reference, op, token).await;
}
async fn validate_credentials(
server: impl AsRef<str>,
username: impl AsRef<str>,
password: impl AsRef<str>,
) -> Result<()> {
let client = dkregistry::v2::Client::configure()
.registry(server.as_ref())
.insecure_registry(false)
.username(Some(username.as_ref().into()))
.password(Some(password.as_ref().into()))
.build()
.context("cannot create client to send authentication request to the registry")?;
match client
.authenticate(&[""])
.await
{
Ok(_) => Ok(()),
Err(e) => bail!(format!(
"cannot authenticate as {} to registry {}: {}",
username.as_ref(),
server.as_ref(),
e
)),
}
}
async fn auth(reference: &Reference) -> Result<RegistryAuth> {
let server = reference
.resolve_registry()
.strip_suffix('/')
.unwrap_or_else(|| reference.resolve_registry());
match AuthConfig::get_auth_from_default(server).await {
Ok(c) => Ok(c),
Err(_) => match docker_credential::get_credential(server) {
Err(e) => {
tracing::trace!("Cannot retrieve credentials from Docker, attempting to use anonymous auth: {}", e);
Ok(RegistryAuth::Anonymous)
}
Ok(DockerCredential::UsernamePassword(username, password)) => {
tracing::trace!("Found Docker credentials");
Ok(RegistryAuth::Basic(username, password))
}
Ok(DockerCredential::IdentityToken(_)) => {
tracing::trace!("Cannot use contents of Docker config, identity token not supported. Using anonymous auth");
Ok(RegistryAuth::Anonymous)
}
},
}
}
fn build_config(insecure: bool) -> oci_distribution::client::ClientConfig {
let protocol = if insecure {
oci_distribution::client::ClientProtocol::Http
} else {
oci_distribution::client::ClientProtocol::Https
};
oci_distribution::client::ClientConfig {
protocol,
default_token_expiration_secs: DEFAULT_TOKEN_EXPIRATION_SECS,
..Default::default()
}
}
}
pub async fn unpack_archive_layer(
cache: &Cache,
bytes: impl AsRef<[u8]>,
digest: impl AsRef<str>,
) -> Result<()> {
cache.write_data(&bytes, &digest).await?;
let path = cache
.data_file(&digest)
.context("unable to read archive layer from cache")?;
let staging_dir = tempfile::tempdir()?;
crate::utils::unarchive(path.as_ref(), staging_dir.path()).await?;
for entry in WalkDir::new(staging_dir.path()) {
let entry = entry?;
if entry.file_type().is_file() && !entry.file_type().is_dir() {
let bytes = tokio::fs::read(entry.path()).await?;
let digest = format!("sha256:{}", sha256::hex_digest_from_bytes(&bytes));
if cache.data_file(&digest).is_ok() {
tracing::debug!(
"Skipping unpacked asset {:?}; file already exists",
entry.path()
);
} else {
tracing::debug!("Adding unpacked asset {:?} to cache", entry.path());
cache.write_data(bytes, &digest).await?;
}
}
}
Ok(())
}
fn digest_from_url(manifest_url: &str) -> Option<String> {
let manifest_url = Url::parse(manifest_url).ok()?;
let segments = manifest_url.path_segments()?;
let last = segments.last()?;
if last.contains(':') {
Some(last.to_owned())
} else {
None
}
}
fn registry_from_input(server: impl AsRef<str>) -> String {
let server = server.as_ref();
let server = match server.parse::<Url>() {
Ok(url) => url.host_str().unwrap_or(server).to_string(),
Err(_) => server.to_string(),
};
match server.as_str() {
"docker.io" => "index.docker.io".to_string(),
_ => server,
}
}
fn all_annotations(
locked_app: &LockedApp,
explicit: Option<BTreeMap<String, String>>,
predefined: InferPredefinedAnnotations,
) -> Option<BTreeMap<String, String>> {
use spin_locked_app::{MetadataKey, APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY};
const APP_AUTHORS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("authors");
if predefined == InferPredefinedAnnotations::None {
return explicit;
}
let mut current = explicit.unwrap_or_default();
let authors = locked_app
.get_metadata(APP_AUTHORS_KEY)
.unwrap_or_default()
.unwrap_or_default();
if !authors.is_empty() {
let authors = authors.join(", ");
add_inferred(
&mut current,
oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
Some(authors),
);
}
let name = locked_app.get_metadata(APP_NAME_KEY).unwrap_or_default();
add_inferred(
&mut current,
oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE,
name,
);
let description = locked_app
.get_metadata(APP_DESCRIPTION_KEY)
.unwrap_or_default();
add_inferred(
&mut current,
oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION,
description,
);
let version = locked_app.get_metadata(APP_VERSION_KEY).unwrap_or_default();
add_inferred(
&mut current,
oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION,
version,
);
let created = chrono::Utc::now().to_rfc3339();
add_inferred(
&mut current,
oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED,
Some(created),
);
Some(current)
}
fn add_inferred(map: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
if let Some(value) = value {
if let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string()) {
e.insert(value);
}
}
}
#[cfg(target_os = "windows")]
fn portable_path(rel_path: &Path) -> PathBuf {
assert!(
rel_path.is_relative(),
"portable_path requires paths to be relative"
);
let portable_path = rel_path.to_string_lossy().replace('\\', "/");
PathBuf::from(portable_path)
}
#[cfg(not(target_os = "windows"))]
fn portable_path(rel_path: &Path) -> PathBuf {
rel_path.into()
}
#[cfg(target_os = "windows")]
fn fs_safe_segment(segment: &str) -> impl AsRef<Path> {
segment.replace(':', "_")
}
#[cfg(not(target_os = "windows"))]
fn fs_safe_segment(segment: &str) -> impl AsRef<Path> + '_ {
segment
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn can_parse_digest_from_manifest_url() {
let manifest_url = "https://ghcr.io/v2/itowlson/osf/manifests/sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
let digest = digest_from_url(manifest_url).unwrap();
assert_eq!(
"sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f",
digest
);
}
#[test]
fn can_derive_registry_from_input() {
#[derive(Clone)]
struct TestCase {
input: &'static str,
want: &'static str,
}
let tests: Vec<TestCase> = [
TestCase {
input: "docker.io",
want: "index.docker.io",
},
TestCase {
input: "index.docker.io",
want: "index.docker.io",
},
TestCase {
input: "https://ghcr.io",
want: "ghcr.io",
},
]
.to_vec();
for tc in tests {
assert_eq!(tc.want, registry_from_input(tc.input));
}
}
#[macro_export]
macro_rules! from_json {
($($json:tt)+) => {
serde_json::from_value(serde_json::json!($($json)+)).expect("valid json")
};
}
#[tokio::test]
async fn can_assemble_layers() {
use spin_locked_app::locked::LockedComponent;
use tokio::io::AsyncWriteExt;
let working_dir = tempfile::tempdir().unwrap();
let _ = tokio::fs::create_dir(working_dir.path().join("component1").as_path()).await;
let _ = tokio::fs::create_dir(working_dir.path().join("component2").as_path()).await;
let mut c1 = tokio::fs::File::create(working_dir.path().join("component1.wasm"))
.await
.expect("should create component wasm file");
c1.write_all(b"c1")
.await
.expect("should write component wasm contents");
let mut c2 = tokio::fs::File::create(working_dir.path().join("component2.wasm"))
.await
.expect("should create component wasm file");
c2.write_all(b"c2")
.await
.expect("should write component wasm contents");
let mut c1f1 = tokio::fs::File::create(working_dir.path().join("component1").join("bar"))
.await
.expect("should create component file");
c1f1.write_all(b"bar")
.await
.expect("should write file contents");
let mut c1f2 = tokio::fs::File::create(working_dir.path().join("component1").join("baz"))
.await
.expect("should create component file");
c1f2.write_all(b"baz")
.await
.expect("should write file contents");
let mut c2f1 = tokio::fs::File::create(working_dir.path().join("component2").join("baz"))
.await
.expect("should create component file");
c2f1.write_all(b"baz")
.await
.expect("should write file contents");
#[derive(Clone)]
struct TestCase {
name: &'static str,
opts: Option<ClientOpts>,
locked_components: Vec<LockedComponent>,
expected_layer_count: usize,
expected_error: Option<&'static str>,
}
let tests: Vec<TestCase> = [
TestCase {
name: "Two component layers",
opts: None,
locked_components: from_json!([{
"id": "component1",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
"digest": "digest",
}},
{
"id": "component2",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
"digest": "digest",
}}]),
expected_layer_count: 2,
expected_error: None,
},
TestCase {
name: "One component layer and two file layers",
opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
locked_components: from_json!([{
"id": "component1",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
"digest": "digest",
},
"files": [
{
"source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
"path": working_dir.path().join("component1").join("bar").to_str().unwrap()
},
{
"source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
"path": working_dir.path().join("component1").join("baz").to_str().unwrap()
}
]
}]),
expected_layer_count: 3,
expected_error: None,
},
TestCase {
name: "One component layer and one file with inlined content",
opts: None,
locked_components: from_json!([{
"id": "component1",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
"digest": "digest",
},
"files": [
{
"source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
"path": working_dir.path().join("component1").join("bar").to_str().unwrap()
}
]
}]),
expected_layer_count: 1,
expected_error: None,
},
TestCase {
name: "One component layer and one dependency component layer",
opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
locked_components: from_json!([{
"id": "component1",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
"digest": "digest",
},
"dependencies": {
"test:comp2": {
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
"digest": "digest",
},
"export": null,
}
}
}]),
expected_layer_count: 2,
expected_error: None,
},
TestCase {
name: "Component has no source",
opts: None,
locked_components: from_json!([{
"id": "component1",
"source": {
"content_type": "application/wasm",
"source": "",
"digest": "digest",
}
}]),
expected_layer_count: 0,
expected_error: Some("Invalid URL: \"\""),
},
TestCase {
name: "Duplicate component sources",
opts: None,
locked_components: from_json!([{
"id": "component1",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
"digest": "digest",
}},
{
"id": "component2",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
"digest": "digest",
}}]),
expected_layer_count: 1,
expected_error: None,
},
TestCase {
name: "Duplicate file paths",
opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
locked_components: from_json!([{
"id": "component1",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
"digest": "digest",
},
"files": [
{
"source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
"path": working_dir.path().join("component1").join("bar").to_str().unwrap()
},
{
"source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
"path": working_dir.path().join("component1").join("baz").to_str().unwrap()
}
]},
{
"id": "component2",
"source": {
"content_type": "application/wasm",
"source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
"digest": "digest",
},
"files": [
{
"source": format!("file://{}", working_dir.path().join("component2").to_str().unwrap()),
"path": working_dir.path().join("component2").join("baz").to_str().unwrap()
}
]
}]),
expected_layer_count: 4,
expected_error: None,
},
]
.to_vec();
for tc in tests {
let triggers = Default::default();
let metadata = Default::default();
let variables = Default::default();
let mut locked = LockedApp {
spin_lock_version: Default::default(),
components: tc.locked_components,
triggers,
metadata,
variables,
must_understand: Default::default(),
host_requirements: Default::default(),
};
let mut client = Client::new(false, Some(working_dir.path().to_path_buf()))
.await
.expect("should create new client");
if let Some(o) = tc.opts {
client.opts = o;
}
match tc.expected_error {
Some(e) => {
assert_eq!(
e,
client
.assemble_layers(&mut locked, AssemblyMode::Simple)
.await
.unwrap_err()
.to_string(),
"{}",
tc.name
)
}
None => {
assert_eq!(
tc.expected_layer_count,
client
.assemble_layers(&mut locked, AssemblyMode::Simple)
.await
.unwrap()
.len(),
"{}",
tc.name
)
}
}
}
}
fn annotatable_app() -> LockedApp {
let mut meta_builder = spin_locked_app::values::ValuesMapBuilder::new();
meta_builder
.string("name", "this-is-spinal-tap")
.string("version", "11.11.11")
.string("description", "")
.string_array("authors", vec!["Marty DiBergi", "Artie Fufkin"]);
let metadata = meta_builder.build();
LockedApp {
spin_lock_version: Default::default(),
must_understand: vec![],
metadata,
host_requirements: Default::default(),
variables: Default::default(),
triggers: Default::default(),
components: Default::default(),
}
}
fn as_annotations(annotations: &[(&str, &str)]) -> Option<BTreeMap<String, String>> {
Some(
annotations
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
)
}
#[test]
fn no_annotations_no_infer_result_is_no_annotations() {
let locked_app = annotatable_app();
let explicit = None;
let infer = InferPredefinedAnnotations::None;
assert!(all_annotations(&locked_app, explicit, infer).is_none());
}
#[test]
fn explicit_annotations_no_infer_result_is_explicit_annotations() {
let locked_app = annotatable_app();
let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
let infer = InferPredefinedAnnotations::None;
let annotations =
all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
assert_eq!(2, annotations.len());
assert_eq!("11", annotations.get("volume").unwrap());
assert_eq!("feet", annotations.get("dimensions").unwrap());
}
#[test]
fn no_annotations_infer_all_result_is_auto_annotations() {
let locked_app = annotatable_app();
let explicit = None;
let infer = InferPredefinedAnnotations::All;
let annotations =
all_annotations(&locked_app, explicit, infer).expect("should now have annotations");
assert_eq!(4, annotations.len());
assert_eq!(
"Marty DiBergi, Artie Fufkin",
annotations
.get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
.expect("should have authors annotation")
);
assert_eq!(
"this-is-spinal-tap",
annotations
.get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE)
.expect("should have title annotation")
);
assert_eq!(
"11.11.11",
annotations
.get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION)
.expect("should have version annotation")
);
assert!(
!annotations
.contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION),
"empty description should not have generated annotation"
);
assert!(
annotations
.contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED),
"creation annotation should have been generated"
);
}
#[test]
fn explicit_annotations_infer_all_gets_both_sets() {
let locked_app = annotatable_app();
let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
let infer = InferPredefinedAnnotations::All;
let annotations =
all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
assert_eq!(6, annotations.len());
assert_eq!(
"11",
annotations
.get("volume")
.expect("should have retained explicit annotation")
);
assert_eq!(
"Marty DiBergi, Artie Fufkin",
annotations
.get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
.expect("should have authors annotation")
);
}
#[test]
fn explicit_annotations_take_precedence_over_inferred() {
let locked_app = annotatable_app();
let explicit = as_annotations(&[
("volume", "11"),
(
oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
"David St Hubbins, Nigel Tufnel",
),
]);
let infer = InferPredefinedAnnotations::All;
let annotations =
all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
assert_eq!(
5,
annotations.len(),
"should have one custom, one predefined explicit, and three inferred"
);
assert_eq!(
"11",
annotations
.get("volume")
.expect("should have retained explicit annotation")
);
assert_eq!(
"David St Hubbins, Nigel Tufnel",
annotations
.get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
.expect("should have authors annotation"),
"explicit authors should have taken precedence"
);
}
}