From 79e0e5dc85f629b1b65a31b9685c99212c9e54f2 Mon Sep 17 00:00:00 2001 From: Joongi Kim Date: Mon, 27 Jul 2026 03:16:00 +0000 Subject: [PATCH] feat: core variant model, index schema, and selection algorithm pkg/variant is pure (no registry/engine I/O): variant label and property validation, image-label parsing, the variant index document (schema-version 1), and the PEP 817-ported selection algorithm (compatibility filter + namespace/feature/value priority ordering with system-preference and lexicographic fallbacks). One deviation from DESIGN.md as first written: property *values* accept dots (^[a-z0-9_.]+$) since version-like values ("12.8") are the primary use case; namespaces and features stay ^[a-z0-9_]+$. DESIGN.md is amended in the base branch. Also adds the Go module, Makefile, .gitignore, and a CI workflow (gofmt check, vet, unit tests). Claude-Session: https://claude.ai/code/session_01D383U8kkQkJc1yzyC5H5Nk --- .github/workflows/ci.yml | 21 +++++ .gitignore | 3 + Makefile | 15 ++++ go.mod | 3 + pkg/variant/index.go | 121 +++++++++++++++++++++++++ pkg/variant/index_test.go | 116 ++++++++++++++++++++++++ pkg/variant/labels.go | 94 +++++++++++++++++++ pkg/variant/labels_test.go | 125 ++++++++++++++++++++++++++ pkg/variant/ordering.go | 170 +++++++++++++++++++++++++++++++++++ pkg/variant/ordering_test.go | 170 +++++++++++++++++++++++++++++++++++ pkg/variant/property.go | 132 +++++++++++++++++++++++++++ 11 files changed, 970 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 go.mod create mode 100644 pkg/variant/index.go create mode 100644 pkg/variant/index_test.go create mode 100644 pkg/variant/labels.go create mode 100644 pkg/variant/labels_test.go create mode 100644 pkg/variant/ordering.go create mode 100644 pkg/variant/ordering_test.go create mode 100644 pkg/variant/property.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4bfac90 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: gofmt + run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) + - name: vet + run: go vet ./... + - name: unit tests + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43b98c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/bin/ +/dist/ +*.test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..863781a --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +GO ?= go + +.PHONY: test vet fmt tidy + +test: + $(GO) test ./... + +vet: + $(GO) vet ./... + +fmt: + gofmt -w . + +tidy: + $(GO) mod tidy diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b6e76cd --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/achimnol/docker-variant + +go 1.26.4 diff --git a/pkg/variant/index.go b/pkg/variant/index.go new file mode 100644 index 0000000..dfa2e26 --- /dev/null +++ b/pkg/variant/index.go @@ -0,0 +1,121 @@ +package variant + +import ( + "encoding/json" + "fmt" +) + +const ( + // SchemaVersion of the variant index document this package produces. + SchemaVersion = "1" + // IndexArtifactType is the OCI artifactType (and layer media type) of the + // variant index artifact. + IndexArtifactType = "application/vnd.pep817.container-variants.v1+json" +) + +// Entry describes one variant inside an Index. +type Entry struct { + Properties Properties `json:"properties"` + // Tag is the variant tag the entry was generated from (informational). + Tag string `json:"tag,omitempty"` + // Digest of the manifest (or manifest list) the variant tag pointed to + // when the index was generated. Selection pulls by this digest. + Digest string `json:"digest,omitempty"` +} + +// Priorities mirrors PEP 817's default-priorities: ordered preference lists +// for namespaces, features within a namespace, and values within a feature. +type Priorities struct { + Namespace []string `json:"namespace,omitempty"` + Feature map[string][]string `json:"feature,omitempty"` + Property map[string]map[string][]string `json:"property,omitempty"` +} + +// IsZero reports whether no priorities are set (used for omitzero). +func (p Priorities) IsZero() bool { + return len(p.Namespace) == 0 && len(p.Feature) == 0 && len(p.Property) == 0 +} + +// Index is the per-(repository, base version) variant index — the container +// analogue of PEP 817's {name}-{version}-variants.json. +type Index struct { + SchemaVersion string `json:"schema-version"` + Repository string `json:"repository,omitempty"` + Version string `json:"version"` + DefaultPriorities Priorities `json:"default-priorities,omitzero"` + Variants map[string]Entry `json:"variants"` +} + +// NewIndex returns an empty index for a repository and base version. +func NewIndex(repository, version string) *Index { + return &Index{ + SchemaVersion: SchemaVersion, + Repository: repository, + Version: version, + Variants: map[string]Entry{}, + } +} + +// Upsert validates and inserts (or replaces) a variant entry. +func (ix *Index) Upsert(label string, e Entry) error { + if err := ValidateLabel(label); err != nil { + return err + } + if e.Properties == nil { + e.Properties = Properties{} + } + if err := e.Properties.Validate(); err != nil { + return fmt.Errorf("variant %q: %w", label, err) + } + if label == NullLabel && e.Properties.Count() > 0 { + return fmt.Errorf("null variant must not declare properties") + } + ix.Variants[label] = e + return nil +} + +// Validate checks the whole index document. +func (ix *Index) Validate() error { + if ix.SchemaVersion != SchemaVersion { + return fmt.Errorf("unsupported schema-version %q (supported: %q)", ix.SchemaVersion, SchemaVersion) + } + if ix.Version == "" { + return fmt.Errorf("index has no version") + } + for label, e := range ix.Variants { + if err := ValidateLabel(label); err != nil { + return err + } + if err := e.Properties.Validate(); err != nil { + return fmt.Errorf("variant %q: %w", label, err) + } + if label == NullLabel && e.Properties.Count() > 0 { + return fmt.Errorf("null variant must not declare properties") + } + } + return nil +} + +// ParseIndex unmarshals and validates an index document. +func ParseIndex(data []byte) (*Index, error) { + var ix Index + if err := json.Unmarshal(data, &ix); err != nil { + return nil, fmt.Errorf("parsing variant index: %w", err) + } + if ix.Variants == nil { + ix.Variants = map[string]Entry{} + } + if err := ix.Validate(); err != nil { + return nil, err + } + return &ix, nil +} + +// Marshal renders the index as indented JSON, ready for storage as the +// artifact blob. +func (ix *Index) Marshal() ([]byte, error) { + if err := ix.Validate(); err != nil { + return nil, err + } + return json.MarshalIndent(ix, "", " ") +} diff --git a/pkg/variant/index_test.go b/pkg/variant/index_test.go new file mode 100644 index 0000000..e36db19 --- /dev/null +++ b/pkg/variant/index_test.go @@ -0,0 +1,116 @@ +package variant + +import ( + "encoding/json" + "testing" +) + +func sampleIndex(t *testing.T) *Index { + t.Helper() + ix := NewIndex("registry.example.com/myimg", "2.1.0") + ix.DefaultPriorities = Priorities{ + Namespace: []string{"nvidia", "x86_64"}, + Feature: map[string][]string{"nvidia": {"cuda_version_lower_bound"}}, + Property: map[string]map[string][]string{ + "nvidia": {"cuda_version_lower_bound": {"12.8", "12.0"}}, + }, + } + for label, e := range map[string]Entry{ + "cu128": { + Properties: Properties{"nvidia": {"cuda_version_lower_bound": {"12.8"}}}, + Tag: "2.1.0-cu128", + Digest: "sha256:aaa", + }, + "null": {Properties: Properties{}, Tag: "2.1.0-null", Digest: "sha256:bbb"}, + } { + if err := ix.Upsert(label, e); err != nil { + t.Fatalf("Upsert(%q): %v", label, err) + } + } + return ix +} + +func TestIndexRoundTrip(t *testing.T) { + ix := sampleIndex(t) + data, err := ix.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + parsed, err := ParseIndex(data) + if err != nil { + t.Fatalf("ParseIndex: %v", err) + } + if parsed.Version != "2.1.0" || parsed.Repository != "registry.example.com/myimg" { + t.Errorf("round-trip lost identity: %+v", parsed) + } + if len(parsed.Variants) != 2 { + t.Errorf("round-trip lost variants: %v", parsed.Variants) + } + if got := parsed.Variants["cu128"].Properties.Get("nvidia", "cuda_version_lower_bound"); len(got) != 1 || got[0] != "12.8" { + t.Errorf("round-trip lost properties: %v", got) + } + if got := parsed.DefaultPriorities.Namespace; len(got) != 2 || got[0] != "nvidia" { + t.Errorf("round-trip lost priorities: %v", got) + } +} + +func TestIndexJSONShape(t *testing.T) { + // The wire format is the contract of docs/DESIGN.md §3.2 — assert the + // exact key names. + data, err := sampleIndex(t).Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + for _, key := range []string{"schema-version", "repository", "version", "default-priorities", "variants"} { + if _, ok := raw[key]; !ok { + t.Errorf("marshaled index missing key %q in %s", key, data) + } + } + // An index without priorities omits the key entirely. + empty := NewIndex("r", "1.0") + data, err = empty.Marshal() + if err != nil { + t.Fatal(err) + } + raw = nil + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["default-priorities"]; ok { + t.Errorf("empty priorities should be omitted: %s", data) + } +} + +func TestIndexUpsertRejects(t *testing.T) { + ix := NewIndex("r", "1.0") + if err := ix.Upsert("variants", Entry{}); err == nil { + t.Error("Upsert(variants): want error (reserved)") + } + if err := ix.Upsert("BAD", Entry{}); err == nil { + t.Error("Upsert(BAD): want error") + } + if err := ix.Upsert("null", Entry{Properties: Properties{"nvidia": {"cuda": {"12.8"}}}}); err == nil { + t.Error("Upsert(null with properties): want error") + } + if err := ix.Upsert("cu128", Entry{Properties: Properties{"nvidia": {"cuda": {}}}}); err == nil { + t.Error("Upsert(empty value list): want error") + } +} + +func TestParseIndexRejects(t *testing.T) { + cases := map[string]string{ + "bad schema version": `{"schema-version": "99", "version": "1.0", "variants": {}}`, + "missing version": `{"schema-version": "1", "variants": {}}`, + "invalid label": `{"schema-version": "1", "version": "1.0", "variants": {"BAD": {"properties": {}}}}`, + "not json": `nope`, + } + for name, doc := range cases { + if _, err := ParseIndex([]byte(doc)); err == nil { + t.Errorf("%s: want error, got nil", name) + } + } +} diff --git a/pkg/variant/labels.go b/pkg/variant/labels.go new file mode 100644 index 0000000..5cbe458 --- /dev/null +++ b/pkg/variant/labels.go @@ -0,0 +1,94 @@ +package variant + +import ( + "fmt" + "strings" +) + +const ( + // VariantLabelKey is the image config label carrying the variant label. + VariantLabelKey = "dev.pep817.variant-label" + // PropertyLabelPrefix prefixes image config labels carrying variant + // properties: dev.pep817.variant.. = v1[,v2...] + PropertyLabelPrefix = "dev.pep817.variant." +) + +// ParseImageLabels extracts the variant label and variant properties from an +// image's config labels, per the schema in docs/DESIGN.md §2. It returns an +// error if the variant label is missing or any key/value is malformed. +// Non-variant labels are ignored. +func ParseImageLabels(labels map[string]string) (string, Properties, error) { + label, ok := labels[VariantLabelKey] + if !ok { + return "", nil, fmt.Errorf("image has no %s label", VariantLabelKey) + } + if err := ValidateLabel(label); err != nil { + return "", nil, err + } + props := Properties{} + for key, value := range labels { + if !strings.HasPrefix(key, PropertyLabelPrefix) { + continue + } + nsFeature := strings.TrimPrefix(key, PropertyLabelPrefix) + ns, feature, found := strings.Cut(nsFeature, ".") + if !found { + return "", nil, fmt.Errorf("malformed property label key %q: want %s.", key, PropertyLabelPrefix) + } + var values []string + for _, v := range strings.Split(value, ",") { + v = strings.TrimSpace(v) + if v != "" { + values = append(values, v) + } + } + if len(values) == 0 { + return "", nil, fmt.Errorf("property label %q has no values", key) + } + props.Add(ns, feature, values...) + } + if err := props.Validate(); err != nil { + return "", nil, err + } + if label == NullLabel && props.Count() > 0 { + return "", nil, fmt.Errorf("null variant must not declare properties, found %d", props.Count()) + } + return label, props, nil +} + +// IndexTag returns the tag addressing the variant index artifact for a base +// version. +func IndexTag(version string) string { + return version + "-variants" +} + +// VariantTag returns the image tag for a (base version, label) pair. +func VariantTag(version, label string) string { + return version + "-" + label +} + +// BaseVersion strips the "-