diff --git a/pkg/importer/compose.go b/pkg/importer/compose.go index 7f36747..ec18e11 100644 --- a/pkg/importer/compose.go +++ b/pkg/importer/compose.go @@ -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) } @@ -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 } diff --git a/pkg/importer/compose_test.go b/pkg/importer/compose_test.go index febc104..f427ad7 100644 --- a/pkg/importer/compose_test.go +++ b/pkg/importer/compose_test.go @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/pkg/importer/extract.go b/pkg/importer/extract.go index 5e52ce5..6080841 100644 --- a/pkg/importer/extract.go +++ b/pkg/importer/extract.go @@ -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 { diff --git a/pkg/importer/extract_test.go b/pkg/importer/extract_test.go index cc8417c..e05a026 100644 --- a/pkg/importer/extract_test.go +++ b/pkg/importer/extract_test.go @@ -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) @@ -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") diff --git a/pkg/importer/importer.go b/pkg/importer/importer.go index e372b13..1d48a22 100644 --- a/pkg/importer/importer.go +++ b/pkg/importer/importer.go @@ -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 = "" - type Options struct { DeltaPath string Baselines map[string]string // transport-prefixed refs @@ -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 @@ -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 { @@ -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 @@ -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 { @@ -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. @@ -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 { @@ -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( @@ -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 @@ -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 { diff --git a/pkg/importer/integration_bundle_test.go b/pkg/importer/integration_bundle_test.go index d8e3948..1a74ac9 100644 --- a/pkg/importer/integration_bundle_test.go +++ b/pkg/importer/integration_bundle_test.go @@ -1,13 +1,17 @@ package importer import ( + "archive/tar" "bytes" "context" + "encoding/json" + "io" "os" "path/filepath" "testing" "time" + "github.com/opencontainers/go-digest" "github.com/stretchr/testify/require" "github.com/leosocy/diffah/pkg/diff" @@ -36,7 +40,7 @@ func newBundleHarness(t *testing.T, pairs []exporter.Pair) *bundleHarness { CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), }) require.NoError(t, err) - b, err := extractBundle(bundlePath) + b, err := extractBundle(bundlePath, t.TempDir()) require.NoError(t, err) defer b.cleanup() return &bundleHarness{t: t, ctx: ctx, tmpDir: tmpDir, bundlePath: bundlePath, sidecar: b.sidecar} @@ -375,6 +379,176 @@ func TestIntegration_MultiImageBundle_PartialSkip(t *testing.T) { require.ErrorIs(t, err, os.ErrNotExist, "svc-b.tar must not exist") } +func TestImport_StrictReturnsApplyTimeFailureWithSiblingSuccess(t *testing.T) { + if testing.Short() { + t.Skip() + } + h := newMultiImageBundleHarness(t) + outDir := filepath.Join(h.tmpDir, "strict-apply-failure") + opts := Options{ + DeltaPath: h.bundlePath, + Baselines: map[string]string{ + "svc-a": "oci-archive:../../testdata/fixtures/v1_oci.tar", + "svc-b": "oci-archive:../../testdata/fixtures/v1_oci.tar", + }, + Outputs: map[string]string{ + "svc-a": "oci-archive:" + filepath.Join(outDir, "svc-a.tar"), + "svc-b": "not-a-valid-reference", + }, + Strict: true, + Workers: 1, + } + + err := Import(h.ctx, opts) + require.Error(t, err) + require.ErrorContains(t, err, "svc-b") + require.ErrorContains(t, err, "output") + _, statErr := os.Stat(filepath.Join(outDir, "svc-a.tar")) + require.NoError(t, statErr, "strict does not cancel already-applied siblings") +} + +func TestImport_BudgetCheckUsesApplyList(t *testing.T) { + if testing.Short() { + t.Skip() + } + h := newMultiImageBundleHarness(t) + bundlePath := makeFirstImageOversizedBaselineOnly(t, h.bundlePath) + + outDir := filepath.Join(h.tmpDir, "out-apply-list-budget") + opts := Options{ + DeltaPath: bundlePath, + Baselines: map[string]string{ + "svc-b": "oci-archive:../../testdata/fixtures/v1_oci.tar", + }, + Outputs: map[string]string{ + "svc-b": "oci-archive:" + filepath.Join(outDir, "svc-b.tar"), + }, + MemoryBudget: 512 << 20, + Workers: 1, + } + + err := Import(h.ctx, opts) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(outDir, "svc-b.tar")) + require.NoError(t, err, "svc-b should import even though preflight-skipped svc-a exceeds budget") +} + +func makeFirstImageOversizedBaselineOnly(t *testing.T, bundlePath string) string { + t.Helper() + + b, err := extractBundle(bundlePath, t.TempDir()) + require.NoError(t, err) + defer b.cleanup() + require.GreaterOrEqual(t, len(b.sidecar.Images), 2) + + hugeLayer := digest.FromBytes([]byte("oversized-preflight-skipped-layer")) + config := digest.FromBytes([]byte("oversized-preflight-skipped-config")) + manifestRaw := marshalSyntheticManifest(t, config, hugeLayer, 4<<30) + manifestDigest := digest.FromBytes(manifestRaw) + writeBundleBlob(t, b.tmpDir, manifestDigest, manifestRaw) + + b.sidecar.Images[0].Target.ManifestDigest = manifestDigest + b.sidecar.Images[0].Target.ManifestSize = int64(len(manifestRaw)) + b.sidecar.Blobs[manifestDigest] = diff.BlobEntry{ + Size: int64(len(manifestRaw)), + MediaType: "application/vnd.oci.image.manifest.v1+json", + Encoding: diff.EncodingFull, + ArchiveSize: int64(len(manifestRaw)), + } + + sidecarRaw, err := b.sidecar.Marshal() + require.NoError(t, err) + outPath := filepath.Join(t.TempDir(), "apply-list-budget.tar") + writeBundleTar(t, outPath, b.tmpDir, sidecarRaw) + return outPath +} + +func marshalSyntheticManifest(t *testing.T, config, layer digest.Digest, layerSize int64) []byte { + t.Helper() + type descriptor struct { + MediaType string `json:"mediaType"` + Digest digest.Digest `json:"digest"` + Size int64 `json:"size"` + } + type manifest struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Config descriptor `json:"config"` + Layers []descriptor `json:"layers"` + } + raw, err := json.Marshal(manifest{ + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", + Config: descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: config, + Size: 2, + }, + Layers: []descriptor{{ + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: layer, + Size: layerSize, + }}, + }) + require.NoError(t, err) + return raw +} + +func writeBundleBlob(t *testing.T, root string, d digest.Digest, raw []byte) { + t.Helper() + dir := filepath.Join(root, "blobs", d.Algorithm().String()) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, d.Encoded()), raw, 0o644)) +} + +func writeBundleTar(t *testing.T, outPath, root string, sidecarRaw []byte) { + t.Helper() + f, err := os.Create(outPath) + require.NoError(t, err) + defer f.Close() + + tw := tar.NewWriter(f) + defer tw.Close() + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: diff.SidecarFilename, + Mode: 0o644, + Size: int64(len(sidecarRaw)), + })) + _, err = tw.Write(sidecarRaw) + require.NoError(t, err) + + require.NoError(t, filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{ + Name: filepath.ToSlash(rel), + Mode: 0o644, + Size: info.Size(), + }); err != nil { + return err + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + _, err = io.Copy(tw, in) + return err + })) +} + func TestDryRun_PopulatesAllFields(t *testing.T) { if testing.Short() { t.Skip() @@ -453,7 +627,7 @@ func TestIntegration_AutoDowngradesUnderReducedPATH(t *testing.T) { }) require.NoError(t, err) - b, err := extractBundle(bundlePath) + b, err := extractBundle(bundlePath, t.TempDir()) require.NoError(t, err) defer b.cleanup() for d, b := range b.sidecar.Blobs { diff --git a/pkg/importer/resolve_test.go b/pkg/importer/resolve_test.go index bad2560..a0386ab 100644 --- a/pkg/importer/resolve_test.go +++ b/pkg/importer/resolve_test.go @@ -11,7 +11,7 @@ import ( func TestResolveBaselines_HappyPath(t *testing.T) { bundlePath := buildTestBundle(t, "svc-a") - b, err := extractBundle(bundlePath) + b, err := extractBundle(bundlePath, t.TempDir()) require.NoError(t, err) defer b.cleanup() @@ -28,7 +28,7 @@ func TestResolveBaselines_HappyPath(t *testing.T) { func TestResolveBaselines_StrictRejectsMissing(t *testing.T) { bundlePath := buildTestBundle(t, "svc-a") - b, err := extractBundle(bundlePath) + b, err := extractBundle(bundlePath, t.TempDir()) require.NoError(t, err) defer b.cleanup() @@ -38,7 +38,7 @@ func TestResolveBaselines_StrictRejectsMissing(t *testing.T) { func TestResolveBaselines_MismatchDigest(t *testing.T) { bundlePath := buildTestBundle(t, "svc-a") - b, err := extractBundle(bundlePath) + b, err := extractBundle(bundlePath, t.TempDir()) require.NoError(t, err) defer b.cleanup()