Просмотр исходного кода

Merge pull request #12016 from zmstone/1122-load-license-file

1122 load license file
Zaiming (Stone) Shi 2 лет назад
Родитель
Сommit
d933d0b9e0

+ 62 - 15
apps/emqx_license/src/emqx_license_checker.erl

@@ -5,12 +5,15 @@
 -module(emqx_license_checker).
 
 -include("emqx_license.hrl").
+-include_lib("emqx/include/logger.hrl").
 -include_lib("snabbkaffe/include/snabbkaffe.hrl").
 
 -behaviour(gen_server).
 
--define(CHECK_INTERVAL, 5000).
--define(EXPIRY_ALARM_CHECK_INTERVAL, 24 * 60 * 60).
+-define(CHECK_INTERVAL, timer:seconds(5)).
+-define(REFRESH_INTERVAL, timer:minutes(2)).
+-define(EXPIRY_ALARM_CHECK_INTERVAL, timer:hours(24)).
+
 -define(OK(EXPR),
     try
         _ = begin
@@ -56,7 +59,7 @@ start_link(LicenseFetcher) ->
 start_link(LicenseFetcher, CheckInterval) ->
     gen_server:start_link({local, ?MODULE}, ?MODULE, [LicenseFetcher, CheckInterval], []).
 
--spec update(emqx_license_parser:license()) -> ok.
+-spec update(emqx_license_parser:license()) -> map().
 update(License) ->
     gen_server:call(?MODULE, {update, License}, infinity).
 
@@ -94,15 +97,18 @@ init([LicenseFetcher, CheckInterval]) ->
                 check_license_interval => CheckInterval,
                 license => License
             }),
-            State = ensure_check_expiry_timer(State0),
+            State1 = ensure_refresh_timer(State0),
+            State = ensure_check_expiry_timer(State1),
             {ok, State};
         {error, Reason} ->
             {stop, Reason}
     end.
 
