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;
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        )
205        .await
206        .map_err(PackagePublishError::Api)?
207        .ok_or_else(|| anyhow::anyhow!("backend did not return upload url"))?
208        .url;
209
210        // upload bytes in chunks
211        let http = reqwest::Client::builder()
212            .timeout(opts.timeout)
213            .build()
214            .map_err(|e| PackagePublishError::Api(e.into()))?;
215
216        let total_len = bytes.len();
217        let resp = http
218            .post(&signed_url)
219            .header(reqwest::header::CONTENT_LENGTH, "0")
220            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
221            .header("x-goog-resumable", "start")
222            .send()
223            .await
224            .map_err(|e| PackagePublishError::Api(e.into()))?
225            .error_for_status()
226            .map_err(|e| PackagePublishError::Api(e.into()))?;
227
228        let session_url = resp
229            .headers()
230            .get(reqwest::header::LOCATION)
231            .ok_or_else(|| {
232                PackagePublishError::Api(anyhow::anyhow!(
233                    "upload server did not provide session URL"
234                ))
235            })?
236            .to_str()
237            .map_err(|e| PackagePublishError::Api(e.into()))?
238            .to_string();
239
240        progress(PublishProgress::Uploading { uploaded: 0, total });
241        let res = http
242            .put(&session_url)
243            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
244            .header(reqwest::header::CONTENT_LENGTH, total_len)
245            .body(bytes)
246            .send()
247            .await
248            .map_err(|e| PackagePublishError::Api(e.into()))?
249            .error_for_status()
250            .map_err(|e| PackagePublishError::Api(e.into()))?;
251        drop(res);
252        progress(PublishProgress::Uploading {
253            uploaded: total,
254            total,
255        });
256
257        wasmer_backend_api::query::push_package_release(
258            client,
259            name.as_deref(),
260            &ns,
261            &session_url,
262            Some(manifest.package.as_ref().is_none_or(|p| p.private)),
263        )
264        .await
265        .map_err(PackagePublishError::Api)?
266        .ok_or_else(|| anyhow::anyhow!("push response empty"))?;
267    }
268
269    // Tagging
270    progress(PublishProgress::Tagging);
271    let package_release = wasmer_backend_api::query::get_package_release(client, &hash.to_string())
272        .await
273        .map_err(PackagePublishError::Api)?
274        .ok_or_else(|| anyhow::anyhow!("package not found after push"))?;
275    let version = opts
276        .version
277        .or_else(|| manifest.package.as_ref().and_then(|p| p.version.clone()))
278        .ok_or_else(|| PackagePublishError::Api(anyhow::anyhow!("package version missing")))?;
279
280    let package_name =
281        name.ok_or_else(|| PackagePublishError::Api(anyhow::anyhow!("package name missing")))?;
282    let id = NamedPackageId {
283        full_name: format!("{ns}/{package_name}"),
284        version,
285    };
286
287    let readme_contents =
288        if let Some(readme_rel) = manifest.package.as_ref().and_then(|p| p.readme.as_ref()) {
289            let parent = manifest_path.parent().ok_or_else(|| {
290                PackagePublishError::Api(anyhow::anyhow!(
291                    "manifest path '{}' has no parent directory",
292                    manifest_path.display()
293                ))
294            })?;
295            let readme_path = parent.join(readme_rel);
296            Some(
297                std::fs::read_to_string(&readme_path)
298                    .with_context(|| format!("failed to read README at {}", readme_path.display()))
299                    .map_err(PackagePublishError::Api)?,
300            )
301        } else {
302            None
303        };
304
305    wasmer_backend_api::query::tag_package_release(
306        client,
307        manifest
308            .package
309            .as_ref()
310            .and_then(|p| p.description.as_deref()),
311        manifest
312            .package
313            .as_ref()
314            .and_then(|p| p.homepage.as_deref()),
315        manifest.package.as_ref().and_then(|p| p.license.as_deref()),
316        manifest
317            .package
318            .as_ref()
319            .and_then(|p| p.license_file.as_ref())
320            .map(|p| p.to_string_lossy())
321            .as_deref(),
322        Some(&manifest_str),
323        &id.full_name,
324        Some(&ns),
325        &package_release.id,
326        Some(manifest.package.as_ref().is_none_or(|p| p.private)),
327        readme_contents.as_deref(),
328        manifest
329            .package
330            .as_ref()
331            .and_then(|p| p.repository.as_deref()),
332        &id.version.to_string(),
333    )
334    .await
335    .map_err(PackagePublishError::Api)?
336    .ok_or_else(|| anyhow::anyhow!("tag package failed"))?;
337
338    if let PublishWait::None = opts.wait {
339    } else {
340        progress(PublishProgress::Waiting(opts.wait));
341        wait_package(client, opts.wait, package_release.id.clone(), opts.timeout).await?;
342    }
343
344    Ok(PackageIdent::Named(id.into()))
345}
346
347async fn wait_package(
348    client: &WasmerClient,
349    to_wait: PublishWait,
350    package_version_id: wasmer_backend_api::types::Id,
351    timeout: Duration,
352) -> Result<(), anyhow::Error> {
353    if let PublishWait::None = to_wait {
354        return Ok(());
355    }
356
357    let mut stream =
358        wasmer_backend_api::subscription::package_version_ready(client, package_version_id.inner())
359            .await?;
360    let mut state = WaitPackageState::from_wait(to_wait);
361    let deadline = std::time::Instant::now() + timeout;
362    while state.is_any() {
363        if std::time::Instant::now() > deadline {
364            anyhow::bail!("timed out waiting for package version");
365        }
366        let data = tokio::time::timeout_at(deadline.into(), stream.next()).await?;
367        let data = match data {
368            Some(d) => d?,
369            None => break,
370        };
371        if let Some(msg) = data.data {
372            use wasmer_backend_api::types::PackageVersionState as S;
373            match msg.package_version_ready.state {
374                S::WebcGenerated => state.container = false,
375                S::BindingsGenerated => state.bindings = false,
376                S::NativeExesGenerated => state.native_executables = false,
377            }
378        }
379    }
380    Ok(())
381}