1use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5
6use anyhow::{bail, Context, Result};
7use docker_credential::DockerCredential;
8use futures_util::future;
9use futures_util::stream::{self, StreamExt, TryStreamExt};
10use itertools::Itertools;
11use oci_distribution::{
12 client::ImageLayer, config::ConfigFile, manifest::OciImageManifest, secrets::RegistryAuth,
13 token_cache::RegistryTokenType, Reference, RegistryOperation,
14};
15use reqwest::Url;
16use spin_common::sha256;
17use spin_common::ui::quoted_path;
18use spin_common::url::parse_file_url;
19use spin_compose::ComponentSourceLoaderFs;
20use spin_loader::cache::Cache;
21use spin_loader::FilesMountStrategy;
22use spin_locked_app::locked::{ContentPath, ContentRef, LockedApp, LockedComponent};
23use tokio::fs;
24use walkdir::WalkDir;
25
26use crate::auth::AuthConfig;
27
28pub const SPIN_APPLICATION_MEDIA_TYPE: &str = "application/vnd.fermyon.spin.application.v1+config";
31pub const DATA_MEDIATYPE: &str = "application/vnd.wasm.content.layer.v1+data";
33pub const ARCHIVE_MEDIATYPE: &str = "application/vnd.wasm.content.bundle.v1.tar+gzip";
35const WASM_LAYER_MEDIA_TYPE: &str = "application/vnd.wasm.content.layer.v1+wasm";
37
38const CONFIG_FILE: &str = "config.json";
39const LATEST_TAG: &str = "latest";
40const MANIFEST_FILE: &str = "manifest.json";
41
42const SPIN_OCI_ARCHIVE_LAYERS_OPT: &str = "SPIN_OCI_ARCHIVE_LAYERS";
44
45const MAX_PARALLEL_PULL: usize = 16;
46const MAX_LAYER_COUNT: usize = 500;
50
51const DEFAULT_CONTENT_REF_INLINE_MAX_SIZE: usize = 128;
54
55const DEFAULT_TOKEN_EXPIRATION_SECS: usize = 300;
60
61#[derive(Copy, Clone)]
63enum AssemblyMode {
64 Simple,
67 Archive,
70}
71
72#[derive(Copy, Clone)]
74pub enum ComposeMode {
75 All,
77 Skip,
79}
80
81pub struct Client {
83 pub cache: Cache,
85 oci: oci_distribution::Client,
87 pub opts: ClientOpts,
89}
90
91#[derive(Clone)]
92pub struct ClientOpts {
94 pub content_ref_inline_max_size: usize,
96}
97
98#[derive(Debug, PartialEq)]
102pub enum InferPredefinedAnnotations {
103 All,
105 None,
107}
108
109impl Client {
110 pub async fn new(insecure: bool, cache_root: Option<PathBuf>) -> Result<Self> {
112 let client = oci_distribution::Client::new(Self::build_config(insecure));
113 let cache = Cache::new(cache_root).await?;
114 let opts = ClientOpts {
115 content_ref_inline_max_size: DEFAULT_CONTENT_REF_INLINE_MAX_SIZE,
116 };
117
118 Ok(Self {
119 oci: client,
120 cache,
121 opts,
122 })
123 }
124
125 pub async fn push(
128 &mut self,
129 manifest_path: &Path,
130 reference: impl AsRef<str>,
131 annotations: Option<BTreeMap<String, String>>,
132 infer_annotations: InferPredefinedAnnotations,
133 compose_mode: ComposeMode,
134 ) -> Result<Option<String>> {
135 let reference: Reference = reference
136 .as_ref()
137 .parse()
138 .with_context(|| format!("cannot parse reference {}", reference.as_ref()))?;
139 let auth = Self::auth(&reference).await?;
140 let working_dir = tempfile::tempdir()?;
141
142 let locked = spin_loader::from_file(
146 manifest_path,
147 FilesMountStrategy::Copy(working_dir.path().into()),
148 None,
149 )
150 .await?;
151
152 self.push_locked_core(
153 locked,
154 auth,
155 reference,
156 annotations,
157 infer_annotations,
158 compose_mode,
159 )
160 .await
161 }
162
163 pub async fn push_locked(
166 &mut self,
167 locked: LockedApp,
168 reference: impl AsRef<str>,
169 annotations: Option<BTreeMap<String, String>>,
170 infer_annotations: InferPredefinedAnnotations,
171 compose_mode: ComposeMode,
172 ) -> Result<Option<String>> {
173 let reference: Reference = reference
174 .as_ref()
175 .parse()
176 .with_context(|| format!("cannot parse reference {}", reference.as_ref()))?;
177 let auth = Self::auth(&reference).await?;
178
179 self.push_locked_core(
180 locked,
181 auth,
182 reference,
183 annotations,
184 infer_annotations,
185 compose_mode,
186 )
187 .await
188 }
189
190 async fn push_locked_core(
193 &mut self,
194 locked: LockedApp,
195 auth: RegistryAuth,
196 reference: Reference,
197 annotations: Option<BTreeMap<String, String>>,
198 infer_annotations: InferPredefinedAnnotations,
199 compose_mode: ComposeMode,
200 ) -> Result<Option<String>> {
201 let mut locked_app = locked.clone();
202 let mut layers = self
203 .assemble_layers(&mut locked_app, AssemblyMode::Simple, compose_mode)
204 .await
205 .context("could not assemble layers for locked application")?;
206
207 if std::env::var(SPIN_OCI_ARCHIVE_LAYERS_OPT).is_ok() || layers.len() > MAX_LAYER_COUNT - 1
211 {
212 locked_app = locked.clone();
213 layers = self
214 .assemble_layers(&mut locked_app, AssemblyMode::Archive, compose_mode)
215 .await
216 .context("could not assemble archive layers for locked application")?;
217 }
218
219 let annotations = all_annotations(&locked_app, annotations, infer_annotations);
220
221 let locked_config_layer = ImageLayer::new(
223 serde_json::to_vec(&locked_app).context("could not serialize locked config")?,
224 SPIN_APPLICATION_MEDIA_TYPE.to_string(),
225 None,
226 );
227 let config_layer_digest = locked_config_layer.sha256_digest().clone();
228 layers.push(locked_config_layer);
229
230 let mut labels = HashMap::new();
231 labels.insert(
232 "com.fermyon.spin.lockedAppDigest".to_string(),
233 config_layer_digest,
234 );
235 let cfg = oci_distribution::config::Config {
236 labels: Some(labels),
237 ..Default::default()
238 };
239
240 let oci_config_file = ConfigFile {
245 architecture: oci_distribution::config::Architecture::Wasm,
246 os: oci_distribution::config::Os::Wasip1,
247 config: Some(cfg),
252 ..Default::default()
253 };
254 let oci_config =
255 oci_distribution::client::Config::oci_v1_from_config_file(oci_config_file, None)?;
256 let manifest = OciImageManifest::build(&layers, &oci_config, annotations);
257
258 let response = self
259 .oci
260 .push(&reference, &layers, oci_config, &auth, Some(manifest))
261 .await
262 .map(|push_response| push_response.manifest_url)
263 .context("cannot push Spin application")?;
264
265 tracing::info!("Pushed {:?}", response);
266
267 let digest = digest_from_url(&response);
268 Ok(digest)
269 }
270
271 async fn assemble_layers(
274 &mut self,
275 locked: &mut LockedApp,
276 assembly_mode: AssemblyMode,
277 compose_mode: ComposeMode,
278 ) -> Result<Vec<ImageLayer>> {
279 let (mut layers, components) = match compose_mode {
280 ComposeMode::All => {
281 self.assemble_layers_composed(assembly_mode, locked.clone())
282 .await?
283 }
284 ComposeMode::Skip => {
285 self.assemble_layers_uncomposed(assembly_mode, locked.clone())
286 .await?
287 }
288 };
289
290 locked.components = components;
291 locked.metadata.remove("origin");
292
293 layers = layers.into_iter().unique().collect();
295
296 Ok(layers)
297 }
298
299 async fn assemble_layers_uncomposed(
300 &mut self,
301 assembly_mode: AssemblyMode,
302 locked: LockedApp,
303 ) -> Result<(Vec<ImageLayer>, Vec<LockedComponent>)> {
304 let mut components = Vec::new();
305 let mut layers = Vec::new();
306
307 for mut c in locked.components {
308 let source = c
310 .source
311 .content
312 .source
313 .as_ref()
314 .context("component loaded from disk should contain a file source")?;
315
316 let source = parse_file_url(source.as_str())?;
317 let layer = Self::wasm_layer(&source).await?;
318
319 c.source.content = self.content_ref_for_layer(&layer);
321
322 layers.push(layer);
323
324 let mut deps = BTreeMap::default();
325 for (dep_name, mut dep) in c.dependencies {
326 let source = dep
327 .source
328 .content
329 .source
330 .context("dependency loaded from disk should contain a file source")?;
331 let source = parse_file_url(source.as_str())?;
332
333 let layer = Self::wasm_layer(&source).await?;
334
335 dep.source.content = self.content_ref_for_layer(&layer);
336 deps.insert(dep_name, dep);
337
338 layers.push(layer);
339 }
340 c.dependencies = deps;
341
342 c.files = self
343 .assemble_content_layers(assembly_mode, &mut layers, c.files.as_slice())
344 .await?;
345 components.push(c);
346 }
347
348 Ok((layers, components))
349 }
350
351 async fn assemble_layers_composed(
352 &mut self,
353 assembly_mode: AssemblyMode,
354 locked: LockedApp,
355 ) -> Result<(Vec<ImageLayer>, Vec<LockedComponent>)> {
356 let mut components = Vec::new();
357 let mut layers = Vec::new();
358
359 for mut c in locked.components {
360 let composed = spin_compose::compose(&ComponentSourceLoaderFs, &c)
361 .await
362 .with_context(|| {
363 format!("failed to resolve dependencies for component {:?}", c.id)
364 })?;
365 let layer = ImageLayer::new(composed, WASM_LAYER_MEDIA_TYPE.to_string(), None);
366 c.source.content = self.content_ref_for_layer(&layer);
367 c.dependencies.clear();
368 layers.push(layer);
369
370 c.files = self
371 .assemble_content_layers(assembly_mode, &mut layers, c.files.as_slice())
372 .await?;
373 components.push(c);
374 }
375
376 Ok((layers, components))
377 }
378
379 async fn assemble_content_layers(
380 &mut self,
381 assembly_mode: AssemblyMode,
382 layers: &mut Vec<ImageLayer>,
383 contents: &[ContentPath],
384 ) -> Result<Vec<ContentPath>> {
385 let mut files = Vec::new();
386 for f in contents {
387 let source = f
388 .content
389 .source
390 .as_ref()
391 .context("file mount loaded from disk should contain a file source")?;
392 let source = parse_file_url(source.as_str())?;
393
394 match assembly_mode {
395 AssemblyMode::Archive => self
396 .push_archive_layer(&source, &mut files, layers)
397 .await
398 .context(format!(
399 "cannot push archive layer for source {}",
400 quoted_path(&source)
401 ))?,
402 AssemblyMode::Simple => self
403 .push_file_layers(&source, &mut files, layers)
404 .await
405 .context(format!(
406 "cannot push file layers for source {}",
407 quoted_path(&source)
408 ))?,
409 }
410 }
411 Ok(files)
412 }
413
414 async fn push_archive_layer(
417 &mut self,
418 source: &PathBuf,
419 files: &mut Vec<ContentPath>,
420 layers: &mut Vec<ImageLayer>,
421 ) -> Result<()> {
422 for entry in WalkDir::new(source) {
424 let entry = entry?;
425 if !entry.file_type().is_file() {
426 continue;
427 }
428 let rel_path = entry.path().strip_prefix(source).unwrap();
430 tracing::trace!("Adding asset {rel_path:?} to component files list");
431 let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
433 let content = self.content_ref_for_layer(&layer);
434 files.push(ContentPath {
435 content,
436 path: rel_path.into(),
437 });
438 }
439
440 tracing::trace!("Adding archive layer for all files in source {:?}", &source);
442 let working_dir = tempfile::tempdir()?;
443 let archive_path = crate::utils::archive(source, &working_dir.into_path())
444 .await
445 .context(format!(
446 "Unable to create compressed archive for source {:?}",
447 source
448 ))?;
449 let layer = Self::data_layer(archive_path.as_path(), ARCHIVE_MEDIATYPE.to_string()).await?;
450 layers.push(layer);
451 Ok(())
452 }
453
454 async fn push_file_layers(
456 &mut self,
457 source: &PathBuf,
458 files: &mut Vec<ContentPath>,
459 layers: &mut Vec<ImageLayer>,
460 ) -> Result<()> {
461 tracing::trace!("Adding new layer per file under source {:?}", source);
464 for entry in WalkDir::new(source) {
465 let entry = entry?;
466 if !entry.file_type().is_file() {
467 continue;
468 }
469 let rel_path = entry.path().strip_prefix(source).unwrap();
471 let rel_path = portable_path(rel_path);
474
475 tracing::trace!("Adding new layer for asset {rel_path:?}");
476 let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
478 let content = self.content_ref_for_layer(&layer);
479 let content_inline = content.inline.is_some();
480 files.push(ContentPath {
481 content,
482 path: rel_path,
483 });
484 let skip_layer = content_inline;
488 if !skip_layer {
489 layers.push(layer);
490 }
491 }
492 Ok(())
493 }
494
495 pub async fn pull(&mut self, reference: &str) -> Result<()> {
497 let reference: Reference = reference.parse().context("cannot parse reference")?;
498 let auth = Self::auth(&reference).await?;
499
500 let (manifest, digest) = self.oci.pull_image_manifest(&reference, &auth).await?;
502
503 let manifest_json = serde_json::to_string(&manifest)?;
504 tracing::debug!("Pulled manifest: {}", manifest_json);
505
506 let m = self.manifest_path(&reference.to_string()).await?;
508 fs::write(&m, &manifest_json).await?;
509
510 let mut cfg_bytes = Vec::new();
514 self.oci
515 .pull_blob(&reference, &manifest.config, &mut cfg_bytes)
516 .await?;
517 self.write_locked_app_config(&reference.to_string(), &cfg_bytes)
518 .await
519 .context("unable to write locked app config to cache")?;
520
521 stream::iter(manifest.layers)
524 .map(|layer| {
525 let this = &self;
526 let reference = reference.clone();
527 async move {
528 if this.cache.wasm_file(&layer.digest).is_ok()
530 || this.cache.data_file(&layer.digest).is_ok()
531 {
532 tracing::debug!("Layer {} already exists in cache", &layer.digest);
533 return anyhow::Ok(());
534 }
535
536 tracing::debug!("Pulling layer {}", &layer.digest);
537 let mut bytes = Vec::with_capacity(layer.size.try_into()?);
538 this.oci.pull_blob(&reference, &layer, &mut bytes).await?;
539 match layer.media_type.as_str() {
540 SPIN_APPLICATION_MEDIA_TYPE => {
541 this.write_locked_app_config(&reference.to_string(), &bytes)
542 .await
543 .with_context(|| "unable to write locked app config to cache")?;
544 }
545 WASM_LAYER_MEDIA_TYPE => {
546 this.cache.write_wasm(&bytes, &layer.digest).await?;
547 }
548 ARCHIVE_MEDIATYPE => {
549 unpack_archive_layer(&this.cache, &bytes, &layer.digest).await?;
550 }
551 _ => {
552 this.cache.write_data(&bytes, &layer.digest).await?;
553 }
554 }
555 Ok(())
556 }
557 })
558 .buffer_unordered(MAX_PARALLEL_PULL)
559 .try_for_each(future::ok)
560 .await?;
561 tracing::info!("Pulled {}@{}", reference, digest);
562
563 Ok(())
564 }
565
566 async fn manifest_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
569 let reference: Reference = reference
570 .as_ref()
571 .parse()
572 .context("cannot parse OCI reference")?;
573 let p = self
574 .cache
575 .manifests_dir()
576 .join(fs_safe_segment(reference.registry()))
577 .join(reference.repository())
578 .join(reference.tag().unwrap_or(LATEST_TAG));
579
580 if !p.is_dir() {
581 fs::create_dir_all(&p).await.with_context(|| {
582 format!("cannot create directory {} for OCI manifest", p.display())
583 })?;
584 }
585
586 Ok(p.join(MANIFEST_FILE))
587 }
588
589 pub async fn lockfile_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
591 let reference: Reference = reference
592 .as_ref()
593 .parse()
594 .context("cannot parse reference")?;
595 let p = self
596 .cache
597 .manifests_dir()
598 .join(fs_safe_segment(reference.registry()))
599 .join(reference.repository())
600 .join(reference.tag().unwrap_or(LATEST_TAG));
601
602 if !p.is_dir() {
603 fs::create_dir_all(&p)
604 .await
605 .context("cannot find configuration object for reference")?;
606 }
607
608 Ok(p.join(CONFIG_FILE))
609 }
610
611 async fn write_locked_app_config(
613 &self,
614 reference: impl AsRef<str>,
615 bytes: impl AsRef<[u8]>,
616 ) -> Result<()> {
617 let cfg = std::str::from_utf8(bytes.as_ref())?;
618 tracing::debug!("Pulled config: {}", cfg);
619
620 let c = self.lockfile_path(reference).await?;
621 fs::write(&c, &cfg).await.map_err(anyhow::Error::from)
622 }
623
624 async fn wasm_layer(file: &Path) -> Result<ImageLayer> {
626 tracing::trace!("Reading wasm module from {:?}", file);
627 Ok(ImageLayer::new(
628 fs::read(file)
629 .await
630 .with_context(|| format!("cannot read wasm module {}", quoted_path(file)))?,
631 WASM_LAYER_MEDIA_TYPE.to_string(),
632 None,
633 ))
634 }
635
636 async fn data_layer(file: &Path, media_type: String) -> Result<ImageLayer> {
638 tracing::trace!("Reading data file from {:?}", file);
639 Ok(ImageLayer::new(
640 fs::read(&file)
641 .await
642 .with_context(|| format!("cannot read file {}", quoted_path(file)))?,
643 media_type,
644 None,
645 ))
646 }
647
648 fn content_ref_for_layer(&self, layer: &ImageLayer) -> ContentRef {
649 ContentRef {
650 inline: (layer.data.len() <= self.opts.content_ref_inline_max_size)
653 .then(|| layer.data.to_vec()),
654 digest: Some(layer.sha256_digest()),
655 ..Default::default()
656 }
657 }
658
659 pub async fn login(
661 server: impl AsRef<str>,
662 username: impl AsRef<str>,
663 password: impl AsRef<str>,
664 ) -> Result<()> {
665 let registry = registry_from_input(server);
666
667 Self::validate_credentials(®istry, &username, &password).await?;
671
672 let mut auth = AuthConfig::load_default().await?;
674 auth.insert(registry, username, password)?;
675 auth.save_default().await
676 }
677
678 pub async fn insert_token(
680 &mut self,
681 reference: &Reference,
682 op: RegistryOperation,
683 token: RegistryTokenType,
684 ) {
685 self.oci.tokens.insert(reference, op, token).await;
686 }
687
688 async fn validate_credentials(
690 server: impl AsRef<str>,
691 username: impl AsRef<str>,
692 password: impl AsRef<str>,
693 ) -> Result<()> {
694 let client = dkregistry::v2::Client::configure()
695 .registry(server.as_ref())
696 .insecure_registry(false)
697 .username(Some(username.as_ref().into()))
698 .password(Some(password.as_ref().into()))
699 .build()
700 .context("cannot create client to send authentication request to the registry")?;
701
702 match client
703 .authenticate(&[""])
706 .await
707 {
708 Ok(_) => Ok(()),
709 Err(e) => bail!(format!(
710 "cannot authenticate as {} to registry {}: {}",
711 username.as_ref(),
712 server.as_ref(),
713 e
714 )),
715 }
716 }
717
718 async fn auth(reference: &Reference) -> Result<RegistryAuth> {
720 let server = reference
721 .resolve_registry()
722 .strip_suffix('/')
723 .unwrap_or_else(|| reference.resolve_registry());
724
725 match AuthConfig::get_auth_from_default(server).await {
726 Ok(c) => Ok(c),
727 Err(_) => match docker_credential::get_credential(server) {
728 Err(e) => {
729 tracing::trace!("Cannot retrieve credentials from Docker, attempting to use anonymous auth: {}", e);
730 Ok(RegistryAuth::Anonymous)
731 }
732
733 Ok(DockerCredential::UsernamePassword(username, password)) => {
734 tracing::trace!("Found Docker credentials");
735 Ok(RegistryAuth::Basic(username, password))
736 }
737 Ok(DockerCredential::IdentityToken(_)) => {
738 tracing::trace!("Cannot use contents of Docker config, identity token not supported. Using anonymous auth");
739 Ok(RegistryAuth::Anonymous)
740 }
741 },
742 }
743 }
744
745 fn build_config(insecure: bool) -> oci_distribution::client::ClientConfig {
747 let protocol = if insecure {
748 oci_distribution::client::ClientProtocol::Http
749 } else {
750 oci_distribution::client::ClientProtocol::Https
751 };
752
753 oci_distribution::client::ClientConfig {
754 protocol,
755 default_token_expiration_secs: DEFAULT_TOKEN_EXPIRATION_SECS,
756 ..Default::default()
757 }
758 }
759}
760
761pub async fn unpack_archive_layer(
766 cache: &Cache,
767 bytes: impl AsRef<[u8]>,
768 digest: impl AsRef<str>,
769) -> Result<()> {
770 cache.write_data(&bytes, &digest).await?;
772
773 let path = cache
775 .data_file(&digest)
776 .context("unable to read archive layer from cache")?;
777 let staging_dir = tempfile::tempdir()?;
778 crate::utils::unarchive(path.as_ref(), staging_dir.path()).await?;
779
780 for entry in WalkDir::new(staging_dir.path()) {
783 let entry = entry?;
784 if entry.file_type().is_file() && !entry.file_type().is_dir() {
785 let bytes = tokio::fs::read(entry.path()).await?;
786 let digest = format!("sha256:{}", sha256::hex_digest_from_bytes(&bytes));
787 if cache.data_file(&digest).is_ok() {
788 tracing::debug!(
789 "Skipping unpacked asset {:?}; file already exists",
790 entry.path()
791 );
792 } else {
793 tracing::debug!("Adding unpacked asset {:?} to cache", entry.path());
794 cache.write_data(bytes, &digest).await?;
795 }
796 }
797 }
798 Ok(())
799}
800
801fn digest_from_url(manifest_url: &str) -> Option<String> {
802 let manifest_url = Url::parse(manifest_url).ok()?;
804 let mut segments = manifest_url.path_segments()?;
805 let last = segments.next_back()?;
806 if last.contains(':') {
807 Some(last.to_owned())
808 } else {
809 None
810 }
811}
812
813fn registry_from_input(server: impl AsRef<str>) -> String {
814 let server = server.as_ref();
816 let server = match server.parse::<Url>() {
817 Ok(url) => url.host_str().unwrap_or(server).to_string(),
818 Err(_) => server.to_string(),
819 };
820 match server.as_str() {
822 "docker.io" => "index.docker.io".to_string(),
823 _ => server,
824 }
825}
826
827fn all_annotations(
828 locked_app: &LockedApp,
829 explicit: Option<BTreeMap<String, String>>,
830 predefined: InferPredefinedAnnotations,
831) -> Option<BTreeMap<String, String>> {
832 use spin_locked_app::{MetadataKey, APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY};
833 const APP_AUTHORS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("authors");
834
835 if predefined == InferPredefinedAnnotations::None {
836 return explicit;
837 }
838
839 let mut current = explicit.unwrap_or_default();
842
843 let authors = locked_app
844 .get_metadata(APP_AUTHORS_KEY)
845 .unwrap_or_default()
846 .unwrap_or_default();
847 if !authors.is_empty() {
848 let authors = authors.join(", ");
849 add_inferred(
850 &mut current,
851 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
852 Some(authors),
853 );
854 }
855
856 let name = locked_app.get_metadata(APP_NAME_KEY).unwrap_or_default();
857 add_inferred(
858 &mut current,
859 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE,
860 name,
861 );
862
863 let description = locked_app
864 .get_metadata(APP_DESCRIPTION_KEY)
865 .unwrap_or_default();
866 add_inferred(
867 &mut current,
868 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION,
869 description,
870 );
871
872 let version = locked_app.get_metadata(APP_VERSION_KEY).unwrap_or_default();
873 add_inferred(
874 &mut current,
875 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION,
876 version,
877 );
878
879 let created = chrono::Utc::now().to_rfc3339();
880 add_inferred(
881 &mut current,
882 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED,
883 Some(created),
884 );
885
886 Some(current)
887}
888
889fn add_inferred(map: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
890 if let Some(value) = value {
891 if let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string()) {
892 e.insert(value);
893 }
894 }
895}
896
897#[cfg(target_os = "windows")]
900fn portable_path(rel_path: &Path) -> PathBuf {
901 assert!(
902 rel_path.is_relative(),
903 "portable_path requires paths to be relative"
904 );
905 let portable_path = rel_path.to_string_lossy().replace('\\', "/");
906 PathBuf::from(portable_path)
907}
908
909#[cfg(not(target_os = "windows"))]
913fn portable_path(rel_path: &Path) -> PathBuf {
914 rel_path.into()
915}
916
917#[cfg(target_os = "windows")]
920fn fs_safe_segment(segment: &str) -> impl AsRef<Path> {
921 segment.replace(':', "_")
922}
923
924#[cfg(not(target_os = "windows"))]
928fn fs_safe_segment(segment: &str) -> impl AsRef<Path> + '_ {
929 segment
930}
931
932#[cfg(test)]
933mod test {
934 use super::*;
935 use wit_parser::{LiftLowerAbi, ManglingAndAbi};
936
937 #[test]
938 fn can_parse_digest_from_manifest_url() {
939 let manifest_url = "https://ghcr.io/v2/itowlson/osf/manifests/sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
940 let digest = digest_from_url(manifest_url).unwrap();
941 assert_eq!(
942 "sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f",
943 digest
944 );
945 }
946
947 #[test]
948 fn can_derive_registry_from_input() {
949 #[derive(Clone)]
950 struct TestCase {
951 input: &'static str,
952 want: &'static str,
953 }
954 let tests: Vec<TestCase> = [
955 TestCase {
956 input: "docker.io",
957 want: "index.docker.io",
958 },
959 TestCase {
960 input: "index.docker.io",
961 want: "index.docker.io",
962 },
963 TestCase {
964 input: "https://ghcr.io",
965 want: "ghcr.io",
966 },
967 ]
968 .to_vec();
969
970 for tc in tests {
971 assert_eq!(tc.want, registry_from_input(tc.input));
972 }
973 }
974
975 #[macro_export]
977 #[allow(missing_docs)] macro_rules! from_json {
979 ($($json:tt)+) => {
980 serde_json::from_value(serde_json::json!($($json)+)).expect("valid json")
981 };
982 }
983
984 #[tokio::test]
985 async fn can_assemble_layers() {
986 use spin_locked_app::locked::LockedComponent;
987 use tokio::io::AsyncWriteExt;
988
989 let working_dir = tempfile::tempdir().unwrap();
990
991 let _ = tokio::fs::create_dir(working_dir.path().join("component1").as_path()).await;
995 let _ = tokio::fs::create_dir(working_dir.path().join("component2").as_path()).await;
996
997 let mut c1 = tokio::fs::File::create(working_dir.path().join("component1.wasm"))
999 .await
1000 .expect("should create component wasm file");
1001 c1.write_all(b"c1")
1002 .await
1003 .expect("should write component wasm contents");
1004 let mut c2 = tokio::fs::File::create(working_dir.path().join("component2.wasm"))
1005 .await
1006 .expect("should create component wasm file");
1007 c2.write_all(b"c2")
1008 .await
1009 .expect("should write component wasm contents");
1010
1011 let mut c1f1 = tokio::fs::File::create(working_dir.path().join("component1").join("bar"))
1013 .await
1014 .expect("should create component file");
1015 c1f1.write_all(b"bar")
1016 .await
1017 .expect("should write file contents");
1018 let mut c1f2 = tokio::fs::File::create(working_dir.path().join("component1").join("baz"))
1019 .await
1020 .expect("should create component file");
1021 c1f2.write_all(b"baz")
1022 .await
1023 .expect("should write file contents");
1024
1025 let mut c2f1 = tokio::fs::File::create(working_dir.path().join("component2").join("baz"))
1027 .await
1028 .expect("should create component file");
1029 c2f1.write_all(b"baz")
1030 .await
1031 .expect("should write file contents");
1032
1033 const TEST_WIT: &str = "
1035 package test:test;
1036
1037 interface a {
1038 a: func();
1039 }
1040
1041 world dep-a {
1042 export a;
1043 }
1044
1045 world root {
1046 import a;
1047 }
1048 ";
1049
1050 let root = generate_dummy_component(TEST_WIT, "root");
1051 let dep_a = generate_dummy_component(TEST_WIT, "dep-a");
1052
1053 let mut r = tokio::fs::File::create(working_dir.path().join("root.wasm"))
1054 .await
1055 .expect("should create component wasm file");
1056 r.write_all(&root)
1057 .await
1058 .expect("should write component wasm contents");
1059
1060 let mut a = tokio::fs::File::create(working_dir.path().join("dep_a.wasm"))
1061 .await
1062 .expect("should create component wasm file");
1063 a.write_all(&dep_a)
1064 .await
1065 .expect("should write component wasm contents");
1066
1067 #[derive(Clone)]
1068 struct TestCase {
1069 name: &'static str,
1070 opts: Option<ClientOpts>,
1071 locked_components: Vec<LockedComponent>,
1072 expected_layer_count: usize,
1073 expected_error: Option<&'static str>,
1074 compose_mode: ComposeMode,
1075 }
1076
1077 let tests: Vec<TestCase> = [
1078 TestCase {
1079 name: "Two component layers",
1080 opts: None,
1081 locked_components: from_json!([{
1082 "id": "component1",
1083 "source": {
1084 "content_type": "application/wasm",
1085 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1086 "digest": "digest",
1087 }},
1088 {
1089 "id": "component2",
1090 "source": {
1091 "content_type": "application/wasm",
1092 "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1093 "digest": "digest",
1094 }}]),
1095 expected_layer_count: 2,
1096 expected_error: None,
1097 compose_mode: ComposeMode::Skip,
1098 },
1099 TestCase {
1100 name: "One component layer and two file layers",
1101 opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1102 locked_components: from_json!([{
1103 "id": "component1",
1104 "source": {
1105 "content_type": "application/wasm",
1106 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1107 "digest": "digest",
1108 },
1109 "files": [
1110 {
1111 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1112 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1113 },
1114 {
1115 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1116 "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1117 }
1118 ]
1119 }]),
1120 expected_layer_count: 3,
1121 expected_error: None,
1122 compose_mode: ComposeMode::Skip,
1123 },
1124 TestCase {
1125 name: "One component layer and one file with inlined content",
1126 opts: None,
1127 locked_components: from_json!([{
1128 "id": "component1",
1129 "source": {
1130 "content_type": "application/wasm",
1131 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1132 "digest": "digest",
1133 },
1134 "files": [
1135 {
1136 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1137 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1138 }
1139 ]
1140 }]),
1141 expected_layer_count: 1,
1142 expected_error: None,
1143 compose_mode: ComposeMode::Skip,
1144 },
1145 TestCase {
1146 name: "One component layer and one dependency component layer skipping composition",
1147 opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1148 locked_components: from_json!([{
1149 "id": "component1",
1150 "source": {
1151 "content_type": "application/wasm",
1152 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1153 "digest": "digest",
1154 },
1155 "dependencies": {
1156 "test:comp2": {
1157 "source": {
1158 "content_type": "application/wasm",
1159 "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1160 "digest": "digest",
1161 },
1162 "export": null,
1163 }
1164 }
1165 }]),
1166 expected_layer_count: 2,
1167 expected_error: None,
1168 compose_mode: ComposeMode::Skip,
1169 },
1170 TestCase {
1171 name: "Component has no source",
1172 opts: None,
1173 locked_components: from_json!([{
1174 "id": "component1",
1175 "source": {
1176 "content_type": "application/wasm",
1177 "source": "",
1178 "digest": "digest",
1179 }
1180 }]),
1181 expected_layer_count: 0,
1182 expected_error: Some("Invalid URL: \"\""),
1183 compose_mode: ComposeMode::Skip,
1184 },
1185 TestCase {
1186 name: "Duplicate component sources",
1187 opts: None,
1188 locked_components: from_json!([{
1189 "id": "component1",
1190 "source": {
1191 "content_type": "application/wasm",
1192 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1193 "digest": "digest",
1194 }},
1195 {
1196 "id": "component2",
1197 "source": {
1198 "content_type": "application/wasm",
1199 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1200 "digest": "digest",
1201 }}]),
1202 expected_layer_count: 1,
1203 expected_error: None,
1204 compose_mode: ComposeMode::Skip,
1205 },
1206 TestCase {
1207 name: "Duplicate file paths",
1208 opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1209 locked_components: from_json!([{
1210 "id": "component1",
1211 "source": {
1212 "content_type": "application/wasm",
1213 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1214 "digest": "digest",
1215 },
1216 "files": [
1217 {
1218 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1219 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1220 },
1221 {
1222 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1223 "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1224 }
1225 ]},
1226 {
1227 "id": "component2",
1228 "source": {
1229 "content_type": "application/wasm",
1230 "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1231 "digest": "digest",
1232 },
1233 "files": [
1234 {
1235 "source": format!("file://{}", working_dir.path().join("component2").to_str().unwrap()),
1236 "path": working_dir.path().join("component2").join("baz").to_str().unwrap()
1237 }
1238 ]
1239 }]),
1240 expected_layer_count: 4,
1241 expected_error: None,
1242 compose_mode: ComposeMode::Skip,
1243 },
1244 TestCase {
1245 name: "One component layer and one dependency component layer with composition",
1246 opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1247 locked_components: from_json!([{
1248 "id": "component-with-deps",
1249 "source": {
1250 "content_type": "application/wasm",
1251 "source": format!("file://{}", working_dir.path().join("root.wasm").to_str().unwrap()),
1252 "digest": "digest",
1253 },
1254 "dependencies": {
1255 "test:test/a": {
1256 "source": {
1257 "content_type": "application/wasm",
1258 "source": format!("file://{}", working_dir.path().join("dep_a.wasm").to_str().unwrap()),
1259 "digest": "digest",
1260 },
1261 "export": null,
1262 }
1263 }
1264 }]),
1265 expected_layer_count: 1,
1266 expected_error: None,
1267 compose_mode: ComposeMode::All,
1268 },
1269 ]
1270 .to_vec();
1271
1272 for tc in tests {
1273 let triggers = Default::default();
1274 let metadata = Default::default();
1275 let variables = Default::default();
1276 let mut locked = LockedApp {
1277 spin_lock_version: Default::default(),
1278 components: tc.locked_components,
1279 triggers,
1280 metadata,
1281 variables,
1282 must_understand: Default::default(),
1283 host_requirements: Default::default(),
1284 };
1285
1286 let mut client = Client::new(false, Some(working_dir.path().to_path_buf()))
1287 .await
1288 .expect("should create new client");
1289 if let Some(o) = tc.opts {
1290 client.opts = o;
1291 }
1292
1293 match tc.expected_error {
1294 Some(e) => {
1295 assert_eq!(
1296 e,
1297 client
1298 .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1299 .await
1300 .unwrap_err()
1301 .to_string(),
1302 "{}",
1303 tc.name
1304 )
1305 }
1306 None => {
1307 assert_eq!(
1308 tc.expected_layer_count,
1309 client
1310 .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1311 .await
1312 .unwrap()
1313 .len(),
1314 "{}",
1315 tc.name
1316 )
1317 }
1318 }
1319 }
1320 }
1321
1322 fn generate_dummy_component(wit: &str, world: &str) -> Vec<u8> {
1323 let mut resolve = wit_parser::Resolve::default();
1324 let package_id = resolve.push_str("test", wit).expect("should parse WIT");
1325 let world_id = resolve
1326 .select_world(package_id, Some(world))
1327 .expect("should select world");
1328
1329 let mut wasm = wit_component::dummy_module(
1330 &resolve,
1331 world_id,
1332 ManglingAndAbi::Legacy(LiftLowerAbi::Sync),
1333 );
1334 wit_component::embed_component_metadata(
1335 &mut wasm,
1336 &resolve,
1337 world_id,
1338 wit_component::StringEncoding::UTF8,
1339 )
1340 .expect("should embed component metadata");
1341
1342 let mut encoder = wit_component::ComponentEncoder::default()
1343 .validate(true)
1344 .module(&wasm)
1345 .expect("should set module");
1346 encoder.encode().expect("should encode component")
1347 }
1348
1349 fn annotatable_app() -> LockedApp {
1350 let mut meta_builder = spin_locked_app::values::ValuesMapBuilder::new();
1351 meta_builder
1352 .string("name", "this-is-spinal-tap")
1353 .string("version", "11.11.11")
1354 .string("description", "")
1355 .string_array("authors", vec!["Marty DiBergi", "Artie Fufkin"]);
1356 let metadata = meta_builder.build();
1357 LockedApp {
1358 spin_lock_version: Default::default(),
1359 must_understand: vec![],
1360 metadata,
1361 host_requirements: Default::default(),
1362 variables: Default::default(),
1363 triggers: Default::default(),
1364 components: Default::default(),
1365 }
1366 }
1367
1368 fn as_annotations(annotations: &[(&str, &str)]) -> Option<BTreeMap<String, String>> {
1369 Some(
1370 annotations
1371 .iter()
1372 .map(|(k, v)| (k.to_string(), v.to_string()))
1373 .collect(),
1374 )
1375 }
1376
1377 #[test]
1378 fn no_annotations_no_infer_result_is_no_annotations() {
1379 let locked_app = annotatable_app();
1380 let explicit = None;
1381 let infer = InferPredefinedAnnotations::None;
1382
1383 assert!(all_annotations(&locked_app, explicit, infer).is_none());
1384 }
1385
1386 #[test]
1387 fn explicit_annotations_no_infer_result_is_explicit_annotations() {
1388 let locked_app = annotatable_app();
1389 let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1390 let infer = InferPredefinedAnnotations::None;
1391
1392 let annotations =
1393 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1394 assert_eq!(2, annotations.len());
1395 assert_eq!("11", annotations.get("volume").unwrap());
1396 assert_eq!("feet", annotations.get("dimensions").unwrap());
1397 }
1398
1399 #[test]
1400 fn no_annotations_infer_all_result_is_auto_annotations() {
1401 let locked_app = annotatable_app();
1402 let explicit = None;
1403 let infer = InferPredefinedAnnotations::All;
1404
1405 let annotations =
1406 all_annotations(&locked_app, explicit, infer).expect("should now have annotations");
1407 assert_eq!(4, annotations.len());
1408 assert_eq!(
1409 "Marty DiBergi, Artie Fufkin",
1410 annotations
1411 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1412 .expect("should have authors annotation")
1413 );
1414 assert_eq!(
1415 "this-is-spinal-tap",
1416 annotations
1417 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE)
1418 .expect("should have title annotation")
1419 );
1420 assert_eq!(
1421 "11.11.11",
1422 annotations
1423 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION)
1424 .expect("should have version annotation")
1425 );
1426 assert!(
1427 !annotations
1428 .contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION),
1429 "empty description should not have generated annotation"
1430 );
1431 assert!(
1432 annotations
1433 .contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED),
1434 "creation annotation should have been generated"
1435 );
1436 }
1437
1438 #[test]
1439 fn explicit_annotations_infer_all_gets_both_sets() {
1440 let locked_app = annotatable_app();
1441 let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1442 let infer = InferPredefinedAnnotations::All;
1443
1444 let annotations =
1445 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1446 assert_eq!(6, annotations.len());
1447 assert_eq!(
1448 "11",
1449 annotations
1450 .get("volume")
1451 .expect("should have retained explicit annotation")
1452 );
1453 assert_eq!(
1454 "Marty DiBergi, Artie Fufkin",
1455 annotations
1456 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1457 .expect("should have authors annotation")
1458 );
1459 }
1460
1461 #[test]
1462 fn explicit_annotations_take_precedence_over_inferred() {
1463 let locked_app = annotatable_app();
1464 let explicit = as_annotations(&[
1465 ("volume", "11"),
1466 (
1467 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
1468 "David St Hubbins, Nigel Tufnel",
1469 ),
1470 ]);
1471 let infer = InferPredefinedAnnotations::All;
1472
1473 let annotations =
1474 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1475 assert_eq!(
1476 5,
1477 annotations.len(),
1478 "should have one custom, one predefined explicit, and three inferred"
1479 );
1480 assert_eq!(
1481 "11",
1482 annotations
1483 .get("volume")
1484 .expect("should have retained explicit annotation")
1485 );
1486 assert_eq!(
1487 "David St Hubbins, Nigel Tufnel",
1488 annotations
1489 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1490 .expect("should have authors annotation"),
1491 "explicit authors should have taken precedence"
1492 );
1493 }
1494}