systemprompt_loader/bundle/source/oci/
push.rs1use std::path::Path;
13
14use sha2::{Digest, Sha256};
15use systemprompt_models::services::bundle::BUNDLE_MEDIA_TYPE;
16
17use super::RegistryClient;
18use super::pull::{OCI_MANIFEST_MEDIA_TYPE, OciDescriptor, OciManifest};
19use crate::bundle::error::{BundleError, BundleResult};
20
21pub const BUNDLE_CONFIG_MEDIA_TYPE: &str =
22 "application/vnd.systemprompt.services-bundle.config.v1+json";
23
24pub async fn push_bundle(
25 reference: &str,
26 archive: &Path,
27 manifest_json: &[u8],
28 secret: Option<String>,
29 client: reqwest::Client,
30) -> BundleResult<String> {
31 let registry = RegistryClient::new("publish", reference, secret, client)?;
32 let archive_bytes = tokio::fs::read(archive).await?;
33
34 let config = upload_blob(®istry, manifest_json, BUNDLE_CONFIG_MEDIA_TYPE).await?;
35 let layer = upload_blob(®istry, &archive_bytes, BUNDLE_MEDIA_TYPE).await?;
36
37 let manifest = OciManifest {
38 schema_version: 2,
39 media_type: Some(OCI_MANIFEST_MEDIA_TYPE.to_owned()),
40 artifact_type: Some(BUNDLE_MEDIA_TYPE.to_owned()),
41 config,
42 layers: vec![layer],
43 };
44 put_manifest(®istry, &manifest).await
45}
46
47async fn upload_blob(
48 registry: &RegistryClient,
49 body: &[u8],
50 media_type: &str,
51) -> BundleResult<OciDescriptor> {
52 let digest = format!("sha256:{}", hex::encode(Sha256::digest(body)));
53
54 let initiate_url = registry.url("/blobs/uploads/")?;
55 let initiated = registry
56 .send(move |client| client.post(initiate_url.clone()))
57 .await?;
58 if !initiated.status().is_success() {
59 return Err(BundleError::fetch(
60 ®istry.name,
61 format!("upload session refused: {}", initiated.status()),
62 ));
63 }
64 let location = initiated
65 .headers()
66 .get(reqwest::header::LOCATION)
67 .and_then(|v| v.to_str().ok())
68 .ok_or_else(|| BundleError::fetch(®istry.name, "upload session has no Location"))?
69 .to_owned();
70 let upload_url = absolute_location(registry, &location)?;
71
72 let owned = body.to_vec();
73 let digest_param = digest.clone();
74 let response = registry
75 .send(move |client| {
76 client
77 .put(upload_url.clone())
78 .query(&[("digest", digest_param.as_str())])
79 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
80 .body(owned.clone())
81 })
82 .await?;
83 if !response.status().is_success() {
84 return Err(BundleError::fetch(
85 ®istry.name,
86 format!("blob upload failed: {}", response.status()),
87 ));
88 }
89
90 Ok(OciDescriptor {
91 media_type: media_type.to_owned(),
92 digest,
93 size: body.len() as u64,
94 })
95}
96
97fn absolute_location(registry: &RegistryClient, location: &str) -> BundleResult<url::Url> {
98 if location.starts_with("http://") || location.starts_with("https://") {
99 return url::Url::parse(location)
100 .map_err(|e| BundleError::fetch(®istry.name, format!("bad upload location: {e}")));
101 }
102 let base = registry.url("")?;
103 base.join(location)
104 .map_err(|e| BundleError::fetch(®istry.name, format!("bad upload location: {e}")))
105}
106
107async fn put_manifest(registry: &RegistryClient, manifest: &OciManifest) -> BundleResult<String> {
108 let body = serde_json::to_vec(manifest)
109 .map_err(|e| BundleError::policy(format!("manifest is not serialisable: {e}")))?;
110 let digest = format!("sha256:{}", hex::encode(Sha256::digest(&body)));
111 let url = registry.url(&format!("/manifests/{}", registry.manifest_ref()))?;
112
113 let response = registry
114 .send(move |client| {
115 client
116 .put(url.clone())
117 .header(reqwest::header::CONTENT_TYPE, OCI_MANIFEST_MEDIA_TYPE)
118 .body(body.clone())
119 })
120 .await?;
121 if !response.status().is_success() {
122 return Err(BundleError::fetch(
123 ®istry.name,
124 format!("manifest push failed: {}", response.status()),
125 ));
126 }
127 Ok(digest)
128}