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;
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 )
205 .await
206 .map_err(PackagePublishError::Api)?
207 .ok_or_else(|| anyhow::anyhow!("backend did not return upload url"))?
208 .url;
209
210 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 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}