diff --git a/cmd/docker-variant/detect.go b/cmd/docker-variant/detect.go new file mode 100644 index 0000000..b7577af --- /dev/null +++ b/cmd/docker-variant/detect.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/achimnol/docker-variant/pkg/providers" + "github.com/achimnol/docker-variant/pkg/variant" +) + +// systemProperties resolves the system properties: from --properties-file / +// $DOCKER_VARIANT_PROPERTIES_FILE when given (mock detection), otherwise by +// running the built-in providers. Provider warnings go to stderr. +func systemProperties(cmd *cobra.Command, propertiesFile string) (variant.Properties, error) { + if propertiesFile == "" { + propertiesFile = os.Getenv(providers.PropertiesFileEnv) + } + if propertiesFile != "" { + return providers.LoadPropertiesFile(propertiesFile) + } + sys, errs := providers.DetectAll(cmd.Context(), providers.Default()) + for _, err := range errs { + fmt.Fprintln(cmd.ErrOrStderr(), "Warning:", err) + } + return sys, nil +} + +func newDetectCommand() *cobra.Command { + var ( + propertiesFile string + formatJSON bool + ) + cmd := &cobra.Command{ + Use: "detect", + Short: "Detect this system's variant properties", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + sys, err := systemProperties(cmd, propertiesFile) + if err != nil { + return err + } + if formatJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(sys) + } + if sys.Count() == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "(no variant properties detected)") + return nil + } + for _, line := range sys.Triples() { + fmt.Fprintln(cmd.OutOrStdout(), line) + } + return nil + }, + } + cmd.Flags().StringVar(&propertiesFile, "properties-file", "", "read system properties from a JSON file instead of detecting") + cmd.Flags().BoolVar(&formatJSON, "json", false, "output as JSON") + return cmd +} diff --git a/cmd/docker-variant/main.go b/cmd/docker-variant/main.go new file mode 100644 index 0000000..25fe5c5 --- /dev/null +++ b/cmd/docker-variant/main.go @@ -0,0 +1,65 @@ +// docker-variant is a Docker CLI plugin providing PEP 817-style +// variant-aware image operations. Installed into ~/.docker/cli-plugins/ it +// surfaces as `docker variant `; it also runs standalone as +// `docker-variant variant `. +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +const version = "0.1.0" + +// pluginMetadata is the handshake payload the Docker CLI requests via the +// reserved docker-cli-plugin-metadata subcommand. +var pluginMetadata = struct { + SchemaVersion string + Vendor string + Version string + ShortDescription string +}{ + SchemaVersion: "0.1.0", + Vendor: "docker-variant", + Version: version, + ShortDescription: "PEP 817-style variant-aware image operations", +} + +func main() { + root := &cobra.Command{ + Use: "docker-variant", + SilenceUsage: true, + SilenceErrors: true, + } + root.AddCommand(newMetadataCommand(), newVariantCommand()) + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} + +func newMetadataCommand() *cobra.Command { + return &cobra.Command{ + Use: "docker-cli-plugin-metadata", + Hidden: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return json.NewEncoder(cmd.OutOrStdout()).Encode(pluginMetadata) + }, + } +} + +func newVariantCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "variant", + Short: "Variant-aware image operations (PEP 817-style)", + Long: "Select, pull, and publish container images by hardware variant,\n" + + "following the PEP 817 wheel-variant model mapped onto image labels,\n" + + "tags, and a per-version variant index artifact.", + } + cmd.AddCommand(newDetectCommand()) + return cmd +} diff --git a/go.mod b/go.mod index b6e76cd..f9ad3db 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,13 @@ module github.com/achimnol/docker-variant go 1.26.4 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/sys v0.47.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6b4c588 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/providers/aarch64.go b/pkg/providers/aarch64.go new file mode 100644 index 0000000..3868863 --- /dev/null +++ b/pkg/providers/aarch64.go @@ -0,0 +1,20 @@ +package providers + +import ( + "context" + "runtime" +) + +// aarch64Provider reports a minimal ARM64 architecture property. Finer +// detection (v9, SVE, …) is future work; "v8" is the baseline every Go +// arm64 binary can assume. +type aarch64Provider struct{} + +func (aarch64Provider) Namespace() string { return "aarch64" } + +func (aarch64Provider) Detect(ctx context.Context) (map[string][]string, error) { + if runtime.GOARCH != "arm64" { + return nil, nil + } + return map[string][]string{"arch": {"v8"}}, nil +} diff --git a/pkg/providers/file.go b/pkg/providers/file.go new file mode 100644 index 0000000..1159c27 --- /dev/null +++ b/pkg/providers/file.go @@ -0,0 +1,31 @@ +package providers + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/achimnol/docker-variant/pkg/variant" +) + +// PropertiesFileEnv, when set, points at a properties file used instead of +// live detection (same as the --properties-file flag). +const PropertiesFileEnv = "DOCKER_VARIANT_PROPERTIES_FILE" + +// LoadPropertiesFile reads system properties from a JSON file shaped like +// variant.Properties: {"namespace": {"feature": ["v1", "v2"]}}. It is the +// mock-detection mechanism used by tests, the demo, and CI. +func LoadPropertiesFile(path string) (variant.Properties, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var props variant.Properties + if err := json.Unmarshal(data, &props); err != nil { + return nil, fmt.Errorf("parsing properties file %s: %w", path, err) + } + if err := props.Validate(); err != nil { + return nil, fmt.Errorf("properties file %s: %w", path, err) + } + return props, nil +} diff --git a/pkg/providers/nvidia.go b/pkg/providers/nvidia.go new file mode 100644 index 0000000..48409cb --- /dev/null +++ b/pkg/providers/nvidia.go @@ -0,0 +1,96 @@ +package providers + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// nvidiaProvider reports NVIDIA GPU properties by parsing `nvidia-smi` +// output. Absence of nvidia-smi (or of a GPU) is not an error — the +// namespace is simply not present. +// +// The feature cuda_version_lower_bound carries "lower bound" semantics in +// variant declarations ("built for CUDA >= X"). To keep the generic +// intersection rule of the selection algorithm, the provider expands the +// predicate: it emits every known CUDA release <= the detected version, +// newest first. +type nvidiaProvider struct{} + +func (nvidiaProvider) Namespace() string { return "nvidia" } + +// knownCUDAVersions lists CUDA releases, newest first. Extend at the front +// as new releases appear; ordering is the preference order. +var knownCUDAVersions = []string{ + "13.1", "13.0", + "12.9", "12.8", "12.6", "12.5", "12.4", "12.3", "12.2", "12.1", "12.0", + "11.8", "11.7", "11.6", "11.5", "11.4", "11.3", "11.2", "11.1", "11.0", +} + +func (nvidiaProvider) Detect(ctx context.Context) (map[string][]string, error) { + path, err := exec.LookPath("nvidia-smi") + if err != nil { + return nil, nil // no NVIDIA driver installed + } + out, err := exec.CommandContext(ctx, path).Output() + if err != nil { + // nvidia-smi exists but fails (e.g. driver/library mismatch): + // worth surfacing as a warning rather than silently reporting no GPU. + return nil, fmt.Errorf("nvidia-smi failed: %w", err) + } + cudaVersion, ok := parseNvidiaSMI(string(out)) + if !ok { + return nil, fmt.Errorf("could not find CUDA version in nvidia-smi output") + } + return map[string][]string{ + "cuda_version_lower_bound": satisfiableCUDABounds(cudaVersion, knownCUDAVersions), + }, nil +} + +var cudaVersionRe = regexp.MustCompile(`CUDA Version:\s*([0-9]+\.[0-9]+)`) + +// parseNvidiaSMI extracts the CUDA version from the nvidia-smi banner. +func parseNvidiaSMI(out string) (string, bool) { + m := cudaVersionRe.FindStringSubmatch(out) + if m == nil { + return "", false + } + return m[1], true +} + +// satisfiableCUDABounds returns the versions from known (newest-first) that +// are <= detected, i.e. every lower bound the detected CUDA version +// satisfies, preference-ordered. +func satisfiableCUDABounds(detected string, known []string) []string { + dMaj, dMin, ok := parseMajorMinor(detected) + if !ok { + return nil + } + var out []string + for _, v := range known { + maj, min, ok := parseMajorMinor(v) + if !ok { + continue + } + if maj < dMaj || (maj == dMaj && min <= dMin) { + out = append(out, v) + } + } + return out +} + +func parseMajorMinor(v string) (int, int, bool) { + majStr, minStr, found := strings.Cut(v, ".") + if !found { + return 0, 0, false + } + maj, err1 := strconv.Atoi(majStr) + min, err2 := strconv.Atoi(minStr) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return maj, min, true +} diff --git a/pkg/providers/provider.go b/pkg/providers/provider.go new file mode 100644 index 0000000..37e60b0 --- /dev/null +++ b/pkg/providers/provider.go @@ -0,0 +1,53 @@ +// Package providers implements hardware/platform detection producing the +// system properties consumed by the variant selection algorithm +// (pkg/variant). Per ADR-5, all providers are compiled in; there is no +// third-party plugin execution. +package providers + +import ( + "context" + "fmt" + + "github.com/achimnol/docker-variant/pkg/variant" +) + +// Provider detects the supported values of one property namespace. +type Provider interface { + // Namespace this provider reports properties for. + Namespace() string + // Detect returns feature -> preference-ordered supported values + // (most-preferred first). An empty map means the namespace is not + // present on this system (e.g. no NVIDIA GPU) — not an error. + Detect(ctx context.Context) (map[string][]string, error) +} + +// Default returns the built-in provider set. +func Default() []Provider { + return []Provider{ + nvidiaProvider{}, + x86Provider{}, + aarch64Provider{}, + } +} + +// DetectAll runs every provider and merges the results into system +// properties. A provider error does not abort detection; errors are +// collected and returned alongside the merged result so callers can surface +// them as warnings. +func DetectAll(ctx context.Context, provs []Provider) (variant.Properties, []error) { + sys := variant.Properties{} + var errs []error + for _, p := range provs { + features, err := p.Detect(ctx) + if err != nil { + errs = append(errs, fmt.Errorf("provider %s: %w", p.Namespace(), err)) + continue + } + for feature, values := range features { + if len(values) > 0 { + sys.Add(p.Namespace(), feature, values...) + } + } + } + return sys, errs +} diff --git a/pkg/providers/providers_test.go b/pkg/providers/providers_test.go new file mode 100644 index 0000000..4b39c32 --- /dev/null +++ b/pkg/providers/providers_test.go @@ -0,0 +1,122 @@ +package providers + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" +) + +func TestSupportedLevels(t *testing.T) { + v2CPU := x86Features{SSE3: true, SSSE3: true, SSE41: true, SSE42: true, POPCNT: true} + v3CPU := v2CPU + v3CPU.AVX, v3CPU.AVX2, v3CPU.BMI1, v3CPU.BMI2, v3CPU.FMA, v3CPU.OSXSAVE = true, true, true, true, true, true + v4CPU := v3CPU + v4CPU.AVX512F, v4CPU.AVX512BW, v4CPU.AVX512CD, v4CPU.AVX512DQ, v4CPU.AVX512VL = true, true, true, true, true + avxOnly := x86Features{AVX: true, AVX2: true} // missing v2 prerequisites + + cases := []struct { + name string + cpu x86Features + want []string + }{ + {"bare", x86Features{}, []string{"v1"}}, + {"v2", v2CPU, []string{"v2", "v1"}}, + {"v3", v3CPU, []string{"v3", "v2", "v1"}}, + {"v4", v4CPU, []string{"v4", "v3", "v2", "v1"}}, + {"avx without v2 base", avxOnly, []string{"v1"}}, + } + for _, c := range cases { + if got := supportedLevels(c.cpu); !slices.Equal(got, c.want) { + t.Errorf("%s: supportedLevels = %v, want %v", c.name, got, c.want) + } + } +} + +func TestParseNvidiaSMI(t *testing.T) { + banner := `Mon Jul 27 10:00:00 2026 ++-----------------------------------------------------------------------------------------+ +| NVIDIA-SMI 570.86.10 Driver Version: 570.86.10 CUDA Version: 12.8 | +|-----------------------------------------+------------------------+----------------------+ +` + v, ok := parseNvidiaSMI(banner) + if !ok || v != "12.8" { + t.Errorf("parseNvidiaSMI = %q, %v; want 12.8, true", v, ok) + } + if _, ok := parseNvidiaSMI("no gpu here"); ok { + t.Error("parseNvidiaSMI on garbage: want ok=false") + } +} + +func TestSatisfiableCUDABounds(t *testing.T) { + known := []string{"13.0", "12.8", "12.6", "12.0", "11.8"} + cases := []struct { + detected string + want []string + }{ + {"12.8", []string{"12.8", "12.6", "12.0", "11.8"}}, + {"13.0", []string{"13.0", "12.8", "12.6", "12.0", "11.8"}}, + {"11.0", nil}, + {"garbage", nil}, + } + for _, c := range cases { + if got := satisfiableCUDABounds(c.detected, known); !slices.Equal(got, c.want) { + t.Errorf("satisfiableCUDABounds(%q) = %v, want %v", c.detected, got, c.want) + } + } +} + +type fakeProvider struct { + ns string + features map[string][]string + err error +} + +func (f fakeProvider) Namespace() string { return f.ns } +func (f fakeProvider) Detect(context.Context) (map[string][]string, error) { + return f.features, f.err +} + +func TestDetectAll(t *testing.T) { + sys, errs := DetectAll(context.Background(), []Provider{ + fakeProvider{ns: "nvidia", features: map[string][]string{"cuda_version_lower_bound": {"12.8", "12.0"}}}, + fakeProvider{ns: "rocm", err: errors.New("boom")}, + fakeProvider{ns: "empty"}, // namespace not present + }) + if len(errs) != 1 { + t.Errorf("errs = %v, want exactly the rocm failure", errs) + } + if got := sys.Get("nvidia", "cuda_version_lower_bound"); !slices.Equal(got, []string{"12.8", "12.0"}) { + t.Errorf("nvidia values = %v", got) + } + if _, ok := sys["empty"]; ok { + t.Error("empty namespace should not appear in system properties") + } + if _, ok := sys["rocm"]; ok { + t.Error("failed provider should not contribute properties") + } +} + +func TestLoadPropertiesFile(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "good.json") + os.WriteFile(good, []byte(`{"nvidia": {"cuda_version_lower_bound": ["12.8", "12.0"]}}`), 0o644) + props, err := LoadPropertiesFile(good) + if err != nil { + t.Fatalf("LoadPropertiesFile: %v", err) + } + if got := props.Get("nvidia", "cuda_version_lower_bound"); !slices.Equal(got, []string{"12.8", "12.0"}) { + t.Errorf("props = %v", got) + } + + bad := filepath.Join(dir, "bad.json") + os.WriteFile(bad, []byte(`{"NVIDIA!": {"x": ["1"]}}`), 0o644) + if _, err := LoadPropertiesFile(bad); err == nil { + t.Error("invalid namespace: want error") + } + if _, err := LoadPropertiesFile(filepath.Join(dir, "missing.json")); err == nil { + t.Error("missing file: want error") + } +} diff --git a/pkg/providers/x86_64.go b/pkg/providers/x86_64.go new file mode 100644 index 0000000..e1880ba --- /dev/null +++ b/pkg/providers/x86_64.go @@ -0,0 +1,64 @@ +package providers + +import ( + "context" + "runtime" + + "golang.org/x/sys/cpu" +) + +// x86Provider reports the x86-64 microarchitecture levels supported by the +// host CPU, as the feature "level" with values "v4" > "v3" > "v2" > "v1" +// (all supported levels are emitted, preference-ordered). +type x86Provider struct{} + +func (x86Provider) Namespace() string { return "x86_64" } + +func (x86Provider) Detect(ctx context.Context) (map[string][]string, error) { + if runtime.GOARCH != "amd64" { + return nil, nil + } + return map[string][]string{"level": supportedLevels(hostX86Features())}, nil +} + +// x86Features is the subset of CPUID flags consulted by supportedLevels, +// extracted as a struct so tests can construct arbitrary CPUs. +type x86Features struct { + SSE3, SSSE3, SSE41, SSE42, POPCNT bool + AVX, AVX2, BMI1, BMI2, FMA, OSXSAVE bool + AVX512F, AVX512BW, AVX512CD, AVX512DQ, AVX512VL bool +} + +func hostX86Features() x86Features { + return x86Features{ + SSE3: cpu.X86.HasSSE3, SSSE3: cpu.X86.HasSSSE3, + SSE41: cpu.X86.HasSSE41, SSE42: cpu.X86.HasSSE42, + POPCNT: cpu.X86.HasPOPCNT, + AVX: cpu.X86.HasAVX, AVX2: cpu.X86.HasAVX2, + BMI1: cpu.X86.HasBMI1, BMI2: cpu.X86.HasBMI2, + FMA: cpu.X86.HasFMA, OSXSAVE: cpu.X86.HasOSXSAVE, + AVX512F: cpu.X86.HasAVX512F, AVX512BW: cpu.X86.HasAVX512BW, + AVX512CD: cpu.X86.HasAVX512CD, AVX512DQ: cpu.X86.HasAVX512DQ, + AVX512VL: cpu.X86.HasAVX512VL, + } +} + +// supportedLevels computes the x86-64-v* levels from CPUID flags. The checks +// follow the psABI level definitions, limited to the flags golang.org/x/sys +// exposes. +func supportedLevels(f x86Features) []string { + v2 := f.SSE3 && f.SSSE3 && f.SSE41 && f.SSE42 && f.POPCNT + v3 := v2 && f.AVX && f.AVX2 && f.BMI1 && f.BMI2 && f.FMA && f.OSXSAVE + v4 := v3 && f.AVX512F && f.AVX512BW && f.AVX512CD && f.AVX512DQ && f.AVX512VL + levels := []string{"v1"} + if v2 { + levels = append([]string{"v2"}, levels...) + } + if v3 { + levels = append([]string{"v3"}, levels...) + } + if v4 { + levels = append([]string{"v4"}, levels...) + } + return levels +}