Compare commits
52 Commits
nodejs
...
hackathon-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f9f4550a | ||
|
|
5a70a6d94e | ||
|
|
aa6a0be593 | ||
|
|
d908f243b4 | ||
|
|
1e3f3617f8 | ||
|
|
188f9be1d1 | ||
|
|
b40465eef2 | ||
|
|
e1890313ab | ||
|
|
0a778c7c25 | ||
|
|
7db746cf04 | ||
|
|
1ffdd899a1 | ||
|
|
424aa3361c | ||
|
|
25d44affe6 | ||
|
|
5aff089633 | ||
|
|
27ebc7e874 | ||
|
|
83c254ac76 | ||
|
|
c29c0dc2a9 | ||
|
|
00a91d2974 | ||
|
|
770af0c170 | ||
|
|
ea7c79e2c3 | ||
|
|
135869854a | ||
|
|
8293580261 | ||
|
|
872d1de89b | ||
|
|
82bd36db7e | ||
|
|
0b9cbc15c4 | ||
|
|
9b29bcfe27 | ||
|
|
a64e53c93b | ||
|
|
4fd42c9580 | ||
|
|
503b174228 | ||
|
|
2bec3cf42b | ||
|
|
ce2a0deb44 | ||
|
|
85262dc373 | ||
|
|
d61e102bf1 | ||
|
|
953f7eb568 | ||
|
|
cd8294f54c | ||
|
|
fe36f9d6ca | ||
|
|
c810b7e0b2 | ||
|
|
4f28552eae | ||
|
|
068bcc4796 | ||
|
|
7c266cd942 | ||
|
|
3a431c8740 | ||
|
|
690c76d931 | ||
|
|
607cdfaa6e | ||
|
|
54dad9a3a3 | ||
|
|
1418b1aecd | ||
|
|
9508a9537e | ||
|
|
963e5996cf | ||
|
|
5e87520e72 | ||
|
|
8e3b098d3c | ||
|
|
6ab33034fc | ||
|
|
12e98c4a87 | ||
|
|
8b450792c7 |
@@ -34,6 +34,10 @@
|
||||
:use {:level :warning}
|
||||
:warn-on-reflection {:level :warning}
|
||||
|
||||
;; release flag linters
|
||||
:metabase/unknown-release-flag {:level :warning}
|
||||
:metabase/non-literal-release-flag {:level :warning}
|
||||
|
||||
;;
|
||||
;; other disabled-by-default linters it would be nice to enable soon
|
||||
;;
|
||||
@@ -318,8 +322,7 @@
|
||||
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
|
||||
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
|
||||
toucan2.core/debug {:message "Don't commit code using t2/debug"}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
|
||||
}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
|
||||
|
||||
:discouraged-namespace
|
||||
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
|
||||
@@ -688,6 +691,11 @@
|
||||
metabase.util.malli.registry/def hooks.metabase.util.malli.registry/def
|
||||
metabase.util/case-enum hooks.metabase.util/case-enum
|
||||
metabase.util/format-color hooks.metabase.util/format-color
|
||||
metabase.release-flags.core/has-release-flag? hooks.metabase.release-flags.models/has-release-flag?
|
||||
metabase.release-flags.core/guard-namespace! hooks.metabase.release-flags.models/guard-namespace!
|
||||
metabase.release-flags.guard/bypass-guard-fixture hooks.metabase.release-flags.models/bypass-guard-fixture
|
||||
metabase.release-flags.guard/guard-namespace! hooks.metabase.release-flags.models/guard-namespace!
|
||||
metabase.release-flags.models/has-release-flag? hooks.metabase.release-flags.models/has-release-flag?
|
||||
metabase.xrays.api.automagic-dashboards-test/with-indexed-model! hooks.metabase.xrays.api.automagic-dashboards-test/with-indexed-model!}
|
||||
|
||||
:macroexpand
|
||||
|
||||
@@ -1009,6 +1009,13 @@
|
||||
:model/QueryExecution
|
||||
:model/User}}
|
||||
|
||||
joke-of-the-day
|
||||
{:team "UX West"
|
||||
:api #{metabase.joke-of-the-day.api}
|
||||
:uses #{api
|
||||
release-flags
|
||||
util}}
|
||||
|
||||
legacy-mbql
|
||||
{:team "Querying Platform"
|
||||
:api #{metabase.legacy-mbql.normalize
|
||||
@@ -1747,6 +1754,15 @@
|
||||
:model/QueryExecution
|
||||
:model/Table}}
|
||||
|
||||
release-flags
|
||||
{:team "UX West"
|
||||
:api #{metabase.release-flags.api
|
||||
metabase.release-flags.core
|
||||
metabase.release-flags.init}
|
||||
:uses #{api
|
||||
task
|
||||
util}}
|
||||
|
||||
remote-sync
|
||||
{:team "UX West"
|
||||
:api #{metabase.remote-sync.core
|
||||
@@ -1765,7 +1781,7 @@
|
||||
api
|
||||
config
|
||||
embedding
|
||||
permissions ; FIXME
|
||||
permissions ; FIXME
|
||||
session
|
||||
settings
|
||||
users
|
||||
@@ -1890,6 +1906,7 @@
|
||||
oauth-server
|
||||
premium-features
|
||||
query-processor
|
||||
release-flags
|
||||
request
|
||||
session
|
||||
settings
|
||||
@@ -1905,7 +1922,7 @@
|
||||
:api #{metabase.session.api
|
||||
metabase.session.core
|
||||
metabase.session.init
|
||||
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
|
||||
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
|
||||
:uses #{api
|
||||
app-db
|
||||
auth-identity
|
||||
|
||||
6
.clj-kondo/config/release-flags/config.edn
Normal file
6
.clj-kondo/config/release-flags/config.edn
Normal file
@@ -0,0 +1,6 @@
|
||||
;; Source of truth for release flags.
|
||||
;; Auto-loaded by clj-kondo via :config-paths, and read by metabase.release-flags.init at startup.
|
||||
;; See docs/developers-guide/release-flags.md for usage.
|
||||
{:metabase/release-flags
|
||||
{"joke-of-the-day" {:description "Whether to show the joke of the day on the homepage."
|
||||
:start-date "2026-02-24"}}}
|
||||
62
.clj-kondo/src/hooks/metabase/release_flags/models.clj
Normal file
62
.clj-kondo/src/hooks/metabase/release_flags/models.clj
Normal file
@@ -0,0 +1,62 @@
|
||||
(ns hooks.metabase.release-flags.models
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as api]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn- keyword->flag-name
|
||||
"Converts a keyword to its full string representation, including namespace if present.
|
||||
:foo => \"foo\", :ns/foo => \"ns/foo\""
|
||||
[kw]
|
||||
(if (namespace kw)
|
||||
(str (namespace kw) "/" (name kw))
|
||||
(name kw)))
|
||||
|
||||
(defn- check-flag-name
|
||||
"Checks if a flag name is in the valid set and registers a finding if not."
|
||||
[valid-flags flag-name flag-node]
|
||||
(when (and (seq valid-flags) (not (contains? valid-flags flag-name)))
|
||||
(api/reg-finding! (assoc (meta flag-node)
|
||||
:message (str "Unknown release flag: " flag-name
|
||||
". Valid flags: " (str/join ", " (sort valid-flags)))
|
||||
:type :metabase/unknown-release-flag))))
|
||||
|
||||
(defn- check-flag-arg
|
||||
"Shared lint check for a flag argument node. Warns if not a literal keyword/string
|
||||
or if the flag is not in release-flags.json."
|
||||
[valid-flags flag-node]
|
||||
(cond
|
||||
(api/keyword-node? flag-node)
|
||||
(check-flag-name valid-flags (keyword->flag-name (api/sexpr flag-node)) flag-node)
|
||||
|
||||
(api/string-node? flag-node)
|
||||
(check-flag-name valid-flags (api/sexpr flag-node) flag-node)
|
||||
|
||||
:else
|
||||
(api/reg-finding! (assoc (meta flag-node)
|
||||
:message "Release flag should be a literal keyword or string, not a variable."
|
||||
:type :metabase/non-literal-release-flag))))
|
||||
|
||||
(defn- get-valid-flags
|
||||
"Reads the valid release flag names from the kondo config.
|
||||
Loaded from .clj-kondo/config/release-flags/config.edn via :config-paths."
|
||||
[config]
|
||||
(set (keys (get config :metabase/release-flags {}))))
|
||||
|
||||
(defn has-release-flag?
|
||||
"Lint hook for metabase.release-flags.models/has-release-flag?."
|
||||
[{:keys [node config]}]
|
||||
(let [[_ flag-node] (:children node)]
|
||||
(check-flag-arg (get-valid-flags config) flag-node)))
|
||||
|
||||
(defn guard-namespace!
|
||||
"Lint hook for metabase.release-flags.guard/guard-namespace!."
|
||||
[{:keys [node config]}]
|
||||
(let [[_ flag-node] (:children node)]
|
||||
(check-flag-arg (get-valid-flags config) flag-node)))
|
||||
|
||||
(defn bypass-guard-fixture
|
||||
"Lint hook for metabase.release-flags.guard/bypass-guard-fixture."
|
||||
[{:keys [node config]}]
|
||||
(let [valid-flags (get-valid-flags config)]
|
||||
(doseq [flag-node (rest (:children node))]
|
||||
(check-flag-arg valid-flags flag-node))))
|
||||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -52,4 +52,8 @@ When reviewing code, use these emoji prefixes to categorize feedback:
|
||||
- For non-critical issues, link to the specific line from the summary instead
|
||||
- Delete all stale comments and summaries on each new push or CI run
|
||||
|
||||
## Release flags
|
||||
|
||||
All code adding or using release flags should follow the best practices outlined in `docs/developers-guide/release-flags.md`.
|
||||
|
||||
Language-specific rules are in `.github/instructions/*.instructions.md`.
|
||||
|
||||
7
bun.lock
7
bun.lock
@@ -93,6 +93,7 @@
|
||||
"dayjs": "^1.11.18",
|
||||
"detect-package-manager": "^3.0.2",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"echarts": "6.0.0",
|
||||
"events": "^3.3.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
@@ -2792,7 +2793,7 @@
|
||||
|
||||
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
|
||||
|
||||
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
@@ -5618,6 +5619,8 @@
|
||||
|
||||
"@redocly/cli/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
|
||||
|
||||
"@redocly/cli/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||
|
||||
"@redocly/cli/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"@redocly/cli/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
@@ -6956,6 +6959,8 @@
|
||||
|
||||
"typedoc/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
||||
|
||||
"typescript-plugin-css-modules/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||
|
||||
"unbox-primitive/which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
"unified/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
|
||||
|
||||
75
docs/developers-guide/release-flags.md
Normal file
75
docs/developers-guide/release-flags.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Release Flags
|
||||
|
||||
The purpose of Metabase release flags is to allow developers to progressively merge good, tested, but incomplete code to master and avoid large, long-running feature branches. Release flags should be temporary, and should be removed at the completion of a project. Release flags are not required. You can do a traditional feature branch approach with just a PR, or even better, merge small, independently and immediately useful code to master. But release flags are available as an option to avoid long-running feature branches when necessary.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. Create a PR which adds a new, unique flag to `release-flags/config.edn` and update `metabase-types/api/release-flg.ts` to include the new key. Have it reviewed and merged.
|
||||
2. Subsequent PRs for the feature should have all code behind the release flag. The code should be disabled at the highest possible level to reduce the number of places where the flag is invoked.
|
||||
3. When you are ready to launch your feature, create a PR which removes the flag from `release-flags/config.edn` and `metabase-types/api/release-flg.ts`. This will trigger linting and TypeScript errors. Remove the conditionals using the flag. Have it reviewed and merged. You’re done!
|
||||
|
||||
## Protecting Code
|
||||
|
||||
Develop your code as normal. Require it from namespaces. This will load your code as normal. However, you should protect the call sites of the functions in your code.
|
||||
|
||||
Throughout development, all code that should be behind the release flag should be disabled at the highest possible level in the calling code. Good encapsulation principles should be used to minimize the number of needed release flag conditionals. Nesting release flags should be avoided wherever possible. Care should be taken that release flagging code should not diminish the maintainability or readability of code. During development, you can toggle release flags from the ui at `/release-flags` or via the API at `PUT /api/release-flags`.
|
||||
|
||||
💡 If you find yourself needing to use the same flag in lots of places, you may need to:
|
||||
1. Reconsider whether a release flag is appropriate for this feature, or
|
||||
2. Consider whether your feature code could be better encapsulated
|
||||
|
||||
### Clojure
|
||||
|
||||
The main way to protect code at a call site is with `metabase.release-flags.core/has-release-flag?`.
|
||||
|
||||
Example:
|
||||
|
||||
```clojure
|
||||
(when (release-flags/has-release-flag? :my-new-feature)
|
||||
...)
|
||||
```
|
||||
|
||||
A new feature often requires new namespaces to be created to support the feature. That code will be committed to master much more quickly than with a feature branch approach. However, the code is not ready to be called normally. This creates a conflict: There is code in master that is not ready to be called. In order to prevent/detect code that incorrectly calls this code, a namespace should also add a single line to the end.
|
||||
|
||||
|
||||
```clojure
|
||||
(release-flags/guard-namespace! :my-new-feature)
|
||||
```
|
||||
|
||||
This function will instrument the functions in the namespace with code that tacks whether the release flag was enabled. If the code was called but the release flag wasn’t enabled, it will fail a test, preventing it from merging to master. This line should be removed when the feature is released.
|
||||
|
||||
A clj-kondo rule will check that `has-release-flag?` and `guard-namespace!` is only called with flags listed in `release-flags/config.edn`.
|
||||
|
||||
When you are writing new tests for your feature, you don't want them to fail. You can tell the release flag system to allow calls to your functions by adding this line to the top of the test namespace:
|
||||
|
||||
```clojure
|
||||
(use-fixtures :once (release-flags/bypass-guard-fixture <my-release-flag>))
|
||||
```
|
||||
|
||||
API endpoints that you want to protect behind a release flag should use `check-404` to fail requests as if the route were not even there. Here's an example:
|
||||
|
||||
```clojure
|
||||
(api.macros/defendpoint :get "/" :- [:map
|
||||
["id" :int]
|
||||
["type" :string]
|
||||
["setup" :string]
|
||||
["punchline" :string]]
|
||||
"Return the joke of the day."
|
||||
[]
|
||||
(api/check-404 (flags/has-release-flag? :joke-of-the-day)) ;; this line at the top of the endpoint
|
||||
(rand-nth (jokes/jokes)))
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
TypeScript code is toggled with `hasReleaseFlag("flag-name")`.
|
||||
|
||||
```ts
|
||||
import { hasReleaseFlag } from "metabase/lib/release-flags";
|
||||
|
||||
...
|
||||
|
||||
if (hasReleaseFlag("cool-feature")) {
|
||||
return <CoolFeature />;
|
||||
}
|
||||
```
|
||||
9
e2e/support/helpers/e2e-release-flag-helpers.ts
Normal file
9
e2e/support/helpers/e2e-release-flag-helpers.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ReleaseFlag } from "metabase-types/api";
|
||||
|
||||
export const setReleaseFlag = (flag: ReleaseFlag, value: boolean) =>
|
||||
cy.request({
|
||||
method: "PUT",
|
||||
url: "/api/release-flags",
|
||||
body: { [flag]: value },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
@@ -45,6 +45,7 @@ export * from "./e2e-permissions-helpers";
|
||||
export * from "./e2e-python-helpers";
|
||||
export * from "./e2e-qa-databases-helpers";
|
||||
export * from "./e2e-relative-date-picker-helpers";
|
||||
export * from "./e2e-release-flag-helpers";
|
||||
export * from "./e2e-remote-sync-helpers";
|
||||
export * from "./e2e-request-helpers";
|
||||
export * from "./e2e-search-helpers";
|
||||
|
||||
36
e2e/test/scenarios/admin-2/release-flags.cy.spec.ts
Normal file
36
e2e/test/scenarios/admin-2/release-flags.cy.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const { H } = cy;
|
||||
|
||||
describe("Admin - Release Flags", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
cy.intercept("GET", "/api/session/properties").as("getSessionProperties");
|
||||
});
|
||||
|
||||
it("should allow toggling a release flag", () => {
|
||||
cy.visit("/");
|
||||
cy.wait("@getSessionProperties");
|
||||
cy.findByTestId("home-page").should("be.visible");
|
||||
cy.findByTestId("joke-of-the-day").should("not.exist");
|
||||
cy.request({
|
||||
method: "GET",
|
||||
url: "/api/joke-of-the-day",
|
||||
failOnStatusCode: false,
|
||||
}).then(({ status }) => {
|
||||
expect(status).to.equal(404);
|
||||
});
|
||||
|
||||
H.setReleaseFlag("joke-of-the-day", true);
|
||||
cy.visit("/");
|
||||
cy.wait("@getSessionProperties");
|
||||
cy.findByTestId("home-page").should("be.visible");
|
||||
cy.findByTestId("joke-of-the-day").should("be.visible");
|
||||
cy.request({
|
||||
method: "GET",
|
||||
url: "/api/joke-of-the-day",
|
||||
failOnStatusCode: false,
|
||||
}).then(({ status }) => {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,7 @@
|
||||
"QueryField"
|
||||
"QueryTable"
|
||||
"RecentViews"
|
||||
"ReleaseFlag"
|
||||
"ReplacementRun"
|
||||
"RemoteSyncObject"
|
||||
"RemoteSyncTask"
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
:model/CloudMigration
|
||||
:model/Comment
|
||||
:model/CommentReaction
|
||||
:model/ReleaseFlag
|
||||
;; TODO (lbrdnk 2025-12-17) -- I've added rest of the workspace models here as Workspace was present. I believe
|
||||
;; going forward all of that will be available for export. We should revisit this later in the project.
|
||||
:model/Workspace
|
||||
|
||||
14
frontend/src/metabase/api/joke.ts
Normal file
14
frontend/src/metabase/api/joke.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Api } from "./api";
|
||||
|
||||
export const jokeApi = Api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getJoke: builder.query<{ setup: string; punchline: string }, void>({
|
||||
query: () => ({
|
||||
method: "GET",
|
||||
url: "/api/joke-of-the-day",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetJokeQuery } = jokeApi;
|
||||
@@ -4,7 +4,9 @@ import { t } from "ttag";
|
||||
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import { useHomepageDashboard } from "metabase/common/hooks/use-homepage-dashboard";
|
||||
import { JokeOfTheDay } from "metabase/jokes";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { hasReleaseFlag } from "metabase/lib/release-flags";
|
||||
import { updateUserSetting } from "metabase/redux/settings";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import { getHasDismissedCustomHomePageToast } from "metabase/selectors/app";
|
||||
@@ -14,12 +16,14 @@ import { HomeLayout } from "../HomeLayout";
|
||||
|
||||
export const HomePage = (): JSX.Element => {
|
||||
const { isLoadingDash } = useDashboardRedirect();
|
||||
|
||||
if (isLoadingDash) {
|
||||
return <LoadingAndErrorWrapper loading={isLoadingDash} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeLayout>
|
||||
{hasReleaseFlag("joke-of-the-day") && <JokeOfTheDay />}
|
||||
<HomeContent />
|
||||
</HomeLayout>
|
||||
);
|
||||
|
||||
27
frontend/src/metabase/jokes/JokeOfTheDay.tsx
Normal file
27
frontend/src/metabase/jokes/JokeOfTheDay.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useGetJokeQuery } from "metabase/api/joke";
|
||||
import { Box, Card, Flex, Text, Title } from "metabase/ui";
|
||||
|
||||
export function JokeOfTheDay() {
|
||||
const { data: joke, isLoading } = useGetJokeQuery();
|
||||
|
||||
if (!joke || isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="lg"
|
||||
my="lg"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
data-testid="joke-of-the-day"
|
||||
>
|
||||
<Flex align="center" gap="lg">
|
||||
<Title order={1}>😂</Title>
|
||||
<Box>
|
||||
<Text fw="bold">{joke.setup}</Text>
|
||||
<Text>{joke.punchline}</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1
frontend/src/metabase/jokes/index.ts
Normal file
1
frontend/src/metabase/jokes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./JokeOfTheDay";
|
||||
13
frontend/src/metabase/lib/release-flags.ts
Normal file
13
frontend/src/metabase/lib/release-flags.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type ReleaseFlag = "joke-of-the-day";
|
||||
|
||||
export type ReleaseFlagMap = Record<ReleaseFlag, boolean>;
|
||||
|
||||
const MetabaseReleaseFlags: ReleaseFlagMap =
|
||||
(process.env.RELEASE_FLAGS as unknown as ReleaseFlagMap) ?? {};
|
||||
|
||||
export const hasReleaseFlag = (flag: ReleaseFlag): boolean => {
|
||||
return MetabaseReleaseFlags?.[flag];
|
||||
};
|
||||
|
||||
// hang this on the window so we can call it in clojureScript
|
||||
window.hasReleaseFlag = hasReleaseFlag;
|
||||
65
frontend/src/types/global.d.ts
vendored
65
frontend/src/types/global.d.ts
vendored
@@ -1,35 +1,38 @@
|
||||
interface Window {
|
||||
MetabaseBootstrap: any;
|
||||
MetabaseRoot?: string;
|
||||
MetabaseNonce?: string;
|
||||
MetabaseUserColorScheme?: string;
|
||||
import type { ReleaseFlag } from "metabase/lib/release-flags";
|
||||
declare global {
|
||||
interface Window {
|
||||
MetabaseBootstrap: any;
|
||||
hasReleaseFlag?: (flag: ReleaseFlag) => boolean;
|
||||
MetabaseRoot?: string;
|
||||
MetabaseNonce?: string;
|
||||
MetabaseUserColorScheme?: string;
|
||||
|
||||
overrideIsWithinIframe?: boolean; // Mock that we're embedding, so we could test embed components
|
||||
METABASE?: boolean; // Add a global so we can check if the parent iframe is Metabase
|
||||
overrideIsWithinIframe?: boolean; // Mock that we're embedding, so we could test embed components
|
||||
METABASE?: boolean; // Add a global so we can check if the parent iframe is Metabase
|
||||
|
||||
// Make iFrameResizer available so that embed users can
|
||||
// have their embeds autosize to their content
|
||||
iFrameResizer?: {
|
||||
autoResize?: boolean;
|
||||
heightCalculationMethod?: string;
|
||||
onReady?: () => void;
|
||||
};
|
||||
// Make iFrameResizer available so that embed users can
|
||||
// have their embeds autosize to their content
|
||||
iFrameResizer?: {
|
||||
autoResize?: boolean;
|
||||
heightCalculationMethod?: string;
|
||||
onReady?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
// This allows importing static SVGs from TypeScript files
|
||||
declare module "*.svg" {
|
||||
const content: any;
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default content;
|
||||
}
|
||||
|
||||
// This allows importing CSS from TypeScript files
|
||||
declare module "*.css" {
|
||||
const classes: { [key: string]: string };
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default classes;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows importing static SVGs from TypeScript files
|
||||
declare module "*.svg" {
|
||||
const content: any;
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default content;
|
||||
}
|
||||
|
||||
// This allows importing CSS from TypeScript files
|
||||
declare module "*.css" {
|
||||
const classes: { [key: string]: string };
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module "iframe-resizer/js/iframeResizer.contentWindow.js";
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
export {};
|
||||
|
||||
1
frontend/src/types/iframe-resizer.d.ts
vendored
Normal file
1
frontend/src/types/iframe-resizer.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "iframe-resizer/js/iframeResizer.contentWindow.js";
|
||||
@@ -98,6 +98,7 @@
|
||||
"dayjs": "^1.11.18",
|
||||
"detect-package-manager": "^3.0.2",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"echarts": "6.0.0",
|
||||
"events": "^3.3.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
@@ -441,7 +442,7 @@
|
||||
"build-hot": "rm -f target/cljs_dev/cljs.core.js && bun run build:cljs && bun run concurrently -n 'js' -c 'green,cyan' 'bun run build-hot:js'",
|
||||
"build-hot-be": "rm -f target/cljs_dev/cljs.core.js && bun run concurrently -n 'cljs,js' -c 'yellow,green,cyan' 'bun run build-hot:cljs' 'ENABLE_CLJS_HOT_RELOAD=true bun run build-hot:js-wait'",
|
||||
"build-hot:cljs": "bun install && shadow-cljs watch app",
|
||||
"build-hot:js": "bun install && bun run clean-dev:js && NODE_OPTIONS=--max-old-space-size=8192 WEBPACK_BUNDLE=hot rspack serve",
|
||||
"build-hot:js": "bun install && bun run clean-dev:js && NODE_OPTIONS='--max-old-space-size=8192' WEBPACK_BUNDLE=hot rspack serve",
|
||||
"build-hot:js-wait": "bun run wait:cljs; bun run build-hot:js",
|
||||
"build-pure:cljs": "bun run clean-dev:cljs && shadow-cljs compile app",
|
||||
"build-release": "bun run build-release:cljs && bun run build-release:js",
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
{{{bootstrapJSON}}}
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="_metabaseReleaseFlags">
|
||||
{{{releaseFlagsJSON}}}
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="_metabaseUserLocalization">
|
||||
{{{userLocalizationJSON}}}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(function() {
|
||||
window.MetabaseBootstrap = JSON.parse(document.getElementById("_metabaseBootstrap").textContent);
|
||||
window.MetabaseReleaseFlags = JSON.parse(document.getElementById("_metabaseReleaseFlags").textContent);
|
||||
window.MetabaseUserLocalization = JSON.parse(document.getElementById("_metabaseUserLocalization").textContent);
|
||||
window.MetabaseSiteLocalization = JSON.parse(document.getElementById("_metabaseSiteLocalization").textContent);
|
||||
window.MetabaseUserColorScheme = JSON.parse(document.getElementById("_metabaseUserColorScheme").textContent);
|
||||
|
||||
62
resources/jokes.json
Normal file
62
resources/jokes.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"type": "general",
|
||||
"setup": "What did the fish say when it hit the wall?",
|
||||
"punchline": "Dam."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "general",
|
||||
"setup": "How do you make a tissue dance?",
|
||||
"punchline": "You put a little boogie on it."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "general",
|
||||
"setup": "What's Forrest Gump's password?",
|
||||
"punchline": "1Forrest1"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "general",
|
||||
"setup": "What do you call a belt made out of watches?",
|
||||
"punchline": "A waist of time."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "general",
|
||||
"setup": "Why can't bicycles stand on their own?",
|
||||
"punchline": "They are two tired"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "general",
|
||||
"setup": "How does a train eat?",
|
||||
"punchline": "It goes chew, chew"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "general",
|
||||
"setup": "What do you call a singing Laptop",
|
||||
"punchline": "A Dell"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "general",
|
||||
"setup": "How many lips does a flower have?",
|
||||
"punchline": "Tulips"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "general",
|
||||
"setup": "How do you organize an outer space party?",
|
||||
"punchline": "You planet"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "general",
|
||||
"setup": "What kind of shoes does a thief wear?",
|
||||
"punchline": "Sneakers"
|
||||
}
|
||||
]
|
||||
36
resources/migrations/060_update_migrations.yaml
Normal file
36
resources/migrations/060_update_migrations.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
databaseChangeLog:
|
||||
- objectQuotingStrategy: QUOTE_ALL_OBJECTS
|
||||
|
||||
- changeSet:
|
||||
id: v60.2026-02-24T00:00:00
|
||||
author: ericnormand
|
||||
comment: Create release_flag table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: release_flag
|
||||
remarks: Stores release flags for feature rollout
|
||||
columns:
|
||||
- column:
|
||||
name: flag
|
||||
type: varchar(254)
|
||||
remarks: Unique name identifying the release flag
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: description
|
||||
type: ${text.type}
|
||||
remarks: Human-readable description of what this flag controls
|
||||
- column:
|
||||
name: is_enabled
|
||||
type: ${boolean.type}
|
||||
defaultValueBoolean: false
|
||||
remarks: Whether this release flag is currently enabled
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: start_date
|
||||
type: date
|
||||
remarks: Date when this release flag was introduced
|
||||
constraints:
|
||||
nullable: false
|
||||
@@ -0,0 +1,29 @@
|
||||
databaseChangeLog:
|
||||
- objectQuotingStrategy: QUOTE_ALL_OBJECTS
|
||||
|
||||
- changeSet:
|
||||
id: vRF.2026-04-01T00:00:00
|
||||
author: iethree
|
||||
context: rf-joke-of-the-day
|
||||
comment: Create jokes table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: jokes
|
||||
remarks: stores jokes for the joke of the day feature
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
remarks: PK
|
||||
type: int
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: setup
|
||||
type: ${text.type}
|
||||
remarks: setup for the joke
|
||||
- column:
|
||||
name: punchline
|
||||
type: ${text.type}
|
||||
remarks: punchline for the joke
|
||||
@@ -1,6 +1,8 @@
|
||||
// @ts-check
|
||||
/* eslint-env node */
|
||||
|
||||
/* eslint-disable depend/ban-dependencies -- it is prohibitively difficult to load the env file for all scripts that call rspack */
|
||||
require("dotenv").config();
|
||||
const fs = require("fs");
|
||||
|
||||
const rspack = require("@rspack/core");
|
||||
@@ -94,6 +96,17 @@ class OnScriptError {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Record<string, boolean>} */
|
||||
const initialFlags = {};
|
||||
const ReleaseFlags = Object.entries(process.env)
|
||||
.filter(([key]) => key.startsWith("RF_"))
|
||||
.reduce((flags /** @type Record<string, boolean> **/, [key, value]) => {
|
||||
const flagName = key.slice(3).toLowerCase().replace(/_/g, "-");
|
||||
flags[flagName] = value === "true";
|
||||
return flags;
|
||||
}, initialFlags);
|
||||
|
||||
|
||||
/** @type {import('@rspack/cli').Configuration} */
|
||||
const config = {
|
||||
mode: isDevMode ? "development" : "production",
|
||||
@@ -297,6 +310,9 @@ const config = {
|
||||
MB_LOG_ANALYTICS: "false",
|
||||
ENABLE_CLJS_HOT_RELOAD: process.env.ENABLE_CLJS_HOT_RELOAD ?? "false",
|
||||
}),
|
||||
new rspack.DefinePlugin({
|
||||
"process.env.RELEASE_FLAGS": JSON.stringify(ReleaseFlags),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
[metabase.geojson.api]
|
||||
[metabase.glossary.api]
|
||||
[metabase.indexed-entities.api]
|
||||
[metabase.joke-of-the-day.api]
|
||||
[metabase.llm.api]
|
||||
[metabase.logger.api]
|
||||
[metabase.login-history.api]
|
||||
@@ -47,6 +48,7 @@
|
||||
[metabase.pulse.api]
|
||||
[metabase.queries-rest.api]
|
||||
[metabase.query-processor.api]
|
||||
[metabase.release-flags.api]
|
||||
[metabase.revisions.api]
|
||||
[metabase.search.api]
|
||||
[metabase.segments.api]
|
||||
@@ -184,6 +186,7 @@
|
||||
"/geojson" 'metabase.geojson.api
|
||||
"/glossary" (+auth 'metabase.glossary.api)
|
||||
"/google" (+auth metabase.sso.api/google-auth-routes)
|
||||
"/joke-of-the-day" 'metabase.joke-of-the-day.api
|
||||
"/ldap" (+auth metabase.sso.api/ldap-routes)
|
||||
"/llm" (+auth metabase.llm.api/routes)
|
||||
"/logger" (+auth 'metabase.logger.api)
|
||||
@@ -203,6 +206,7 @@
|
||||
"/product-feedback" 'metabase.product-feedback.api
|
||||
"/public" (+public-exceptions metabase.public-sharing-rest.api/routes)
|
||||
"/pulse" metabase.pulse.api/pulse-routes
|
||||
"/release-flags" (+auth 'metabase.release-flags.api)
|
||||
"/revision" (+auth 'metabase.revisions.api)
|
||||
"/search" (+auth metabase.search.api/routes)
|
||||
"/segment" (+auth 'metabase.segments.api)
|
||||
|
||||
@@ -104,7 +104,9 @@
|
||||
(not (str/starts-with? latest-migration "v"))
|
||||
changelog-legacy-file
|
||||
|
||||
(< (->> latest-migration (re-find #"v(\d+)\..*") second parse-long) 45)
|
||||
;; If the ID starts with "v" but doesn't match the versioned pattern (e.g., a release-flag
|
||||
;; migration ID), some-> short-circuits to nil (falsy) and we fall through to :else.
|
||||
(some-> (re-find #"v(\d+)\..*" latest-migration) second parse-long (< 45))
|
||||
changelog-legacy-file
|
||||
|
||||
:else
|
||||
@@ -191,16 +193,20 @@
|
||||
(.toString writer)))
|
||||
|
||||
(defn unrun-migrations
|
||||
"Returns a list of unrun migrations.
|
||||
"Returns a list of unrun migrations for the given `contexts` (default: all non-RF migrations).
|
||||
|
||||
It's a good idea to check to make sure there's actually something to do before running `(migrate :up)` so we can
|
||||
skip creating and releasing migration locks, which is both slightly dangerous and a waste of time when we won't be
|
||||
using them.
|
||||
|
||||
IMPORTANT: this function takes `data-source` but not `liquibase` because `.listUnrunChangeSets` is buggy. See #38257."
|
||||
[^DataSource data-source]
|
||||
(with-liquibase [liquibase data-source]
|
||||
(.listUnrunChangeSets liquibase nil (LabelExpression.))))
|
||||
([^DataSource data-source]
|
||||
;; Contexts("default") causes Liquibase to run changesets with *no* context (all normal migrations)
|
||||
;; but skip changesets that have an explicit context (i.e. rf-* release-flag changesets).
|
||||
(unrun-migrations data-source (Contexts. "default")))
|
||||
([^DataSource data-source ^Contexts contexts]
|
||||
(with-liquibase [liquibase data-source]
|
||||
(.listUnrunChangeSets liquibase contexts (LabelExpression.)))))
|
||||
|
||||
(defn- migration-lock-exists?
|
||||
"Is a migration lock in place for `liquibase`?"
|
||||
@@ -373,8 +379,8 @@
|
||||
(let [to-run-migrations (unrun-migrations data-source)
|
||||
unrun-migrations-count (count to-run-migrations)]
|
||||
(if (pos? unrun-migrations-count)
|
||||
(let [^Contexts contexts nil
|
||||
timer (u/start-timer)]
|
||||
(let [contexts (Contexts. "default")
|
||||
timer (u/start-timer)]
|
||||
(log/infof "Running %s migrations ..." unrun-migrations-count)
|
||||
(doseq [^ChangeSet change to-run-migrations]
|
||||
(log/tracef "To run migration %s" (.getId change)))
|
||||
@@ -388,14 +394,15 @@
|
||||
([liquibase]
|
||||
(update-with-change-log liquibase {}))
|
||||
([^Liquibase liquibase
|
||||
{:keys [^List change-set-filters exec-listener]
|
||||
:or {change-set-filters []}}]
|
||||
{:keys [^List change-set-filters exec-listener ^Contexts contexts]
|
||||
:or {change-set-filters []
|
||||
contexts (Contexts. "default")}}]
|
||||
(assert-locked liquibase)
|
||||
(let [change-log (.getDatabaseChangeLog liquibase)
|
||||
database (.getDatabase liquibase)
|
||||
log-iterator (ChangeLogIterator. change-log ^"[Lliquibase.changelog.filter.ChangeSetFilter;" (into-array ChangeSetFilter change-set-filters))
|
||||
update-visitor (UpdateVisitor. database ^ChangeExecListener exec-listener)
|
||||
runtime-env (RuntimeEnvironment. database (Contexts.) nil)]
|
||||
runtime-env (RuntimeEnvironment. database contexts nil)]
|
||||
(.run ^ChangeLogIterator log-iterator update-visitor runtime-env))))
|
||||
|
||||
(mu/defn force-migrate-up-if-needed!
|
||||
@@ -447,6 +454,44 @@
|
||||
(doseq [[^ChangeSet change-set fail-on-error?] fail-on-errors]
|
||||
(.setFailOnError change-set fail-on-error?))))))))
|
||||
|
||||
;;; ---- Release flag migrations ----
|
||||
|
||||
(defn- enabled-rf-contexts
|
||||
"Returns a seq of Liquibase context name strings for each enabled RF_ environment variable.
|
||||
Only reads from the OS environment — not JVM system properties or app-defaults — so migrations
|
||||
cannot be accidentally enabled via other config mechanisms.
|
||||
|
||||
RF_JOKE_OF_THE_DAY=true -> context \"rf-joke-of-the-day\""
|
||||
[]
|
||||
(seq (for [[k v] (System/getenv)
|
||||
:when (and (str/starts-with? k "RF_")
|
||||
(= "true" (str/lower-case (str/trim v))))]
|
||||
(-> k (subs 3) str/lower-case (str/replace "_" "-") (->> (str "rf-"))))))
|
||||
|
||||
(defn run-release-flag-migrations-if-needed!
|
||||
"Run any unrun release-flag migrations for each enabled RF_ environment variable.
|
||||
|
||||
Release-flag changesets live in 'migrations/release-flags/' alongside normal migrations and are
|
||||
protected by a Liquibase context matching their flag name. Normal updates pass Contexts(\"default\")
|
||||
which skips any changeset with an explicit context, so RF changesets never run accidentally.
|
||||
|
||||
Naming convention: a changeset must declare 'context: rf-<flag-name>' where <flag-name> is the
|
||||
part of the env var after 'RF_', lowercased with underscores replaced by dashes.
|
||||
|
||||
Example: RF_JOKE_OF_THE_DAY=true runs all changesets with 'context: rf-joke-of-the-day'."
|
||||
[^Liquibase liquibase ^DataSource data-source]
|
||||
(doseq [ctx (enabled-rf-contexts)]
|
||||
(let [contexts (Contexts. ctx)]
|
||||
;; Use a fresh Liquibase instance for the pre-lock check to avoid the .listUnrunChangeSets
|
||||
;; caching bug. See #38257. .update is idempotent so there is no correctness risk here.
|
||||
(when (seq (unrun-migrations data-source contexts))
|
||||
(log/infof "Running release-flag migrations for context: %s" ctx)
|
||||
(with-scope-locked liquibase
|
||||
(.update liquibase contexts)
|
||||
(log/infof "Completed release-flag migrations for %s" ctx))))))
|
||||
|
||||
;;; ---- Legacy / consolidated migrations ----
|
||||
|
||||
(def ^:private legacy-migrations-file "migrations/000_legacy_migrations.yaml")
|
||||
(def ^:private update001-migrations-file "migrations/001_update_migrations.yaml")
|
||||
|
||||
|
||||
@@ -69,7 +69,11 @@
|
||||
* `:print` - Just print the SQL for running the migrations, don't actually run them.
|
||||
* `:release-locks` - Manually release migration locks left by an earlier failed migration.
|
||||
(This shouldn't be necessary now that we run migrations inside a transaction, but is
|
||||
available just in case)."
|
||||
available just in case).
|
||||
|
||||
After running standard migrations, also runs any pending release-flag migrations from
|
||||
'migrations/release-flags/' for each enabled RF_ environment variable.
|
||||
See [[liquibase/run-release-flag-migrations-if-needed!]] for details."
|
||||
[data-source :- (ms/InstanceOfClass javax.sql.DataSource)
|
||||
direction :- :keyword
|
||||
& args]
|
||||
@@ -89,8 +93,10 @@
|
||||
|
||||
(log/info "Liquibase is ready.")
|
||||
(case direction
|
||||
:up (liquibase/migrate-up-if-needed! liquibase data-source)
|
||||
:force (liquibase/force-migrate-up-if-needed! liquibase data-source)
|
||||
:up (do (liquibase/migrate-up-if-needed! liquibase data-source)
|
||||
(liquibase/run-release-flag-migrations-if-needed! liquibase data-source))
|
||||
:force (do (liquibase/force-migrate-up-if-needed! liquibase data-source)
|
||||
(liquibase/run-release-flag-migrations-if-needed! liquibase data-source))
|
||||
:down (apply liquibase/rollback-major-version! conn liquibase false args)
|
||||
:down-force (apply liquibase/rollback-major-version! conn liquibase true args)
|
||||
:print (print-migrations-and-quit-if-needed! liquibase data-source)
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
[metabase.pulse.init]
|
||||
[metabase.queries.init]
|
||||
[metabase.query-processor.init]
|
||||
[metabase.release-flags.init]
|
||||
[metabase.remote-sync.init]
|
||||
[metabase.request.init]
|
||||
[metabase.revisions.init]
|
||||
|
||||
17
src/metabase/joke_of_the_day/api.clj
Normal file
17
src/metabase/joke_of_the_day/api.clj
Normal file
@@ -0,0 +1,17 @@
|
||||
(ns metabase.joke-of-the-day.api
|
||||
"/api/joke-of-the-day endpoint."
|
||||
(:require
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.joke-of-the-day.jokes :as jokes]
|
||||
[metabase.release-flags.core :as flags]))
|
||||
|
||||
(api.macros/defendpoint :get "/" :- [:map
|
||||
["id" :int]
|
||||
["type" :string]
|
||||
["setup" :string]
|
||||
["punchline" :string]]
|
||||
"Return the joke of the day."
|
||||
[]
|
||||
(api/check-404 (flags/has-release-flag? :joke-of-the-day))
|
||||
(rand-nth (jokes/jokes)))
|
||||
15
src/metabase/joke_of_the_day/jokes.clj
Normal file
15
src/metabase/joke_of_the_day/jokes.clj
Normal file
@@ -0,0 +1,15 @@
|
||||
(ns metabase.joke-of-the-day.jokes
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[metabase.release-flags.core :as release-flags]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(defn jokes
|
||||
"Returns the collection of jokes from jokes.json."
|
||||
[]
|
||||
(json/decode (slurp (io/resource "jokes.json"))))
|
||||
|
||||
;; guard-namespace! must be called at the end, after all functions are defined.
|
||||
;; Add functions above this line.
|
||||
|
||||
(release-flags/guard-namespace! "joke-of-the-day")
|
||||
@@ -90,6 +90,7 @@
|
||||
:model/QueryField metabase.queries.models.query-field
|
||||
:model/QueryTable metabase.queries.models.query-table
|
||||
:model/RecentViews metabase.activity-feed.models.recent-views
|
||||
:model/ReleaseFlag metabase.release-flags.models
|
||||
:model/RemoteSyncObject metabase-enterprise.remote-sync.models.remote-sync-object
|
||||
:model/RemoteSyncTask metabase-enterprise.remote-sync.models.remote-sync-task
|
||||
:model/ReplacementRun metabase-enterprise.replacement.models.replacement-run
|
||||
|
||||
25
src/metabase/release_flags/api.clj
Normal file
25
src/metabase/release_flags/api.clj
Normal file
@@ -0,0 +1,25 @@
|
||||
(ns metabase.release-flags.api
|
||||
"/api/release-flags endpoints."
|
||||
(:require
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.release-flags.models :as models]
|
||||
[metabase.release-flags.schema :as schema]))
|
||||
|
||||
(api.macros/defendpoint :get "/" :- schema/FlagMap
|
||||
"Return the status of all feature flags.
|
||||
Returns an empty map in prod mode."
|
||||
[]
|
||||
(if (models/release-flags-enabled?)
|
||||
(models/all-flags)
|
||||
{}))
|
||||
|
||||
(api.macros/defendpoint :put "/" :- schema/FlagMap
|
||||
"Modify the release flags."
|
||||
[_route-params
|
||||
_query-params
|
||||
flags :- schema/StatusMap]
|
||||
(api/check-404 (models/release-flags-enabled?))
|
||||
(api/check-superuser)
|
||||
(models/update-statuses! flags)
|
||||
(models/all-flags))
|
||||
14
src/metabase/release_flags/core.clj
Normal file
14
src/metabase/release_flags/core.clj
Normal file
@@ -0,0 +1,14 @@
|
||||
(ns metabase.release-flags.core
|
||||
(:require
|
||||
[metabase.release-flags.guard]
|
||||
[metabase.release-flags.models]
|
||||
[potemkin :as p]))
|
||||
|
||||
(p/import-vars
|
||||
[metabase.release-flags.guard
|
||||
bypass-guard-fixture
|
||||
guard-namespace!]
|
||||
[metabase.release-flags.models
|
||||
all-flags
|
||||
has-release-flag?
|
||||
release-flags-enabled?])
|
||||
90
src/metabase/release_flags/guard.clj
Normal file
90
src/metabase/release_flags/guard.clj
Normal file
@@ -0,0 +1,90 @@
|
||||
(ns metabase.release-flags.guard
|
||||
(:require
|
||||
[metabase.release-flags.models :as models]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:dynamic *blocked-calls*
|
||||
"When bound to an atom, guarded function calls that are blocked by a disabled
|
||||
release flag will be recorded here. Each entry is a map with :flag, :fn,
|
||||
and :call-site. Bind to (atom []) in test fixtures to capture blocked calls."
|
||||
nil)
|
||||
|
||||
(def ^:dynamic *bypass-guard*
|
||||
"When bound to a set of flag name strings, guarded functions for those flags execute
|
||||
without checking the release flag. Use [[bypass-guard-fixture]] in tests."
|
||||
#{})
|
||||
|
||||
(defn bypass-guard-fixture
|
||||
"Returns a clojure.test fixture that bypasses the given release flag guards.
|
||||
Usage: (use-fixtures :once (guard/bypass-guard-fixture :joke-of-the-day :other-flag))"
|
||||
[& flags]
|
||||
(let [flag-strs (into #{}
|
||||
(map #(if (keyword? %)
|
||||
(if (namespace %)
|
||||
(str (namespace %) "/" (name %))
|
||||
(name %))
|
||||
(str %)))
|
||||
flags)]
|
||||
(fn [thunk]
|
||||
(binding [*bypass-guard* (into *bypass-guard* flag-strs)]
|
||||
(thunk)))))
|
||||
|
||||
(defn- call-site
|
||||
"Returns the call site of the guarded function as a string, e.g. \"my.ns (my_ns.clj:42)\".
|
||||
Walks the stack trace to find the first frame outside the guard namespace."
|
||||
[]
|
||||
(let [guard-class-prefix "metabase.release_flags.guard"]
|
||||
(->> (.getStackTrace (Thread/currentThread))
|
||||
(drop-while #(or (.startsWith (.getClassName ^StackTraceElement %) guard-class-prefix)
|
||||
(.startsWith (.getClassName ^StackTraceElement %) "clojure.lang")))
|
||||
first
|
||||
((fn [^StackTraceElement frame]
|
||||
(when frame
|
||||
(format "%s (%s:%d)"
|
||||
(.getClassName frame)
|
||||
(.getFileName frame)
|
||||
(.getLineNumber frame))))))))
|
||||
|
||||
(defn guard-namespace!
|
||||
"Protects all public functions in a namespace behind a release flag.
|
||||
Call this at the end of a namespace. When a guarded function is called:
|
||||
- If the flag is enabled, the original function runs normally.
|
||||
- If the flag is not enabled (or does not exist), the call is recorded in
|
||||
[[blocked-calls]], a warning is logged, and nil is returned.
|
||||
If `target-ns` is not provided, defaults to `*ns*`."
|
||||
([flag]
|
||||
#_{:clj-kondo/ignore [:metabase/non-literal-release-flag]}
|
||||
(guard-namespace! flag *ns*))
|
||||
([flag target-ns]
|
||||
(let [flag-name (if (keyword? flag)
|
||||
(if (namespace flag)
|
||||
(str (namespace flag) "/" (name flag))
|
||||
(name flag))
|
||||
(str flag))
|
||||
ns-name (ns-name target-ns)]
|
||||
(doseq [[sym v] (ns-interns target-ns)
|
||||
:when (and (var? v) (fn? @v))]
|
||||
(let [original-fn @v
|
||||
qualified (str ns-name "/" sym)]
|
||||
(alter-var-root
|
||||
v
|
||||
(constantly
|
||||
(fn [& args]
|
||||
(if (or (contains? *bypass-guard* flag-name)
|
||||
(try
|
||||
#_{:clj-kondo/ignore [:metabase/non-literal-release-flag]}
|
||||
(models/has-release-flag? flag-name)
|
||||
(catch Exception _
|
||||
false)))
|
||||
(apply original-fn args)
|
||||
(let [site (call-site)
|
||||
entry {:flag flag-name
|
||||
:fn qualified
|
||||
:call-site site}]
|
||||
(when *blocked-calls*
|
||||
(swap! *blocked-calls* conj entry))
|
||||
(log/warnf "Release flag \"%s\" is not enabled. Skipping call to %s (called from %s)"
|
||||
flag-name qualified site)
|
||||
nil))))))))))
|
||||
45
src/metabase/release_flags/init.clj
Normal file
45
src/metabase/release_flags/init.clj
Normal file
@@ -0,0 +1,45 @@
|
||||
(ns metabase.release-flags.init
|
||||
(:require
|
||||
[clojure.edn :as edn]
|
||||
[clojure.set :as set]
|
||||
[metabase.release-flags.models :as models]
|
||||
[metabase.task.core :as task]
|
||||
[metabase.util.log :as log])
|
||||
(:import
|
||||
(java.time LocalDate)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- read-flags-file
|
||||
"Reads the release flags EDN config and returns the flag map."
|
||||
[]
|
||||
(try
|
||||
(:metabase/release-flags
|
||||
(edn/read-string (slurp ".clj-kondo/config/release-flags/config.edn")))
|
||||
(catch Exception e
|
||||
(log/warn e "Could not read .clj-kondo/config/release-flags/config.edn")
|
||||
{})))
|
||||
|
||||
(defn sync-flags-from-file!
|
||||
"Syncs the release_flag table with the release flags EDN config.
|
||||
Inserts any flags from the file that are missing from the table.
|
||||
Deletes any flags from the table that are not in the file.
|
||||
Existing flags are updated with the description and start_date from the file."
|
||||
[]
|
||||
(let [file-flags (read-flags-file)
|
||||
file-keys (set (keys file-flags))
|
||||
db-keys (set (map name (keys (models/all-flags))))]
|
||||
;; Delete flags not in the file
|
||||
(let [to-delete (set/difference db-keys file-keys)]
|
||||
(when (seq to-delete)
|
||||
(log/info "Deleting release flags not in file:" to-delete)
|
||||
(models/delete-flags! to-delete)))
|
||||
;; Upsert flags from the file
|
||||
(doseq [[flag-name data] file-flags]
|
||||
(models/upsert-flag! flag-name
|
||||
(:description data)
|
||||
(LocalDate/parse (:start-date data))))))
|
||||
|
||||
(defmethod task/init! ::ReleaseFlagSync [_]
|
||||
(log/info "Syncing release flags from EDN config")
|
||||
(sync-flags-from-file!))
|
||||
90
src/metabase/release_flags/models.clj
Normal file
90
src/metabase/release_flags/models.clj
Normal file
@@ -0,0 +1,90 @@
|
||||
(ns metabase.release-flags.models
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[environ.core :as env]
|
||||
[metabase.release-flags.schema :as schema]
|
||||
[metabase.util.malli :as mu]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(methodical/defmethod t2/table-name :model/ReleaseFlag [_model] :release_flag)
|
||||
|
||||
(methodical/defmethod t2/primary-keys :model/ReleaseFlag [_model] [:flag])
|
||||
|
||||
(doto :model/ReleaseFlag
|
||||
(derive :metabase/model))
|
||||
|
||||
(mu/defn all-flags :- schema/FlagMap
|
||||
"Returns a map of flag name to its data (description, start_date, is_enabled)."
|
||||
[]
|
||||
(into {}
|
||||
(map (fn [{:keys [flag] :as row}]
|
||||
[flag (dissoc row :flag)]))
|
||||
(t2/select :model/ReleaseFlag)))
|
||||
|
||||
(mu/defn update-statuses!
|
||||
"Takes a map of flag name to enabled status and updates each flag's is_enabled accordingly."
|
||||
[statuses :- schema/StatusMap]
|
||||
(doseq [[flag enabled?] statuses]
|
||||
(let [flag (if (keyword? flag)
|
||||
(if (namespace flag)
|
||||
(str (namespace flag) "/" (name flag))
|
||||
(name flag))
|
||||
flag)]
|
||||
(t2/update! :model/ReleaseFlag :flag flag {:is_enabled enabled?}))))
|
||||
|
||||
(mu/defn delete-flags! :- :int
|
||||
"Deletes the given set of flags from the release_flag table. Returns the number deleted."
|
||||
[flag-names :- [:set :string]]
|
||||
(if (seq flag-names)
|
||||
(t2/delete! :model/ReleaseFlag :flag [:in flag-names])
|
||||
0))
|
||||
|
||||
(mu/defn upsert-flag!
|
||||
"Inserts or updates a release flag with the given name, description, and start_date."
|
||||
[flag-name :- :string
|
||||
description :- [:maybe :string]
|
||||
start-date :- :any]
|
||||
(t2/with-transaction [_conn]
|
||||
(if (t2/exists? :model/ReleaseFlag :flag flag-name)
|
||||
(t2/update! :model/ReleaseFlag :flag flag-name
|
||||
{:description description
|
||||
:start_date start-date})
|
||||
(t2/insert! :model/ReleaseFlag {:flag flag-name
|
||||
:description description
|
||||
:start_date start-date}))))
|
||||
|
||||
(defn- env-str
|
||||
"Looks up a config key from environ (env vars, JVM properties, .lein-env).
|
||||
Returns the string value or nil."
|
||||
[k]
|
||||
(let [v (get env/env k)]
|
||||
(when-not (str/blank? v)
|
||||
(str/trim v))))
|
||||
|
||||
(mu/defn release-flags-enabled? :- :boolean
|
||||
"Returns true if the release flags system is active.
|
||||
This is the case in dev/test mode or when the MB_ENABLE_RELEASE_FLAGS env var is truthy."
|
||||
[]
|
||||
(boolean
|
||||
(or (not= "prod" (or (env-str :mb-run-mode) "prod"))
|
||||
(let [enable-str (env-str :mb-enable-release-flags)]
|
||||
(and enable-str
|
||||
(not= "false" enable-str)
|
||||
(not= "0" enable-str))))))
|
||||
|
||||
(mu/defn has-release-flag? :- :boolean
|
||||
"Is this release flag enabled?
|
||||
If the release flag does not exist, always returns false.
|
||||
If running in prod mode, always returns false."
|
||||
[flag :- schema/FlagName]
|
||||
(boolean
|
||||
(when (release-flags-enabled?)
|
||||
(let [flag (if (keyword? flag)
|
||||
(if (namespace flag)
|
||||
(str (namespace flag) "/" (name flag))
|
||||
(name flag))
|
||||
flag)]
|
||||
(t2/select-one-fn :is_enabled :model/ReleaseFlag :flag flag)))))
|
||||
21
src/metabase/release_flags/schema.clj
Normal file
21
src/metabase/release_flags/schema.clj
Normal file
@@ -0,0 +1,21 @@
|
||||
(ns metabase.release-flags.schema
|
||||
"Malli schemas for the release-flags module.")
|
||||
|
||||
(def FlagName
|
||||
"A release flag name, either a keyword or a string."
|
||||
[:or :keyword :string])
|
||||
|
||||
(def FlagData
|
||||
"The data for a single release flag."
|
||||
[:map
|
||||
[:description [:maybe :string]]
|
||||
[:start_date :any]
|
||||
[:is_enabled :boolean]])
|
||||
|
||||
(def FlagMap
|
||||
"A map of flag name strings to their data."
|
||||
[:map-of FlagName FlagData])
|
||||
|
||||
(def StatusMap
|
||||
"A map of flag names to boolean enabled status, used for updates."
|
||||
[:map-of FlagName :boolean])
|
||||
@@ -9,6 +9,7 @@
|
||||
[metabase.appearance.core :as appearance]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.initialization-status.core :as init-status]
|
||||
[metabase.release-flags.core :as release-flags]
|
||||
[metabase.settings.core :as setting]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.users.settings :as users-settings]
|
||||
@@ -82,10 +83,14 @@
|
||||
(defn- template-parameters
|
||||
[embeddable? {:keys [uri params nonce]}]
|
||||
(let [{:keys [anon-tracking-enabled google-auth-client-id], :as public-settings} (setting/user-readable-values-map #{:public})
|
||||
all-release-flags (if (release-flags/release-flags-enabled?)
|
||||
(release-flags/all-flags)
|
||||
{})
|
||||
;; We disable `locale` parameter on static embeds/public links (metabase#50313)
|
||||
should-load-locale-params? (not embeddable?)]
|
||||
{:bootstrapJS (load-inline-js "index_bootstrap")
|
||||
:bootstrapJSON (escape-script (json/encode public-settings))
|
||||
:releaseFlagsJSON (escape-script (json/encode all-release-flags))
|
||||
:assetOnErrorJS (load-inline-js "asset_loading_error")
|
||||
:userLocalizationJSON (escape-script (load-localization (when should-load-locale-params? (:locale params))))
|
||||
:siteLocalizationJSON (escape-script (load-localization (system/site-locale)))
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
:model/QueryExecution
|
||||
:model/QueryField
|
||||
:model/QueryTable
|
||||
:model/ReleaseFlag
|
||||
:model/RemoteSyncObject
|
||||
:model/RemoteSyncTask
|
||||
:model/ReplacementRun
|
||||
|
||||
19
test/metabase/joke_of_the_day/jokes_test.clj
Normal file
19
test/metabase/joke_of_the_day/jokes_test.clj
Normal file
@@ -0,0 +1,19 @@
|
||||
(ns metabase.joke-of-the-day.jokes-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.joke-of-the-day.jokes :as jokes]
|
||||
[metabase.release-flags.guard :as guard]))
|
||||
|
||||
(use-fixtures :once (guard/bypass-guard-fixture :joke-of-the-day))
|
||||
|
||||
(deftest jokes-test
|
||||
(testing "jokes returns a non-empty collection"
|
||||
(let [result (jokes/jokes)]
|
||||
(is (seq result))
|
||||
(is (sequential? result))))
|
||||
(testing "each joke has the expected keys"
|
||||
(doseq [joke (jokes/jokes)]
|
||||
(is (contains? joke "id"))
|
||||
(is (contains? joke "type"))
|
||||
(is (contains? joke "setup"))
|
||||
(is (contains? joke "punchline")))))
|
||||
37
test/metabase/release_flags/api_test.clj
Normal file
37
test/metabase/release_flags/api_test.clj
Normal file
@@ -0,0 +1,37 @@
|
||||
(ns metabase.release-flags.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures])
|
||||
(:import
|
||||
(java.time LocalDate)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db :web-server :test-users))
|
||||
|
||||
(deftest get-flags-test
|
||||
(testing "GET /api/release-flags"
|
||||
(testing "requires authentication"
|
||||
(is (= "Unauthenticated"
|
||||
(mt/client :get 401 "release-flags"))))
|
||||
(testing "returns all flags"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "get-test" :description "Test" :is_enabled false
|
||||
:start_date (LocalDate/parse "2026-01-01")}]
|
||||
(let [resp (mt/user-http-request :rasta :get 200 "release-flags")]
|
||||
(is (contains? resp :get-test))
|
||||
(is (= false (get-in resp [:get-test :is_enabled]))))))))
|
||||
|
||||
(deftest put-flags-test
|
||||
(testing "PUT /api/release-flags"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "put-flag" :description "Put test" :is_enabled false
|
||||
:start_date (LocalDate/parse "2026-01-01")}]
|
||||
(testing "requires superuser"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :put 403 "release-flags" {"put-flag" true}))))
|
||||
(testing "superuser can update flag status"
|
||||
(let [resp (mt/user-http-request :crowberto :put 200 "release-flags" {"put-flag" true})]
|
||||
(is (get-in resp [:put-flag :is_enabled]))))
|
||||
(testing "returns updated flag map"
|
||||
(let [resp (mt/user-http-request :crowberto :put 200 "release-flags" {"put-flag" false})]
|
||||
(is (not (get-in resp ["put-flag" "is_enabled"]))))))))
|
||||
93
test/metabase/release_flags/guard_test.clj
Normal file
93
test/metabase/release_flags/guard_test.clj
Normal file
@@ -0,0 +1,93 @@
|
||||
#_{:clj-kondo/ignore [:metabase/unknown-release-flag :metabase/non-literal-release-flag]}
|
||||
(ns metabase.release-flags.guard-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.release-flags.guard :as guard]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(defn- setup-guard-ns!
|
||||
"Creates a temporary namespace with a function and guards it. Returns [ns-sym fn-var]."
|
||||
[flag-name]
|
||||
(let [ns-sym (symbol (str "metabase.release-flags.guard-test-" (gensym)))]
|
||||
(create-ns ns-sym)
|
||||
(intern ns-sym 'test-fn (fn [] :original-value))
|
||||
#_{:clj-kondo/ignore [:metabase/non-literal-release-flag]}
|
||||
(guard/guard-namespace! flag-name (the-ns ns-sym))
|
||||
[ns-sym (ns-resolve (the-ns ns-sym) 'test-fn)]))
|
||||
|
||||
(defn- teardown-guard-ns! [ns-sym]
|
||||
(remove-ns ns-sym))
|
||||
|
||||
(deftest guard-namespace-blocks-when-disabled-test
|
||||
(let [[ns-sym test-fn-var] (setup-guard-ns! "guard-test-flag")]
|
||||
(try
|
||||
(testing "guarded function returns nil when flag does not exist"
|
||||
(binding [guard/*blocked-calls* nil]
|
||||
(is (nil? (test-fn-var)))))
|
||||
(finally
|
||||
(teardown-guard-ns! ns-sym)))))
|
||||
|
||||
(deftest guard-namespace-allows-when-enabled-test
|
||||
(let [[ns-sym test-fn-var] (setup-guard-ns! "guard-enabled-flag")]
|
||||
(try
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "guard-enabled-flag" :description "Test" :is_enabled true}]
|
||||
(testing "guarded function runs normally when flag is enabled"
|
||||
(is (= :original-value (test-fn-var)))))
|
||||
(finally
|
||||
(teardown-guard-ns! ns-sym)))))
|
||||
|
||||
(deftest guard-records-blocked-calls-test
|
||||
(let [[ns-sym test-fn-var] (setup-guard-ns! "blocked-test-flag")]
|
||||
(try
|
||||
(testing "blocked calls are recorded in *blocked-calls* when bound"
|
||||
(binding [guard/*blocked-calls* (atom [])]
|
||||
(test-fn-var)
|
||||
(let [calls @guard/*blocked-calls*]
|
||||
(is (= 1 (count calls)))
|
||||
(is (= "blocked-test-flag" (:flag (first calls))))
|
||||
(is (string? (:fn (first calls))))
|
||||
(is (string? (:call-site (first calls)))))))
|
||||
(testing "blocked calls are not recorded when *blocked-calls* is nil"
|
||||
(binding [guard/*blocked-calls* nil]
|
||||
(is (nil? (test-fn-var)))))
|
||||
(finally
|
||||
(teardown-guard-ns! ns-sym)))))
|
||||
|
||||
(deftest bypass-guard-fixture-test
|
||||
(let [[ns-sym test-fn-var] (setup-guard-ns! "bypass-test-flag")]
|
||||
(try
|
||||
(testing "bypassed flag allows function to run"
|
||||
(binding [guard/*bypass-guard* #{"bypass-test-flag"}]
|
||||
(is (= :original-value (test-fn-var)))))
|
||||
(testing "non-bypassed flag still blocks"
|
||||
(binding [guard/*blocked-calls* nil
|
||||
guard/*bypass-guard* #{"some-other-flag"}]
|
||||
(is (nil? (test-fn-var)))))
|
||||
(testing "bypass-guard-fixture returns a working fixture"
|
||||
(let [fixture #_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
|
||||
(guard/bypass-guard-fixture :bypass-test-flag)]
|
||||
(fixture (fn []
|
||||
(is (= :original-value (test-fn-var)))))))
|
||||
(finally
|
||||
(teardown-guard-ns! ns-sym)))))
|
||||
|
||||
(deftest guard-namespace-with-explicit-ns-test
|
||||
(testing "guard-namespace! accepts an explicit namespace argument"
|
||||
(let [ns-sym (symbol (str "metabase.release-flags.guard-test-explicit-" (gensym)))]
|
||||
(create-ns ns-sym)
|
||||
(intern ns-sym 'explicit-fn (fn [] :explicit-value))
|
||||
(try
|
||||
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
|
||||
(guard/guard-namespace! "explicit-flag" (the-ns ns-sym))
|
||||
(let [v (ns-resolve (the-ns ns-sym) 'explicit-fn)]
|
||||
(testing "function is guarded"
|
||||
(binding [guard/*blocked-calls* nil]
|
||||
(is (nil? (v)))))
|
||||
(testing "bypass works"
|
||||
(binding [guard/*bypass-guard* #{"explicit-flag"}]
|
||||
(is (= :explicit-value (v))))))
|
||||
(finally
|
||||
(remove-ns ns-sym))))))
|
||||
87
test/metabase/release_flags/models_test.clj
Normal file
87
test/metabase/release_flags/models_test.clj
Normal file
@@ -0,0 +1,87 @@
|
||||
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
|
||||
(ns metabase.release-flags.models-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.release-flags.models :as models]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.time LocalDate)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db))
|
||||
|
||||
(defn- get-release-flag [flag]
|
||||
(t2/select-one :model/ReleaseFlag :flag flag))
|
||||
|
||||
(defn- has-release-flag-unchecked? [flag]
|
||||
(:is_enabled (get-release-flag flag)))
|
||||
|
||||
(deftest all-flags-test
|
||||
(testing "returns flag data keyed by name"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "all-flags-test" :description "A test"}]
|
||||
(let [flags (models/all-flags)]
|
||||
(is (contains? flags "all-flags-test"))
|
||||
(is (not (get-in flags ["all-flags-test" :is_enabled])))
|
||||
(is (= "A test" (get-in flags ["all-flags-test" :description])))))))
|
||||
|
||||
(deftest update-statuses!-test
|
||||
(testing "updates is_enabled for existing flags"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "status-a" :description "A" :is_enabled false}
|
||||
:model/ReleaseFlag _ {:flag "status-b" :description "B" :is_enabled true}]
|
||||
(models/update-statuses! {"status-a" true "status-b" false})
|
||||
(is (has-release-flag-unchecked? "status-a"))
|
||||
(is (not (has-release-flag-unchecked? "status-b")))))
|
||||
(testing "handles keyword keys"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "kw-flag" :description "KW" :is_enabled false}]
|
||||
(models/update-statuses! {:kw-flag true})
|
||||
(is (has-release-flag-unchecked? "kw-flag")))))
|
||||
|
||||
(deftest delete-flags!-test
|
||||
(testing "returns 0 for empty set"
|
||||
(is (= 0 (models/delete-flags! #{}))))
|
||||
(testing "deletes specified flags and returns count"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "del-a" :description "A" :is_enabled false}
|
||||
:model/ReleaseFlag _ {:flag "del-b" :description "B" :is_enabled false}
|
||||
:model/ReleaseFlag _ {:flag "keep" :description "C" :is_enabled false}]
|
||||
(is (= 2 (models/delete-flags! #{"del-a" "del-b"})))
|
||||
(is (nil? (get-release-flag "del-a")))
|
||||
(is (some? (get-release-flag "keep"))))))
|
||||
|
||||
(deftest upsert-flag!-test
|
||||
(testing "inserts a new flag"
|
||||
(let [flag-name (str "upsert-" (random-uuid))]
|
||||
(try
|
||||
(models/upsert-flag! flag-name "New description" (LocalDate/parse "2026-03-01"))
|
||||
(let [row (get-release-flag flag-name)]
|
||||
(is (some? row))
|
||||
(is (= "New description" (:description row))))
|
||||
(finally
|
||||
(t2/delete! :model/ReleaseFlag :flag flag-name)))))
|
||||
(testing "updates an existing flag"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "upsert-existing" :description "Old" :is_enabled false}]
|
||||
(models/upsert-flag! "upsert-existing" "Updated" (LocalDate/parse "2026-04-01"))
|
||||
(is (= "Updated" (:description (get-release-flag "upsert-existing")))))))
|
||||
|
||||
(deftest has-release-flag?-test
|
||||
(testing "returns true when flag is enabled"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "enabled-flag" :description "E" :is_enabled true}]
|
||||
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
|
||||
(is (models/has-release-flag? "enabled-flag"))))
|
||||
(testing "returns false when flag is disabled"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "disabled-flag" :description "D" :is_enabled false}]
|
||||
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
|
||||
(is (not (models/has-release-flag? "disabled-flag")))))
|
||||
(testing "throws on missing flag"
|
||||
(is (not #_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
|
||||
(models/has-release-flag? "nonexistent"))))
|
||||
(testing "handles keyword argument"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "kw-test" :description "K" :is_enabled true}]
|
||||
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
|
||||
(is (models/has-release-flag? :kw-test))))
|
||||
(testing "handles namespaced keyword argument"
|
||||
(mt/with-temp [:model/ReleaseFlag _ {:flag "my-ns/ns-flag" :description "NS" :is_enabled true}]
|
||||
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
|
||||
(is (models/has-release-flag? :my-ns/ns-flag)))))
|
||||
@@ -23,6 +23,7 @@
|
||||
^{:clj-kondo/ignore [:deprecated-namespace]} [metabase.query-processor.store :as qp.store]
|
||||
[metabase.query-processor.test :as qp]
|
||||
[metabase.query-processor.test-util :as qp.test-util]
|
||||
[metabase.release-flags.guard :as guard]
|
||||
[metabase.request.core]
|
||||
[metabase.test-runner.assert-exprs :as test-runner.assert-exprs]
|
||||
[metabase.test.data :as data]
|
||||
@@ -372,12 +373,22 @@
|
||||
"A modified version of `clojure.test/test-var` that:
|
||||
- logs every toucan2 query we run, with details on the query type, model, args, and resulting query
|
||||
- adds some context to any logs emitted during the test, so that we have information on what test ran
|
||||
- fails the test if any guarded release-flag functions were called while the flag was disabled
|
||||
"
|
||||
[v]
|
||||
(let [test-n (-> v meta :name)
|
||||
test-ns (-> v meta :ns str)]
|
||||
(log/with-context {:test (str test-ns "/" test-n)}
|
||||
(original-test-var v))))
|
||||
(binding [guard/*blocked-calls* (atom [])
|
||||
clojure.test/*testing-vars* (conj clojure.test/*testing-vars* v)]
|
||||
(original-test-var v)
|
||||
;; report when blocked calls are used
|
||||
(doseq [{:keys [flag fn call-site]} @guard/*blocked-calls*]
|
||||
(clojure.test/do-report
|
||||
{:type :fail
|
||||
:message (str "Called guarded function " fn " while release flag \"" flag "\" is disabled")
|
||||
:expected (str "Release flag \"" flag "\" to be enabled")
|
||||
:actual (str "Flag was disabled. Call site: " call-site)}))))))
|
||||
|
||||
(alter-var-root #'clojure.test/test-var (constantly test-var-with-context))
|
||||
|
||||
|
||||
@@ -284,6 +284,12 @@
|
||||
:schedule_type :daily
|
||||
:schedule_hour 15}))
|
||||
|
||||
:model/ReleaseFlag
|
||||
(fn [_] {:flag (u.random/random-name)
|
||||
:description "Test release flag"
|
||||
:start_date (t/local-date)
|
||||
:is_enabled false})
|
||||
|
||||
:model/Revision
|
||||
(fn [_] {:user_id (rasta-id)
|
||||
:is_creation false
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[mb.hawk.core :as hawk]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.core.bootstrap]
|
||||
[metabase.test]
|
||||
[metabase.test-runner.assert-exprs]
|
||||
[metabase.test.data.env :as tx.env]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
|
||||
Reference in New Issue
Block a user