1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PublishWait {
22 None,
24 Container,
26 NativeExecutables,
28 Bindings,
30 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#[derive(Debug, Clone)]
79pub enum PublishProgress {
80 Building,
81 Uploading { uploaded: u64, total: u64 },
82 Tagging,
83 Waiting(PublishWait),
84}
85
86#[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#[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
128pub 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 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 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 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 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}