diff --git a/resource/resource.go b/resource/resource.go new file mode 100644 index 000000000..ec89b216c --- /dev/null +++ b/resource/resource.go @@ -0,0 +1,163 @@ +// Copyright 2018, OpenCensus 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 resource provides functionality for resource, which capture +// identifying information about the entities for which signals are exported. +package resource + +import ( + "context" + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" +) + +const ( + EnvVarType = "OC_RESOURCE_TYPE" + EnvVarLabels = "OC_RESOURCE_LABELS" +) + +// Resource describes an entity about which identifying information and metadata is exposed. +// For example, a type "k8s.io/container" may hold labels describing the pod name and namespace. +type Resource struct { + Type string + Labels map[string]string +} + +// EncodeLabels encodes a labels map to a string as provided via the OC_RESOURCE_LABELS environment variable. +func EncodeLabels(labels map[string]string) string { + sortedKeys := make([]string, 0, len(labels)) + for k := range labels { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + + s := "" + for i, k := range sortedKeys { + if i > 0 { + s += "," + } + s += k + "=" + strconv.Quote(labels[k]) + } + return s +} + +var labelRegex = regexp.MustCompile(`^\s*([[:ascii:]]{1,256}?)=("[[:ascii:]]{0,256}?")\s*,`) + +// DecodeLabels decodes a serialized label map as used in the OC_RESOURCE_LABELS variable. +// A list of labels of the form `="",="",...` is accepted. +// Domain names and paths are accepted as label keys. +// Most users will want to use FromEnv instead. +func DecodeLabels(s string) (map[string]string, error) { + m := map[string]string{} + // Ensure a trailing comma, which allows us to keep the regex simpler + s = strings.TrimRight(strings.TrimSpace(s), ",") + "," + + for len(s) > 0 { + match := labelRegex.FindStringSubmatch(s) + if len(match) == 0 { + return nil, fmt.Errorf("invalid label formatting, remainder: %s", s) + } + v := match[2] + if v == "" { + v = match[3] + } else { + var err error + if v, err = strconv.Unquote(v); err != nil { + return nil, fmt.Errorf("invalid label formatting, remainder: %s, err: %s", s, err) + } + } + m[match[1]] = v + + s = s[len(match[0]):] + } + return m, nil +} + +// FromEnv is a detector that loads resource information from the OC_RESOURCE_TYPE +// and OC_RESOURCE_labelS environment variables. +func FromEnv(context.Context) (*Resource, error) { + res := &Resource{ + Type: strings.TrimSpace(os.Getenv(EnvVarType)), + } + labels := strings.TrimSpace(os.Getenv(EnvVarLabels)) + if labels == "" { + return res, nil + } + var err error + if res.Labels, err = DecodeLabels(labels); err != nil { + return nil, err + } + return res, nil +} + +var _ Detector = FromEnv + +// merge resource information from b into a. In case of a collision, a takes precedence. +func merge(a, b *Resource) *Resource { + if a == nil { + return b + } + if b == nil { + return a + } + res := &Resource{ + Type: a.Type, + Labels: map[string]string{}, + } + if res.Type == "" { + res.Type = b.Type + } + for k, v := range b.Labels { + res.Labels[k] = v + } + // Labels from resource a overwrite labels from resource b. + for k, v := range a.Labels { + res.Labels[k] = v + } + return res +} + +// Detector attempts to detect resource information. +// If the detector cannot find resource information, the returned resource is nil but no +// error is returned. +// An error is only returned on unexpected failures. +type Detector func(context.Context) (*Resource, error) + +// MultiDetector returns a Detector that calls all input detectors in order and +// merges each result with the previous one. In case a type of label key is already set, +// the first set value is takes precedence. +// It returns on the first error that a sub-detector encounters. +func MultiDetector(detectors ...Detector) Detector { + return func(ctx context.Context) (*Resource, error) { + return detectAll(ctx, detectors...) + } +} + +// detectall calls all input detectors sequentially an merges each result with the previous one. +// It returns on the first error that a sub-detector encounters. +func detectAll(ctx context.Context, detectors ...Detector) (*Resource, error) { + var res *Resource + for _, d := range detectors { + r, err := d(ctx) + if err != nil { + return nil, err + } + res = merge(res, r) + } + return res, nil +} diff --git a/resource/resource_test.go b/resource/resource_test.go new file mode 100644 index 000000000..77fef040e --- /dev/null +++ b/resource/resource_test.go @@ -0,0 +1,163 @@ +// Copyright 2018, OpenCensus 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 resource + +import ( + "context" + "errors" + "fmt" + "reflect" + "testing" +) + +func TestMerge(t *testing.T) { + cases := []struct { + a, b, want *Resource + }{ + { + a: &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1", "b": "2"}, + }, + b: &Resource{ + Type: "t2", + Labels: map[string]string{"a": "1", "b": "3", "c": "4"}, + }, + want: &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1", "b": "2", "c": "4"}, + }, + }, + { + a: nil, + b: &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1"}, + }, + want: &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1"}, + }, + }, + { + a: &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1"}, + }, + b: nil, + want: &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1"}, + }, + }, + } + for i, c := range cases { + t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { + res := merge(c.a, c.b) + if !reflect.DeepEqual(res, c.want) { + t.Fatalf("unwanted result: want %+v, got %+v", c.want, res) + } + }) + } +} + +func TestDecodeLabels(t *testing.T) { + cases := []struct { + encoded string + wantLabels map[string]string + wantFail bool + }{ + { + encoded: `example.org/test-1="test $ \"" , Abc="Def"`, + wantLabels: map[string]string{"example.org/test-1": "test $ \"", "Abc": "Def"}, + }, { + encoded: `single="key"`, + wantLabels: map[string]string{"single": "key"}, + }, + {encoded: `invalid-char-ü="test"`, wantFail: true}, + {encoded: `invalid-char="ü-test"`, wantFail: true}, + {encoded: `missing="trailing-quote`, wantFail: true}, + {encoded: `missing=leading-quote"`, wantFail: true}, + {encoded: `extra="chars", a`, wantFail: true}, + } + for i, c := range cases { + t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { + res, err := DecodeLabels(c.encoded) + if err != nil && !c.wantFail { + t.Fatalf("unwanted error: %s", err) + } + if c.wantFail && err == nil { + t.Fatalf("wanted failure but got none, result: %v", res) + } + if !reflect.DeepEqual(res, c.wantLabels) { + t.Fatalf("wanted result %v, got %v", c.wantLabels, res) + } + }) + } +} + +func TestEncodeLabels(t *testing.T) { + got := EncodeLabels(map[string]string{ + "example.org/test-1": "test ¥ \"", + "un": "quøted", + "Abc": "Def", + }) + if want := `Abc="Def",example.org/test-1="test ¥ \"",un="quøted"`; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestMultiDetector(t *testing.T) { + got, err := MultiDetector( + func(context.Context) (*Resource, error) { + return &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1", "b": "2"}, + }, nil + }, + func(context.Context) (*Resource, error) { + return &Resource{ + Type: "t2", + Labels: map[string]string{"a": "11", "c": "3"}, + }, nil + }, + )(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + want := &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1", "b": "2", "c": "3"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected resource: want %v, got %v", want, got) + } + + wantErr := errors.New("err1") + got, err = MultiDetector( + func(context.Context) (*Resource, error) { + return &Resource{ + Type: "t1", + Labels: map[string]string{"a": "1", "b": "2"}, + }, nil + }, + func(context.Context) (*Resource, error) { + return nil, wantErr + }, + )(context.Background()) + if err != wantErr { + t.Fatalf("unexpected error: want %v, got %v", wantErr, err) + } +}