diff --git a/cmd/hauler/cli/store.go b/cmd/hauler/cli/store.go index e305ceb..5d4cd4d 100644 --- a/cmd/hauler/cli/store.go +++ b/cmd/hauler/cli/store.go @@ -36,6 +36,7 @@ func addStore(parent *cobra.Command, ro *flags.CliRootOpts) { addStoreCopy(rso, ro), addStoreAdd(rso, ro), addStoreRemove(rso, ro), + addStoreCreate(rso, ro), ) parent.AddCommand(cmd) @@ -494,6 +495,53 @@ func addStoreAddChart(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Co return cmd } +func addStoreCreate(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create content derived from the store", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + addStoreCreateManifest(rso, ro), + ) + + return cmd +} + +func addStoreCreateManifest(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Command { + o := &flags.CreateManifestOpts{StoreRootOpts: rso} + + cmd := &cobra.Command{ + Use: "manifest", + Short: "(EXPERIMENTAL) Create a hauler content manifest from the store's metadata", + Example: ` # print a manifest for the default store to stdout + hauler store create manifest + + # print a manifest for a specific store to stdout + hauler store create manifest --store /path/to/my-store + + # write a manifest for a specific store to a file + hauler store create manifest --store /path/to/store --output my-manifest.yaml`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + s, err := o.Store(ctx, ro) + if err != nil { + return err + } + + return store.CreateManifestCmd(ctx, o, s) + }, + } + o.AddFlags(cmd) + + return cmd +} + func addStoreRemove(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Command { o := &flags.RemoveOpts{} cmd := &cobra.Command{ diff --git a/cmd/hauler/cli/store/create_manifest.go b/cmd/hauler/cli/store/create_manifest.go new file mode 100644 index 0000000..c67153a --- /dev/null +++ b/cmd/hauler/cli/store/create_manifest.go @@ -0,0 +1,354 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + gname "github.com/google/go-containerregistry/pkg/name" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "golang.org/x/mod/semver" + "gopkg.in/yaml.v3" + + "hauler.dev/go/hauler/v2/internal/flags" + "hauler.dev/go/hauler/v2/pkg/consts" + "hauler.dev/go/hauler/v2/pkg/log" + "hauler.dev/go/hauler/v2/pkg/store" +) + +// manifestImage, manifestChart, and manifestFile mirror the relevant fields of +// v1.Image/v1.Chart/v1.File, but keep only what can be confidently recovered from the +// store's metadata and use "omitempty" throughout (unlike the api types, which most +// callers unmarshal rather than marshal) so the generated manifest stays readable +// instead of listing every unset flag. +type manifestImage struct { + Name string `yaml:"name"` + Platform string `yaml:"platform,omitempty"` + Rewrite string `yaml:"rewrite,omitempty"` +} + +type manifestChart struct { + Name string `yaml:"name"` + RepoURL string `yaml:"repoURL,omitempty"` + Version string `yaml:"version,omitempty"` + Rewrite string `yaml:"rewrite,omitempty"` +} + +type manifestFile struct { + Path string `yaml:"path"` + Name string `yaml:"name,omitempty"` +} + +type manifestMetadata struct { + Name string `yaml:"name"` +} + +type manifestDoc struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata manifestMetadata `yaml:"metadata"` + Spec interface{} `yaml:"spec"` +} + +// CreateManifestCmd walks the store's OCI index (and the manifests/configs it +// references) to reconstruct a hauler content manifest capable of recreating the +// store's contents via `hauler store sync`. It groups discovered content into +// Images/Charts/Files documents and writes them to o.Output, or to stdout when +// o.Output is empty. +func CreateManifestCmd(ctx context.Context, o *flags.CreateManifestOpts, s *store.Layout) error { + l := log.FromContext(ctx) + + toStdout := o.Output == "" + if toStdout { + l.SetLevel("fatal") + } + + // Warn when the store predates the provenance metadata this command relies on + // to faithfully reconstruct the manifest. Written to stderr so it stays visible + // even in stdout mode (where the logger is silenced and stdout carries the YAML). + if version, err := readStoreHaulerVersion(s.Root); err != nil || storeLacksProvenance(version) { + fmt.Fprintln(os.Stderr, "WARNING: The version of Hauler used to create this store did not include provenance metadata to reconstruct the manifest. Please confirm the generated manifest is accurate.") + } + + // Advise reviewing the output regardless of provenance. Written to stderr so it + // stays visible even in stdout mode (where the logger is silenced and stdout + // carries the YAML). + fmt.Fprintln(os.Stderr, "INFO: Always confirm the accuracy of the generated manifest before recreating store.") + + var images []manifestImage + var charts []manifestChart + var files []manifestFile + chartsMissingRepoURL := false + + if err := s.Walk(func(_ string, desc ocispec.Descriptor) error { + refName, ok := desc.Annotations[ocispec.AnnotationRefName] + if !ok { + return nil + } + + kind := desc.Annotations[consts.KindAnnotationName] + switch { + case kind == consts.KindAnnotationSigs, kind == consts.KindAnnotationAtts, kind == consts.KindAnnotationSboms: + // cosign-related artifacts are rediscovered automatically when the + // parent image is re-added, so they don't need their own entry. + return nil + case strings.HasPrefix(kind, consts.KindAnnotationReferrers): + return nil + } + + // Container images (both single-platform and multi-arch indexes) carry the + // full OCI reference under this annotation; charts and files never do. + if fullRef, isImage := desc.Annotations[consts.ContainerdImageNameKey]; isImage { + name := fullRef + rewrite := "" + if orig, ok := desc.Annotations[consts.OriginalRefAnnotation]; ok && orig != "" && orig != fullRef { + // The current ref differs from what was captured at the initial add, + // meaning --rewrite changed it since. Recover the original, pullable + // name and reapply the same rewrite so a resync reproduces this exact + // store layout. If there's no annotation at all (a store from before + // this was tracked) or it matches fullRef (never rewritten), fullRef + // is already the right, pullable name. + rewrite = fullRef + name = orig + } + + img := manifestImage{Name: name, Rewrite: rewrite} + if kind == consts.KindAnnotationImage { + // Only a single-platform manifest has an unambiguous platform to pin. + // A stored multi-arch index is left unset so a future sync re-pulls + // every platform, matching what's actually in the store. + platform, err := imagePlatform(ctx, s, desc) + if err != nil { + l.Warnf("could not determine platform for image [%s]: %v", name, err) + } else if platform != "" { + img.Platform = platform + } + } + images = append(images, img) + return nil + } + + rc, err := s.Fetch(ctx, desc) + if err != nil { + return fmt.Errorf("fetching manifest for [%s]: %w", refName, err) + } + defer rc.Close() + + var m ocispec.Manifest + if err := json.NewDecoder(rc).Decode(&m); err != nil { + return fmt.Errorf("decoding manifest for [%s]: %w", refName, err) + } + + ref, err := gname.ParseReference(refName) + if err != nil { + return fmt.Errorf("parsing reference [%s]: %w", refName, err) + } + name := strings.TrimPrefix(ref.Context().RepositoryStr(), consts.DefaultNamespace+"/") + + switch m.Config.MediaType { + case consts.ChartConfigMediaType: + version := ref.Identifier() + if tag, ok := ref.(gname.Tag); ok { + version = tag.TagStr() + } + + repoURL := "" + rewrite := "" + if orig, ok := desc.Annotations[consts.OriginalRefAnnotation]; ok && orig != "" { + origRepoURL, origTotal := decodeOriginalChartRef(orig) + repoURL = origRepoURL + if origTotal != "" && origTotal != refName { + // The current ref differs from what was captured at the initial + // add, meaning --rewrite changed it since. Recover the original, + // pullable name/version and reapply the same rewrite so a resync + // reproduces this exact store layout. + rewrite = refName + if origRef, err := gname.ParseReference(origTotal); err == nil { + name = strings.TrimPrefix(origRef.Context().RepositoryStr(), consts.DefaultNamespace+"/") + version = origRef.Identifier() + if tag, ok := origRef.(gname.Tag); ok { + version = tag.TagStr() + } + } + } + } + + charts = append(charts, manifestChart{Name: name, RepoURL: repoURL, Version: version, Rewrite: rewrite}) + if repoURL == "" { + chartsMissingRepoURL = true + } + + case consts.FileLocalConfigMediaType, consts.FileHttpConfigMediaType, consts.FileDirectoryConfigMediaType: + path := name + if orig, ok := desc.Annotations[consts.OriginalRefAnnotation]; ok && orig != "" { + path = orig + } + files = append(files, manifestFile{Path: path, Name: name}) + + default: + l.Warnf("skipping unrecognized artifact [%s] with config media type [%s]", refName, m.Config.MediaType) + } + + return nil + }); err != nil { + return err + } + + if len(images) == 0 && len(charts) == 0 && len(files) == 0 { + return fmt.Errorf("store contains no content to build a manifest from") + } + + base := filepath.Base(s.Root) + + var out strings.Builder + if len(images) > 0 { + if err := writeDoc(&out, "", consts.ImagesContentKind, base+"-images", struct { + Images []manifestImage `yaml:"images"` + }{images}); err != nil { + return err + } + } + if len(charts) > 0 { + header := "" + if chartsMissingRepoURL { + header = "# NOTE: repoURL could not be recovered from the store's metadata and must be filled in below.\n" + } + if err := writeDoc(&out, header, consts.ChartsContentKind, base+"-charts", struct { + Charts []manifestChart `yaml:"charts"` + }{charts}); err != nil { + return err + } + } + if len(files) > 0 { + if err := writeDoc(&out, "", consts.FilesContentKind, base+"-files", struct { + Files []manifestFile `yaml:"files"` + }{files}); err != nil { + return err + } + } + + if toStdout { + if _, err := os.Stdout.Write([]byte(out.String())); err != nil { + return err + } + return nil + } + + if err := os.WriteFile(o.Output, []byte(out.String()), 0o644); err != nil { + return fmt.Errorf("writing manifest to [%s]: %w", o.Output, err) + } + + outPath := o.Output + if abs, err := filepath.Abs(o.Output); err == nil { + outPath = abs + } + l.Infof("wrote manifest with [%d] image(s), [%d] chart(s), [%d] file(s) to [%s]", len(images), len(charts), len(files), outPath) + + return nil +} + +// provenanceMinVersion is the first Hauler release whose stores record enough +// provenance metadata for `store create manifest` to faithfully reconstruct +// them. Stores written by earlier versions (or with no recorded version) get a +// best-effort manifest and a warning. +const provenanceMinVersion = "v2.1.0" + +// storeVersionMetadata mirrors the subset of store.json this command reads to +// decide whether the store carries reliable provenance metadata. +type storeVersionMetadata struct { + HaulerVersion string `json:"hauler-version"` +} + +// readStoreHaulerVersion returns the "hauler-version" recorded in the store's +// store.json, or an error if the file is missing or unparseable. +func readStoreHaulerVersion(root string) (string, error) { + data, err := os.ReadFile(filepath.Join(root, consts.DefaultStoreMetadataName)) + if err != nil { + return "", err + } + var m storeVersionMetadata + if err := json.Unmarshal(data, &m); err != nil { + return "", err + } + return m.HaulerVersion, nil +} + +// storeLacksProvenance reports whether a store written by haulerVersion predates +// provenanceMinVersion. An empty or unparseable version is treated as lacking +// provenance. The comparison is by major.minor so that pre-releases of the +// threshold (e.g. v2.1.0-rc1) are not flagged. +func storeLacksProvenance(haulerVersion string) bool { + v := strings.TrimSpace(haulerVersion) + if v == "" { + return true + } + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + if !semver.IsValid(v) { + return true + } + return semver.Compare(semver.MajorMinor(v), semver.MajorMinor(provenanceMinVersion)) < 0 +} + +func writeDoc(out *strings.Builder, header string, kind string, name string, spec interface{}) error { + doc := manifestDoc{ + APIVersion: consts.ContentGroup + "/v1", + Kind: kind, + Metadata: manifestMetadata{Name: name}, + Spec: spec, + } + data, err := yaml.Marshal(doc) + if err != nil { + return fmt.Errorf("marshaling [%s] manifest: %w", kind, err) + } + out.WriteString("---\n") + out.WriteString(header) + out.Write(data) + return nil +} + +// imagePlatform returns the "os/arch" of a single-platform image manifest by +// fetching its config blob, or "" if the platform can't be determined. +func imagePlatform(ctx context.Context, s *store.Layout, desc ocispec.Descriptor) (string, error) { + rc, err := s.Fetch(ctx, desc) + if err != nil { + return "", err + } + defer rc.Close() + + var m ocispec.Manifest + if err := json.NewDecoder(rc).Decode(&m); err != nil { + return "", err + } + + cfgRc, err := s.FetchManifest(ctx, m) + if err != nil { + return "", err + } + defer cfgRc.Close() + + var cfg ocispec.Image + if err := json.NewDecoder(cfgRc).Decode(&cfg); err != nil { + return "", err + } + if cfg.OS == "" || cfg.Architecture == "" { + return "", nil + } + return cfg.OS + "/" + cfg.Architecture, nil +} + +// decodeOriginalChartRef splits a value produced by encodeOriginalChartRef (see +// storeChart in add.go) back into its repoURL and "repo:tag" parts. Values with no +// "|" (shouldn't occur once only encodeOriginalChartRef ever writes this annotation +// for charts) are treated as a bare ref with an unknown repoURL. +func decodeOriginalChartRef(v string) (repoURL string, total string) { + repoURL, total, found := strings.Cut(v, "|") + if !found { + return "", v + } + return repoURL, total +} diff --git a/cmd/hauler/cli/store/create_manifest_test.go b/cmd/hauler/cli/store/create_manifest_test.go new file mode 100644 index 0000000..f3c9760 --- /dev/null +++ b/cmd/hauler/cli/store/create_manifest_test.go @@ -0,0 +1,426 @@ +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "hauler.dev/go/hauler/v2/internal/flags" + v1 "hauler.dev/go/hauler/v2/pkg/apis/hauler.cattle.io/v1" +) + +// newCreateManifestOpts returns a CreateManifestOpts writing to a fresh file +// under t.TempDir(). +func newCreateManifestOpts(t *testing.T, rso *flags.StoreRootOpts) *flags.CreateManifestOpts { + t.Helper() + return &flags.CreateManifestOpts{ + StoreRootOpts: rso, + Output: filepath.Join(t.TempDir(), "manifest.yaml"), + } +} + +// readManifest reads the manifest file at path, failing the test on error. +func readManifest(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading manifest at %s: %v", path, err) + } + return string(data) +} + +func TestCreateManifestCmd_EmptyStore(t *testing.T) { + ctx := newTestContext(t) + s := newTestStore(t) + o := newCreateManifestOpts(t, defaultRootOpts(s.Root)) + + err := CreateManifestCmd(ctx, o, s) + if err == nil { + t.Fatal("expected error for empty store, got nil") + } + if !strings.Contains(err.Error(), "no content to build a manifest from") { + t.Errorf("unexpected error: %v", err) + } + if _, statErr := os.Stat(o.Output); statErr == nil { + t.Errorf("expected no manifest file to be written on error") + } +} + +func TestCreateManifestCmd_Image(t *testing.T) { + ctx := newTestContext(t) + host, rOpts := newLocalhostRegistry(t) + seedImage(t, host, "test/repo", "v1", rOpts...) + + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + if err := storeImage(ctx, s, v1.Image{Name: host + "/test/repo:v1"}, "", false, rso, ro, "", "", false); err != nil { + t.Fatalf("storeImage: %v", err) + } + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + if !strings.Contains(content, "kind: Images") { + t.Errorf("expected an Images doc, got:\n%s", content) + } + if !strings.Contains(content, "name: "+host+"/test/repo:v1") { + t.Errorf("expected image name %q in manifest, got:\n%s", host+"/test/repo:v1", content) + } + if strings.Contains(content, "rewrite:") { + t.Errorf("did not expect a rewrite field for a never-rewritten image, got:\n%s", content) + } +} + +func TestCreateManifestCmd_ImageWithRewrite(t *testing.T) { + ctx := newTestContext(t) + host, rOpts := newLocalhostRegistry(t) + seedImage(t, host, "src/repo", "v1", rOpts...) + + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + // storeImage with a rewrite target: the store ends up with the new ref as + // the "current" annotations, but consts.OriginalRefAnnotation still holds + // the original, pullable source ref captured at the initial add. + if err := storeImage(ctx, s, v1.Image{Name: host + "/src/repo:v1"}, "", false, rso, ro, "newrepo/img:v2", "", false); err != nil { + t.Fatalf("storeImage with rewrite: %v", err) + } + assertArtifactInStore(t, s, "newrepo/img:v2") + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + // The recovered name must be the original, pullable source ref... + if !strings.Contains(content, "name: "+host+"/src/repo:v1") { + t.Errorf("expected original ref %q recovered as name, got:\n%s", host+"/src/repo:v1", content) + } + // ...and rewrite must reproduce the store's actual current ref so a resync + // recreates this exact layout. + if !strings.Contains(content, "rewrite: "+host+"/newrepo/img:v2") { + t.Errorf("expected rewrite %q in manifest, got:\n%s", host+"/newrepo/img:v2", content) + } +} + +func TestCreateManifestCmd_MultiPlatformIndexOmitsPlatform(t *testing.T) { + ctx := newTestContext(t) + host, rOpts := newLocalhostRegistry(t) + seedIndex(t, host, "test/multiarch", "v1", rOpts...) + + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + if err := storeImage(ctx, s, v1.Image{Name: host + "/test/multiarch:v1"}, "", false, rso, ro, "", "", false); err != nil { + t.Fatalf("storeImage multi-arch index: %v", err) + } + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + if !strings.Contains(content, "name: "+host+"/test/multiarch:v1") { + t.Errorf("expected multi-arch image name in manifest, got:\n%s", content) + } + // A stored index has no single unambiguous platform, so it must be left + // unset rather than pinning one arbitrary arch. + if strings.Contains(content, "platform:") { + t.Errorf("did not expect a platform field for a multi-platform index, got:\n%s", content) + } +} + +func TestCreateManifestCmd_Chart(t *testing.T) { + ctx := newTestContext(t) + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + co := newAddChartOpts(chartTestdataDir, "") + if err := AddChartCmd(ctx, co, s, "rancher-cluster-templates-0.5.2.tgz", rso, ro); err != nil { + t.Fatalf("AddChartCmd: %v", err) + } + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + if !strings.Contains(content, "kind: Charts") { + t.Errorf("expected a Charts doc, got:\n%s", content) + } + if !strings.Contains(content, "name: rancher-cluster-templates") { + t.Errorf("expected chart name in manifest, got:\n%s", content) + } + if !strings.Contains(content, "repoURL: "+chartTestdataDir) { + t.Errorf("expected repoURL %q in manifest, got:\n%s", chartTestdataDir, content) + } + if strings.Contains(content, "NOTE: repoURL could not be recovered") { + t.Errorf("did not expect the missing-repoURL note when repoURL is known, got:\n%s", content) + } +} + +func TestCreateManifestCmd_ChartMissingRepoURL(t *testing.T) { + ctx := newTestContext(t) + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + // A chart added from a bare local .tgz path with no RepoURL, mirroring how a + // chart added without --repo has no recoverable source. + chartDir := t.TempDir() + tgzPath := seedChartWithImages(t, chartDir, nil) + + co := newAddChartOpts("", "") + if err := AddChartCmd(ctx, co, s, tgzPath, rso, ro); err != nil { + t.Fatalf("AddChartCmd: %v", err) + } + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + if !strings.Contains(content, "NOTE: repoURL could not be recovered from the store's metadata") { + t.Errorf("expected missing-repoURL note, got:\n%s", content) + } + if !strings.Contains(content, "name: test-chart") { + t.Errorf("expected chart name in manifest, got:\n%s", content) + } +} + +func TestCreateManifestCmd_ChartWithRewrite(t *testing.T) { + ctx := newTestContext(t) + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + co := newAddChartOpts(chartTestdataDir, "") + co.Rewrite = "myorg/custom-chart" + if err := AddChartCmd(ctx, co, s, "rancher-cluster-templates-0.5.2.tgz", rso, ro); err != nil { + t.Fatalf("AddChartCmd with rewrite: %v", err) + } + assertArtifactInStore(t, s, "myorg/custom-chart") + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + // The recovered name/repoURL must be the original chart, not the rewritten one. + if !strings.Contains(content, "name: rancher-cluster-templates") { + t.Errorf("expected original chart name in manifest, got:\n%s", content) + } + if !strings.Contains(content, "repoURL: "+chartTestdataDir) { + t.Errorf("expected original repoURL in manifest, got:\n%s", content) + } + if !strings.Contains(content, "rewrite: myorg/custom-chart") { + t.Errorf("expected rewrite field in manifest, got:\n%s", content) + } +} + +func TestCreateManifestCmd_File(t *testing.T) { + ctx := newTestContext(t) + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + tmp, err := os.CreateTemp(t.TempDir(), "testfile-*.txt") + if err != nil { + t.Fatal(err) + } + tmp.WriteString("hello hauler") //nolint:errcheck + tmp.Close() + + if err := storeFile(ctx, s, v1.File{Path: tmp.Name()}, ro, rso); err != nil { + t.Fatalf("storeFile: %v", err) + } + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + if !strings.Contains(content, "kind: Files") { + t.Errorf("expected a Files doc, got:\n%s", content) + } + if !strings.Contains(content, "path: "+tmp.Name()) { + t.Errorf("expected original local path %q recovered, got:\n%s", tmp.Name(), content) + } + if !strings.Contains(content, "name: "+filepath.Base(tmp.Name())) { + t.Errorf("expected file name %q in manifest, got:\n%s", filepath.Base(tmp.Name()), content) + } +} + +func TestCreateManifestCmd_FileHTTP(t *testing.T) { + ctx := newTestContext(t) + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + url := seedFileInHTTPServer(t, "script.sh", "#!/bin/sh\necho ok") + if err := storeFile(ctx, s, v1.File{Path: url}, ro, rso); err != nil { + t.Fatalf("storeFile: %v", err) + } + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + if !strings.Contains(content, "path: "+url) { + t.Errorf("expected original URL %q recovered as path, got:\n%s", url, content) + } +} + +func TestCreateManifestCmd_MixedContent(t *testing.T) { + ctx := newTestContext(t) + host, rOpts := newLocalhostRegistry(t) + seedImage(t, host, "test/repo", "v1", rOpts...) + + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + if err := storeImage(ctx, s, v1.Image{Name: host + "/test/repo:v1"}, "", false, rso, ro, "", "", false); err != nil { + t.Fatalf("storeImage: %v", err) + } + co := newAddChartOpts(chartTestdataDir, "") + if err := AddChartCmd(ctx, co, s, "rancher-cluster-templates-0.5.2.tgz", rso, ro); err != nil { + t.Fatalf("AddChartCmd: %v", err) + } + tmp, err := os.CreateTemp(t.TempDir(), "testfile-*.txt") + if err != nil { + t.Fatal(err) + } + tmp.Close() + if err := storeFile(ctx, s, v1.File{Path: tmp.Name()}, ro, rso); err != nil { + t.Fatalf("storeFile: %v", err) + } + + o := newCreateManifestOpts(t, rso) + if err := CreateManifestCmd(ctx, o, s); err != nil { + t.Fatalf("CreateManifestCmd: %v", err) + } + + content := readManifest(t, o.Output) + for _, kind := range []string{"kind: Images", "kind: Charts", "kind: Files"} { + if !strings.Contains(content, kind) { + t.Errorf("expected %q doc in mixed-content manifest, got:\n%s", kind, content) + } + } + if got := strings.Count(content, "---\n"); got != 3 { + t.Errorf("expected 3 YAML documents, got %d in:\n%s", got, content) + } +} + +func TestStoreLacksProvenance(t *testing.T) { + tests := []struct { + name string + version string + want bool + }{ + {name: "empty version", version: "", want: true}, + {name: "whitespace only", version: " ", want: true}, + {name: "unparseable", version: "not-a-version", want: true}, + {name: "older patch", version: "v2.0.2", want: true}, + {name: "older minor", version: "v2.0.99", want: true}, + {name: "older major", version: "v1.9.9", want: true}, + {name: "pseudo-version before threshold", version: "v2.0.2-0.20260728211252-c6fbcc97b769+dirty", want: true}, + {name: "threshold exactly", version: "v2.1.0", want: false}, + {name: "threshold pre-release", version: "v2.1.0-rc1", want: false}, + {name: "newer patch", version: "v2.1.5", want: false}, + {name: "newer major", version: "v3.0.0", want: false}, + {name: "missing v prefix still parses", version: "2.0.2", want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := storeLacksProvenance(tc.version); got != tc.want { + t.Errorf("storeLacksProvenance(%q) = %v, want %v", tc.version, got, tc.want) + } + }) + } +} + +func TestReadStoreHaulerVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "store.json") + if err := os.WriteFile(path, []byte(`{"store-id":"abc","hauler-version":"v2.0.2"}`), 0o644); err != nil { + t.Fatal(err) + } + got, err := readStoreHaulerVersion(dir) + if err != nil { + t.Fatalf("readStoreHaulerVersion: %v", err) + } + if got != "v2.0.2" { + t.Errorf("readStoreHaulerVersion = %q, want %q", got, "v2.0.2") + } + + // A store.json with no hauler-version field yields an empty string (which the + // caller treats as lacking provenance). + if err := os.WriteFile(path, []byte(`{"store-id":"abc"}`), 0o644); err != nil { + t.Fatal(err) + } + got, err = readStoreHaulerVersion(dir) + if err != nil { + t.Fatalf("readStoreHaulerVersion (no version): %v", err) + } + if got != "" { + t.Errorf("readStoreHaulerVersion (no version) = %q, want empty", got) + } + + // A missing store.json is surfaced as an error. + if _, err := readStoreHaulerVersion(t.TempDir()); err == nil { + t.Error("expected error reading version from a directory with no store.json") + } +} + +func TestDecodeOriginalChartRef(t *testing.T) { + tests := []struct { + name string + in string + wantRepoURL string + wantTotal string + }{ + { + name: "repoURL and ref separated by pipe", + in: "https://charts.example.com|myrepo/mychart:1.0.0", + wantRepoURL: "https://charts.example.com", + wantTotal: "myrepo/mychart:1.0.0", + }, + { + name: "empty repoURL with leading pipe", + in: "|myrepo/mychart:1.0.0", + wantRepoURL: "", + wantTotal: "myrepo/mychart:1.0.0", + }, + { + name: "no pipe treated as bare ref with unknown repoURL", + in: "myrepo/mychart:1.0.0", + wantRepoURL: "", + wantTotal: "myrepo/mychart:1.0.0", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotRepoURL, gotTotal := decodeOriginalChartRef(tc.in) + if gotRepoURL != tc.wantRepoURL || gotTotal != tc.wantTotal { + t.Errorf("decodeOriginalChartRef(%q) = (%q, %q), want (%q, %q)", + tc.in, gotRepoURL, gotTotal, tc.wantRepoURL, tc.wantTotal) + } + }) + } +} diff --git a/internal/flags/create.go b/internal/flags/create.go new file mode 100644 index 0000000..b0d7ad2 --- /dev/null +++ b/internal/flags/create.go @@ -0,0 +1,15 @@ +package flags + +import "github.com/spf13/cobra" + +type CreateManifestOpts struct { + *StoreRootOpts + + Output string +} + +func (o *CreateManifestOpts) AddFlags(cmd *cobra.Command) { + f := cmd.Flags() + + f.StringVarP(&o.Output, "output", "o", "", "(Optional) Path to write the generated manifest to (defaults to stdout)") +}