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!("Cannot retrieve credentials from Docker, attempting to use anonymous auth: {}", e);
736 Ok(RegistryAuth::Anonymous)
737 }
738
739 Ok(DockerCredential::UsernamePassword(username, password)) => {
740 tracing::trace!("Found Docker credentials");
741 Ok(RegistryAuth::Basic(username, password))
742 }
743 Ok(DockerCredential::IdentityToken(_)) => {
744 tracing::trace!("Cannot use contents of Docker config, identity token not supported. Using anonymous auth");
745 Ok(RegistryAuth::Anonymous)
746 }
747 },
748 }
749 }
750
751 fn build_config(insecure: bool) -> oci_distribution::client::ClientConfig {
753 let protocol = if insecure {
754 oci_distribution::client::ClientProtocol::Http
755 } else {
756 oci_distribution::client::ClientProtocol::Https
757 };
758
759 oci_distribution::client::ClientConfig {
760 protocol,
761 default_token_expiration_secs: DEFAULT_TOKEN_EXPIRATION_SECS,
762 ..Default::default()
763 }
764 }
765}
766
767pub async fn unpack_archive_layer(
772 cache: &Cache,
773 bytes: impl AsRef<[u8]>,
774 digest: impl AsRef<str>,
775) -> Result<()> {
776 cache.write_data(&bytes, &digest).await?;
778
779 let path = cache
781 .data_file(&digest)
782 .context("unable to read archive layer from cache")?;
783 let staging_dir = tempfile::tempdir()?;
784 crate::utils::unarchive(path.as_ref(), staging_dir.path()).await?;
785
786 for entry in WalkDir::new(staging_dir.path()) {
789 let entry = entry?;
790 if entry.file_type().is_file() && !entry.file_type().is_dir() {
791 let bytes = tokio::fs::read(entry.path()).await?;
792 let digest = format!("sha256:{}", sha256::hex_digest_from_bytes(&bytes));
793 if cache.data_file(&digest).is_ok() {
794 tracing::debug!(
795 "Skipping unpacked asset {:?}; file already exists",
796 entry.path()
797 );
798 } else {
799 tracing::debug!("Adding unpacked asset {:?} to cache", entry.path());
800 cache.write_data(bytes, &digest).await?;
801 }
802 }
803 }
804 Ok(())
805}
806
807fn digest_from_url(manifest_url: &str) -> Option<String> {
808 let manifest_url = Url::parse(manifest_url).ok()?;
810 let mut segments = manifest_url.path_segments()?;
811 let last = segments.next_back()?;
812 if last.contains(':') {
813 Some(last.to_owned())
814 } else {
815 None
816 }
817}
818
819fn registry_from_input(server: impl AsRef<str>) -> String {
820 let server = server.as_ref();
822 let server = match server.parse::<Url>() {
823 Ok(url) => url.host_str().unwrap_or(server).to_string(),
824 Err(_) => server.to_string(),
825 };
826 match server.as_str() {
828 "docker.io" => "index.docker.io".to_string(),
829 _ => server,
830 }
831}
832
833fn all_annotations(
834 locked_app: &LockedApp,
835 explicit: Option<BTreeMap<String, String>>,
836 predefined: InferPredefinedAnnotations,
837) -> Option<BTreeMap<String, String>> {
838 use spin_locked_app::{MetadataKey, APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY};
839 const APP_AUTHORS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("authors");
840
841 if predefined == InferPredefinedAnnotations::None {
842 return explicit;
843 }
844
845 let mut current = explicit.unwrap_or_default();
848
849 let authors = locked_app
850 .get_metadata(APP_AUTHORS_KEY)
851 .unwrap_or_default()
852 .unwrap_or_default();
853 if !authors.is_empty() {
854 let authors = authors.join(", ");
855 add_inferred(
856 &mut current,
857 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
858 Some(authors),
859 );
860 }
861
862 let name = locked_app.get_metadata(APP_NAME_KEY).unwrap_or_default();
863 add_inferred(
864 &mut current,
865 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE,
866 name,
867 );
868
869 let description = locked_app
870 .get_metadata(APP_DESCRIPTION_KEY)
871 .unwrap_or_default();
872 add_inferred(
873 &mut current,
874 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION,
875 description,
876 );
877
878 let version = locked_app.get_metadata(APP_VERSION_KEY).unwrap_or_default();
879 add_inferred(
880 &mut current,
881 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION,
882 version,
883 );
884
885 let created = chrono::Utc::now().to_rfc3339();
886 add_inferred(
887 &mut current,
888 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED,
889 Some(created),
890 );
891
892 Some(current)
893}
894
895fn add_inferred(map: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
896 if let Some(value) = value {
897 if let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string()) {
898 e.insert(value);
899 }
900 }
901}
902
903#[cfg(target_os = "windows")]
906fn portable_path(rel_path: &Path) -> PathBuf {
907 assert!(
908 rel_path.is_relative(),
909 "portable_path requires paths to be relative"
910 );
911 let portable_path = rel_path.to_string_lossy().replace('\\', "/");
912 PathBuf::from(portable_path)
913}
914
915#[cfg(not(target_os = "windows"))]
919fn portable_path(rel_path: &Path) -> PathBuf {
920 rel_path.into()
921}
922
923#[cfg(target_os = "windows")]
926fn fs_safe_segment(segment: &str) -> impl AsRef<Path> {
927 segment.replace(':', "_")
928}
929
930#[cfg(not(target_os = "windows"))]
934fn fs_safe_segment(segment: &str) -> impl AsRef<Path> + '_ {
935 segment
936}
937
938#[cfg(test)]
939mod test {
940 use super::*;
941 use wit_parser::{LiftLowerAbi, ManglingAndAbi};
942
943 #[test]
944 fn can_parse_digest_from_manifest_url() {
945 let manifest_url = "https://ghcr.io/v2/itowlson/osf/manifests/sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
946 let digest = digest_from_url(manifest_url).unwrap();
947 assert_eq!(
948 "sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f",
949 digest
950 );
951 }
952
953 #[test]
954 fn can_derive_registry_from_input() {
955 #[derive(Clone)]
956 struct TestCase {
957 input: &'static str,
958 want: &'static str,
959 }
960 let tests: Vec<TestCase> = [
961 TestCase {
962 input: "docker.io",
963 want: "index.docker.io",
964 },
965 TestCase {
966 input: "index.docker.io",
967 want: "index.docker.io",
968 },
969 TestCase {
970 input: "https://ghcr.io",
971 want: "ghcr.io",
972 },
973 ]
974 .to_vec();
975
976 for tc in tests {
977 assert_eq!(tc.want, registry_from_input(tc.input));
978 }
979 }
980
981 #[macro_export]
983 #[allow(missing_docs)] macro_rules! from_json {
985 ($($json:tt)+) => {
986 serde_json::from_value(serde_json::json!($($json)+)).expect("valid json")
987 };
988 }
989
990 #[tokio::test]
991 async fn can_assemble_layers() {
992 use spin_locked_app::locked::LockedComponent;
993 use tokio::io::AsyncWriteExt;
994
995 let working_dir = tempfile::tempdir().unwrap();
996
997 let _ = tokio::fs::create_dir(working_dir.path().join("component1").as_path()).await;
1001 let _ = tokio::fs::create_dir(working_dir.path().join("component2").as_path()).await;
1002
1003 let mut c1 = tokio::fs::File::create(working_dir.path().join("component1.wasm"))
1005 .await
1006 .expect("should create component wasm file");
1007 c1.write_all(b"c1")
1008 .await
1009 .expect("should write component wasm contents");
1010 let mut c2 = tokio::fs::File::create(working_dir.path().join("component2.wasm"))
1011 .await
1012 .expect("should create component wasm file");
1013 c2.write_all(b"c2")
1014 .await
1015 .expect("should write component wasm contents");
1016
1017 let mut c1f1 = tokio::fs::File::create(working_dir.path().join("component1").join("bar"))
1019 .await
1020 .expect("should create component file");
1021 c1f1.write_all(b"bar")
1022 .await
1023 .expect("should write file contents");
1024 let mut c1f2 = tokio::fs::File::create(working_dir.path().join("component1").join("baz"))
1025 .await
1026 .expect("should create component file");
1027 c1f2.write_all(b"baz")
1028 .await
1029 .expect("should write file contents");
1030
1031 let mut c2f1 = tokio::fs::File::create(working_dir.path().join("component2").join("baz"))
1033 .await
1034 .expect("should create component file");
1035 c2f1.write_all(b"baz")
1036 .await
1037 .expect("should write file contents");
1038
1039 const TEST_WIT: &str = "
1041 package test:test;
1042
1043 interface a {
1044 a: func();
1045 }
1046
1047 world dep-a {
1048 export a;
1049 }
1050
1051 world root {
1052 import a;
1053 }
1054 ";
1055
1056 let root = generate_dummy_component(TEST_WIT, "root");
1057 let dep_a = generate_dummy_component(TEST_WIT, "dep-a");
1058
1059 let mut r = tokio::fs::File::create(working_dir.path().join("root.wasm"))
1060 .await
1061 .expect("should create component wasm file");
1062 r.write_all(&root)
1063 .await
1064 .expect("should write component wasm contents");
1065
1066 let mut a = tokio::fs::File::create(working_dir.path().join("dep_a.wasm"))
1067 .await
1068 .expect("should create component wasm file");
1069 a.write_all(&dep_a)
1070 .await
1071 .expect("should write component wasm contents");
1072
1073 #[derive(Clone)]
1074 struct TestCase {
1075 name: &'static str,
1076 opts: Option<ClientOpts>,
1077 locked_components: Vec<LockedComponent>,
1078 expected_layer_count: usize,
1079 expected_error: Option<&'static str>,
1080 compose_mode: ComposeMode,
1081 }
1082
1083 let tests: Vec<TestCase> = [
1084 TestCase {
1085 name: "Two component layers",
1086 opts: None,
1087 locked_components: from_json!([{
1088 "id": "component1",
1089 "source": {
1090 "content_type": "application/wasm",
1091 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1092 "digest": "digest",
1093 }},
1094 {
1095 "id": "component2",
1096 "source": {
1097 "content_type": "application/wasm",
1098 "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1099 "digest": "digest",
1100 }}]),
1101 expected_layer_count: 2,
1102 expected_error: None,
1103 compose_mode: ComposeMode::Skip,
1104 },
1105 TestCase {
1106 name: "One component layer and two file layers",
1107 opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1108 locked_components: from_json!([{
1109 "id": "component1",
1110 "source": {
1111 "content_type": "application/wasm",
1112 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1113 "digest": "digest",
1114 },
1115 "files": [
1116 {
1117 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1118 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1119 },
1120 {
1121 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1122 "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1123 }
1124 ]
1125 }]),
1126 expected_layer_count: 3,
1127 expected_error: None,
1128 compose_mode: ComposeMode::Skip,
1129 },
1130 TestCase {
1131 name: "One component layer and one file with inlined content",
1132 opts: None,
1133 locked_components: from_json!([{
1134 "id": "component1",
1135 "source": {
1136 "content_type": "application/wasm",
1137 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1138 "digest": "digest",
1139 },
1140 "files": [
1141 {
1142 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1143 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1144 }
1145 ]
1146 }]),
1147 expected_layer_count: 1,
1148 expected_error: None,
1149 compose_mode: ComposeMode::Skip,
1150 },
1151 TestCase {
1152 name: "One component layer and one dependency component layer skipping composition",
1153 opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1154 locked_components: from_json!([{
1155 "id": "component1",
1156 "source": {
1157 "content_type": "application/wasm",
1158 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1159 "digest": "digest",
1160 },
1161 "dependencies": {
1162 "test:comp2": {
1163 "source": {
1164 "content_type": "application/wasm",
1165 "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1166 "digest": "digest",
1167 },
1168 "export": null,
1169 }
1170 }
1171 }]),
1172 expected_layer_count: 2,
1173 expected_error: None,
1174 compose_mode: ComposeMode::Skip,
1175 },
1176 TestCase {
1177 name: "Component has no source",
1178 opts: None,
1179 locked_components: from_json!([{
1180 "id": "component1",
1181 "source": {
1182 "content_type": "application/wasm",
1183 "source": "",
1184 "digest": "digest",
1185 }
1186 }]),
1187 expected_layer_count: 0,
1188 expected_error: Some("Invalid URL: \"\""),
1189 compose_mode: ComposeMode::Skip,
1190 },
1191 TestCase {
1192 name: "Duplicate component sources",
1193 opts: None,
1194 locked_components: from_json!([{
1195 "id": "component1",
1196 "source": {
1197 "content_type": "application/wasm",
1198 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1199 "digest": "digest",
1200 }},
1201 {
1202 "id": "component2",
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 expected_layer_count: 1,
1209 expected_error: None,
1210 compose_mode: ComposeMode::Skip,
1211 },
1212 TestCase {
1213 name: "Duplicate file paths",
1214 opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1215 locked_components: from_json!([{
1216 "id": "component1",
1217 "source": {
1218 "content_type": "application/wasm",
1219 "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1220 "digest": "digest",
1221 },
1222 "files": [
1223 {
1224 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1225 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1226 },
1227 {
1228 "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1229 "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1230 }
1231 ]},
1232 {
1233 "id": "component2",
1234 "source": {
1235 "content_type": "application/wasm",
1236 "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1237 "digest": "digest",
1238 },
1239 "files": [
1240 {
1241 "source": format!("file://{}", working_dir.path().join("component2").to_str().unwrap()),
1242 "path": working_dir.path().join("component2").join("baz").to_str().unwrap()
1243 }
1244 ]
1245 }]),
1246 expected_layer_count: 4,
1247 expected_error: None,
1248 compose_mode: ComposeMode::Skip,
1249 },
1250 TestCase {
1251 name: "One component layer and one dependency component layer with composition",
1252 opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1253 locked_components: from_json!([{
1254 "id": "component-with-deps",
1255 "source": {
1256 "content_type": "application/wasm",
1257 "source": format!("file://{}", working_dir.path().join("root.wasm").to_str().unwrap()),
1258 "digest": "digest",
1259 },
1260 "dependencies": {
1261 "test:test/a": {
1262 "source": {
1263 "content_type": "application/wasm",
1264 "source": format!("file://{}", working_dir.path().join("dep_a.wasm").to_str().unwrap()),
1265 "digest": "digest",
1266 },
1267 "export": null,
1268 }
1269 }
1270 }]),
1271 expected_layer_count: 1,
1272 expected_error: None,
1273 compose_mode: ComposeMode::All,
1274 },
1275 ]
1276 .to_vec();
1277
1278 for tc in tests {
1279 let triggers = Default::default();
1280 let metadata = Default::default();
1281 let variables = Default::default();
1282 let mut locked = LockedApp {
1283 spin_lock_version: Default::default(),
1284 components: tc.locked_components,
1285 triggers,
1286 metadata,
1287 variables,
1288 must_understand: Default::default(),
1289 host_requirements: Default::default(),
1290 };
1291
1292 let mut client = Client::new(false, Some(working_dir.path().to_path_buf()))
1293 .await
1294 .expect("should create new client");
1295 if let Some(o) = tc.opts {
1296 client.opts = o;
1297 }
1298
1299 match tc.expected_error {
1300 Some(e) => {
1301 assert_eq!(
1302 e,
1303 client
1304 .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1305 .await
1306 .unwrap_err()
1307 .to_string(),
1308 "{}",
1309 tc.name
1310 )
1311 }
1312 None => {
1313 assert_eq!(
1314 tc.expected_layer_count,
1315 client
1316 .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1317 .await
1318 .unwrap()
1319 .len(),
1320 "{}",
1321 tc.name
1322 )
1323 }
1324 }
1325 }
1326 }
1327
1328 fn generate_dummy_component(wit: &str, world: &str) -> Vec<u8> {
1329 let mut resolve = wit_parser::Resolve::default();
1330 let package_id = resolve.push_str("test", wit).expect("should parse WIT");
1331 let world_id = resolve
1332 .select_world(package_id, Some(world))
1333 .expect("should select world");
1334
1335 let mut wasm = wit_component::dummy_module(
1336 &resolve,
1337 world_id,
1338 ManglingAndAbi::Legacy(LiftLowerAbi::Sync),
1339 );
1340 wit_component::embed_component_metadata(
1341 &mut wasm,
1342 &resolve,
1343 world_id,
1344 wit_component::StringEncoding::UTF8,
1345 )
1346 .expect("should embed component metadata");
1347
1348 let mut encoder = wit_component::ComponentEncoder::default()
1349 .validate(true)
1350 .module(&wasm)
1351 .expect("should set module");
1352 encoder.encode().expect("should encode component")
1353 }
1354
1355 fn annotatable_app() -> LockedApp {
1356 let mut meta_builder = spin_locked_app::values::ValuesMapBuilder::new();
1357 meta_builder
1358 .string("name", "this-is-spinal-tap")
1359 .string("version", "11.11.11")
1360 .string("description", "")
1361 .string_array("authors", vec!["Marty DiBergi", "Artie Fufkin"]);
1362 let metadata = meta_builder.build();
1363 LockedApp {
1364 spin_lock_version: Default::default(),
1365 must_understand: vec![],
1366 metadata,
1367 host_requirements: Default::default(),
1368 variables: Default::default(),
1369 triggers: Default::default(),
1370 components: Default::default(),
1371 }
1372 }
1373
1374 fn as_annotations(annotations: &[(&str, &str)]) -> Option<BTreeMap<String, String>> {
1375 Some(
1376 annotations
1377 .iter()
1378 .map(|(k, v)| (k.to_string(), v.to_string()))
1379 .collect(),
1380 )
1381 }
1382
1383 #[test]
1384 fn no_annotations_no_infer_result_is_no_annotations() {
1385 let locked_app = annotatable_app();
1386 let explicit = None;
1387 let infer = InferPredefinedAnnotations::None;
1388
1389 assert!(all_annotations(&locked_app, explicit, infer).is_none());
1390 }
1391
1392 #[test]
1393 fn explicit_annotations_no_infer_result_is_explicit_annotations() {
1394 let locked_app = annotatable_app();
1395 let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1396 let infer = InferPredefinedAnnotations::None;
1397
1398 let annotations =
1399 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1400 assert_eq!(2, annotations.len());
1401 assert_eq!("11", annotations.get("volume").unwrap());
1402 assert_eq!("feet", annotations.get("dimensions").unwrap());
1403 }
1404
1405 #[test]
1406 fn no_annotations_infer_all_result_is_auto_annotations() {
1407 let locked_app = annotatable_app();
1408 let explicit = None;
1409 let infer = InferPredefinedAnnotations::All;
1410
1411 let annotations =
1412 all_annotations(&locked_app, explicit, infer).expect("should now have annotations");
1413 assert_eq!(4, annotations.len());
1414 assert_eq!(
1415 "Marty DiBergi, Artie Fufkin",
1416 annotations
1417 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1418 .expect("should have authors annotation")
1419 );
1420 assert_eq!(
1421 "this-is-spinal-tap",
1422 annotations
1423 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE)
1424 .expect("should have title annotation")
1425 );
1426 assert_eq!(
1427 "11.11.11",
1428 annotations
1429 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION)
1430 .expect("should have version annotation")
1431 );
1432 assert!(
1433 !annotations
1434 .contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION),
1435 "empty description should not have generated annotation"
1436 );
1437 assert!(
1438 annotations
1439 .contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED),
1440 "creation annotation should have been generated"
1441 );
1442 }
1443
1444 #[test]
1445 fn explicit_annotations_infer_all_gets_both_sets() {
1446 let locked_app = annotatable_app();
1447 let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1448 let infer = InferPredefinedAnnotations::All;
1449
1450 let annotations =
1451 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1452 assert_eq!(6, annotations.len());
1453 assert_eq!(
1454 "11",
1455 annotations
1456 .get("volume")
1457 .expect("should have retained explicit annotation")
1458 );
1459 assert_eq!(
1460 "Marty DiBergi, Artie Fufkin",
1461 annotations
1462 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1463 .expect("should have authors annotation")
1464 );
1465 }
1466
1467 #[test]
1468 fn explicit_annotations_take_precedence_over_inferred() {
1469 let locked_app = annotatable_app();
1470 let explicit = as_annotations(&[
1471 ("volume", "11"),
1472 (
1473 oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
1474 "David St Hubbins, Nigel Tufnel",
1475 ),
1476 ]);
1477 let infer = InferPredefinedAnnotations::All;
1478
1479 let annotations =
1480 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1481 assert_eq!(
1482 5,
1483 annotations.len(),
1484 "should have one custom, one predefined explicit, and three inferred"
1485 );
1486 assert_eq!(
1487 "11",
1488 annotations
1489 .get("volume")
1490 .expect("should have retained explicit annotation")
1491 );
1492 assert_eq!(
1493 "David St Hubbins, Nigel Tufnel",
1494 annotations
1495 .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1496 .expect("should have authors annotation"),
1497 "explicit authors should have taken precedence"
1498 );
1499 }
1500}