-handle_call({update, License}, _From, State) ->
+handle_call({update, License}, _From, #{license := Old} = State) ->
     ok = expiry_early_alarm(License),
-    {reply, check_license(License), State#{license => License}};
+    State1 = ensure_refresh_timer(State),
+    ok = log_new_license(Old, License),
+    {reply, check_license(License), State1#{license => License}};
 handle_call(dump, _From, #{license := License} = State) ->
     {reply, emqx_license_parser:dump(License), State};
 handle_call(purge, _From, State) ->
@@ -123,6 +129,10 @@ handle_info(check_expiry_alarm, #{license := License} = State) ->
     ok = expiry_early_alarm(License),
     NewState = ensure_check_expiry_timer(State),
     {noreply, NewState};
+handle_info(refresh, State0) ->
+    State1 = refresh(State0),
+    NewState = ensure_refresh_timer(State1),
+    {noreply, NewState};
 handle_info(_Msg, State) ->
     {noreply, State}.
 
@@ -130,22 +140,59 @@ handle_info(_Msg, State) ->
 %% Private functions
 %%------------------------------------------------------------------------------
 
+refresh(#{license := #{source := <<"file://", _/binary>> = Source} = License} = State) ->
+    case emqx_license_parser:parse(Source) of
+        {ok, License} ->
+            ?tp(emqx_license_refresh_no_change, #{}),
+            %% no change
+            State;
+        {ok, NewLicense} ->
+            ok = log_new_license(License, NewLicense),
+            %% ensure alarm is set or cleared
+            ok = expiry_early_alarm(NewLicense),
+            ?tp(emqx_license_refresh_changed, #{new_license => NewLicense}),
+            State#{license => NewLicense};
+        {error, Reason} ->
+            ?tp(
+                error,
+                emqx_license_refresh_failed,
+                Reason#{continue_with_license => emqx_license_parser:summary(License)}
+            ),
+            State
+    end;
+refresh(State) ->
+    State.
+
+log_new_license(Old, New) ->
+    ?SLOG(info, #{
+        msg => "new_license_loaded",
+        old_license => emqx_license_parser:summary(Old),
+        new_license => emqx_license_parser:summary(New)
+    }).
+
 ensure_check_license_timer(#{check_license_interval := CheckInterval} = State) ->
-    cancel_timer(State, timer),
-    State#{timer => erlang:send_after(CheckInterval, self(), check_license)}.
+    ok = cancel_timer(State, check_timer),
+    State#{check_timer => erlang:send_after(CheckInterval, self(), check_license)}.
 
 ensure_check_expiry_timer(State) ->
-    cancel_timer(State, expiry_alarm_timer),
+    ok = cancel_timer(State, expiry_alarm_timer),
     Ref = erlang:send_after(?EXPIRY_ALARM_CHECK_INTERVAL, self(), check_expiry_alarm),
     State#{expiry_alarm_timer => Ref}.
 
+%% refresh is to work with file:// license keys.
+ensure_refresh_timer(State) ->
+    ok = cancel_timer(State, refresh_timer),
+    Ref = erlang:send_after(?REFRESH_INTERVAL, self(), refresh),
+    State#{refresh_timer => Ref}.
+
 cancel_timer(State, Key) ->
-    _ =
-        case maps:find(Key, State) of
-            {ok, Ref} when is_reference(Ref) -> erlang:cancel_timer(Ref);
-            _ -> ok
-        end,
-    ok.
+    case maps:find(Key, State) of
+        {ok, Ref} when is_reference(Ref) ->
+            _ = erlang:cancel_timer(Ref),
+            ok;
+        _ ->
+            ok
+    end.
 
 check_license(License) ->
     DaysLeft = days_left(License),

+ 0 - 1
apps/emqx_license/src/emqx_license_http_api.erl

@@ -54,7 +54,6 @@ schema("/license") ->
                 )
             }
         },
-        %% TODO(5.x): It's a update action, should use PUT instead
         post => #{
             tags => ?LICENSE_TAGS,
             summary => <<"Update license key">>,

+ 63 - 17
apps/emqx_license/src/emqx_license_parser.erl

@@ -32,7 +32,14 @@
 
 -type license_type() :: ?OFFICIAL | ?TRIAL.
 
--type license() :: #{module := module(), data := license_data()}.
+-type license() :: #{
+    %% the parser module which parsed the license
+    module := module(),
+    %% the parse result
+    data := license_data(),
+    %% the source of the license, e.g. "file://path/to/license/file" or "******" for license key
+    source := binary()
+}.
 
 -export_type([
     license_data/0,
@@ -45,12 +52,19 @@
     parse/1,
     parse/2,
     dump/1,
+    summary/1,
     customer_type/1,
     license_type/1,
     expiry_date/1,
     max_connections/1
 ]).
 
+%% for testing purpose
+-export([
+    default/0,
+    pubkey/0
+]).
+
 %%--------------------------------------------------------------------
 %% Behaviour
 %%--------------------------------------------------------------------
@@ -59,6 +73,9 @@
 
 -callback dump(license_data()) -> list({atom(), term()}).
 
+%% provide a summary map for logging purposes
+-callback summary(license_data()) -> map().
+
 -callback customer_type(license_data()) -> customer_type().
 
 -callback license_type(license_data()) -> license_type().
@@ -71,19 +88,37 @@
 %% API
 %%--------------------------------------------------------------------
 
--ifdef(TEST).
--spec parse(string() | binary()) -> {ok, license()} | {error, term()}.
-parse(Content) ->
-    PubKey = persistent_term:get(emqx_license_test_pubkey, ?PUBKEY),
-    parse(Content, PubKey).
--else.
--spec parse(string() | binary()) -> {ok, license()} | {error, term()}.
-parse(Content) ->
-    parse(Content, ?PUBKEY).
--endif.
+pubkey() -> ?PUBKEY.
+default() -> emqx_license_schema:default_license().
 
-parse(Content, Pem) ->
-    [PemEntry] = public_key:pem_decode(Pem),
+%% @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()}.
+parse(Content) ->
+    parse(to_bin(Content), ?MODULE:pubkey()).
+
+parse(<<"default">>, PubKey) ->
+    parse(?MODULE:default(), PubKey);
+parse(<<"file://", Path/binary>> = FileKey, PubKey) ->
+    case file:read_file(Path) of
+        {ok, Content} ->
+            case parse(Content, PubKey) of
+                {ok, License} ->
+                    {ok, License#{source => FileKey}};
+                {error, Reason} ->
+                    {error, Reason#{
+                        license_file => Path
+                    }}
+            end;
+        {error, Reason} ->
+            {error, #{
+                license_file => Path,
+                read_error => Reason
+            }}
+    end;
+parse(Content, PubKey) ->
+    [PemEntry] = public_key:pem_decode(PubKey),
     Key = public_key:pem_entry_decode(PemEntry),
     do_parse(iolist_to_binary(Content), Key, ?LICENSE_PARSE_MODULES, []).
 
@@ -91,6 +126,10 @@ parse(Content, Pem) ->
 dump(#{module := Module, data := LicenseData}) ->
     Module:dump(LicenseData).
 
+-spec summary(license()) -> map().
+summary(#{module := Module, data := Data}) ->
+    Module:summary(Data).
+
 -spec customer_type(license()) -> customer_type().
 customer_type(#{module := Module, data := LicenseData}) ->
     Module:customer_type(LicenseData).
@@ -112,14 +151,21 @@ max_connections(#{module := Module, data := LicenseData}) ->
 %%--------------------------------------------------------------------
 
 do_parse(_Content, _Key, [], Errors) ->
-    {error, lists:reverse(Errors)};
+    {error, #{parse_results => lists:reverse(Errors)}};
 do_parse(Content, Key, [Module | Modules], Errors) ->
     try Module:parse(Content, Key) of
         {ok, LicenseData} ->
-            {ok, #{module => Module, data => LicenseData}};
+            {ok, #{module => Module, data => LicenseData, source => <<"******">>}};
         {error, Error} ->
-            do_parse(Content, Key, Modules, [{Module, Error} | Errors])
+            do_parse(Content, Key, Modules, [#{module => Module, error => Error} | Errors])
     catch
         _Class:Error:Stacktrace ->
-            do_parse(Content, Key, Modules, [{Module, {Error, Stacktrace}} | Errors])
+            do_parse(Content, Key, Modules, [
+                #{module => Module, error => Error, stacktrace => Stacktrace} | Errors
+            ])
     end.
+
+to_bin(A) when is_atom(A) ->
+    atom_to_binary(A);
+to_bin(L) ->
+    iolist_to_binary(L).

+ 32 - 6
apps/emqx_license/src/emqx_license_parser_v20220101.erl

@@ -21,6 +21,7 @@
 -export([
     parse/2,
     dump/1,
+    summary/1,
     customer_type/1,
     license_type/1,
     expiry_date/1,
@@ -69,6 +70,21 @@ dump(
         {expiry, Expiry}
     ].
 
+summary(
+    #{
+        deployment := Deployment,
+        date_start := DateStart,
+        max_connections := MaxConns
+    } = License
+) ->
+    DateExpiry = expiry_date(License),
+    #{
+        deployment => Deployment,
+        max_connections => MaxConns,
+        start_at => format_date(DateStart),
+        expiry_at => format_date(DateExpiry)
+    }.
+
 customer_type(#{customer_type := CType}) -> CType.
 
 license_type(#{type := Type}) -> Type.
@@ -85,17 +101,27 @@ max_connections(#{max_connections := MaxConns}) ->
 %% Private functions
 %%------------------------------------------------------------------------------
 
-do_parse(Content) ->
+do_parse(Content0) ->
     try
-        [EncodedPayload, EncodedSignature] = binary:split(Content, <<".">>),
-        Payload = base64:decode(EncodedPayload),
-        Signature = base64:decode(EncodedSignature),
-        {ok, {Payload, Signature}}
+        Content = normalize(Content0),
+        do_parse2(Content)
     catch
         _:_ ->
             {error, bad_license_format}
     end.
 
+do_parse2(<<>>) ->
+    {error, empty_string};
+do_parse2(Content) ->
+    [EncodedPayload, EncodedSignature] = binary:split(Content, <<".">>),
+    Payload = base64:decode(EncodedPayload),
+    Signature = base64:decode(EncodedSignature),
+    {ok, {Payload, Signature}}.
+
+%% drop whitespaces and newlines (CRLF)
+normalize(Bin) ->
+    <<<<C>> || <<C>> <= Bin, C =/= $\s andalso C =/= $\n andalso C =/= $\r>>.
+
 verify_signature(Payload, Signature, Key) ->
     public_key:verify(Payload, ?DIGEST_TYPE, Signature, Key).
 
@@ -182,7 +208,7 @@ collect_fields(Fields) ->
         {FieldValues, []} ->
             {ok, maps:from_list(FieldValues)};
         {_, Errors} ->
-            {error, lists:reverse(Errors)}
+            {error, maps:from_list(Errors)}
     end.
 
 format_date({Year, Month, Day}) ->

+ 2 - 2
apps/emqx_license/src/emqx_license_schema.erl

@@ -38,8 +38,8 @@ tags() ->
 fields(key_license) ->
     [
         {key, #{
-            type => binary(),
-            default => default_license(),
+            type => hoconsc:union([default, binary()]),
+            default => <<"default">>,
             %% so it's not logged
             sensitive => true,
             required => true,

+ 7 - 15
apps/emqx_license/test/emqx_license_SUITE.erl

@@ -16,12 +16,14 @@ all() ->
     emqx_common_test_helpers:all(?MODULE).
 
 init_per_suite(Config) ->
+    emqx_license_test_lib:mock_parser(),
     _ = application:load(emqx_conf),
     emqx_config:save_schema_mod_and_names(emqx_license_schema),
     emqx_common_test_helpers:start_apps([emqx_license], fun set_special_configs/1),
     Config.
 
 end_per_suite(_) ->
+    emqx_license_test_lib:unmock_parser(),
     emqx_common_test_helpers:stop_apps([emqx_license]),
     ok.
 
@@ -103,17 +105,7 @@ setup_test(TestCase, Config) when
                     ),
                     ok;
                 (emqx_license) ->
-                    LicensePath = filename:join(emqx_license:license_dir(), "emqx.lic"),
-                    filelib:ensure_dir(LicensePath),
-                    ok = file:write_file(LicensePath, LicenseKey),
-                    LicConfig = #{type => file, file => LicensePath},
-                    emqx_config:put([license], LicConfig),
-                    RawConfig = #{<<"type">> => file, <<"file">> => LicensePath},
-                    emqx_config:put_raw([<<"license">>], RawConfig),
-                    ok = persistent_term:put(
-                        emqx_license_test_pubkey,
-                        emqx_license_test_lib:public_key_pem()
-                    ),
+                    set_special_configs(emqx_license),
                     ok;
                 (_) ->
                     ok
@@ -129,9 +121,9 @@ teardown_test(_TestCase, _Config) ->
     ok.
 
 set_special_configs(emqx_license) ->
-    Config = #{key => emqx_license_test_lib:default_license()},
+    Config = #{key => default},
     emqx_config:put([license], Config),
-    RawConfig = #{<<"key">> => emqx_license_test_lib:default_license()},
+    RawConfig = #{<<"key">> => <<"default">>},
     emqx_config:put_raw([<<"license">>], RawConfig);
 set_special_configs(_) ->
     ok.
@@ -146,11 +138,11 @@ assert_on_nodes(Nodes, RunFun, CheckFun) ->
 
 t_update_value(_Config) ->
     ?assertMatch(
-        {error, [_ | _]},
+        {error, #{parse_results := [_ | _]}},
         emqx_license:update_key("invalid.license")
     ),
 
-    LicenseValue = emqx_license_test_lib:default_license(),
+    LicenseValue = emqx_license_test_lib:default_test_license(),
 
     ?assertMatch(
         {ok, #{}},

+ 107 - 4
apps/emqx_license/test/emqx_license_checker_SUITE.erl

@@ -14,12 +14,14 @@
 all() ->
     emqx_common_test_helpers:all(?MODULE).
 
-init_per_suite(Config) ->
+init_per_suite(CtConfig) ->
     _ = application:load(emqx_conf),
+    emqx_license_test_lib:mock_parser(),
     ok = emqx_common_test_helpers:start_apps([emqx_license], fun set_special_configs/1),
-    Config.
+    CtConfig.
 
 end_per_suite(_) ->
+    emqx_license_test_lib:unmock_parser(),
     ok = emqx_common_test_helpers:stop_apps([emqx_license]).
 
 init_per_testcase(t_default_limits, Config) ->
@@ -35,7 +37,7 @@ end_per_testcase(_Case, _Config) ->
     ok.
 
 set_special_configs(emqx_license) ->
-    Config = #{key => emqx_license_test_lib:default_license()},
+    Config = #{key => emqx_license_test_lib:default_test_license()},
     emqx_config:put([license], Config);
 set_special_configs(_) ->
     ok.
@@ -100,7 +102,7 @@ t_update(_Config) ->
         emqx_license_checker:limits()
     ).
 
-t_update_by_timer(_Config) ->
+t_check_by_timer(_Config) ->
     ?check_trace(
         begin
             ?wait_async_action(
@@ -228,10 +230,111 @@ t_unknown_calls(_Config) ->
     some_msg = erlang:send(emqx_license_checker, some_msg),
     ?assertEqual(unknown, gen_server:call(emqx_license_checker, some_request)).
 
+t_refresh_no_change(Config) when is_list(Config) ->
+    {ok, License} = write_test_license(Config, ?FUNCTION_NAME, 1, 111),
+    #{} = emqx_license_checker:update(License),
+    ?check_trace(
+        begin
+            ?wait_async_action(
+                begin
+                    erlang:send(
+                        emqx_license_checker,
+                        refresh
+                    )
+                end,
+                #{?snk_kind := emqx_license_refresh_no_change},
+                1000
+            )
+        end,
+        fun(Trace) ->
+            ?assertMatch([_ | _], ?of_kind(emqx_license_refresh_no_change, Trace))
+        end
+    ).
+
+t_refresh_change(Config) when is_list(Config) ->
+    {ok, License} = write_test_license(Config, ?FUNCTION_NAME, 1, 111),
+    #{} = emqx_license_checker:update(License),
+    {ok, License2} = write_test_license(Config, ?FUNCTION_NAME, 2, 222),
+    ?check_trace(
+        begin
+            ?wait_async_action(
+                begin
+                    erlang:send(
+                        emqx_license_checker,
+                        refresh
+                    )
+                end,
+                #{?snk_kind := emqx_license_refresh_changed},
+                1000
+            )
+        end,
+        fun(Trace) ->
+            ?assertMatch(
+                [#{new_license := License2} | _], ?of_kind(emqx_license_refresh_changed, Trace)
+            )
+        end
+    ).
+
+t_refresh_failure(Config) when is_list(Config) ->
+    Filename = test_license_file_name(Config, ?FUNCTION_NAME),
+    {ok, License} = write_test_license(Config, ?FUNCTION_NAME, 1, 111),
+    Summary = emqx_license_parser:summary(License),
+    #{} = emqx_license_checker:update(License),
+    ok = file:write_file(Filename, <<"invalid license">>),
+    ?check_trace(
+        begin
+            ?wait_async_action(
+                begin
+                    erlang:send(
+                        emqx_license_checker,
+                        refresh
+                    )
+                end,
+                #{?snk_kind := emqx_license_refresh_failed},
+                1000
+            )
+        end,
+        fun(Trace) ->
+            ?assertMatch(
+                [#{continue_with_license := Summary} | _],
+                ?of_kind(emqx_license_refresh_failed, Trace)
+            )
+        end
+    ).
+
 %%------------------------------------------------------------------------------
 %% Tests
 %%------------------------------------------------------------------------------
 
+write_test_license(Config, Name, ExpireInDays, Connections) ->
+    {NowDate, _} = calendar:universal_time(),
+    DateTomorrow = calendar:gregorian_days_to_date(
+        calendar:date_to_gregorian_days(NowDate) + ExpireInDays
+    ),
+    Fields = [
+        "220111",
+        "1",
+        "0",
+        "Foo",
+        "contact@foo.com",
+        "bar",
+        format_date(DateTomorrow),
+        "1",
+        integer_to_list(Connections)
+    ],
+    FileName = test_license_file_name(Config, Name),
+    ok = write_license_file(FileName, Fields),
+    emqx_license_parser:parse(<<"file://", FileName/binary>>).
+
+test_license_file_name(Config, Name) ->
+    Dir = ?config(data_dir, Config),
+    iolist_to_binary(filename:join(Dir, atom_to_list(Name) ++ ".lic")).
+
+write_license_file(FileName, Fields) ->
+    EncodedLicense = emqx_license_test_lib:make_license(Fields),
+    ok = filelib:ensure_dir(FileName),
+    ok = file:write_file(FileName, EncodedLicense).
+
 mk_license(Fields) ->
     EncodedLicense = emqx_license_test_lib:make_license(Fields),
     {ok, License} = emqx_license_parser:parse(

+ 2 - 5
apps/emqx_license/test/emqx_license_cli_SUITE.erl

@@ -24,15 +24,12 @@ end_per_suite(_) ->
     ok.
 
 init_per_testcase(_Case, Config) ->
-    ok = persistent_term:put(
-        emqx_license_test_pubkey,
-        emqx_license_test_lib:public_key_pem()
-    ),
+    emqx_license_test_lib:mock_parser(),
     {ok, _} = emqx_cluster_rpc:start_link(node(), emqx_cluster_rpc, 1000),
     Config.
 
 end_per_testcase(_Case, _Config) ->
-    persistent_term:erase(emqx_license_test_pubkey),
+    emqx_license_test_lib:unmock_parser(),
     ok.
 
 set_special_configs(emqx_license) ->

+ 15 - 5
apps/emqx_license/test/emqx_license_http_api_SUITE.erl

@@ -19,6 +19,7 @@ all() ->
     emqx_common_test_helpers:all(?MODULE).
 
 init_per_suite(Config) ->
+    emqx_license_test_lib:mock_parser(),
     _ = application:load(emqx_conf),
     emqx_config:save_schema_mod_and_names(emqx_license_schema),
     emqx_common_test_helpers:start_apps([emqx_license, emqx_dashboard], fun set_special_configs/1),
@@ -31,7 +32,7 @@ end_per_suite(_) ->
     emqx_config:put([license], Config),
     RawConfig = #{<<"key">> => LicenseKey},
     emqx_config:put_raw([<<"license">>], RawConfig),
-    persistent_term:erase(emqx_license_test_pubkey),
+    emqx_license_test_lib:unmock_parser(),
     ok.
 
 set_special_configs(emqx_dashboard) ->
@@ -48,10 +49,6 @@ set_special_configs(emqx_license) ->
         <<"connection_high_watermark">> => <<"80%">>
     },
     emqx_config:put_raw([<<"license">>], RawConfig),
-    ok = persistent_term:put(
-        emqx_license_test_pubkey,
-        emqx_license_test_lib:public_key_pem()
-    ),
     ok;
 set_special_configs(_) ->
     ok.
@@ -113,6 +110,19 @@ t_license_info(_Config) ->
     ),
     ok.
 
+t_set_default_license(_Config) ->
+    NewKey = <<"default">>,
+    Res = request(
+        post,
+        uri(["license"]),
+        #{key => NewKey}
+    ),
+    ?assertMatch({ok, 200, _}, Res),
+    {ok, 200, Payload} = Res,
+    %% assert that it's not the string "default" returned
+    ?assertMatch(#{<<"customer">> := _}, emqx_utils_json:decode(Payload, [return_maps])),
+    ok.
+
 t_license_upload_key_success(_Config) ->
     NewKey = emqx_license_test_lib:make_license(#{max_connections => "999"}),
     Res = request(

+ 53 - 28
apps/emqx_license/test/emqx_license_parser_SUITE.erl

@@ -40,6 +40,7 @@ set_special_configs(_) ->
 %%------------------------------------------------------------------------------
 
 t_parse(_Config) ->
+    Parser = emqx_license_parser_v20220101,
     ?assertMatch({ok, _}, emqx_license_parser:parse(sample_license(), public_key_pem())),
 
     %% invalid version
@@ -61,10 +62,7 @@ t_parse(_Config) ->
     ),
     ?assertMatch({error, _}, Res1),
     {error, Err1} = Res1,
-    ?assertEqual(
-        invalid_version,
-        proplists:get_value(emqx_license_parser_v20220101, Err1)
-    ),
+    ?assertMatch(#{error := invalid_version}, find_error(Parser, Err1)),
 
     %% invalid field number
     Res2 = emqx_license_parser:parse(
@@ -87,9 +85,9 @@ t_parse(_Config) ->
     ),
     ?assertMatch({error, _}, Res2),
     {error, Err2} = Res2,
-    ?assertEqual(
-        unexpected_number_of_fields,
-        proplists:get_value(emqx_license_parser_v20220101, Err2)
+    ?assertMatch(
+        #{error := unexpected_number_of_fields},
+        find_error(Parser, Err2)
     ),
 
     Res3 = emqx_license_parser:parse(
@@ -110,14 +108,17 @@ t_parse(_Config) ->
     ),
     ?assertMatch({error, _}, Res3),
     {error, Err3} = Res3,
-    ?assertEqual(
-        [
-            {type, invalid_license_type},
-            {customer_type, invalid_customer_type},
-            {date_start, invalid_date},
-            {days, invalid_int_value}
-        ],
-        proplists:get_value(emqx_license_parser_v20220101, Err3)
+    ?assertMatch(
+        #{
+            error :=
+                #{
+                    type := invalid_license_type,
+                    customer_type := invalid_customer_type,
+                    date_start := invalid_date,
+                    days := invalid_int_value
+                }
+        },
+        find_error(Parser, Err3)
     ),
 
     Res4 = emqx_license_parser:parse(
@@ -139,14 +140,17 @@ t_parse(_Config) ->
     ?assertMatch({error, _}, Res4),
     {error, Err4} = Res4,
 
-    ?assertEqual(
-        [
-            {type, invalid_license_type},
-            {customer_type, invalid_customer_type},
-            {date_start, invalid_date},
-            {days, invalid_int_value}
-        ],
-        proplists:get_value(emqx_license_parser_v20220101, Err4)
+    ?assertMatch(
+        #{
+            error :=
+                #{
+                    type := invalid_license_type,
+                    customer_type := invalid_customer_type,
+                    date_start := invalid_date,
+                    days := invalid_int_value
+                }
+        },
+        find_error(Parser, Err4)
     ),
 
     %% invalid signature
@@ -189,14 +193,14 @@ t_parse(_Config) ->
     ),
     ?assertMatch({error, _}, Res5),
     {error, Err5} = Res5,
-    ?assertEqual(
-        invalid_signature,
-        proplists:get_value(emqx_license_parser_v20220101, Err5)
+    ?assertMatch(
+        #{error := invalid_signature},
+        find_error(Parser, Err5)
     ),
 
     %% totally invalid strings as license
     ?assertMatch(
-        {error, [_ | _]},
+        {error, #{parse_results := [#{error := bad_license_format}]}},
         emqx_license_parser:parse(
             <<"badlicense">>,
             public_key_pem()
@@ -204,7 +208,7 @@ t_parse(_Config) ->
     ),
 
     ?assertMatch(
-        {error, [_ | _]},
+        {error, #{parse_results := [#{error := bad_license_format}]}},
         emqx_license_parser:parse(
             <<"bad.license">>,
             public_key_pem()
@@ -249,6 +253,20 @@ t_expiry_date(_Config) ->
 
     ?assertEqual({2295, 10, 27}, emqx_license_parser:expiry_date(License)).
 
+t_empty_string(_Config) ->
+    ?assertMatch(
+        {error, #{
+            parse_results := [
+                #{
+                    error := empty_string,
+                    module := emqx_license_parser_v20220101
+                }
+                | _
+            ]
+        }},
+        emqx_license_parser:parse(<<>>)
+    ).
+
 %%------------------------------------------------------------------------------
 %% Helpers
 %%------------------------------------------------------------------------------
@@ -270,3 +288,10 @@ sample_license() ->
             "10"
         ]
     ).
+
+find_error(Module, #{parse_results := Results}) ->
+    find_error(Module, Results);
+find_error(Module, [#{module := Module} = Result | _Results]) ->
+    Result;
+find_error(Module, [_Result | Results]) ->
+    find_error(Module, Results).

+ 13 - 11
apps/emqx_license/test/emqx_license_test_lib.erl

@@ -7,17 +7,6 @@
 -compile(nowarn_export_all).
 -compile(export_all).
 
--define(DEFAULT_LICENSE_VALUES, [
-    "220111",
-    "0",
-    "10",
-    "Foo",
-    "contact@foo.com",
-    "20220111",
-    "100000",
-    "10"
-]).
-
 private_key() ->
     test_key("pvt.key").
 
@@ -76,5 +65,18 @@ make_license(Values) ->
     EncodedSignature = base64:encode(Signature),
     iolist_to_binary([EncodedText, ".", EncodedSignature]).
 
+default_test_license() ->
+    make_license(#{}).
+
 default_license() ->
     emqx_license_schema:default_license().
+
+mock_parser() ->
+    meck:new(emqx_license_parser, [non_strict, passthrough, no_history, no_link]),
+    meck:expect(emqx_license_parser, pubkey, fun() -> public_key_pem() end),
+    meck:expect(emqx_license_parser, default, fun() -> default_test_license() end),
+    ok.
+
+unmock_parser() ->
+    meck:unload(emqx_license_parser),
+    ok.

+ 4 - 0
changes/ee/feat-12016.en.md

@@ -0,0 +1,4 @@
+Enhanced license key management.
+
+EMQX can now load the license key from a specified file. This is enabled by setting the `license.key` configuration to a file path, which should be prefixed with `"file://"`.
+Also added the ability to revert to the default trial license by setting `license.key = default`. This option simplifies the process of returning to the trial license if needed.

+ 10 - 1
rel/i18n/emqx_license_schema.hocon

@@ -25,7 +25,16 @@ connection_low_watermark_field_deprecated.label:
 """deprecated use /license/setting instead"""
 
 key_field.desc:
-"""License string"""
+"""This configuration parameter is designated for the license key and supports below input formats:
+
+- Direct Key: Enter the secret key directly as a string value.
+- File Path: Specify the path to a file that contains the secret key. Ensure the path starts with <code>file://</code>.
+- "default": Use string value <code>"default"</code> to apply the default trial license.
+
+Note: An invalid license key or an incorrect file path may prevent EMQX from starting successfully.
+If a file path is used, EMQX attempts to reload the license key from the file every 2 minutes.
+Any failure in reloading the license file will be recorded as an error level log message,
+and EMQX continues to apply the license loaded previously."""
 
 key_field.label:
 """License string"""