create a manifest from the contents of the store (#695)

This commit is contained in:
Camryn Carter
2026-08-19 11:50:09 -04:00
committed by GitHub
parent f24b813927
commit 923a92d8ce
4 changed files with 843 additions and 0 deletions
+48
View File
@@ -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{
+354
View File
@@ -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
}
@@ -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)
}
})
}
}
+15
View File
@@ -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)")
}