1use crate::{error::DeployError, report::Reporter, transport::Transport};
9use anyhow::{anyhow, bail, Context};
10use ignore::WalkBuilder;
11use reqwest::blocking::{Body, Client};
12use reqwest::header::AUTHORIZATION;
13use sha2::{Digest, Sha256};
14use smbcloud_model::artifact_deployment::{
15 ArtifactDeployment, ArtifactFile, CreateArtifactDeployment,
16};
17use smbcloud_network::network::bearer_value;
18use std::{
19 fs::File,
20 io::Read,
21 path::{Path, PathBuf},
22 sync::{
23 atomic::{AtomicUsize, Ordering},
24 Mutex,
25 },
26 time::{Duration, Instant},
27};
28
29pub const MAX_FILES: usize = 20_000;
31pub const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024;
32pub const MAX_TOTAL_BYTES: u64 = 500 * 1024 * 1024;
33const UPLOAD_CONCURRENCY: usize = 4;
34const UPLOAD_ATTEMPTS: u32 = 3;
35const ALWAYS_EXCLUDED_DIRS: [&str; 2] = [".git", ".smb"];
37const IGNORE_FILES: [&str; 2] = [".gitignore", ".smbignore"];
38
39#[derive(Debug, Clone)]
41pub struct ManifestFile {
42 pub entry: ArtifactFile,
43 pub source: PathBuf,
44}
45
46#[derive(Debug, Clone)]
48pub struct ArtifactManifest {
49 pub files: Vec<ManifestFile>,
50}
51
52impl ArtifactManifest {
53 pub fn total_bytes(&self) -> u64 {
54 self.files.iter().map(|f| f.entry.size).sum()
55 }
56
57 pub fn entries(&self) -> Vec<ArtifactFile> {
58 self.files.iter().map(|f| f.entry.clone()).collect()
59 }
60}
61
62pub fn build_manifest(root: &Path) -> Result<ArtifactManifest, DeployError> {
67 if !root.is_dir() {
68 return Err(anyhow!("{} is not a directory", root.display()).into());
69 }
70 let walker = WalkBuilder::new(root)
71 .hidden(false)
72 .parents(false)
73 .git_global(false)
74 .git_exclude(false)
75 .require_git(false)
76 .follow_links(false)
77 .add_custom_ignore_filename(".smbignore")
78 .filter_entry(|entry| {
79 let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
80 !(is_dir
81 && entry.depth() > 0
82 && ALWAYS_EXCLUDED_DIRS
83 .iter()
84 .any(|name| entry.file_name() == *name))
85 })
86 .build();
87
88 let mut files = Vec::new();
89 for result in walker {
90 let entry = result.map_err(|e| anyhow!("walking {}: {e}", root.display()))?;
91 if entry.depth() == 0 {
92 continue;
93 }
94 let file_type = entry
95 .file_type()
96 .ok_or_else(|| anyhow!("cannot read {}", entry.path().display()))?;
97 if file_type.is_dir() {
98 continue;
99 }
100 let relative = entry.path().strip_prefix(root).unwrap_or(entry.path());
101 if file_type.is_symlink() {
102 return Err(anyhow!(
103 "{} is a symlink; symlinks are not published. Remove it or add it to .smbignore",
104 relative.display()
105 )
106 .into());
107 }
108 if !file_type.is_file() {
109 return Err(anyhow!(
110 "{} is not a regular file; add it to .smbignore",
111 relative.display()
112 )
113 .into());
114 }
115 let name = entry.file_name().to_string_lossy();
116 if IGNORE_FILES.iter().any(|f| *f == name) {
117 continue;
118 }
119
120 let path = relative_path(relative)?;
121 let (sha256, size) = hash_file(entry.path())
122 .with_context(|| format!("hashing {}", relative.display()))
123 .map_err(DeployError::Other)?;
124 if size > MAX_FILE_BYTES {
125 return Err(
126 anyhow!("{path} is {size} bytes; the limit is {MAX_FILE_BYTES} per file").into(),
127 );
128 }
129 files.push(ManifestFile {
130 entry: ArtifactFile { path, sha256, size },
131 source: entry.path().to_path_buf(),
132 });
133 }
134
135 files.sort_by(|a, b| a.entry.path.cmp(&b.entry.path));
136 if files.len() > MAX_FILES {
137 return Err(anyhow!("{} files; the limit is {MAX_FILES}", files.len()).into());
138 }
139 let manifest = ArtifactManifest { files };
140 if manifest.total_bytes() > MAX_TOTAL_BYTES {
141 return Err(anyhow!(
142 "{} bytes in total; the limit is {MAX_TOTAL_BYTES}",
143 manifest.total_bytes()
144 )
145 .into());
146 }
147 Ok(manifest)
148}
149
150fn relative_path(relative: &Path) -> Result<String, DeployError> {
151 let mut parts = Vec::new();
152 for component in relative.components() {
153 let part = component
154 .as_os_str()
155 .to_str()
156 .ok_or_else(|| anyhow!("{} is not valid UTF-8", relative.display()))?;
157 if part.contains('\\') || part.chars().any(|c| c.is_control()) {
158 return Err(anyhow!(
159 "{} has a backslash or control character in its name",
160 relative.display()
161 )
162 .into());
163 }
164 parts.push(part);
165 }
166 Ok(parts.join("/"))
167}
168
169fn hash_file(path: &Path) -> anyhow::Result<(String, u64)> {
170 let mut file = File::open(path)?;
171 let mut hasher = Sha256::new();
172 let mut buffer = vec![0u8; 64 * 1024];
173 let mut size = 0u64;
174 loop {
175 let read = file.read(&mut buffer)?;
176 if read == 0 {
177 break;
178 }
179 hasher.update(&buffer[..read]);
180 size += read as u64;
181 }
182 let hex = hasher
183 .finalize()
184 .iter()
185 .map(|b| format!("{b:02x}"))
186 .collect();
187 Ok((hex, size))
188}
189
190pub struct HttpsArtifactTransport {
195 api_base: String,
196 app_id: String,
197 token: String,
198 commit_sha: Option<String>,
199 poll_interval: Duration,
200 publish_timeout: Duration,
201 retry_delay: Duration,
202 deployment: Mutex<Option<ArtifactDeployment>>,
203}
204
205impl HttpsArtifactTransport {
206 pub fn new(
208 api_base: String,
209 app_id: String,
210 token: String,
211 commit_sha: Option<String>,
212 ) -> Self {
213 Self {
214 api_base: api_base.trim_end_matches('/').to_string(),
215 app_id,
216 token,
217 commit_sha,
218 poll_interval: Duration::from_secs(2),
219 publish_timeout: Duration::from_secs(600),
220 retry_delay: Duration::from_millis(500),
221 deployment: Mutex::new(None),
222 }
223 }
224
225 pub fn with_timing(
227 mut self,
228 poll: Duration,
229 publish_timeout: Duration,
230 retry: Duration,
231 ) -> Self {
232 self.poll_interval = poll;
233 self.publish_timeout = publish_timeout;
234 self.retry_delay = retry;
235 self
236 }
237
238 pub fn deployment(&self) -> Option<ArtifactDeployment> {
240 self.deployment.lock().unwrap().clone()
241 }
242
243 fn url(&self, tail: &str) -> String {
244 format!(
245 "{}/v1/apps/{}/artifact_deployments{tail}",
246 self.api_base, self.app_id
247 )
248 }
249
250 fn create(
251 &self,
252 http: &Client,
253 manifest: &ArtifactManifest,
254 ) -> anyhow::Result<ArtifactDeployment> {
255 let body = CreateArtifactDeployment {
256 manifest: manifest.entries(),
257 commit_sha: self.commit_sha.clone(),
258 };
259 let response = http
260 .post(self.url(""))
261 .header(AUTHORIZATION, bearer_value(&self.token))
262 .json(&body)
263 .send()
264 .context("creating the deployment")?;
265 read_json(response, "creating the deployment")
266 }
267
268 fn upload(
269 &self,
270 http: &Client,
271 id: &str,
272 sha256: &str,
273 source: &Path,
274 size: u64,
275 ) -> anyhow::Result<()> {
276 let mut last = anyhow!("upload never attempted");
277 for attempt in 1..=UPLOAD_ATTEMPTS {
278 let file =
279 File::open(source).with_context(|| format!("opening {}", source.display()))?;
280 let result = http
281 .put(self.url(&format!("/{id}/blobs/{sha256}")))
282 .header(AUTHORIZATION, bearer_value(&self.token))
283 .header("Content-Type", "application/octet-stream")
284 .body(Body::sized(file, size))
285 .send();
286 match result {
287 Ok(r) if r.status().is_success() => return Ok(()),
288 Ok(r) => {
289 let status = r.status();
290 let detail = r.text().unwrap_or_default();
291 last = anyhow!("upload of {sha256} failed with {status}: {detail}");
292 if status.is_client_error() && status.as_u16() != 429 {
294 return Err(last);
295 }
296 }
297 Err(e) => last = anyhow!("upload of {sha256} failed: {e}"),
298 }
299 if attempt < UPLOAD_ATTEMPTS {
300 std::thread::sleep(self.retry_delay * attempt);
301 }
302 }
303 Err(last)
304 }
305
306 fn upload_missing(
307 &self,
308 http: &Client,
309 id: &str,
310 manifest: &ArtifactManifest,
311 missing: &[String],
312 reporter: &dyn Reporter,
313 ) -> anyhow::Result<()> {
314 let mut jobs: Vec<&ManifestFile> = Vec::new();
316 for sha in missing {
317 if let Some(file) = manifest.files.iter().find(|f| &f.entry.sha256 == sha) {
318 jobs.push(file);
319 }
320 }
321 let next = AtomicUsize::new(0);
322 let failure: Mutex<Option<anyhow::Error>> = Mutex::new(None);
323 std::thread::scope(|scope| {
324 for _ in 0..UPLOAD_CONCURRENCY.min(jobs.len()) {
325 scope.spawn(|| loop {
326 if failure.lock().unwrap().is_some() {
327 return;
328 }
329 let index = next.fetch_add(1, Ordering::SeqCst);
330 let Some(file) = jobs.get(index) else { return };
331 match self.upload(http, id, &file.entry.sha256, &file.source, file.entry.size) {
332 Ok(()) => reporter.info(&format!("uploaded {}", file.entry.path)),
333 Err(e) => {
334 failure.lock().unwrap().get_or_insert(e);
335 return;
336 }
337 }
338 });
339 }
340 });
341 match failure.into_inner().unwrap() {
342 Some(e) => Err(e),
343 None => Ok(()),
344 }
345 }
346
347 fn finalize(&self, http: &Client, id: &str) -> anyhow::Result<ArtifactDeployment> {
348 let response = http
349 .post(self.url(&format!("/{id}/finalize")))
350 .header(AUTHORIZATION, bearer_value(&self.token))
351 .send()
352 .context("finalizing the deployment")?;
353 read_json(response, "finalizing the deployment")
354 }
355
356 fn wait_until_published(&self, http: &Client, id: &str) -> anyhow::Result<ArtifactDeployment> {
357 let started = Instant::now();
358 loop {
359 let response = http
360 .get(self.url(&format!("/{id}")))
361 .header(AUTHORIZATION, bearer_value(&self.token))
362 .send()
363 .context("reading the deployment status")?;
364 let deployment: ArtifactDeployment =
365 read_json(response, "reading the deployment status")?;
366 if deployment.is_terminal() {
367 return Ok(deployment);
368 }
369 if started.elapsed() > self.publish_timeout {
370 bail!(
371 "the deployment is still {} after {}s",
372 deployment.status,
373 self.publish_timeout.as_secs()
374 );
375 }
376 std::thread::sleep(self.poll_interval);
377 }
378 }
379}
380
381fn read_json<T: serde::de::DeserializeOwned>(
382 response: reqwest::blocking::Response,
383 what: &str,
384) -> anyhow::Result<T> {
385 let status = response.status();
386 if !status.is_success() {
387 let body = response.text().unwrap_or_default();
388 bail!("{what} was refused ({status}): {body}");
389 }
390 response
391 .json()
392 .with_context(|| format!("parsing the response from {what}"))
393}
394
395impl Transport for HttpsArtifactTransport {
396 fn ship(&self, source: &Path, reporter: &dyn Reporter) -> Result<(), DeployError> {
397 let http = Client::builder()
398 .timeout(Duration::from_secs(300))
399 .build()
400 .map_err(|e| anyhow!("building the HTTP client: {e}"))?;
401
402 reporter.step_start(&format!("Hashing {}", source.display()));
403 let manifest = build_manifest(source)?;
404 reporter.step_done(&format!(
405 "{} files, {} bytes",
406 manifest.files.len(),
407 manifest.total_bytes()
408 ));
409
410 let created = self.create(&http, &manifest)?;
411 *self.deployment.lock().unwrap() = Some(created.clone());
412 reporter.step_start(&format!("Uploading {} new blob(s)", created.missing.len()));
413 self.upload_missing(&http, &created.id, &manifest, &created.missing, reporter)?;
414 reporter.step_done("Upload complete");
415
416 let queued = self.finalize(&http, &created.id)?;
417 *self.deployment.lock().unwrap() = Some(queued);
418 reporter.step_start("Publishing");
419 let finished = self.wait_until_published(&http, &created.id)?;
420 *self.deployment.lock().unwrap() = Some(finished.clone());
421 match finished.status.as_str() {
422 "live" => {
423 reporter.step_done("Live");
424 Ok(())
425 }
426 other => {
427 let reason = finished.error.unwrap_or_else(|| other.to_string());
428 reporter.step_fail(&reason);
429 Err(anyhow!("the deployment ended as {other}: {reason}").into())
430 }
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::report::NoopReporter;
439 use std::fs;
440 use wiremock::{
441 matchers::{header, method, path, path_regex},
442 Mock, MockServer, Request, Respond, ResponseTemplate,
443 };
444
445 fn write(dir: &Path, rel: &str, data: &str) {
446 let target = dir.join(rel);
447 fs::create_dir_all(target.parent().unwrap()).unwrap();
448 fs::write(target, data).unwrap();
449 }
450
451 fn paths(manifest: &ArtifactManifest) -> Vec<String> {
452 manifest
453 .files
454 .iter()
455 .map(|f| f.entry.path.clone())
456 .collect()
457 }
458
459 #[test]
460 fn honours_ignore_files_and_always_skips_git_and_smb() {
461 let dir = tempfile::tempdir().unwrap();
462 write(dir.path(), "index.html", "hi");
463 write(dir.path(), "assets/app.js", "js");
464 write(dir.path(), ".well-known/security.txt", "x");
465 write(dir.path(), "secret.env", "s");
466 write(dir.path(), "notes/DEPLOY.md", "d");
467 write(dir.path(), ".git/config", "g");
468 write(dir.path(), ".smb/config.toml", "c");
469 write(dir.path(), "sub/.git/HEAD", "g");
470 write(dir.path(), ".gitignore", "secret.env\n");
471 write(dir.path(), ".smbignore", "notes/\n");
472
473 let manifest = build_manifest(dir.path()).unwrap();
474
475 assert_eq!(
476 paths(&manifest),
477 [".well-known/security.txt", "assets/app.js", "index.html"]
478 );
479 }
480
481 #[test]
482 fn parent_gitignore_does_not_hide_the_output_dir() {
483 let repo = tempfile::tempdir().unwrap();
484 write(repo.path(), ".gitignore", "dist/\n");
485 write(repo.path(), "dist/index.html", "hi");
486
487 let manifest = build_manifest(&repo.path().join("dist")).unwrap();
488
489 assert_eq!(paths(&manifest), ["index.html"]);
490 }
491
492 #[cfg(unix)]
493 #[test]
494 fn refuses_symlinks() {
495 let dir = tempfile::tempdir().unwrap();
496 write(dir.path(), "index.html", "hi");
497 std::os::unix::fs::symlink("/etc/passwd", dir.path().join("link")).unwrap();
498
499 let error = build_manifest(dir.path()).unwrap_err().to_string();
500
501 assert!(error.contains("symlink"), "{error}");
502 }
503
504 #[test]
505 fn hashes_are_stable_and_correct() {
506 let dir = tempfile::tempdir().unwrap();
507 write(dir.path(), "a.txt", "hello");
508
509 let first = build_manifest(dir.path()).unwrap();
510 let second = build_manifest(dir.path()).unwrap();
511
512 assert_eq!(
513 first.files[0].entry.sha256,
514 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
515 );
516 assert_eq!(first.files[0].entry, second.files[0].entry);
517 assert_eq!(first.total_bytes(), 5);
518 }
519
520 struct Statuses(Mutex<Vec<&'static str>>);
521
522 impl Respond for Statuses {
523 fn respond(&self, _: &Request) -> ResponseTemplate {
524 let mut list = self.0.lock().unwrap();
525 let status = if list.len() > 1 {
526 list.remove(0)
527 } else {
528 list[0]
529 };
530 ResponseTemplate::new(200).set_body_json(serde_json::json!({
531 "id": "d1", "status": status, "error": if status == "failed" { Some("disk full") } else { None },
532 "url": "https://site.example"
533 }))
534 }
535 }
536
537 fn transport(server: &MockServer) -> HttpsArtifactTransport {
538 HttpsArtifactTransport::new(
539 server.uri(),
540 "app1".into(),
541 "smb_deploy_x".into(),
542 Some("abc1234".into()),
543 )
544 .with_timing(
545 Duration::from_millis(5),
546 Duration::from_secs(5),
547 Duration::from_millis(1),
548 )
549 }
550
551 async fn ship(
552 transport: HttpsArtifactTransport,
553 dir: PathBuf,
554 ) -> (HttpsArtifactTransport, Result<(), DeployError>) {
555 tokio::task::spawn_blocking(move || {
556 let result = transport.ship(&dir, &NoopReporter);
557 (transport, result)
558 })
559 .await
560 .unwrap()
561 }
562
563 #[tokio::test(flavor = "multi_thread")]
564 async fn uploads_only_the_missing_blobs_then_waits_for_live() {
565 let dir = tempfile::tempdir().unwrap();
566 write(dir.path(), "a.txt", "hello");
567 write(dir.path(), "b.txt", "world");
568 let known = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
569 let new = "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7";
570
571 let server = MockServer::start().await;
572 Mock::given(method("POST"))
573 .and(path("/v1/apps/app1/artifact_deployments"))
574 .and(header("authorization", "Bearer smb_deploy_x"))
575 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
576 "id": "d1", "status": "uploading", "missing": [new]
577 })))
578 .expect(1)
579 .mount(&server)
580 .await;
581 Mock::given(method("PUT"))
582 .and(path(format!(
583 "/v1/apps/app1/artifact_deployments/d1/blobs/{new}"
584 )))
585 .respond_with(ResponseTemplate::new(204))
586 .expect(1)
587 .mount(&server)
588 .await;
589 Mock::given(method("PUT"))
590 .and(path(format!(
591 "/v1/apps/app1/artifact_deployments/d1/blobs/{known}"
592 )))
593 .respond_with(ResponseTemplate::new(204))
594 .expect(0)
595 .mount(&server)
596 .await;
597 Mock::given(method("POST"))
598 .and(path("/v1/apps/app1/artifact_deployments/d1/finalize"))
599 .respond_with(
600 ResponseTemplate::new(202)
601 .set_body_json(serde_json::json!({"id":"d1","status":"queued"})),
602 )
603 .expect(1)
604 .mount(&server)
605 .await;
606 Mock::given(method("GET"))
607 .and(path("/v1/apps/app1/artifact_deployments/d1"))
608 .respond_with(Statuses(Mutex::new(vec!["queued", "publishing", "live"])))
609 .mount(&server)
610 .await;
611
612 let (transport, result) = ship(transport(&server), dir.path().to_path_buf()).await;
613
614 result.unwrap();
615 assert_eq!(
616 transport.deployment().unwrap().url.as_deref(),
617 Some("https://site.example")
618 );
619 }
620
621 #[tokio::test(flavor = "multi_thread")]
622 async fn retries_a_flaky_upload() {
623 let dir = tempfile::tempdir().unwrap();
624 write(dir.path(), "a.txt", "hello");
625 let sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
626 let server = MockServer::start().await;
627 Mock::given(method("POST"))
628 .and(path("/v1/apps/app1/artifact_deployments"))
629 .respond_with(
630 ResponseTemplate::new(201).set_body_json(
631 serde_json::json!({"id":"d1","status":"uploading","missing":[sha]}),
632 ),
633 )
634 .mount(&server)
635 .await;
636 Mock::given(method("PUT"))
637 .and(path_regex("/blobs/"))
638 .respond_with(ResponseTemplate::new(503))
639 .up_to_n_times(2)
640 .with_priority(1)
641 .mount(&server)
642 .await;
643 Mock::given(method("PUT"))
644 .and(path_regex("/blobs/"))
645 .respond_with(ResponseTemplate::new(204))
646 .with_priority(2)
647 .expect(1)
648 .mount(&server)
649 .await;
650 Mock::given(method("POST"))
651 .and(path("/v1/apps/app1/artifact_deployments/d1/finalize"))
652 .respond_with(
653 ResponseTemplate::new(202)
654 .set_body_json(serde_json::json!({"id":"d1","status":"queued"})),
655 )
656 .mount(&server)
657 .await;
658 Mock::given(method("GET"))
659 .and(path("/v1/apps/app1/artifact_deployments/d1"))
660 .respond_with(Statuses(Mutex::new(vec!["live"])))
661 .mount(&server)
662 .await;
663
664 let (_, result) = ship(transport(&server), dir.path().to_path_buf()).await;
665
666 result.unwrap();
667 }
668
669 #[tokio::test(flavor = "multi_thread")]
670 async fn a_stored_login_header_still_sends_one_bearer() {
671 let dir = tempfile::tempdir().unwrap();
672 write(dir.path(), "a.txt", "hello");
673 let server = MockServer::start().await;
674 Mock::given(method("POST"))
675 .and(path("/v1/apps/app1/artifact_deployments"))
676 .and(header("authorization", "Bearer jwt.value"))
677 .respond_with(
678 ResponseTemplate::new(201).set_body_json(
679 serde_json::json!({"id":"d1","status":"uploading","missing":[]}),
680 ),
681 )
682 .expect(1)
683 .mount(&server)
684 .await;
685 Mock::given(method("POST"))
686 .and(path("/v1/apps/app1/artifact_deployments/d1/finalize"))
687 .and(header("authorization", "Bearer jwt.value"))
688 .respond_with(
689 ResponseTemplate::new(202)
690 .set_body_json(serde_json::json!({"id":"d1","status":"queued"})),
691 )
692 .expect(1)
693 .mount(&server)
694 .await;
695 Mock::given(method("GET"))
696 .and(path("/v1/apps/app1/artifact_deployments/d1"))
697 .and(header("authorization", "Bearer jwt.value"))
698 .respond_with(Statuses(Mutex::new(vec!["live"])))
699 .mount(&server)
700 .await;
701
702 let login = HttpsArtifactTransport::new(
703 server.uri(),
704 "app1".into(),
705 "Bearer jwt.value".into(),
706 None,
707 )
708 .with_timing(
709 Duration::from_millis(5),
710 Duration::from_secs(5),
711 Duration::from_millis(1),
712 );
713 let (_, result) = ship(login, dir.path().to_path_buf()).await;
714
715 result.unwrap();
716 }
717
718 #[tokio::test(flavor = "multi_thread")]
719 async fn a_failed_status_surfaces_the_server_error() {
720 let dir = tempfile::tempdir().unwrap();
721 write(dir.path(), "a.txt", "hello");
722 let server = MockServer::start().await;
723 Mock::given(method("POST"))
724 .and(path("/v1/apps/app1/artifact_deployments"))
725 .respond_with(
726 ResponseTemplate::new(201).set_body_json(
727 serde_json::json!({"id":"d1","status":"uploading","missing":[]}),
728 ),
729 )
730 .mount(&server)
731 .await;
732 Mock::given(method("POST"))
733 .and(path("/v1/apps/app1/artifact_deployments/d1/finalize"))
734 .respond_with(
735 ResponseTemplate::new(202)
736 .set_body_json(serde_json::json!({"id":"d1","status":"queued"})),
737 )
738 .mount(&server)
739 .await;
740 Mock::given(method("GET"))
741 .and(path("/v1/apps/app1/artifact_deployments/d1"))
742 .respond_with(Statuses(Mutex::new(vec!["publishing", "failed"])))
743 .mount(&server)
744 .await;
745
746 let (_, result) = ship(transport(&server), dir.path().to_path_buf()).await;
747
748 let error = result.unwrap_err().to_string();
749 assert!(error.contains("disk full"), "{error}");
750 }
751
752 #[tokio::test(flavor = "multi_thread")]
753 async fn a_refused_create_is_an_error_and_uploads_nothing() {
754 let dir = tempfile::tempdir().unwrap();
755 write(dir.path(), "a.txt", "hello");
756 let server = MockServer::start().await;
757 Mock::given(method("POST"))
758 .and(path("/v1/apps/app1/artifact_deployments"))
759 .respond_with(ResponseTemplate::new(401))
760 .mount(&server)
761 .await;
762
763 let (_, result) = ship(transport(&server), dir.path().to_path_buf()).await;
764
765 assert!(result.unwrap_err().to_string().contains("401"));
766 }
767}