diff --git a/cmd/hauler/cli/store.go b/cmd/hauler/cli/store.go index ccc03f05..17fbc795 100644 --- a/cmd/hauler/cli/store.go +++ b/cmd/hauler/cli/store.go @@ -71,6 +71,15 @@ func addStoreSync(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Comman Short: "Sync content to the content store", Args: cobra.ExactArgs(0), PreRunE: func(cmd *cobra.Command, args []string) error { + // --dry-run requires --products + if o.DryRun && len(o.Products) == 0 { + return fmt.Errorf("--dry-run requires --products") + } + // suppress log output during dry-run so YAML is the only stdout content + // must be set before any log calls to keep stdout clean for piping + if o.DryRun { + log.FromContext(cmd.Context()).SetLevel("fatal") + } // warn if products or product-registry flag is used by the user if cmd.Flags().Changed("products") { log.FromContext(cmd.Context()).Warnf("!!! WARNING !!! [--products] will be updating its default registry in a future release.") @@ -90,6 +99,10 @@ func addStoreSync(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Comman RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + if o.DryRun { + return store.SyncCmd(ctx, o, nil, rso, ro) + } + s, err := o.Store(ctx) if err != nil { return err diff --git a/cmd/hauler/cli/store/sync.go b/cmd/hauler/cli/store/sync.go index 72e57eaa..1efb6cf4 100644 --- a/cmd/hauler/cli/store/sync.go +++ b/cmd/hauler/cli/store/sync.go @@ -10,7 +10,12 @@ import ( "path/filepath" "strings" + "github.com/google/go-containerregistry/pkg/authn" + gname "github.com/google/go-containerregistry/pkg/name" + gv1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/mitchellh/go-homedir" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "helm.sh/helm/v3/pkg/action" "k8s.io/apimachinery/pkg/util/yaml" @@ -28,6 +33,72 @@ import ( func SyncCmd(ctx context.Context, o *flags.SyncOpts, s *store.Layout, rso *flags.StoreRootOpts, ro *flags.CliRootOpts) error { l := log.FromContext(ctx) + // Handle dry-run before any local side effects (temp dirs, store writes). + if o.DryRun { + for _, productName := range o.Products { + parts := strings.Split(productName, "=") + tag := strings.ReplaceAll(parts[1], "+", "-") + + ProductRegistry := o.ProductRegistry + if o.ProductRegistry == "" { + ProductRegistry = consts.CarbideRegistry + } + + manifestLoc := fmt.Sprintf("%s/hauler/%s-manifest.yaml:%s", ProductRegistry, parts[0], tag) + fileName := fmt.Sprintf("%s-manifest.yaml", parts[0]) + + parsedRef, err := gname.ParseReference(manifestLoc) + if err != nil { + return fmt.Errorf("failed to fetch product manifest for [%s]: %w", productName, err) + } + remoteImg, err := remote.Image(parsedRef, + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithContext(ctx), + ) + if err != nil { + return fmt.Errorf("failed to fetch product manifest for [%s]: %w", productName, err) + } + mf, err := remoteImg.Manifest() + if err != nil { + return err + } + // Select the layer whose AnnotationTitle matches the expected + // manifest filename, rather than assuming layer order. + var layerDigest *gv1.Hash + for _, desc := range mf.Layers { + if desc.Annotations[ocispec.AnnotationTitle] == fileName { + layerDigest = &desc.Digest + break + } + } + if layerDigest == nil { + return fmt.Errorf("product manifest for [%s] has no layer with title %q", productName, fileName) + } + layer, err := remoteImg.LayerByDigest(*layerDigest) + if err != nil { + return err + } + rc, err := layer.Compressed() + if err != nil { + return err + } + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return err + } + + // Ensure each manifest starts with a YAML document separator. + if !strings.HasPrefix(string(content), "---") { + content = append([]byte("---\n"), content...) + } + if _, err := os.Stdout.Write(content); err != nil { + return err + } + } + return nil + } + tempOverride := rso.TempOverride if tempOverride == "" { @@ -56,19 +127,19 @@ func SyncCmd(ctx context.Context, o *flags.SyncOpts, s *store.Layout, rso *flags manifestLoc := fmt.Sprintf("%s/hauler/%s-manifest.yaml:%s", ProductRegistry, parts[0], tag) l.Infof("fetching product manifest from [%s]", manifestLoc) + img := v1.Image{ Name: manifestLoc, } err := storeImage(ctx, s, img, o.Platform, o.ExcludeExtras, rso, ro, "") if err != nil { - return err + return fmt.Errorf("failed to fetch product manifest for [%s]: %w", productName, err) } err = ExtractCmd(ctx, &flags.ExtractOpts{StoreRootOpts: o.StoreRootOpts}, s, fmt.Sprintf("hauler/%s-manifest.yaml:%s", parts[0], tag)) if err != nil { return err } fileName := fmt.Sprintf("%s-manifest.yaml", parts[0]) - fi, err := os.Open(fileName) if err != nil { return err diff --git a/cmd/hauler/cli/store/sync_test.go b/cmd/hauler/cli/store/sync_test.go index 8a4ecf1a..2a0e5985 100644 --- a/cmd/hauler/cli/store/sync_test.go +++ b/cmd/hauler/cli/store/sync_test.go @@ -6,9 +6,21 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" + "github.com/google/go-containerregistry/pkg/name" + gcrv1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/static" + gvtypes "github.com/google/go-containerregistry/pkg/v1/types" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/rs/zerolog" + "hauler.dev/go/hauler/internal/flags" + "hauler.dev/go/hauler/pkg/consts" ) // writeManifestFile writes yamlContent to a temp file, seeks back to the @@ -439,3 +451,110 @@ spec: } assertArtifactInStore(t, s, "synced-remote.sh") } + +// -------------------------------------------------------------------------- +// SyncCmd --dry-run tests +// -------------------------------------------------------------------------- + +// buildProductManifestImage constructs a synthetic OCI file-artifact image +// containing yamlContent as a single layer. The image uses the same media +// types and AnnotationTitle annotation that storeFile/AddArtifact produce, +// so ExtractCmd extracts the layer to a file named fileName. +func buildProductManifestImage(t *testing.T, fileName string, yamlContent []byte) gcrv1.Image { + t.Helper() + fileLayer := static.NewLayer(yamlContent, gvtypes.MediaType(consts.FileLayerMediaType)) + img, err := mutate.Append(empty.Image, mutate.Addendum{ + Layer: fileLayer, + Annotations: map[string]string{ + ocispec.AnnotationTitle: fileName, + }, + }) + if err != nil { + t.Fatalf("buildProductManifestImage mutate.Append: %v", err) + } + img = mutate.MediaType(img, gvtypes.OCIManifestSchema1) + img = mutate.ConfigMediaType(img, gvtypes.MediaType(consts.FileLocalConfigMediaType)) + return img +} + +// TestSyncCmd_DryRun_Products_PrintsManifestToStdout verifies that when +// DryRun is true the product manifest YAML is written to stdout without +// writing anything to the local store — storeImage is never called. +func TestSyncCmd_DryRun_Products_PrintsManifestToStdout(t *testing.T) { + ctx := newTestContext(t) + t.Cleanup(func() { zerolog.SetGlobalLevel(zerolog.InfoLevel) }) + + const productName = "testproduct" + const productVersion = "v1.0.0" + const manifestFileName = productName + "-manifest.yaml" + + manifestYAML := []byte(`apiVersion: content.hauler.cattle.io/v1 +kind: Files +metadata: + name: testproduct-files +spec: + files: + - path: https://example.com/test.sh +`) + + // Seed the product registry with the manifest as a file-artifact OCI image. + host, rOpts := newLocalhostRegistry(t) + img := buildProductManifestImage(t, manifestFileName, manifestYAML) + imgTag, err := name.NewTag( + fmt.Sprintf("%s/hauler/%s:%s", host, manifestFileName, productVersion), + name.Insecure, + ) + if err != nil { + t.Fatalf("name.NewTag: %v", err) + } + if err := remote.Write(imgTag, img, rOpts...); err != nil { + t.Fatalf("remote.Write product manifest image: %v", err) + } + + // Redirect os.Stdout to capture what SyncCmd prints during dry-run. + oldStdout := os.Stdout + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatalf("os.Pipe: %v", pipeErr) + } + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + w.Close() + r.Close() + }) + + o := newSyncOpts(t.TempDir()) + o.Products = []string{fmt.Sprintf("%s=%s", productName, productVersion)} + o.ProductRegistry = host + o.DryRun = true + rso := defaultRootOpts(t.TempDir()) + ro := defaultCliOpts() + + // Pass nil store — dry-run must not touch the store at all. + syncErr := SyncCmd(ctx, o, nil, rso, ro) + + // Close the write end before reading to unblock io.Copy. + w.Close() + var buf strings.Builder + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read captured stdout: %v", err) + } + r.Close() + os.Stdout = oldStdout + + if syncErr != nil { + t.Fatalf("SyncCmd dry-run: %v", syncErr) + } + + got := buf.String() + if !strings.HasPrefix(got, "---\n") { + t.Errorf("dry-run stdout should start with YAML document separator; got:\n%s", got) + } + if !strings.Contains(got, "kind: Files") { + t.Errorf("dry-run stdout missing 'kind: Files'; got:\n%s", got) + } + if !strings.Contains(got, "testproduct-files") { + t.Errorf("dry-run stdout missing manifest name 'testproduct-files'; got:\n%s", got) + } +} diff --git a/internal/flags/sync.go b/internal/flags/sync.go index 455808e2..36012231 100644 --- a/internal/flags/sync.go +++ b/internal/flags/sync.go @@ -21,6 +21,7 @@ type SyncOpts struct { Tlog bool Rewrite string ExcludeExtras bool + DryRun bool } func (o *SyncOpts) AddFlags(cmd *cobra.Command) { @@ -41,4 +42,5 @@ func (o *SyncOpts) AddFlags(cmd *cobra.Command) { f.BoolVar(&o.Tlog, "use-tlog-verify", false, "(Optional) Allow transparency log verification (defaults to false)") f.StringVar(&o.Rewrite, "rewrite", "", "(EXPERIMENTAL & Optional) Rewrite artifact path to specified string") f.BoolVar(&o.ExcludeExtras, "exclude-extras", false, "(Optional) Exclude cosign signatures, attestations, SBOMs, and OCI referrers when pulling images") + f.BoolVar(&o.DryRun, "dry-run", false, "(Optional) Output product manifest content to stdout instead of processing it (requires --products)") }