diff --git a/apps/emqx_license/include/emqx_license.hrl b/apps/emqx_license/include/emqx_license.hrl index bfc1d2cfe..35aa62f5b 100644 --- a/apps/emqx_license/include/emqx_license.hrl +++ b/apps/emqx_license/include/emqx_license.hrl @@ -31,6 +31,7 @@ -define(SMALL_CUSTOMER, 0). -define(MEDIUM_CUSTOMER, 1). -define(LARGE_CUSTOMER, 2). +-define(BUSINESS_CRITICAL_CUSTOMER, 3). -define(EVALUATION_CUSTOMER, 10). -define(EXPIRED_DAY, -90). diff --git a/apps/emqx_license/src/emqx_license.erl b/apps/emqx_license/src/emqx_license.erl index 73c1cdf4e..dfb747a96 100644 --- a/apps/emqx_license/src/emqx_license.erl +++ b/apps/emqx_license/src/emqx_license.erl @@ -154,7 +154,16 @@ do_update({key, Content}, Conf) when is_binary(Content); is_list(Content) -> {error, Reason} -> erlang:throw(Reason) end; -do_update({setting, Setting}, Conf) -> +do_update({setting, Setting0}, Conf) -> + #{<<"key">> := Key} = Conf, + %% only allow updating dynamic_max_connections when it's BUSINESS_CRITICAL + Setting = + case emqx_license_parser:is_business_critical(Key) of + true -> + Setting0; + false -> + maps:without([<<"dynamic_max_connections">>], Setting0) + end, maps:merge(Conf, Setting); do_update(NewConf, _PrevConf) -> #{<<"key">> := NewKey} = NewConf, diff --git a/apps/emqx_license/src/emqx_license_checker.erl b/apps/emqx_license/src/emqx_license_checker.erl index 8270e03d2..5d8393037 100644 --- a/apps/emqx_license/src/emqx_license_checker.erl +++ b/apps/emqx_license/src/emqx_license_checker.erl @@ -33,7 +33,9 @@ expiry_epoch/0, purge/0, limits/0, - print_warnings/1 + print_warnings/1, + get_max_connections/1, + get_dynamic_max_connections/0 ]). %% gen_server callbacks @@ -46,21 +48,23 @@ -define(LICENSE_TAB, emqx_license). +-type limits() :: #{max_connections := non_neg_integer() | ?ERR_EXPIRED}. +-type license() :: emqx_license_parser:license(). +-type fetcher() :: fun(() -> {ok, license()} | {error, term()}). + %%------------------------------------------------------------------------------ %% API %%------------------------------------------------------------------------------ --type limits() :: #{max_connections := non_neg_integer() | ?ERR_EXPIRED}. - --spec start_link(emqx_license_parser:license()) -> {ok, pid()}. +-spec start_link(fetcher()) -> {ok, pid()}. start_link(LicenseFetcher) -> start_link(LicenseFetcher, ?CHECK_INTERVAL). --spec start_link(emqx_license_parser:license(), timeout()) -> {ok, pid()}. +-spec start_link(fetcher(), timeout()) -> {ok, pid()}. start_link(LicenseFetcher, CheckInterval) -> gen_server:start_link({local, ?MODULE}, ?MODULE, [LicenseFetcher, CheckInterval], []). --spec update(emqx_license_parser:license()) -> map(). +-spec update(license()) -> map(). update(License) -> gen_server:call(?MODULE, {update, License}, infinity). @@ -210,8 +214,7 @@ check_license(License) -> DaysLeft = days_left(License), IsOverdue = is_overdue(License, DaysLeft), NeedRestriction = IsOverdue, - MaxConn = emqx_license_parser:max_connections(License), - Limits = limits(License, NeedRestriction), + #{max_connections := MaxConn} = Limits = limits(License, NeedRestriction), true = apply_limits(Limits), #{ warn_evaluation => warn_evaluation(License, NeedRestriction, MaxConn), @@ -223,8 +226,34 @@ warn_evaluation(License, false, MaxConn) -> warn_evaluation(_License, _NeedRestrict, _Limits) -> false. -limits(License, false) -> #{max_connections => emqx_license_parser:max_connections(License)}; -limits(_License, true) -> #{max_connections => ?ERR_EXPIRED}. +limits(License, false) -> + #{ + max_connections => get_max_connections(License) + }; +limits(_License, true) -> + #{ + max_connections => ?ERR_EXPIRED + }. + +%% @doc Return the max_connections limit defined in license. +%% For business-critical type, it returns the dynamic value set in config. +-spec get_max_connections(license()) -> non_neg_integer(). +get_max_connections(License) -> + Max = emqx_license_parser:max_connections(License), + Dyn = + case emqx_license_parser:customer_type(License) of + ?BUSINESS_CRITICAL_CUSTOMER -> + min(get_dynamic_max_connections(), Max); + _ -> + Max + end, + min(Max, Dyn). + +%% @doc Get the dynamic max_connections limit set in config. +%% It's only meaningful for business-critical license. +-spec get_dynamic_max_connections() -> non_neg_integer(). +get_dynamic_max_connections() -> + emqx_conf:get([license, dynamic_max_connections]). days_left(License) -> DateEnd = emqx_license_parser:expiry_date(License), diff --git a/apps/emqx_license/src/emqx_license_http_api.erl b/apps/emqx_license/src/emqx_license_http_api.erl index dcf7afc7e..4d869f840 100644 --- a/apps/emqx_license/src/emqx_license_http_api.erl +++ b/apps/emqx_license/src/emqx_license_http_api.erl @@ -147,7 +147,7 @@ error_msg(Code, Msg) -> {400, error_msg(?BAD_REQUEST, <<"Invalid request params">>)}. '/license/setting'(get, _Params) -> - {200, maps:remove(<<"key">>, emqx_config:get_raw([license]))}; + {200, get_setting()}; '/license/setting'(put, #{body := Setting}) -> case emqx_license:update_setting(Setting) of {error, Error} -> @@ -170,3 +170,14 @@ fields(key_license) -> setting() -> lists:keydelete(key, 1, emqx_license_schema:fields(key_license)). + +%% Drop dynamic_max_connections unless it's a BUSINESS_CRITICAL license. +get_setting() -> + #{<<"key">> := Key} = Raw = emqx_config:get_raw([license]), + Result = maps:remove(<<"key">>, Raw), + case emqx_license_parser:is_business_critical(Key) of + true -> + Result; + false -> + maps:remove(<<"dynamic_max_connections">>, Result) + end. diff --git a/apps/emqx_license/src/emqx_license_parser.erl b/apps/emqx_license/src/emqx_license_parser.erl index d7fcde338..67ad801bc 100644 --- a/apps/emqx_license/src/emqx_license_parser.erl +++ b/apps/emqx_license/src/emqx_license_parser.erl @@ -28,6 +28,7 @@ ?SMALL_CUSTOMER | ?MEDIUM_CUSTOMER | ?LARGE_CUSTOMER + | ?BUSINESS_CRITICAL_CUSTOMER | ?EVALUATION_CUSTOMER. -type license_type() :: ?OFFICIAL | ?TRIAL. @@ -41,6 +42,8 @@ source := binary() }. +-type raw_license() :: string() | binary() | default. + -export_type([ license_data/0, customer_type/0, @@ -56,7 +59,8 @@ customer_type/1, license_type/1, expiry_date/1, - max_connections/1 + max_connections/1, + is_business_critical/1 ]). %% for testing purpose @@ -94,7 +98,7 @@ default() -> emqx_license_schema:default_license(). %% @doc Parse license key. %% If the license key is prefixed with "file://path/to/license/file", %% then the license key is read from the file. --spec parse(default | string() | binary()) -> {ok, license()} | {error, map()}. +-spec parse(raw_license()) -> {ok, license()} | {error, map()}. parse(Content) -> parse(to_bin(Content), ?MODULE:pubkey()). @@ -146,6 +150,13 @@ expiry_date(#{module := Module, data := LicenseData}) -> max_connections(#{module := Module, data := LicenseData}) -> Module:max_connections(LicenseData). +-spec is_business_critical(license() | raw_license()) -> boolean(). +is_business_critical(#{module := Module, data := LicenseData}) -> + Module:customer_type(LicenseData) =:= ?BUSINESS_CRITICAL_CUSTOMER; +is_business_critical(Key) when is_binary(Key) -> + {ok, License} = parse(Key), + is_business_critical(License). + %%-------------------------------------------------------------------- %% Private functions %%-------------------------------------------------------------------- diff --git a/apps/emqx_license/src/emqx_license_schema.erl b/apps/emqx_license/src/emqx_license_schema.erl index 1a1f388d9..0780f5971 100644 --- a/apps/emqx_license/src/emqx_license_schema.erl +++ b/apps/emqx_license/src/emqx_license_schema.erl @@ -16,7 +16,8 @@ -export([namespace/0, roots/0, fields/1, validations/0, desc/1, tags/0]). -export([ - default_license/0 + default_license/0, + default_setting/0 ]). namespace() -> "license". @@ -45,16 +46,26 @@ fields(key_license) -> required => true, desc => ?DESC(key_field) }}, + %% This feature is not made GA yet, hence hidden. + %% When license is issued to cutomer-type BUSINESS_CRITICAL (code 3) + %% This config is taken as the real max_connections limit. + {dynamic_max_connections, #{ + type => non_neg_integer(), + default => default(dynamic_max_connections), + required => false, + importance => ?IMPORTANCE_HIDDEN, + desc => ?DESC(dynamic_max_connections) + }}, {connection_low_watermark, #{ type => emqx_schema:percent(), - default => <<"75%">>, - example => <<"75%">>, + default => default(connection_low_watermark), + example => default(connection_low_watermark), desc => ?DESC(connection_low_watermark_field) }}, {connection_high_watermark, #{ type => emqx_schema:percent(), - default => <<"80%">>, - example => <<"80%">>, + default => default(connection_high_watermark), + example => default(connection_high_watermark), desc => ?DESC(connection_high_watermark_field) }} ]. @@ -87,11 +98,39 @@ check_license_watermark(Conf) -> %% @doc The default license key. %% This default license has 25 connections limit. -%% Issued on 2023-12-08 and valid for 5 years (1825 days) -%% NOTE: when updating a new key, the schema doc in emqx_license_schema.hocon -%% should be updated accordingly +%% Issued on 2024-04-18 and valid for 5 years (1825 days) +%% +%% NOTE: when updating a new key, below should be updated accordingly: +%% - emqx_license_schema.hocon default connections limit +%% - default(dynamic_max_connections) return value default_license() -> << - "MjIwMTExCjAKMTAKRXZhbHVhdGlvbgpjb250YWN0QGVtcXguaW8KdHJpYWwKMjAyMzEyMDgKMTgyNQoyNQo=." - "MEUCIE271MtH+4bb39OZKD4mvVkurwZ3LX44KUvuOxkbjQz2AiEAqL7BP44PMUS5z5SAN1M4y3v3h47J8qORAqcuetnyexw=" + "MjIwMTExCjAKMTAKRXZhbHVhdGlvbgpjb250YWN0QGVtcXguaW8KdHJpYWwKMjAyNDA0MTgKMTgyNQoyNQo=" + "." + "MEUCICMWWkfrvyMwQaQAOXEsEcs+d6+5uXc1BDxR7j25fRy4AiEAmblQ4p+FFmdsvnKgcRRkv1zj7PExmZKVk3mVcxH3fgw=" >>. + +%% @doc Exported for testing +default_setting() -> + Keys = + [ + connection_low_watermark, + connection_high_watermark, + dynamic_max_connections + ], + maps:from_list( + lists:map( + fun(K) -> + {K, default(K)} + end, + Keys + ) + ). + +default(connection_low_watermark) -> + <<"75%">>; +default(connection_high_watermark) -> + <<"80%">>; +default(dynamic_max_connections) -> + %Must match the value encoded in default license. + 25. diff --git a/apps/emqx_license/test/emqx_license_cli_SUITE.erl b/apps/emqx_license/test/emqx_license_cli_SUITE.erl index b362efd95..1e8cfe7de 100644 --- a/apps/emqx_license/test/emqx_license_cli_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_cli_SUITE.erl @@ -65,6 +65,7 @@ t_conf_update(_Config) -> #{ connection_high_watermark => 0.5, connection_low_watermark => 0.45, + dynamic_max_connections => 25, key => LicenseKey }, emqx:get_config([license]) diff --git a/apps/emqx_license/test/emqx_license_http_api_SUITE.erl b/apps/emqx_license/test/emqx_license_http_api_SUITE.erl index c207b3a40..b64a4d5af 100644 --- a/apps/emqx_license/test/emqx_license_http_api_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_http_api_SUITE.erl @@ -19,17 +19,16 @@ all() -> init_per_suite(Config) -> emqx_license_test_lib:mock_parser(), + Setting = emqx_license_schema:default_setting(), + Key = emqx_license_test_lib:make_license(#{max_connections => "100"}), + LicenseConf = maps:merge(#{key => Key}, Setting), Apps = emqx_cth_suite:start( [ emqx, emqx_conf, {emqx_license, #{ config => #{ - license => #{ - key => emqx_license_test_lib:make_license(#{max_connections => "100"}), - connection_low_watermark => <<"75%">>, - connection_high_watermark => <<"80%">> - } + license => LicenseConf } }}, {emqx_dashboard, @@ -50,7 +49,7 @@ init_per_testcase(_TestCase, Config) -> Config. end_per_testcase(_TestCase, _Config) -> - {ok, _} = reset_license(), + ok = reset_license(), ok. %%------------------------------------------------------------------------------ @@ -70,7 +69,11 @@ default_license() -> emqx_license_test_lib:make_license(#{max_connections => "100"}). reset_license() -> - emqx_license:update_key(default_license()). + {ok, _} = emqx_license:update_key(default_license()), + Setting = emqx_license_schema:default_setting(), + Req = maps:from_list([{atom_to_binary(K), V} || {K, V} <- maps:to_list(Setting)]), + {ok, _} = emqx_license:update_setting(Req), + ok. assert_untouched_license() -> ?assertMatch( @@ -224,6 +227,26 @@ t_license_setting(_Config) -> ), ok. +t_license_setting_bc(_Config) -> + %% Create a BC license + Key = emqx_license_test_lib:make_license(#{customer_type => "3"}), + Res = request(post, uri(["license"]), #{key => Key}), + ?assertMatch({ok, 200, _}, Res), + %% get + GetRes = request(get, uri(["license", "setting"]), []), + validate_setting(GetRes, <<"75%">>, <<"80%">>, 25), + %% update + Low = <<"50%">>, + High = <<"55%">>, + UpdateRes = request(put, uri(["license", "setting"]), #{ + <<"connection_low_watermark">> => Low, + <<"connection_high_watermark">> => High, + <<"dynamic_max_connections">> => 26 + }), + validate_setting(UpdateRes, Low, High, 26), + ?assertEqual(26, emqx_config:get([license, dynamic_max_connections])), + ok. + validate_setting(Res, ExpectLow, ExpectHigh) -> ?assertMatch({ok, 200, _}, Res), {ok, 200, Payload} = Res, @@ -234,3 +257,13 @@ validate_setting(Res, ExpectLow, ExpectHigh) -> }, emqx_utils_json:decode(Payload, [return_maps]) ). + +validate_setting(Res, ExpectLow, ExpectHigh, DynMax) -> + ?assertMatch({ok, 200, _}, Res), + {ok, 200, Payload} = Res, + #{ + <<"connection_low_watermark">> := ExpectLow, + <<"connection_high_watermark">> := ExpectHigh, + <<"dynamic_max_connections">> := DynMax + } = + emqx_utils_json:decode(Payload, [return_maps]). diff --git a/rel/i18n/emqx_license_schema.hocon b/rel/i18n/emqx_license_schema.hocon index 72f31266b..e3d418029 100644 --- a/rel/i18n/emqx_license_schema.hocon +++ b/rel/i18n/emqx_license_schema.hocon @@ -12,17 +12,12 @@ connection_low_watermark_field.desc: connection_low_watermark_field.label: """Connection low watermark""" -connection_high_watermark_field_deprecated.desc: -"""deprecated use /license/setting instead""" - -connection_high_watermark_field_deprecated.label: -"""deprecated use /license/setting instead""" - -connection_low_watermark_field_deprecated.desc: -"""deprecated use /license/setting instead""" - -connection_low_watermark_field_deprecated.label: -"""deprecated use /license/setting instead""" +dynamic_max_connections { + label: "Dynamic Connections Limit" + desc: """~ + Only applicable for "Business Critical" license type. This config sets the current allocation of license for the current cluster. + This value cannot exceed the connections limit assigned in the license key.""" +} key_field.desc: """This configuration parameter is designated for the license key and supports below input formats: @@ -43,7 +38,7 @@ license_root.desc: """Defines the EMQX Enterprise license. EMQX Enterprise is initially provided with a default trial license. -This license, issued in December 2023, is valid for a period of 5 years. +This license, issued in April 2024, is valid for a period of 5 years. It supports up to 25 concurrent connections, catering to early-stage development and testing needs. For deploying EMQX Enterprise in a production environment, a different license is required. You can apply for a production license by visiting https://www.emqx.com/apply-licenses/emqx?version=5"""