diff --git a/platforms/database.go b/platforms/database.go new file mode 100644 index 000000000..bd66e2517 --- /dev/null +++ b/platforms/database.go @@ -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 +} diff --git a/platforms/defaults.go b/platforms/defaults.go new file mode 100644 index 000000000..d49912052 --- /dev/null +++ b/platforms/defaults.go @@ -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. + } +} diff --git a/platforms/defaults_test.go b/platforms/defaults_test.go new file mode 100644 index 000000000..08c40be63 --- /dev/null +++ b/platforms/defaults_test.go @@ -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) + } +} diff --git a/platforms/platforms.go b/platforms/platforms.go new file mode 100644 index 000000000..96ecf38ec --- /dev/null +++ b/platforms/platforms.go @@ -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 [/[/]]. 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 +} diff --git a/platforms/platforms_test.go b/platforms/platforms_test.go new file mode 100644 index 000000000..a34fa4753 --- /dev/null +++ b/platforms/platforms_test.go @@ -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") + } + }) + } +}