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