Compare commits

...

53 Commits

Author SHA1 Message Date
Timothy Dean
164caee2ee reworking flargs 2026-04-17 16:08:52 -06:00
Ryan Laurie
58f9f4550a release flags for migrations 2026-04-01 13:35:25 -06:00
Ryan Laurie
5a70a6d94e remove frontend runtime flags 2026-04-01 09:02:03 -06:00
Eric Normand
aa6a0be593 Disable put in prod 2026-04-01 06:48:42 -06:00
Eric Normand
d908f243b4 Appease the kondo 2026-04-01 06:48:42 -06:00
Eric Normand
1e3f3617f8 Protect API endpoints
API requires logged in user
Prod mode always returns {} in bootstrap and API
2026-04-01 06:48:42 -06:00
Eric Normand
188f9be1d1 Always return false on prod without hitting the db 2026-04-01 06:48:24 -06:00
Ryan Laurie
b40465eef2 remove failing prod test 2026-04-01 06:48:24 -06:00
Ryan Laurie
e1890313ab cleanup 2026-04-01 06:48:24 -06:00
Ryan Laurie
0a778c7c25 add robot instructions 2026-04-01 06:48:24 -06:00
Ryan Laurie
7db746cf04 add e2e tests 2026-04-01 06:48:23 -06:00
Eric Normand
1ffdd899a1 Bind *blocked-calls* to nil during guard tests 2026-04-01 06:48:23 -06:00
Ryan Laurie
424aa3361c dont show flags page in production 2026-04-01 06:48:23 -06:00
Eric Normand
25d44affe6 Add :model/ReleaseFlag to non-serialized entities list 2026-04-01 06:48:23 -06:00
Eric Normand
5aff089633 Fix test 2026-04-01 06:48:08 -06:00
Eric Normand
27ebc7e874 Make non-existent flags return false 2026-04-01 06:48:08 -06:00
Eric Normand
83c254ac76 Better docs 2026-04-01 06:48:08 -06:00
Ryan Laurie
c29c0dc2a9 hang hasReleaseFlag on the window for clojurescript 2026-04-01 06:48:08 -06:00
Ryan Laurie
00a91d2974 put flags on the window object 2026-04-01 06:48:08 -06:00
Ryan Laurie
770af0c170 cleanup 2026-04-01 06:48:08 -06:00
Eric Normand
ea7c79e2c3 Whitespace 2026-04-01 06:48:08 -06:00
Eric Normand
135869854a Fix tests 2026-04-01 06:48:08 -06:00
Eric Normand
8293580261 Better tests and kondo appeasement 2026-04-01 06:48:08 -06:00
Eric Normand
872d1de89b Add releaseFlagsJSON 2026-04-01 06:48:08 -06:00
Eric Normand
82bd36db7e More tests 2026-04-01 06:48:08 -06:00
Eric Normand
0b9cbc15c4 Fix tests 2026-04-01 06:48:08 -06:00
Eric Normand
9b29bcfe27 Better order for joke-of-the-day 2026-04-01 06:48:07 -06:00
Eric Normand
a64e53c93b Fixes 2026-04-01 06:48:07 -06:00
Eric Normand
4fd42c9580 Add remarks 2026-04-01 06:48:07 -06:00
Eric Normand
503b174228 Change boolean 2026-04-01 06:48:07 -06:00
Eric Normand
2bec3cf42b Better migration 2026-04-01 06:48:07 -06:00
Eric Normand
ce2a0deb44 Whitespace 2026-04-01 06:48:07 -06:00
Eric Normand
85262dc373 Remove kondo errors 2026-04-01 06:48:07 -06:00
Eric Normand
d61e102bf1 Test the jokes without failing 2026-04-01 06:48:07 -06:00
Eric Normand
953f7eb568 Add test bypass guard fixture 2026-04-01 06:48:07 -06:00
Eric Normand
cd8294f54c Move release-flags.json to resources 2026-04-01 06:48:07 -06:00
Eric Normand
fe36f9d6ca Centralize schema and allow keywords 2026-04-01 06:48:07 -06:00
Ryan Laurie
c810b7e0b2 joke frontend 2026-04-01 06:48:07 -06:00
Ryan Laurie
4f28552eae release flag ui toggle 2026-04-01 06:48:07 -06:00
Eric Normand
068bcc4796 Check 404 2026-04-01 06:48:06 -06:00
Eric Normand
7c266cd942 Make it work! 2026-04-01 06:48:06 -06:00
Eric Normand
3a431c8740 Oops, forgot to require 2026-04-01 06:48:06 -06:00
Eric Normand
690c76d931 Jokes api 2026-04-01 06:48:06 -06:00
Eric Normand
607cdfaa6e Guard test 2026-04-01 06:48:06 -06:00
Eric Normand
54dad9a3a3 Doh! Use API endpoint correctly 2026-04-01 06:48:06 -06:00
Ryan Laurie
1418b1aecd hook it up 2026-04-01 06:48:06 -06:00
Eric Normand
9508a9537e Fix Wrong schema 2026-04-01 06:48:06 -06:00
Eric Normand
963e5996cf Fix json 2026-04-01 06:48:06 -06:00
Ryan Laurie
5e87520e72 add frontend flagging
# Conflicts:
#	frontend/src/metabase-types/api/index.ts
2026-04-01 06:48:06 -06:00
Eric Normand
8e3b098d3c Linter (backend) 2026-04-01 06:47:31 -06:00
Eric Normand
6ab33034fc Main entrypoint for release-flags (guard and check) 2026-04-01 06:47:30 -06:00
Eric Normand
12e98c4a87 Initial db, model, api 2026-04-01 06:47:30 -06:00
Ryan Laurie
8b450792c7 add flag file 2026-04-01 06:47:30 -06:00
45 changed files with 1333 additions and 62 deletions

View File

@@ -318,8 +318,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."}
@@ -583,6 +582,7 @@
metabase.embedding-rest.api.embed-test/with-chain-filter-fixtures! hooks.common/let-one-with-optional-value
metabase.embedding-rest.api.embed-test/with-temp-card hooks.common/let-one-with-optional-value
metabase.embedding-rest.api.embed-test/with-temp-dashcard hooks.common/let-one-with-optional-value
metabase.flargs.core/defflarg hooks.metabase.flargs.core/defflarg
metabase.legacy-mbql.schema.macros/defclause hooks.metabase.legacy-mbql.schemas.macros/defclause
metabase.legacy-mbql.schema.macros/defclause* hooks.metabase.legacy-mbql.schemas.macros/defclause*
metabase.legacy-mbql.schema.macros/one-of hooks.metabase.legacy-mbql.schemas.macros/one-of

View File

