feat(kit/feature): add feature flag package (#17851)

* feat(kit/feature): add feature flag package

* refactor(kit/feature/_codegen): simplify with fmt.Errorf

* chore(kit/feature): tidy comments
pull/17920/head^2
Gavin Cabbage 2020-04-30 11:29:43 -04:00 committed by GitHub
parent 2c0916addf
commit 95913534a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1446 additions and 3 deletions

View File

@ -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

View File

@ -14,6 +14,8 @@
### Features
1. [17851](https://github.com/influxdata/influxdb/pull/17851): Add feature flag package capability and flags endpoint
### Bug Fixes
1. [17618](https://github.com/influxdata/influxdb/pull/17618): Add index for URM by user ID to improve lookup performance

View File

@ -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

View File

@ -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"
@ -303,8 +305,12 @@ 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",
},
}
cli.BindOptions(cmd, opts)
cmd.AddCommand(inspect.NewCommand())
}
@ -332,6 +338,8 @@ type Launcher struct {
enableNewMetaStore bool
featureFlags map[string]string
// Query options.
concurrencyQuota int
initialMemoryBytesQuotaPerQuery int
@ -843,6 +851,18 @@ func (m *Launcher) run(ctx context.Context) (err error) {
Addr: m.httpBindAddress,
}
flagger := feature.DefaultFlagger()
if len(m.featureFlags) > 0 {
f, err := overrideflagger.Make(m.featureFlags)
if err != nil {
m.log.Error("Failed to configure feature flag overrides",
zap.Error(err), zap.Any("overrides", m.featureFlags))
return err
}
m.log.Info("Running with feature flag overrides", zap.Any("config", m.featureFlags))
flagger = f
}
m.apibackend = &http.APIBackend{
AssetsPath: m.assetsPath,
HTTPErrorHandler: kithttp.ErrorHandler(0),
@ -885,6 +905,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()...)

28
flags.yml Normal file
View File

@ -0,0 +1,28 @@
# This file 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

View File

@ -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)
}

View File

