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