diff --git a/go.mod b/go.mod index 15e65462..ce6dd935 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/lekkodev/cli go 1.20 require ( - buf.build/gen/go/lekkodev/cli/bufbuild/connect-go v1.10.0-20230808195657-843d07fa9e34.1 - buf.build/gen/go/lekkodev/cli/protocolbuffers/go v1.31.0-20230808195657-843d07fa9e34.1 + buf.build/gen/go/lekkodev/cli/bufbuild/connect-go v1.10.0-20231020162356-e763402ec965.1 + buf.build/gen/go/lekkodev/cli/protocolbuffers/go v1.31.0-20231020162356-e763402ec965.1 github.com/AlecAivazis/survey/v2 v2.3.6 github.com/bazelbuild/buildtools v0.0.0-20220907133145-b9bfff5d7f91 github.com/bufbuild/connect-go v1.10.0 diff --git a/go.sum b/go.sum index e2a8478b..1f7e716a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -buf.build/gen/go/lekkodev/cli/bufbuild/connect-go v1.10.0-20230808195657-843d07fa9e34.1 h1:mt4QLKZtQwEBqRm2bi7aj4/PJ8AJQg2Tq0G3hJHzwHc= -buf.build/gen/go/lekkodev/cli/bufbuild/connect-go v1.10.0-20230808195657-843d07fa9e34.1/go.mod h1:15ARpvleKTFJ7xogv/ihJL1cigfDJij4juFMBQzY+Eo= -buf.build/gen/go/lekkodev/cli/protocolbuffers/go v1.31.0-20230808195657-843d07fa9e34.1 h1:VA17A//BYMH5yjd1ADllRk5p+fmIUQ7mauEZ31zKdDM= -buf.build/gen/go/lekkodev/cli/protocolbuffers/go v1.31.0-20230808195657-843d07fa9e34.1/go.mod h1:mYcnts9MJhUckfRD5/qOThXC7Kaj7O714LOw5GoGZ9c= +buf.build/gen/go/lekkodev/cli/bufbuild/connect-go v1.10.0-20231020162356-e763402ec965.1 h1:iGIRelUi9fFwQ15Op90jP73+L8T0M8uewZeI6WXW8+s= +buf.build/gen/go/lekkodev/cli/bufbuild/connect-go v1.10.0-20231020162356-e763402ec965.1/go.mod h1:hJtG6Y49OSN1DhCYPGaCkfN1jibDflptgnHnLJo/Nl0= +buf.build/gen/go/lekkodev/cli/protocolbuffers/go v1.31.0-20231020162356-e763402ec965.1 h1:YzjpTz4MgOa+EhI6lbIFLBAUTgQf2aVSIUoZlPBBv2k= +buf.build/gen/go/lekkodev/cli/protocolbuffers/go v1.31.0-20231020162356-e763402ec965.1/go.mod h1:mYcnts9MJhUckfRD5/qOThXC7Kaj7O714LOw5GoGZ9c= buf.build/gen/go/lekkodev/sdk/protocolbuffers/go v1.31.0-20230419180142-0694c10ef23c.1/go.mod h1:UOnQUnbc9uR4s5SlhBKspO4dffz+T3A6X200yYBnaZg= buf.build/gen/go/lekkodev/sdk/protocolbuffers/go v1.31.0-20230810202034-1c821065b9a0.1 h1:jd5EUbnTPSEuCL+U3TsK4fjym4eVveXJCOssOUOiJcM= buf.build/gen/go/lekkodev/sdk/protocolbuffers/go v1.31.0-20230810202034-1c821065b9a0.1/go.mod h1:UOnQUnbc9uR4s5SlhBKspO4dffz+T3A6X200yYBnaZg= diff --git a/pkg/feature/feature.go b/pkg/feature/feature.go index 2957ed26..fcdd8445 100644 --- a/pkg/feature/feature.go +++ b/pkg/feature/feature.go @@ -171,6 +171,8 @@ func GroupFeatureFiles( func ComplianceCheck(f FeatureFile, nsMD *metadata.NamespaceConfigRepoMetadata) error { switch nsMD.Version { + case "v1beta7": + fallthrough case "v1beta6": fallthrough case "v1beta5": @@ -361,6 +363,7 @@ type Feature struct { Value interface{} FeatureType eval.ConfigType Namespace string + Metadata map[string]any Overrides []*Override UnitTests []UnitTest diff --git a/pkg/feature/version.go b/pkg/feature/version.go index 72638667..d3f61546 100644 --- a/pkg/feature/version.go +++ b/pkg/feature/version.go @@ -30,6 +30,8 @@ const ( NamespaceVersionV1Beta5 NamespaceVersion = "v1beta5" // Supports using "overrides" instead of "rules" and "export(Config(...))" instead of "result = feature(...)" (dual support) NamespaceVersionV1Beta6 NamespaceVersion = "v1beta6" + // Supports `metadata` field + NamespaceVersionV1Beta7 NamespaceVersion = "v1beta7" ) var ( @@ -50,6 +52,7 @@ func AllNamespaceVersions() []NamespaceVersion { NamespaceVersionV1Beta4, NamespaceVersionV1Beta5, NamespaceVersionV1Beta6, + NamespaceVersionV1Beta7, } } diff --git a/pkg/feature/version_test.go b/pkg/feature/version_test.go index 9fa98c9a..9d840f51 100644 --- a/pkg/feature/version_test.go +++ b/pkg/feature/version_test.go @@ -28,6 +28,7 @@ func TestPriorVersionsSupported(t *testing.T) { assert.Contains(t, supported, NamespaceVersionV1Beta4) assert.Contains(t, supported, NamespaceVersionV1Beta5) assert.Contains(t, supported, NamespaceVersionV1Beta6) + assert.Contains(t, supported, NamespaceVersionV1Beta7) } func TestVersionOrder(t *testing.T) { diff --git a/pkg/star/feature.go b/pkg/star/feature.go index 1509ef25..034a09e7 100644 --- a/pkg/star/feature.go +++ b/pkg/star/feature.go @@ -39,6 +39,7 @@ const ( ResultVariableName string = "result" DefaultValueAttrName string = "default" DescriptionAttrName string = "description" + MetadataAttrName string = "metadata" // TODO: Fully migrate to overrides over rules RulesAttrName string = "rules" OverridesAttrName string = "overrides" @@ -54,6 +55,7 @@ var ( OverridesAttrName: {}, validatorAttrName: {}, unitTestsAttrName: {}, + MetadataAttrName: {}, } ) @@ -153,6 +155,12 @@ func (fb *featureBuilder) Build() (*feature.CompiledFeature, error) { return nil, errors.Wrap(err, "description") } f.Namespace = fb.namespace + if fb.nv >= feature.NamespaceVersionV1Beta7 { + f.Metadata, err = fb.getMetadata(featureVal) + if err != nil { + return nil, errors.Wrap(err, "metadata") + } + } overrideVals, err := fb.addOverrides(f, featureVal) if err != nil { @@ -295,6 +303,23 @@ func (fb *featureBuilder) getDescription(featureVal *starlarkstruct.Struct) (str return dsc.GoString(), nil } +func (fb *featureBuilder) getMetadata(featureVal *starlarkstruct.Struct) (map[string]any, error) { + metadataVal, err := featureVal.Attr(MetadataAttrName) + if err != nil { + //lint:ignore nilerr `Struct.Attr` returns error if attribute doesn't exist + return nil, nil + } + metadataDict, ok := metadataVal.(*starlark.Dict) + if !ok { + return nil, fmt.Errorf("metadata must be a dict (got a %s)", metadataVal.Type()) + } + metadataMap, err := translateContext(metadataDict) + if err != nil { + return nil, errors.Wrap(err, "translate metadata attribute") + } + return metadataMap, nil +} + func (fb *featureBuilder) addOverrides(f *feature.Feature, featureVal *starlarkstruct.Struct) ([]starlark.Value, error) { overridesVal, err := featureVal.Attr(OverridesAttrName) if err != nil { diff --git a/pkg/star/static/traverse.go b/pkg/star/static/traverse.go index acf21a82..7f4d97a2 100644 --- a/pkg/star/static/traverse.go +++ b/pkg/star/static/traverse.go @@ -30,6 +30,7 @@ const ( ResultVariableName string = "result" DefaultValueAttrName string = "default" DescriptionAttrName string = "description" + MetadataAttrName string = "metadata" // TODO: Fully migrate to overrides over rules RulesAttrName string = "rules" OverridesAttrName string = "overrides" @@ -40,11 +41,13 @@ func defaultNoop(v *build.Expr) error { return nil } func descriptionNoop(v *build.StringExpr) error { return nil } func rulesNoop(rules *overridesWrapper) error { return nil } func importsNoop(imports *importsWrapper) error { return nil } +func metadataNoop(ast *starFeatureAST) error { return nil } type defaultFn func(v *build.Expr) error type descriptionFn func(v *build.StringExpr) error type overridesFn func(rules *overridesWrapper) error type importsFn func(imports *importsWrapper) error +type metadataFn func(ast *starFeatureAST) error // Traverses a lekko starlark file, running methods on various // components of the file. Methods can be provided to read the @@ -57,6 +60,7 @@ type traverser struct { descriptionFn descriptionFn overridesFn overridesFn protoImportsFn importsFn + metadataFn metadataFn } func newTraverser(f *build.File, nv feature.NamespaceVersion) *traverser { @@ -67,6 +71,7 @@ func newTraverser(f *build.File, nv feature.NamespaceVersion) *traverser { descriptionFn: descriptionNoop, overridesFn: rulesNoop, protoImportsFn: importsNoop, + metadataFn: metadataNoop, } } @@ -90,6 +95,11 @@ func (t *traverser) withProtoImportsFn(fn importsFn) *traverser { return t } +func (t *traverser) withMetadataFn(fn metadataFn) *traverser { + t.metadataFn = fn + return t +} + func (t *traverser) traverse() error { imports := t.getProtoImports() if err := t.protoImportsFn(imports); err != nil { @@ -118,6 +128,11 @@ func (t *traverser) traverse() error { if err := t.descriptionFn(descriptionStr); err != nil { return errors.Wrap(err, "description fn") } + if t.nv >= feature.NamespaceVersionV1Beta7 { + if err := t.metadataFn(ast); err != nil { + return errors.Wrap(err, "metadata fn") + } + } // rules if err := ast.parseOverrides(t.overridesFn, t.nv); err != nil { return err diff --git a/pkg/star/static/walk.go b/pkg/star/static/walk.go index f355e6fd..1eb43ea2 100644 --- a/pkg/star/static/walk.go +++ b/pkg/star/static/walk.go @@ -85,7 +85,8 @@ func (w *walker) Build() (*featurev1beta1.StaticFeature, error) { withDefaultFn(w.buildDefaultFn(ret)). withDescriptionFn(w.buildDescriptionFn(ret)). withOverridesFn(w.buildRulesFn(ret)). - withProtoImportsFn(w.buildProtoImportsFn(ret)) + withProtoImportsFn(w.buildProtoImportsFn(ret)). + withMetadataFn(w.buildMetadataFn(ret)) if err := t.traverse(); err != nil { return nil, errors.Wrap(err, "traverse") @@ -101,7 +102,8 @@ func (w *walker) Mutate(f *featurev1beta1.StaticFeature) ([]byte, error) { t := newTraverser(ast, w.nv). withDefaultFn(w.mutateDefaultFn(f)). withDescriptionFn(w.mutateDescriptionFn(f)). - withOverridesFn(w.mutateOverridesFn(f)) + withOverridesFn(w.mutateOverridesFn(f)). + withMetadataFn(w.mutateMetadataFn(f)) if err := t.traverse(); err != nil { return nil, errors.Wrap(err, "traverse") @@ -211,27 +213,35 @@ func (w *walker) extractJSONValue(v build.Expr) (*structpb.Value, error) { } return structpb.NewListValue(listVal), nil case *build.DictExpr: - structVal := structpb.Struct{ - Fields: map[string]*structpb.Value{}, - } - for _, kvExpr := range t.List { - kvExpr := kvExpr - keyExpr, ok := kvExpr.Key.(*build.StringExpr) - if !ok { - return nil, errors.Wrapf(ErrUnsupportedStaticParsing, "json structs must have keys of type string, not %T", kvExpr.Key) - } - key := keyExpr.Value - vVar, err := w.extractJSONValue(kvExpr.Value) - if err != nil { - return nil, errors.Wrap(err, "extract struct elem value") - } - structVal.Fields[key] = vVar + structVal, err := w.extractJSONStruct(t) + if err != nil { + return nil, err } - return structpb.NewStructValue(&structVal), nil + return structpb.NewStructValue(structVal), nil } return nil, errors.Wrapf(ErrUnsupportedStaticParsing, "type %T", v) } +func (w *walker) extractJSONStruct(d *build.DictExpr) (*structpb.Struct, error) { + structVal := structpb.Struct{ + Fields: map[string]*structpb.Value{}, + } + for _, kvExpr := range d.List { + kvExpr := kvExpr + keyExpr, ok := kvExpr.Key.(*build.StringExpr) + if !ok { + return nil, errors.Wrapf(ErrUnsupportedStaticParsing, "json structs must have keys of type string, not %T", kvExpr.Key) + } + key := keyExpr.Value + vVar, err := w.extractJSONValue(kvExpr.Value) + if err != nil { + return nil, errors.Wrap(err, "extract struct elem value") + } + structVal.Fields[key] = vVar + } + return &structVal, nil +} + func (w *walker) buildDescriptionFn(f *featurev1beta1.StaticFeature) descriptionFn { return func(v *build.StringExpr) error { f.Feature.Description = v.Value @@ -240,6 +250,27 @@ func (w *walker) buildDescriptionFn(f *featurev1beta1.StaticFeature) description } } +func (w *walker) buildMetadataFn(f *featurev1beta1.StaticFeature) metadataFn { + return func(ast *starFeatureAST) error { + metadataExprPtr, found := ast.get(MetadataAttrName) + if !found { + return nil + } + metadataExpr := *metadataExprPtr + metadataDict, ok := metadataExpr.(*build.DictExpr) + if !ok { + return errors.Wrapf(ErrUnsupportedStaticParsing, "metadata kwarg: expected dict, got %T", metadataExpr) + } + metadataStruct, err := w.extractJSONStruct(metadataDict) + if err != nil { + return errors.Wrap(err, "extract metadata") + } + f.Feature.Metadata = metadataStruct + f.FeatureOld.Metadata = metadataStruct + return nil + } +} + func (w *walker) buildRulesFn(f *featurev1beta1.StaticFeature) overridesFn { return func(overridesW *overridesWrapper) error { for i, o := range overridesW.overrides { @@ -405,6 +436,24 @@ func (w *walker) genValue(a *anypb.Any, sf *featurev1beta1.StaticFeature, meta * } } +func (w *walker) genJSONStruct(s *structpb.Struct, meta *featurev1beta1.StarMeta) (*build.DictExpr, error) { + dictExpr := &build.DictExpr{ + ForceMultiLine: true, + } + for key, value := range s.Fields { + valExpr, err := w.genJSONValue(value, meta) + if err != nil { + return nil, errors.Wrap(err, "gen value dict elem") + } + dictExpr.List = append(dictExpr.List, &build.KeyValueExpr{ + Key: starString(key), + Value: valExpr, + }) + } + sortKVs(dictExpr.List) + return dictExpr, nil +} + func (w *walker) genJSONValue(val *structpb.Value, meta *featurev1beta1.StarMeta) (build.Expr, error) { switch k := val.Kind.(type) { case *structpb.Value_NullValue: @@ -434,20 +483,10 @@ func (w *walker) genJSONValue(val *structpb.Value, meta *featurev1beta1.StarMeta } return listExpr, nil case *structpb.Value_StructValue: - dictExpr := &build.DictExpr{ - ForceMultiLine: true, - } - for key, value := range k.StructValue.Fields { - valExpr, err := w.genJSONValue(value, meta) - if err != nil { - return nil, errors.Wrap(err, "gen value dict elem") - } - dictExpr.List = append(dictExpr.List, &build.KeyValueExpr{ - Key: starString(key), - Value: valExpr, - }) + dictExpr, err := w.genJSONStruct(k.StructValue, meta) + if err != nil { + return nil, err } - sortKVs(dictExpr.List) return dictExpr, nil default: return nil, errors.Wrapf(ErrUnsupportedStaticParsing, "structpb val type %T", k) @@ -485,6 +524,21 @@ func (w *walker) mutateDescriptionFn(f *featurev1beta1.StaticFeature) descriptio } } +func (w *walker) mutateMetadataFn(f *featurev1beta1.StaticFeature) metadataFn { + return func(ast *starFeatureAST) error { + metadataProto := f.FeatureOld.GetMetadata() + if metadataProto == nil { + return nil + } + metadataStarDict, err := w.genJSONStruct(metadataProto, nil) + if err != nil { + return err + } + ast.set(MetadataAttrName, metadataStarDict) + return nil + } +} + func (w *walker) mutateOverridesFn(f *featurev1beta1.StaticFeature) overridesFn { return func(overridesW *overridesWrapper) error { var newOverrides []override diff --git a/proto/lekko/feature/v1beta1/feature.proto b/proto/lekko/feature/v1beta1/feature.proto index 1204ebca..068171d2 100644 --- a/proto/lekko/feature/v1beta1/feature.proto +++ b/proto/lekko/feature/v1beta1/feature.proto @@ -17,6 +17,7 @@ syntax = "proto3"; package lekko.feature.v1beta1; import "google/protobuf/any.proto"; +import "google/protobuf/struct.proto"; import "lekko/rules/v1beta2/rules.proto"; import "lekko/rules/v1beta3/rules.proto"; @@ -30,6 +31,7 @@ message Feature { string description = 2; Tree tree = 3; FeatureType type = 4; + google.protobuf.Struct metadata = 7; } // Enumerates the canonical types that lekko supports diff --git a/proto/lekko/feature/v1beta1/static.proto b/proto/lekko/feature/v1beta1/static.proto index e6a7db68..eb446892 100644 --- a/proto/lekko/feature/v1beta1/static.proto +++ b/proto/lekko/feature/v1beta1/static.proto @@ -16,6 +16,7 @@ syntax = "proto3"; package lekko.feature.v1beta1; +import "google/protobuf/struct.proto"; import "lekko/feature/v1beta1/feature.proto"; // Represents a statically parsed feature. @@ -40,13 +41,14 @@ message StaticFeature { lekko.feature.v1beta1.Feature feature_old = 5; } -// Represents everything stored in the feature struct in starlark. -// i.e. `feature(...)` +// Represents everything stored in the config struct in starlark. +// i.e. `Config(...)` message FeatureStruct { StarMeta meta = 1; string description = 2; StarExpr default = 3; Rules rules = 4; + google.protobuf.Struct metadata = 7; } message Rules {