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
63 changes: 63 additions & 0 deletions cmd/docker-variant/detect.go
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 65 additions & 0 deletions cmd/docker-variant/main.go
Original file line number Diff line number Diff line change
@@ -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 <subcommand>`; it also runs standalone as
// `docker-variant variant <subcommand>`.
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
}
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
20 changes: 20 additions & 0 deletions pkg/providers/aarch64.go
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions pkg/providers/file.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions pkg/providers/nvidia.go
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions pkg/providers/provider.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading