1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitStatus};
6use std::{fs, io, iter};
7
8use ephemeris_files::RepositoryPath;
9use ephemeris_token::Token;
10use facet::Facet;
11
12use crate::ForgeRelease;
13use crate::digest::DigestAlgorithm;
14use crate::error::ForgeFailure;
15use crate::remote::{RemoteAsset, RemoteRelease};
16use crate::state::{AssetSpec, OciSpec};
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub(crate) struct Publication<'a> {
21 pub unit: String,
22 pub tag: String,
23 pub target_commit: String,
24 pub title: String,
25 pub notes: String,
26 pub expected: Vec<(&'a str, &'a AssetSpec)>,
28 pub oci: Vec<(&'a str, &'a OciSpec)>,
30 pub version: String,
31 pub working_directory: PathBuf,
32}
33
34#[derive(Debug, thiserror::Error)]
36pub(crate) enum ArtifactError {
37 #[error("invalid artifact manifest {}", path.display())]
39 ManifestDecode {
40 path: PathBuf,
42 #[source]
44 source: Box<facet_json::DeserializeError>,
45 },
46 #[error("unit {unit:?} stages {actual} artifacts, but the evaluated state declares {expected}")]
48 ArtifactCount {
49 unit: String,
51 expected: usize,
53 actual: usize,
55 },
56 #[error("artifact {0:?} is absent from the manifest")]
58 MissingArtifact(String),
59 #[error("artifact {name:?} stages a different {field} than the evaluated state declares")]
61 ArtifactMismatch {
62 name: String,
64 field: &'static str,
66 },
67 #[error("artifact filename {0:?} is unsafe or duplicated")]
69 UnsafeFilename(String),
70 #[error("artifact {name:?} is staged at {}", path.display())]
72 UnexpectedPath {
73 name: String,
75 path: PathBuf,
77 },
78 #[error("artifact I/O failed at {path:?}")]
80 Io {
81 path: PathBuf,
83 source: io::Error,
85 },
86 #[error("staged package {} is not a file", path.display())]
88 NotAFile {
89 path: PathBuf,
91 },
92 #[error("oci artifact {0:?} has no push program in the manifest")]
94 MissingOciProgram(String),
95 #[error("oci artifact {name:?} push program {} failed to start", program.display())]
97 OciSpawn {
98 name: String,
100 program: PathBuf,
102 source: io::Error,
104 },
105 #[error("oci artifact {name:?} push failed with {status}")]
107 OciPush {
108 name: String,
110 status: ExitStatus,
112 },
113 #[error("oci artifact {0:?} needs a registry login, and the state declares none")]
115 MissingRegistryLogin(String),
116 #[error("remote artifact operation failed")]
118 Remote(#[from] ForgeFailure),
119 #[error("uploaded release {tag:?} is missing required verified assets")]
121 IncompleteUpload {
122 tag: String,
124 },
125 #[error("published release {tag:?} differs from the locally staged artifacts")]
127 PublishedMismatch {
128 tag: String,
130 },
131}
132
133#[derive(Debug, Facet)]
134#[facet(tag = "kind")]
135#[repr(u8)]
136enum ManifestEntry {
137 #[facet(rename = "asset")]
138 Asset(AssetEntry),
139 #[facet(rename = "oci")]
140 Oci(OciEntry),
141 #[facet(rename = "cargo")]
142 Cargo(PackageEntry),
143 #[facet(rename = "npm")]
144 Npm(PackageEntry),
145}
146
147#[derive(Debug, Facet)]
148#[facet(deny_unknown_fields)]
149struct AssetEntry {
150 unit: String,
151 name: String,
152 package: String,
153 output: String,
154 filename: String,
155 format: String,
156 variant: String,
157 required_names: Vec<String>,
158 path: String,
159}
160
161#[derive(Debug, Facet)]
162#[facet(deny_unknown_fields)]
163struct OciEntry {
164 unit: String,
165 name: String,
166 package: String,
167 registry: String,
168 repository: String,
169 latest: bool,
170 program: String,
171}
172
173#[derive(Debug, Facet)]
174#[facet(deny_unknown_fields)]
175struct PackageEntry {
176 unit: String,
177 name: String,
178 package: String,
179 path: String,
180}
181
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub(crate) enum PackageKind {
185 Cargo,
187 Npm,
189}
190
191impl PackageKind {
192 pub(crate) const fn label(self) -> &'static str {
194 match self {
195 Self::Cargo => "cargo",
196 Self::Npm => "npm",
197 }
198 }
199
200 const fn extension(self) -> &'static str {
201 match self {
202 Self::Cargo => "crate",
203 Self::Npm => "tgz",
204 }
205 }
206
207 fn entry(self, entry: &ManifestEntry) -> Option<&PackageEntry> {
208 match entry {
209 ManifestEntry::Cargo(package) if self == Self::Cargo => Some(package),
210 ManifestEntry::Npm(package) if self == Self::Npm => Some(package),
211 ManifestEntry::Cargo(_)
212 | ManifestEntry::Npm(_)
213 | ManifestEntry::Asset(_)
214 | ManifestEntry::Oci(_) => None,
215 }
216 }
217}
218
219#[derive(Debug)]
220struct LocalAsset {
221 name: String,
222 bytes: Vec<u8>,
223 sha256: String,
224}
225
226#[derive(Clone, Debug)]
228pub(crate) struct OciLogin {
229 username: String,
230 password: Token,
231}
232
233impl OciLogin {
234 pub(crate) const fn new(username: String, password: Token) -> Self {
236 Self { username, password }
237 }
238}
239
240#[derive(Debug)]
241struct OciPush {
242 name: String,
243 program: String,
244 references: Vec<String>,
245 login: OciLogin,
246}
247
248impl OciPush {
249 fn resolve(
253 entries: &[ManifestEntry],
254 publication: &Publication<'_>,
255 login: Option<&OciLogin>,
256 ) -> Result<Vec<Self>, ArtifactError> {
257 publication
258 .oci
259 .iter()
260 .map(|&(name, spec)| {
261 let entry = entries
262 .iter()
263 .find_map(|entry| match entry {
264 ManifestEntry::Oci(oci)
265 if oci.unit == publication.unit && oci.name == name =>
266 {
267 Some(oci)
268 }
269 ManifestEntry::Oci(_)
270 | ManifestEntry::Asset(_)
271 | ManifestEntry::Cargo(_)
272 | ManifestEntry::Npm(_) => None,
273 })
274 .ok_or_else(|| ArtifactError::MissingOciProgram(name.to_owned()))?;
275 if let Some(field) = spec.mismatch(entry) {
276 return Err(ArtifactError::ArtifactMismatch {
277 name: name.to_owned(),
278 field,
279 });
280 }
281 let login = login
282 .ok_or_else(|| ArtifactError::MissingRegistryLogin(name.to_owned()))?
283 .clone();
284 let tags =
285 iter::once(publication.version.as_str()).chain(spec.latest.then_some("latest"));
286 Ok(Self {
287 name: name.to_owned(),
288 program: entry.program.clone(),
289 references: tags
290 .map(|tag| format!("{}/{}:{tag}", spec.registry, spec.repository))
291 .collect(),
292 login,
293 })
294 })
295 .collect()
296 }
297
298 fn run(&self, working_directory: &Path) -> Result<(), ArtifactError> {
299 for reference in &self.references {
300 let status = Command::new(&self.program)
301 .arg(reference)
302 .env("EPHEMERIS_OCI_USERNAME", &self.login.username)
303 .env("EPHEMERIS_OCI_PASSWORD", self.login.password.expose())
304 .current_dir(working_directory)
305 .status()
306 .map_err(|source| ArtifactError::OciSpawn {
307 name: self.name.clone(),
308 program: PathBuf::from(&self.program),
309 source,
310 })?;
311 if !status.success() {
312 return Err(ArtifactError::OciPush {
313 name: self.name.clone(),
314 status,
315 });
316 }
317 }
318 Ok(())
319 }
320}
321
322#[derive(Debug)]
324pub(crate) struct Stage {
325 entries: Vec<ManifestEntry>,
326 directory: PathBuf,
327}
328
329#[derive(Debug)]
331pub(crate) struct StagedRelease<'a> {
332 publication: Publication<'a>,
333 assets: Vec<LocalAsset>,
334 pushes: Vec<OciPush>,
335}
336
337impl Stage {
338 pub(crate) fn read(manifest: &Path, directory: &Path) -> Result<Self, ArtifactError> {
343 Ok(Self {
344 entries: ManifestEntry::read_all(manifest)?,
345 directory: directory.to_owned(),
346 })
347 }
348
349 pub(crate) const fn empty() -> Self {
351 Self {
352 entries: Vec::new(),
353 directory: PathBuf::new(),
354 }
355 }
356
357 pub(crate) fn package(
367 &self,
368 unit: &str,
369 kind: PackageKind,
370 name: &str,
371 package: &str,
372 ) -> Result<PathBuf, ArtifactError> {
373 let entries: Vec<_> = self
374 .entries
375 .iter()
376 .filter_map(|entry| kind.entry(entry))
377 .filter(|entry| entry.unit == unit)
378 .collect();
379 if entries.len() > 1 {
380 return Err(ArtifactError::ArtifactCount {
381 unit: unit.to_owned(),
382 expected: 1,
383 actual: entries.len(),
384 });
385 }
386 let entry = entries
387 .first()
388 .filter(|entry| entry.name == name)
389 .ok_or_else(|| ArtifactError::MissingArtifact(name.to_owned()))?;
390 if entry.package != package {
391 return Err(ArtifactError::ArtifactMismatch {
392 name: name.to_owned(),
393 field: "package",
394 });
395 }
396 if RepositoryPath::try_from(entry.path.as_str()).is_err() {
397 return Err(ArtifactError::UnsafeFilename(entry.path.clone()));
398 }
399 let expected_path = Path::new("artifacts").join(format!("{unit}.{}", kind.extension()));
400 if Path::new(&entry.path) != expected_path {
401 return Err(ArtifactError::UnexpectedPath {
402 name: name.to_owned(),
403 path: PathBuf::from(&entry.path),
404 });
405 }
406 let path = self.directory.join(&entry.path);
407 match fs::metadata(&path) {
408 Ok(metadata) if metadata.is_file() => Ok(path),
409 Ok(_) => Err(ArtifactError::NotAFile { path }),
410 Err(source) => Err(ArtifactError::Io { path, source }),
411 }
412 }
413
414 pub(crate) fn asset_bytes(
420 &self,
421 unit: &str,
422 name: &str,
423 ) -> Result<Option<Vec<u8>>, ArtifactError> {
424 let Some(entry) = self.entries.iter().find_map(|entry| match entry {
425 ManifestEntry::Asset(asset) if asset.unit == unit && asset.name == name => Some(asset),
426 ManifestEntry::Asset(_)
427 | ManifestEntry::Oci(_)
428 | ManifestEntry::Cargo(_)
429 | ManifestEntry::Npm(_) => None,
430 }) else {
431 return Ok(None);
432 };
433 let path = self.directory.join(&entry.path);
434 let bytes = fs::read(&path).map_err(|source| ArtifactError::Io { path, source })?;
435 Ok(Some(bytes))
436 }
437
438 pub(crate) fn release<'a>(
444 &self,
445 publication: Publication<'a>,
446 login: Option<&OciLogin>,
447 ) -> Result<StagedRelease<'a>, ArtifactError> {
448 let assets = LocalAsset::load_all(&self.entries, &self.directory, &publication)?;
449 let pushes = OciPush::resolve(&self.entries, &publication, login)?;
450 Ok(StagedRelease {
451 publication,
452 assets,
453 pushes,
454 })
455 }
456}
457
458#[derive(Clone, Copy, Debug, Eq, PartialEq)]
460pub(crate) enum Prepared {
461 Published,
463 Draft(u64),
465}
466
467impl Prepared {
468 pub(crate) async fn publish<R: ForgeRelease>(self, remote: &R) -> Result<(), ArtifactError> {
474 match self {
475 Self::Published => Ok(()),
476 Self::Draft(id) => Ok(remote.publish(id).await.map_err(ForgeFailure::new)?),
477 }
478 }
479}
480
481impl StagedRelease<'_> {
482 pub(crate) async fn prepare<R: ForgeRelease>(
490 &self,
491 remote: &R,
492 ) -> Result<Prepared, ArtifactError> {
493 let publication = &self.publication;
494 let id = match remote
495 .release_by_tag(&publication.tag)
496 .await
497 .map_err(ForgeFailure::new)?
498 {
499 Some(RemoteRelease {
500 id: _,
501 draft: false,
502 }) if publication.expected.is_empty() && publication.oci.is_empty() => {
503 return Ok(Prepared::Published);
504 }
505 Some(RemoteRelease { id, draft: false }) => {
506 let existing = remote.assets(id).await.map_err(ForgeFailure::new)?;
507 return if LocalAsset::all_match(&self.assets, &existing, remote).await? {
508 Ok(Prepared::Published)
509 } else {
510 Err(ArtifactError::PublishedMismatch {
511 tag: publication.tag.clone(),
512 })
513 };
514 }
515 Some(RemoteRelease { id, draft: true }) => id,
516 None => remote
517 .create_draft(
518 &publication.tag,
519 &publication.target_commit,
520 &publication.title,
521 &publication.notes,
522 )
523 .await
524 .map_err(ForgeFailure::new)?,
525 };
526 let mut kept = BTreeSet::new();
527 for asset in remote.assets(id).await.map_err(ForgeFailure::new)? {
528 let wanted = self.assets.iter().find(|local| local.name == asset.name);
529 let keep = match wanted {
530 Some(local) if !kept.contains(&asset.name) => local.matches(&asset, remote).await?,
531 Some(_) | None => false,
532 };
533 if keep {
534 kept.insert(asset.name);
535 } else {
536 remote
537 .delete_asset(id, asset.id)
538 .await
539 .map_err(ForgeFailure::new)?;
540 }
541 }
542 for local in &self.assets {
543 if !kept.contains(&local.name) {
544 remote
545 .upload(id, &local.name, &local.bytes)
546 .await
547 .map_err(ForgeFailure::new)?;
548 }
549 }
550 let existing = remote.assets(id).await.map_err(ForgeFailure::new)?;
551 if !LocalAsset::all_match(&self.assets, &existing, remote).await? {
552 return Err(ArtifactError::IncompleteUpload {
553 tag: publication.tag.clone(),
554 });
555 }
556 for push in &self.pushes {
557 push.run(&publication.working_directory)?;
558 }
559 Ok(Prepared::Draft(id))
560 }
561}
562
563impl LocalAsset {
564 fn load_all(
565 entries: &[ManifestEntry],
566 directory: &Path,
567 publication: &Publication<'_>,
568 ) -> Result<Vec<Self>, ArtifactError> {
569 let entries: BTreeMap<_, _> = entries
570 .iter()
571 .filter_map(|entry| match entry {
572 ManifestEntry::Asset(asset) if asset.unit == publication.unit => {
573 Some((asset.name.clone(), asset))
574 }
575 ManifestEntry::Asset(_)
576 | ManifestEntry::Oci(_)
577 | ManifestEntry::Cargo(_)
578 | ManifestEntry::Npm(_) => None,
579 })
580 .collect();
581 if entries.len() != publication.expected.len() {
582 return Err(ArtifactError::ArtifactCount {
583 unit: publication.unit.clone(),
584 expected: publication.expected.len(),
585 actual: entries.len(),
586 });
587 }
588 let declared_names: BTreeSet<&str> = publication
589 .expected
590 .iter()
591 .map(|(_, spec)| spec.filename.as_str())
592 .collect();
593 let mut filenames = BTreeSet::new();
594 let mut result = Vec::new();
595 for &(name, spec) in &publication.expected {
596 let entry = entries
597 .get(name)
598 .ok_or_else(|| ArtifactError::MissingArtifact(name.to_owned()))?;
599 if let Some(field) = spec.mismatch(entry, &declared_names) {
600 return Err(ArtifactError::ArtifactMismatch {
601 name: name.to_owned(),
602 field,
603 });
604 }
605 if !filenames.insert(entry.filename.clone())
606 || RepositoryPath::try_from(entry.path.as_str()).is_err()
607 {
608 return Err(ArtifactError::UnsafeFilename(entry.filename.clone()));
609 }
610 let expected_path = Path::new("artifacts").join(&entry.filename);
611 if Path::new(&entry.path) != expected_path {
612 return Err(ArtifactError::UnexpectedPath {
613 name: entry.name.clone(),
614 path: PathBuf::from(&entry.path),
615 });
616 }
617 let path = directory.join(&entry.path);
618 let bytes = fs::read(&path).map_err(|source| ArtifactError::Io { path, source })?;
619 let sha256 = DigestAlgorithm::Sha256.hex(&bytes);
620 result.push(Self {
621 name: entry.filename.clone(),
622 bytes,
623 sha256,
624 });
625 }
626 Ok(result)
627 }
628
629 async fn all_match<R: ForgeRelease>(
630 local: &[Self],
631 remote_assets: &[RemoteAsset],
632 remote: &R,
633 ) -> Result<bool, ArtifactError> {
634 if local.len() != remote_assets.len() {
635 return Ok(false);
636 }
637 for artifact in local {
638 let Some(asset) = remote_assets
639 .iter()
640 .find(|asset| asset.name == artifact.name)
641 else {
642 return Ok(false);
643 };
644 if !artifact.matches(asset, remote).await? {
645 return Ok(false);
646 }
647 }
648 Ok(true)
649 }
650
651 async fn matches<R: ForgeRelease>(
652 &self,
653 asset: &RemoteAsset,
654 remote: &R,
655 ) -> Result<bool, ArtifactError> {
656 if usize::try_from(asset.size).ok() != Some(self.bytes.len()) {
657 return Ok(false);
658 }
659 let digest = if let Some(value) = asset.sha256.as_deref() {
660 value.strip_prefix("sha256:").unwrap_or(value).to_owned()
661 } else {
662 let bytes = remote.asset_bytes(asset).await.map_err(ForgeFailure::new)?;
663 DigestAlgorithm::Sha256.hex(&bytes)
664 };
665 Ok(digest == self.sha256)
666 }
667}
668
669impl ManifestEntry {
670 fn read_all(manifest: &Path) -> Result<Vec<Self>, ArtifactError> {
671 let raw = fs::read_to_string(manifest).map_err(|source| ArtifactError::Io {
672 path: manifest.to_owned(),
673 source,
674 })?;
675 facet_json::from_str(&raw).map_err(|source| ArtifactError::ManifestDecode {
676 path: manifest.to_owned(),
677 source: Box::new(source),
678 })
679 }
680}
681
682impl AssetSpec {
683 fn mismatch(&self, entry: &AssetEntry, declared: &BTreeSet<&str>) -> Option<&'static str> {
686 let required: BTreeSet<&str> = self.required_names.iter().map(String::as_str).collect();
687 let staged: BTreeSet<&str> = entry.required_names.iter().map(String::as_str).collect();
688 [
689 ("package", entry.package == self.package),
690 ("output", entry.output == self.output),
691 ("filename", entry.filename == self.filename),
692 ("format", entry.format == self.format),
693 ("variant", entry.variant == self.variant),
694 (
695 "required_names",
696 staged == required && required.is_subset(declared),
697 ),
698 ]
699 .into_iter()
700 .find_map(|(field, equal)| (!equal).then_some(field))
701 }
702}
703
704impl OciSpec {
705 fn mismatch(&self, entry: &OciEntry) -> Option<&'static str> {
707 [
708 ("registry", entry.registry == self.registry),
709 ("repository", entry.repository == self.repository),
710 ("latest", entry.latest == self.latest),
711 ]
712 .into_iter()
713 .find_map(|(field, equal)| (!equal).then_some(field))
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use std::cell::RefCell;
720 use std::sync::LazyLock;
721
722 use super::*;
723
724 static ASSETS: LazyLock<[AssetSpec; 2]> = LazyLock::new(|| {
725 let required_names = vec!["prod.deb".to_owned(), "test.deb".to_owned()];
726 [
727 AssetSpec {
728 package: "app".into(),
729 output: "prod".into(),
730 filename: "prod.deb".into(),
731 format: "deb".into(),
732 variant: "production".into(),
733 required_names: required_names.clone(),
734 },
735 AssetSpec {
736 package: "app-test".into(),
737 output: "test".into(),
738 filename: "test.deb".into(),
739 format: "deb".into(),
740 variant: "testbroker".into(),
741 required_names,
742 },
743 ]
744 });
745
746 static IMAGE: LazyLock<OciSpec> = LazyLock::new(|| OciSpec {
747 package: "image".into(),
748 registry: "registry.example/team".into(),
749 repository: "app".into(),
750 latest: true,
751 });
752
753 #[derive(Default)]
754 struct Fake {
755 state: RefCell<State>,
756 }
757 #[derive(Default)]
758 struct State {
759 release: Option<RemoteRelease>,
760 assets: Vec<(RemoteAsset, Vec<u8>)>,
761 calls: Vec<String>,
762 failing_uploads: usize,
763 next_id: u64,
764 }
765
766 impl ForgeRelease for Fake {
767 type Error = io::Error;
768 async fn delete_asset(&self, _: u64, asset_id: u64) -> Result<(), Self::Error> {
769 let mut state = self.state.borrow_mut();
770 let index = state
771 .assets
772 .iter()
773 .position(|(asset, _)| asset.id == asset_id)
774 .ok_or_else(|| io::Error::other("no such asset"))?;
775 let (asset, _) = state.assets.remove(index);
776 state.calls.push(format!("delete:{}", asset.name));
777 drop(state);
778 Ok(())
779 }
780 async fn release_by_tag(&self, _: &str) -> Result<Option<RemoteRelease>, Self::Error> {
781 Ok(self.state.borrow().release)
782 }
783 async fn create_draft(
784 &self,
785 _: &str,
786 _: &str,
787 _: &str,
788 _: &str,
789 ) -> Result<u64, Self::Error> {
790 let mut state = self.state.borrow_mut();
791 state.calls.push("draft".into());
792 state.release = Some(RemoteRelease { id: 1, draft: true });
793 drop(state);
794 Ok(1)
795 }
796 async fn assets(&self, _: u64) -> Result<Vec<RemoteAsset>, Self::Error> {
797 Ok(self
798 .state
799 .borrow()
800 .assets
801 .iter()
802 .map(|(asset, _)| asset.clone())
803 .collect())
804 }
805 async fn upload(&self, _: u64, filename: &str, bytes: &[u8]) -> Result<(), Self::Error> {
806 let mut state = self.state.borrow_mut();
807 if state.failing_uploads > 0 {
808 state.failing_uploads = state.failing_uploads.saturating_sub(1);
809 return Err(io::Error::other("upload interrupted"));
810 }
811 state.calls.push(format!("upload:{filename}"));
812 let digest = DigestAlgorithm::Sha256.hex(bytes);
813 state.next_id = state.next_id.saturating_add(1);
814 let id = state.next_id.saturating_add(100);
815 state.assets.push((
816 RemoteAsset {
817 id,
818 name: filename.into(),
819 size: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
820 sha256: Some(format!("sha256:{digest}")),
821 download_url: String::new(),
822 },
823 bytes.to_vec(),
824 ));
825 drop(state);
826 Ok(())
827 }
828 async fn asset_bytes(&self, asset: &RemoteAsset) -> Result<Vec<u8>, Self::Error> {
829 Ok(self
830 .state
831 .borrow()
832 .assets
833 .iter()
834 .find(|(candidate, _)| candidate.id == asset.id)
835 .map(|(_, bytes)| bytes.clone())
836 .unwrap_or_default())
837 }
838 async fn publish(&self, _: u64) -> Result<(), Self::Error> {
839 self.state.borrow_mut().calls.push("publish".into());
840 Ok(())
841 }
842 }
843
844 async fn publish(
845 manifest: &Path,
846 directory: &Path,
847 publication: &Publication<'_>,
848 remote: &Fake,
849 ) -> Result<(), ArtifactError> {
850 let login = OciLogin::new("bot".into(), Token::new("secret"));
851 let release =
852 Stage::read(manifest, directory)?.release(publication.clone(), Some(&login))?;
853 release.prepare(remote).await?.publish(remote).await
854 }
855
856 fn asset(id: u64, name: &str, bytes: &[u8]) -> (RemoteAsset, Vec<u8>) {
857 (
858 RemoteAsset {
859 id,
860 name: name.into(),
861 size: u64::try_from(bytes.len()).unwrap(),
862 sha256: None,
863 download_url: String::new(),
864 },
865 bytes.to_vec(),
866 )
867 }
868
869 fn fixture() -> (tempfile::TempDir, PathBuf, Publication<'static>) {
870 let directory = tempfile::tempdir().unwrap();
871 fs::create_dir_all(directory.path().join("artifacts")).unwrap();
872 fs::write(directory.path().join("artifacts/prod.deb"), b"prod").unwrap();
873 fs::write(directory.path().join("artifacts/test.deb"), b"test").unwrap();
874 let manifest = directory.path().join("manifest.json");
875 fs::write(&manifest, r#"[{"kind":"asset","unit":"app","name":"production","package":"app","output":"prod","filename":"prod.deb","format":"deb","variant":"production","required_names":["prod.deb","test.deb"],"path":"artifacts/prod.deb"},{"kind":"asset","unit":"app","name":"testbroker","package":"app-test","output":"test","filename":"test.deb","format":"deb","variant":"testbroker","required_names":["prod.deb","test.deb"],"path":"artifacts/test.deb"}]"#).unwrap();
876 let publication = Publication {
877 unit: "app".into(),
878 tag: "app-v1-1".into(),
879 target_commit: "abc".into(),
880 title: "App".into(),
881 notes: String::new(),
882 expected: vec![("production", &ASSETS[0]), ("testbroker", &ASSETS[1])],
883 oci: Vec::new(),
884 version: "1".into(),
885 working_directory: directory.path().to_owned(),
886 };
887 (directory, manifest, publication)
888 }
889
890 #[tokio::test]
891 async fn verifies_all_outputs_before_creating_a_draft_then_uploads_and_publishes() {
892 let (directory, manifest, publication) = fixture();
893 let remote = Fake::default();
894 publish(&manifest, directory.path(), &publication, &remote)
895 .await
896 .unwrap();
897 assert_eq!(
898 remote.state.borrow_mut().calls,
899 ["draft", "upload:prod.deb", "upload:test.deb", "publish"]
900 );
901 }
902
903 #[tokio::test]
904 async fn a_stale_staged_field_names_the_mismatch_before_any_remote_call() {
905 let (directory, manifest, publication) = fixture();
906 let raw = fs::read_to_string(&manifest).unwrap();
907 fs::write(
908 &manifest,
909 raw.replace(r#""variant":"production""#, r#""variant":"old""#),
910 )
911 .unwrap();
912 let remote = Fake::default();
913 assert!(matches!(
914 publish(&manifest, directory.path(), &publication, &remote).await,
915 Err(ArtifactError::ArtifactMismatch { name, field })
916 if name == "production" && field == "variant"
917 ));
918 assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
919 }
920
921 #[tokio::test]
922 async fn staged_required_names_must_equal_the_configured_ones() {
923 let (directory, manifest, publication) = fixture();
924 let raw = fs::read_to_string(&manifest).unwrap();
925 fs::write(
926 &manifest,
927 raw.replacen(
928 r#""required_names":["prod.deb","test.deb"]"#,
929 r#""required_names":["prod.deb"]"#,
930 1,
931 ),
932 )
933 .unwrap();
934 let remote = Fake::default();
935 assert!(matches!(
936 publish(&manifest, directory.path(), &publication, &remote).await,
937 Err(ArtifactError::ArtifactMismatch { name, field })
938 if name == "production" && field == "required_names"
939 ));
940 }
941
942 #[tokio::test]
943 async fn a_missing_required_output_causes_no_remote_calls() {
944 let (directory, manifest, publication) = fixture();
945 fs::remove_file(directory.path().join("artifacts/test.deb")).unwrap();
946 let remote = Fake::default();
947 assert!(
948 publish(&manifest, directory.path(), &publication, &remote)
949 .await
950 .is_err()
951 );
952 assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
953 }
954
955 #[tokio::test]
956 async fn retries_verify_existing_bytes_and_never_change_a_published_release() {
957 let (directory, manifest, publication) = fixture();
958 let remote = Fake::default();
959 publish(&manifest, directory.path(), &publication, &remote)
960 .await
961 .unwrap();
962 remote.state.borrow_mut().release.as_mut().unwrap().draft = false;
963 remote.state.borrow_mut().calls.clear();
964 publish(&manifest, directory.path(), &publication, &remote)
965 .await
966 .unwrap();
967 assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
968 remote.state.borrow_mut().assets[0].1 = b"wrng".to_vec();
969 remote.state.borrow_mut().assets[0].0.sha256 = None;
970 assert!(matches!(
971 publish(&manifest, directory.path(), &publication, &remote).await,
972 Err(ArtifactError::PublishedMismatch { tag }) if tag == "app-v1-1"
973 ));
974 assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
975 }
976
977 #[tokio::test]
978 async fn a_published_release_of_a_unit_without_artifacts_is_not_inspected() {
979 let (directory, manifest, mut publication) = fixture();
980 fs::write(&manifest, "[]").unwrap();
981 publication.expected.clear();
982 let remote = Fake {
983 state: RefCell::new(State {
984 release: Some(RemoteRelease {
985 id: 1,
986 draft: false,
987 }),
988 assets: vec![asset(1, "hand.txt", b"hand")],
989 ..State::default()
990 }),
991 };
992 publish(&manifest, directory.path(), &publication, &remote)
993 .await
994 .unwrap();
995 assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
996 }
997
998 #[tokio::test]
999 async fn a_rerun_after_a_failed_upload_repairs_a_draft_with_wrong_and_extra_assets() {
1000 let (directory, manifest, publication) = fixture();
1001 let remote = Fake {
1002 state: RefCell::new(State {
1003 release: Some(RemoteRelease { id: 1, draft: true }),
1004 assets: vec![
1005 asset(1, "prod.deb", b"prod"),
1006 asset(2, "test.deb", b"tset"),
1007 asset(3, "stale.deb", b"stale"),
1008 ],
1009 failing_uploads: 1,
1010 ..State::default()
1011 }),
1012 };
1013 assert!(matches!(
1014 publish(&manifest, directory.path(), &publication, &remote).await,
1015 Err(ArtifactError::Remote(_))
1016 ));
1017 assert_eq!(
1018 remote.state.borrow_mut().calls,
1019 ["delete:test.deb", "delete:stale.deb"]
1020 );
1021 remote.state.borrow_mut().calls.clear();
1022 publish(&manifest, directory.path(), &publication, &remote)
1023 .await
1024 .unwrap();
1025 assert_eq!(
1026 remote.state.borrow_mut().calls,
1027 ["upload:test.deb", "publish"]
1028 );
1029 let names: Vec<_> = remote
1030 .state
1031 .borrow()
1032 .assets
1033 .iter()
1034 .map(|(asset, bytes)| (asset.name.clone(), bytes.clone()))
1035 .collect();
1036 assert_eq!(
1037 names,
1038 [
1039 ("prod.deb".to_owned(), b"prod".to_vec()),
1040 ("test.deb".to_owned(), b"test".to_vec())
1041 ]
1042 );
1043 }
1044
1045 #[cfg(unix)]
1046 fn with_oci_program(
1047 directory: &Path,
1048 manifest: &Path,
1049 publication: &mut Publication<'static>,
1050 script: &str,
1051 ) {
1052 use std::os::unix::fs::PermissionsExt as _;
1053
1054 let program = directory.join("push");
1055 fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap();
1056 fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).unwrap();
1057 let raw = fs::read_to_string(manifest).unwrap();
1058 let oci = format!(
1059 r#"{{"kind":"oci","unit":"app","name":"image","package":"image","registry":"registry.example/team","repository":"app","latest":true,"program":{program:?}}}"#
1060 );
1061 fs::write(manifest, format!("{},{oci}]", raw.trim_end_matches(']'))).unwrap();
1062 publication.oci.push(("image", &IMAGE));
1063 }
1064
1065 #[cfg(unix)]
1066 #[tokio::test]
1067 async fn oci_pushes_run_after_the_uploads_and_before_the_release_is_published() {
1068 let (directory, manifest, mut publication) = fixture();
1069 with_oci_program(
1070 directory.path(),
1071 &manifest,
1072 &mut publication,
1073 "printf '%s\\n' \"$1\" >> \"$(dirname \"$0\")/pushed\"",
1074 );
1075 let remote = Fake::default();
1076 publish(&manifest, directory.path(), &publication, &remote)
1077 .await
1078 .unwrap();
1079 assert_eq!(
1080 remote.state.borrow_mut().calls,
1081 ["draft", "upload:prod.deb", "upload:test.deb", "publish"]
1082 );
1083 assert_eq!(
1084 fs::read_to_string(directory.path().join("pushed")).unwrap(),
1085 "registry.example/team/app:1\nregistry.example/team/app:latest\n"
1086 );
1087 }
1088
1089 #[cfg(unix)]
1090 #[tokio::test]
1091 async fn a_failed_oci_push_leaves_the_release_a_draft() {
1092 let (directory, manifest, mut publication) = fixture();
1093 with_oci_program(directory.path(), &manifest, &mut publication, "exit 3");
1094 let remote = Fake::default();
1095 let result = publish(&manifest, directory.path(), &publication, &remote).await;
1096 assert!(matches!(
1097 result,
1098 Err(ArtifactError::OciPush { name, status })
1099 if name == "image" && status.code() == Some(3)
1100 ));
1101 assert_eq!(
1102 remote.state.borrow_mut().calls,
1103 ["draft", "upload:prod.deb", "upload:test.deb"]
1104 );
1105 }
1106
1107 #[cfg(unix)]
1108 #[tokio::test]
1109 async fn a_published_release_is_not_pushed_again() {
1110 let (directory, manifest, mut publication) = fixture();
1111 let remote = Fake::default();
1112 publish(&manifest, directory.path(), &publication, &remote)
1113 .await
1114 .unwrap();
1115 remote.state.borrow_mut().release.as_mut().unwrap().draft = false;
1116 with_oci_program(
1117 directory.path(),
1118 &manifest,
1119 &mut publication,
1120 "touch \"$(dirname \"$0\")/pushed\"",
1121 );
1122 publish(&manifest, directory.path(), &publication, &remote)
1123 .await
1124 .unwrap();
1125 assert!(!directory.path().join("pushed").exists());
1126 }
1127}