platforms: define selectors for platforms

For supporting selection of images and runtimes in the containerized
world, there is thin support for selecting objects by platform. This
package defines a syntax to display to users that can express specific
platforms in addition to wild cards for matching platforms.

The plan is to extend this to provide support for parsing flag
arguments and displaying platform types for images. This package will
also provide a configurable matcher to allow match of platforms against
arbitrary targets, invariant to the Go compilation.

The internals are based the OCI Image Spec model.

This changeset is being submitted for feedback on the approach before
this gets larger. Specifically, review the unit tests and raise any
concerns.

Signed-off-by: Stephen J Day <stephen.day@docker.com>
This commit is contained in:
Stephen J Day 2017-08-18 17:45:40 -07:00
parent 17901fafa0
commit fb0688362c
No known key found for this signature in database
GPG Key ID: 67B3DED84EDC823F
5 changed files with 475 additions and 0 deletions

77
platforms/database.go Normal file
View File

@ -0,0 +1,77 @@
package platforms
import (
"runtime"
"strings"
)
// These function are generated from from https://golang.org/src/go/build/syslist.go.
//
// We use switch statements because they are slightly faster than map lookups
// and use a little less memory.
// isKnownOS returns true if we know about the operating system.
//
// The OS value should be normalized before calling this function.
func isKnownOS(os string) bool {
switch os {
case "android", "darwin", "dragonfly", "freebsd", "linux", "nacl", "netbsd", "openbsd", "plan9", "solaris", "windows", "zos":
return true
}
return false
}
// isKnownArch returns true if we know about the architecture.
//
// The arch value should be normalized before being passed to this function.
func isKnownArch(arch string) bool {
switch arch {
case "386", "amd64", "amd64p32", "arm", "armbe", "arm64", "arm64be", "ppc64", "ppc64le", "mips", "mipsle", "mips64", "mips64le", "mips64p32", "mips64p32le", "ppc", "s390", "s390x", "sparc", "sparc64":
return true
}
return false
}
func normalizeOS(os string) string {
if os == "" {
return runtime.GOOS
}
os = strings.ToLower(os)
switch os {
case "macos":
os = "darwin"
}
return os
}
// normalizeArch normalizes the architecture.
func normalizeArch(arch, variant string) (string, string) {
arch, variant = strings.ToLower(arch), strings.ToLower(variant)
switch arch {
case "i386":
arch = "386"
variant = ""
case "x86_64", "x86-64":
arch = "amd64"
variant = ""
case "aarch64":
arch = "arm64"
variant = "" // v8 is implied
case "armhf":
arch = "arm"
variant = ""
case "armel":
arch = "arm"
variant = "v6"
case "arm":
switch variant {
case "v7", "7":
variant = "v7"
case "5", "6", "8":
variant = "v" + variant
}
}
return arch, variant
}

16
platforms/defaults.go Normal file
View File

@ -0,0 +1,16 @@
package platforms
import (
"runtime"
specs "github.com/opencontainers/image-spec/specs-go/v1"
)
// Default returns the current platform's default platform specification.
func Default() specs.Platform {
return specs.Platform{
OS: runtime.GOOS,
Architecture: runtime.GOARCH,
// TODO(stevvooe): Need to resolve GOARM for arm hosts.
}
}

View File

@ -0,0 +1,20 @@
package platforms
import (
"reflect"
"runtime"
"testing"
specs "github.com/opencontainers/image-spec/specs-go/v1"
)
func TestDefault(t *testing.T) {
expected := specs.Platform{
OS: runtime.GOOS,
Architecture: runtime.GOARCH,
}
p := Default()
if !reflect.DeepEqual(p, expected) {
t.Fatalf("default platform not as expected: %#v != %#v", p, expected)
}
}

129
platforms/platforms.go Normal file
View File

