1use crate::github::types::{
2 Artifact, ArtifactsResponse, Release, ReleaseAsset, Repository, WorkflowRunsResponse,
3};
4use crate::github::{GithubClient, OtaProgress};
5use crate::http::ChunkedDownloadError;
6use crate::version::GitVersion;
7use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
8use std::fs::File;
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use zip::ZipArchive;
12
13#[cfg(all(not(test), not(feature = "emulator")))]
14use crate::settings::INTERNAL_CARD_ROOT;
15
16pub struct OtaClient {
22 github: GithubClient,
23 tmp_dir: PathBuf,
24}
25
26#[derive(Debug, Clone)]
28pub enum ArtifactSource {
29 PullRequest(u32),
31 DefaultBranch,
33 WorkflowRun(String),
35 ReleaseAsset(String),
37}
38
39impl std::fmt::Display for ArtifactSource {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 ArtifactSource::PullRequest(pr) => write!(f, "No build artifacts found for PR #{}", pr),
43 ArtifactSource::DefaultBranch => {
44 write!(f, "No build artifacts found for default branch")
45 }
46 ArtifactSource::WorkflowRun(pattern) => {
47 write!(
48 f,
49 "No artifact matching '{}' found in workflow run",
50 pattern
51 )
52 }
53 ArtifactSource::ReleaseAsset(name) => write!(f, "No release asset '{}' found", name),
54 }
55 }
56}
57
58#[derive(thiserror::Error, Debug)]
60pub enum OtaError {
61 #[error("GitHub API error: {0}")]
63 Api(String),
64
65 #[error("HTTP request error: {0}")]
67 Request(#[from] reqwest::Error),
68
69 #[error("PR #{0} not found")]
71 PrNotFound(u32),
72
73 #[error("{0}")]
75 ArtifactsNotFound(ArtifactSource),
76
77 #[error("GitHub token not configured")]
79 NoToken,
80
81 #[error("GitHub token is invalid or revoked")]
83 Unauthorized,
84
85 #[error(transparent)]
90 InsufficientScopes(#[from] crate::github::ScopeError),
91
92 #[error("Insufficient disk space: need 100MB, have {0}MB")]
94 InsufficientSpace(u64),
95
96 #[error("I/O error: {0}")]
98 Io(#[from] std::io::Error),
99
100 #[error("System error: {0}")]
102 Nix(#[from] nix::errno::Errno),
103
104 #[error("TLS configuration error: {0}")]
106 TlsConfig(String),
107
108 #[error("ZIP extraction error: {0}")]
110 ZipError(#[from] zip::result::ZipError),
111
112 #[error("Deployment error: {0}")]
114 DeploymentError(String),
115
116 #[error(transparent)]
118 VersionParse(#[from] crate::version::VersionError),
119}
120
121impl From<ChunkedDownloadError> for OtaError {
122 fn from(e: ChunkedDownloadError) -> Self {
123 match e {
124 ChunkedDownloadError::Request(r) if r.status().is_some() => api_error(r),
125 ChunkedDownloadError::Request(r) => OtaError::Request(r),
126 ChunkedDownloadError::Io(e) => OtaError::Io(e),
127 }
128 }
129}
130
131impl OtaClient {
132 pub fn new(github: GithubClient, tmp_dir: PathBuf) -> Self {
138 Self { github, tmp_dir }
139 }
140
141 pub fn download_pr_artifact<F>(
171 &self,
172 pr_number: u32,
173 mut progress_callback: F,
174 ) -> Result<PathBuf, OtaError>
175 where
176 F: FnMut(OtaProgress),
177 {
178 check_disk_space(&self.tmp_dir)?;
179 verify_scopes(&self.github)?;
180
181 progress_callback(OtaProgress::CheckingPr);
182 tracing::info!(pr_number, "Starting PR build download");
183 tracing::debug!(pr_number, "Checking PR");
184
185 let pr_url = format!(
186 "https://api.github.com/repos/ogkevin/cadmus/pulls/{}",
187 pr_number
188 );
189 tracing::debug!(url = %pr_url, "Fetching PR");
190
191 let response = self
192 .github
193 .get(&pr_url)
194 .send()?
195 .error_for_status()
196 .map_err(|e| {
197 tracing::error!(pr_number, status = ?e.status(), error = %e, "PR fetch failed");
198 if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
199 OtaError::Unauthorized
200 } else {
201 OtaError::PrNotFound(pr_number)
202 }
203 })?;
204
205 let pr: crate::github::types::PullRequest = response.json()?;
206 tracing::debug!("Successfully parsed PR response");
207 let head_sha = pr.head.sha;
208 tracing::debug!(pr_number, head_sha = %head_sha, "Retrieved PR head SHA");
209
210 progress_callback(OtaProgress::FindingWorkflow);
211 tracing::debug!(head_sha = %head_sha, "Finding workflow runs");
212
213 let runs_url = format!(
214 "https://api.github.com/repos/ogkevin/cadmus/actions/runs?head_sha={}&event=pull_request",
215 head_sha
216 );
217 tracing::debug!(url = %runs_url, "Fetching workflow runs");
218
219 let runs: WorkflowRunsResponse = self
220 .github
221 .get(&runs_url)
222 .send()?
223 .error_for_status()
224 .map_err(|e| {
225 tracing::error!(head_sha = %head_sha, status = ?e.status(), error = %e, "Workflow runs fetch failed");
226 api_error(e)
227 })?
228 .json()?;
229
230 tracing::debug!(count = runs.workflow_runs.len(), "Found workflow runs");
231
232 #[cfg(feature = "tracing")]
233 if tracing::enabled!(tracing::Level::DEBUG) {
234 for (idx, run) in runs.workflow_runs.iter().enumerate() {
235 tracing::debug!(
236 index = idx,
237 name = %run.name,
238 id = run.id,
239 "Workflow run"
240 );
241 }
242 }
243
244 let run = runs
245 .workflow_runs
246 .iter()
247 .find(|r| r.name == "Cargo")
248 .ok_or_else(|| {
249 tracing::error!(pr_number, "No Cargo workflow run found");
250 OtaError::ArtifactsNotFound(ArtifactSource::PullRequest(pr_number))
251 })?;
252
253 tracing::debug!(run_id = run.id, "Found Cargo workflow run");
254
255 #[cfg(feature = "test")]
256 let artifact_name_pattern = format!("cadmus-kobo-test-pr{}", pr_number);
257 #[cfg(not(feature = "test"))]
258 let artifact_name_pattern = format!("cadmus-kobo-pr{}", pr_number);
259
260 let artifact = self
261 .find_artifact_in_run(run.id, &artifact_name_pattern)
262 .map_err(|e| match e {
263 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(_)) => {
264 OtaError::ArtifactsNotFound(ArtifactSource::PullRequest(pr_number))
265 }
266 other => other,
267 })?;
268
269 tracing::debug!(
270 name = %artifact.name,
271 id = artifact.id,
272 size_bytes = artifact.size_in_bytes,
273 "Found artifact"
274 );
275
276 let download_path = self.tmp_dir.join(format!("cadmus-ota-{}.zip", pr_number));
277
278 self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
279
280 progress_callback(OtaProgress::Complete {
281 path: download_path.clone(),
282 });
283
284 tracing::info!(pr_number, "PR build download completed");
285 Ok(download_path)
286 }
287
288 pub fn download_default_branch_artifact<F>(
315 &self,
316 mut progress_callback: F,
317 ) -> Result<PathBuf, OtaError>
318 where
319 F: FnMut(OtaProgress),
320 {
321 check_disk_space(&self.tmp_dir)?;
322 verify_scopes(&self.github)?;
323
324 progress_callback(OtaProgress::FindingLatestBuild);
325 tracing::info!("Starting main branch build download");
326 tracing::debug!("Finding latest default branch build");
327
328 let default_branch = self.fetch_default_branch()?;
329
330 let encoded_branch = utf8_percent_encode(&default_branch, NON_ALPHANUMERIC);
331 let runs_url = format!(
332 "https://api.github.com/repos/ogkevin/cadmus/actions/workflows/cargo.yml/runs?branch={}&event=push&status=success&per_page=1",
333 encoded_branch
334 );
335 tracing::debug!(url = %runs_url, "Fetching Cargo workflow runs on default branch");
336
337 let runs: WorkflowRunsResponse = self
338 .github
339 .get(&runs_url)
340 .send()?
341 .error_for_status()
342 .map_err(|e| {
343 tracing::error!(status = ?e.status(), error = %e, "Cargo workflow runs fetch failed");
344 api_error(e)
345 })?
346 .json()?;
347
348 let cargo_run = runs.workflow_runs.first().ok_or_else(|| {
349 tracing::error!("No successful Cargo workflow run found on default branch");
350 OtaError::ArtifactsNotFound(ArtifactSource::DefaultBranch)
351 })?;
352
353 tracing::debug!(run_id = cargo_run.id, "Found Cargo workflow run");
354
355 let head_sha = cargo_run.head_sha.as_deref().ok_or_else(|| {
356 tracing::error!(run_id = cargo_run.id, "Workflow run missing head_sha");
357 OtaError::Api(format!("Workflow run {} missing head_sha", cargo_run.id))
358 })?;
359 let short_sha = &head_sha[..7.min(head_sha.len())];
360
361 #[cfg(feature = "test")]
362 let artifact_name_prefix = format!("cadmus-kobo-test-{}", short_sha);
363 #[cfg(not(feature = "test"))]
364 let artifact_name_prefix = format!("cadmus-kobo-{}", short_sha);
365
366 tracing::debug!(pattern = %artifact_name_prefix, "Looking for artifact");
367
368 progress_callback(OtaProgress::FindingWorkflow);
369
370 let artifact = self
371 .find_artifact_in_run(cargo_run.id, &artifact_name_prefix)
372 .map_err(|e| match e {
373 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(pattern)) => {
374 tracing::error!(pattern = %pattern, "No matching artifact found on default branch");
375 OtaError::ArtifactsNotFound(ArtifactSource::DefaultBranch)
376 }
377 other => other,
378 })?;
379
380 tracing::debug!(
381 name = %artifact.name,
382 id = artifact.id,
383 size_bytes = artifact.size_in_bytes,
384 "Found default branch artifact"
385 );
386
387 let download_path = self.tmp_dir.join(format!("cadmus-ota-{}.zip", short_sha));
388
389 self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
390
391 progress_callback(OtaProgress::Complete {
392 path: download_path.clone(),
393 });
394
395 tracing::info!(sha = %short_sha, "Main branch build download completed");
396 Ok(download_path)
397 }
398
399 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
426 pub fn download_stable_release_artifact<F>(
427 &self,
428 mut progress_callback: F,
429 ) -> Result<PathBuf, OtaError>
430 where
431 F: FnMut(OtaProgress),
432 {
433 check_disk_space(&self.tmp_dir)?;
434
435 progress_callback(OtaProgress::FindingLatestBuild);
436 tracing::info!("Starting stable release download");
437 tracing::debug!("Finding latest stable release");
438
439 let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
440 tracing::debug!(url = %releases_url, "Fetching latest release");
441
442 let release: Release = self
443 .github
444 .get_unauthenticated(releases_url)
445 .send()?
446 .error_for_status()
447 .map_err(|e| {
448 tracing::error!(status = ?e.status(), error = %e, "Latest release fetch failed");
449 api_error(e)
450 })?
451 .json()?;
452
453 tracing::debug!(asset_count = release.assets.len(), "Found release assets");
454
455 #[cfg(feature = "tracing")]
456 for (idx, asset) in release.assets.iter().enumerate() {
457 tracing::debug!(
458 index = idx,
459 name = %asset.name,
460 size_bytes = asset.size,
461 "Asset"
462 );
463 }
464
465 let asset_name = "KoboRoot.tgz";
466
467 let asset = release
468 .assets
469 .iter()
470 .find(|a| a.name == asset_name)
471 .ok_or_else(|| {
472 tracing::error!(
473 target_asset = asset_name,
474 "Asset not found in latest release"
475 );
476 OtaError::ArtifactsNotFound(ArtifactSource::ReleaseAsset(asset_name.to_owned()))
477 })?;
478
479 tracing::debug!(
480 name = %asset.name,
481 url = %asset.browser_download_url,
482 size_bytes = asset.size,
483 "Found release asset"
484 );
485
486 let download_path = self.tmp_dir.join("cadmus-ota-stable-release.tgz");
487
488 self.download_release_asset(asset, &download_path, &mut progress_callback)?;
489
490 progress_callback(OtaProgress::Complete {
491 path: download_path.clone(),
492 });
493
494 tracing::info!("Stable release download completed");
495 Ok(download_path)
496 }
497
498 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
527 pub fn fetch_latest_release_version(&self) -> Result<GitVersion, OtaError> {
528 let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
529 tracing::debug!(url = %releases_url, "Fetching latest release version");
530
531 let release: Release = self
532 .github
533 .get_unauthenticated(releases_url)
534 .send()?
535 .error_for_status()
536 .map_err(|e| {
537 tracing::error!(status = ?e.status(), error = %e, "Latest release fetch failed");
538 api_error(e)
539 })?
540 .json()?;
541
542 tracing::info!(version = %release.tag_name, "Fetched latest release version");
543
544 let version: GitVersion = release.tag_name.parse()?;
545 Ok(version)
546 }
547
548 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
567 pub fn deploy(&self, kobo_root_path: PathBuf) -> Result<PathBuf, OtaError> {
568 tracing::info!(path = ?kobo_root_path, "Deploying KoboRoot.tgz");
569
570 let deploy_path = self.deploy_path();
571 self.ensure_deploy_dir(&deploy_path)?;
572
573 let mut src = File::open(&kobo_root_path)?;
574 let mut dst = File::create(&deploy_path)?;
575 let bytes_copied = std::io::copy(&mut src, &mut dst)?;
576
577 tracing::debug!(
578 bytes = bytes_copied,
579 src = ?kobo_root_path,
580 dst = ?deploy_path,
581 "Streamed KoboRoot.tgz to deploy path"
582 );
583
584 if kobo_root_path != deploy_path {
585 if let Err(e) = std::fs::remove_file(&kobo_root_path) {
586 tracing::error!(path = ?kobo_root_path, error = %e, "Failed to remove source file");
587 }
588 }
589
590 tracing::info!(path = ?deploy_path, "Update deployed successfully");
591 Ok(deploy_path)
592 }
593
594 fn deploy_path(&self) -> PathBuf {
602 #[cfg(test)]
603 let path = std::env::temp_dir()
604 .join("test-kobo-deployment")
605 .join("KoboRoot.tgz");
606
607 #[cfg(all(feature = "emulator", not(test)))]
608 let path = PathBuf::from("/tmp/.kobo/KoboRoot.tgz");
609
610 #[cfg(all(not(feature = "emulator"), not(test)))]
611 let path = PathBuf::from(format!("{}/.kobo/KoboRoot.tgz", INTERNAL_CARD_ROOT));
612
613 tracing::debug!(path = ?path, "Deploy destination");
614 path
615 }
616
617 fn ensure_deploy_dir(&self, deploy_path: &Path) -> Result<(), OtaError> {
618 #[cfg(any(test, feature = "emulator"))]
619 {
620 if let Some(parent) = deploy_path.parent() {
621 tracing::debug!(directory = ?parent, "Creating parent directory");
622 std::fs::create_dir_all(parent)?;
623 }
624 }
625
626 let _ = deploy_path;
627 Ok(())
628 }
629
630 fn deploy_bytes(&self, data: &[u8]) -> Result<PathBuf, OtaError> {
631 let deploy_path = self.deploy_path();
632 self.ensure_deploy_dir(&deploy_path)?;
633
634 tracing::debug!(bytes = data.len(), path = ?deploy_path, "Writing file");
635 let mut file = File::create(&deploy_path)?;
636 file.write_all(data)?;
637
638 tracing::debug!(path = ?deploy_path, "Deployment complete");
639 tracing::info!(path = ?deploy_path, "Update deployed successfully");
640
641 Ok(deploy_path)
642 }
643
644 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
665 pub fn extract_and_deploy(&self, zip_path: PathBuf) -> Result<PathBuf, OtaError> {
666 tracing::info!(path = ?zip_path, "Extracting and deploying update");
667 tracing::debug!(path = ?zip_path, "Starting extraction");
668
669 let file = File::open(&zip_path)?;
670 let mut archive = ZipArchive::new(file)?;
671
672 tracing::debug!(file_count = archive.len(), "Opened ZIP archive");
673
674 let mut kobo_root_data = Vec::new();
675 let mut found = false;
676
677 #[cfg(not(feature = "test"))]
678 let kobo_root_name = "KoboRoot.tgz";
679 #[cfg(feature = "test")]
680 let kobo_root_name = "KoboRoot-test.tgz";
681
682 tracing::debug!(target_file = kobo_root_name, "Looking for file");
683
684 for i in 0..archive.len() {
685 let mut entry = archive.by_index(i)?;
686 let entry_name = entry.name().to_string();
687
688 tracing::debug!(index = i, name = %entry_name, "Checking entry");
689
690 if entry_name.eq(kobo_root_name) {
691 tracing::debug!(name = %entry_name, "Found target file");
692 entry.read_to_end(&mut kobo_root_data)?;
693 found = true;
694 break;
695 }
696 }
697
698 if !found {
699 tracing::error!(
700 target_file = kobo_root_name,
701 "Target file not found in artifact"
702 );
703 return Err(OtaError::DeploymentError(format!(
704 "{} not found in artifact",
705 kobo_root_name
706 )));
707 }
708
709 tracing::debug!(
710 bytes = kobo_root_data.len(),
711 file = kobo_root_name,
712 "Extracted file"
713 );
714
715 let deploy_path = self.deploy_bytes(&kobo_root_data)?;
716 if let Err(e) = std::fs::remove_file(&zip_path) {
717 tracing::error!(path = ?zip_path, error = %e, "Failed to remove source file");
718 }
719
720 Ok(deploy_path)
721 }
722
723 fn fetch_default_branch(&self) -> Result<String, OtaError> {
725 let repo_url = "https://api.github.com/repos/ogkevin/cadmus";
726 tracing::debug!(url = %repo_url, "Fetching repository metadata");
727
728 let repo: Repository = self
729 .github
730 .get(repo_url)
731 .send()?
732 .error_for_status()
733 .map_err(|e| {
734 tracing::error!(status = ?e.status(), error = %e, "Repository metadata fetch failed");
735 api_error(e)
736 })?
737 .json()?;
738
739 tracing::debug!(default_branch = %repo.default_branch, "Resolved default branch");
740 Ok(repo.default_branch)
741 }
742
743 fn find_artifact_in_run(&self, run_id: u64, name_prefix: &str) -> Result<Artifact, OtaError> {
745 let artifacts_url = format!(
746 "https://api.github.com/repos/ogkevin/cadmus/actions/runs/{}/artifacts",
747 run_id
748 );
749 tracing::debug!(url = %artifacts_url, "Fetching artifacts");
750
751 let artifacts: ArtifactsResponse = self
752 .github
753 .get(&artifacts_url)
754 .send()?
755 .error_for_status()
756 .map_err(|e| {
757 tracing::error!(run_id, status = ?e.status(), error = %e, "Artifacts fetch failed");
758 api_error(e)
759 })?
760 .json()?;
761
762 tracing::debug!(count = artifacts.artifacts.len(), "Found artifacts");
763
764 #[cfg(feature = "tracing")]
765 if tracing::enabled!(tracing::Level::DEBUG) {
766 for (idx, artifact) in artifacts.artifacts.iter().enumerate() {
767 tracing::debug!(
768 index = idx,
769 name = %artifact.name,
770 id = artifact.id,
771 size_bytes = artifact.size_in_bytes,
772 "Artifact"
773 );
774 }
775 }
776
777 tracing::debug!(pattern = %name_prefix, "Looking for artifact");
778
779 artifacts
780 .artifacts
781 .into_iter()
782 .find(|a| a.name.starts_with(name_prefix))
783 .ok_or_else(|| {
784 tracing::error!(run_id, pattern = %name_prefix, "No matching artifact found");
785 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(name_prefix.to_owned()))
786 })
787 }
788
789 fn download_artifact_to_path<F>(
793 &self,
794 artifact: &Artifact,
795 download_path: &PathBuf,
796 progress_callback: &mut F,
797 ) -> Result<(), OtaError>
798 where
799 F: FnMut(OtaProgress),
800 {
801 let download_url = format!(
802 "https://api.github.com/repos/ogkevin/cadmus/actions/artifacts/{}/zip",
803 artifact.id
804 );
805
806 self.github.download(
807 &download_url,
808 artifact.size_in_bytes,
809 download_path,
810 |url| self.github.get(url),
811 &mut |downloaded, total| {
812 progress_callback(OtaProgress::DownloadingArtifact { downloaded, total })
813 },
814 )?;
815 Ok(())
816 }
817
818 #[inline]
823 #[cfg_attr(
824 feature = "tracing",
825 tracing::instrument(skip(self, progress_callback))
826 )]
827 fn download_release_asset<F>(
828 &self,
829 asset: &ReleaseAsset,
830 download_path: &PathBuf,
831 progress_callback: &mut F,
832 ) -> Result<(), OtaError>
833 where
834 F: FnMut(OtaProgress),
835 {
836 self.github.download(
837 &asset.browser_download_url,
838 asset.size,
839 download_path,
840 |url| self.github.get_unauthenticated(url),
841 &mut |downloaded, total| {
842 progress_callback(OtaProgress::DownloadingArtifact { downloaded, total })
843 },
844 )?;
845 Ok(())
846 }
847}
848
849fn verify_scopes(github: &crate::github::GithubClient) -> Result<(), OtaError> {
859 github.verify_token_scopes().map_err(|e| match e {
860 crate::github::VerifyScopesError::Request(e) => api_error(e),
861 crate::github::VerifyScopesError::InsufficientScopes(e) => OtaError::InsufficientScopes(e),
862 })
863}
864
865fn api_error(e: reqwest::Error) -> OtaError {
871 if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
872 tracing::warn!("GitHub API returned 401 — token invalid or revoked");
873 OtaError::Unauthorized
874 } else {
875 OtaError::Api(e.to_string())
876 }
877}
878
879fn check_disk_space(path: &Path) -> Result<(), OtaError> {
880 use nix::sys::statvfs::statvfs;
881
882 let stat = statvfs(path)?;
883 let available_mb = (stat.blocks_available() as u64 * stat.block_size() as u64) / (1024 * 1024);
884 tracing::debug!(path = ?path, available_mb, "Checking disk space");
885
886 if available_mb < 100 {
887 tracing::error!(
888 path = ?path,
889 available_mb,
890 required_mb = 100,
891 "Insufficient disk space"
892 );
893 return Err(OtaError::InsufficientSpace(available_mb));
894 }
895 Ok(())
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use crate::github::GithubClient;
902 use secrecy::SecretString;
903
904 fn make_client(tmp_dir: PathBuf) -> OtaClient {
905 crate::crypto::init_crypto_provider();
906 let github =
907 GithubClient::new(Some(SecretString::from("test_token"))).expect("client build");
908 OtaClient::new(github, tmp_dir)
909 }
910
911 #[test]
912 fn test_extract_and_deploy_success() {
913 let temp_dir = tempfile::tempdir().unwrap();
914 let client = make_client(temp_dir.path().to_path_buf());
915 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
916 .join("src/ota/tests/fixtures/test_artifact.zip");
917 let artifact_path = temp_dir.path().join("test_artifact.zip");
918 std::fs::copy(&fixture_path, &artifact_path).unwrap();
919
920 let result = client.extract_and_deploy(artifact_path.clone());
921
922 assert!(
923 result.is_ok(),
924 "Deployment should succeed: {:?}",
925 result.err()
926 );
927
928 let deploy_path = result.unwrap();
929 assert!(
930 deploy_path.exists(),
931 "Deployed file should exist at {:?}",
932 deploy_path
933 );
934
935 let content = std::fs::read_to_string(&deploy_path).unwrap();
936 assert!(
937 content.contains("Mock KoboRoot.tgz"),
938 "Deployed file should contain mock content"
939 );
940
941 std::fs::remove_file(&deploy_path).ok();
942 assert!(
943 !artifact_path.exists(),
944 "Downloaded artifact should be removed after successful deployment"
945 );
946 }
947
948 #[test]
949 fn test_extract_and_deploy_missing_koboroot() {
950 let temp_dir = tempfile::tempdir().unwrap();
951 let client = make_client(temp_dir.path().to_path_buf());
952 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
953 .join("src/ota/tests/fixtures/empty_artifact.zip");
954 let artifact_path = temp_dir.path().join("empty_artifact.zip");
955 std::fs::copy(&fixture_path, &artifact_path).unwrap();
956
957 let result = client.extract_and_deploy(artifact_path.clone());
958 assert!(result.is_err(), "Should fail when KoboRoot.tgz is missing");
959
960 if let Err(OtaError::DeploymentError(msg)) = result {
961 assert!(
962 msg.contains("not found in artifact"),
963 "Error should mention missing file"
964 );
965 } else {
966 panic!("Expected DeploymentError");
967 }
968
969 assert!(
970 artifact_path.exists(),
971 "Source artifact should be retained when deployment fails"
972 );
973 }
974
975 #[test]
976 fn test_check_disk_space_sufficient() {
977 use tempfile::TempDir;
978 let temp_dir = TempDir::new().unwrap();
979 let result = check_disk_space(temp_dir.path());
980 assert!(
981 result.is_ok(),
982 "Should have sufficient disk space in temp directory"
983 );
984 }
985
986 fn create_external_client(tmp_dir: PathBuf) -> OtaClient {
987 crate::crypto::init_crypto_provider();
988 let token = std::env::var("GH_TOKEN").expect("GH_TOKEN must be set");
989 let github = GithubClient::new(Some(SecretString::from(token))).expect("client build");
990 OtaClient::new(github, tmp_dir)
991 }
992
993 #[test]
994 #[ignore]
995 fn test_external_download_default_branch_and_deploy() {
996 let temp_dir = tempfile::tempdir().unwrap();
997 let client = create_external_client(temp_dir.path().to_path_buf());
998 let mut last_progress = None;
999
1000 let download_result = client.download_default_branch_artifact(|progress| {
1001 last_progress = Some(format!("{:?}", progress));
1002 });
1003
1004 assert!(
1005 download_result.is_ok(),
1006 "Default branch artifact download should succeed: {:?}",
1007 download_result.err()
1008 );
1009
1010 let zip_path = download_result.unwrap();
1011 assert!(
1012 zip_path.exists(),
1013 "Downloaded ZIP should exist at {:?}",
1014 zip_path
1015 );
1016 assert!(
1017 zip_path.metadata().unwrap().len() > 0,
1018 "Downloaded ZIP should not be empty"
1019 );
1020
1021 let deploy_result = client.extract_and_deploy(zip_path.clone());
1022
1023 assert!(
1024 deploy_result.is_ok(),
1025 "Deployment should succeed: {:?}",
1026 deploy_result.err()
1027 );
1028
1029 let deploy_path = deploy_result.unwrap();
1030 assert!(
1031 deploy_path.exists(),
1032 "Deployed file should exist at {:?}",
1033 deploy_path
1034 );
1035
1036 std::fs::remove_file(&deploy_path).ok();
1037 }
1038
1039 #[test]
1040 #[ignore]
1041 fn test_external_download_stable_release_and_deploy() {
1042 let temp_dir = tempfile::tempdir().unwrap();
1043 let client = create_external_client(temp_dir.path().to_path_buf());
1044 let download_result = client.download_stable_release_artifact(|_| {});
1045
1046 assert!(
1047 download_result.is_ok(),
1048 "Stable release artifact download should succeed: {:?}",
1049 download_result.err()
1050 );
1051
1052 let asset_path = download_result.unwrap();
1053 assert!(
1054 asset_path.exists(),
1055 "Downloaded asset should exist at {:?}",
1056 asset_path
1057 );
1058 assert!(
1059 asset_path.metadata().unwrap().len() > 0,
1060 "Downloaded asset should not be empty"
1061 );
1062
1063 let deploy_result = client.deploy(asset_path.clone());
1064
1065 assert!(
1066 deploy_result.is_ok(),
1067 "Deployment should succeed: {:?}",
1068 deploy_result.err()
1069 );
1070
1071 let deploy_path = deploy_result.unwrap();
1072 assert!(
1073 deploy_path.exists(),
1074 "Deployed file should exist at {:?}",
1075 deploy_path
1076 );
1077
1078 std::fs::remove_file(&deploy_path).ok();
1079 }
1080}