diff --git a/cmd/hauler/cli/store/create_manifest.go b/cmd/hauler/cli/store/create_manifest.go index f791465..f623db9 100644 --- a/cmd/hauler/cli/store/create_manifest.go +++ b/cmd/hauler/cli/store/create_manifest.go @@ -10,6 +10,7 @@ import ( 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" @@ -65,6 +66,13 @@ func CreateManifestCmd(ctx context.Context, o *flags.CreateManifestOpts, s *stor 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.") + } + var images []manifestImage var charts []manifestChart var files []manifestFile @@ -237,6 +245,50 @@ func CreateManifestCmd(ctx context.Context, o *flags.CreateManifestOpts, s *stor 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", diff --git a/cmd/hauler/cli/store/create_manifest_test.go b/cmd/hauler/cli/store/create_manifest_test.go index 1201c66..92d056e 100644 --- a/cmd/hauler/cli/store/create_manifest_test.go +++ b/cmd/hauler/cli/store/create_manifest_test.go @@ -327,6 +327,67 @@ func TestCreateManifestCmd_MixedContent(t *testing.T) { } } +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