Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Sync OCI container images across registries - efficiently.

<p align="center">
<img src="docs/public/ecr-banner.svg" alt="ocync - 4x faster sync, 30% fewer API requests, adaptive rate control" width="900">
<img src="docs/public/ecr-banner.png" alt="ocync - 4x faster sync, 30% fewer API requests, adaptive rate control" width="900">
</p>

[![CI](https://github.com/clowdhaus/ocync/actions/workflows/ci.yml/badge.svg)](https://github.com/clowdhaus/ocync/actions/workflows/ci.yml)
Expand Down
8 changes: 5 additions & 3 deletions crates/ocync-distribution/src/auth/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ async fn run_credential_helper(helper: &str, registry: &str) -> Result<Credentia
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
tracing::warn!(helper = %program, registry, error = %e, "credential helper failed to execute");
tracing::debug!(helper = %program, registry, error = %e, "credential helper failed to execute");
Error::CredentialHelperFailed {
helper: program.clone(),
reason: format!("failed to execute: {e}"),
Expand All @@ -263,14 +263,16 @@ async fn run_credential_helper(helper: &str, registry: &str) -> Result<Credentia
)
.await
.map_err(|_| {
// Timeout is a real operational problem (hung helper, locked keychain).
// Distinct from the noisy fallback paths around it -- keep at WARN.
tracing::warn!(helper = %program, registry, "credential helper timed out after 30s");
Error::CredentialHelperFailed {
helper: program.clone(),
reason: "timed out after 30s (helper may be waiting for interactive input)".into(),
}
})?
.map_err(|e| {
tracing::warn!(helper = %program, registry, error = %e, "credential helper failed to execute");
tracing::debug!(helper = %program, registry, error = %e, "credential helper failed to execute");
Error::CredentialHelperFailed {
helper: program.clone(),
reason: format!("failed to execute: {e}"),
Expand All @@ -279,7 +281,7 @@ async fn run_credential_helper(helper: &str, registry: &str) -> Result<Credentia

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(helper = %program, registry, status = %output.status, "credential helper exited with error");
tracing::debug!(helper = %program, registry, status = %output.status, "credential helper exited with error");
return Err(Error::CredentialHelperFailed {
helper: program,
reason: format!("exited with {}: {}", output.status, stderr.trim()),
Expand Down
14 changes: 7 additions & 7 deletions crates/ocync-sync/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,7 @@ impl SyncEngine {
for tag_pair in &mapping.tags {
// Tier 1: immutable tag skip (0 API calls).
if mapping.should_skip_immutable(&tag_pair.target) {
info!(
debug!(
source_repo = %mapping.source_repo,
tag = %tag_pair.target,
"skipping -- immutable tag exists at all targets"
Expand Down Expand Up @@ -1430,7 +1430,7 @@ async fn check_targets_against_digest(params: TargetCheckParams<'_>) -> TargetCh
while let Some((target_name, target_client, batch_checker, result)) = head_checks.next().await {
match result {
Ok(Some(head)) if head.digest == *compare_digest => {
info!(
debug!(
source_repo = %source.repo,
source_tag = %source.tag,
target_repo = %target.repo,
Expand Down Expand Up @@ -1574,7 +1574,7 @@ async fn full_pull_and_build_tasks(params: FullPullParams<'_>) -> DiscoveryOutco
while let Some((target_name, target_client, batch_checker, result)) = head_checks.next().await {
match result {
Ok(Some(head)) if head.digest == *source_digest => {
info!(
debug!(
source_repo = %source.repo,
source_tag = %source.tag,
target_repo = %target.repo,
Expand Down Expand Up @@ -1763,7 +1763,7 @@ async fn execute_item(
.notify_repo_failed(&item.target_name, &item.target.repo);

if is_immutable_tag_error(&err) {
info!(
debug!(
source_repo = %item.source.repo,
target_repo = %item.target.repo,
target_tag = %item.target.tag,
Expand Down Expand Up @@ -2868,7 +2868,7 @@ async fn discover_referrers(
}
},
Err(e) if e.is_not_found() => {
info!(
debug!(
repo = %source_repo,
digest = %parent_digest,
"no referrers found (API 404, tag fallback 404)"
Expand All @@ -2877,7 +2877,7 @@ async fn discover_referrers(
}
Err(e) => {
// Non-404 error on fallback is not fatal; log and continue.
info!(
debug!(
repo = %source_repo,
digest = %parent_digest,
error = %e,
Expand All @@ -2891,7 +2891,7 @@ async fn discover_referrers(
Err(e) => {
// Non-404 error from referrers API. Log and continue rather
// than failing the entire image sync for an artifact query.
info!(
debug!(
repo = %source_repo,
digest = %parent_digest,
error = %e,
Expand Down
186 changes: 166 additions & 20 deletions crates/ocync-sync/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::sync::OnceLock;

use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
use tracing::warn;
use tracing::debug;

use crate::Error;

Expand Down Expand Up @@ -111,6 +111,48 @@ impl FilterConfig {
self.run_pipeline(tags, true)
}

/// One-line summary of the active filter clauses, e.g.
/// `semver >=1.0.0, latest=5`. Returns `None` when no filtering applies.
///
/// Sole formatter for filter rationale shown in non-dry-run logs; uses
/// the same `FilterConfig` fields the pipeline operates on so adding a
/// new field to the config will fail tests here before it ships.
pub fn describe(&self) -> Option<String> {
let mut parts = Vec::new();
if !self.glob.is_empty() {
parts.push(format!("glob {}", self.glob.join(",")));
}
if let Some(ref s) = self.semver {
parts.push(format!("semver {s}"));
}
if !self.exclude.is_empty() {
parts.push(format!("exclude {}", self.exclude.join(",")));
}
if !self.include.is_empty() {
parts.push(format!("include {}", self.include.join(",")));
}
if let Some(order) = self.sort {
parts.push(format!(
"sort {}",
match order {
SortOrder::Semver => "semver",
SortOrder::Alpha => "alpha",
}
));
}
if let Some(n) = self.latest {
parts.push(format!("latest={n}"));
}
if let Some(n) = self.min_tags {
parts.push(format!("min_tags={n}"));
}
if parts.is_empty() {
None
} else {
Some(parts.join(", "))
}
}

/// Shared pipeline implementation. When `track` is false (the real-sync
/// hot path), per-stage `StageDelta` and per-reason `DropReason` are not
/// constructed; the resulting `Filtered.report` carries empty vectors.
Expand Down Expand Up @@ -171,18 +213,18 @@ impl FilterConfig {
range: range.to_owned(),
reason: e.to_string(),
})?;
let (kept, dropped) =
partition_with_drop(
&pipeline,
track,
|t| match crate::version::TagVersion::parse(t) {
Some(ver) => req.matches(&ver),
None => {
warn!(tag = t, "tag is not parseable as a version, dropping");
false
}
},
);
let (kept, dropped) = partition_with_drop(&pipeline, track, |t| {
if is_referrers_fallback_tag(t) {
return false;
}
match crate::version::TagVersion::parse(t) {
Some(ver) => req.matches(&ver),
None => {
debug!(tag = t, "tag is not parseable as a version, dropping");
false
}
}
});
push_drop_reason(
&mut drop_reasons,
track,
Expand Down Expand Up @@ -248,7 +290,19 @@ impl FilterConfig {
}

if let Some(order) = self.sort {
let before = pipeline.len();
sort_tags_in_place(&mut pipeline, order);
if track {
let label = match order {
SortOrder::Semver => "sort semver desc",
SortOrder::Alpha => "sort alpha desc",
};
pipeline_stages.push(StageDelta {
label: label.to_string(),
count_in: before,
count_out: pipeline.len(),
});
}
}
if let Some(n) = self.latest {
let before = pipeline.len();
Expand All @@ -265,13 +319,8 @@ impl FilterConfig {
pipeline.truncate(n);
}
if track {
let label = match self.sort {
Some(SortOrder::Semver) => format!("sort semver desc, latest {n}"),
Some(SortOrder::Alpha) => format!("sort alpha desc, latest {n}"),
None => format!("latest {n}"),
};
pipeline_stages.push(StageDelta {
label,
label: format!("keep latest {n}"),
count_in: before,
count_out: pipeline.len(),
});
Expand Down Expand Up @@ -477,6 +526,18 @@ fn push_drop_reason(
// Individual stages
// ---------------------------------------------------------------------------

/// True for OCI 1.1 referrers fallback tags (`<algo>-<hex>` and the cosign
/// `.sig`/`.sbom`/`.att` variants). These are pointers to artifacts, not image
/// versions, and will never satisfy a semver range -- skip parsing so they do
/// not appear in the unparseable-tag log channel.
///
/// Public so that observability/UX code outside the filter pipeline (e.g. the
/// CLI's "no tags matched" warn) can partition source tag lists without
/// reintroducing a duplicate prefix check that would drift over time.
pub fn is_referrers_fallback_tag(tag: &str) -> bool {
tag.starts_with("sha256-") || tag.starts_with("sha512-")
}

/// Build a [`GlobSet`] from patterns, returning an error on invalid patterns.
pub fn build_glob_set(patterns: &[String]) -> Result<GlobSet, Error> {
let mut builder = GlobSetBuilder::new();
Expand Down Expand Up @@ -520,8 +581,11 @@ fn filter_semver<'a>(tags: &[&'a str], range: &str) -> Result<Vec<&'a str>, Erro
.iter()
.copied()
.filter(|tag| {
if is_referrers_fallback_tag(tag) {
return false;
}
let Some(ver) = crate::version::TagVersion::parse(tag) else {
warn!(tag, "tag is not parseable as a version, dropping");
debug!(tag, "tag is not parseable as a version, dropping");
return false;
};
req.matches(&ver)
Expand Down Expand Up @@ -728,6 +792,24 @@ mod tests {
assert_eq!(result, vec!["1.0.0"]);
}

/// Referrers fallback tags (cosign signatures, SBOMs, attestations) bypass
/// the version parser. They drop silently so noisy unparseable-tag logs do
/// not fire once per artifact tag per image.
#[test]
fn semver_skips_referrers_fallback_tags() {
let tags = vec![
"1.0.0",
"sha256-abc123def456.sig",
"sha256-abc123def456.sbom",
"sha256-abc123def456.att",
"sha256-abc123def456",
"sha512-deadbeef.sig",
"2.0.0",
];
let result = filter_semver(&tags, ">=1.0.0").unwrap();
assert_eq!(result, vec!["1.0.0", "2.0.0"]);
}

// - pipeline tests ----------------------------------------------------

#[test]
Expand Down Expand Up @@ -1378,4 +1460,68 @@ mod tests {
assert_eq!(reason.count, reason.samples.len());
}
}

// - describe ----------------------------------------------------------

#[test]
fn describe_default_returns_none() {
assert!(FilterConfig::default().describe().is_none());
}

#[test]
fn describe_combines_clauses_in_pipeline_order() {
let config = FilterConfig {
glob: vec!["1.*".into()],
semver: Some(">=1.0.0".into()),
exclude: vec!["*-rc*".into()],
sort: Some(SortOrder::Semver),
latest: Some(5),
min_tags: Some(1),
..FilterConfig::default()
};
assert_eq!(
config.describe().as_deref(),
Some("glob 1.*, semver >=1.0.0, exclude *-rc*, sort semver, latest=5, min_tags=1")
);
}

/// Regression guard: every `FilterConfig` field that influences selection
/// must contribute to `describe()`. Adding a new field without updating
/// `describe()` would silently leave INFO-line filter rationale stale.
#[test]
fn describe_covers_every_selection_field() {
let cfg = FilterConfig {
include: vec!["latest".into()],
glob: vec!["v*".into()],
semver: Some("^1".into()),
exclude: vec!["nightly".into()],
sort: Some(SortOrder::Alpha),
latest: Some(3),
min_tags: Some(2),
};
let desc = cfg.describe().expect("non-empty config describes");
for needle in [
"include latest",
"glob v*",
"semver ^1",
"exclude nightly",
"sort alpha",
"latest=3",
"min_tags=2",
] {
assert!(desc.contains(needle), "missing {needle:?} in {desc:?}");
}
}

// - is_referrers_fallback_tag ----------------------------------------

#[test]
fn referrers_fallback_detection() {
assert!(is_referrers_fallback_tag("sha256-abcdef"));
assert!(is_referrers_fallback_tag("sha256-deadbeef.sig"));
assert!(is_referrers_fallback_tag("sha512-cafef00d.sbom"));
assert!(!is_referrers_fallback_tag("v1.0.0"));
assert!(!is_referrers_fallback_tag("latest"));
assert!(!is_referrers_fallback_tag("sha256")); // no dash
}
}
2 changes: 1 addition & 1 deletion crates/ocync-sync/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ impl std::fmt::Display for ErrorKind {
}

/// Aggregate statistics for a sync run.
#[derive(Debug, Default, Serialize)]
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
pub struct SyncStats {
/// Number of images successfully synced.
pub images_synced: u64,
Expand Down
Binary file added docs/public/ecr-banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions docs/src/content/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ ocync sync -c config.yaml --json

`--dry-run` runs the full filter pipeline against each mapping's source tags and prints, per mapping:

- **`source candidates: N`** -- the number of tags fetched from the source.
- **`source tags: N`** -- the number of tags fetched from the source.
- **`include path:`** -- tags rescued via `include:` (bypasses `glob:`/`semver:` and the system-exclude defaults). Default cap is 5 names; `-v` removes the cap.
- **`pipeline:`** -- per-stage attrition (`glob`, `semver`, `exclude`, `latest`). Each row shows count_in -> count_out and the drop count.
- **`filter:`** -- per-stage attrition (`glob`, `semver`, `exclude`, `sort`, `keep latest`). Each row shows count_in -> count_out and the drop count.
- **`kept (N):`** -- the final tags. When `include:` is used, rescued tags are listed first and tagged `[via include]` so the rescue path is visible.
- **`dropped N:`** -- Pareto-sorted drop attribution (largest cause first), with sample tag names per reason. Default cap is 5 names per reason; `-v` removes the cap.
- **`dropped (N):`** -- Pareto-sorted drop attribution (largest cause first), with sample tag names per reason. Default cap is 5 names per reason; `-v` removes the cap.
- **`min_tags: N`** -- when `min_tags:` is configured, the line prints `kept M, satisfied` or `kept M, real sync will FAIL with BelowMinTags`. Real-sync (no `--dry-run`) errors out below `min_tags`; dry-run shows the report and surfaces the gap so the configuration can be fixed before running.

## copy
Expand Down
Loading