mirror of
https://github.com/hauler-dev/hauler.git
synced 2026-05-06 09:18:30 +00:00
add dry-run flag for sync --products (#547)
signed-off-by: Adam Martin <adam.martin@ranchergovernment.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user