Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/bin/
/dist/
*.test
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
GO ?= go

.PHONY: test vet fmt tidy

test:
$(GO) test ./...

vet:
$(GO) vet ./...

fmt:
gofmt -w .

tidy:
$(GO) mod tidy
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/achimnol/docker-variant

go 1.26.4
121 changes: 121 additions & 0 deletions pkg/variant/index.go
Original file line number Diff line number Diff line change
@@ -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, "", " ")
}
116 changes: 116 additions & 0 deletions pkg/variant/index_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
94 changes: 94 additions & 0 deletions pkg/variant/labels.go
Original file line number Diff line number Diff line change
@@ -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.<namespace>.<feature> = 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<namespace>.<feature>", 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 "-<label>" suffix from a variant tag, validating
// that the tag actually ends with it.
func BaseVersion(tag, label string) (string, error) {
suffix := "-" + label
version, found := strings.CutSuffix(tag, suffix)
if !found || version == "" {
return "", fmt.Errorf("tag %q does not end in %q: variant tags must be <base-version>-<label>", tag, suffix)
}
return version, nil
}

// MatchVariantTag reports whether tag names a variant of version, returning
// the label. It is used when scanning a repository's tag list: it matches
// "<version>-<label>" where label is syntactically valid and not the
// reserved index suffix.
func MatchVariantTag(tag, version string) (string, bool) {
label, found := strings.CutPrefix(tag, version+"-")
if !found {
return "", false
}
if ValidateLabel(label) != nil {
return "", false
}
return label, true
}
Loading
Loading