use super::{PreparedDependencies, TargetAdapter};
use crate::command::{CommandRequest, CommandRunner};
use crate::config::TargetConfig;
use crate::domain::{ArtifactManifest, ReleaseCandidate, TargetPlan};
use crate::oci::{
OCI_REGISTRY_PUBLISHER, OciClient, PreparedImageAction, PreparedOciImage, PreparedOciOutput,
canonical_reference, command_error, image_identity, image_reference,
};
use crate::tag::TagCatalog;
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct OciImageTarget {
root: PathBuf,
name: String,
depends_on: Vec<String>,
image: String,
platform: String,
reuse_check: Vec<String>,
build: Vec<String>,
client: OciClient,
runner: Arc<dyn CommandRunner>,
}
enum ReuseDecision {
Reuse,
Build,
}
impl OciImageTarget {
pub fn new(
root: impl Into<PathBuf>,
config: &TargetConfig,
runner: Arc<dyn CommandRunner>,
) -> Result<Self> {
let TargetConfig::OciImage {
name,
depends_on,
image,
platform,
reuse_check,
build,
..
} = config
else {
bail!("OciImageTarget requires an oci_image target config");
};
let root = root.into();
Ok(Self {
client: OciClient::new(&root, Arc::clone(&runner)),
root,
name: name.clone(),
depends_on: depends_on.clone(),
image: image.clone(),
platform: platform.clone(),
reuse_check: reuse_check.clone(),
build: build.clone(),
runner,
})
}
fn dependency_outputs(
&self,
plan: &TargetPlan,
dependencies: &PreparedDependencies,
) -> Result<BTreeMap<String, PreparedOciOutput>> {
if dependencies.len() != self.depends_on.len()
|| self
.depends_on
.iter()
.any(|name| !dependencies.contains_key(name))
{
bail!(
"OCI target `{}` received a different dependency set than its ReleasePlan",
self.name
);
}
self.depends_on
.iter()
.map(|name| {
let manifest = &dependencies[name];
if manifest.target != *name {
bail!("OCI dependency manifest key/name mismatch for `{name}`");
}
if manifest.release != plan.release {
bail!(
"OCI dependency `{name}` was prepared for a different release than target `{}`",
self.name
);
}
Ok((name.clone(), PreparedOciImage::read(manifest)?.output()))
})
.collect()
}
fn environment(
&self,
plan: &TargetPlan,
current_reference: &str,
dependencies: &BTreeMap<String, PreparedOciOutput>,
) -> Result<BTreeMap<String, String>> {
Ok(BTreeMap::from([
("RELEASE_TARGET".to_owned(), self.name.clone()),
("RELEASE_VERSION".to_owned(), plan.release.tag.to_string()),
("RELEASE_COMMIT".to_owned(), plan.release.commit.clone()),
("RELEASE_IMAGE".to_owned(), current_reference.to_owned()),
(
"RELEASE_DEPENDENCIES_JSON".to_owned(),
serde_json::to_string(dependencies)?,
),
]))
}
fn reuse_decision(
&self,
plan: &TargetPlan,
current_reference: &str,
candidate_reference: &str,
dependencies: &BTreeMap<String, PreparedOciOutput>,
) -> Result<ReuseDecision> {
let (program, arguments) = self
.reuse_check
.split_first()
.context("OCI reuse_check command must not be empty")?;
let mut request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
request.environment = self.environment(plan, current_reference, dependencies)?;
request.environment.insert(
"RELEASE_CANDIDATE_IMAGE".to_owned(),
candidate_reference.to_owned(),
);
let result = self.runner.execute(&request)?;
match result.status {
0 => Ok(ReuseDecision::Reuse),
10 => Ok(ReuseDecision::Build),
_ => Err(command_error(
result,
&format!("check whether OCI target `{}` can be reused", self.name),
)),
}
}
fn build_image(
&self,
plan: &TargetPlan,
current_reference: &str,
dependencies: &BTreeMap<String, PreparedOciOutput>,
) -> Result<(PreparedImageAction, crate::oci::ImageConfigMetadata)> {
let (program, arguments) = self
.build
.split_first()
.context("OCI build command must not be empty")?;
let mut request = CommandRequest::new(program, arguments.iter().cloned(), &self.root);
request.environment = self.environment(plan, current_reference, dependencies)?;
self.runner
.execute_inheriting_output(&request)?
.require_success(&format!("build OCI target `{}`", self.name))?;
let local = self.client.local_image(current_reference)?;
self.validate_platform(&local.platform)?;
if self.client.remote_image(current_reference)?.is_some() {
bail!(
"OCI image `{current_reference}` appeared during Prepare; refusing to overwrite or adopt a concurrent publication"
);
}
Ok((
PreparedImageAction::Build {
local_reference: current_reference.to_owned(),
},
local,
))
}
fn previous_candidate(
&self,
release: &ReleaseCandidate,
) -> Result<Option<(String, crate::oci::RemoteImageMetadata)>> {
let request = CommandRequest::new("git", ["ls-remote", "--tags", "origin"], &self.root);
let output = self
.runner
.execute(&request)?
.require_success("list previous sealed releases for OCI reuse")?
.stdout;
let tags = TagCatalog::parse_ls_remote(&output)?;
let mut candidates = tags.iter().collect::<Vec<_>>();
candidates.reverse();
for candidate in candidates {
if !candidate.annotated || candidate.name >= release.tag {
continue;
}
let (repository, _, tagged_reference) =
image_reference(&self.image, &candidate.name.to_string())?;
let Some(remote) = self.client.remote_image(&tagged_reference)? else {
continue;
};
self.validate_platform(&remote.config.platform)?;
return Ok(Some((
canonical_reference(&repository, &remote.manifest_digest),
remote,
)));
}
Ok(None)
}
fn validate_platform(&self, actual: &str) -> Result<()> {
if actual != self.platform {
bail!(
"OCI target `{}` platform is {actual}, expected {}",
self.name,
self.platform
);
}
Ok(())
}
}
impl TargetAdapter for OciImageTarget {
fn resolve(&self, release: &ReleaseCandidate) -> Result<TargetPlan> {
Ok(TargetPlan {
name: self.name.clone(),
publisher: OCI_REGISTRY_PUBLISHER.to_owned(),
release: release.clone(),
artifacts: vec![image_identity(&self.image, &self.platform, release)?],
})
}
fn prepare(
&self,
plan: &TargetPlan,
dependencies: &PreparedDependencies,
staging: &Path,
) -> Result<ArtifactManifest> {
if plan.name != self.name || plan.publisher != OCI_REGISTRY_PUBLISHER {
bail!("target plan does not belong to OCI target `{}`", self.name);
}
let expected = self.resolve(&plan.release)?;
if expected.artifacts != plan.artifacts {
bail!("OCI target plan inventory changed before Prepare");
}
let dependency_outputs = self.dependency_outputs(plan, dependencies)?;
let (repository, tag, current_reference) =
image_reference(&self.image, &plan.release.tag.to_string())?;
let (action, config) = if let Some(current) =
self.client.remote_image(¤t_reference)?
{
self.validate_platform(¤t.config.platform)?;
if !plan.release.tag_already_sealed {
bail!(
"OCI artifacts exist for an unsealed release candidate; refusing to adopt orphan publication state"
);
}
let candidate = canonical_reference(&repository, ¤t.manifest_digest);
match self.reuse_decision(plan, ¤t_reference, &candidate, &dependency_outputs)? {
ReuseDecision::Reuse => (
PreparedImageAction::Existing {
manifest_digest: current.manifest_digest,
},
current.config,
),
ReuseDecision::Build => bail!(
"current OCI image `{current_reference}` does not satisfy target `{}`; refusing to overwrite an immutable release tag",
self.name
),
}
} else if let Some((candidate, previous)) = self.previous_candidate(&plan.release)? {
match self.reuse_decision(plan, ¤t_reference, &candidate, &dependency_outputs)? {
ReuseDecision::Reuse => (
PreparedImageAction::Reuse {
source_reference: candidate,
manifest_digest: previous.manifest_digest,
},
previous.config,
),
ReuseDecision::Build => {
self.build_image(plan, ¤t_reference, &dependency_outputs)?
}
}
} else {
self.build_image(plan, ¤t_reference, &dependency_outputs)?
};
let prepared = PreparedOciImage {
schema_version: 1,
target: self.name.clone(),
repository,
tag,
platform: self.platform.clone(),
config_digest: config.config_digest,
action,
};
let artifact = prepared.write(&plan.artifacts[0], staging)?;
Ok(ArtifactManifest {
target: self.name.clone(),
publisher: OCI_REGISTRY_PUBLISHER.to_owned(),
release: plan.release.clone(),
artifacts: vec![artifact],
})
}
}