@ -0,0 +1,129 @@
package platforms
import (
"regexp"
"runtime"
"strings"
"github.com/containerd/containerd/errdefs"
specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
)
type platformKey struct {
os string
arch string
variant string
}
var (
selectorRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
// ParseSelector parses the platform selector syntax into a platform
// declaration.
//
// Platform selectors are in the format <os|arch>[/<arch>[/<variant>]]. The
// minimum required information for a platform selector is the operating system
// or architecture. If there is only a single string (no slashes), the value
// will be matched against the known set of operating systems, then fall
// back to the known set of architectures. The missing component will be
// inferred based on the local environment.
func Parse(selector string) (specs.Platform, error) {
if strings.Contains(selector, "*") {
// TODO(stevvooe): need to work out exact wildcard handling
return specs.Platform{}, errors.Wrapf(errdefs.ErrInvalidArgument, "%q: wildcards not yet supported", selector)
}
parts := strings.Split(selector, "/")
for _, part := range parts {
if !selectorRe.MatchString(part) {
return specs.Platform{}, errors.Wrapf(errdefs.ErrInvalidArgument, "%q is an invalid component of %q: platform selector component must match %q", part, selector, selectorRe.String())
}
}
var p specs.Platform
switch len(parts) {
case 1:
// in this case, we will test that the value might be an OS, then look
// it up. If it is not known, we'll treat it as an architecture. Since
// we have very little information about the platform here, we are
// going to be a little more strict if we don't know about the argument
// value.
p.OS = normalizeOS(parts[0])
if isKnownOS(p.OS) {
// picks a default architecture
p.Architecture = runtime.GOARCH
if p.Architecture == "arm" {
// TODO(stevvooe): Resolve arm variant, if not v6 (default)
}
return p, nil
}
p.Architecture, p.Variant = normalizeArch(parts[0], "")
if isKnownArch(p.Architecture) {
p.OS = runtime.GOOS
return p, nil
}
return specs.Platform{}, errors.Wrapf(errdefs.ErrInvalidArgument, "%q: unknown operating system or architecture", selector)
case 2:
// In this case, we treat as a regular os/arch pair. We don't care
// about whether or not we know of the platform.
p.OS = normalizeOS(parts[0])
p.Architecture, p.Variant = normalizeArch(parts[1], "")
return p, nil
case 3:
// we have a fully specified variant, this is rare
p.OS = normalizeOS(parts[0])
p.Architecture, p.Variant = normalizeArch(parts[1], parts[2])
return p, nil
}
return specs.Platform{}, errors.Wrapf(errdefs.ErrInvalidArgument, "%q: cannot parse platform selector", selector)
}
func Match(selector string, platform specs.Platform) bool {
return false
}
// Format returns a string that provides a shortened overview of the platform.
func Format(platform specs.Platform) string {
if platform.OS == "" {
return "unknown"
}
return joinNotEmpty(platform.OS, platform.Architecture, platform.Variant)
}
func joinNotEmpty(s ...string) string {
var ss []string
for _, s := range s {
if s == "" {
continue
}
ss = append(ss, s)
}
return strings.Join(ss, "/")
}
// Normalize validates and translate the platform to the canonical value.
//
// For example, if "Aarch64" is encountered, we change it to "arm64" or if
// "x86_64" is encountered, it becomes "amd64".
func Normalize(platform specs.Platform) specs.Platform {
platform.OS = normalizeOS(platform.OS)
platform.Architecture, platform.Variant = normalizeArch(platform.Architecture, platform.Variant)
// these fields are deprecated, remove them
platform.OSFeatures = nil
platform.OSVersion = ""
return platform
}

233
platforms/platforms_test.go Normal file
View File

@ -0,0 +1,233 @@
package platforms
import (
"reflect"
"runtime"
"testing"
specs "github.com/opencontainers/image-spec/specs-go/v1"
)
func TestParseSelector(t *testing.T) {
var (
defaultOS = runtime.GOOS
defaultArch = runtime.GOARCH
)
for _, testcase := range []struct {
skip bool
input string
expected specs.Platform
formatted string
}{
// While wildcards are a valid use case for platform selection,
// addressing these cases is outside the initial scope for this
// package. When we do add platform wildcards, we should add in these
// testcases to ensure that they are correctly represented.
{
skip: true,
input: "*",
expected: specs.Platform{
OS: "*",
Architecture: "*",
},
formatted: "*/*",
},
{
skip: true,
input: "linux/*",
expected: specs.Platform{
OS: "linux",
Architecture: "*",
},
formatted: "linux/*",
},
{
skip: true,
input: "*/arm64",
expected: specs.Platform{
OS: "*",
Architecture: "arm64",
},
formatted: "*/arm64",
},
{
// NOTE(stevvooe): In this case, the consumer can assume this is v7
// but we leave the variant blank. This will represent the vast
// majority of arm images.
input: "linux/arm",
expected: specs.Platform{
OS: "linux",
Architecture: "arm",
},
formatted: "linux/arm",
},
{
input: "linux/arm/v6",
expected: specs.Platform{
OS: "linux",
Architecture: "arm",
Variant: "v6",
},
formatted: "linux/arm/v6",
},
{
input: "linux/arm/v7",
expected: specs.Platform{
OS: "linux",
Architecture: "arm",
Variant: "v7",
},
formatted: "linux/arm/v7",
},
{
input: "arm",
expected: specs.Platform{
OS: defaultOS,
Architecture: "arm",
},
formatted: "linux/arm",
},
{
input: "armel",
expected: specs.Platform{
OS: defaultOS,
Architecture: "arm",
Variant: "v6",
},
formatted: "linux/arm/v6",
},
{
input: "armhf",
expected: specs.Platform{
OS: defaultOS,
Architecture: "arm",
},
formatted: "linux/arm",
},
{
input: "Aarch64",
expected: specs.Platform{
OS: defaultOS,
Architecture: "arm64",
},
formatted: joinNotEmpty(defaultOS, "arm64"),
},
{
input: "x86_64",
expected: specs.Platform{
OS: defaultOS,
Architecture: "amd64",
},
formatted: joinNotEmpty(defaultOS, "amd64"),
},
{
input: "Linux/x86_64",
expected: specs.Platform{
OS: "linux",
Architecture: "amd64",
},
formatted: "linux/amd64",
},
{
input: "i386",
expected: specs.Platform{
OS: defaultOS,
Architecture: "386",
},
formatted: joinNotEmpty(defaultOS, "386"),
},
{
input: "linux",
expected: specs.Platform{
OS: "linux",
Architecture: defaultArch,
},
formatted: joinNotEmpty("linux", defaultArch),
},
{
input: "s390x",
expected: specs.Platform{
OS: defaultOS,
Architecture: "s390x",
},
formatted: joinNotEmpty(defaultOS, "s390x"),
},
{
input: "linux/s390x",
expected: specs.Platform{
OS: "linux",
Architecture: "s390x",
},
formatted: "linux/s390x",
},
{
input: "macOS",
expected: specs.Platform{
OS: "darwin",
Architecture: defaultArch,
},
formatted: joinNotEmpty("darwin", defaultArch),
},
} {
t.Run(testcase.input, func(t *testing.T) {
if testcase.skip {
t.Skip("this case is not yet supported")
}
p, err := Parse(testcase.input)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(p, testcase.expected) {
t.Fatalf("platform did not match expected: %#v != %#v", p, testcase.expected)
}
formatted := Format(p)
if formatted != testcase.formatted {
t.Fatalf("unexpected format: %q != %q", formatted, testcase.formatted)
}
// re-parse the formatted output and ensure we are stable
reparsed, err := Parse(formatted)
if err != nil {
t.Fatalf("error parsing formatted output: %v", err)
}
if Format(reparsed) != formatted {
t.Fatalf("normalized output did not survive the round trip: %v != %v", Format(reparsed), formatted)
}
})
}
}
func TestParseSelectorInvalid(t *testing.T) {
for _, testcase := range []struct {
input string
}{
{
input: "", // empty
},
{
input: "/linux/arm", // leading slash
},
{
input: "linux/arm/", // trailing slash
},
{
input: "linux /arm", // spaces
},
{
input: "linux/&arm", // invalid character
},
{
input: "linux/arm/foo/bar", // too mayn components
},
} {
t.Run(testcase.input, func(t *testing.T) {
if _, err := Parse(testcase.input); err == nil {
t.Fatalf("should have received an error")
}
})
}
}