1use crate::github::types::{
2 Artifact, ArtifactsResponse, Release, ReleaseAsset, Repository, WorkflowRunsResponse,
3};
4use crate::github::{GithubClient, OtaProgress};
5use crate::version::GitVersion;
6use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
7use std::fs::File;
8use std::io::{Read, Write};
9use std::path::PathBuf;
10use std::time::Duration;
11use zip::ZipArchive;
12
13#[cfg(all(not(test), not(feature = "emulator")))]
14use crate::settings::INTERNAL_CARD_ROOT;
15
16const MIN_CHUNK_SIZE: usize = 256 * 1024;
17const MAX_CHUNK_SIZE: usize = 10 * 1024 * 1024;
18const INITIAL_CHUNK_SIZE: usize = 1024 * 1024;
19const TARGET_CHUNK_SECS: f64 = crate::github::CLIENT_TIMEOUT_SECS as f64 * 0.8;
21
22const MAX_RETRIES: usize = 3;
24
25pub struct OtaClient {
31 github: GithubClient,
32}
33
34#[derive(Debug, Clone)]
36pub enum ArtifactSource {
37 PullRequest(u32),
39 DefaultBranch,
41 WorkflowRun(String),
43 ReleaseAsset(String),
45}
46
47impl std::fmt::Display for ArtifactSource {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 ArtifactSource::PullRequest(pr) => write!(f, "No build artifacts found for PR #{}", pr),
51 ArtifactSource::DefaultBranch => {
52 write!(f, "No build artifacts found for default branch")
53 }
54 ArtifactSource::WorkflowRun(pattern) => {
55 write!(
56 f,
57 "No artifact matching '{}' found in workflow run",
58 pattern
59 )
60 }
61 ArtifactSource::ReleaseAsset(name) => write!(f, "No release asset '{}' found", name),
62 }
63 }
64}
65
66#[derive(thiserror::Error, Debug)]
68pub enum OtaError {
69 #[error("GitHub API error: {0}")]
71 Api(String),
72
73 #[error("HTTP request error: {0}")]
75 Request(#[from] reqwest::Error),
76
77 #[error("PR #{0} not found")]
79 PrNotFound(u32),
80
81 #[error("{0}")]
83 ArtifactsNotFound(ArtifactSource),
84
85 #[error("GitHub token not configured")]
87 NoToken,
88
89 #[error("GitHub token is invalid or revoked")]
91 Unauthorized,
92
93 #[error(transparent)]
98 InsufficientScopes(#[from] crate::github::ScopeError),
99
100 #[error("Insufficient disk space: need 100MB, have {0}MB")]
102 InsufficientSpace(u64),
103
104 #[error("I/O error: {0}")]
106 Io(#[from] std::io::Error),
107
108 #[error("System error: {0}")]
110 Nix(#[from] nix::errno::Errno),
111
112 #[error("TLS configuration error: {0}")]
114 TlsConfig(String),
115
116 #[error("ZIP extraction error: {0}")]
118 ZipError(#[from] zip::result::ZipError),
119
120 #[error("Deployment error: {0}")]
122 DeploymentError(String),
123
124 #[error(transparent)]
126 VersionParse(#[from] crate::version::VersionError),
127}
128
129impl OtaClient {
130 pub fn new(github: GithubClient) -> Self {
136 Self { github }
137 }
138
139 pub fn download_pr_artifact<F>(
169 &self,
170 pr_number: u32,
171 mut progress_callback: F,
172 ) -> Result<PathBuf, OtaError>
173 where
174 F: FnMut(OtaProgress),
175 {
176 check_disk_space("/tmp")?;
177 verify_scopes(&self.github)?;
178
179 progress_callback(OtaProgress::CheckingPr);
180 tracing::info!(pr_number, "Starting PR build download");
181 tracing::debug!(pr_number, "Checking PR");
182
183 let pr_url = format!(
184 "https://api.github.com/repos/ogkevin/cadmus/pulls/{}",
185 pr_number
186 );
187 tracing::debug!(url = %pr_url, "Fetching PR");
188
189 let response = self
190 .github
191 .get(&pr_url)
192 .send()?
193 .error_for_status()
194 .map_err(|e| {
195 tracing::error!(pr_number, status = ?e.status(), error = %e, "PR fetch failed");
196 if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
197 OtaError::Unauthorized
198 } else {
199 OtaError::PrNotFound(pr_number)
200 }
201 })?;
202
203 let pr: crate::github::types::PullRequest = response.json()?;
204 tracing::debug!("Successfully parsed PR response");
205 let head_sha = pr.head.sha;
206 tracing::debug!(pr_number, head_sha = %head_sha, "Retrieved PR head SHA");
207
208 progress_callback(OtaProgress::FindingWorkflow);
209 tracing::debug!(head_sha = %head_sha, "Finding workflow runs");
210
211 let runs_url = format!(
212 "https://api.github.com/repos/ogkevin/cadmus/actions/runs?head_sha={}&event=pull_request",
213 head_sha
214 );
215 tracing::debug!(url = %runs_url, "Fetching workflow runs");
216
217 let runs: WorkflowRunsResponse = self
218 .github
219 .get(&runs_url)
220 .send()?
221 .error_for_status()
222 .map_err(|e| {
223 tracing::error!(head_sha = %head_sha, status = ?e.status(), error = %e, "Workflow runs fetch failed");
224 api_error(e)
225 })?
226 .json()?;
227
228 tracing::debug!(count = runs.workflow_runs.len(), "Found workflow runs");
229
230 #[cfg(feature = "otel")]
231 if tracing::enabled!(tracing::Level::DEBUG) {
232 for (idx, run) in runs.workflow_runs.iter().enumerate() {
233 tracing::debug!(
234 index = idx,
235 name = %run.name,
236 id = run.id,
237 "Workflow run"
238 );
239 }
240 }
241
242 let run = runs
243 .workflow_runs
244 .iter()
245 .find(|r| r.name == "Cargo")
246 .ok_or_else(|| {
247 tracing::error!(pr_number, "No Cargo workflow run found");
248 OtaError::ArtifactsNotFound(ArtifactSource::PullRequest(pr_number))
249 })?;
250
251 tracing::debug!(run_id = run.id, "Found Cargo workflow run");
252
253 #[cfg(feature = "test")]
254 let artifact_name_pattern = format!("cadmus-kobo-test-pr{}", pr_number);
255 #[cfg(not(feature = "test"))]
256 let artifact_name_pattern = format!("cadmus-kobo-pr{}", pr_number);
257
258 let artifact = self
259 .find_artifact_in_run(run.id, &artifact_name_pattern)
260 .map_err(|e| match e {
261 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(_)) => {
262 OtaError::ArtifactsNotFound(ArtifactSource::PullRequest(pr_number))
263 }
264 other => other,
265 })?;
266
267 tracing::debug!(
268 name = %artifact.name,
269 id = artifact.id,
270 size_bytes = artifact.size_in_bytes,
271 "Found artifact"
272 );
273
274 let download_path = PathBuf::from(format!("/tmp/cadmus-ota-{}.zip", pr_number));
275
276 self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
277
278 progress_callback(OtaProgress::Complete {
279 path: download_path.clone(),
280 });
281
282 tracing::info!(pr_number, "PR build download completed");
283 Ok(download_path)
284 }
285
286 pub fn download_default_branch_artifact<F>(
313 &self,
314 mut progress_callback: F,
315 ) -> Result<PathBuf, OtaError>
316 where
317 F: FnMut(OtaProgress),
318 {
319 check_disk_space("/tmp")?;
320 verify_scopes(&self.github)?;
321
322 progress_callback(OtaProgress::FindingLatestBuild);
323 tracing::info!("Starting main branch build download");
324 tracing::debug!("Finding latest default branch build");
325
326 let default_branch = self.fetch_default_branch()?;
327
328 let encoded_branch = utf8_percent_encode(&default_branch, NON_ALPHANUMERIC);
329 let runs_url = format!(
330 "https://api.github.com/repos/ogkevin/cadmus/actions/workflows/cargo.yml/runs?branch={}&event=push&status=success&per_page=1",
331 encoded_branch
332 );
333 tracing::debug!(url = %runs_url, "Fetching Cargo workflow runs on default branch");
334
335 let runs: WorkflowRunsResponse = self
336 .github
337 .get(&runs_url)
338 .send()?
339 .error_for_status()
340 .map_err(|e| {
341 tracing::error!(status = ?e.status(), error = %e, "Cargo workflow runs fetch failed");
342 api_error(e)
343 })?
344 .json()?;
345
346 let cargo_run = runs.workflow_runs.first().ok_or_else(|| {
347 tracing::error!("No successful Cargo workflow run found on default branch");
348 OtaError::ArtifactsNotFound(ArtifactSource::DefaultBranch)
349 })?;
350
351 tracing::debug!(run_id = cargo_run.id, "Found Cargo workflow run");
352
353 let head_sha = cargo_run.head_sha.as_deref().ok_or_else(|| {
354 tracing::error!(run_id = cargo_run.id, "Workflow run missing head_sha");
355 OtaError::Api(format!("Workflow run {} missing head_sha", cargo_run.id))
356 })?;
357 let short_sha = &head_sha[..7.min(head_sha.len())];
358
359 #[cfg(feature = "test")]
360 let artifact_name_prefix = format!("cadmus-kobo-test-{}", short_sha);
361 #[cfg(not(feature = "test"))]
362 let artifact_name_prefix = format!("cadmus-kobo-{}", short_sha);
363
364 tracing::debug!(pattern = %artifact_name_prefix, "Looking for artifact");
365
366 progress_callback(OtaProgress::FindingWorkflow);
367
368 let artifact = self
369 .find_artifact_in_run(cargo_run.id, &artifact_name_prefix)
370 .map_err(|e| match e {
371 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(pattern)) => {
372 tracing::error!(pattern = %pattern, "No matching artifact found on default branch");
373 OtaError::ArtifactsNotFound(ArtifactSource::DefaultBranch)
374 }
375 other => other,
376 })?;
377
378 tracing::debug!(
379 name = %artifact.name,
380 id = artifact.id,
381 size_bytes = artifact.size_in_bytes,
382 "Found default branch artifact"
383 );
384
385 let download_path = PathBuf::from(format!("/tmp/cadmus-ota-{}.zip", short_sha));
386
387 self.download_artifact_to_path(&artifact, &download_path, &mut progress_callback)?;
388
389 progress_callback(OtaProgress::Complete {
390 path: download_path.clone(),
391 });
392
393 tracing::info!(sha = %short_sha, "Main branch build download completed");
394 Ok(download_path)
395 }
396
397 #[cfg_attr(feature = "otel", tracing::instrument(skip_all))]
424 pub fn download_stable_release_artifact<F>(
425 &self,
426 mut progress_callback: F,
427 ) -> Result<PathBuf, OtaError>
428 where
429 F: FnMut(OtaProgress),
430 {
431 check_disk_space("/tmp")?;
432
433 progress_callback(OtaProgress::FindingLatestBuild);
434 tracing::info!("Starting stable release download");
435 tracing::debug!("Finding latest stable release");
436
437 let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
438 tracing::debug!(url = %releases_url, "Fetching latest release");
439
440 let release: Release = self
441 .github
442 .get_unauthenticated(releases_url)
443 .send()?
444 .error_for_status()
445 .map_err(|e| {
446 tracing::error!(status = ?e.status(), error = %e, "Latest release fetch failed");
447 api_error(e)
448 })?
449 .json()?;
450
451 tracing::debug!(asset_count = release.assets.len(), "Found release assets");
452
453 #[cfg(feature = "otel")]
454 for (idx, asset) in release.assets.iter().enumerate() {
455 tracing::debug!(
456 index = idx,
457 name = %asset.name,
458 size_bytes = asset.size,
459 "Asset"
460 );
461 }
462
463 let asset_name = "KoboRoot.tgz";
464
465 let asset = release
466 .assets
467 .iter()
468 .find(|a| a.name == asset_name)
469 .ok_or_else(|| {
470 tracing::error!(
471 target_asset = asset_name,
472 "Asset not found in latest release"
473 );
474 OtaError::ArtifactsNotFound(ArtifactSource::ReleaseAsset(asset_name.to_owned()))
475 })?;
476
477 tracing::debug!(
478 name = %asset.name,
479 url = %asset.browser_download_url,
480 size_bytes = asset.size,
481 "Found release asset"
482 );
483
484 let download_path = PathBuf::from("/tmp/cadmus-ota-stable-release.tgz");
485
486 self.download_release_asset(asset, &download_path, &mut progress_callback)?;
487
488 progress_callback(OtaProgress::Complete {
489 path: download_path.clone(),
490 });
491
492 tracing::info!("Stable release download completed");
493 Ok(download_path)
494 }
495
496 #[cfg_attr(feature = "otel", tracing::instrument(skip(self)))]
525 pub fn fetch_latest_release_version(&self) -> Result<GitVersion, OtaError> {
526 let releases_url = "https://api.github.com/repos/ogkevin/cadmus/releases/latest";
527 tracing::debug!(url = %releases_url, "Fetching latest release version");
528
529 let release: Release = self
530 .github
531 .get_unauthenticated(releases_url)
532 .send()?
533 .error_for_status()
534 .map_err(|e| {
535 tracing::error!(status = ?e.status(), error = %e, "Latest release fetch failed");
536 api_error(e)
537 })?
538 .json()?;
539
540 tracing::info!(version = %release.tag_name, "Fetched latest release version");
541
542 let version: GitVersion = release.tag_name.parse()?;
543 Ok(version)
544 }
545
546 #[cfg_attr(feature = "otel", tracing::instrument(skip(self)))]
563 pub fn deploy(&self, kobo_root_path: PathBuf) -> Result<PathBuf, OtaError> {
564 tracing::info!(path = ?kobo_root_path, "Deploying KoboRoot.tgz");
565
566 let mut kobo_root_data = Vec::new();
567 {
568 let mut file = File::open(&kobo_root_path)?;
569 file.read_to_end(&mut kobo_root_data)?;
570 }
571
572 tracing::debug!(
573 bytes = kobo_root_data.len(),
574 path = ?kobo_root_path,
575 "Read KoboRoot.tgz"
576 );
577
578 self.deploy_bytes(&kobo_root_data)
579 }
580
581 fn deploy_bytes(&self, data: &[u8]) -> Result<PathBuf, OtaError> {
600 #[cfg(test)]
601 let deploy_path = {
602 std::env::temp_dir()
603 .join("test-kobo-deployment")
604 .join("KoboRoot.tgz")
605 };
606
607 #[cfg(all(feature = "emulator", not(test)))]
608 let deploy_path = PathBuf::from("/tmp/.kobo/KoboRoot.tgz");
609
610 #[cfg(all(not(feature = "emulator"), not(test)))]
611 let deploy_path = PathBuf::from(format!("{}/.kobo/KoboRoot.tgz", INTERNAL_CARD_ROOT));
612
613 tracing::debug!(path = ?deploy_path, "Deploy destination");
614
615 #[cfg(any(test, feature = "emulator"))]
616 {
617 if let Some(parent) = deploy_path.parent() {
618 tracing::debug!(directory = ?parent, "Creating parent directory");
619 std::fs::create_dir_all(parent)?;
620 }
621 }
622
623 tracing::debug!(bytes = data.len(), path = ?deploy_path, "Writing file");
624 let mut file = File::create(&deploy_path)?;
625 file.write_all(data)?;
626
627 tracing::debug!(path = ?deploy_path, "Deployment complete");
628 tracing::info!(path = ?deploy_path, "Update deployed successfully");
629
630 Ok(deploy_path)
631 }
632
633 #[cfg_attr(feature = "otel", tracing::instrument(skip(self)))]
653 pub fn extract_and_deploy(&self, zip_path: PathBuf) -> Result<PathBuf, OtaError> {
654 tracing::info!(path = ?zip_path, "Extracting and deploying update");
655 tracing::debug!(path = ?zip_path, "Starting extraction");
656
657 let file = File::open(&zip_path)?;
658 let mut archive = ZipArchive::new(file)?;
659
660 tracing::debug!(file_count = archive.len(), "Opened ZIP archive");
661
662 let mut kobo_root_data = Vec::new();
663 let mut found = false;
664
665 #[cfg(not(feature = "test"))]
666 let kobo_root_name = "KoboRoot.tgz";
667 #[cfg(feature = "test")]
668 let kobo_root_name = "KoboRoot-test.tgz";
669
670 tracing::debug!(target_file = kobo_root_name, "Looking for file");
671
672 for i in 0..archive.len() {
673 let mut entry = archive.by_index(i)?;
674 let entry_name = entry.name().to_string();
675
676 tracing::debug!(index = i, name = %entry_name, "Checking entry");
677
678 if entry_name.eq(kobo_root_name) {
679 tracing::debug!(name = %entry_name, "Found target file");
680 entry.read_to_end(&mut kobo_root_data)?;
681 found = true;
682 break;
683 }
684 }
685
686 if !found {
687 tracing::error!(
688 target_file = kobo_root_name,
689 "Target file not found in artifact"
690 );
691 return Err(OtaError::DeploymentError(format!(
692 "{} not found in artifact",
693 kobo_root_name
694 )));
695 }
696
697 tracing::debug!(
698 bytes = kobo_root_data.len(),
699 file = kobo_root_name,
700 "Extracted file"
701 );
702
703 self.deploy_bytes(&kobo_root_data)
704 }
705
706 fn fetch_default_branch(&self) -> Result<String, OtaError> {
708 let repo_url = "https://api.github.com/repos/ogkevin/cadmus";
709 tracing::debug!(url = %repo_url, "Fetching repository metadata");
710
711 let repo: Repository = self
712 .github
713 .get(repo_url)
714 .send()?
715 .error_for_status()
716 .map_err(|e| {
717 tracing::error!(status = ?e.status(), error = %e, "Repository metadata fetch failed");
718 api_error(e)
719 })?
720 .json()?;
721
722 tracing::debug!(default_branch = %repo.default_branch, "Resolved default branch");
723 Ok(repo.default_branch)
724 }
725
726 fn find_artifact_in_run(&self, run_id: u64, name_prefix: &str) -> Result<Artifact, OtaError> {
728 let artifacts_url = format!(
729 "https://api.github.com/repos/ogkevin/cadmus/actions/runs/{}/artifacts",
730 run_id
731 );
732 tracing::debug!(url = %artifacts_url, "Fetching artifacts");
733
734 let artifacts: ArtifactsResponse = self
735 .github
736 .get(&artifacts_url)
737 .send()?
738 .error_for_status()
739 .map_err(|e| {
740 tracing::error!(run_id, status = ?e.status(), error = %e, "Artifacts fetch failed");
741 api_error(e)
742 })?
743 .json()?;
744
745 tracing::debug!(count = artifacts.artifacts.len(), "Found artifacts");
746
747 #[cfg(feature = "otel")]
748 if tracing::enabled!(tracing::Level::DEBUG) {
749 for (idx, artifact) in artifacts.artifacts.iter().enumerate() {
750 tracing::debug!(
751 index = idx,
752 name = %artifact.name,
753 id = artifact.id,
754 size_bytes = artifact.size_in_bytes,
755 "Artifact"
756 );
757 }
758 }
759
760 tracing::debug!(pattern = %name_prefix, "Looking for artifact");
761
762 artifacts
763 .artifacts
764 .into_iter()
765 .find(|a| a.name.starts_with(name_prefix))
766 .ok_or_else(|| {
767 tracing::error!(run_id, pattern = %name_prefix, "No matching artifact found");
768 OtaError::ArtifactsNotFound(ArtifactSource::WorkflowRun(name_prefix.to_owned()))
769 })
770 }
771
772 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, progress_callback)))]
789 fn download_by_url_to_path<F>(
790 &self,
791 url: &str,
792 total_size: u64,
793 download_path: &PathBuf,
794 progress_callback: &mut F,
795 use_auth: bool,
796 ) -> Result<(), OtaError>
797 where
798 F: FnMut(OtaProgress),
799 {
800 progress_callback(OtaProgress::DownloadingArtifact {
801 downloaded: 0,
802 total: total_size,
803 });
804
805 tracing::debug!(url = %url, "Downloading file");
806 tracing::debug!(path = ?download_path, "Download destination");
807
808 let mut file = File::create(download_path)?;
809
810 let mut downloaded = 0u64;
811 let mut chunk_size = INITIAL_CHUNK_SIZE;
812
813 tracing::debug!(
814 initial_chunk_size = INITIAL_CHUNK_SIZE,
815 "Starting chunked download"
816 );
817
818 while downloaded < total_size {
819 let chunk_start = downloaded;
820 let chunk_end = std::cmp::min(downloaded + chunk_size as u64 - 1, total_size - 1);
821
822 tracing::debug!(
823 chunk_start,
824 chunk_end,
825 chunk_size,
826 total_size,
827 "Downloading chunk"
828 );
829
830 let start = std::time::Instant::now();
831 let chunk_data =
832 self.download_chunk_with_retries(url, chunk_start, chunk_end, use_auth)?;
833 let elapsed_secs = start.elapsed().as_secs_f64();
834
835 file.write_all(&chunk_data)?;
836 downloaded += chunk_data.len() as u64;
837
838 if elapsed_secs > 0.0 {
839 let throughput = chunk_data.len() as f64 / elapsed_secs;
840 chunk_size = ((throughput * TARGET_CHUNK_SECS) as usize)
841 .clamp(MIN_CHUNK_SIZE, MAX_CHUNK_SIZE);
842 tracing::debug!(
843 elapsed_secs,
844 throughput_bytes_per_sec = throughput as u64,
845 next_chunk_size = chunk_size,
846 "Adjusted chunk size"
847 );
848 }
849
850 progress_callback(OtaProgress::DownloadingArtifact {
851 downloaded,
852 total: total_size,
853 });
854
855 tracing::debug!(
856 downloaded,
857 total_size,
858 progress_percent = (downloaded as f64 / total_size as f64) * 100.0,
859 "Download progress"
860 );
861 }
862
863 tracing::debug!(bytes = downloaded, "Download complete");
864 tracing::debug!(path = ?download_path, "Saved file");
865
866 Ok(())
867 }
868
869 fn download_artifact_to_path<F>(
873 &self,
874 artifact: &Artifact,
875 download_path: &PathBuf,
876 progress_callback: &mut F,
877 ) -> Result<(), OtaError>
878 where
879 F: FnMut(OtaProgress),
880 {
881 let download_url = format!(
882 "https://api.github.com/repos/ogkevin/cadmus/actions/artifacts/{}/zip",
883 artifact.id
884 );
885
886 self.download_by_url_to_path(
887 &download_url,
888 artifact.size_in_bytes,
889 download_path,
890 progress_callback,
891 true,
892 )
893 }
894
895 fn download_chunk_with_retries(
914 &self,
915 url: &str,
916 start: u64,
917 end: u64,
918 use_auth: bool,
919 ) -> Result<Vec<u8>, OtaError> {
920 let mut last_error = None;
921
922 for attempt in 1..=MAX_RETRIES {
923 match self.download_chunk(url, start, end, use_auth) {
924 Ok(data) => {
925 if attempt > 1 {
926 tracing::debug!(
927 attempt,
928 max_retries = MAX_RETRIES,
929 "Chunk download succeeded after retry"
930 );
931 }
932 return Ok(data);
933 }
934 Err(e) => {
935 tracing::warn!(
936 attempt,
937 max_retries = MAX_RETRIES,
938 error = %e,
939 "Chunk download failed"
940 );
941 last_error = Some(e);
942
943 if attempt < MAX_RETRIES {
944 let backoff_ms = 1000 * (2u64.pow(attempt as u32 - 1));
945 tracing::debug!(backoff_ms, "Retrying after backoff");
946 std::thread::sleep(Duration::from_millis(backoff_ms));
947 }
948 }
949 }
950 }
951
952 Err(last_error.unwrap_or_else(|| {
953 OtaError::Api("Failed to download chunk after all retries".to_string())
954 }))
955 }
956
957 fn download_chunk(
974 &self,
975 url: &str,
976 start: u64,
977 end: u64,
978 use_auth: bool,
979 ) -> Result<Vec<u8>, OtaError> {
980 let range_header = format!("bytes={}-{}", start, end);
981
982 let builder = if use_auth {
983 self.github.get(url)
984 } else {
985 self.github.get_unauthenticated(url)
986 };
987
988 let bytes = builder
989 .header("Range", range_header)
990 .send()?
991 .error_for_status()
992 .map_err(api_error)?
993 .bytes()?;
994
995 Ok(bytes.to_vec())
996 }
997
998 #[inline]
1003 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, progress_callback)))]
1004 fn download_release_asset<F>(
1005 &self,
1006 asset: &ReleaseAsset,
1007 download_path: &PathBuf,
1008 progress_callback: &mut F,
1009 ) -> Result<(), OtaError>
1010 where
1011 F: FnMut(OtaProgress),
1012 {
1013 self.download_by_url_to_path(
1014 &asset.browser_download_url,
1015 asset.size,
1016 download_path,
1017 progress_callback,
1018 false,
1019 )
1020 }
1021}
1022
1023fn verify_scopes(github: &crate::github::GithubClient) -> Result<(), OtaError> {
1033 github.verify_token_scopes().map_err(|e| match e {
1034 crate::github::VerifyScopesError::Request(e) => api_error(e),
1035 crate::github::VerifyScopesError::InsufficientScopes(e) => OtaError::InsufficientScopes(e),
1036 })
1037}
1038
1039fn api_error(e: reqwest::Error) -> OtaError {
1045 if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
1046 tracing::warn!("GitHub API returned 401 — token invalid or revoked");
1047 OtaError::Unauthorized
1048 } else {
1049 OtaError::Api(e.to_string())
1050 }
1051}
1052
1053fn check_disk_space(path: &str) -> Result<(), OtaError> {
1054 use nix::sys::statvfs::statvfs;
1055
1056 let stat = statvfs(path)?;
1057 let available_mb = (stat.blocks_available() as u64 * stat.block_size() as u64) / (1024 * 1024);
1058 tracing::debug!(path, available_mb, "Checking disk space");
1059
1060 if available_mb < 100 {
1061 tracing::error!(
1062 path,
1063 available_mb,
1064 required_mb = 100,
1065 "Insufficient disk space"
1066 );
1067 return Err(OtaError::InsufficientSpace(available_mb));
1068 }
1069 Ok(())
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use crate::github::GithubClient;
1076 use secrecy::SecretString;
1077
1078 fn make_client() -> OtaClient {
1079 crate::crypto::init_crypto_provider();
1080 let github =
1081 GithubClient::new(Some(SecretString::from("test_token"))).expect("client build");
1082 OtaClient::new(github)
1083 }
1084
1085 #[test]
1086 fn test_extract_and_deploy_success() {
1087 let client = make_client();
1088 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1089 .join("src/ota/tests/fixtures/test_artifact.zip");
1090
1091 let result = client.extract_and_deploy(fixture_path);
1092
1093 assert!(
1094 result.is_ok(),
1095 "Deployment should succeed: {:?}",
1096 result.err()
1097 );
1098
1099 let deploy_path = result.unwrap();
1100 assert!(
1101 deploy_path.exists(),
1102 "Deployed file should exist at {:?}",
1103 deploy_path
1104 );
1105
1106 let content = std::fs::read_to_string(&deploy_path).unwrap();
1107 assert!(
1108 content.contains("Mock KoboRoot.tgz"),
1109 "Deployed file should contain mock content"
1110 );
1111
1112 std::fs::remove_file(&deploy_path).ok();
1113 }
1114
1115 #[test]
1116 fn test_extract_and_deploy_missing_koboroot() {
1117 let client = make_client();
1118 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1119 .join("src/ota/tests/fixtures/empty_artifact.zip");
1120
1121 let result = client.extract_and_deploy(fixture_path);
1122 assert!(result.is_err(), "Should fail when KoboRoot.tgz is missing");
1123
1124 if let Err(OtaError::DeploymentError(msg)) = result {
1125 assert!(
1126 msg.contains("not found in artifact"),
1127 "Error should mention missing file"
1128 );
1129 } else {
1130 panic!("Expected DeploymentError");
1131 }
1132 }
1133
1134 #[test]
1135 fn test_check_disk_space_sufficient() {
1136 use tempfile::TempDir;
1137 let temp_dir = TempDir::new().unwrap();
1138 let result = check_disk_space(temp_dir.path().to_str().unwrap());
1139 assert!(
1140 result.is_ok(),
1141 "Should have sufficient disk space in temp directory"
1142 );
1143 }
1144
1145 fn external_test_enabled() -> bool {
1146 std::env::var("CADMUS_TEST_OTA_EXTERNAL").is_ok() && std::env::var("GH_TOKEN").is_ok()
1147 }
1148
1149 fn create_external_client() -> OtaClient {
1150 crate::crypto::init_crypto_provider();
1151 let token = std::env::var("GH_TOKEN").expect("GH_TOKEN must be set");
1152 let github = GithubClient::new(Some(SecretString::from(token))).expect("client build");
1153 OtaClient::new(github)
1154 }
1155
1156 #[test]
1157 #[ignore]
1158 fn test_external_download_default_branch_and_deploy() {
1159 if !external_test_enabled() {
1160 return;
1161 }
1162
1163 let client = create_external_client();
1164 let mut last_progress = None;
1165
1166 let download_result = client.download_default_branch_artifact(|progress| {
1167 last_progress = Some(format!("{:?}", progress));
1168 });
1169
1170 assert!(
1171 download_result.is_ok(),
1172 "Default branch artifact download should succeed: {:?}",
1173 download_result.err()
1174 );
1175
1176 let zip_path = download_result.unwrap();
1177 assert!(
1178 zip_path.exists(),
1179 "Downloaded ZIP should exist at {:?}",
1180 zip_path
1181 );
1182 assert!(
1183 zip_path.metadata().unwrap().len() > 0,
1184 "Downloaded ZIP should not be empty"
1185 );
1186
1187 let deploy_result = client.extract_and_deploy(zip_path.clone());
1188
1189 assert!(
1190 deploy_result.is_ok(),
1191 "Deployment should succeed: {:?}",
1192 deploy_result.err()
1193 );
1194
1195 let deploy_path = deploy_result.unwrap();
1196 assert!(
1197 deploy_path.exists(),
1198 "Deployed file should exist at {:?}",
1199 deploy_path
1200 );
1201
1202 std::fs::remove_file(&zip_path).ok();
1203 std::fs::remove_file(&deploy_path).ok();
1204 }
1205
1206 #[test]
1207 #[ignore]
1208 fn test_external_download_stable_release_and_deploy() {
1209 if !external_test_enabled() {
1210 return;
1211 }
1212
1213 let client = create_external_client();
1214 let download_result = client.download_stable_release_artifact(|_| {});
1215
1216 assert!(
1217 download_result.is_ok(),
1218 "Stable release artifact download should succeed: {:?}",
1219 download_result.err()
1220 );
1221
1222 let asset_path = download_result.unwrap();
1223 assert!(
1224 asset_path.exists(),
1225 "Downloaded asset should exist at {:?}",
1226 asset_path
1227 );
1228 assert!(
1229 asset_path.metadata().unwrap().len() > 0,
1230 "Downloaded asset should not be empty"
1231 );
1232
1233 let deploy_result = client.deploy(asset_path.clone());
1234
1235 assert!(
1236 deploy_result.is_ok(),
1237 "Deployment should succeed: {:?}",
1238 deploy_result.err()
1239 );
1240
1241 let deploy_path = deploy_result.unwrap();
1242 assert!(
1243 deploy_path.exists(),
1244 "Deployed file should exist at {:?}",
1245 deploy_path
1246 );
1247
1248 std::fs::remove_file(&asset_path).ok();
1249 std::fs::remove_file(&deploy_path).ok();
1250 }
1251}