add dry-run flag for sync --products (#547)

signed-off-by: Adam Martin <adam.martin@ranchergovernment.com>
This commit is contained in:
Adam Martin
2026-04-14 16:27:59 -04:00
committed by GitHub
parent 57d45f136e
commit b2d0f9f01e
4 changed files with 207 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)")
}