use super::{PreparedDependencies, TargetAdapter};
use crate::command::{CommandRequest, CommandRunner};
use crate::config::TargetConfig;
use crate::domain::{
ArtifactIdentity, ArtifactManifest, PreparedArtifact, ReleaseCandidate, TargetPlan,
};
use crate::lifecycle::expand;
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct DockerArchiveTarget {
root: PathBuf,
name: String,
publisher: String,
platform: String,
image: String,
asset: String,
build: Vec<String>,
local_check: Vec<String>,
runner: Arc<dyn CommandRunner>,
}
impl DockerArchiveTarget {
pub fn new(
root: impl Into<PathBuf>,
config: &TargetConfig,
runner: Arc<dyn CommandRunner>,
) -> Result<Self> {
let TargetConfig::DockerArchive {
name,
publisher,
platform,
image,
asset,
build,
local_check,
..
} = config
else {
bail!("DockerArchiveTarget requires a docker_archive target config");
};
Ok(Self {
root: root.into(),
name: name.clone(),
publisher: publisher.clone(),
platform: platform.clone(),
image: image.clone(),
asset: asset.clone(),
build: build.clone(),
local_check: local_check.clone(),
runner,
})
}
fn run_configured(&self, command: &[String], version: &str, image: &str) -> Result<()> {
let expanded = command
.iter()
.map(|argument| expand(argument, version, Some(image)))
.collect::<Result<Vec<_>>>()?;
let (program, arguments) = expanded
.split_first()
.context("configured command must not be empty")?;
let request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
self.runner
.execute_inheriting_output(&request)?
.require_success(&format!("{program} {}", arguments.join(" ")))?;
Ok(())
}
fn run(&self, program: &str, arguments: &[String]) -> Result<String> {
let request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
Ok(self
.runner
.execute(&request)?
.require_success(&format!("{program} {}", arguments.join(" ")))?
.stdout)
}
}
impl TargetAdapter for DockerArchiveTarget {
fn resolve(&self, release: &ReleaseCandidate) -> Result<TargetPlan> {
let version = release.tag.to_string();
let asset = expand(&self.asset, &version, None)?;
Ok(TargetPlan {
name: self.name.clone(),
publisher: self.publisher.clone(),
release: release.clone(),
artifacts: vec![
ArtifactIdentity::GithubReleaseAsset {
name: asset.clone(),
},
ArtifactIdentity::GithubReleaseAsset {
name: format!("{asset}.sha256"),
},
],
})
}
fn prepare(
&self,
plan: &TargetPlan,
dependencies: &PreparedDependencies,
staging: &Path,
) -> Result<ArtifactManifest> {
if !dependencies.is_empty() {
bail!("Docker archive targets do not accept target dependencies");
}
if plan.name != self.name || plan.publisher != self.publisher {
bail!(
"target plan does not belong to Docker target `{}`",
self.name
);
}
let expected = self.resolve(&plan.release)?;
if expected.artifacts != plan.artifacts {
bail!("Docker target plan artifact inventory changed before Prepare");
}
fs::create_dir_all(staging)
.with_context(|| format!("failed to create staging directory {}", staging.display()))?;
let version = plan.release.tag.to_string();
let image = expand(&self.image, &version, None)?;
self.run_configured(&self.build, &version, &image)?;
self.run_configured(&self.local_check, &version, &image)?;
let inspect_arguments = vec![
"image".to_owned(),
"inspect".to_owned(),
"--format".to_owned(),
"{{.Os}}/{{.Architecture}}".to_owned(),
image.clone(),
];
let actual_platform = self.run("docker", &inspect_arguments)?;
if actual_platform.trim() != self.platform {
bail!(
"Docker image {image} has platform {}, expected {}",
actual_platform.trim(),
self.platform
);
}
let raw = staging.join("image.tar");
let compressed_raw = staging.join("image.tar.xz");
let asset_name = expand(&self.asset, &version, None)?;
let archive = staging.join(&asset_name);
let checksum = staging.join(format!("{asset_name}.sha256"));
for path in [&raw, &compressed_raw, &archive, &checksum] {
if path.exists() {
fs::remove_file(path)
.with_context(|| format!("failed to replace {}", path.display()))?;
}
}
self.run(
"docker",
&[
"save".to_owned(),
"-o".to_owned(),
raw.display().to_string(),
image,
],
)?;
self.run("xz", &["--threads=0".to_owned(), raw.display().to_string()])?;
fs::rename(&compressed_raw, &archive).with_context(|| {
format!(
"failed to move {} to {}",
compressed_raw.display(),
archive.display()
)
})?;
let archive_digest = sha256(&archive)?;
fs::write(&checksum, format!("{archive_digest} {asset_name}\n"))
.with_context(|| format!("failed to write {}", checksum.display()))?;
let checksum_digest = sha256(&checksum)?;
let identities = &plan.artifacts;
if identities.len() != 2 {
bail!("Docker target plan must contain archive and checksum identities");
}
Ok(ArtifactManifest {
target: self.name.clone(),
publisher: self.publisher.clone(),
release: plan.release.clone(),
artifacts: vec![
PreparedArtifact {
identity: identities[0].clone(),
path: archive,
sha256: archive_digest,
},
PreparedArtifact {
identity: identities[1].clone(),
path: checksum,
sha256: checksum_digest,
},
],
})
}
}
fn sha256(path: &Path) -> Result<String> {
let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
Ok(hex::encode(Sha256::digest(bytes)))
}