mirror of
https://github.com/hauler-dev/hauler.git
synced 2026-08-27 14:07:17 +00:00
create a manifest from the contents of the store (#695)
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
Reference in New Issue
Block a user