diff --git a/.circleci/config.yml b/.circleci/config.yml index cd8fb9c3f93..d1440e57996 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -275,6 +275,42 @@ jobs: destination: raw-test-output - store_test_results: # Upload test results for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/ path: /tmp/test-results + + lint-feature-flags: + docker: + - image: circleci/golang:1.13 + environment: + GOCACHE: /tmp/go-cache + GOFLAGS: "-mod=readonly -p=2" # Go on Circle thinks 32 CPUs are available, but there aren't. + working_directory: /go/src/github.com/influxdata/influxdb + steps: + - checkout + # Populate GOCACHE. + - restore_cache: + name: Restoring GOCACHE + keys: + - influxdb-gocache-{{ .Branch }}-{{ .Revision }} # Matches when retrying a single run. + - influxdb-gocache-{{ .Branch }}- # Matches a new commit on an existing branch. + - influxdb-gocache- # Matches a new branch. + # Populate GOPATH/pkg. + - restore_cache: + name: Restoring GOPATH/pkg/mod + keys: + - influxdb-gomod-{{ checksum "go.sum" }} # Matches based on go.sum checksum. + - run: ./scripts/ci/lint/flags.bash + - skip_if_not_master + - save_cache: + name: Saving GOCACHE + key: influxdb-gocache-{{ .Branch }}-{{ .Revision }} + paths: + - /tmp/go-cache + when: always + - save_cache: + name: Saving GOPATH/pkg/mod + key: influxdb-gomod-{{ checksum "go.sum" }} + paths: + - /go/pkg/mod + when: always golint: docker: - image: circleci/golang:1.13 diff --git a/Makefile b/Makefile index d26ce70453f..704a8f97fc4 100644 --- a/Makefile +++ b/Makefile @@ -206,5 +206,9 @@ protoc: unzip -o -d /go /tmp/protoc.zip chmod +x /go/bin/protoc +# generate feature flags +flags: + $(GO_GENERATE) ./kit/feature + # .PHONY targets represent actions that do not create an actual file. -.PHONY: all $(SUBDIRS) run fmt checkfmt tidy checktidy checkgenerate test test-go test-js test-go-race bench clean node_modules vet nightly chronogiraffe dist ping protoc e2e run-e2e influxd libflux +.PHONY: all $(SUBDIRS) run fmt checkfmt tidy checktidy checkgenerate test test-go test-js test-go-race bench clean node_modules vet nightly chronogiraffe dist ping protoc e2e run-e2e influxd libflux flags diff --git a/cmd/influxd/launcher/launcher.go b/cmd/influxd/launcher/launcher.go index fe37a2acd04..0c07fb20cc2 100644 --- a/cmd/influxd/launcher/launcher.go +++ b/cmd/influxd/launcher/launcher.go @@ -27,6 +27,8 @@ import ( "github.com/influxdata/influxdb/v2/inmem" "github.com/influxdata/influxdb/v2/internal/fs" "github.com/influxdata/influxdb/v2/kit/cli" + "github.com/influxdata/influxdb/v2/kit/feature" + overrideflagger "github.com/influxdata/influxdb/v2/kit/feature/override" "github.com/influxdata/influxdb/v2/kit/prom" "github.com/influxdata/influxdb/v2/kit/signals" "github.com/influxdata/influxdb/v2/kit/tracing" @@ -309,6 +311,11 @@ func buildLauncherCommand(l *Launcher, cmd *cobra.Command) { Default: 10, Desc: "the number of queries that are allowed to be awaiting execution before new queries are rejected", }, + { + DestP: &l.featureFlags, + Flag: "feature-flags", + Desc: "feature flag overrides formatted as a list of key-value pairs, i.e. k1:v1,k2:v2,...", + }, } cli.BindOptions(cmd, opts) @@ -339,6 +346,8 @@ type Launcher struct { enableNewMetaStore bool newMetaStoreReadOnly bool + featureFlags string + // Query options. concurrencyQuota int initialMemoryBytesQuotaPerQuery int @@ -864,6 +873,18 @@ func (m *Launcher) run(ctx context.Context) (err error) { Addr: m.httpBindAddress, } + flagger := feature.DefaultFlagger() + if m.featureFlags != "" { + f, err := overrideflagger.Make(m.featureFlags) + if err != nil { + m.log.Error("Failed to configure feature flag overrides", + zap.Error(err), zap.String("config", m.featureFlags)) + return err + } + m.log.Info("Running with feature flag overrides", zap.String("config", m.featureFlags)) + flagger = f + } + m.apibackend = &http.APIBackend{ AssetsPath: m.assetsPath, HTTPErrorHandler: kithttp.ErrorHandler(0), @@ -906,6 +927,7 @@ func (m *Launcher) run(ctx context.Context) (err error) { OrgLookupService: m.kvService, WriteEventRecorder: infprom.NewEventRecorder("write"), QueryEventRecorder: infprom.NewEventRecorder("query"), + Flagger: flagger, } m.reg.MustRegister(m.apibackend.PrometheusCollectors()...) diff --git a/flags.yml b/flags.yml new file mode 100644 index 00000000000..4583cde3664 --- /dev/null +++ b/flags.yml @@ -0,0 +1,28 @@ +# This file defines defines feature flags. +# +# It is used for code generation in the ./kit/feature package. +# If you change this file, run `make flags` to regenerate. +# +# Format details: +# +# - name: Human-readable name +# description: Human-readable description +# key: Programmatic name +# default: Used when unable to reach server and to infer flag type +# contact: Contact for information or issues regarding the flag +# lifetime: Expected lifetime of the flag; temporary or permanent, default temporary +# expose: Boolean indicating whether the flag should be exposed to callers; default false + +- name: Backend Example + description: A permanent backend example boolean flag + key: backendExample + default: false + contact: Gavin Cabbage + lifetime: permanent + +- name: Frontend Example + description: A temporary frontend example integer flag + key: frontendExample + default: 42 + contact: Gavin Cabbage + expose: true diff --git a/http/api_handler.go b/http/api_handler.go index 47e81eb36df..3085338bf97 100644 --- a/http/api_handler.go +++ b/http/api_handler.go @@ -8,6 +8,7 @@ import ( "github.com/influxdata/influxdb/v2/authorizer" "github.com/influxdata/influxdb/v2/chronograf/server" "github.com/influxdata/influxdb/v2/http/metric" + "github.com/influxdata/influxdb/v2/kit/feature" "github.com/influxdata/influxdb/v2/kit/prom" kithttp "github.com/influxdata/influxdb/v2/kit/transport/http" "github.com/influxdata/influxdb/v2/query" @@ -82,6 +83,7 @@ type APIBackend struct { DocumentService influxdb.DocumentService NotificationRuleStore influxdb.NotificationRuleStore NotificationEndpointService influxdb.NotificationEndpointService + Flagger feature.Flagger } // PrometheusCollectors exposes the prometheus collectors associated with an APIBackend. @@ -203,6 +205,7 @@ func NewAPIHandler(b *APIBackend, opts ...APIHandlerOptFn) *APIHandler { userHandler := NewUserHandler(b.Logger, userBackend) h.Mount(prefixMe, userHandler) h.Mount(prefixUsers, userHandler) + h.Mount("/api/v2/flags", serveFlagsHandler(b.HTTPErrorHandler)) variableBackend := NewVariableBackend(b.Logger.With(zap.String("handler", "variable")), b) variableBackend.VariableService = authorizer.NewVariableService(b.VariableService) @@ -236,6 +239,7 @@ var apiLinks = map[string]interface{}{ "external": map[string]string{ "statusFeed": "https://www.influxdata.com/feed/json", }, + "flags": "/api/v2/flags", "labels": "/api/v2/labels", "variables": "/api/v2/variables", "me": "/api/v2/me", @@ -277,3 +281,16 @@ func serveLinksHandler(errorHandler influxdb.HTTPErrorHandler) http.Handler { } return http.HandlerFunc(fn) } + +func serveFlagsHandler(errorHandler influxdb.HTTPErrorHandler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + flags = feature.ExposedFlagsFromContext(ctx) + ) + if err := encodeResponse(ctx, w, http.StatusOK, flags); err != nil { + errorHandler.HandleHTTPError(ctx, err, w) + } + } + return http.HandlerFunc(fn) +} diff --git a/http/platform_handler.go b/http/platform_handler.go index 5c04e0fa9fa..5d9d7ef7f50 100644 --- a/http/platform_handler.go +++ b/http/platform_handler.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/kit/feature" kithttp "github.com/influxdata/influxdb/v2/kit/transport/http" ) @@ -17,8 +18,10 @@ type PlatformHandler struct { // NewPlatformHandler returns a platform handler that serves the API and associated assets. func NewPlatformHandler(b *APIBackend, us influxdb.UserService, opts ...APIHandlerOptFn) *PlatformHandler { + handler := feature.NewHandler(b.Logger, b.Flagger, feature.Flags(), NewAPIHandler(b, opts...)) + h := NewAuthenticationHandler(b.Logger, b.HTTPErrorHandler) - h.Handler = NewAPIHandler(b, opts...) + h.Handler = handler h.AuthorizationService = b.AuthorizationService h.SessionService = b.SessionService h.SessionRenewDisabled = b.SessionRenewDisabled diff --git a/kit/feature/_codegen/main.go b/kit/feature/_codegen/main.go new file mode 100644 index 00000000000..588f3b84255 --- /dev/null +++ b/kit/feature/_codegen/main.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "go/format" + "io/ioutil" + "os" + "strings" + "text/template" + + "github.com/Masterminds/sprig" + "github.com/influxdata/influxdb/v2/kit/feature" + yaml "gopkg.in/yaml.v2" +) + +const tmpl = `// Code generated by the feature package; DO NOT EDIT. + +package feature + +{{ .Qualify | import }} + +{{ range $_, $flag := .Flags }} +var {{ $flag.Key }} = {{ $.Qualify | package }}{{ $flag.Default | maker }}( + {{ $flag.Name | quote }}, + {{ $flag.Key | quote }}, + {{ $flag.Contact | quote }}, + {{ $flag.Default | conditionalQuote }}, + {{ $flag.Lifetime | lifetime }}, + {{ $flag.Expose }}, +) + +// {{ $flag.Name | replace " " "_" | camelcase }} - {{ $flag.Description }} +func {{ $flag.Name | replace " " "_" | camelcase }}() {{ $.Qualify | package }}{{ $flag.Default | flagType }} { + return {{ $flag.Key }} +} +{{ end }} + +var all = []{{ .Qualify | package }}Flag{ +{{ range $_, $flag := .Flags }} {{ $flag.Key }}, +{{ end }}} + +var byKey = map[string]Flag{ +{{ range $_, $flag := .Flags }} {{ $flag.Key | quote }}: {{ $flag.Key }}, +{{ end }}} +` + +type flagConfig struct { + Name string + Description string + Key string + Default interface{} + Contact string + Lifetime feature.Lifetime + Expose bool +} + +func (f flagConfig) Valid() error { + var problems []string + if f.Key == "" { + problems = append(problems, "missing key") + } + if f.Contact == "" { + problems = append(problems, "missing contact") + } + if f.Default == nil { + problems = append(problems, "missing default") + } + if f.Description == "" { + problems = append(problems, "missing description") + } + + if len(problems) > 0 { + name := f.Name + if name == "" { + if f.Key != "" { + name = f.Key + } else { + name = "anonymous" + } + } + // e.g. "my flag: missing key; missing default" + return errors.New(fmt.Sprintf("%s: %s\n", name, strings.Join(problems, "; "))) + } + return nil +} + +type flagValidationError struct { + errs []error +} + +func newFlagValidationError(errs []error) *flagValidationError { + if len(errs) == 0 { + return nil + } + return &flagValidationError{errs} +} + +func (e *flagValidationError) Error() string { + var s strings.Builder + s.WriteString("flag validation error: \n") + for _, err := range e.errs { + s.WriteString(err.Error()) + } + return s.String() +} + +func validate(flags []flagConfig) error { + var ( + errs []error + seen = make(map[string]bool, len(flags)) + ) + for _, flag := range flags { + if err := flag.Valid(); err != nil { + errs = append(errs, err) + } else if _, repeated := seen[flag.Key]; repeated { + errs = append(errs, fmt.Errorf("duplicate flag key '%s'\n", flag.Key)) + } + seen[flag.Key] = true + } + if len(errs) != 0 { + return newFlagValidationError(errs) + } + + return nil +} + +var argv = struct { + in, out *string + qualify *bool +}{ + in: flag.String("in", "", "flag configuration path"), + out: flag.String("out", "", "flag generation destination path"), + qualify: flag.Bool("qualify", false, "qualify types with imported package name"), +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + os.Exit(0) +} + +func run() error { + flag.Parse() + + in, err := os.Open(*argv.in) + if err != nil { + return err + } + defer in.Close() + + configuration, err := ioutil.ReadAll(in) + if err != nil { + return err + } + + var flags []flagConfig + err = yaml.Unmarshal(configuration, &flags) + if err != nil { + return err + } + err = validate(flags) + if err != nil { + return err + } + + t, err := template.New("flags").Funcs(templateFunctions()).Parse(tmpl) + if err != nil { + return err + } + + out, err := os.Create(*argv.out) + if err != nil { + return err + } + defer out.Close() + + var ( + buf = new(bytes.Buffer) + vars = struct { + Qualify bool + Flags []flagConfig + }{ + Qualify: *argv.qualify, + Flags: flags, + } + ) + if err := t.Execute(buf, vars); err != nil { + return err + } + + raw, err := ioutil.ReadAll(buf) + if err != nil { + return err + } + + formatted, err := format.Source(raw) + if err != nil { + return err + } + + _, err = out.Write(formatted) + return err +} + +func templateFunctions() template.FuncMap { + functions := sprig.TxtFuncMap() + + functions["lifetime"] = func(t interface{}) string { + switch t { + case feature.Permanent: + return "Permanent" + default: + return "Temporary" + } + } + + functions["conditionalQuote"] = func(t interface{}) string { + switch t.(type) { + case string: + return fmt.Sprintf("%q", t) + default: + return fmt.Sprintf("%v", t) + } + } + + functions["flagType"] = func(t interface{}) string { + switch t.(type) { + case bool: + return "BoolFlag" + case float64: + return "FloatFlag" + case int: + return "IntFlag" + default: + return "StringFlag" + } + } + + functions["maker"] = func(t interface{}) string { + switch t.(type) { + case bool: + return "MakeBoolFlag" + case float64: + return "MakeFloatFlag" + case int: + return "MakeIntFlag" + default: + return "MakeStringFlag" + } + } + + functions["package"] = func(t interface{}) string { + if t.(bool) { + return "feature." + } + return "" + } + + functions["import"] = func(t interface{}) string { + if t.(bool) { + return "import \"github.com/influxdata/influxdb/v2/kit/feature\"" + } + return "" + } + + return functions +} diff --git a/kit/feature/feature.go b/kit/feature/feature.go new file mode 100644 index 00000000000..53cf54a1211 --- /dev/null +++ b/kit/feature/feature.go @@ -0,0 +1,134 @@ +// Package feature defines interaction with the feature flagging system. +package feature + +import ( + "context" + "strings" + + "github.com/opentracing/opentracing-go" +) + +type contextKey string + +const featureContextKey contextKey = "influx/feature/v1" + +// Flagger returns flag values. +type Flagger interface { + // Flags returns a map of flag keys to flag values. + // + // If an authorization is present on the context, it may be used to compute flag + // values according to the affiliated user ID and its organization and other mappings. + // Otherwise, they should be computed generally or return a default. + // + // One or more flags may be provided to restrict the results. + // Otherwise, all flags should be computed. + Flags(context.Context, ...Flag) (map[string]interface{}, error) +} + +// Annotate the context with a map computed of computed flags. +func Annotate(ctx context.Context, f Flagger, flags ...Flag) (context.Context, error) { + computed, err := f.Flags(ctx, flags...) + if err != nil { + return nil, err + } + + span := opentracing.SpanFromContext(ctx) + if span != nil { + for k, v := range computed { + span.LogKV(k, v) + } + } + + return context.WithValue(ctx, featureContextKey, computed), nil +} + +// FlagsFromContext returns the map of flags attached to the context +//by Annotate, or nil if none is found. +func FlagsFromContext(ctx context.Context) map[string]interface{} { + v, ok := ctx.Value(featureContextKey).(map[string]interface{}) + if !ok { + return nil + } + return v +} + +// ExposedFlagsFromContext returns the filtered map of exposed flags attached +//to the context by Annotate, or nil if none is found. +func ExposedFlagsFromContext(ctx context.Context) map[string]interface{} { + m := FlagsFromContext(ctx) + + if m == nil { + return nil + } + + filtered := make(map[string]interface{}) + for k, v := range m { + if flag := byKey[k]; flag != nil && flag.Expose() { + filtered[k] = v + } + } + + return filtered +} + +// Lifetime represents the intended lifetime of the feature flag. +// +// The zero value is Temporary, the most common case, but Permanent +// is included to mark special cases where a flag is not intended +// to be removed, e.g. enabling debug tracing for an organization. +// +// TODO(gavincabbage): This may become a stale date, which can then +// be used to trigger a notification to the contact when the flag +// has become stale, to encourage flag cleanup. +type Lifetime int + +const ( + // Temporary indicates a flag is intended to be removed after a feature is no longer in development. + Temporary Lifetime = iota + // Permanent indicates a flag is not intended to be removed. + Permanent +) + +// UnmarshalYAML implements yaml.Unmarshaler and interprets a case-insensitive text +// representation as a lifetime constant. +func (l *Lifetime) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + + switch strings.ToLower(s) { + case "permanent": + *l = Permanent + default: + *l = Temporary + } + + return nil +} + +type defaultFlagger struct{} + +// DefaultFlagger returns a flagger that always returns default values. +func DefaultFlagger() Flagger { + return &defaultFlagger{} +} + +// Flags returns a map of default values. It never returns an error. +func (*defaultFlagger) Flags(_ context.Context, flags ...Flag) (map[string]interface{}, error) { + if len(flags) == 0 { + flags = all + } + + m := make(map[string]interface{}, len(flags)) + for _, flag := range flags { + m[flag.Key()] = flag.Default() + } + + return m, nil +} + +// Flags returns all feature flags. +func Flags() []Flag { + return all +} diff --git a/kit/feature/feature_test.go b/kit/feature/feature_test.go new file mode 100644 index 00000000000..541a23a2ea0 --- /dev/null +++ b/kit/feature/feature_test.go @@ -0,0 +1,185 @@ +package feature_test + +import ( + "context" + "testing" + + "github.com/influxdata/influxdb/v2/kit/feature" +) + +func Test_feature(t *testing.T) { + + cases := []struct { + name string + flag feature.Flag + err error + values map[string]interface{} + ctx context.Context + expected interface{} + }{ + { + name: "bool happy path", + flag: newFlag("test", false), + values: map[string]interface{}{ + "test": true, + }, + expected: true, + }, + { + name: "int happy path", + flag: newFlag("test", 0), + values: map[string]interface{}{ + "test": int32(42), + }, + expected: int32(42), + }, + { + name: "float happy path", + flag: newFlag("test", 0.0), + values: map[string]interface{}{ + "test": 42.42, + }, + expected: 42.42, + }, + { + name: "string happy path", + flag: newFlag("test", ""), + values: map[string]interface{}{ + "test": "restaurantattheendoftheuniverse", + }, + expected: "restaurantattheendoftheuniverse", + }, + { + name: "bool missing use default", + flag: newFlag("test", false), + expected: false, + }, + { + name: "bool missing use default true", + flag: newFlag("test", true), + expected: true, + }, + { + name: "int missing use default", + flag: newFlag("test", 65), + expected: int32(65), + }, + { + name: "float missing use default", + flag: newFlag("test", 65.65), + expected: 65.65, + }, + { + name: "string missing use default", + flag: newFlag("test", "mydefault"), + expected: "mydefault", + }, + + { + name: "bool invalid use default", + flag: newFlag("test", true), + values: map[string]interface{}{ + "test": "notabool", + }, + expected: true, + }, + { + name: "int invalid use default", + flag: newFlag("test", 42), + values: map[string]interface{}{ + "test": 99.99, + }, + expected: int32(42), + }, + { + name: "float invalid use default", + flag: newFlag("test", 42.42), + values: map[string]interface{}{ + "test": 99, + }, + expected: 42.42, + }, + { + name: "string invalid use default", + flag: newFlag("test", "restaurantattheendoftheuniverse"), + values: map[string]interface{}{ + "test": true, + }, + expected: "restaurantattheendoftheuniverse", + }, + } + + for _, test := range cases { + t.Run("flagger "+test.name, func(t *testing.T) { + flagger := testFlagsFlagger{ + m: test.values, + err: test.err, + } + + var actual interface{} + switch flag := test.flag.(type) { + case feature.BoolFlag: + actual = flag.Enabled(test.ctx, flagger) + case feature.FloatFlag: + actual = flag.Float(test.ctx, flagger) + case feature.IntFlag: + actual = flag.Int(test.ctx, flagger) + case feature.StringFlag: + actual = flag.String(test.ctx, flagger) + default: + t.Errorf("unknown flag type %T (%#v)", flag, flag) + } + + if actual != test.expected { + t.Errorf("unexpected flag value: got %v, want %v", actual, test.expected) + } + }) + + t.Run("annotate "+test.name, func(t *testing.T) { + flagger := testFlagsFlagger{ + m: test.values, + err: test.err, + } + + ctx, err := feature.Annotate(context.Background(), flagger) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + var actual interface{} + switch flag := test.flag.(type) { + case feature.BoolFlag: + actual = flag.Enabled(ctx) + case feature.FloatFlag: + actual = flag.Float(ctx) + case feature.IntFlag: + actual = flag.Int(ctx) + case feature.StringFlag: + actual = flag.String(ctx) + default: + t.Errorf("unknown flag type %T (%#v)", flag, flag) + } + + if actual != test.expected { + t.Errorf("unexpected flag value: got %v, want %v", actual, test.expected) + } + }) + } +} + +type testFlagsFlagger struct { + m map[string]interface{} + err error +} + +func (f testFlagsFlagger) Flags(ctx context.Context, flags ...feature.Flag) (map[string]interface{}, error) { + if f.err != nil { + return nil, f.err + } + + return f.m, nil +} + +func newFlag(key string, defaultValue interface{}) feature.Flag { + return feature.MakeFlag(key, key, "", defaultValue, feature.Temporary, false) +} diff --git a/kit/feature/flag.go b/kit/feature/flag.go new file mode 100644 index 00000000000..045e8a53a40 --- /dev/null +++ b/kit/feature/flag.go @@ -0,0 +1,216 @@ +//go:generate go run ./_codegen/main.go --in ../../flags.yml --out ./list.go + +package feature + +import ( + "context" + "fmt" +) + +// Flag represents a generic feature flag with a key and a default. +type Flag interface { + // Key returns the programmatic backend identifier for the flag. + Key() string + // Default returns the type-agnostic zero value for the flag. + // Type-specific flag implementations may expose a typed default + // (e.g. BoolFlag includes a boolean Default field). + Default() interface{} + // Expose the flag. + Expose() bool +} + +// MakeFlag constructs a Flag. The concrete implementation is inferred from the provided default. +func MakeFlag(name, key, owner string, defaultValue interface{}, lifetime Lifetime, expose bool) Flag { + b := MakeBase(name, key, owner, defaultValue, lifetime, expose) + switch v := defaultValue.(type) { + case bool: + return BoolFlag{b, v} + case float64: + return FloatFlag{b, v} + case int32: + return IntFlag{b, v} + case int: + return IntFlag{b, int32(v)} + case string: + return StringFlag{b, v} + default: + return StringFlag{b, fmt.Sprintf("%v", v)} + } +} + +// flag base type. +type Base struct { + // name of the flag. + name string + // key is the programmatic backend identifier for the flag. + key string + // defaultValue for the flag. + defaultValue interface{} + // owner is an individual or team responsible for the flag. + owner string + // lifetime of the feature flag. + lifetime Lifetime + // expose the flag. + expose bool +} + +var _ Flag = Base{} + +// MakeBase constructs a flag flag. +func MakeBase(name, key, owner string, defaultValue interface{}, lifetime Lifetime, expose bool) Base { + return Base{ + name: name, + key: key, + owner: owner, + defaultValue: defaultValue, + lifetime: lifetime, + expose: expose, + } +} + +// Key returns the programmatic backend identifier for the flag. +func (f Base) Key() string { + return f.key +} + +// Default returns the type-agnostic zero value for the flag. +func (f Base) Default() interface{} { + return f.defaultValue +} + +// Expose the flag. +func (f Base) Expose() bool { + return f.expose +} + +func (f Base) value(ctx context.Context, flagger ...Flagger) (interface{}, bool) { + var ( + m map[string]interface{} + ok bool + ) + if len(flagger) < 1 { + m, ok = ctx.Value(featureContextKey).(map[string]interface{}) + } else { + var err error + m, err = flagger[0].Flags(ctx, f) + ok = err == nil + } + if !ok { + return nil, false + } + + v, ok := m[f.Key()] + if !ok { + return nil, false + } + + return v, true +} + +// StringFlag implements Flag for string values. +type StringFlag struct { + Base + defaultString string +} + +var _ Flag = StringFlag{} + +// MakeStringFlag returns a string flag with the given Base and default. +func MakeStringFlag(name, key, owner string, defaultValue string, lifetime Lifetime, expose bool) StringFlag { + b := MakeBase(name, key, owner, defaultValue, lifetime, expose) + return StringFlag{b, defaultValue} +} + +// String value of the flag on the request context. +func (f StringFlag) String(ctx context.Context, flagger ...Flagger) string { + i, ok := f.value(ctx, flagger...) + if !ok { + return f.defaultString + } + s, ok := i.(string) + if !ok { + return f.defaultString + } + return s +} + +// FloatFlag implements Flag for float values. +type FloatFlag struct { + Base + defaultFloat float64 +} + +var _ Flag = FloatFlag{} + +// MakeFloatFlag returns a string flag with the given Base and default. +func MakeFloatFlag(name, key, owner string, defaultValue float64, lifetime Lifetime, expose bool) FloatFlag { + b := MakeBase(name, key, owner, defaultValue, lifetime, expose) + return FloatFlag{b, defaultValue} +} + +// Float value of the flag on the request context. +func (f FloatFlag) Float(ctx context.Context, flagger ...Flagger) float64 { + i, ok := f.value(ctx, flagger...) + if !ok { + return f.defaultFloat + } + v, ok := i.(float64) + if !ok { + return f.defaultFloat + } + return v +} + +// IntFlag implements Flag for integer values. +type IntFlag struct { + Base + defaultInt int32 +} + +var _ Flag = IntFlag{} + +// MakeIntFlag returns a string flag with the given Base and default. +func MakeIntFlag(name, key, owner string, defaultValue int32, lifetime Lifetime, expose bool) IntFlag { + b := MakeBase(name, key, owner, defaultValue, lifetime, expose) + return IntFlag{b, defaultValue} +} + +// Int value of the flag on the request context. +func (f IntFlag) Int(ctx context.Context, flagger ...Flagger) int32 { + i, ok := f.value(ctx, flagger...) + if !ok { + return f.defaultInt + } + v, ok := i.(int32) + if !ok { + return f.defaultInt + } + return v +} + +// BoolFlag implements Flag for boolean values. +type BoolFlag struct { + Base + defaultBool bool +} + +var _ Flag = BoolFlag{} + +// MakeBoolFlag returns a string flag with the given Base and default. +func MakeBoolFlag(name, key, owner string, defaultValue bool, lifetime Lifetime, expose bool) BoolFlag { + b := MakeBase(name, key, owner, defaultValue, lifetime, expose) + return BoolFlag{b, defaultValue} +} + +// Enabled indicates whether flag is true or false on the request context. +func (f BoolFlag) Enabled(ctx context.Context, flagger ...Flagger) bool { + i, ok := f.value(ctx, flagger...) + if !ok { + return f.defaultBool + } + v, ok := i.(bool) + if !ok { + return f.defaultBool + } + return v +} diff --git a/kit/feature/list.go b/kit/feature/list.go new file mode 100644 index 00000000000..de3da06cfef --- /dev/null +++ b/kit/feature/list.go @@ -0,0 +1,41 @@ +// Code generated by the feature package; DO NOT EDIT. + +package feature + +var backendExample = MakeBoolFlag( + "Backend Example", + "backendExample", + "Gavin Cabbage", + false, + Permanent, + false, +) + +// BackendExample - A permanent backend example boolean flag +func BackendExample() BoolFlag { + return backendExample +} + +var frontendExample = MakeIntFlag( + "Frontend Example", + "frontendExample", + "Gavin Cabbage", + 42, + Temporary, + true, +) + +// FrontendExample - A temporary frontend example integer flag +func FrontendExample() IntFlag { + return frontendExample +} + +var all = []Flag{ + backendExample, + frontendExample, +} + +var byKey = map[string]Flag{ + "backendExample": backendExample, + "frontendExample": frontendExample, +} diff --git a/kit/feature/middleware.go b/kit/feature/middleware.go new file mode 100644 index 00000000000..675dd56eb06 --- /dev/null +++ b/kit/feature/middleware.go @@ -0,0 +1,43 @@ +package feature + +import ( + "net/http" + + "go.uber.org/zap" +) + +// Handler is a middleware that annotates the context with a map of computed feature flags. +// To accurately compute identity-scoped flags, this middleware should be executed after any +// authorization middleware has annotated the request context with an authorizer. +type Handler struct { + log *zap.Logger + next http.Handler + flagger Flagger + flags []Flag +} + +// NewHandler returns a configured feature flag middleware that will annotate request context +// with a computed map of the given flags using the provided Flagger. +func NewHandler(log *zap.Logger, flagger Flagger, flags []Flag, next http.Handler) http.Handler { + return &Handler{ + log: log, + next: next, + flagger: flagger, + flags: flags, + } +} + +// ServeHTTP annotates the request context with a map of computed feature flags before +// continuing to serve the request. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, err := Annotate(r.Context(), h.flagger, h.flags...) + if err != nil { + h.log.Warn("Unable to annotate context with feature flags", zap.Error(err)) + } else { + r = r.WithContext(ctx) + } + + if h.next != nil { + h.next.ServeHTTP(w, r) + } +} diff --git a/kit/feature/middleware_test.go b/kit/feature/middleware_test.go new file mode 100644 index 00000000000..73e3bd05ba4 --- /dev/null +++ b/kit/feature/middleware_test.go @@ -0,0 +1,47 @@ +package feature_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/influxdata/influxdb/v2/kit/feature" + "go.uber.org/zap/zaptest" +) + +func Test_Handler(t *testing.T) { + var ( + w = &httptest.ResponseRecorder{} + r = httptest.NewRequest(http.MethodGet, "http://nowhere.test", new(bytes.Buffer)). + WithContext(context.Background()) + + original = r.Context() + ) + + handler := &checkHandler{t: t, f: func(t *testing.T, r *http.Request) { + if r.Context() == original { + t.Error("expected annotated context") + } + }} + + subject := feature.NewHandler(zaptest.NewLogger(t), feature.DefaultFlagger(), feature.Flags(), handler) + + subject.ServeHTTP(w, r) + + if !handler.called { + t.Error("expected handler to be called") + } +} + +type checkHandler struct { + t *testing.T + f func(t *testing.T, r *http.Request) + called bool +} + +func (h *checkHandler) ServeHTTP(_ http.ResponseWriter, r *http.Request) { + h.called = true + h.f(h.t, r) +} diff --git a/kit/feature/override/override.go b/kit/feature/override/override.go new file mode 100644 index 00000000000..8b7621c22f5 --- /dev/null +++ b/kit/feature/override/override.go @@ -0,0 +1,99 @@ +package override + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/influxdata/influxdb/v2/kit/feature" +) + +const ( + // Comma should delimit key-value pairs in the value of the configuration string. + Comma = "," + // Colon is the assignment operator in the key-value pairs in the configuration string, i.e. 'k:v'. + Colon = ":" + // These are specifically named to be documented as part of the public API, as opposed to e.g. "Delimiter" +) + +// Flagger computes any flag values from a string formatted as a list of key-value pairs, +// i.e. 'k1:v1,k2:v2,...' and uses them to override their corresponding defaults. +type Flagger struct { + flags map[string]string +} + +// Make a Flagger that returns defaults with any overrides parsed from the string. +func Make(s string) (Flagger, error) { + flags, err := parse(s) + if err != nil { + return Flagger{}, err + } + + return Flagger{ + flags: flags, + }, nil +} + +func parse(s string) (map[string]string, error) { + var ( + pairs = strings.Split(s, Comma) + m = make(map[string]string, len(pairs)) + ) + if len(pairs) < 1 { + return nil, errMalformed(s) + } + for _, pair := range pairs { + split := strings.Split(pair, Colon) + if len(split) != 2 { + return nil, errMalformed(s) + } + m[split[0]] = split[1] + } + + return m, nil +} + +func errMalformed(s string) error { + return fmt.Errorf("malformed configuration string %q must match format \"k1:v1,k2:v2,...\"", s) +} + +// Flags returns a map of default values. It never returns an error. +func (f Flagger) Flags(_ context.Context, flags ...feature.Flag) (map[string]interface{}, error) { + if len(flags) == 0 { + flags = feature.Flags() + } + + m := make(map[string]interface{}, len(flags)) + for _, flag := range flags { + if s, overridden := f.flags[flag.Key()]; overridden { + iface, err := f.coerce(s, flag) + if err != nil { + return nil, err + } + m[flag.Key()] = iface + } else { + m[flag.Key()] = flag.Default() + } + } + + return m, nil +} + +func (Flagger) coerce(s string, flag feature.Flag) (iface interface{}, err error) { + switch flag.(type) { + case feature.BoolFlag: + iface, err = strconv.ParseBool(s) + case feature.IntFlag: + iface, err = strconv.Atoi(s) + case feature.FloatFlag: + iface, err = strconv.ParseFloat(s, 64) + default: + iface = s + } + + if err != nil { + return nil, fmt.Errorf("coercing string %q based on flag type %T: %v", s, flag, err) + } + return +} diff --git a/kit/feature/override/override_test.go b/kit/feature/override/override_test.go new file mode 100644 index 00000000000..364b3e42858 --- /dev/null +++ b/kit/feature/override/override_test.go @@ -0,0 +1,110 @@ +package override + +import ( + "context" + "testing" + + "github.com/influxdata/influxdb/v2/kit/feature" +) + +func TestFlagger(t *testing.T) { + + cases := []struct { + name string + env string + defaults []feature.Flag + expected map[string]interface{} + expectMakeErr bool + expectFlagsErr bool + }{ + { + name: "enabled happy path filtering", + env: "flag1:new1,flag3:new3", + defaults: []feature.Flag{ + newFlag("flag0", "original0"), + newFlag("flag1", "original1"), + newFlag("flag2", "original2"), + newFlag("flag3", "original3"), + newFlag("flag4", "original4"), + }, + expected: map[string]interface{}{ + "flag0": "original0", + "flag1": "new1", + "flag2": "original2", + "flag3": "new3", + "flag4": "original4", + }, + }, + { + name: "enabled happy path types", + env: "intflag:43,floatflag:43.43,boolflag:true", + defaults: []feature.Flag{ + newFlag("intflag", 42), + newFlag("floatflag", 42.42), + newFlag("boolflag", false), + }, + expected: map[string]interface{}{ + "intflag": 43, + "floatflag": 43.43, + "boolflag": true, + }, + }, + { + name: "parse error", + env: "hello i am malformed, how are you?", + expectMakeErr: true, + }, + { + name: "type coerce error", + env: "key:not_an_int", + defaults: []feature.Flag{ + newFlag("key", 42), + }, + expectFlagsErr: true, + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + subject, err := Make(test.env) + if err != nil { + if test.expectMakeErr { + return + } + t.Fatalf("unexpected error making Flagger: %v", err) + } + + computed, err := subject.Flags(context.Background(), test.defaults...) + if err != nil { + if test.expectFlagsErr { + return + } + t.Fatalf("unexpected error calling Flags: %v", err) + } + + if len(computed) != len(test.expected) { + t.Fatalf("incorrect number of flags computed: expected %d, got %d", len(test.expected), len(computed)) + } + + // check for extra or incorrect keys + for k, v := range computed { + if xv, found := test.expected[k]; !found { + t.Errorf("unexpected key %s", k) + } else if v != xv { + t.Errorf("incorrect value for key %s: expected %v, got %v", k, xv, v) + } + } + + // check for missing keys + for k := range test.expected { + if _, found := computed[k]; !found { + t.Errorf("missing expected key %s", k) + } + } + }) + } +} + +func newFlag(key string, defaultValue interface{}) feature.Flag { + return feature.MakeFlag(key, key, "", defaultValue, feature.Temporary, false) +} diff --git a/kit/feature/target.go b/kit/feature/target.go new file mode 100644 index 00000000000..41771ed28e9 --- /dev/null +++ b/kit/feature/target.go @@ -0,0 +1,68 @@ +package feature + +import ( + "context" + "errors" + "fmt" + + "github.com/influxdata/influxdb/v2" + icontext "github.com/influxdata/influxdb/v2/context" +) + +var ErrMissingTargetInfo = errors.New("unable to determine any user or org IDs from authorizer on context") + +// Target against which to match a feature flag rule. +type Target struct { + // UserID to Target. + UserID influxdb.ID + // OrgIDs to Target. + OrgIDs []influxdb.ID +} + +// MakeTarget returns a populated feature flag Target for the given environment, +// including user and org information from the provided context, if available. +// +// If the authorizer on the context provides a user ID, it is used to fetch associated org IDs. +// If a user ID is not provided, an org ID is taken directly off the authorizer if possible. +// If no user or org information can be determined, a sentinel error is returned. +func MakeTarget(ctx context.Context, urms influxdb.UserResourceMappingService) (Target, error) { + auth, err := icontext.GetAuthorizer(ctx) + if err != nil { + return Target{}, ErrMissingTargetInfo + } + userID := auth.GetUserID() + + var orgIDs []influxdb.ID + if userID.Valid() { + orgIDs, err = fromURMs(ctx, userID, urms) + if err != nil { + return Target{}, err + } + } else if a, ok := auth.(*influxdb.Authorization); ok { + orgIDs = []influxdb.ID{a.OrgID} + } else { + return Target{}, ErrMissingTargetInfo + } + + return Target{ + UserID: userID, + OrgIDs: orgIDs, + }, nil +} + +func fromURMs(ctx context.Context, userID influxdb.ID, urms influxdb.UserResourceMappingService) ([]influxdb.ID, error) { + m, _, err := urms.FindUserResourceMappings(ctx, influxdb.UserResourceMappingFilter{ + UserID: userID, + ResourceType: influxdb.OrgsResourceType, + }) + if err != nil { + return nil, fmt.Errorf("finding organization mappings for user %s: %v", userID, err) + } + + orgIDs := make([]influxdb.ID, 0, len(m)) + for _, o := range m { + orgIDs = append(orgIDs, o.ResourceID) + } + + return orgIDs, nil +} diff --git a/mock/flagger.go b/mock/flagger.go new file mode 100644 index 00000000000..9e5aa263bdb --- /dev/null +++ b/mock/flagger.go @@ -0,0 +1,27 @@ +package mock + +import ( + "context" + + "github.com/influxdata/influxdb/v2/kit/feature" +) + +// Flagger is a mock. +type Flagger struct { + m map[string]interface{} +} + +// NewFlagger returns a mock Flagger. +func NewFlagger(flags map[feature.Flag]interface{}) *Flagger { + m := make(map[string]interface{}, len(flags)) + for k, v := range flags { + m[k.Key()] = v + } + return &Flagger{m} +} + +// Flags returns a map of flag keys to flag values according to its configured flag map. +// It never returns an error. +func (f Flagger) Flags(context.Context, ...feature.Flag) (map[string]interface{}, error) { + return f.m, nil +} diff --git a/scripts/ci/lint/flags.bash b/scripts/ci/lint/flags.bash new file mode 100755 index 00000000000..f96ef88389c --- /dev/null +++ b/scripts/ci/lint/flags.bash @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# This script regenerates the flag list and checks for differences to ensure flags +# have been regenerated in case of changes to flags.yml. + +make flags + +if ! git --no-pager diff --exit-code -- ./kit/feature/list.go +then + echo "Differences detected! Run 'make flags' to regenerate feature flag list." + exit 1 +fi