Merge pull request #14216 from Budibase/budi-8483-consolidate-feature-flags-into-a-single-endpoint
Support primitives in feature flags, make flag types flow, remove some obsolete feature flag systems.
This commit is contained in:
commit
9f621f0b70
|
@ -1,79 +1,108 @@
|
|||
import env from "../environment"
|
||||
import * as context from "../context"
|
||||
import { cloneDeep } from "lodash"
|
||||
|
||||
export * from "./installation"
|
||||
class Flag<T> {
|
||||
static withDefault<T>(value: T) {
|
||||
return new Flag(value)
|
||||
}
|
||||
|
||||
private constructor(public defaultValue: T) {}
|
||||
}
|
||||
|
||||
// This is the primary source of truth for feature flags. If you want to add a
|
||||
// new flag, add it here and use the `fetch` and `get` functions to access it.
|
||||
// All of the machinery in this file is to make sure that flags have their
|
||||
// default values set correctly and their types flow through the system.
|
||||
const FLAGS = {
|
||||
LICENSING: Flag.withDefault(false),
|
||||
GOOGLE_SHEETS: Flag.withDefault(false),
|
||||
USER_GROUPS: Flag.withDefault(false),
|
||||
ONBOARDING_TOUR: Flag.withDefault(false),
|
||||
}
|
||||
|
||||
const DEFAULTS = Object.keys(FLAGS).reduce((acc, key) => {
|
||||
const typedKey = key as keyof typeof FLAGS
|
||||
// @ts-ignore
|
||||
acc[typedKey] = FLAGS[typedKey].defaultValue
|
||||
return acc
|
||||
}, {} as Flags)
|
||||
|
||||
type UnwrapFlag<F> = F extends Flag<infer U> ? U : never
|
||||
export type Flags = {
|
||||
[K in keyof typeof FLAGS]: UnwrapFlag<(typeof FLAGS)[K]>
|
||||
}
|
||||
|
||||
// Exported for use in tests, should not be used outside of this file.
|
||||
export function defaultFlags(): Flags {
|
||||
return cloneDeep(DEFAULTS)
|
||||
}
|
||||
|
||||
function isFlagName(name: string): name is keyof Flags {
|
||||
return FLAGS[name as keyof typeof FLAGS] !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant.
|
||||
* The env var is formatted as:
|
||||
* tenant1:feature1:feature2,tenant2:feature1
|
||||
* Reads the TENANT_FEATURE_FLAGS environment variable and returns a Flags object
|
||||
* populated with the flags for the current tenant, filling in the default values
|
||||
* if the flag is not set.
|
||||
*
|
||||
* Check the tests for examples of how TENANT_FEATURE_FLAGS should be formatted.
|
||||
*
|
||||
* In future we plan to add more ways of setting feature flags, e.g. PostHog, and
|
||||
* they will be accessed through this function as well.
|
||||
*/
|
||||
export function buildFeatureFlags() {
|
||||
if (!env.TENANT_FEATURE_FLAGS) {
|
||||
return
|
||||
export async function fetch(): Promise<Flags> {
|
||||
const currentTenantId = context.getTenantId()
|
||||
const flags = defaultFlags()
|
||||
|
||||
const split = (env.TENANT_FEATURE_FLAGS || "")
|
||||
.split(",")
|
||||
.map(x => x.split(":"))
|
||||
for (const [tenantId, ...features] of split) {
|
||||
if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tenantFeatureFlags: Record<string, string[]> = {}
|
||||
|
||||
env.TENANT_FEATURE_FLAGS.split(",").forEach(tenantToFeatures => {
|
||||
const [tenantId, ...features] = tenantToFeatures.split(":")
|
||||
|
||||
features.forEach(feature => {
|
||||
if (!tenantFeatureFlags[tenantId]) {
|
||||
tenantFeatureFlags[tenantId] = []
|
||||
}
|
||||
tenantFeatureFlags[tenantId].push(feature)
|
||||
})
|
||||
})
|
||||
|
||||
return tenantFeatureFlags
|
||||
}
|
||||
|
||||
export function isEnabled(featureFlag: string) {
|
||||
const tenantId = context.getTenantId()
|
||||
const flags = getTenantFeatureFlags(tenantId)
|
||||
return flags.includes(featureFlag)
|
||||
}
|
||||
|
||||
export function getTenantFeatureFlags(tenantId: string) {
|
||||
let flags: string[] = []
|
||||
const envFlags = buildFeatureFlags()
|
||||
if (envFlags) {
|
||||
const globalFlags = envFlags["*"]
|
||||
const tenantFlags = envFlags[tenantId] || []
|
||||
|
||||
// Explicitly exclude tenants from global features if required.
|
||||
// Prefix the tenant flag with '!'
|
||||
const tenantOverrides = tenantFlags.reduce(
|
||||
(acc: string[], flag: string) => {
|
||||
if (flag.startsWith("!")) {
|
||||
let stripped = flag.substring(1)
|
||||
acc.push(stripped)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (globalFlags) {
|
||||
flags.push(...globalFlags)
|
||||
}
|
||||
if (tenantFlags.length) {
|
||||
flags.push(...tenantFlags)
|
||||
for (let feature of features) {
|
||||
let value = true
|
||||
if (feature.startsWith("!")) {
|
||||
feature = feature.slice(1)
|
||||
value = false
|
||||
}
|
||||
|
||||
// Purge any tenant specific overrides
|
||||
flags = flags.filter(flag => {
|
||||
return tenantOverrides.indexOf(flag) == -1 && !flag.startsWith("!")
|
||||
})
|
||||
if (!isFlagName(feature)) {
|
||||
throw new Error(`Feature: ${feature} is not an allowed option`)
|
||||
}
|
||||
|
||||
if (typeof flags[feature] !== "boolean") {
|
||||
throw new Error(`Feature: ${feature} is not a boolean`)
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
flags[feature] = value
|
||||
}
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
export enum TenantFeatureFlag {
|
||||
LICENSING = "LICENSING",
|
||||
GOOGLE_SHEETS = "GOOGLE_SHEETS",
|
||||
USER_GROUPS = "USER_GROUPS",
|
||||
ONBOARDING_TOUR = "ONBOARDING_TOUR",
|
||||
// Gets a single feature flag value. This is a convenience function for
|
||||
// `fetch().then(flags => flags[name])`.
|
||||
export async function get<K extends keyof Flags>(name: K): Promise<Flags[K]> {
|
||||
const flags = await fetch()
|
||||
return flags[name]
|
||||
}
|
||||
|
||||
type BooleanFlags = {
|
||||
[K in keyof typeof FLAGS]: (typeof FLAGS)[K] extends Flag<boolean> ? K : never
|
||||
}[keyof typeof FLAGS]
|
||||
|
||||
// Convenience function for boolean flag values. This makes callsites more
|
||||
// readable for boolean flags.
|
||||
export async function isEnabled<K extends BooleanFlags>(
|
||||
name: K
|
||||
): Promise<boolean> {
|
||||
const flags = await fetch()
|
||||
return flags[name]
|
||||
}
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
export function processFeatureEnvVar<T>(
|
||||
fullList: string[],
|
||||
featureList?: string
|
||||
) {
|
||||
let list
|
||||
if (!featureList) {
|
||||
list = fullList
|
||||
} else {
|
||||
list = featureList.split(",")
|
||||
}
|
||||
for (let feature of list) {
|
||||
if (!fullList.includes(feature)) {
|
||||
throw new Error(`Feature: ${feature} is not an allowed option`)
|
||||
}
|
||||
}
|
||||
return list as unknown as T[]
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
import {
|
||||
TenantFeatureFlag,
|
||||
buildFeatureFlags,
|
||||
getTenantFeatureFlags,
|
||||
} from "../"
|
||||
import env from "../../environment"
|
||||
|
||||
const { ONBOARDING_TOUR, LICENSING, USER_GROUPS } = TenantFeatureFlag
|
||||
|
||||
describe("featureFlags", () => {
|
||||
beforeEach(() => {
|
||||
env._set("TENANT_FEATURE_FLAGS", "")
|
||||
})
|
||||
|
||||
it("Should return no flags when the TENANT_FEATURE_FLAG is empty", async () => {
|
||||
let features = buildFeatureFlags()
|
||||
expect(features).toBeUndefined()
|
||||
})
|
||||
|
||||
it("Should generate a map of global and named tenant feature flags from the env value", async () => {
|
||||
env._set(
|
||||
"TENANT_FEATURE_FLAGS",
|
||||
`*:${ONBOARDING_TOUR},tenant1:!${ONBOARDING_TOUR},tenant2:${USER_GROUPS},tenant1:${LICENSING}`
|
||||
)
|
||||
|
||||
const parsedFlags: Record<string, string[]> = {
|
||||
"*": [ONBOARDING_TOUR],
|
||||
tenant1: [`!${ONBOARDING_TOUR}`, LICENSING],
|
||||
tenant2: [USER_GROUPS],
|
||||
}
|
||||
|
||||
let features = buildFeatureFlags()
|
||||
|
||||
expect(features).toBeDefined()
|
||||
expect(features).toEqual(parsedFlags)
|
||||
})
|
||||
|
||||
it("Should add feature flag flag only to explicitly configured tenant", async () => {
|
||||
env._set(
|
||||
"TENANT_FEATURE_FLAGS",
|
||||
`*:${LICENSING},*:${USER_GROUPS},tenant1:${ONBOARDING_TOUR}`
|
||||
)
|
||||
|
||||
let tenant1Flags = getTenantFeatureFlags("tenant1")
|
||||
let tenant2Flags = getTenantFeatureFlags("tenant2")
|
||||
|
||||
expect(tenant1Flags).toBeDefined()
|
||||
expect(tenant1Flags).toEqual([LICENSING, USER_GROUPS, ONBOARDING_TOUR])
|
||||
|
||||
expect(tenant2Flags).toBeDefined()
|
||||
expect(tenant2Flags).toEqual([LICENSING, USER_GROUPS])
|
||||
})
|
||||
})
|
||||
|
||||
it("Should exclude tenant1 from global feature flag", async () => {
|
||||
env._set(
|
||||
"TENANT_FEATURE_FLAGS",
|
||||
`*:${LICENSING},*:${ONBOARDING_TOUR},tenant1:!${ONBOARDING_TOUR}`
|
||||
)
|
||||
|
||||
let tenant1Flags = getTenantFeatureFlags("tenant1")
|
||||
let tenant2Flags = getTenantFeatureFlags("tenant2")
|
||||
|
||||
expect(tenant1Flags).toBeDefined()
|
||||
expect(tenant1Flags).toEqual([LICENSING])
|
||||
|
||||
expect(tenant2Flags).toBeDefined()
|
||||
expect(tenant2Flags).toEqual([LICENSING, ONBOARDING_TOUR])
|
||||
})
|
||||
|
||||
it("Should explicitly add flags to configured tenants only", async () => {
|
||||
env._set(
|
||||
"TENANT_FEATURE_FLAGS",
|
||||
`tenant1:${ONBOARDING_TOUR},tenant1:${LICENSING},tenant2:${LICENSING}`
|
||||
)
|
||||
|
||||
let tenant1Flags = getTenantFeatureFlags("tenant1")
|
||||
let tenant2Flags = getTenantFeatureFlags("tenant2")
|
||||
|
||||
expect(tenant1Flags).toBeDefined()
|
||||
expect(tenant1Flags).toEqual([ONBOARDING_TOUR, LICENSING])
|
||||
|
||||
expect(tenant2Flags).toBeDefined()
|
||||
expect(tenant2Flags).toEqual([LICENSING])
|
||||
})
|
|
@ -0,0 +1,86 @@
|
|||
import { defaultFlags, fetch, get, Flags } from "../"
|
||||
import { context } from "../.."
|
||||
import env from "../../environment"
|
||||
|
||||
async function withFlags<T>(flags: string, f: () => T): Promise<T> {
|
||||
const oldFlags = env.TENANT_FEATURE_FLAGS
|
||||
env._set("TENANT_FEATURE_FLAGS", flags)
|
||||
try {
|
||||
return await f()
|
||||
} finally {
|
||||
env._set("TENANT_FEATURE_FLAGS", oldFlags)
|
||||
}
|
||||
}
|
||||
|
||||
describe("feature flags", () => {
|
||||
interface TestCase {
|
||||
tenant: string
|
||||
flags: string
|
||||
expected: Partial<Flags>
|
||||
}
|
||||
|
||||
it.each<TestCase>([
|
||||
{
|
||||
tenant: "tenant1",
|
||||
flags: "tenant1:ONBOARDING_TOUR",
|
||||
expected: { ONBOARDING_TOUR: true },
|
||||
},
|
||||
{
|
||||
tenant: "tenant1",
|
||||
flags: "tenant1:!ONBOARDING_TOUR",
|
||||
expected: { ONBOARDING_TOUR: false },
|
||||
},
|
||||
{
|
||||
tenant: "tenant1",
|
||||
flags: "*:ONBOARDING_TOUR",
|
||||
expected: { ONBOARDING_TOUR: true },
|
||||
},
|
||||
{
|
||||
tenant: "tenant1",
|
||||
flags: "tenant2:ONBOARDING_TOUR",
|
||||
expected: { ONBOARDING_TOUR: false },
|
||||
},
|
||||
{
|
||||
tenant: "tenant1",
|
||||
flags: "",
|
||||
expected: defaultFlags(),
|
||||
},
|
||||
])(
|
||||
'should find flags $expected for $tenant with string "$flags"',
|
||||
({ tenant, flags, expected }) =>
|
||||
context.doInTenant(tenant, () =>
|
||||
withFlags(flags, async () => {
|
||||
const flags = await fetch()
|
||||
expect(flags).toMatchObject(expected)
|
||||
|
||||
for (const [key, expectedValue] of Object.entries(expected)) {
|
||||
const value = await get(key as keyof Flags)
|
||||
expect(value).toBe(expectedValue)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
interface FailedTestCase {
|
||||
tenant: string
|
||||
flags: string
|
||||
expected: string | RegExp
|
||||
}
|
||||
|
||||
it.each<FailedTestCase>([
|
||||
{
|
||||
tenant: "tenant1",
|
||||
flags: "tenant1:ONBOARDING_TOUR,tenant1:FOO",
|
||||
expected: "Feature: FOO is not an allowed option",
|
||||
},
|
||||
])(
|
||||
"should fail with message \"$expected\" for $tenant with string '$flags'",
|
||||
async ({ tenant, flags, expected }) => {
|
||||
context.doInTenant(tenant, () =>
|
||||
withFlags(flags, async () => {
|
||||
await expect(fetch()).rejects.toThrow(expected)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
|
@ -7,8 +7,7 @@ export * as roles from "./security/roles"
|
|||
export * as permissions from "./security/permissions"
|
||||
export * as accounts from "./accounts"
|
||||
export * as installation from "./installation"
|
||||
export * as featureFlags from "./features"
|
||||
export * as features from "./features/installation"
|
||||
export * as features from "./features"
|
||||
export * as sessions from "./security/sessions"
|
||||
export * as platform from "./platform"
|
||||
export * as auth from "./auth"
|
||||
|
|
|
@ -5,9 +5,10 @@ export const TENANT_FEATURE_FLAGS = {
|
|||
LICENSING: "LICENSING",
|
||||
USER_GROUPS: "USER_GROUPS",
|
||||
ONBOARDING_TOUR: "ONBOARDING_TOUR",
|
||||
GOOGLE_SHEETS: "GOOGLE_SHEETS",
|
||||
}
|
||||
|
||||
export const isEnabled = featureFlag => {
|
||||
const user = get(auth).user
|
||||
return !!user?.featureFlags?.includes(featureFlag)
|
||||
return !!user?.flags?.[featureFlag]
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 7dbe323aec724ae6336b13c06aaefa4a89837edf
|
||||
Subproject commit 62ef0e2d6e83522b6732fb3c61338de303f06ff0
|
|
@ -1,4 +1,3 @@
|
|||
import { features } from "@budibase/backend-core"
|
||||
import env from "./environment"
|
||||
|
||||
enum AppFeature {
|
||||
|
@ -6,7 +5,25 @@ enum AppFeature {
|
|||
AUTOMATIONS = "automations",
|
||||
}
|
||||
|
||||
const featureList = features.processFeatureEnvVar<AppFeature>(
|
||||
export function processFeatureEnvVar<T>(
|
||||
fullList: string[],
|
||||
featureList?: string
|
||||
) {
|
||||
let list
|
||||
if (!featureList) {
|
||||
list = fullList
|
||||
} else {
|
||||
list = featureList.split(",")
|
||||
}
|
||||
for (let feature of list) {
|
||||
if (!fullList.includes(feature)) {
|
||||
throw new Error(`Feature: ${feature} is not an allowed option`)
|
||||
}
|
||||
}
|
||||
return list as unknown as T[]
|
||||
}
|
||||
|
||||
const featureList = processFeatureEnvVar<AppFeature>(
|
||||
Object.values(AppFeature),
|
||||
env.APP_FEATURES
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as userSdk from "../../../sdk/users"
|
||||
import {
|
||||
featureFlags,
|
||||
features,
|
||||
tenancy,
|
||||
db as dbCore,
|
||||
utils,
|
||||
|
@ -104,8 +104,8 @@ export async function getSelf(ctx: any) {
|
|||
ctx.body = await groups.enrichUserRolesFromGroups(user)
|
||||
|
||||
// add the feature flags for this tenant
|
||||
const tenantId = tenancy.getTenantId()
|
||||
ctx.body.featureFlags = featureFlags.getTenantFeatureFlags(tenantId)
|
||||
const flags = await features.fetch()
|
||||
ctx.body.flags = flags
|
||||
|
||||
addSessionAttributesToUser(ctx)
|
||||
}
|
||||
|
|
|
@ -19,8 +19,6 @@ function parseIntSafe(number: any) {
|
|||
}
|
||||
|
||||
const environment = {
|
||||
// features
|
||||
WORKER_FEATURES: process.env.WORKER_FEATURES,
|
||||
// auth
|
||||
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
|
||||
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import { features } from "@budibase/backend-core"
|
||||
import env from "./environment"
|
||||
|
||||
enum WorkerFeature {}
|
||||
|
||||
const featureList: WorkerFeature[] = features.processFeatureEnvVar(
|
||||
Object.values(WorkerFeature),
|
||||
env.WORKER_FEATURES
|
||||
)
|
||||
|
||||
export function isFeatureEnabled(feature: WorkerFeature) {
|
||||
return featureList.includes(feature)
|
||||
}
|
Loading…
Reference in New Issue