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
13 changes: 13 additions & 0 deletions cmd/hauler/cli/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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
Expand Down
75 changes: 73 additions & 2 deletions cmd/hauler/cli/store/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions cmd/hauler/cli/store/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions internal/flags/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type SyncOpts struct {
Tlog bool
Rewrite string
ExcludeExtras bool
DryRun bool
}

func (o *SyncOpts) AddFlags(cmd *cobra.Command) {
Expand All @@ -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)")
}
Loading