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 commentspull/17920/head^2
parent
2c0916addf
commit
95913534a0
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
6
Makefile
6
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
|
||||
|
|
|
@ -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()...)
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
Loading…
Reference in New Issue