diff --git a/actool/discover.go b/actool/discover.go index f91f526d..114a63b7 100644 --- a/actool/discover.go +++ b/actool/discover.go @@ -21,6 +21,7 @@ import ( "strings" "github.com/appc/spec/discovery" + "github.com/appc/spec/schema/types" ) var ( @@ -49,10 +50,10 @@ func runDiscover(args []string) (exit int) { for _, name := range args { app, err := discovery.NewAppFromString(name) if app.Labels["os"] == "" { - app.Labels["os"] = runtime.GOOS + app.Labels["os"] = types.ACString(runtime.GOOS) } if app.Labels["arch"] == "" { - app.Labels["arch"] = runtime.GOARCH + app.Labels["arch"] = types.ACString(runtime.GOARCH) } if err != nil { stderr("%s: %s", name, err) diff --git a/discovery/discovery.go b/discovery/discovery.go index b2dc6595..e730e9eb 100644 --- a/discovery/discovery.go +++ b/discovery/discovery.go @@ -121,7 +121,7 @@ func createTemplateVars(app App) []string { // If a label is called "name", it will be ignored as it appears after // in the slice for n, v := range app.Labels { - tplVars = append(tplVars, fmt.Sprintf("{%s}", n), v) + tplVars = append(tplVars, fmt.Sprintf("{%s}", n), v.String()) } return tplVars } diff --git a/discovery/discovery_test.go b/discovery/discovery_test.go index bb2ca254..c1cfabf2 100644 --- a/discovery/discovery_test.go +++ b/discovery/discovery_test.go @@ -113,7 +113,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -146,7 +146,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp/foobar", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -180,7 +180,7 @@ func TestDiscoverEndpoints(t *testing.T) { false, App{ Name: "example.com/myapp/foobar/bazzer", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -214,7 +214,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -252,7 +252,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -284,7 +284,7 @@ func TestDiscoverEndpoints(t *testing.T) { false, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -316,7 +316,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -351,7 +351,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -389,7 +389,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", }, }, @@ -419,7 +419,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{}, + Labels: map[types.ACIdentifier]types.ACString{}, }, []ACIEndpoint{ ACIEndpoint{ @@ -446,7 +446,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "name": "labelcalledname", "version": "1.0.0", }, @@ -476,7 +476,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", @@ -516,7 +516,7 @@ func TestDiscoverEndpoints(t *testing.T) { true, App{ Name: "example.com/myapp", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", "os": "linux", "arch": "amd64", diff --git a/discovery/parse.go b/discovery/parse.go index 7b9d574a..9ffbf0ae 100644 --- a/discovery/parse.go +++ b/discovery/parse.go @@ -25,12 +25,12 @@ import ( type App struct { Name types.ACIdentifier - Labels map[types.ACIdentifier]string + Labels map[types.ACIdentifier]types.ACString } -func NewApp(name string, labels map[types.ACIdentifier]string) (*App, error) { +func NewApp(name string, labels map[types.ACIdentifier]types.ACString) (*App, error) { if labels == nil { - labels = make(map[types.ACIdentifier]string, 0) + labels = make(map[types.ACIdentifier]types.ACString, 0) } acn, err := types.NewACIdentifier(name) if err != nil { @@ -55,7 +55,7 @@ func NewApp(name string, labels map[types.ACIdentifier]string) (*App, error) { func NewAppFromString(app string) (*App, error) { var ( name string - labels map[types.ACIdentifier]string + labels map[types.ACIdentifier]types.ACString ) preparedApp, err := prepareAppString(app) @@ -66,7 +66,7 @@ func NewAppFromString(app string) (*App, error) { if err != nil { return nil, err } - labels = make(map[types.ACIdentifier]string, 0) + labels = make(map[types.ACIdentifier]types.ACString, 0) for key, val := range v { if len(val) > 1 { return nil, fmt.Errorf("label %s with multiple values %q", key, val) @@ -79,7 +79,11 @@ func NewAppFromString(app string) (*App, error) { if err != nil { return nil, err } - labels[*labelName] = val[0] + acsv, err := types.NewACString(val[0]) + if err != nil { + return nil, err + } + labels[*labelName] = *acsv } a, err := NewApp(name, labels) if err != nil { @@ -112,7 +116,7 @@ func checkColon(app string) error { func (a *App) Copy() *App { ac := &App{ Name: a.Name, - Labels: make(map[types.ACIdentifier]string, 0), + Labels: make(map[types.ACIdentifier]types.ACString, 0), } for k, v := range a.Labels { ac.Labels[k] = v diff --git a/discovery/parse_test.go b/discovery/parse_test.go index 61bd4a99..f10f6df9 100644 --- a/discovery/parse_test.go +++ b/discovery/parse_test.go @@ -33,7 +33,7 @@ func TestNewAppFromString(t *testing.T) { &App{ Name: "example.com/reduce-worker", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", }, }, @@ -44,7 +44,7 @@ func TestNewAppFromString(t *testing.T) { &App{ Name: "example.com/reduce-worker", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "channel": "alpha", "label": "value", }, @@ -57,7 +57,7 @@ func TestNewAppFromString(t *testing.T) { &App{ Name: "example.com/app", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.2.3", "special": "!*'();@&+$/?#[]", "channel": "beta", @@ -133,14 +133,14 @@ func TestAppString(t *testing.T) { { &App{ Name: "example.com/reduce-worker", - Labels: map[types.ACIdentifier]string{}, + Labels: map[types.ACIdentifier]types.ACString{}, }, "example.com/reduce-worker", }, { &App{ Name: "example.com/reduce-worker", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", }, }, @@ -149,7 +149,7 @@ func TestAppString(t *testing.T) { { &App{ Name: "example.com/reduce-worker", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "channel": "alpha", "label": "value", }, @@ -178,14 +178,14 @@ func TestAppCopy(t *testing.T) { { &App{ Name: "example.com/reduce-worker", - Labels: map[types.ACIdentifier]string{}, + Labels: map[types.ACIdentifier]types.ACString{}, }, "example.com/reduce-worker", }, { &App{ Name: "example.com/reduce-worker", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "version": "1.0.0", }, }, @@ -194,7 +194,7 @@ func TestAppCopy(t *testing.T) { { &App{ Name: "example.com/reduce-worker", - Labels: map[types.ACIdentifier]string{ + Labels: map[types.ACIdentifier]types.ACString{ "channel": "alpha", "label": "value", }, diff --git a/schema/image.go b/schema/image.go index fac4c794..cf468ddd 100644 --- a/schema/image.go +++ b/schema/image.go @@ -94,7 +94,7 @@ func (im *ImageManifest) assertValid() error { return nil } -func (im *ImageManifest) GetLabel(name string) (val string, ok bool) { +func (im *ImageManifest) GetLabel(name string) (val types.ACString, ok bool) { return im.Labels.Get(name) } diff --git a/schema/types/acstring.go b/schema/types/acstring.go new file mode 100644 index 00000000..e38e8601 --- /dev/null +++ b/schema/types/acstring.go @@ -0,0 +1,117 @@ +// Copyright 2015 The appc Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/json" + "strconv" + "unicode/utf8" +) + +var ( + ErrACStringTooLong = ACStringError("ACString exceeds maximum number of characters") + ErrInvalidCharInACString = ACStringError("ACString must contain only printable unicode characters") +) + +const ( + // Max number of characters (not bytes) + ACStringMaxChars = 255 +) + +// ACString (an App-Container String) is a format used in image labels values of the App Container Standard. +// An ACString MUST be UTF-8 encoded and is restricted to all unicode printable +// characters. Such characters include letters, marks, numbers, punctuation, +// symbols, and the ASCII space character, from unicode categories L, M, N, P, +// S and the ASCII space character. +type ACString string + +func (n ACString) String() string { + return string(n) +} + +// Set sets the ACString to the given value, if it is valid; if not, +// an error is returned. +func (n *ACString) Set(s string) error { + nn, err := NewACString(s) + if err == nil { + *n = *nn + } + return err +} + +// Equals checks whether a given ACString is equal to this one. +func (n ACString) Equals(o ACString) bool { + return n == o +} + +// Empty returns a boolean indicating whether this ACString is empty. +func (n ACString) Empty() bool { + return n.String() == "" +} + +// NewACString generates a new ACString from a string. If the given string is +// not a valid ACString, nil and an error are returned. +func NewACString(s string) (*ACString, error) { + n := ACString(s) + if err := n.assertValid(); err != nil { + return nil, err + } + return &n, nil +} + +// MustACString generates a new ACString from a string, If the given string is +// not a valid ACString, it panics. +func MustACString(s string) *ACString { + n, err := NewACString(s) + if err != nil { + panic(err) + } + return n +} + +func (n ACString) assertValid() error { + s := string(n) + if utf8.RuneCountInString(s) > ACStringMaxChars { + return ErrACStringTooLong + } + for _, r := range s { + if !strconv.IsPrint(r) { + return ErrInvalidCharInACString + } + } + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface +func (n *ACString) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + nn, err := NewACString(s) + if err != nil { + return err + } + *n = *nn + return nil +} + +// MarshalJSON implements the json.Marshaler interface +func (n ACString) MarshalJSON() ([]byte, error) { + if err := n.assertValid(); err != nil { + return nil, err + } + return json.Marshal(n.String()) +} diff --git a/schema/types/acstring_test.go b/schema/types/acstring_test.go new file mode 100644 index 00000000..78534240 --- /dev/null +++ b/schema/types/acstring_test.go @@ -0,0 +1,139 @@ +// Copyright 2015 The appc Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "reflect" + "strings" + "testing" +) + +var ( + goodStrings = []string{ + "asdf", + "asdf !*'();@&+$/?#[]¼µß", + // 255 char length string + strings.Repeat("a", 255), + } + + badStrings = []string{ + "asdf\t", + "asdf\n", + // 256 char length string + strings.Repeat("a", 256), + } +) + +func TestNewACString(t *testing.T) { + for i, in := range goodStrings { + l, err := NewACString(in) + if err != nil { + t.Errorf("#%d: got err=%v, want nil", i, err) + } + if l == nil { + t.Errorf("#%d: got l=nil, want non-nil", i) + } + } +} + +func TestNewACStringBad(t *testing.T) { + for i, in := range badStrings { + l, err := NewACString(in) + if l != nil { + t.Errorf("#%d: got l=%v, want nil", i, l) + } + if err == nil { + t.Errorf("#%d: got err=nil, want non-nil", i) + } + } +} + +func TestMustACString(t *testing.T) { + for i, in := range goodStrings { + l := MustACString(in) + if l == nil { + t.Errorf("#%d: got l=nil, want non-nil", i) + } + } +} + +func expectPanicMustACString(i int, in string, t *testing.T) { + defer func() { + recover() + }() + _ = MustACString(in) + t.Errorf("#%d: panic expected", i) +} + +func TestMustACStringBad(t *testing.T) { + for i, in := range badStrings { + expectPanicMustACString(i, in, t) + } +} + +func TestACStringSetGood(t *testing.T) { + tests := map[string]ACString{ + "asdf !*'();@&+$/?#[]¼µß": ACString("asdf !*'();@&+$/?#[]¼µß"), + } + for in, w := range tests { + // Ensure an empty name is set appropriately + var a ACString + err := a.Set(in) + if err != nil { + t.Errorf("%v: got err=%v, want nil", in, err) + continue + } + if !reflect.DeepEqual(a, w) { + t.Errorf("%v: a=%v, want %v", in, a, w) + } + + // Ensure an existing name is overwritten + var b ACString = ACString("orig") + err = b.Set(in) + if err != nil { + t.Errorf("%v: got err=%v, want nil", in, err) + continue + } + if !reflect.DeepEqual(b, w) { + t.Errorf("%v: b=%v, want %v", in, b, w) + } + } +} + +func TestACStringSetBad(t *testing.T) { + for i, in := range badStrings { + // Ensure an empty name stays empty + var a ACString + err := a.Set(in) + if err == nil { + t.Errorf("#%d: err=%v, want nil", i, err) + continue + } + if w := ACString(""); !reflect.DeepEqual(a, w) { + t.Errorf("%d: a=%v, want %v", i, a, w) + } + + // Ensure an existing name is not overwritten + var b ACString = ACString("orig") + err = b.Set(in) + if err == nil { + t.Errorf("#%d: err=%v, want nil", i, err) + continue + } + if w := ACString("orig"); !reflect.DeepEqual(b, w) { + t.Errorf("%d: b=%v, want %v", i, b, w) + } + } +} diff --git a/schema/types/errors.go b/schema/types/errors.go index bb465159..b40ab231 100644 --- a/schema/types/errors.go +++ b/schema/types/errors.go @@ -47,3 +47,10 @@ type ACNameError string func (e ACNameError) Error() string { return string(e) } + +// An ACStringError is returned when a bad value is used for an ACString +type ACStringError string + +func (e ACStringError) Error() string { + return string(e) +} diff --git a/schema/types/labels.go b/schema/types/labels.go index ebd2bb1a..acf602ba 100644 --- a/schema/types/labels.go +++ b/schema/types/labels.go @@ -32,14 +32,14 @@ type labels Labels type Label struct { Name ACIdentifier `json:"name"` - Value string `json:"value"` + Value ACString `json:"value"` } // IsValidOsArch checks if a OS-architecture combination is valid given a map // of valid OS-architectures -func IsValidOSArch(labels map[ACIdentifier]string, validOSArch map[string][]string) error { +func IsValidOSArch(labels map[ACIdentifier]ACString, validOSArch map[string][]string) error { if os, ok := labels["os"]; ok { - if validArchs, ok := validOSArch[os]; !ok { + if validArchs, ok := validOSArch[string(os)]; !ok { // Not a whitelisted OS. TODO: how to warn rather than fail? validOses := make([]string, 0, len(validOSArch)) for validOs := range validOSArch { @@ -53,7 +53,7 @@ func IsValidOSArch(labels map[ACIdentifier]string, validOSArch map[string][]stri if arch, ok := labels["arch"]; ok { found := false for _, validArch := range validArchs { - if arch == validArch { + if string(arch) == validArch { found = true break } @@ -68,7 +68,7 @@ func IsValidOSArch(labels map[ACIdentifier]string, validOSArch map[string][]stri } func (l Labels) assertValid() error { - seen := map[ACIdentifier]string{} + seen := map[ACIdentifier]ACString{} for _, lbl := range l { if lbl.Name == "name" { return fmt.Errorf(`invalid label name: "name"`) @@ -103,7 +103,7 @@ func (l *Labels) UnmarshalJSON(data []byte) error { } // Get retrieves the value of the label by the given name from Labels, if it exists -func (l Labels) Get(name string) (val string, ok bool) { +func (l Labels) Get(name string) (val ACString, ok bool) { for _, lbl := range l { if lbl.Name.String() == name { return lbl.Value, true @@ -112,17 +112,17 @@ func (l Labels) Get(name string) (val string, ok bool) { return "", false } -// ToMap creates a map[ACIdentifier]string. -func (l Labels) ToMap() map[ACIdentifier]string { - labelsMap := make(map[ACIdentifier]string) +// ToMap creates a map[ACIdentifier]ACString. +func (l Labels) ToMap() map[ACIdentifier]ACString { + labelsMap := make(map[ACIdentifier]ACString) for _, lbl := range l { labelsMap[lbl.Name] = lbl.Value } return labelsMap } -// LabelsFromMap creates Labels from a map[ACIdentifier]string -func LabelsFromMap(labelsMap map[ACIdentifier]string) (Labels, error) { +// LabelsFromMap creates Labels from a map[ACIdentifier]ACString +func LabelsFromMap(labelsMap map[ACIdentifier]ACString) (Labels, error) { labels := Labels{} for n, v := range labelsMap { labels = append(labels, Label{Name: n, Value: v}) diff --git a/spec/aci.md b/spec/aci.md index 86078476..b47846cc 100644 --- a/spec/aci.md +++ b/spec/aci.md @@ -219,7 +219,7 @@ JSON Schema for the Image Manifest (app image manifest, ACI manifest), conformin * **acKind** (string, required) must be an [AC Kind](types.md#ac-kind-type) of value "ImageManifest" * **acVersion** (string, required) represents the version of the schema specification [AC Version Type](types.md#ac-version-type) * **name** (string, required) a human-readable name for this App Container Image (string, restricted to the [AC Identifier](types.md#ac-identifier-type) formatting). This is not expected to be unique (see the **version** label) but SHOULD have a URL-like structure to facilitate **[App Container Image Discovery](discovery.md)**. If this image is resolved through the discovery process, this field MUST match the name used for discovery. -* **labels** (list of objects, optional) used during image discovery and dependency resolution. The listed objects must have two key-value pairs: *name* is restricted to the [AC Identifier](types.md#ac-identifier-type) formatting and *value* is an arbitrary string. Label names must be unique within the list, and (to avoid confusion with the image's name) cannot be "name". Several well-known labels are defined: +* **labels** (list of objects, optional) used during image discovery and dependency resolution. The listed objects must have two key-value pairs: *name* is restricted to the [AC Identifier](types.md#ac-identifier-type) formatting and *value* is restricted to the [AC String](types.md#ac-string-type) formatting. Label names must be unique within the list, and (to avoid confusion with the image's name) cannot be "name". Several well-known labels are defined: * **version** when combined with "name", this SHOULD be unique for every build of an app (on a given "os"/"arch" combination). * **os**, **arch** can together be considered to describe the syscall ABI this image requires. **arch** is meaningful only if **os** is provided. If one or both values are not provided, the image is assumed to be OS- and/or architecture-independent. Currently supported combinations are listed in the [`types.ValidOSArch`](../schema/types/labels.go) variable, which can be updated by an implementation that supports other combinations. The combinations whitelisted by default are (in format `os/arch`): `linux/amd64`, `linux/i386`, `freebsd/amd64`, `freebsd/i386`, `freebsd/arm`, `darwin/x86_64`, `darwin/i386`. See the [Operating System spec](OS-SPEC.md) for the environment apps can expect to run in given a known **os** label. * **app** (object, optional) if present, defines the default parameters that can be used to execute this image as an application. diff --git a/spec/types.md b/spec/types.md index 7ac58b1c..481ced76 100644 --- a/spec/types.md +++ b/spec/types.md @@ -34,6 +34,13 @@ Examples: The AC Name Type is used as the primary key for a number of fields in the schemas below. The schema validator will ensure that the keys conform to these constraints. +### AC String Type + +An AC String Type is restricted to all unicode printable characters. Such characters include letters, marks, numbers, punctuation, symbols, and the ASCII space character, from unicode categories L, M, N, P, S and the ASCII space character. The string must be UTF-8 encoded. +An AC String Type must be 255 characters or less. + +The AC String Type is used as the value for a number of fields in the schemas below. +The schema validator will ensure that the values conform to these constraints. ### AC Kind Type