@@ -933,6 +933,15 @@
:model/Table
:model/User}}
flargs
;; The defflarg macro, registry, dispatcher, and the env-var-driven startup activation. Consumed
;; by main-tree seams (routes, core.init) and by flarg-side code.
{:team "DevEx"
:api #{metabase.flargs.core
metabase.flargs.init}
:uses #{classloader
util}}
formatter
{:team "UX West"
:api #{metabase.formatter.core}
@@ -1765,7 +1774,7 @@
api
config
embedding
permissions ; FIXME
permissions ; FIXME
session
settings
users
@@ -1905,7 +1914,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
@@ -2560,6 +2569,18 @@
:model/Segment
:model/Table}}
flarg/joke-of-the-day
{:team "UX West"
:api #{}
:uses #{api
flargs
util}}
flarg/test-flarg
{:team "DevEx"
:api #{}
:uses #{flargs}}
enterprise/action-v2
{:team "Gadget"
:api #{metabase-enterprise.action-v2.api
@@ -2944,7 +2965,6 @@
premium-features}
:model-imports #{:model/Card :model/Collection :model/Table}}
enterprise/metabot
{:team "Metabot"
:api #{}
@@ -2962,7 +2982,6 @@
enterprise/transforms-python}
:model-imports #{:model/Card :model/Transform}}
enterprise/permission-debug
{:team "UX West"
:api #{metabase-enterprise.permission-debug.api}

View File

@@ -15,8 +15,9 @@
(defn module
"E.g.
(module 'metabase.qp.middleware.wow) => 'qp
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
(module 'metabase.qp.middleware.wow) => 'qp
(module 'metabase-enterprise.whatever.core) => 'enterprise/whatever
(module 'metabase.flarg.joke-of-the-day.api) => 'flarg/joke-of-the-day"
[ns-symb]
{:pre [(simple-symbol? ns-symb)]}
;; treat something like `metabase.driver-test` (for a module that hasn't fully been updated to use `.core`
@@ -27,6 +28,9 @@
(or (some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
second
(symbol "enterprise"))
(some->> (re-find #"^metabase\.flarg\.([^.]+)" (str ns-symb))
second
(symbol "flarg"))
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
second
symbol))))

View File

@@ -0,0 +1,52 @@
(ns hooks.metabase.flargs.core
(:require
[clj-kondo.hooks-api :as hooks]
[hooks.common]))
(defn defflarg
"Hook for [[metabase.flargs.core/defflarg]]. The macro shape is:
(defflarg fn-name docstring flarg-key flarg-ns arglist & body)
We rewrite to:
(let [_flarg-key flarg-key]
(defn fn-name docstring arglist & body))
so that kondo:
- sees a function definition with the given name, docstring, arglist, and body;
- treats `flarg-key` as a referenced value (no unused warnings);
- performs normal arity checking at call sites based on `arglist`.
`flarg-ns` is dropped from the expansion entirely because it is a bare namespace
symbol -- not something kondo can resolve as a var -- and appearing in the output
would produce a spurious unresolved-symbol error. This mirrors how
[[hooks.metabase.premium-features.defenterprise/defenterprise]] discards its
enterprise-namespace positional arg."
[{node :node}]
(let [[_defflarg fn-name docstring flarg-key _flarg-ns & fn-tail] (:children node)]
{:node (hooks/list-node
(list
(hooks/token-node 'let)
(hooks/vector-node
[(hooks/token-node '_flarg-key) flarg-key])
(-> (hooks/list-node
(concat
(filter some?
(list (hooks/token-node 'defn)
fn-name
docstring))
fn-tail))
(with-meta (meta node))
hooks.common/add-lsp-ignore-unused-public-var-metadata)))}))
(comment
;; Dev helper: pass a string form through the hook to see what it rewrites to.
(defn- defflarg* [form-str]
(hooks/sexpr
(:node
(defflarg {:node (hooks/parse-string form-str)}))))
(defflarg*
"(defflarg test-fn \"Default: returns :default.\" :flarg/test-flarg metabase.flarg.test-flarg.core [] :default)"))

View File

@@ -122,7 +122,13 @@ jobs:
- name: "Test Java ${{ matrix.java-version }} ${{ matrix.job.name }}"
id: run-java-tests
# `bin/with-flargs` reads `.release-flags` (if present) and propagates it into the command
# as RF_* env vars + `-A:flarg/*` alias args. On master the file is absent (enforced by the
# merge-gate test in `metabase.flargs.release-flags-gate-test`) so this is a pass-through;
# on a PR branch with `.release-flags` committed, the flargs it names are activated for
# this test run. See FLARG.md § How `.release-flags` interacts with CI.
run: >-
bin/with-flargs
clojure -X:dev:ci:test:${{ matrix.job.edition }}:${{ matrix.job.edition }}-dev
:exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
${{ matrix.job.test-args }}

View File

@@ -43,4 +43,4 @@ clean_output() {
| sed $'s/\033[\[(][0-9;]*[A-Za-z]//g; s/\033[^[]*//g'
}
exec clojure -X"${ALIASES}" "${args[@]}" 2>&1 | clean_output
exec "$(dirname "$0")/with-flargs" clojure -X"${ALIASES}" "${args[@]}" 2>&1 | clean_output

99
bin/with-flargs Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Wrap a command with flarg activation from the repo-root `.release-flags` file.
#
# The `.release-flags` file is the source of truth for which flargs are enabled for a build.
# Its presence on master is blocked by a merge-gate test; it lives transiently on PR branches
# during feature development. See FLARG.md § Configuration.
#
# What this wrapper does:
#
# 1. If `.release-flags` is absent, exec the passed command unmodified (the master-clean path).
# 2. If present, parse it as an EDN set of `:flarg/<name>` keywords.
# 3. For each keyword:
# - export RF_<UPPERCASE_UNDERSCORE>=true (the runtime activation env var read by
# `metabase.flargs.init/activate!`)
# - append `-A:flarg/<name>` to the command-line args (inserted after the first
# `clojure` / `clj` token so the alias joins the rest of the alias chain)
# 4. exec the modified command.
#
# The RF_* env var name and the `:flarg/<name>` alias are the INVERSE of the translation in
# `metabase.flargs.init/env-key->flarg-keyword`:
#
# :flarg/joke-of-the-day <-> RF_JOKE_OF_THE_DAY <-> -A:flarg/joke-of-the-day
#
# Usage:
#
# bin/with-flargs clojure -X:dev:ee:ee-dev:test ...
# bin/with-flargs ./bin/test-agent :only '[metabase.foo-test]'
# bin/with-flargs env # debug: see which RF_* vars would be exported
#
# If `.release-flags` contains `#{:flarg/joke-of-the-day}`, the first example becomes
# equivalent to:
#
# RF_JOKE_OF_THE_DAY=true clojure -X:dev:ee:ee-dev:test:flarg/joke-of-the-day ...
#
# macOS bash 3.2-compatible (no mapfile, no read -d, no associative arrays).
set -euo pipefail
if [[ $# -eq 0 ]]; then
echo "Usage: bin/with-flargs <command> [args...]" >&2
echo " Reads .release-flags (if present) and propagates it to the command as RF_* env vars" >&2
echo " and -A:flarg/* aliases. If the file is absent, passes the command through unchanged." >&2
exit 2
fi
RELEASE_FLAGS_FILE=".release-flags"
if [[ ! -f "$RELEASE_FLAGS_FILE" ]]; then
# No flargs configured - pass through untouched. This is the master-clean path.
exec "$@"
fi
# Extract :flarg/<name> keywords from the EDN set. The file is whitespace + comments + a single
# `#{ ... }` literal; we don't parse EDN here, we just pluck keyword forms. Comments (`;` to
# end-of-line) are stripped first. `grep -oE` extracts each match; we then trim the `:flarg/`
# prefix and dedupe via `sort -u`. `|| true` keeps us alive when the file has no matches (an
# empty `#{}` set is valid) — grep exits non-zero on no match under `set -e`.
FLARG_NAMES=$(
sed 's/;.*$//' "$RELEASE_FLAGS_FILE" \
| { grep -oE ':flarg/[A-Za-z0-9_-]+' || true; } \
| sed 's|^:flarg/||' \
| sort -u
)
if [[ -z "$FLARG_NAMES" ]]; then
# File exists but declares no flargs (e.g. `#{}` or just comments). Still a pass-through.
exec "$@"
fi
# Build the alias chain `:flarg/a:flarg/b:...` (no leading colon — it follows the `-A` flag).
# Export RF_<UPPERCASE_UNDERSCORE>=true for each. `:flarg/foo-bar` -> `RF_FOO_BAR`.
ALIAS_ARG=""
while IFS= read -r name; do
[[ -z "$name" ]] && continue
env_name="RF_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')"
export "$env_name=true"
ALIAS_ARG="${ALIAS_ARG}:flarg/${name}"
done <<< "$FLARG_NAMES"
# Inject `-A<alias-chain>` right after the first `clojure` / `clj` token in argv. For any other
# command (e.g. `bin/test-agent`, `env`), we don't inject — scripts that compose their own
# clojure invocation internally should pick up the RF_* env vars and handle aliases themselves
# (e.g. `bin/test-agent` appends its own alias chain).
#
# Detection rule: the first arg whose basename is `clojure` or `clj`.
INJECTED=0
REWRITTEN=()
for arg in "$@"; do
REWRITTEN+=("$arg")
if [[ "$INJECTED" -eq 0 ]]; then
base=$(basename -- "$arg")
if [[ "$base" == "clojure" || "$base" == "clj" ]]; then
REWRITTEN+=("-A${ALIAS_ARG}")
INJECTED=1
fi
fi
done
exec "${REWRITTEN[@]}"

View File

@@ -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=="],

View File

@@ -60,7 +60,7 @@
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
{:mvn/version "1.0.1"} ; Snowplow analytics
com.sun.mail/jakarta.mail ^:antq/exclude ; 2+ changes imports from javax.mail to jakarta.mail
{:mvn/version "1.6.8"} ; SMTP lib, required by postal
{:mvn/version "1.6.8"} ; SMTP lib, required by postal
com.taoensso/encore {:mvn/version "3.158.0"} ; Utilities library (used by Nippy)
com.taoensso/nippy {:mvn/version "3.6.0"} ; Fast serialization (i.e., GZIP) library for Clojure
com.vladsch.flexmark/flexmark {:mvn/version "0.64.8"} ; Markdown parsing
@@ -422,6 +422,21 @@
:oss-dev
{}
;; ---------------------------------------- flarg aliases ----------------------------------------
;; See FLARG.md. Each :flarg/<name> alias adds exactly one flarg's paths to the classpath. Paths
;; are entirely disjoint from the base classpath - a flarg never shadows main-tree code. Off by
;; default; enable per-build via deps alias (local) or the .release-flags file (CI/PR Envs).
;;
:flarg/joke-of-the-day
{:extra-paths ["release-flags/joke-of-the-day/src"
"release-flags/joke-of-the-day/test"]}
;; :flarg/test-flarg is the test flarg used by metabase.flargs.integration-test to prove the
;; defflarg machinery works under real classpath isolation. It stays in master permanently as
;; the load-bearing end-to-end test of the design.
:flarg/test-flarg
{:extra-paths ["release-flags/test-flarg/src"]}
;; Generate BE documentation with
;; clojure -M:marginalia
:marginalia

View File

@@ -43,20 +43,34 @@
(for [file (.listFiles (io/file (str (.getAbsolutePath (project-root-directory)) "/modules/drivers")))]
(io/file file "src")))
(mu/defn- release-flags-source-roots :- [:sequential (ms/InstanceOfClass java.io.File)]
[]
(let [root (io/file (str (.getAbsolutePath (project-root-directory)) "/release-flags"))]
(if (.exists root)
(for [file (.listFiles root)]
(io/file file "src"))
[])))
(mu/defn- find-source-files :- [:sequential (ms/InstanceOfClass java.io.File)]
[]
(mapcat ns.find/find-sources-in-dir
(list* (source-root) (enterprise-source-root) (drivers-source-roots))))
(concat [(source-root) (enterprise-source-root)]
(drivers-source-roots)
(release-flags-source-roots))))
(mu/defn- module :- [:maybe symbol?]
"E.g.
(module 'metabase.qp.middleware.wow) => 'qp
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
(module 'metabase.qp.middleware.wow) => 'qp
(module 'metabase-enterprise.whatever.core) => 'enterprise/whatever
(module 'metabase.flarg.joke-of-the-day.api) => 'flarg/joke-of-the-day"
[ns-symb :- simple-symbol?]
(or (some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
second
(symbol "enterprise"))
(some->> (re-find #"^metabase\.flarg\.([^.]+)" (str ns-symb))
second
(symbol "flarg"))
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
second
symbol)))

View 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,
});

View File

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

View 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);
});
});
});

View 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;

View File

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

View 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>
);
}

View File

@@ -0,0 +1 @@
export * from "./JokeOfTheDay";

View 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;

View File

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

View File

@@ -0,0 +1 @@
declare module "iframe-resizer/js/iframeResizer.contentWindow.js";

View File

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

View File

@@ -0,0 +1,15 @@
(ns metabase.flarg.joke-of-the-day.api
"/api/joke-of-the-day endpoint. Only on the classpath when the `:flarg/joke-of-the-day` alias is
active — classpath presence is the gate, so no runtime release-flag check is needed here."
(:require
[metabase.api.macros :as api.macros]
[metabase.flarg.joke-of-the-day.jokes :as jokes]))
(api.macros/defendpoint :get "/" :- [:map
["id" :int]
["type" :string]
["setup" :string]
["punchline" :string]]
"Return the joke of the day."
[]
(rand-nth (jokes/jokes)))

View File

@@ -0,0 +1,6 @@
(ns metabase.flarg.joke-of-the-day.init
"Loads the joke-of-the-day flarg's namespaces so its defflarg impls register and any
side-effecting forms fire."
(:require
[metabase.flarg.joke-of-the-day.api]
[metabase.flarg.joke-of-the-day.routes]))

View File

@@ -0,0 +1,9 @@
(ns metabase.flarg.joke-of-the-day.jokes
(:require
[clojure.java.io :as io]
[metabase.util.json :as json]))
(defn jokes
"Returns the collection of jokes from jokes.json."
[]
(json/decode (slurp (io/resource "jokes.json"))))

View File

@@ -0,0 +1,14 @@
(ns metabase.flarg.joke-of-the-day.routes
"Flarg-side impl of the [[metabase.api-routes.routes/joke-of-the-day-routes]] seam. Only on the
classpath when the `:flarg/joke-of-the-day` alias is active. Registering this impl causes the
main-side [[metabase.api-routes.routes/joke-of-the-day-routes]] dispatcher to return the route
map entry that mounts `metabase.flarg.joke-of-the-day.api` under `/joke-of-the-day`."
(:require
[metabase.flargs.core :as flargs]))
(flargs/defflarg joke-of-the-day-routes
"Route contribution for the joke-of-the-day flarg."
:flarg/joke-of-the-day
metabase.flarg.joke-of-the-day.routes
[]
{"/joke-of-the-day" 'metabase.flarg.joke-of-the-day.api})

View File

@@ -0,0 +1,24 @@
(ns metabase.flarg.joke-of-the-day.api-test
(:require
[clojure.test :refer :all]
[metabase.flarg.joke-of-the-day.jokes :as jokes]
[metabase.test.http-client :as client]))
(defn- joke-set
"The jokes from jokes.json, with keyword keys so they compare equal to a parsed HTTP response."
[]
(set (map #(update-keys % keyword) (jokes/jokes))))
(deftest ^:parallel joke-of-the-day-endpoint-test
(testing "GET /api/joke-of-the-day"
;; The expected status of 200 covers both happy-path success and the no-auth
;; requirement: [[client/client]] sends no credentials, so a 200 response proves
;; the endpoint doesn't sit behind [[metabase.api.routes.common/+auth]].
(let [response (client/client :get 200 "joke-of-the-day")]
(testing "response matches the endpoint's schema"
(is (int? (:id response)))
(is (string? (:type response)))
(is (string? (:setup response)))
(is (string? (:punchline response))))
(testing "returned joke is one of the jokes in jokes.json"
(is (contains? (joke-set) response))))))

View File

@@ -0,0 +1,16 @@
(ns metabase.flarg.joke-of-the-day.jokes-test
(:require
[clojure.test :refer :all]
[metabase.flarg.joke-of-the-day.jokes :as jokes]))
(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")))))

View File

@@ -0,0 +1,17 @@
(ns metabase.flarg.test-flarg.core
"Flarg-side impl of the [[metabase.flargs.integration-seam/test-fn]] seam. Only on the classpath
when the `:flarg/test-flarg` alias is active.
Registering its impl (via the `defflarg` form below) is what proves the end-to-end machinery
works: classpath presence of this ns causes the seam's dispatcher to route to `:impl` instead of
`:default`."
(:require
[metabase.flargs.core :as flargs]))
(flargs/defflarg test-fn
"Flarg-side impl: returns `:impl`. Verifies that when the flarg ns is on the classpath, the
dispatcher routes calls to the flarg impl rather than the main-side default."
:flarg/test-flarg
metabase.flarg.test-flarg.core
[]
:impl)

View File

@@ -0,0 +1,6 @@
(ns metabase.flarg.test-flarg.init
"Loads the test-flarg's source so its `defflarg` impls register. Phase 1.6 will wire up a startup
hook that requires this ns; for now it just establishes the convention that every flarg has an
`init` ns responsible for triggering registration."
(:require
[metabase.flarg.test-flarg.core]))

62
resources/jokes.json Normal file
View 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"
}
]

View File

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

View File

@@ -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),
}),
],
};

View File

@@ -25,6 +25,7 @@
[metabase.documents.api]
[metabase.eid-translation.api]
[metabase.embedding-rest.api]
[metabase.flargs.core :as flargs]
[metabase.frontend-errors.api]
[metabase.geojson.api]
[metabase.glossary.api]
@@ -151,8 +152,15 @@
;;; !! Please read https://metaboat.slack.com/archives/CKZEMT1MJ/p1738972144181069 for more info !!
;;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
(flargs/defflarg joke-of-the-day-routes
"Route map entries contributed by :flarg/joke-of-the-day. Empty when flarg is off."
:flarg/joke-of-the-day
metabase.flarg.joke-of-the-day.routes
[]
{})
;;; ↓↓↓ KEEP THIS SORTED OR ELSE! ↓↓↓
(def ^:private route-map
(def ^:private base-route-map
{"/action" (+auth 'metabase.actions-rest.api)
"/activity" (+auth 'metabase.activity-feed.api)
"/agent" metabase.agent-api.api/routes
@@ -225,6 +233,11 @@
"/util" 'metabase.api.util})
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑
(def ^:private route-map
"The full route map, composed from the base map plus any flarg-contributed entries."
(merge base-route-map
(joke-of-the-day-routes)))
;;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
;;; !! !!
;;; !! DO NOT ADD `metabase.api.*` NAMESPACES THAT CONTAIN ENDPOINTS !!

View File

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

View File

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

View File

@@ -30,6 +30,7 @@
[metabase.eid-translation.init]
[metabase.embedding.init]
[metabase.events.init]
[metabase.flargs.init :as flargs.init]
[metabase.geojson.init]
[metabase.indexed-entities.init]
[metabase.lib-be.init]
@@ -80,6 +81,21 @@
[metabase.warehouses.init]
[metabase.xrays.init]))
;; Activate flargs BEFORE any EE init code runs and before anything user-facing happens. By the
;; time control reaches this form, the module-level `:require`s above have already loaded their
;; side-effectful init namespaces (classloader, logger, drivers, multimethods, etc.), so the
;; shared classloader is ready and log output can be produced. Loading a flarg init ns causes its
;; `defflarg` forms to register impls against `metabase.flargs.core/registry`; registrations
;; purely update atoms, so later dispatcher calls will see them regardless of ordering between
;; siblings. The ONE ordering constraint we care about — impls registered before any seam fn is
;; invoked — is satisfied because `metabase.core.core/init!*` (which actually calls the
;; application startup flow) runs strictly AFTER this namespace has finished loading.
;;
;; If RF_* env vars are misconfigured (flarg requested but not on the classpath), `activate!`
;; throws — per FLARG-PROGRESS.md §Decisions (Approach A), that's the desired loud failure.
(when-not *compile-files*
(flargs.init/activate!))
;; load EE init code on system launch if it exists.
(when (and (not *compile-files*)
config/ee-available?)

View File

@@ -0,0 +1,118 @@
(ns metabase.flargs.core
"Definition of the `defflarg` macro, which enables writing functions with a default (main-side) implementation and an
optional override (flarg-side) implementation. Classpath-isolated in-progress features (\"flargs\") register their
impls from a `metabase.flarg.<flarg-name>.*` or `metabase-enterprise.flarg.<flarg-name>.*` namespace, and those
namespaces are only on the classpath when the corresponding flarg is enabled at build time.
The shape deliberately mirrors `metabase.premium-features.defenterprise` so anyone who already reads that fluently
reads `defflarg` fluently. The key differences:
- No `:feature` option and no token check. The gate is classpath presence alone.
- No `:fallback` option. If the flarg's namespace cannot be loaded, the default runs.
- \"Side\" detection uses the `metabase(-enterprise)?.flarg.` namespace prefix."
(:require
[clojure.string :as str]
[metabase.classloader.core :as classloader]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]))
(defonce
^{:doc "A map from fully-qualified flarg function names to maps containing the default and, when one has been
registered, impl implementations. Mirrors the shape of [[metabase.premium-features.defenterprise/registry]].
For example:
{flarg-ns/flarg-fn-name {:default default-fn
:impl impl-fn
:flarg :flarg/some-flarg}}"}
registry
(atom {}))
(defn register-mapping!
"Adds new values to the `registry` for `flarg-fn-name`, merging them onto any existing entry."
[flarg-fn-name values]
(swap! registry update flarg-fn-name merge values))
(defn dynamic-flarg-fn
"Returns a dispatcher fn that, on first invocation, lazily tries to load `flarg-ns` (so any `defflarg` impls in that
namespace register themselves against the `registry`). Subsequent calls skip the require. The dispatcher then looks
up `flarg-fn-name` in the `registry` and invokes the impl if one is registered, otherwise the default.
If the require fails (ns not on classpath because the flarg is off), the exception is swallowed and the default runs
from then on. This mirrors [[metabase.premium-features.defenterprise/dynamic-ee-oss-fn]]."
[flarg-ns flarg-fn-name]
(let [try-require-once (delay (u/ignore-exceptions (classloader/require flarg-ns)))]
(fn [& args]
@try-require-once
(let [{:keys [default impl]} (get @registry flarg-fn-name)]
(apply (or impl default) args)))))
(defn- in-flarg?
"Is the current namespace a flarg namespace? Flarg namespaces are prefixed with `metabase.flarg.` or
`metabase-enterprise.flarg.`."
[]
(let [nm (str (ns-name *ns*))]
(or (str/starts-with? nm "metabase.flarg.")
(str/starts-with? nm "metabase-enterprise.flarg."))))
(defn- docstr-exception
"The exception to throw when `defflarg` is used without a docstring."
[fn-name]
(Exception. (tru "Flarg function {0}/{1} does not have a docstring. Go add one!" (ns-name *ns*) fn-name)))
(defn- validate-flarg-key
"Throws if `flarg-key` is not a literal keyword whose namespace is \"flarg\" (e.g. `:flarg/workflows`)."
[flarg-key]
(when-not (and (keyword? flarg-key)
(= "flarg" (namespace flarg-key)))
(throw (ex-info (tru "defflarg requires a namespaced flarg keyword like :flarg/foo, got: {0}" (pr-str flarg-key))
{:flarg-key flarg-key}))))
(defn- validate-flarg-ns
"Throws if `flarg-ns` is not a symbol."
[flarg-ns]
(when-not (symbol? flarg-ns)
(throw (ex-info (tru "defflarg requires a symbol naming the flarg-side namespace, got: {0}" (pr-str flarg-ns))
{:flarg-ns flarg-ns}))))
(defn- validate-arglist
"Throws if `arglist` is not a vector."
[arglist]
(when-not (vector? arglist)
(throw (ex-info (tru "defflarg requires a vector arglist, got: {0}" (pr-str arglist))
{:arglist arglist}))))
(defmacro defflarg
"Defines a function that has a main-side default implementation and an optional flarg-side impl. Analogous to
[[metabase.premium-features.defenterprise/defenterprise]], but the gate is classpath presence of the flarg's
namespace rather than license token + EE edition.
Call shape:
(defflarg fn-name
\"docstring\"
:flarg/<flarg-name> ; literal keyword, namespace must be \"flarg\"
metabase.flarg.<name>.<sub> ; symbol naming the flarg-side namespace
[arg1 arg2]
body...) ; default body on the main side, impl body on the flarg side
The same `defflarg` form is written on BOTH the main side (e.g. `src/metabase/...`) AND the flarg side
(`release-flags/<flarg-name>/src/metabase/flarg/<flarg-name>/...`) with different bodies. At call time, if an impl is
registered for this fn, the impl runs; otherwise the default runs. The flarg-side namespace is lazily required on
first call; if it is not on the classpath, the require is silently ignored and the default runs."
{:arglists '([fn-name docstring flarg-key flarg-ns arglist & body])}
[fn-name docstring flarg-key flarg-ns arglist & body]
{:pre [(symbol? fn-name)]}
(when-not (string? docstring) (throw (docstr-exception fn-name)))
(validate-flarg-key flarg-key)
(validate-flarg-ns flarg-ns)
(validate-arglist arglist)
(let [main-or-flarg (if (in-flarg?) :impl :default)]
`(let [flarg-ns# '~flarg-ns
flarg-fn-name# (symbol (str flarg-ns# "/" '~fn-name))
body-fn# (fn ~(symbol (str fn-name)) ~arglist ~@body)]
(register-mapping! flarg-fn-name# {~main-or-flarg body-fn#
:flarg ~flarg-key})
(def
~(vary-meta fn-name merge {:arglists ''([& args])})
~docstring
(dynamic-flarg-fn flarg-ns# flarg-fn-name#)))))

View File

@@ -0,0 +1,93 @@
(ns metabase.flargs.init
"Startup wiring that converts `RF_*` environment variables into loaded flarg-side `init`
namespaces. Triggers the flarg-side `defflarg` forms to register their impls against the
registry so the dispatcher in `metabase.flargs.core` can route to them.
Call shape: `(activate!)` at JVM startup reads the env, translates each truthy `RF_*` var to a
`:flarg/<name>` keyword, and requires the corresponding `metabase.flarg.<name>.init` namespace.
Any require failure throws immediately (Approach A per FLARG-PROGRESS.md §Decisions) — a
misconfiguration where a flarg is requested via env var but its code isn't on the classpath
must fail loud.
Env-var convention matches Ryan's already-shipped Liquibase context gating
([[metabase.app-db.liquibase/enabled-rf-contexts]]): `RF_FOO_BAR=true` activates the flarg named
`:flarg/foo-bar`. Truthy means literally `\"true\"` (case-insensitive, trimmed) — nothing else."
(:require
[clojure.string :as str]
[metabase.classloader.core :as classloader]
[metabase.util :as u]))
(set! *warn-on-reflection* true)
(def ^:private rf-prefix "RF_")
(defn- truthy?
"Matches the convention used by [[metabase.app-db.liquibase/enabled-rf-contexts]]: a value is
truthy iff it equals `\"true\"` after trimming and lowercasing. Anything else — including
`\"false\"`, `\"0\"`, `\"\"`, or `nil` — is falsy."
[v]
(and (some? v)
(= "true" (u/lower-case-en (str/trim v)))))
(defn- env-key->flarg-keyword
"Translates `RF_FOO_BAR` → `:flarg/foo-bar`. Caller must have already verified the key starts
with `RF_`."
[env-key]
(-> env-key
(subs (count rf-prefix))
u/lower-case-en
(str/replace "_" "-")
(->> (keyword "flarg"))))
(defn enabled-flargs
"Returns the set of `:flarg/<name>` keywords for every truthy `RF_*` entry in `env`. An empty
set means no flargs are enabled. Non-`RF_` entries are ignored.
With no arg, reads from the real process environment via `System/getenv`. Tests should pass an
explicit env map to avoid mutating process env."
([]
(enabled-flargs (System/getenv)))
([env]
(into #{}
(keep (fn [[k v]]
(when (and (str/starts-with? k rf-prefix)
(truthy? v))
(env-key->flarg-keyword k))))
env)))
(defn- flarg-keyword->init-ns
"Translates `:flarg/foo-bar` → the symbol `metabase.flarg.foo-bar.init`."
[flarg-kw]
(symbol (str "metabase.flarg." (name flarg-kw) ".init")))
(defn require-flarg-inits!
"For each flarg keyword in `flargs`, requires its `metabase.flarg.<name>.init` namespace via
[[metabase.classloader.core/require]]. Iteration order is whatever the input set yields (no
stability guarantee — order between flargs must not matter).
On the first require failure, throws `ex-info` with a remediation hint pointing at the
`-A:flarg/<name>` alias. Remaining flargs are NOT attempted."
[flargs]
(doseq [flarg-kw flargs]
(let [init-ns (flarg-keyword->init-ns flarg-kw)]
(try
(classloader/require init-ns)
(catch Throwable e
(throw (ex-info (str "Failed to load flarg init namespace " init-ns
" for " flarg-kw ". "
"Did you include `-A:" (namespace flarg-kw) "/" (name flarg-kw)
"` in the JVM launch?")
{:flarg flarg-kw
:init-ns init-ns}
e)))))))
(defn activate!
"Reads the process env for `RF_*` flags and requires the corresponding flarg init namespaces so
their `defflarg` impls register. Returns the set of activated flarg keywords (possibly empty)
for logging and debuggability.
This is the single entry point called from `metabase.core.init`."
[]
(let [flargs (enabled-flargs)]
(require-flarg-inits! flargs)
flargs))

View File

@@ -0,0 +1,209 @@
(ns metabase.flargs.core-test
(:require
[clojure.test :refer :all]
[metabase.classloader.core :as classloader]
[metabase.flargs.core :as flargs]))
;;; -------------------------------------------------- 1.1 Registry --------------------------------------------------
(deftest register-mapping!-sets-value-test
(testing "register-mapping! associates values with a function name"
(let [registry (atom {})]
(with-redefs [flargs/registry registry]
(flargs/register-mapping! 'some.ns/foo {:default :a-default})
(is (= {'some.ns/foo {:default :a-default}} @registry))))))
(deftest register-mapping!-merges-test
(testing "A second call to register-mapping! merges new keys into the existing map"
(let [registry (atom {})]
(with-redefs [flargs/registry registry]
(flargs/register-mapping! 'some.ns/foo {:default :a-default})
(flargs/register-mapping! 'some.ns/foo {:impl :an-impl :flarg :flarg/name})
(is (= {'some.ns/foo {:default :a-default
:impl :an-impl
:flarg :flarg/name}}
@registry))))))
(deftest register-mapping!-isolates-keys-test
(testing "Different function names are stored as separate entries"
(let [registry (atom {})]
(with-redefs [flargs/registry registry]
(flargs/register-mapping! 'some.ns/foo {:default :foo-default})
(flargs/register-mapping! 'some.ns/bar {:default :bar-default})
(is (= {'some.ns/foo {:default :foo-default}
'some.ns/bar {:default :bar-default}}
@registry))))))
;;; ------------------------------------------------- 1.2 Dispatcher -------------------------------------------------
(deftest dispatcher-only-default-test
(testing "With only a default registered, the dispatcher calls the default"
(let [registry (atom {})]
(with-redefs [flargs/registry registry]
(flargs/register-mapping! 'some.ns/only-default
{:default (fn [x] [:default x])})
(let [f (flargs/dynamic-flarg-fn 'some.ns 'some.ns/only-default)]
(is (= [:default 42] (f 42))))))))
(deftest dispatcher-with-impl-test
(testing "With an impl registered, the dispatcher calls the impl"
(let [registry (atom {})]
(with-redefs [flargs/registry registry]
(flargs/register-mapping! 'some.ns/with-impl
{:default (fn [x] [:default x])
:impl (fn [x] [:impl x])})
(let [f (flargs/dynamic-flarg-fn 'some.ns 'some.ns/with-impl)]
(is (= [:impl 42] (f 42))))))))
(deftest dispatcher-preserves-varargs-test
(testing "The dispatcher forwards all args (including varargs) to the selected fn"
(let [registry (atom {})]
(with-redefs [flargs/registry registry]
(flargs/register-mapping! 'some.ns/varargs-default
{:default (fn [& args] [:default args])})
(flargs/register-mapping! 'some.ns/varargs-impl
{:default (fn [& _] :ignored)
:impl (fn [& args] [:impl args])})
(let [f-default (flargs/dynamic-flarg-fn 'some.ns 'some.ns/varargs-default)
f-impl (flargs/dynamic-flarg-fn 'some.ns 'some.ns/varargs-impl)]
(is (= [:default '(1 2 3)] (f-default 1 2 3)))
(is (= [:impl '(:a :b)] (f-impl :a :b))))))))
(deftest dispatcher-preserves-return-value-test
(testing "The dispatcher returns whatever the underlying fn returns (including nil)"
(let [registry (atom {})]
(with-redefs [flargs/registry registry]
(flargs/register-mapping! 'some.ns/returns-nil
{:default (fn [_] nil)})
(flargs/register-mapping! 'some.ns/returns-map
{:default (fn [_] :ignored)
:impl (fn [_] {:ok true})})
(let [f-nil (flargs/dynamic-flarg-fn 'some.ns 'some.ns/returns-nil)
f-map (flargs/dynamic-flarg-fn 'some.ns 'some.ns/returns-map)]
(is (nil? (f-nil :anything)))
(is (= {:ok true} (f-map :anything))))))))
;;; ------------------------------------------------ 1.3 Lazy require ------------------------------------------------
(deftest lazy-require-called-exactly-once-test
(testing "classloader/require is called exactly once across many invocations"
(let [registry (atom {})
call-count (atom 0)]
(with-redefs [flargs/registry registry
classloader/require (fn [& _] (swap! call-count inc))]
(flargs/register-mapping! 'some.ns/lazy {:default (fn [x] x)})
(let [f (flargs/dynamic-flarg-fn 'some.ns 'some.ns/lazy)]
(dotimes [_ 5] (f :x))
(is (= 1 @call-count)))))))
(deftest lazy-require-failure-uses-default-test
(testing "If classloader/require throws, the default is still used (exception is swallowed)"
(let [registry (atom {})]
(with-redefs [flargs/registry registry
classloader/require (fn [& _] (throw (ex-info "boom" {})))]
(flargs/register-mapping! 'some.ns/boom {:default (fn [x] [:default x])})
(let [f (flargs/dynamic-flarg-fn 'some.ns 'some.ns/boom)]
(is (= [:default :ok] (f :ok))))))))
;;; -------------------------------------------------- 1.4 Macro -----------------------------------------------------
(deftest ^:parallel macro-requires-docstring-test
(testing "defflarg without a docstring throws a compile-time exception"
(is (thrown? Exception
(macroexpand
'(metabase.flargs.core/defflarg foo
:flarg/foo
metabase.flarg.foo.core
[x]
x))))))
(deftest ^:parallel macro-requires-literal-keyword-flarg-test
(testing "defflarg requires a literal keyword whose namespace is 'flarg'"
(is (thrown-with-msg? Exception #"(?i)flarg"
(macroexpand
'(metabase.flargs.core/defflarg foo
"docstring"
:foo ; not namespaced
metabase.flarg.foo.core
[x]
x))))
(is (thrown-with-msg? Exception #"(?i)flarg"
(macroexpand
'(metabase.flargs.core/defflarg foo
"docstring"
:other/foo ; wrong namespace
metabase.flarg.foo.core
[x]
x))))
(is (thrown? Exception
(macroexpand
'(metabase.flargs.core/defflarg foo
"docstring"
"not-a-keyword"
metabase.flarg.foo.core
[x]
x))))))
(deftest ^:parallel macro-requires-symbol-flarg-ns-test
(testing "defflarg requires the flarg-ns argument to be a symbol"
(is (thrown? Exception
(macroexpand
'(metabase.flargs.core/defflarg foo
"docstring"
:flarg/foo
"not-a-symbol"
[x]
x))))))
(deftest ^:parallel macro-requires-vector-arglist-test
(testing "defflarg requires the arglist to be a vector"
(is (thrown? Exception
(macroexpand
'(metabase.flargs.core/defflarg foo
"docstring"
:flarg/foo
metabase.flarg.foo.core
(x) ; a list, not a vector
x))))))
;;; ---------------------------------------------- 1.5 Integration ---------------------------------------------------
(deftest in-process-integration-test
(testing "A defflarg whose flarg namespace exists uses the impl; one whose flarg namespace does not exist uses the default"
;; Reset the registry for a clean slate. Use a fresh atom so we don't stomp any shared state.
(let [registry (atom {})]
(with-redefs [flargs/registry registry]
;; "Main side" ns: not prefixed with metabase.flarg. or metabase-enterprise.flarg.
(let [main-ns (create-ns 'metabase.flargs.core-test.fake-main)]
(binding [*ns* main-ns]
(refer-clojure)
(require '[metabase.flargs.core :as flargs])
(eval '(flargs/defflarg impl-will-load
"Will be overridden by the flarg-side impl."
:flarg/integration-test
metabase.flarg.integration-test.impl
[x]
[:default x]))
(eval '(flargs/defflarg impl-missing
"Flarg ns does not exist, so default wins."
:flarg/missing
metabase.flarg.integration-test.nope.does.not.exist
[x]
[:default-only x]))))
;; "Flarg side" ns: prefixed with metabase.flarg.
(let [flarg-ns (create-ns 'metabase.flarg.integration-test.impl)]
(binding [*ns* flarg-ns]
(refer-clojure)
(require '[metabase.flargs.core :as flargs])
(eval '(flargs/defflarg impl-will-load
"Impl used when flarg is on."
:flarg/integration-test
metabase.flarg.integration-test.impl
[x]
[:impl x]))))
(is (= [:impl :hello]
((resolve 'metabase.flargs.core-test.fake-main/impl-will-load) :hello))
"When a matching flarg-side impl is registered, it runs instead of the default")
(is (= [:default-only :hello]
((resolve 'metabase.flargs.core-test.fake-main/impl-missing) :hello))
"When the flarg ns cannot be loaded, the default runs")))))

View File

@@ -0,0 +1,158 @@
(ns metabase.flargs.init-test
"Unit tests for the env-var → flarg activation wiring. These tests mock the environment and the
classloader require side so they don't touch the real process env or the real classpath."
(:require
[clojure.test :refer :all]
[metabase.classloader.core :as classloader]
[metabase.flargs.init :as flargs.init]))
;;; ----------------------------------------- enabled-flargs (pure) -----------------------------------------
(deftest ^:parallel enabled-flargs-empty-env-test
(testing "With no env vars at all, returns an empty set"
(is (= #{} (flargs.init/enabled-flargs {})))))
(deftest ^:parallel enabled-flargs-no-rf-vars-test
(testing "Non-RF_ env vars are ignored"
(is (= #{} (flargs.init/enabled-flargs {"PATH" "/usr/bin"
"HOME" "/home/me"
"METABASE_FOO" "true"
"RFOO" "true" ; missing underscore
"rf_lower" "true"}))))) ; lowercase rf_
(deftest ^:parallel enabled-flargs-single-truthy-test
(testing "A single truthy RF_ var produces a single :flarg/* keyword"
(is (= #{:flarg/foo} (flargs.init/enabled-flargs {"RF_FOO" "true"})))))
(deftest ^:parallel enabled-flargs-multi-segment-name-test
(testing "Underscores in the flarg name become dashes in the keyword"
(is (= #{:flarg/foo-bar-baz}
(flargs.init/enabled-flargs {"RF_FOO_BAR_BAZ" "true"})))))
(deftest ^:parallel enabled-flargs-truthy-values-test
(testing "Case-insensitive, whitespace-trimmed 'true' is truthy; everything else is falsy"
(are [v enabled?] (= (if enabled? #{:flarg/x} #{})
(flargs.init/enabled-flargs {"RF_X" v}))
"true" true
"TRUE" true
"True" true
" true " true
"false" false
"False" false
"0" false
"1" false ; only the literal string "true" counts
"yes" false
"" false
" " false)))
(deftest ^:parallel enabled-flargs-nil-value-test
(testing "A nil value is not truthy"
(is (= #{} (flargs.init/enabled-flargs {"RF_X" nil})))))
(deftest ^:parallel enabled-flargs-mixed-test
(testing "A mix of truthy and falsy RF_ vars plus noise yields only the truthy ones"
(is (= #{:flarg/a :flarg/c}
(flargs.init/enabled-flargs {"RF_A" "true"
"RF_B" "false"
"RF_C" "TRUE"
"PATH" "/usr/bin"
"METABASE_FOO" "true"})))))
;;; ------------------------------------- require-flarg-inits! (mockable) -------------------------------------
;; These tests use `with-redefs` to mock `classloader/require`, which the Metabase hawk test
;; runner forbids under `^:parallel`. They're intentionally sequential.
(deftest require-flarg-inits-empty-test
(testing "With an empty set, classloader/require is not called"
(let [calls (atom [])]
(with-redefs [classloader/require (fn [& args] (swap! calls conj args))]
(flargs.init/require-flarg-inits! #{})
(is (= [] @calls))))))
(deftest require-flarg-inits-single-test
(testing "With one flarg, classloader/require is called once with the right init symbol"
(let [calls (atom [])]
(with-redefs [classloader/require (fn [& args] (swap! calls conj args))]
(flargs.init/require-flarg-inits! #{:flarg/my-flarg})
(is (= [['metabase.flarg.my-flarg.init]] @calls))))))
(deftest require-flarg-inits-multi-test
(testing "With multiple flargs, classloader/require is called once per flarg"
(let [calls (atom [])]
(with-redefs [classloader/require (fn [& args] (swap! calls conj args))]
(flargs.init/require-flarg-inits! #{:flarg/a :flarg/b :flarg/c})
(is (= #{['metabase.flarg.a.init]
['metabase.flarg.b.init]
['metabase.flarg.c.init]}
(set @calls)))
(is (= 3 (count @calls)))))))
(deftest require-flarg-inits-failure-throws-test
(testing "A require failure throws ex-info naming the flarg and init-ns with an alias hint"
(with-redefs [classloader/require (fn [& _] (throw (ex-info "boom" {})))]
(let [e (try (flargs.init/require-flarg-inits! #{:flarg/broken})
nil
(catch Exception e e))]
(is (some? e) "An exception is thrown")
(is (= :flarg/broken (:flarg (ex-data e))))
(is (= 'metabase.flarg.broken.init (:init-ns (ex-data e))))
(is (re-find #"metabase\.flarg\.broken\.init" (ex-message e)))
(is (re-find #":flarg/broken" (ex-message e)))
(is (re-find #"-A:flarg/broken" (ex-message e))
"The error message includes the remediation alias hint")))))
(deftest require-flarg-inits-first-failure-wins-test
(testing "When a require fails, subsequent flargs are NOT attempted"
(let [calls (atom [])
fail-on-2nd (let [n (atom 0)]
(fn [& args]
(swap! calls conj args)
(when (= 2 (swap! n inc))
(throw (ex-info "second call boom" {})))))]
(with-redefs [classloader/require fail-on-2nd]
(is (thrown? Exception
;; vector forces iteration order so we can assert which attempts happened
(flargs.init/require-flarg-inits! [:flarg/first :flarg/second :flarg/third])))
(is (= 2 (count @calls))
"Third flarg was not attempted after the second one failed")
(is (= [['metabase.flarg.first.init]
['metabase.flarg.second.init]]
@calls))))))
;;; ------------------------------------------ activate! (smoke) ------------------------------------------
(deftest activate!-orchestrates-and-returns-set-test
(testing "activate! consults enabled-flargs and requires each init, returning the activated set"
(let [calls (atom [])]
(with-redefs [flargs.init/enabled-flargs (fn
([] #{:flarg/one :flarg/two})
([_] #{:flarg/one :flarg/two}))
classloader/require (fn [& args] (swap! calls conj args))]
(is (= #{:flarg/one :flarg/two} (flargs.init/activate!)))
(is (= #{['metabase.flarg.one.init]
['metabase.flarg.two.init]}
(set @calls)))))))
(deftest activate!-empty-no-requires-test
(testing "When no flargs are enabled, activate! returns an empty set and requires nothing"
(let [calls (atom [])]
(with-redefs [flargs.init/enabled-flargs (fn
([] #{})
([_] #{}))
classloader/require (fn [& args] (swap! calls conj args))]
(is (= #{} (flargs.init/activate!)))
(is (= [] @calls))))))
;;; --------------------------- End-to-end (documented, runs via a separate command) ---------------------------
;; A real end-to-end check requires a fresh JVM with both the :flarg/test-flarg deps.edn alias AND
;; the RF_TEST_FLARG env var set so that metabase.flargs.init/activate! requires the
;; metabase.flarg.test-flarg.init namespace, which registers its impl against the flarg registry.
;;
;; Coordinator-runnable command (see metabase.flargs.integration-test for the cached-load rationale):
;;
;; RF_TEST_FLARG=true clojure -X:dev:ee:ee-dev:test:flarg/test-flarg \
;; :only '[metabase.flargs.integration-test]'
;;
;; This test namespace deliberately does not attempt it because it would mutate process env and
;; depend on classpath state outside the unit-test harness.

View File

@@ -0,0 +1,14 @@
(ns metabase.flargs.integration-seam
"Test-only main-side seam. Defines a [[defflarg]] whose default returns `:default`. The flarg-side
impl in `metabase.flarg.test-flarg.core` (on the classpath only when `:flarg/test-flarg` is
active) overrides this to return `:impl`. Used by [[metabase.flargs.integration-test]]."
(:require
[metabase.flargs.core :as flargs]))
(flargs/defflarg test-fn
"Default: returns `:default`. The flarg-side impl in `metabase.flarg.test-flarg.core` overrides
this to return `:impl` when the flarg is enabled."
:flarg/test-flarg
metabase.flarg.test-flarg.core
[]
:default)

View File

@@ -0,0 +1,41 @@
(ns metabase.flargs.integration-test
"Integration test for the `defflarg` machinery exercised under real classpath isolation.
This test is alias-aware: it inspects the classpath to determine whether the flarg's namespace is
loadable, then asserts the expected behavior for that condition. To exercise BOTH conditions, run
it twice:
- Without `:flarg/test-flarg` (default REPL/test config): asserts the `:default` branch runs.
- With `:flarg/test-flarg`: asserts the `:impl` branch runs.
Coordinator-verifiable command for the on-alias path:
clojure -X:dev:ee:ee-dev:test:flarg/test-flarg \\
:only '[metabase.flargs.integration-test]'
Because the lazy require in `metabase.flargs.core/dynamic-flarg-fn` caches its first result, any
impl registration must happen before the seam function is first invoked. The `flarg-loadable?`
probe below requires the flarg-side namespace (triggering the `defflarg` impl registration if
it's on the classpath) before any call to the seam. Calling the seam without that require first
would race the lazy-load and observe whatever state happens to be cached."
(:require
[clojure.test :refer :all]
[metabase.flargs.integration-seam :as seam]))
(defn- flarg-loadable?
"Returns true when the flarg-side namespace can be loaded on the current classpath. Loading also
triggers the flarg-side `defflarg` form to register its impl against the registry."
[]
(try
(require 'metabase.flarg.test-flarg.core)
true
(catch Exception _ false)))
(deftest defflarg-classpath-isolation-test
(let [loadable? (flarg-loadable?)]
(testing (str "When the flarg ns is "
(if loadable? "loadable" "NOT loadable")
", the dispatcher routes to the "
(if loadable? "impl" "default"))
(is (= (if loadable? :impl :default)
(seam/test-fn))))))

View File

@@ -0,0 +1,20 @@
(ns metabase.flargs.release-flags-gate-test
"Merge gate: the `.release-flags` file MUST be absent for a commit to be mergeable to master.
The file is the configuration source of truth for which flargs are enabled for a build. During
feature development on a PR branch, the file is committed so CI and PR Envs run with the flarg
enabled. Before merge, the developer deletes the file and CI re-runs with all flargs off to
prove the main tree is still clean.
See FLARG.md § How `.release-flags` interacts with CI and FLARG-PROGRESS.md § Phase 4."
(:require
[clojure.java.io :as io]
[clojure.test :refer :all]))
(set! *warn-on-reflection* true)
(deftest ^:parallel no-release-flags-file-on-master-test
(testing "The .release-flags file must be absent for a commit to be mergeable to master"
(is (not (.exists (io/file ".release-flags")))
(str "You committed .release-flags. Remove it before merging to master. "
"See FLARG.md § How `.release-flags` interacts with CI."))))

View File

@@ -371,8 +371,7 @@
(defn- test-var-with-context
"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
"
- adds some context to any logs emitted during the test, so that we have information on what test ran"
[v]
(let [test-n (-> v meta :name)
test-ns (-> v meta :ns str)]

View File

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