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