Skip to content
Snippets Groups Projects
Unverified Commit 1addcc19 authored by dpsutton's avatar dpsutton Committed by GitHub
Browse files

Controlled upgrades (#47877)


* Initial commit of controlled upgrades

- new setting upgrade-threshold (`MB_UPGRADE_THRESHOLD`)
  number 0-100
- conditionally removing latest from upgrade checks

AS OF RIGHT NOW IT ALWAYS REMOVES the latest.
Should help FE make this optional

* dumb mistake

* handle no latest version info

* implement and basic test

* More tests, refactor name

* more tests

* cljfmt does not like commas in source code.

* Just move site-uuid to the declaration and don't declare it

nothing is sacred in the settings order. no reason. i think i was just
trying to minimize the diff, which a dumb and ignoble goal.

* add context strings

* update unit tests

* use display version

* Have upgrade threshold include current major version

hopefully helps rotate people from early to late, and late to early
across major versions. Idea from sanya and quite nice!

---------

Co-authored-by: default avatarRyan Laurie <iethree@gmail.com>
parent 24235d2b
No related branches found
No related tags found
No related merge requests found
......@@ -8,7 +8,7 @@ import { Flex } from "metabase/ui";
import { SettingsSetting } from "../SettingsSetting";
import VersionUpdateNotice from "./VersionUpdateNotice/VersionUpdateNotice";
import { VersionUpdateNotice } from "./VersionUpdateNotice/VersionUpdateNotice";
export default function SettingsUpdatesForm({ elements, updateSetting }) {
const settings = elements.map((setting, index) => (
<SettingsSetting
......
......@@ -65,9 +65,11 @@ describe("SettingsUpdatesForm", () => {
).toBeInTheDocument();
});
it("shows correct message when no version checks have been run", () => {
setup({ currentVersion: null, latestVersion: null });
expect(screen.getByText("No successful checks yet.")).toBeInTheDocument();
it("shows current version when latest version info is missing", () => {
setup({ currentVersion: "v1.0.0", latestVersion: null });
expect(
screen.getByText(/You're running Metabase 1.0.0/),
).toBeInTheDocument();
});
it("shows upgrade call-to-action if not in Enterprise plan", () => {
......
import cx from "classnames";
import PropTypes from "prop-types";
import { t } from "ttag";
import { c, t } from "ttag";
import {
getCurrentVersion,
......@@ -13,13 +12,14 @@ import { useSelector } from "metabase/lib/redux";
import MetabaseSettings from "metabase/lib/settings";
import { newVersionAvailable, versionIsLatest } from "metabase/lib/utils";
import { getIsHosted } from "metabase/setup/selectors";
import type { VersionInfoRecord } from "metabase-types/api";
import {
NewVersionContainer,
OnLatestVersionMessage,
} from "./VersionUpdateNotice.styled";
export default function VersionUpdateNotice() {
export function VersionUpdateNotice() {
const currentVersion = useSelector(getCurrentVersion);
const latestVersion = useSelector(getLatestVersion);
const isHosted = useSelector(getIsHosted);
......@@ -37,11 +37,10 @@ export default function VersionUpdateNotice() {
if (newVersionAvailable({ currentVersion, latestVersion })) {
return <NewVersionAvailable currentVersion={displayVersion} />;
}
return <div>{t`No successful checks yet.`}</div>;
return <DefaultUpdateMessage currentVersion={displayVersion} />;
}
function CloudCustomers({ currentVersion }) {
function CloudCustomers({ currentVersion }: { currentVersion: string }) {
return (
<div>
{t`Metabase Cloud keeps your instance up-to-date. You're currently on version ${currentVersion}. Thanks for being a customer!`}
......@@ -49,25 +48,29 @@ function CloudCustomers({ currentVersion }) {
);
}
CloudCustomers.propTypes = {
currentVersion: PropTypes.string.isRequired,
};
function OnLatestVersion({ currentVersion }) {
function OnLatestVersion({ currentVersion }: { currentVersion: string }) {
return (
<div>
<OnLatestVersionMessage>
{t`You're running Metabase ${currentVersion} which is the latest and greatest!`}
{c(`{0} is a version number`)
.t`You're running Metabase ${currentVersion} which is the latest and greatest!`}
</OnLatestVersionMessage>
</div>
);
}
OnLatestVersion.propTypes = {
currentVersion: PropTypes.string.isRequired,
};
function DefaultUpdateMessage({ currentVersion }: { currentVersion: string }) {
return (
<div>
<OnLatestVersionMessage>
{c(`{0} is a version number`)
.t`You're running Metabase ${currentVersion}`}
</OnLatestVersionMessage>
</div>
);
}
function NewVersionAvailable({ currentVersion }) {
function NewVersionAvailable({ currentVersion }: { currentVersion: string }) {
const latestVersion = MetabaseSettings.latestVersion();
const versionInfo = MetabaseSettings.versionInfo();
......@@ -119,7 +122,7 @@ function NewVersionAvailable({ currentVersion }) {
>
<h3 className={cx(CS.pb3, CS.textUppercase)}>{t`What's Changed:`}</h3>
<Version version={versionInfo.latest} />
{versionInfo.latest && <Version version={versionInfo.latest} />}
{versionInfo.older &&
versionInfo.older.map((version, index) => (
......@@ -130,11 +133,7 @@ function NewVersionAvailable({ currentVersion }) {
);
}
NewVersionAvailable.propTypes = {
currentVersion: PropTypes.string.isRequired,
};
function Version({ version }) {
function Version({ version }: { version: VersionInfoRecord }) {
if (!version) {
return null;
}
......@@ -157,10 +156,6 @@ function Version({ version }) {
);
}
Version.propTypes = {
version: PropTypes.object.isRequired,
};
function formatVersion(versionLabel = "") {
return versionLabel.replace(/^v/, "");
}
......@@ -124,12 +124,18 @@
Looks something like `Metabase v0.25.0.RC1`."
(str "Metabase " (mb-version-info :tag)))
(defn major-version
"Detect major version from a version string.
ex: (major-version \"v1.50.25\") -> 50"
[version-string]
(some-> (second (re-find #"\d+\.(\d+)" version-string))
parse-long))
(defn current-major-version
"Returns the major version of the running Metabase JAR.
When the version.properties file is missing (e.g., running in local dev), returns nil."
[]
(some-> (second (re-find #"\d+\.(\d+)" (:tag mb-version-info)))
parse-long))
(major-version (:tag mb-version-info)))
(defonce ^{:doc "This UUID is randomly-generated upon launch and used to identify this specific Metabase instance during
this specifc run. Restarting the server will change this UUID, and each server in a horizontal cluster
......
......@@ -70,12 +70,62 @@
:audit :getter
:default true)
(defsetting site-uuid
;; Don't i18n this docstring because it's not user-facing! :)
"Unique identifier used for this instance of {0}. This is set once and only once the first time it is fetched via
its magic getter. Nice!"
:visibility :authenticated
:base setting/uuid-nonce-base
:doc false)
(defsetting upgrade-threshold
(deferred-tru "Threshold (value in 0-100) indicating at which treshold it should offer an upgrade to the latest major version.")
:visibility :internal
:export? false
:type :integer
:setter :none
:getter (fn []
;; site-uuid is stable, current-major lets the threshold randomize during each major revision. So they
;; might be early one release, and then later the next.
(-> (site-uuid) (str "-" (config/current-major-version)) hash (mod 100))))
(defn- prevent-upgrade?
"On a major upgrade, we check the rollout threshold to indicate whether we should remove the latest release from the
version info. This lets us stage upgrade notifications to self-hosted instances in a controlled manner. Defaults to
show the upgrade except under certain circumstances."
[current-major latest threshold]
(when (and (integer? current-major) (integer? threshold) (string? (:version latest)))
(try (let [upgrade-major (-> latest :version config/major-version)
rollout (some-> latest :rollout)]
(when (and upgrade-major rollout)
(cond
;; it's the same or a minor release
(= upgrade-major current-major) false
;; the rollout threshold is larger than our threshold
(>= rollout threshold) false
:else true)))
(catch Exception _e true))))
(defn- version-info*
[raw-version-info {:keys [current-major upgrade-threshold-value]}]
(try
(cond-> raw-version-info
(prevent-upgrade? current-major (-> raw-version-info :latest) upgrade-threshold-value)
(dissoc :latest))
(catch Exception e
(log/error e "Error processing version info")
raw-version-info)))
(defsetting version-info
(deferred-tru "Information about available versions of Metabase.")
:type :json
:audit :never
:default {}
:doc false)
:doc false
:getter (fn []
(let [raw-vi (setting/get-value-of-type :json :version-info)
current-major (config/current-major-version)]
(version-info* raw-vi {:current-major current-major :upgrade-threshold-value (upgrade-threshold)}))))
(defsetting version-info-last-checked
(deferred-tru "Indicates when Metabase last checked for new versions.")
......@@ -114,14 +164,6 @@
:visibility :public
:audit :getter)
(defsetting site-uuid
;; Don't i18n this docstring because it's not user-facing! :)
"Unique identifier used for this instance of {0}. This is set once and only once the first time it is fetched via
its magic getter. Nice!"
:visibility :authenticated
:base setting/uuid-nonce-base
:doc false)
(defsetting site-uuid-for-premium-features-token-checks
"In the interest of respecting everyone's privacy and keeping things as anonymous as possible we have a *different*
site-wide UUID that we use for the EE/premium features token feature check API calls. It works in fundamentally the
......
(ns ^:mb/once metabase.public-settings-test
(:require
[clojure.test :refer :all]
[metabase.config :as config]
[metabase.models.setting :as setting]
[metabase.public-settings :as public-settings]
[metabase.public-settings.premium-features :as premium-features]
......@@ -370,3 +371,69 @@
(public-settings/show-metabase-links! true)))
(is (= true (public-settings/show-metabase-links)))))))
(def prevent? #'public-settings/prevent-upgrade?)
(deftest upgrade-threshold-test
(testing "it is stable but changes across releases"
(letfn [(threshold [version]
(with-redefs [config/current-major-version (constantly version)]
(public-settings/upgrade-threshold)))]
;; asserting that across 10 versions we have at leaset 5 distinct values
(let [thresholds (into [] (map threshold) (range 50 60))]
;; kinda the same but very explicit: it's not the same value across versions
(is (> (count (distinct thresholds)) 1) "value should change between versions")
(is (< 5 (count (set thresholds))) "value should be decently random between versions")
(is (every? (fn [x] (and (integer? x) (<= 0 x 100))) thresholds) "should always be an integer between 0 and 100")))))
(deftest prevent-upgrade?-test
;; verify that the base value works
(is (prevent? 45 {:version "0.46" :rollout 50} 75) "base case that it does prevent when rollout is below threshold")
(testing "never throws and returns truthy"
(is (not (prevent? 45 {:version "0.46"} 75)) "missing rollout")
;; version is weird
(is (not (prevent? 45 {:version 45} 75)) "version not a version string")
;; misshape
(is (not (prevent? 45 {:latest {:version "0.46" :rollout 80}} 75)) "Wrong shape"))
(testing "Knows when to upgrade"
(let [threshold 25
above 50
below 15]
(is (not (prevent? 50 {:version "1.51.23.1" :rollout above} threshold)))
(is (prevent? 50 {:version "1.51.23.1" :rollout below} threshold))
(testing "when major is the same, threshold does not matter"
(is (not (prevent? 50 {:version "1.50.23.1" :rollout above} threshold)) "Same major")
(is (not (prevent? 50 {:version "1.50.23.1" :rollout below} threshold)) "Same major"))
(testing "when major is two versions below, follows normal behavior"
;; todo: should this offer the next major? ie on 49, 51 is at 10% rollout, should we offer 50 or not?
(is (not (prevent? 49 {:version "1.51.23.1" :rollout above} threshold)))
(is (prevent? 49 {:version "1.51.23.1" :rollout below} threshold))))))
(def info #'public-settings/version-info*)
(deftest version-info*-test
(let [version-info {:latest {:version "1.51.23.1" :rollout 50
:highlights ["highlights for 1.51.23.1"]}
:older [{:version "1.51.22" :highlights ["highlights for 1.51.22"]}
{:version "1.51.21" :highlights ["highlights for 1.51.21"]}]}]
(testing "When on same major, includes latest"
(is (= version-info (info version-info {:current-major 51 :upgrade-threshold-value 25}))))
(testing "When below major"
(testing "And below rollout threshold lacks latest"
(is (not (contains? (info version-info {:current-major 50 :upgrade-threshold-value 75}) :latest))))
(testing "And above rollout threshold includes latest"
(is (contains? (info version-info {:current-major 50 :upgrade-threshold-value 25}) :latest))))
(testing "if something feels off, just includes it by default"
(testing "missing rollout"
(let [modified (update version-info :latest dissoc :rollout)]
(is (= modified (info modified {:current-major 51 :upgrade-threshold-value 25})))))
(testing "version is weird"
(let [modified (update version-info :latest assoc :version "x01.51")]
(is (= modified (info modified {:current-major 51 :upgrade-threshold-value 25})))))
(testing "unknown current threshold"
(doseq [weird-value [nil "45" (Object.) 23.234 :keyword "string"]]
(is (= version-info (info version-info {:current-major 51 :upgrade-threshold-value weird-value})))))
(testing "rollout is a decimal"
(let [modified (update version-info :latest assoc :rollout 0.2)]
(is (= modified (info modified {:current-major 51 :upgrade-threshold-value 25}))))))))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment