warning message if hauler store created before 2.1.0 provenance

This commit is contained in:
CamrynCarter
2026-08-13 18:37:29 -07:00
parent b6588244e3
commit 809211d181
2 changed files with 113 additions and 0 deletions
+52
View File
@@ -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",
@@ -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