1use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, bail};
7use docker_credential::DockerCredential;
8use futures_util::future;
9use futures_util::stream::{self, StreamExt, TryStreamExt};
10use itertools::Itertools;
11use oci_client::{
12 Reference, RegistryOperation, client::ImageLayer, config::ConfigFile,
13 manifest::OciImageManifest, secrets::RegistryAuth, token_cache::RegistryTokenType,
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::FilesMountStrategy;
21use spin_loader::cache::Cache;
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_client::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_client::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_client::config::Config {
247 labels: Some(labels),
248 ..Default::default()
249 };
250
251 let oci_config_file = ConfigFile {
256 architecture: oci_client::config::Architecture::Wasm,
257 os: oci_client::config::Os::Other("Wasip1".to_string()),
258 config: Some(cfg),
263 ..Default::default()
264 };
265 let oci_config =
266 oci_client::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 let temp_dir =
371 tempfile::tempdir().context("unable to create tempdir for precomposition")?;
372 let working_dir = temp_dir.path();
373 let locked_url = write_locked_app(&locked, working_dir)
374 .await
375 .context("unable to write locked app for precomposition")?;
376
377 for mut c in locked.components {
378 let trigger_deps = &c.trigger_dependencies;
379
380 let composed = if trigger_deps.is_empty() {
381 spin_compose::compose(&ComponentSourceLoaderFs, &c, async |a| Ok(a)).await?
382 } else {
383 precompose_using_trigger(&c, &locked_url, working_dir).await?
386 };
387
388 let layer = ImageLayer::new(composed, WASM_LAYER_MEDIA_TYPE.to_string(), None);
389 c.source.content = self.content_ref_for_layer(&layer);
390 c.dependencies.clear();
391 c.trigger_dependencies.clear();
392 layers.push(layer);
393
394 c.files = self
395 .assemble_content_layers(assembly_mode, &mut layers, c.files.as_slice())
396 .await?;
397 components.push(c);
398 }
399
400 async fn write_locked_app(
402 locked_app: &LockedApp,
403 working_dir: &Path,
404 ) -> Result<String, anyhow::Error> {
405 let locked_path = working_dir.join("spin.lock");
406 let locked_app_contents =
407 serde_json::to_vec_pretty(&locked_app).context("failed to serialize locked app")?;
408 tokio::fs::write(&locked_path, locked_app_contents)
409 .await
410 .with_context(|| format!("failed to write {}", quoted_path(&locked_path)))?;
411 let locked_url = Url::from_file_path(&locked_path)
412 .map_err(|_| {
413 anyhow::anyhow!("cannot convert to file URL: {}", quoted_path(&locked_path))
414 })?
415 .to_string();
416
417 Ok(locked_url)
418 }
419
420 Ok((layers, components))
421 }
422
423 async fn assemble_content_layers(
424 &mut self,
425 assembly_mode: AssemblyMode,
426 layers: &mut Vec<ImageLayer>,
427 contents: &[ContentPath],
428 ) -> Result<Vec<ContentPath>> {
429 let mut files = Vec::new();
430 for f in contents {
431 let source = f
432 .content
433 .source
434 .as_ref()
435 .context("file mount loaded from disk should contain a file source")?;
436 let source = parse_file_url(source.as_str())?;
437
438 match assembly_mode {
439 AssemblyMode::Archive => self
440 .push_archive_layer(&source, &mut files, layers)
441 .await
442 .context(format!(
443 "cannot push archive layer for source {}",
444 quoted_path(&source)
445 ))?,
446 AssemblyMode::Simple => self
447 .push_file_layers(&source, &mut files, layers)
448 .await
449 .context(format!(
450 "cannot push file layers for source {}",
451 quoted_path(&source)
452 ))?,
453 }
454 }
455 Ok(files)
456 }
457
458 async fn push_archive_layer(
461 &mut self,
462 source: &PathBuf,
463 files: &mut Vec<ContentPath>,
464 layers: &mut Vec<ImageLayer>,
465 ) -> Result<()> {
466 for entry in WalkDir::new(source) {
468 let entry = entry?;
469 if !entry.file_type().is_file() {
470 continue;
471 }
472 let rel_path = entry.path().strip_prefix(source).unwrap();
474 tracing::trace!("Adding asset {rel_path:?} to component files list");
475 let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
477 let content = self.content_ref_for_layer(&layer);
478 files.push(ContentPath {
479 content,
480 path: rel_path.into(),
481 });
482 }
483
484 tracing::trace!("Adding archive layer for all files in source {:?}", &source);
486 let working_dir = tempfile::tempdir()?;
487 let archive_path = crate::utils::archive(source, &working_dir.keep())
488 .await
489 .context(format!(
490 "Unable to create compressed archive for source {source:?}"
491 ))?;
492 let layer = Self::data_layer(archive_path.as_path(), ARCHIVE_MEDIATYPE.to_string()).await?;
493 layers.push(layer);
494 Ok(())
495 }
496
497 async fn push_file_layers(
499 &mut self,
500 source: &PathBuf,
501 files: &mut Vec<ContentPath>,
502 layers: &mut Vec<ImageLayer>,
503 ) -> Result<()> {
504 tracing::trace!("Adding new layer per file under source {:?}", source);
507 for entry in WalkDir::new(source) {
508 let entry = entry?;
509 if !entry.file_type().is_file() {
510 continue;
511 }
512 let rel_path = entry.path().strip_prefix(source).unwrap();
514 let rel_path = portable_path(rel_path);
517
518 tracing::trace!("Adding new layer for asset {rel_path:?}");
519 let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
521 let content = self.content_ref_for_layer(&layer);
522 let content_inline = content.inline.is_some();
523 files.push(ContentPath {
524 content,
525 path: rel_path,
526 });
527 let skip_layer = content_inline;
531 if !skip_layer {
532 layers.push(layer);
533 }
534 }
535 Ok(())
536 }
537
538 pub async fn pull(&mut self, reference: &str) -> Result<OciImageManifest> {
540 let reference: Reference = reference.parse().context("cannot parse reference")?;
541 let auth = Self::auth(&reference).await?;
542
543 let (manifest, digest) = self.oci.pull_image_manifest(&reference, &auth).await?;
545
546 let manifest_json = serde_json::to_string(&manifest)?;
547 tracing::debug!("Pulled manifest: {}", manifest_json);
548
549 let m = self.manifest_path(&reference.to_string()).await?;
551 fs::write(&m, &manifest_json).await?;
552
553 let mut cfg_bytes = Vec::new();
557 self.oci
558 .pull_blob(&reference, &manifest.config, &mut cfg_bytes)
559 .await?;
560 self.write_locked_app_config(&reference.to_string(), &cfg_bytes)
561 .await
562 .context("unable to write locked app config to cache")?;
563
564 stream::iter(&manifest.layers)
567 .map(|layer| {
568 let this = &self;
569 let reference = reference.clone();
570 async move {
571 if this.cache.wasm_file(&layer.digest).is_ok()
573 || this.cache.data_file(&layer.digest).is_ok()
574 {
575 tracing::debug!("Layer {} already exists in cache", &layer.digest);
576 return anyhow::Ok(());
577 }
578
579 tracing::debug!("Pulling layer {}", &layer.digest);
580 let mut bytes = Vec::with_capacity(layer.size.try_into()?);
581 this.oci.pull_blob(&reference, layer, &mut bytes).await?;
582 match layer.media_type.as_str() {
583 SPIN_APPLICATION_MEDIA_TYPE => {
584 this.write_locked_app_config(&reference.to_string(), &bytes)
585 .await
586 .with_context(|| "unable to write locked app config to cache")?;
587 }
588 WASM_LAYER_MEDIA_TYPE | WASM_LAYER_MEDIA_TYPE_WKG => {
589 this.cache.write_wasm(&bytes, &layer.digest).await?;
590 }
591 ARCHIVE_MEDIATYPE => {
592 unpack_archive_layer(&this.cache, &bytes, &layer.digest).await?;
593 }
594 _ => {
595 this.cache.write_data(&bytes, &layer.digest).await?;
596 }
597 }
598 Ok(())
599 }
600 })
601 .buffer_unordered(MAX_PARALLEL_PULL)
602 .try_for_each(future::ok)
603 .await?;
604 tracing::info!("Pulled {}@{}", reference, digest);
605
606 Ok(manifest)
607 }
608
609 async fn manifest_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
612 let reference: Reference = reference
613 .as_ref()
614 .parse()
615 .context("cannot parse OCI reference")?;
616 let p = self
617 .cache
618 .manifests_dir()
619 .join(fs_safe_segment(reference.registry()))
620 .join(reference.repository())
621 .join(reference.tag().unwrap_or(LATEST_TAG));
622
623 if !p.is_dir() {
624 fs::create_dir_all(&p).await.with_context(|| {
625 format!("cannot create directory {} for OCI manifest", p.display())
626 })?;
627 }
628
629 Ok(p.join(MANIFEST_FILE))
630 }
631
632 pub async fn lockfile_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
634 let reference: Reference = reference
635 .as_ref()
636 .parse()
637 .context("cannot parse reference")?;
638 let p = self
639 .cache
640 .manifests_dir()
641 .join(fs_safe_segment(reference.registry()))
642 .join(reference.repository())
643 .join(reference.tag().unwrap_or(LATEST_TAG));
644
645 if !p.is_dir() {
646 fs::create_dir_all(&p)
647 .await
648 .context("cannot find configuration object for reference")?;
649 }
650
651 Ok(p.join(CONFIG_FILE))
652 }
653
654 async fn write_locked_app_config(
656 &self,
657 reference: impl AsRef<str>,
658 bytes: impl AsRef<[u8]>,
659 ) -> Result<()> {
660 let cfg = std::str::from_utf8(bytes.as_ref())?;
661 tracing::debug!("Pulled config: {}", cfg);
662
663 let c = self.lockfile_path(reference).await?;
664 fs::write(&c, &cfg).await.map_err(anyhow::Error::from)
665 }
666
667 async fn wasm_layer(file: &Path) -> Result<ImageLayer> {
669 tracing::trace!("Reading wasm module from {:?}", file);
670 Ok(ImageLayer::new(
671 fs::read(file)
672 .await
673 .with_context(|| format!("cannot read wasm module {}", quoted_path(file)))?,
674 WASM_LAYER_MEDIA_TYPE.to_string(),
675 None,
676 ))
677 }
678
679 async fn data_layer(file: &Path, media_type: String) -> Result<ImageLayer> {
681 tracing::trace!("Reading data file from {:?}", file);
682 Ok(ImageLayer::new(
683 fs::read(&file)
684 .await
685 .with_context(|| format!("cannot read file {}", quoted_path(file)))?,
686 media_type,
687 None,
688 ))
689 }
690
691 fn content_ref_for_layer(&self, layer: &ImageLayer) -> ContentRef {
692 ContentRef {
693 inline: (layer.data.len() <= self.opts.content_ref_inline_max_size)
696 .then(|| layer.data.to_vec()),
697 digest: Some(layer.sha256_digest()),
698 ..Default::default()
699 }
700 }
701
702 pub async fn login(
704 server: impl AsRef<str>,
705 username: impl AsRef<str>,
706 password: impl AsRef<str>,
707 ) -> Result<()> {
708 let registry = registry_from_input(server);
709
710 Self::validate_credentials(®istry, &username, &password).await?;
714
715 let mut auth = AuthConfig::load_default().await?;
717 auth.insert(registry, username, password)?;
718 auth.save_default().await
719 }
720
721 pub async fn insert_token(
723 &mut self,
724 reference: &Reference,
725 op: RegistryOperation,
726 token: RegistryTokenType,
727 ) {
728 self.oci.tokens.insert(reference, op, token).await;
729 }
730
731 async fn validate_credentials(
733 server: impl AsRef<str>,
734 username: impl AsRef<str>,
735 password: impl AsRef<str>,
736 ) -> Result<()> {
737 let client = docker_registry::v2::Client::configure()
738 .registry(server.as_ref())
739 .insecure_registry(false)
740 .username(Some(username.as_ref().into()))
741 .password(Some(password.as_ref().into()))
742 .build()
743 .context("cannot create client to send authentication request to the registry")?;
744
745 match client
746 .authenticate(&[""])
749 .await
750 {
751 Ok(_) => Ok(()),
752 Err(e) => bail!(format!(
753 "cannot authenticate as {} to registry {}: {}",
754 username.as_ref(),
755 server.as_ref(),
756 e
757 )),
758 }
759 }
760
761 async fn auth(reference: &Reference) -> Result<RegistryAuth> {
763 let server = reference
764 .resolve_registry()
765 .strip_suffix('/')
766 .unwrap_or_else(|| reference.resolve_registry());
767
768 match AuthConfig::get_auth_from_default(server).await {
769 Ok(c) => Ok(c),
770 Err(_) => match docker_credential::get_credential(server) {
771 Err(e) => {
772 tracing::trace!(
773 "Cannot retrieve credentials from Docker, attempting to use anonymous auth: {}",
774 e
775 );
776 Ok(RegistryAuth::Anonymous)
777 }
778
779 Ok(DockerCredential::UsernamePassword(username, password)) => {
780 tracing::trace!("Found Docker credentials");
781 Ok(RegistryAuth::Basic(username, password))
782 }
783 Ok(DockerCredential::IdentityToken(_)) => {
784 tracing::trace!(
785 "Cannot use contents of Docker config, identity token not supported. Using anonymous auth"
786 );
787 Ok(RegistryAuth::Anonymous)
788 }
789 },
790 }
791 }
792
793 fn build_config(insecure: bool) -> oci_client::client::ClientConfig {
795 let protocol = if insecure {
796 oci_client::client::ClientProtocol::Http
797 } else {
798 oci_client::client::ClientProtocol::Https
799 };
800
801 oci_client::client::ClientConfig {
802 protocol,
803 default_token_expiration_secs: DEFAULT_TOKEN_EXPIRATION_SECS,
804 ..Default::default()
805 }
806 }
807}
808
809pub async fn unpack_archive_layer(
814 cache: &Cache,
815 bytes: impl AsRef<[u8]>,
816 digest: impl AsRef<str>,
817) -> Result<()> {
818 cache.write_data(&bytes, &digest).await?;
820
821 let path = cache
823 .data_file(&digest)
824 .context("unable to read archive layer from cache")?;
825 let staging_dir = tempfile::tempdir()?;
826 crate::utils::unarchive(path.as_ref(), staging_dir.path()).await?;
827
828 for entry in WalkDir::new(staging_dir.path()) {
831 let entry = entry?;
832 if entry.file_type().is_file() && !entry.file_type().is_dir() {
833 let bytes = tokio::fs::read(entry.path()).await?;
834 let digest = format!("sha256:{}", sha256::hex_digest_from_bytes(&bytes));
835 if cache.data_file(&digest).is_ok() {
836 tracing::debug!(
837 "Skipping unpacked asset {:?}; file already exists",
838 entry.path()
839 );
840 } else {
841 tracing::debug!("Adding unpacked asset {:?} to cache", entry.path());
842 cache.write_data(bytes, &digest).await?;
843 }
844 }
845 }
846 Ok(())
847}
848
849fn digest_from_url(manifest_url: &str) -> Option<String> {
850 let manifest_url = Url::parse(manifest_url).ok()?;
852 let mut segments = manifest_url.path_segments()?;
853 let last = segments.next_back()?;
854 if last.contains(':') {
855 Some(last.to_owned())
856 } else {
857 None
858 }
859}
860
861fn registry_from_input(server: impl AsRef<str>) -> String {
862 let server = server.as_ref();
864 let server = match server.parse::<Url>() {
865 Ok(url) => url.host_str().unwrap_or(server).to_string(),
866 Err(_) => server.to_string(),
867 };
868 match server.as_str() {
870 "docker.io" => "index.docker.io".to_string(),
871 _ => server,
872 }
873}
874
875fn all_annotations(
876 locked_app: &LockedApp,
877 explicit: Option<BTreeMap<String, String>>,
878 predefined: InferPredefinedAnnotations,
879) -> Option<BTreeMap<String, String>> {
880 use spin_locked_app::{APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY, MetadataKey};
881 const APP_AUTHORS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("authors");
882
883 if predefined == InferPredefinedAnnotations::None {
884 return explicit;
885 }
886
887 let mut current = explicit.unwrap_or_default();
890
891 let authors = locked_app
892 .get_metadata(APP_AUTHORS_KEY)
893 .unwrap_or_default()
894 .unwrap_or_default();
895 if !authors.is_empty() {
896 let authors = authors.join(", ");
897 add_inferred(
898 &mut current,
899 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
900 Some(authors),
901 );
902 }
903
904 let name = locked_app.get_metadata(APP_NAME_KEY).unwrap_or_default();
905 add_inferred(
906 &mut current,
907 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE,
908 name,
909 );
910
911 let description = locked_app
912 .get_metadata(APP_DESCRIPTION_KEY)
913 .unwrap_or_default();
914 add_inferred(
915 &mut current,
916 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION,
917 description,
918 );
919
920 let version = locked_app.get_metadata(APP_VERSION_KEY).unwrap_or_default();
921 add_inferred(
922 &mut current,
923 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION,
924 version,
925 );
926
927 let created = chrono::Utc::now().to_rfc3339();
928 add_inferred(
929 &mut current,
930 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED,
931 Some(created),
932 );
933
934 Some(current)
935}
936
937fn add_inferred(map: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
938 if let Some(value) = value
939 && let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string())
940 {
941 e.insert(value);
942 }
943}
944
945const SPIN_LOCKED_URL: &str = "SPIN_LOCKED_URL";
946const SPIN_WORKING_DIR: &str = "SPIN_WORKING_DIR";
947
948async fn precompose_using_trigger(
949 c: &LockedComponent,
950 locked_url: &str,
951 working_dir: &Path,
952) -> Result<Vec<u8>, spin_compose::ComposeError> {
953 use spin_compose::ComposeError;
954
955 let Some(resolve_extras_using) = c
956 .metadata
957 .get("resolve-trigger-dependencies-using")
958 .and_then(|v| v.as_str())
959 else {
960 return spin_compose::compose(&ComponentSourceLoaderFs, c, async |a| Ok(a)).await;
961 };
962
963 let resolver_subcmd = match resolve_extras_using {
964 "http" | "redis" => vec!["trigger".into(), resolve_extras_using.into()],
965 _ => vec![format!("trigger-{resolve_extras_using}")],
966 };
967
968 let mut cmd = tokio::process::Command::new(std::env::current_exe().unwrap());
969 cmd.args(resolver_subcmd)
970 .args(["--precompose-only", "--precompose-component-id"])
971 .arg(&c.id)
972 .stdout(std::process::Stdio::piped())
973 .stderr(std::process::Stdio::inherit())
974 .env("SPIN_PLUGINS_SUPPRESS_COMPATIBILITY_WARNINGS", "1")
975 .env(SPIN_LOCKED_URL, locked_url)
976 .env(SPIN_WORKING_DIR, working_dir);
977
978 let child = cmd
979 .spawn()
980 .map_err(|e| ComposeError::PrepareError(e.into()))?;
981
982 let trigger_out = child
983 .wait_with_output()
984 .await
985 .map_err(|e| ComposeError::PrepareError(e.into()))?;
986
987 if !trigger_out.status.success() {
988 return Err(ComposeError::PrepareError(anyhow::anyhow!(
989 "unable to compose additional components for {} using `{}`",
990 c.id,
991 resolve_extras_using
992 )));
993 }
994
995 let composed = trigger_out.stdout;
996 Ok(composed)
997}
998
999#[cfg(target_os = "windows")]
1002fn portable_path(rel_path: &Path) -> PathBuf {
1003 assert!(
1004 rel_path.is_relative(),
1005 "portable_path requires paths to be relative"
1006 );
1007 let portable_path = rel_path.to_string_lossy().replace('\\', "/");
1008 PathBuf::from(portable_path)
1009}
1010
1011#[cfg(not(target_os = "windows"))]
1015fn portable_path(rel_path: &Path) -> PathBuf {
1016 rel_path.into()
1017}
1018
1019#[cfg(target_os = "windows")]
1022fn fs_safe_segment(segment: &str) -> impl AsRef<Path> {
1023 segment.replace(':', "_")
1024}
1025
1026#[cfg(not(target_os = "windows"))]
1030fn fs_safe_segment(segment: &str) -> impl AsRef<Path> + '_ {
1031 segment
1032}
1033
1034#[cfg(test)]
1035mod test {
1036 use super::*;
1037 use wit_parser::{LiftLowerAbi, ManglingAndAbi};
1038
1039 #[test]
1040 fn can_parse_digest_from_manifest_url() {
1041 let manifest_url = "https://ghcr.io/v2/itowlson/osf/manifests/sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
1042 let digest = digest_from_url(manifest_url).unwrap();
1043 assert_eq!(
1044 "sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f",
1045 digest
1046 );
1047 }
1048
1049 #[test]
1050 fn can_derive_registry_from_input() {
1051 #[derive(Clone)]
1052 struct TestCase {
1053 input: &'static str,
1054 want: &'static str,
1055 }
1056 let tests: Vec<TestCase> = [
1057 TestCase {
1058 input: "docker.io",
1059 want: "index.docker.io",
1060 },
1061 TestCase {
1062 input: "index.docker.io",
1063 want: "index.docker.io",
1064 },
1065 TestCase {
1066 input: "https://ghcr.io",
1067 want: "ghcr.io",
1068 },
1069 ]
1070 .to_vec();
1071
1072 for tc in tests {
1073 assert_eq!(tc.want, registry_from_input(tc.input));
1074 }
1075 }
1076
1077 #[macro_export]
1079 #[allow(missing_docs)] macro_rules! from_json {
1081 ($($json:tt)+) => {
1082 serde_json::from_value(serde_json::json!($($json)+)).expect("valid json")
1083 };
1084 }
1085
1086 fn file_url(path: impl AsRef<Path>) -> String {
1087 Url::from_file_path(path)
1088 .expect("should convert test path to file URL")
1089 .to_string()
1090 }
1091
1092 #[tokio::test]
1093 async fn can_assemble_layers() {
1094 use spin_locked_app::locked::LockedComponent;
1095 use tokio::io::AsyncWriteExt;
1096
1097 let working_dir = tempfile::tempdir().unwrap();
1098
1099 let _ = tokio::fs::create_dir(working_dir.path().join("component1").as_path()).await;
1103 let _ = tokio::fs::create_dir(working_dir.path().join("component2").as_path()).await;
1104
1105 let mut c1 = tokio::fs::File::create(working_dir.path().join("component1.wasm"))
1107 .await
1108 .expect("should create component wasm file");
1109 c1.write_all(b"c1")
1110 .await
1111 .expect("should write component wasm contents");
1112 let mut c2 = tokio::fs::File::create(working_dir.path().join("component2.wasm"))
1113 .await
1114 .expect("should create component wasm file");
1115 c2.write_all(b"c2")
1116 .await
1117 .expect("should write component wasm contents");
1118
1119 let mut c1f1 = tokio::fs::File::create(working_dir.path().join("component1").join("bar"))
1121 .await
1122 .expect("should create component file");
1123 c1f1.write_all(b"bar")
1124 .await
1125 .expect("should write file contents");
1126 let mut c1f2 = tokio::fs::File::create(working_dir.path().join("component1").join("baz"))
1127 .await
1128 .expect("should create component file");
1129 c1f2.write_all(b"baz")
1130 .await
1131 .expect("should write file contents");
1132
1133 let mut c2f1 = tokio::fs::File::create(working_dir.path().join("component2").join("baz"))
1135 .await
1136 .expect("should create component file");
1137 c2f1.write_all(b"baz")
1138 .await
1139 .expect("should write file contents");
1140
1141 const TEST_WIT: &str = "
1143 package test:test;
1144
1145 interface a {
1146 a: func();
1147 }
1148
1149 world dep-a {
1150 export a;
1151 }
1152
1153 world root {
1154 import a;
1155 }
1156 ";
1157
1158 let root = generate_dummy_component(TEST_WIT, "root");
1159 let dep_a = generate_dummy_component(TEST_WIT, "dep-a");
1160
1161 let mut r = tokio::fs::File::create(working_dir.path().join("root.wasm"))
1162 .await
1163 .expect("should create component wasm file");
1164 r.write_all(&root)
1165 .await
1166 .expect("should write component wasm contents");
1167
1168 let mut a = tokio::fs::File::create(working_dir.path().join("dep_a.wasm"))
1169 .await
1170 .expect("should create component wasm file");
1171 a.write_all(&dep_a)
1172 .await
1173 .expect("should write component wasm contents");
1174
1175 #[derive(Clone)]
1176 struct TestCase {
1177 name: &'static str,
1178 opts: Option<ClientOpts>,
1179 locked_components: Vec<LockedComponent>,
1180 expected_layer_count: usize,
1181 expected_error: Option<&'static str>,
1182 compose_mode: ComposeMode,
1183 }
1184
1185 let tests: Vec<TestCase> = [
1186 TestCase {
1187 name: "Two component layers",
1188 opts: None,
1189 locked_components: from_json!([{
1190 "id": "component1",
1191 "source": {
1192 "content_type": "application/wasm",
1193 "source": file_url(working_dir.path().join("component1.wasm")),
1194 "digest": "digest",
1195 }},
1196 {
1197 "id": "component2",
1198 "source": {
1199 "content_type": "application/wasm",
1200 "source": file_url(working_dir.path().join("component2.wasm")),
1201 "digest": "digest",
1202 }}]),
1203 expected_layer_count: 2,
1204 expected_error: None,
1205 compose_mode: ComposeMode::Skip,
1206 },
1207 TestCase {
1208 name: "One component layer and two file layers",
1209 opts: Some(ClientOpts {
1210 content_ref_inline_max_size: 0,
1211 }),
1212 locked_components: from_json!([{
1213 "id": "component1",
1214 "source": {
1215 "content_type": "application/wasm",
1216 "source": file_url(working_dir.path().join("component1.wasm")),
1217 "digest": "digest",
1218 },
1219 "files": [
1220 {
1221 "source": file_url(working_dir.path().join("component1")),
1222 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1223 },
1224 {
1225 "source": file_url(working_dir.path().join("component1")),
1226 "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1227 }
1228 ]
1229 }]),
1230 expected_layer_count: 3,
1231 expected_error: None,
1232 compose_mode: ComposeMode::Skip,
1233 },
1234 TestCase {
1235 name: "One component layer and one file with inlined content",
1236 opts: None,
1237 locked_components: from_json!([{
1238 "id": "component1",
1239 "source": {
1240 "content_type": "application/wasm",
1241 "source": file_url(working_dir.path().join("component1.wasm")),
1242 "digest": "digest",
1243 },
1244 "files": [
1245 {
1246 "source": file_url(working_dir.path().join("component1")),
1247 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1248 }
1249 ]
1250 }]),
1251 expected_layer_count: 1,
1252 expected_error: None,
1253 compose_mode: ComposeMode::Skip,
1254 },
1255 TestCase {
1256 name: "One component layer and one dependency component layer skipping composition",
1257 opts: Some(ClientOpts {
1258 content_ref_inline_max_size: 0,
1259 }),
1260 locked_components: from_json!([{
1261 "id": "component1",
1262 "source": {
1263 "content_type": "application/wasm",
1264 "source": file_url(working_dir.path().join("component1.wasm")),
1265 "digest": "digest",
1266 },
1267 "dependencies": {
1268 "test:comp2": {
1269 "source": {
1270 "content_type": "application/wasm",
1271 "source": file_url(working_dir.path().join("component2.wasm")),
1272 "digest": "digest",
1273 },
1274 "export": null,
1275 }
1276 }
1277 }]),
1278 expected_layer_count: 2,
1279 expected_error: None,
1280 compose_mode: ComposeMode::Skip,
1281 },
1282 TestCase {
1283 name: "Component has no source",
1284 opts: None,
1285 locked_components: from_json!([{
1286 "id": "component1",
1287 "source": {
1288 "content_type": "application/wasm",
1289 "source": "",
1290 "digest": "digest",
1291 }
1292 }]),
1293 expected_layer_count: 0,
1294 expected_error: Some("Invalid URL: \"\""),
1295 compose_mode: ComposeMode::Skip,
1296 },
1297 TestCase {
1298 name: "Duplicate component sources",
1299 opts: None,
1300 locked_components: from_json!([{
1301 "id": "component1",
1302 "source": {
1303 "content_type": "application/wasm",
1304 "source": file_url(working_dir.path().join("component1.wasm")),
1305 "digest": "digest",
1306 }},
1307 {
1308 "id": "component2",
1309 "source": {
1310 "content_type": "application/wasm",
1311 "source": file_url(working_dir.path().join("component1.wasm")),
1312 "digest": "digest",
1313 }}]),
1314 expected_layer_count: 1,
1315 expected_error: None,
1316 compose_mode: ComposeMode::Skip,
1317 },
1318 TestCase {
1319 name: "Duplicate file paths",
1320 opts: Some(ClientOpts {
1321 content_ref_inline_max_size: 0,
1322 }),
1323 locked_components: from_json!([{
1324 "id": "component1",
1325 "source": {
1326 "content_type": "application/wasm",
1327 "source": file_url(working_dir.path().join("component1.wasm")),
1328 "digest": "digest",
1329 },
1330 "files": [
1331 {
1332 "source": file_url(working_dir.path().join("component1")),
1333 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1334 },
1335 {
1336 "source": file_url(working_dir.path().join("component1")),
1337 "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1338 }
1339 ]},
1340 {
1341 "id": "component2",
1342 "source": {
1343 "content_type": "application/wasm",
1344 "source": file_url(working_dir.path().join("component2.wasm")),
1345 "digest": "digest",
1346 },
1347 "files": [
1348 {
1349 "source": file_url(working_dir.path().join("component2")),
1350 "path": working_dir.path().join("component2").join("baz").to_str().unwrap()
1351 }
1352 ]
1353 }]),
1354 expected_layer_count: 4,
1355 expected_error: None,
1356 compose_mode: ComposeMode::Skip,
1357 },
1358 TestCase {
1359 name: "One component layer and one dependency component layer with composition",
1360 opts: Some(ClientOpts {
1361 content_ref_inline_max_size: 0,
1362 }),
1363 locked_components: from_json!([{
1364 "id": "component-with-deps",
1365 "source": {
1366 "content_type": "application/wasm",
1367 "source": file_url(working_dir.path().join("root.wasm")),
1368 "digest": "digest",
1369 },
1370 "dependencies": {
1371 "test:test/a": {
1372 "source": {
1373 "content_type": "application/wasm",
1374 "source": file_url(working_dir.path().join("dep_a.wasm")),
1375 "digest": "digest",
1376 },
1377 "export": null,
1378 }
1379 }
1380 }]),
1381 expected_layer_count: 1,
1382 expected_error: None,
1383 compose_mode: ComposeMode::All,
1384 },
1385 ]
1386 .to_vec();
1387
1388 for tc in tests {
1389 let triggers = Default::default();
1390 let metadata = Default::default();
1391 let variables = Default::default();
1392 let mut locked = LockedApp {
1393 spin_lock_version: Default::default(),
1394 components: tc.locked_components,
1395 triggers,
1396 metadata,
1397 variables,
1398 must_understand: Default::default(),
1399 host_requirements: Default::default(),
1400 };
1401
1402 let mut client = Client::new(false, Some(working_dir.path().to_path_buf()))
1403 .await
1404 .expect("should create new client");
1405 if let Some(o) = tc.opts {
1406 client.opts = o;
1407 }
1408
1409 match tc.expected_error {
1410 Some(e) => {
1411 assert_eq!(
1412 e,
1413 client
1414 .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1415 .await
1416 .unwrap_err()
1417 .to_string(),
1418 "{}",
1419 tc.name
1420 )
1421 }
1422 None => {
1423 assert_eq!(
1424 tc.expected_layer_count,
1425 client
1426 .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1427 .await
1428 .unwrap()
1429 .len(),
1430 "{}",
1431 tc.name
1432 )
1433 }
1434 }
1435 }
1436 }
1437
1438 fn generate_dummy_component(wit: &str, world: &str) -> Vec<u8> {
1439 let mut resolve = wit_parser::Resolve::default();
1440 let package_id = resolve.push_str("test", wit).expect("should parse WIT");
1441 let world_id = resolve
1442 .select_world(&[package_id], Some(world))
1443 .expect("should select world");
1444
1445 let mut wasm = wit_component::dummy_module(
1446 &resolve,
1447 world_id,
1448 ManglingAndAbi::Legacy(LiftLowerAbi::Sync),
1449 );
1450 wit_component::embed_component_metadata(
1451 &mut wasm,
1452 &resolve,
1453 world_id,
1454 wit_component::StringEncoding::UTF8,
1455 )
1456 .expect("should embed component metadata");
1457
1458 let mut encoder = wit_component::ComponentEncoder::default()
1459 .validate(true)
1460 .module(&wasm)
1461 .expect("should set module");
1462 encoder.encode().expect("should encode component")
1463 }
1464
1465 fn annotatable_app() -> LockedApp {
1466 let mut meta_builder = spin_locked_app::values::ValuesMapBuilder::new();
1467 meta_builder
1468 .string("name", "this-is-spinal-tap")
1469 .string("version", "11.11.11")
1470 .string("description", "")
1471 .string_array("authors", vec!["Marty DiBergi", "Artie Fufkin"]);
1472 let metadata = meta_builder.build();
1473 LockedApp {
1474 spin_lock_version: Default::default(),
1475 must_understand: vec![],
1476 metadata,
1477 host_requirements: Default::default(),
1478 variables: Default::default(),
1479 triggers: Default::default(),
1480 components: Default::default(),
1481 }
1482 }
1483
1484 fn as_annotations(annotations: &[(&str, &str)]) -> Option<BTreeMap<String, String>> {
1485 Some(
1486 annotations
1487 .iter()
1488 .map(|(k, v)| (k.to_string(), v.to_string()))
1489 .collect(),
1490 )
1491 }
1492
1493 #[test]
1494 fn no_annotations_no_infer_result_is_no_annotations() {
1495 let locked_app = annotatable_app();
1496 let explicit = None;
1497 let infer = InferPredefinedAnnotations::None;
1498
1499 assert!(all_annotations(&locked_app, explicit, infer).is_none());
1500 }
1501
1502 #[test]
1503 fn explicit_annotations_no_infer_result_is_explicit_annotations() {
1504 let locked_app = annotatable_app();
1505 let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1506 let infer = InferPredefinedAnnotations::None;
1507
1508 let annotations =
1509 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1510 assert_eq!(2, annotations.len());
1511 assert_eq!("11", annotations.get("volume").unwrap());
1512 assert_eq!("feet", annotations.get("dimensions").unwrap());
1513 }
1514
1515 #[test]
1516 fn no_annotations_infer_all_result_is_auto_annotations() {
1517 let locked_app = annotatable_app();
1518 let explicit = None;
1519 let infer = InferPredefinedAnnotations::All;
1520
1521 let annotations =
1522 all_annotations(&locked_app, explicit, infer).expect("should now have annotations");
1523 assert_eq!(4, annotations.len());
1524 assert_eq!(
1525 "Marty DiBergi, Artie Fufkin",
1526 annotations
1527 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1528 .expect("should have authors annotation")
1529 );
1530 assert_eq!(
1531 "this-is-spinal-tap",
1532 annotations
1533 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE)
1534 .expect("should have title annotation")
1535 );
1536 assert_eq!(
1537 "11.11.11",
1538 annotations
1539 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION)
1540 .expect("should have version annotation")
1541 );
1542 assert!(
1543 !annotations
1544 .contains_key(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION),
1545 "empty description should not have generated annotation"
1546 );
1547 assert!(
1548 annotations.contains_key(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED),
1549 "creation annotation should have been generated"
1550 );
1551 }
1552
1553 #[test]
1554 fn explicit_annotations_infer_all_gets_both_sets() {
1555 let locked_app = annotatable_app();
1556 let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1557 let infer = InferPredefinedAnnotations::All;
1558
1559 let annotations =
1560 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1561 assert_eq!(6, annotations.len());
1562 assert_eq!(
1563 "11",
1564 annotations
1565 .get("volume")
1566 .expect("should have retained explicit annotation")
1567 );
1568 assert_eq!(
1569 "Marty DiBergi, Artie Fufkin",
1570 annotations
1571 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1572 .expect("should have authors annotation")
1573 );
1574 }
1575
1576 #[test]
1577 fn explicit_annotations_take_precedence_over_inferred() {
1578 let locked_app = annotatable_app();
1579 let explicit = as_annotations(&[
1580 ("volume", "11"),
1581 (
1582 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
1583 "David St Hubbins, Nigel Tufnel",
1584 ),
1585 ]);
1586 let infer = InferPredefinedAnnotations::All;
1587
1588 let annotations =
1589 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1590 assert_eq!(
1591 5,
1592 annotations.len(),
1593 "should have one custom, one predefined explicit, and three inferred"
1594 );
1595 assert_eq!(
1596 "11",
1597 annotations
1598 .get("volume")
1599 .expect("should have retained explicit annotation")
1600 );
1601 assert_eq!(
1602 "David St Hubbins, Nigel Tufnel",
1603 annotations
1604 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1605 .expect("should have authors annotation"),
1606 "explicit authors should have taken precedence"
1607 );
1608 }
1609}