Skip to content
Open
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
12 changes: 10 additions & 2 deletions pkg/importer/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,11 @@ func (s *bundleImageSource) servePatch(
}
var mismatch *diff.ErrBaselineBlobDigestMismatch
if errors.As(err, &mismatch) && mismatch.ImageName == "" {
mismatch.ImageName = s.imageName
err = &diff.ErrBaselineBlobDigestMismatch{
ImageName: s.imageName,
Digest: mismatch.Digest,
Got: mismatch.Got,
}
}
return nil, 0, fmt.Errorf("baseline spool %s: %w", entry.PatchFromDigest, err)
}
Expand Down Expand Up @@ -380,7 +384,11 @@ func (s *bundleImageSource) fetchVerifiedBaselineBlob(
if err != nil {
var mismatch *diff.ErrBaselineBlobDigestMismatch
if errors.As(err, &mismatch) && mismatch.ImageName == "" {
mismatch.ImageName = s.imageName
err = &diff.ErrBaselineBlobDigestMismatch{
ImageName: s.imageName,
Digest: mismatch.Digest,
Got: mismatch.Got,
}
}
return nil, 0, err
}
Expand Down
10 changes: 5 additions & 5 deletions pkg/importer/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func openBaseline(t *testing.T, path string) types.ImageSource {

func TestBundleImageSource_GetManifest_ReturnsStoredBytes(t *testing.T) {
bundlePath := buildTestBundle(t, "svc-a")
b, err := extractBundle(bundlePath)
b, err := extractBundle(bundlePath, t.TempDir())
require.NoError(t, err)
t.Cleanup(b.cleanup)

Expand Down Expand Up @@ -63,7 +63,7 @@ func TestBundleImageSource_GetManifest_ReturnsStoredBytes(t *testing.T) {

func TestBundleImageSource_GetBlob_FullEncoding_ReturnsVerifiedBytes(t *testing.T) {
bundlePath := buildTestBundle(t, "svc-a")
b, err := extractBundle(bundlePath)
b, err := extractBundle(bundlePath, t.TempDir())
require.NoError(t, err)
t.Cleanup(b.cleanup)

Expand Down Expand Up @@ -117,7 +117,7 @@ func TestBundleImageSource_GetBlob_PatchEncoding_DecodesAndVerifies(t *testing.T
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
b, err := extractBundle(bp)
b, err := extractBundle(bp, t.TempDir())
require.NoError(t, err)
t.Cleanup(b.cleanup)

Expand Down Expand Up @@ -174,7 +174,7 @@ func TestBundleImageSource_GetBlob_PatchEncoding_CorruptedBlob_RaisesAssemblyMis
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
b, err := extractBundle(bp)
b, err := extractBundle(bp, t.TempDir())
require.NoError(t, err)
t.Cleanup(b.cleanup)

Expand Down Expand Up @@ -235,7 +235,7 @@ func TestBundleImageSource_GetBlob_BaselineDelegation_Verified(t *testing.T) {
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
b, err := extractBundle(bp)
b, err := extractBundle(bp, t.TempDir())
require.NoError(t, err)
t.Cleanup(b.cleanup)

Expand Down
11 changes: 7 additions & 4 deletions pkg/importer/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ type extractedBundle struct {
sidecarRawBytes []byte
}

func extractBundle(deltaPath string) (*extractedBundle, error) {
tmpDir, err := os.MkdirTemp("", "diffah-import-")
if err != nil {
return nil, fmt.Errorf("create tmp dir: %w", err)
func extractBundle(deltaPath, workdir string) (*extractedBundle, error) {
tmpDir := filepath.Join(workdir, "bundle")
if err := os.RemoveAll(tmpDir); err != nil {
return nil, fmt.Errorf("reset bundle dir: %w", err)
}
if err := os.MkdirAll(tmpDir, 0o700); err != nil {
return nil, fmt.Errorf("create bundle dir: %w", err)
}
raw, err := archive.Extract(deltaPath, tmpDir)
if err != nil {
Expand Down
6 changes: 4 additions & 2 deletions pkg/importer/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import (

func TestExtractBundle_ParsesSidecar(t *testing.T) {
bundlePath := buildTestBundle(t, "svc-a")
b, err := extractBundle(bundlePath)
wd := t.TempDir()
b, err := extractBundle(bundlePath, wd)
require.NoError(t, err)
defer b.cleanup()

require.Equal(t, filepath.Join(wd, "bundle"), b.tmpDir)
require.Equal(t, diff.SchemaVersionV1, b.sidecar.Version)
require.Equal(t, diff.FeatureBundle, b.sidecar.Feature)
require.Len(t, b.sidecar.Images, 1)
Expand All @@ -39,7 +41,7 @@ func TestExtractBundle_RejectsLegacyArchive(t *testing.T) {
require.NoError(t, tw.Close())
f.Close()

_, err = extractBundle(legacyPath)
_, err = extractBundle(legacyPath, t.TempDir())
require.Error(t, err)
var p1 *diff.ErrPhase1Archive
require.ErrorAs(t, err, &p1, "must reject Phase 1 archive")
Expand Down
94 changes: 55 additions & 39 deletions pkg/importer/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ import (
"github.com/leosocy/diffah/pkg/signer"
)

// budgetSentinelName is the synthetic ImageName recorded in the apply
// report when checkSingleImageFitsInBudget rejects the bundle pre-pool.
// Quoted so renderSummary's per-image row doesn't conflate it with a
// real image name (no real name can contain '<' per the sidecar regex).
const budgetSentinelName = "<budget>"

type Options struct {
DeltaPath string
Baselines map[string]string // transport-prefixed refs
Expand Down Expand Up @@ -89,18 +83,12 @@ func (o *Options) probeOrDefault() func(context.Context) (bool, string) {
func Import(ctx context.Context, opts Options) error {
defer opts.reporter().Finish()

wd, cleanupWorkdir, werr := ensureImportWorkdir(opts)
if werr != nil {
return werr
}
defer cleanupWorkdir()
opts.Workdir = wd

bundle, err := extractBundle(opts.DeltaPath)
wd, bundle, cleanup, err := prepareImportBundle(opts)
if err != nil {
return err
}
defer bundle.cleanup()
defer cleanup()
opts.Workdir = wd

if err := preApplyChecks(ctx, opts, bundle); err != nil {
return err
Expand Down Expand Up @@ -133,6 +121,9 @@ func Import(ctx context.Context, opts Options) error {
); err != nil {
return err
}
if err := checkApplyListFitsInBudget(applyList, bundle, opts); err != nil {
return err
}

spool, err := newImportSpool(wd)
if err != nil {
Expand All @@ -143,6 +134,9 @@ func Import(ctx context.Context, opts Options) error {
mergePreflightSkips(&report, bundle.sidecar.Images, skippedByPreflight)
finalizeImportReport(ctx, rep, report, len(skippedByPreflight))

if opts.Strict && report.Successful() < report.Total {
return firstNonOKError(report)
}
// Partial-mode contract: at least one image must succeed for an
// exit-0 outcome. Returning the first non-OK image's error keeps
// classification (CategoryContent for B1/B2/invariant) intact for
Expand All @@ -153,6 +147,23 @@ func Import(ctx context.Context, opts Options) error {
return nil
}

func prepareImportBundle(opts Options) (string, *extractedBundle, func(), error) {
wd, cleanupWorkdir, err := ensureImportWorkdir(opts)
if err != nil {
return "", nil, func() {}, err
}
bundle, err := extractBundle(opts.DeltaPath, wd)
if err != nil {
cleanupWorkdir()
return "", nil, func() {}, err
}
cleanup := func() {
bundle.cleanup()
cleanupWorkdir()
}
return wd, bundle, cleanup, nil
}

func resolvedBaselinesByName(resolved []resolvedBaseline) map[string]resolvedBaseline {
out := make(map[string]resolvedBaseline, len(resolved))
for _, r := range resolved {
Expand Down Expand Up @@ -253,10 +264,6 @@ func splitPreflightResults(results []PreflightResult) ([]string, map[string]Pref
// they produce; the final report is post-sorted to applyList order so the
// per-image rows are byte-identical regardless of worker count.
//
// Pre-pool, checkSingleImageFitsInBudget rejects bundles whose largest
// image cannot fit under MemoryBudget — surfaces a CategoryUser error
// before any worker starts.
//
// Partial mode (opts.Strict=false): submitted closures always return nil
// so errgroup never cancels queued siblings; per-image errors are
// recorded under the report mutex.
Expand All @@ -278,29 +285,12 @@ func importEachImage(
) ApplyReport {
report := ApplyReport{}

// Pre-pool admission gate: fail fast with the offending image's name
// when the operator's budget cannot fit even a single image. The
// admission pool's memSem clamps oversized estimates internally to
// avoid deadlock, but that's a safety net — the operator-facing
// contract is "you'll learn before any image starts."
if err := checkSingleImageFitsInBudget(
bundle.sidecar.Images, bundle.blobDir, bundle.sidecar.Blobs,
opts.WindowLog, opts.MemoryBudget,
); err != nil {
report.Results = append(report.Results, ApplyImageResult{
ImageName: budgetSentinelName,
Status: ApplyImageFailedCompose,
Err: err,
})
return report
}

// Serial path: with Workers<=1 the admission pool's errgroup.Go
// scheduling is non-deterministic — observable order of the shared
// BaselineSpool's first-touches depends on which task's goroutine
// reaches workerSem.Acquire first, not on submission order. Run
// inline in applyList order to give the spool a deterministic
// view of who fetches first. checkSingleImageFitsInBudget already
// view of who fetches first. Import's apply-list budget gate already
// capped each image at the budget, so a single-task-at-a-time
// sequence cannot exceed it.
if opts.Workers <= 1 {
Expand Down Expand Up @@ -450,6 +440,27 @@ func sortResultsByApplyList(report *ApplyReport, applyList []string) {
})
}

func imageEntriesFor(applyList []string, images []diff.ImageEntry) []diff.ImageEntry {
wanted := make(map[string]struct{}, len(applyList))
for _, name := range applyList {
wanted[name] = struct{}{}
}
filtered := make([]diff.ImageEntry, 0, len(applyList))
for _, img := range images {
if _, ok := wanted[img.Name]; ok {
filtered = append(filtered, img)
}
}
return filtered
}

func checkApplyListFitsInBudget(applyList []string, bundle *extractedBundle, opts Options) error {
return checkSingleImageFitsInBudget(
imageEntriesFor(applyList, bundle.sidecar.Images), bundle.blobDir, bundle.sidecar.Blobs,
opts.WindowLog, opts.MemoryBudget,
)
}

// applyOneImage runs the full per-image pipeline (resolve output, compose,
// invariant verify) and returns the typed result.
func applyOneImage(
Expand Down Expand Up @@ -682,11 +693,11 @@ func firstNonOKError(report ApplyReport) error {
}

func DryRun(ctx context.Context, opts Options) (DryRunReport, error) {
bundle, err := extractBundle(opts.DeltaPath)
bundle, cleanup, err := prepareDryRunBundle(opts)
if err != nil {
return DryRunReport{}, err
}
defer bundle.cleanup()
defer cleanup()

if err := validatePositionalBaseline(bundle.sidecar, opts.Baselines); err != nil {
return DryRunReport{}, err
Expand Down Expand Up @@ -747,6 +758,11 @@ func DryRun(ctx context.Context, opts Options) (DryRunReport, error) {
}, nil
}

func prepareDryRunBundle(opts Options) (*extractedBundle, func(), error) {
_, bundle, cleanup, err := prepareImportBundle(opts)
return bundle, cleanup, err
}

func buildImageDryRuns(bundle *extractedBundle, resolved []resolvedBaseline) ([]ImageDryRun, error) {
provided := make(map[string]struct{}, len(resolved))
for _, r := range resolved {
Expand Down
Loading
Loading