From 95dc2e82839ba237f78e67bb40c106f391e1d816 Mon Sep 17 00:00:00 2001 From: Gemene Narcis Date: Wed, 16 Oct 2024 13:42:20 +0300 Subject: [PATCH] [cmd/mdatagen]: Add feature gates support to metadata-schema.yaml --- cmd/mdatagen/internal/command.go | 14 +-- cmd/mdatagen/internal/loader.go | 78 +++++++++++++++++ cmd/mdatagen/internal/loader_test.go | 85 +++++++++++++++++++ .../internal/templates/feature_gates.go.tmpl | 26 ++++++ cmd/mdatagen/metadata-schema.yaml | 17 ++++ 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 cmd/mdatagen/internal/templates/feature_gates.go.tmpl diff --git a/cmd/mdatagen/internal/command.go b/cmd/mdatagen/internal/command.go index 5abe033a943..3208d65aba8 100644 --- a/cmd/mdatagen/internal/command.go +++ b/cmd/mdatagen/internal/command.go @@ -75,7 +75,7 @@ func run(ymlPath string) error { tmplDir := "templates" codeDir := filepath.Join(ymlDir, "internal", md.GeneratedPackageName) - if err = os.MkdirAll(codeDir, 0700); err != nil { + if err = os.MkdirAll(codeDir, 0o700); err != nil { return fmt.Errorf("unable to create output directory %q: %w", codeDir, err) } if md.Status != nil { @@ -120,6 +120,10 @@ func run(ymlPath string) error { toGenerate[filepath.Join(tmplDir, "documentation.md.tmpl")] = filepath.Join(ymlDir, "documentation.md") } + if len(md.FeatureGates) != 0 { + toGenerate[filepath.Join(tmplDir, "feature_gates.go.tmpl")] = filepath.Join(ymlDir, "generated_feature_gates.go") + } + for tmpl, dst := range toGenerate { if err = generateFile(tmpl, dst, md, "metadata"); err != nil { return err @@ -130,7 +134,7 @@ func run(ymlPath string) error { return nil } - if err = os.MkdirAll(filepath.Join(codeDir, "testdata"), 0700); err != nil { + if err = os.MkdirAll(filepath.Join(codeDir, "testdata"), 0o700); err != nil { return fmt.Errorf("unable to create output directory %q: %w", filepath.Join(codeDir, "testdata"), err) } @@ -378,7 +382,7 @@ func inlineReplace(tmplFile string, outputFile string, md Metadata, start string return err } - var re = regexp.MustCompile(fmt.Sprintf("%s[\\s\\S]*%s", start, end)) + re := regexp.MustCompile(fmt.Sprintf("%s[\\s\\S]*%s", start, end)) if !re.Match(readmeContents) { return nil } @@ -393,7 +397,7 @@ func inlineReplace(tmplFile string, outputFile string, md Metadata, start string } s := re.ReplaceAllString(string(readmeContents), string(buf)) - if err := os.WriteFile(outputFile, []byte(s), 0600); err != nil { + if err := os.WriteFile(outputFile, []byte(s), 0o600); err != nil { return fmt.Errorf("failed writing %q: %w", outputFile, err) } @@ -418,7 +422,7 @@ func generateFile(tmplFile string, outputFile string, md Metadata, goPackage str } } - if err := os.WriteFile(outputFile, result, 0600); err != nil { + if err := os.WriteFile(outputFile, result, 0o600); err != nil { return fmt.Errorf("failed writing %q: %w", outputFile, err) } diff --git a/cmd/mdatagen/internal/loader.go b/cmd/mdatagen/internal/loader.go index b51fc86f141..8ced68ec637 100644 --- a/cmd/mdatagen/internal/loader.go +++ b/cmd/mdatagen/internal/loader.go @@ -9,6 +9,7 @@ import ( "fmt" "os/exec" "path/filepath" + "regexp" "strings" "go.opentelemetry.io/collector/component" @@ -20,6 +21,20 @@ import ( "go.opentelemetry.io/collector/pdata/pcommon" ) +var ( + // idRegexp is used to validate the ID of a Gate. + // IDs' characters must be alphanumeric or dots. + idRegexp = regexp.MustCompile(`^[0-9a-zA-Z\.]*$`) + versionRegexp = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)$`) + referenceURLRegexp = regexp.MustCompile(`^(https?:\/\/)?([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+)(\/[^\s]*)?$`) + validStages = map[string]bool{ + "Alpha": true, + "Beta": true, + "Stable": true, + "Deprecated": true, + } +) + type MetricName string func (mn MetricName) Render() (string, error) { @@ -40,6 +55,16 @@ func (mn AttributeName) RenderUnexported() (string, error) { return FormatIdentifier(string(mn), false) } +type featureGateName string + +func (mn featureGateName) Render() (string, error) { + return FormatIdentifier(string(mn), true) +} + +func (mn featureGateName) RenderUnexported() (string, error) { + return FormatIdentifier(string(mn), false) +} + // ValueType defines an attribute value type. type ValueType struct { // ValueType is type of the attribute value. @@ -159,6 +184,7 @@ func (m *Metric) Unmarshal(parser *confmap.Conf) error { } return parser.Unmarshal(m) } + func (m Metric) Data() MetricData { if m.Sum != nil { return m.Sum @@ -296,6 +322,8 @@ type Metadata struct { ShortFolderName string `mapstructure:"-"` // Tests is the set of tests generated with the component Tests tests `mapstructure:"tests"` + // FeatureGates that can be used for the component. + FeatureGates map[featureGateName]featureGate `mapstructure:"feature_gates"` } func setAttributesFullName(attrs map[AttributeName]Attribute) { @@ -373,3 +401,53 @@ func packageName() (string, error) { } return strings.TrimSpace(string(output)), nil } + +type featureGate struct { + // Required. + ID string `mapstructure:"id"` + // Description describes the purpose of the attribute. + Description string `mapstructure:"description"` + // Stage current stage at which the feature gate is in the development lifecyle + Stage string `mapstructure:"stage"` + // ReferenceURL can optionally give the url of the feature_gate + ReferenceURL string `mapstructure:"reference_url"` + // FromVersion optional field which gives the release version from which the gate has been given the current stage + FromVersion string `mapstructure:"from_version"` + // ToVersion optional field which gives the release version till which the gate the gate had the given lifecycle stage + ToVersion string `mapstructure:"to_version"` + // FeatureGateName name of the feature gate + FeatureGateName featureGateName `mapstructure:"-"` +} + +func (f *featureGate) validate(parser *confmap.Conf) error { + var err []error + if !parser.IsSet("id") { + err = append(err, errors.New("missing required field: `id`")) + } else if !idRegexp.MatchString(fmt.Sprintf("%v", parser.Get("id"))) { + err = append(err, fmt.Errorf("invalid character(s) in ID")) + } + + if !parser.IsSet("stage") { + err = append(err, errors.New("missing required field: `stage`")) + } else if _, ok := validStages[fmt.Sprintf("%v", parser.Get("stage"))]; !ok { + err = append(err, fmt.Errorf("invalid stage")) + } + + if parser.IsSet("from_version") && !versionRegexp.MatchString(fmt.Sprintf("%v", parser.Get("from_version"))) { + err = append(err, fmt.Errorf("invalid character(s) in from_version")) + } + if parser.IsSet("to_version") && !versionRegexp.MatchString(fmt.Sprintf("%v", parser.Get("to_version"))) { + err = append(err, fmt.Errorf("invalid character(s) in to_version")) + } + if parser.IsSet("reference_url") && !referenceURLRegexp.MatchString(fmt.Sprintf("%v", parser.Get("reference_url"))) { + err = append(err, fmt.Errorf("invalid character(s) in reference_url")) + } + return errors.Join(err...) +} + +func (f *featureGate) Unmarshal(parser *confmap.Conf) error { + if err := f.validate(parser); err != nil { + return err + } + return parser.Unmarshal(f) +} diff --git a/cmd/mdatagen/internal/loader_test.go b/cmd/mdatagen/internal/loader_test.go index ed404744f75..c78f4dfa119 100644 --- a/cmd/mdatagen/internal/loader_test.go +++ b/cmd/mdatagen/internal/loader_test.go @@ -4,11 +4,14 @@ package internal import ( + "errors" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/pmetric" ) @@ -369,6 +372,88 @@ func TestLoadMetadata(t *testing.T) { } } +func TestFeatureGateValidate(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expectedErr error + }{ + { + name: "valid input", + input: map[string]interface{}{ + "id": "valid.id", + "stage": "Alpha", + "from_version": "v1.2.3", + "to_version": "v1.3.0", + "reference_url": "http://example.com", + }, + expectedErr: nil, + }, + { + name: "missing id", + input: map[string]interface{}{ + "stage": "Alpha", + }, + expectedErr: errors.New("missing required field: `id`"), + }, + { + name: "invalid id", + input: map[string]interface{}{ + "id": "invalid@id", + "stage": "Alpha", + }, + expectedErr: errors.New("invalid character(s) in ID"), + }, + { + name: "missing stage", + input: map[string]interface{}{ + "id": "valid.id", + }, + expectedErr: errors.New("missing required field: `stage`"), + }, + { + name: "invalid stage", + input: map[string]interface{}{ + "id": "valid.id", + "stage": "InvalidStage", + }, + expectedErr: errors.New("invalid stage"), + }, + { + name: "invalid from_version", + input: map[string]interface{}{ + "id": "valid.id", + "stage": "Alpha", + "from_version": "v1.2.a", + }, + expectedErr: errors.New("invalid character(s) in from_version"), + }, + { + name: "invalid reference_url", + input: map[string]interface{}{ + "id": "valid.id", + "stage": "Alpha", + "reference_url": "invalid-url", + }, + expectedErr: errors.New("invalid character(s) in reference_url"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := confmap.NewFromStringMap(tt.input) + featureGate := &featureGate{} + err := featureGate.validate(parser) + if tt.expectedErr != nil { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + func strPtr(s string) *string { return &s } diff --git a/cmd/mdatagen/internal/templates/feature_gates.go.tmpl b/cmd/mdatagen/internal/templates/feature_gates.go.tmpl new file mode 100644 index 00000000000..18d6c2d998d --- /dev/null +++ b/cmd/mdatagen/internal/templates/feature_gates.go.tmpl @@ -0,0 +1,26 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package {{ .Package }} + +import ( + "go.opentelemetry.io/collector/featuregate" +) + +func init() { + for _, fg := range {{ .FeatureGates }} { + var opts []featuregate.RegisterOption + if fg.FromVersion != "" { + opts = append(opts, featuregate.WithRegisterFromVersion(fg.FromVersion) + } + if fg.Description != "" { + opts = append(opts, featuregate.WithRegisterDescription(fg.Description) + } + if fg.ReferenceURL != "" { + opts = append(opts, featuregate.WithRegisterReferenceURL(fg.ReferenceURL) + } + if fg.ToVersion != "" { + opts = append(opts, featuregate.WithRegisterToVersion("v0.70.0")) + } + _ = featuregate.GlobalRegistry().MustRegister(fg.ID, fg.Stage, opts...) + } +} \ No newline at end of file diff --git a/cmd/mdatagen/metadata-schema.yaml b/cmd/mdatagen/metadata-schema.yaml index afd1f09b62a..3d2d08ddd70 100644 --- a/cmd/mdatagen/metadata-schema.yaml +++ b/cmd/mdatagen/metadata-schema.yaml @@ -175,3 +175,20 @@ telemetry: # Optional: array of attributes that were defined in the attributes section that are emitted by this metric. # Note: Only the following attribute types are supported: attributes: [string] + +#Optional: Gate is an immutable object that is owned by the Registry and represents an individual feature that +# may be enabled or disabled based on the lifecycle state of the feature and CLI flags specified by the user. +feature_gates: + : + #Required: id of the feature gate + id: + #Optional: description of the gate + description: + #Required: current stage at which the feature gate is in the development lifecyle + stage: + #Optional: link to the issue where the gate has been discussed + reference_url: + #Optional: the release version from which the gate has been given the current stage + from_version: + #Optional: the release version till which the gate had the given lifecycle stage + to_version: \ No newline at end of file