Merge pull request #12897 from zmstone/0418-support-dynamic-license

feat(license): add business-critical customer type
This commit is contained in:
Zaiming (Stone) Shi 2024-04-20 09:03:58 +02:00 committed by GitHub
commit 307cd79be2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 172 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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