wasmer_sdk/package/
publish.rs

1//! Provides package publishing functionality.
2
3use std::{
4    path::{Path, PathBuf},
5    time::Duration,
6};
7
8use anyhow::{self, Context as _};
9use futures_util::StreamExt;
10use semver::Version;
11use sha2::{Digest, Sha256};
12use thiserror::Error;
13use toml;
14
15use wasmer_backend_api::{WasmerClient, query::UploadMethod};
16use wasmer_config::package::{Manifest, NamedPackageId, PackageHash, PackageIdent};
17use wasmer_package::package::{Package, WalkBuilderFactory};
18
19/// Conditions that can be waited on after publishing a package.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PublishWait {
22    /// Do not wait for the package to be processed.
23    None,
24    /// Wait until the container (webc) is available.
25    Container,
26    /// Wait until native executables are available.
27    NativeExecutables,
28    /// Wait until bindings are available.
29    Bindings,
30    /// Wait until everything is available.
31    All,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35struct WaitPackageState {
36    container: bool,
37    native_executables: bool,
38    bindings: bool,
39}
40
41impl WaitPackageState {
42    fn from_wait(w: PublishWait) -> Self {
43        match w {
44            PublishWait::None => Self {
45                container: false,
46                native_executables: false,
47                bindings: false,
48            },
49            PublishWait::Container => Self {
50                container: true,
51                native_executables: false,
52                bindings: false,
53            },
54            PublishWait::NativeExecutables => Self {
55                container: true,
56                native_executables: true,
57                bindings: false,
58            },
59            PublishWait::Bindings => Self {
60                container: true,
61                native_executables: false,
62                bindings: true,
63            },
64            PublishWait::All => Self {
65                container: true,
66                native_executables: true,
67                bindings: true,
68            },
69        }
70    }
71
72    fn is_any(&self) -> bool {
73        self.container || self.native_executables || self.bindings
74    }
75}
76
77/// Progress events generated during the publish process.
78#[derive(Debug, Clone)]
79pub enum PublishProgress {
80    Building,
81    Uploading { uploaded: u64, total: u64 },
82    Tagging,
83    Waiting(PublishWait),
84}
85
86/// Options controlling the publish process.
87#[derive(Debug, Clone)]
88pub struct PublishOptions {
89    pub namespace: Option<String>,
90    pub name: Option<String>,
91    pub version: Option<Version>,
92    pub timeout: Duration,
93    pub wait: PublishWait,
94    pub walker_factory: WalkBuilderFactory,
95}
96
97impl Default for PublishOptions {
98    fn default() -> Self {
99        Self {
100            namespace: None,
101            name: None,
102            version: None,
103            timeout: Duration::from_secs(60 * 5),
104            wait: PublishWait::None,
105            walker_factory: wasmer_package::package::wasmer_ignore_walker(),
106        }
107    }
108}
109
110/// Errors that may occur during package publishing.
111#[derive(Debug, Error)]
112#[non_exhaustive]
113pub enum PackagePublishError {
114    #[error("manifest not found at {0}")]
115    ManifestNotFound(PathBuf),
116    #[error("failed to read manifest: {0}")]
117    ManifestRead(#[from] std::io::Error),
118    #[error("failed to parse manifest: {0}")]
119    ManifestParse(#[from] toml::de::Error),
120    #[error("package build error: {0}")]
121    PackageBuild(#[from] wasmer_package::package::WasmerPackageError),
122    #[error("backend API error: {0}")]
123    Api(#[from] anyhow::Error),
124    #[error("unexpected error: {0}")]
125    Other(Box<dyn std::error::Error + Send + Sync>),
126}
127
128/// Publish a package described by the manifest at `manifest_path`.
129///
130/// `progress` will be called with updates about the publishing process.
131pub async fn publish_package_directory<F>(
132    client: &WasmerClient,
133    path: &Path,
134    opts: PublishOptions,
135    mut progress: F,
136) -> Result<PackageIdent, PackagePublishError>
137where
138    F: FnMut(PublishProgress) + Send,
139{
140    progress(PublishProgress::Building);
141
142    let manifest_path = if path.is_dir() {
143        path.join("wasmer.toml")
144    } else {
145        path.to_path_buf()
146    };
147
148    let (manifest_str, manifest, bytes, hash) = tokio::task::spawn_blocking({
149        let manifest_path = manifest_path.clone();
150        move || {
151            let manifest_str = std::fs::read_to_string(&manifest_path).map_err(|e| {
152                if e.kind() == std::io::ErrorKind::NotFound {
153                    PackagePublishError::ManifestNotFound(manifest_path.clone())
154                } else {
155                    PackagePublishError::ManifestRead(e)
156                }
157            })?;
158            let manifest: Manifest = toml::from_str(&manifest_str)?;
159
160            let package = Package::from_manifest_with_walker(&manifest_path, opts.walker_factory)?;
161            let bytes = package.serialize()?;
162            let hash_bytes: [u8; 32] = Sha256::digest(&bytes).into();
163            let hash = PackageHash::from_sha256_bytes(hash_bytes);
164
165            Ok::<_, PackagePublishError>((manifest_str, manifest, bytes, hash))
166        }
167    })
168    .await
169    .map_err(|e| PackagePublishError::Other(Box::new(e)))??;
170
171    let total = bytes.len() as u64;
172
173    // Determine namespace/name from opts or manifest
174    let (ns, name) = {
175        let manifest_pkg = manifest.package.as_ref();
176        let parsed = manifest_pkg.and_then(|p| p.name.as_deref());
177        let manifest_ns = parsed.and_then(|n| n.split('/').next());
178        let manifest_name = parsed.and_then(|n| n.split('/').nth(1));
179        let namespace = opts
180            .namespace
181            .clone()
182            .or_else(|| manifest_ns.map(|s| s.to_string()))
183            .ok_or_else(|| PackagePublishError::Api(anyhow::anyhow!("namespace missing")))?;
184        let name = opts
185            .name
186            .clone()
187            .or_else(|| manifest_name.map(|s| s.to_string()));
188        (namespace, name)
189    };
190
191    // Determine if push needed
192    let push_needed = wasmer_backend_api::query::get_package_release(client, &hash.to_string())
193        .await
194        .map_err(PackagePublishError::Api)?
195        .is_none();
196    if push_needed {
197        let hash_string = hash.to_string();
198        let signed_url = wasmer_backend_api::query::get_signed_url_for_package_upload(
199            client,
200            Some(60 * 30),
201            Some(hash_string.trim_start_matches("sha256:")),
202            None,
203            None,
204            Some(UploadMethod::R2),
205        )
206        .await
207        .map_err(PackagePublishError::Api)?
208        .ok_or_else(|| anyhow::anyhow!("backend did not return upload url"))?
209        .url;
210
211        // upload bytes in chunks
212        let http = reqwest::Client::builder()
213            .timeout(opts.timeout)
214            .build()
215            .map_err(|e| PackagePublishError::Api(e.into()))?;
216
217        let total_len = bytes.len();
218        let resp = http
219            .post(&signed_url)
220            .header(reqwest::header::CONTENT_LENGTH, "0")
221            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
222            .header("x-goog-resumable", "start")
223            .send()
224            .await
225            .map_err(|e| PackagePublishError::Api(e.into()))?
226            .error_for_status()
227            .map_err(|e| PackagePublishError::Api(e.into()))?;
228
229        let session_url = resp
230            .headers()
231            .get(reqwest::header::LOCATION)
232            .ok_or_else(|| {
233                PackagePublishError::Api(anyhow::anyhow!(
234                    "upload server did not provide session URL"
235                ))
236            })?
237            .to_str()
238            .map_err(|e| PackagePublishError::Api(e.into()))?
239            .to_string();
240
241        progress(PublishProgress::Uploading { uploaded: 0, total });
242        let res = http
243            .put(&session_url)
244            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
245            .header(reqwest::header::CONTENT_LENGTH, total_len)
246            .body(bytes)
247            .send()
248            .await
249            .map_err(|e| PackagePublishError::Api(e.into()))?
250            .error_for_status()
251            .map_err(|e| PackagePublishError::Api(e.into()))?;
252        drop(res);
253        progress(PublishProgress::Uploading {
254            uploaded: total,
255            total,
256        });
257
258        wasmer_backend_api::query::push_package_release(
259            client,
260            name.as_deref(),
261            &ns,
262            &session_url,
263            Some(manifest.package.as_ref().is_none_or(|p| p.private)),
264        )
265        .await
266        .map_err(PackagePublishError::Api)?
267        .ok_or_else(|| anyhow::anyhow!("push response empty"))?;
268    }
269
270    // Tagging
271    progress(PublishProgress::Tagging);
272    let package_release = wasmer_backend_api::query::get_package_release(client, &hash.to_string())
273        .await
274        .map_err(PackagePublishError::Api)?
275        .ok_or_else(|| anyhow::anyhow!("package not found after push"))?;
276    let version = opts
277        .version
278        .or_else(|| manifest.package.as_ref().and_then(|p| p.version.clone()))
279        .ok_or_else(|| PackagePublishError::Api(anyhow::anyhow!("package version missing")))?;
280
281    let package_name =
282        name.ok_or_else(|| PackagePublishError::Api(anyhow::anyhow!("package name missing")))?;
283    let id = NamedPackageId {
284        full_name: format!("{ns}/{package_name}"),
285        version,
286    };
287
288    let readme_contents =
289        if let Some(readme_rel) = manifest.package.as_ref().and_then(|p| p.readme.as_ref()) {
290            let parent = manifest_path.parent().ok_or_else(|| {
291                PackagePublishError::Api(anyhow::anyhow!(
292                    "manifest path '{}' has no parent directory",
293                    manifest_path.display()
294                ))
295            })?;
296            let readme_path = parent.join(readme_rel);
297            Some(
298                std::fs::read_to_string(&readme_path)
299                    .with_context(|| format!("failed to read README at {}", readme_path.display()))
300                    .map_err(PackagePublishError::Api)?,
301            )
302        } else {
303            None
304        };
305
306    wasmer_backend_api::query::tag_package_release(
307        client,
308        manifest
309            .package
310            .as_ref()
311            .and_then(|p| p.description.as_deref()),
312        manifest
313            .package
314            .as_ref()
315            .and_then(|p| p.homepage.as_deref()),
316        manifest.package.as_ref().and_then(|p| p.license.as_deref()),
317        manifest
318            .package
319            .as_ref()
320            .and_then(|p| p.license_file.as_ref())
321            .map(|p| p.to_string_lossy())
322            .as_deref(),
323        Some(&manifest_str),
324        &id.full_name,
325        Some(&ns),
326        &package_release.id,
327        Some(manifest.package.as_ref().is_none_or(|p| p.private)),
328        readme_contents.as_deref(),
329        manifest
330            .package
331            .as_ref()
332            .and_then(|p| p.repository.as_deref()),
333        &id.version.to_string(),
334    )
335    .await
336    .map_err(PackagePublishError::Api)?
337    .ok_or_else(|| anyhow::anyhow!("tag package failed"))?;
338
339    if let PublishWait::None = opts.wait {
340    } else {
341        progress(PublishProgress::Waiting(opts.wait));
342        wait_package(client, opts.wait, package_release.id.clone(), opts.timeout).await?;
343    }
344
345    Ok(PackageIdent::Named(id.into()))
346}
347
348async fn wait_package(
349    client: &WasmerClient,
350    to_wait: PublishWait,
351    package_version_id: wasmer_backend_api::types::Id,
352    timeout: Duration,
353) -> Result<(), anyhow::Error> {
354    if let PublishWait::None = to_wait {
355        return Ok(());
356    }
357
358    let mut stream =
359        wasmer_backend_api::subscription::package_version_ready(client, package_version_id.inner())
360            .await?;
361    let mut state = WaitPackageState::from_wait(to_wait);
362    let deadline = std::time::Instant::now() + timeout;
363    while state.is_any() {
364        if std::time::Instant::now() > deadline {
365            anyhow::bail!("timed out waiting for package version");
366        }
367        let data = tokio::time::timeout_at(deadline.into(), stream.next()).await?;
368        let data = match data {
369            Some(d) => d?,
370            None => break,
371        };
372        if let Some(msg) = data.data {
373            use wasmer_backend_api::types::PackageVersionState as S;
374            match msg.package_version_ready.state {
375                S::WebcGenerated => state.container = false,
376                S::BindingsGenerated => state.bindings = false,
377                S::NativeExesGenerated => state.native_executables = false,
378            }
379        }
380    }
381    Ok(())
382}