diff --git a/apps/app/lib/app_web/router.ex b/apps/app/lib/app_web/router.ex index 0f01a0a4..0ebff178 100644 --- a/apps/app/lib/app_web/router.ex +++ b/apps/app/lib/app_web/router.ex @@ -57,6 +57,7 @@ defmodule AppWeb.Router do pow_extension_routes() end + use Legendary.Core.Routes use Legendary.Admin.Routes use Legendary.Content.Routes end diff --git a/apps/core/guides/features/feature-flags.md b/apps/core/guides/features/feature-flags.md new file mode 100644 index 00000000..d74064db --- /dev/null +++ b/apps/core/guides/features/feature-flags.md @@ -0,0 +1,32 @@ +# Feature Flags + +Legendary comes with [Fun with Flags](https://github.com/tompave/fun_with_flags) +preconfigured for managing [feature flags](https://en.wikipedia.org/wiki/Feature_toggle). +This allows you to have more granular control over which users see which features +and when. For example, you can hide a feature which is not complete, or show it +to only a select group of testers. + + +Fun With Flags supports a variety of different feature gate types. From +the Fun With Flags docs: + +* **Boolean**: globally on and off. +* **Actors**: on or off for specific structs or data. The `FunWithFlags.Actor` protocol can be implemented for types and structs that should have specific rules. For example, in web applications it's common to use a `%User{}` struct or equivalent as an actor, or perhaps the current country of the request. +* **Groups**: on or off for structs or data that belong to a category or satisfy a condition. The `FunWithFlags.Group` protocol can be implemented for types and structs that belong to groups for which a feature flag can be enabled or disabled. For example, one could implement the protocol for a `%User{}` struct to identify administrators. +* **%-of-Time**: globally on for a percentage of the time. It ignores actors and groups. Mutually exclusive with the %-of-actors gate. +* **%-of-Actors**: globally on for a percentage of the actors. It only applies when the flag is checked with a specific actor and is ignored when the flag is checked without actor arguments. Mutually exclusive with the %-of-time gate. + +Since feature flags may be checked often (sometimes multiple times per request), +Fun With Flags uses a two-layer approach. Flags are cached in [ETS](https://erlang.org/doc/man/ets.html) +and also persisted to longer-term storage so that they are not lost when the app +restarts. + +By default, Legendary caches the flags for five minutes. We use Ecto for +persistence. We also use Phoenix PubSub to inform application nodes when a flag +has been updated. This configuration is a sensible default that we would not +expect you to need to change in most cases. + +## UI + +We integrate the Fun With Flags UI for managing flags. You can reach it through +a link in the admin. diff --git a/apps/core/lib/auth/user_admin.ex b/apps/core/lib/auth/user_admin.ex index cbbdb1bc..73292c29 100644 --- a/apps/core/lib/auth/user_admin.ex +++ b/apps/core/lib/auth/user_admin.ex @@ -3,6 +3,12 @@ defmodule Legendary.Auth.UserAdmin do alias Legendary.Auth.User alias Legendary.Core.Repo + def custom_links(_schema) do + [ + %{name: "Feature Flags", url: "/admin/feature-flags", order: 2, location: :top, icon: "flag"}, + ] + end + def create_changeset(schema, attrs) do Legendary.Auth.User.admin_changeset(schema, attrs) end diff --git a/apps/core/lib/core_web/routes.ex b/apps/core/lib/core_web/routes.ex new file mode 100644 index 00000000..6acc091b --- /dev/null +++ b/apps/core/lib/core_web/routes.ex @@ -0,0 +1,11 @@ +defmodule Legendary.Core.Routes do + defmacro __using__(_opts \\ []) do + quote do + scope path: "/admin/feature-flags" do + pipe_through :require_admin + + forward "/", FunWithFlags.UI.Router, namespace: "admin/feature-flags" + end + end + end +end diff --git a/apps/core/mix.exs b/apps/core/mix.exs index e42e0635..656d1c7f 100644 --- a/apps/core/mix.exs +++ b/apps/core/mix.exs @@ -58,6 +58,7 @@ defmodule Legendary.Core.MixProject do "guides/features/content-management.md", "guides/features/devops-templates.md", "guides/features/email.md", + "guides/features/feature-flags.md", "guides/features/i18n.md", "guides/features/tasks-and-scripts.md", ] @@ -139,6 +140,8 @@ defmodule Legendary.Core.MixProject do {:ex_cldr, "~> 2.13.0"}, {:ex_doc, "~> 0.24", only: :dev, runtime: false}, {:excoveralls, "~> 0.10", only: [:dev, :test]}, + {:fun_with_flags, "~> 1.6.0"}, + {:fun_with_flags_ui, "~> 0.7.2"}, {:phoenix, "~> 1.5.8"}, {:phoenix_ecto, "~> 4.1"}, {:ecto_sql, "~> 3.4"}, diff --git a/apps/core/priv/repo/migrations/20210611174550_add_feature_flag_table.exs b/apps/core/priv/repo/migrations/20210611174550_add_feature_flag_table.exs new file mode 100644 index 00000000..40bf6fde --- /dev/null +++ b/apps/core/priv/repo/migrations/20210611174550_add_feature_flag_table.exs @@ -0,0 +1,23 @@ +defmodule Legendary.Core.Repo.Migrations.AddFeatureFlagTable do + use Ecto.Migration + + def up do + create table(:fun_with_flags_toggles, primary_key: false) do + add :id, :bigserial, primary_key: true + add :flag_name, :string, null: false + add :gate_type, :string, null: false + add :target, :string, null: false + add :enabled, :boolean, null: false + end + + create index( + :fun_with_flags_toggles, + [:flag_name, :gate_type, :target], + [unique: true, name: "fwf_flag_name_gate_target_idx"] + ) + end + + def down do + drop table(:fun_with_flags_toggles) + end +end diff --git a/config/config.exs b/config/config.exs index db9fc37c..5fe28f6b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -76,6 +76,24 @@ config :app, config :mnesia, dir: to_charlist(Path.expand("./priv/mnesia")) +# Feature flags + +config :fun_with_flags, :cache, + enabled: true, + ttl: 300 # seconds + +config :fun_with_flags, :persistence, + adapter: FunWithFlags.Store.Persistent.Ecto, + repo: Legendary.Core.Repo + +config :fun_with_flags, :cache_bust_notifications, + enabled: true, + adapter: FunWithFlags.Notifications.PhoenixPubSub, + client: App.PubSub + +# Notifications can also be disabled, which will also remove the Redis/Redix dependency +config :fun_with_flags, :cache_bust_notifications, [enabled: false] + import_config "email_styles.exs" import_config "admin.exs" diff --git a/mix.lock b/mix.lock index 61e25b90..963022c2 100644 --- a/mix.lock +++ b/mix.lock @@ -28,6 +28,8 @@ "excoveralls": {:hex, :excoveralls, "0.13.0", "4e1b7cc4e0351d8d16e9be21b0345a7e165798ee5319c7800b9138ce17e0b38e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "fe2a56c8909564e2e6764765878d7d5e141f2af3bc8ff3b018a68ee2a218fced"}, "file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm", "97a3b6f8d63ef53bd0113070102db2ce05352ecf0d25390eb8d747c2bde98bca"}, "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, + "fun_with_flags": {:hex, :fun_with_flags, "1.6.0", "507fcbc19374e83d34ba13d63b0816d37af952da1c6592978bbf40dad6a2e671", [:mix], [{:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: true]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: true]}], "hexpm", "96b53f54737906c5a83b5d89922ca1145fda2d50db23b1f619478460b8d5f8d8"}, + "fun_with_flags_ui": {:hex, :fun_with_flags_ui, "0.7.2", "c8df9e90f92481c014824ab1ff5db7d501ac34ec28a4599b76251ec5a6db0861", [:mix], [{:cowboy, ">= 1.0.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:fun_with_flags, "~> 1.1", [hex: :fun_with_flags, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "99b3635b067304722560ea3d5aab38ae23ea1217366c95ecb6263559488a22f2"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, "gen_stage": {:hex, :gen_stage, "1.0.0", "51c8ae56ff54f9a2a604ca583798c210ad245f415115453b773b621c49776df5", [:mix], [], "hexpm", "1d9fc978db5305ac54e6f5fec7adf80cd893b1000cf78271564c516aa2af7706"}, "gen_state_machine": {:hex, :gen_state_machine, "2.1.0", "a38b0e53fad812d29ec149f0d354da5d1bc0d7222c3711f3a0bd5aa608b42992", [:mix], [], "hexpm", "ae367038808db25cee2f2c4b8d0531522ea587c4995eb6f96ee73410a60fa06b"},