@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"github.com/influxdata/influxdb/v2/kit/feature"
kithttp "github.com/influxdata/influxdb/v2/kit/transport/http"
)
@ -17,7 +18,7 @@ type PlatformHandler struct {
// NewPlatformHandler returns a platform handler that serves the API and associated assets.
func NewPlatformHandler(b *APIBackend, opts ...APIHandlerOptFn) *PlatformHandler {
h := NewAuthenticationHandler(b.Logger, b.HTTPErrorHandler)
h.Handler = NewAPIHandler(b, opts...)
h.Handler = feature.NewHandler(b.Logger, b.Flagger, feature.Flags(), NewAPIHandler(b, opts...))
h.AuthorizationService = b.AuthorizationService
h.SessionService = b.SessionService
h.SessionRenewDisabled = b.SessionRenewDisabled

View File

@ -4936,6 +4936,27 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
/flags:
get:
operationId: GetFlags
tags:
- Users
summary: Return the feature flags for the currently authenticated user
parameters:
- $ref: '#/components/parameters/TraceSpan'
responses:
'200':
description: Feature flags for the currently authenticated user
content:
application/json:
schema:
$ref: "#/components/schemas/Flags"
default:
description: Unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/me:
get:
operationId: GetMe
@ -8138,6 +8159,9 @@ components:
type: array
items:
$ref: "#/components/schemas/User"
Flags:
type: object
additionalProperties: true
ResourceMember:
allOf:
- $ref: "#/components/schemas/User"
@ -8223,6 +8247,9 @@ components:
me:
type: string
format: uri
flags:
type: string
format: uri
orgs:
type: string
format: uri

View File

@ -150,6 +150,18 @@ func BindOptions(cmd *cobra.Command, opts []Opt) {
}
mustBindPFlag(o.Flag, flagset)
*destP = viper.GetStringSlice(envVar)
case *map[string]string:
var d map[string]string
if o.Default != nil {
d = o.Default.(map[string]string)
}
if hasShort {
flagset.StringToStringVarP(destP, o.Flag, string(o.Short), d, o.Desc)
} else {
flagset.StringToStringVar(destP, o.Flag, d, o.Desc)
}
mustBindPFlag(o.Flag, flagset)
*destP = viper.GetStringMapString(envVar)
case pflag.Value:
if hasShort {
flagset.VarP(destP, o.Flag, string(o.Short), o.Desc)

View File

@ -0,0 +1,271 @@
package main
import (
"bytes"
"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 }},
{{ $.Qualify | package }}{{ $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]{{ $.Qualify | package }}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 fmt.Errorf("%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
}

75
kit/feature/doc.go Normal file
View File

@ -0,0 +1,75 @@
// Package feature provides feature flagging capabilities for InfluxDB servers.
// This document describes this package and how it is used to control
// experimental features in `influxd`.
//
// Flags are configured in `flags.yml` at the top of this repository.
// Running `make flags` generates Go code based on this configuration
// to programmatically test flag values in a given request context.
// Boolean flags are the most common case, but integers, floats and
// strings are supported for more complicated experiments.
//
// The `Flagger` interface is the crux of this package.
// It computes a map of feature flag values for a given request context.
// The default implementation always returns the flag default configured
// in `flags.yml`. The override implementation allows an operator to
// override feature flag defaults at startup. Changing these overrides
// requires a restart.
//
// In `influxd`, a `Flagger` instance is provided to a `Handler` middleware
// configured to intercept all API requests and annotate their request context
// with a map of feature flags.
//
// A flag can opt in to be exposed externally in `flags.yml`. If exposed,
// this flag will be included in the response from the `/api/v2/flags`
// endpoint. This allows the UI and other API clients to control their
// behavior according to the flag in addition to the server itself.
//
// A concrete example to illustrate the above:
//
// I have a feature called "My Feature" that will involve turning on new code
// in both the UI and the server.
//
// First, I add an entry to `flags.yml`.
//
// ```yaml
// - name: My Feature
// description: My feature is awesome
// key: myFeature
// default: false
// expose: true
// contact: My Name
// ```
//
// My flag type is inferred to be boolean by my defaulf of `false` when I run
// `make flags` and the `feature` package now includes `func MyFeature() BoolFlag`.
//
// I use this to control my backend code with
//
// ```go
// if feature.MyFeature.Enabled(ctx) {
// // new code...
// } else {
// // new code...
// }
// ```
//
// and the `/api/v2/flags` response provides the same information to the frontend.
//
// ```json
// {
// "myFeature": false
// }
// ```
//
// While `false` by default, I can turn on my experimental feature by starting
// my server with a flag override.
//
// ```
// env INFLUXD_FEATURE_FLAGS="{\"flag1\":\value1\",\"key2\":\"value2\"}" influxd
// ```
//
// ```
// influxd --feature-flags flag1:value1,flag2:value2
// ```
//
package feature

133
kit/feature/feature.go Normal file
View File

@ -0,0 +1,133 @@
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 = Flags()
}
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
}

185
kit/feature/feature_test.go Normal file
View File

@ -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)
}

216
kit/feature/flag.go Normal file
View File

@ -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
}

41
kit/feature/list.go Normal file
View File

@ -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,
}

43
kit/feature/middleware.go Normal file
View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -0,0 +1,61 @@
package override
import (
"context"
"fmt"
"strconv"
"github.com/influxdata/influxdb/v2/kit/feature"
)
// Flagger can override default flag values.
type Flagger struct {
overrides map[string]string
}
// Make a Flagger that returns defaults with any overrides parsed from the string.
func Make(m map[string]string) (Flagger, error) {
return Flagger{
overrides: m,
}, nil
}
// 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.overrides[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
}

View File

@ -0,0 +1,114 @@
package override
import (
"context"
"testing"
"github.com/influxdata/influxdb/v2/kit/feature"
)
func TestFlagger(t *testing.T) {
cases := []struct {
name string
env map[string]string
defaults []feature.Flag
expected map[string]interface{}
expectMakeErr bool
expectFlagsErr bool
}{
{
name: "enabled happy path filtering",
env: map[string]string{
"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: map[string]string{
"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: "type coerce error",
env: map[string]string{
"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)
}

68
kit/feature/target.go Normal file
View File

@ -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
}

27
mock/flagger.go Normal file
View File

@ -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
}

14
scripts/ci/lint/flags.bash Executable file
View File

@ -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