%%-------------------------------------------------------------------- %% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. %%-------------------------------------------------------------------- -module(emqx_mgmt_data_backup_SUITE). -compile(export_all). -compile(nowarn_export_all). -include_lib("eunit/include/eunit.hrl"). -include_lib("common_test/include/ct.hrl"). -include_lib("snabbkaffe/include/snabbkaffe.hrl"). -define(ROLE_SUPERUSER, <<"administrator">>). -define(ROLE_API_SUPERUSER, <<"administrator">>). -define(BOOTSTRAP_BACKUP, "emqx-export-test-bootstrap-ce.tar.gz"). all() -> emqx_common_test_helpers:all(?MODULE). init_per_suite(Config) -> Config. end_per_suite(_Config) -> ok. init_per_testcase(TC = t_import_on_cluster, Config) -> %% Don't import listeners to avoid port conflicts %% when the same conf will be imported to another cluster meck:new(emqx_mgmt_listeners_conf, [passthrough]), meck:new(emqx_gateway_conf, [passthrough]), meck:expect( emqx_mgmt_listeners_conf, import_config, 1, {ok, #{changed => [], root_key => listeners}} ), meck:expect( emqx_gateway_conf, import_config, 1, {ok, #{changed => [], root_key => gateway}} ), [{cluster, cluster(TC, Config)} | setup(TC, Config)]; init_per_testcase(TC = t_verify_imported_mnesia_tab_on_cluster, Config) -> [{cluster, cluster(TC, Config)} | setup(TC, Config)]; init_per_testcase(t_mnesia_bad_tab_schema, Config) -> meck:new(emqx_mgmt_data_backup, [passthrough]), meck:expect(TC = emqx_mgmt_data_backup, mnesia_tabs_to_backup, 0, [?MODULE]), setup(TC, Config); init_per_testcase(TC, Config) -> setup(TC, Config). end_per_testcase(t_import_on_cluster, Config) -> emqx_cth_cluster:stop(?config(cluster, Config)), cleanup(Config), meck:unload(emqx_mgmt_listeners_conf), meck:unload(emqx_gateway_conf); end_per_testcase(t_verify_imported_mnesia_tab_on_cluster, Config) -> emqx_cth_cluster:stop(?config(cluster, Config)), cleanup(Config); end_per_testcase(t_mnesia_bad_tab_schema, Config) -> cleanup(Config), meck:unload(emqx_mgmt_data_backup); end_per_testcase(_TestCase, Config) -> cleanup(Config). t_empty_export_import(_Config) -> ExpRawConf = emqx:get_raw_config([]), {ok, #{filename := FileName}} = emqx_mgmt_data_backup:export(), Exp = {ok, #{db_errors => #{}, config_errors => #{}}}, ?assertEqual(Exp, emqx_mgmt_data_backup:import(FileName)), ?assertEqual(ExpRawConf, emqx:get_raw_config([])), %% idempotent update assert ?assertEqual(Exp, emqx_mgmt_data_backup:import(FileName)), ?assertEqual(ExpRawConf, emqx:get_raw_config([])). t_cluster_hocon_export_import(Config) -> RawConfBeforeImport = emqx:get_raw_config([]), BootstrapFile = filename:join(?config(data_dir, Config), ?BOOTSTRAP_BACKUP), Exp = {ok, #{db_errors => #{}, config_errors => #{}}}, ?assertEqual(Exp, emqx_mgmt_data_backup:import(BootstrapFile)), RawConfAfterImport = emqx:get_raw_config([]), ?assertNotEqual(RawConfBeforeImport, RawConfAfterImport), {ok, #{filename := FileName}} = emqx_mgmt_data_backup:export(), ?assertEqual(Exp, emqx_mgmt_data_backup:import(FileName)), ?assertEqual(RawConfAfterImport, emqx:get_raw_config([])), %% idempotent update assert ?assertEqual(Exp, emqx_mgmt_data_backup:import(FileName)), ?assertEqual(RawConfAfterImport, emqx:get_raw_config([])), %% lookup file inside /backup ?assertEqual(Exp, emqx_mgmt_data_backup:import(filename:basename(FileName))), %% backup data migration test ?assertMatch([_, _, _], ets:tab2list(emqx_app)), ?assertMatch( {ok, #{name := <<"key_to_export2">>, role := ?ROLE_API_SUPERUSER}}, emqx_mgmt_auth:read(<<"key_to_export2">>) ), ok. t_ee_to_ce_backup(Config) -> case emqx_release:edition() of ce -> EEBackupFileName = filename:join(?config(priv_dir, Config), "export-backup-ee.tar.gz"), Meta = unicode:characters_to_binary( hocon_pp:do(#{edition => ee, version => emqx_release:version()}, #{}) ), ok = erl_tar:create( EEBackupFileName, [ {"export-backup-ee/cluster.hocon", <<>>}, {"export-backup-ee/META.hocon", Meta} ], [compressed] ), ExpReason = ee_to_ce_backup, ?assertEqual( {error, ExpReason}, emqx_mgmt_data_backup:import(EEBackupFileName) ), %% Must be translated to a readable string ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(ExpReason)); ee -> %% Don't fail if the test is run with emqx-enterprise profile ok end. t_no_backup_file(_Config) -> ExpReason = not_found, ?assertEqual( {error, not_found}, emqx_mgmt_data_backup:import("no_such_backup.tar.gz") ), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(ExpReason)). t_bad_backup_file(Config) -> BadFileName = filename:join(?config(priv_dir, Config), "export-bad-backup-tar-gz"), ok = file:write_file(BadFileName, <<>>), NoMetaFileName = filename:join(?config(priv_dir, Config), "export-no-meta.tar.gz"), ok = erl_tar:create(NoMetaFileName, [{"export-no-meta/cluster.hocon", <<>>}], [compressed]), BadArchiveDirFileName = filename:join(?config(priv_dir, Config), "export-bad-dir.tar.gz"), ok = erl_tar:create( BadArchiveDirFileName, [ {"tmp/cluster.hocon", <<>>}, {"export-bad-dir-inside/META.hocon", <<>>}, {"/export-bad-dir-inside/mnesia/test_tab", <<>>} ], [compressed] ), InvalidEditionFileName = filename:join( ?config(priv_dir, Config), "export-invalid-edition.tar.gz" ), Meta = unicode:characters_to_binary( hocon_pp:do(#{edition => "test", version => emqx_release:version()}, #{}) ), ok = erl_tar:create( InvalidEditionFileName, [ {"export-invalid-edition/cluster.hocon", <<>>}, {"export-invalid-edition/META.hocon", Meta} ], [compressed] ), InvalidVersionFileName = filename:join( ?config(priv_dir, Config), "export-invalid-version.tar.gz" ), Meta1 = unicode:characters_to_binary( hocon_pp:do(#{edition => emqx_release:edition(), version => "test"}, #{}) ), ok = erl_tar:create( InvalidVersionFileName, [ {"export-invalid-version/cluster.hocon", <<>>}, {"export-invalid-version/META.hocon", Meta1} ], [compressed] ), BadFileNameReason = bad_backup_name, NoMetaReason = missing_backup_meta, BadArchiveDirReason = bad_archive_dir, InvalidEditionReason = invalid_edition, InvalidVersionReason = invalid_version, ?assertEqual({error, BadFileNameReason}, emqx_mgmt_data_backup:import(BadFileName)), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(BadFileNameReason)), ?assertEqual({error, NoMetaReason}, emqx_mgmt_data_backup:import(NoMetaFileName)), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(NoMetaReason)), ?assertEqual( {error, BadArchiveDirReason}, emqx_mgmt_data_backup:import(BadArchiveDirFileName) ), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(BadArchiveDirReason)), ?assertEqual( {error, InvalidEditionReason}, emqx_mgmt_data_backup:import(InvalidEditionFileName) ), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(InvalidEditionReason)), ?assertEqual( {error, InvalidVersionReason}, emqx_mgmt_data_backup:import(InvalidVersionFileName) ), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(InvalidVersionReason)). t_future_version(Config) -> CurrentVersion = list_to_binary(emqx_release:version()), [_, _ | Patch] = string:split(CurrentVersion, ".", all), {ok, {MajorInt, MinorInt}} = emqx_mgmt_data_backup:parse_version_no_patch(CurrentVersion), FutureMajorVersion = recompose_version(MajorInt + 1, MinorInt, Patch), FutureMinorVersion = recompose_version(MajorInt, MinorInt + 1, Patch), [MajorMeta, MinorMeta] = [ unicode:characters_to_binary( hocon_pp:do(#{edition => emqx_release:edition(), version => V}, #{}) ) || V <- [FutureMajorVersion, FutureMinorVersion] ], MajorFileName = filename:join(?config(priv_dir, Config), "export-future-major-ver.tar.gz"), MinorFileName = filename:join(?config(priv_dir, Config), "export-future-minor-ver.tar.gz"), ok = erl_tar:create( MajorFileName, [ {"export-future-major-ver/cluster.hocon", <<>>}, {"export-future-major-ver/META.hocon", MajorMeta} ], [compressed] ), ok = erl_tar:create( MinorFileName, [ {"export-future-minor-ver/cluster.hocon", <<>>}, {"export-future-minor-ver/META.hocon", MinorMeta} ], [compressed] ), ExpMajorReason = {unsupported_version, FutureMajorVersion}, ExpMinorReason = {unsupported_version, FutureMinorVersion}, ?assertEqual({error, ExpMajorReason}, emqx_mgmt_data_backup:import(MajorFileName)), ?assertEqual({error, ExpMinorReason}, emqx_mgmt_data_backup:import(MinorFileName)), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(ExpMajorReason)), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(ExpMinorReason)). t_bad_config(Config) -> BadConfigFileName = filename:join(?config(priv_dir, Config), "export-bad-config-backup.tar.gz"), Meta = unicode:characters_to_binary( hocon_pp:do(#{edition => emqx_release:edition(), version => emqx_release:version()}, #{}) ), BadConfigMap = #{ <<"listeners">> => #{ <<"bad-type">> => #{<<"bad-name">> => #{<<"bad-field">> => <<"bad-val">>}} } }, BadConfig = unicode:characters_to_binary(hocon_pp:do(BadConfigMap, #{})), ok = erl_tar:create( BadConfigFileName, [ {"export-bad-config-backup/cluster.hocon", BadConfig}, {"export-bad-config-backup/META.hocon", Meta} ], [compressed] ), Res = emqx_mgmt_data_backup:import(BadConfigFileName), ?assertMatch({error, #{kind := validation_error}}, Res). t_import_on_cluster(Config) -> %% Randomly chosen config key to verify import result additionally ?assertEqual([], emqx:get_config([authentication])), BootstrapFile = filename:join(?config(data_dir, Config), ?BOOTSTRAP_BACKUP), ExpImportRes = {ok, #{db_errors => #{}, config_errors => #{}}}, ?assertEqual(ExpImportRes, emqx_mgmt_data_backup:import(BootstrapFile)), ImportedAuthnConf = emqx:get_config([authentication]), ?assertMatch([_ | _], ImportedAuthnConf), {ok, #{filename := FileName}} = emqx_mgmt_data_backup:export(), {ok, Cwd} = file:get_cwd(), AbsFilePath = filename:join(Cwd, FileName), [CoreNode1, _CoreNode2, ReplicantNode] = NodesList = ?config(cluster, Config), ReplImportReason = not_core_node, ?assertEqual( {error, ReplImportReason}, rpc:call(ReplicantNode, emqx_mgmt_data_backup, import, [AbsFilePath]) ), ?assertMatch([_ | _], emqx_mgmt_data_backup:format_error(ReplImportReason)), [?assertEqual([], rpc:call(N, emqx, get_config, [[authentication]])) || N <- NodesList], ?assertEqual( ExpImportRes, rpc:call(CoreNode1, emqx_mgmt_data_backup, import, [AbsFilePath]) ), [ ?assertEqual( authn_ids(ImportedAuthnConf), authn_ids(rpc:call(N, emqx, get_config, [[authentication]])) ) || N <- NodesList ]. t_verify_imported_mnesia_tab_on_cluster(Config) -> UsersToExport = users(<<"user_to_export_">>), UsersBeforeImport = users(<<"user_before_import_">>), [{ok, _} = emqx_dashboard_admin:add_user(U, U, ?ROLE_SUPERUSER, U) || U <- UsersToExport], {ok, #{filename := FileName}} = emqx_mgmt_data_backup:export(), {ok, Cwd} = file:get_cwd(), AbsFilePath = filename:join(Cwd, FileName), [CoreNode1, CoreNode2, ReplicantNode] = ?config(cluster, Config), [ {ok, _} = rpc:call(CoreNode1, emqx_dashboard_admin, add_user, [U, U, ?ROLE_SUPERUSER, U]) || U <- UsersBeforeImport ], ?assertEqual( {ok, #{db_errors => #{}, config_errors => #{}}}, rpc:call(CoreNode1, emqx_mgmt_data_backup, import, [AbsFilePath]) ), [Tab] = emqx_dashboard_admin:backup_tables(), AllUsers = lists:sort(mnesia:dirty_all_keys(Tab) ++ UsersBeforeImport), [ ?assertEqual( AllUsers, lists:sort(rpc:call(N, mnesia, dirty_all_keys, [Tab])) ) || N <- [CoreNode1, CoreNode2] ], %% Give some extra time to replicant to import data... timer:sleep(3000), ?assertEqual(AllUsers, lists:sort(rpc:call(ReplicantNode, mnesia, dirty_all_keys, [Tab]))). backup_tables() -> [data_backup_test]. t_mnesia_bad_tab_schema(_Config) -> OldAttributes = [id, name, description], ok = create_test_tab(OldAttributes), ok = mria:dirty_write({data_backup_test, <<"id">>, <<"old_name">>, <<"old_description">>}), {ok, #{filename := FileName}} = emqx_mgmt_data_backup:export(), {atomic, ok} = mnesia:delete_table(data_backup_test), NewAttributes = [id, name, description, new_field], ok = create_test_tab(NewAttributes), NewRec = {data_backup_test, <<"id">>, <<"new_name">>, <<"new_description">>, <<"new_field_value">>}, ok = mria:dirty_write(NewRec), ?assertEqual( {ok, #{ db_errors => #{data_backup_test => {error, {"Backup traversal failed", different_table_schema}}}, config_errors => #{} }}, emqx_mgmt_data_backup:import(FileName) ), ?assertEqual([NewRec], mnesia:dirty_read(data_backup_test, <<"id">>)), ?assertEqual([<<"id">>], mnesia:dirty_all_keys(data_backup_test)). t_read_files(_Config) -> DataDir = emqx:data_dir(), {ok, Cwd} = file:get_cwd(), AbsDataDir = filename:join(Cwd, DataDir), FileBaseName = "t_read_files_tmp_file", TestFileAbsPath = iolist_to_binary(filename:join(AbsDataDir, FileBaseName)), TestFilePath = iolist_to_binary(filename:join(DataDir, FileBaseName)), TestFileContent = <<"test_file_content">>, ok = file:write_file(TestFileAbsPath, TestFileContent), RawConf = #{ <<"test_rootkey">> => #{ <<"test_field">> => <<"test_field_path">>, <<"abs_data_dir_path_file">> => TestFileAbsPath, <<"rel_data_dir_path_file">> => TestFilePath, <<"path_outside_data_dir">> => <<"/tmp/some-file">> } }, RawConf1 = emqx_utils_maps:deep_put( [<<"test_rootkey">>, <<"abs_data_dir_path_file">>], RawConf, TestFileContent ), ExpectedConf = emqx_utils_maps:deep_put( [<<"test_rootkey">>, <<"rel_data_dir_path_file">>], RawConf1, TestFileContent ), ?assertEqual(ExpectedConf, emqx_mgmt_data_backup:read_data_files(RawConf)). %%------------------------------------------------------------------------------ %% Internal test helpers %%------------------------------------------------------------------------------ setup(TC, Config) -> WorkDir = filename:join(emqx_cth_suite:work_dir(TC, Config), local), Started = emqx_cth_suite:start(apps_to_start(), #{work_dir => WorkDir}), [{suite_apps, Started} | Config]. cleanup(Config) -> emqx_cth_suite:stop(?config(suite_apps, Config)). users(Prefix) -> [ <> || _ <- lists:seq(1, 10) ]. authn_ids(AuthnConf) -> lists:sort([emqx_authn_chains:authenticator_id(Conf) || Conf <- AuthnConf]). recompose_version(MajorInt, MinorInt, Patch) -> unicode:characters_to_list( [integer_to_list(MajorInt + 1), $., integer_to_list(MinorInt), $. | Patch] ). cluster(TC, Config) -> Nodes = emqx_cth_cluster:start( [ {data_backup_core1, #{role => core, apps => apps_to_start()}}, {data_backup_core2, #{role => core, apps => apps_to_start()}}, {data_backup_replicant, #{role => replicant, apps => apps_to_start()}} ], #{work_dir => emqx_cth_suite:work_dir(TC, Config)} ), Nodes. create_test_tab(Attributes) -> ok = mria:create_table(data_backup_test, [ {type, set}, {rlog_shard, data_backup_test_shard}, {storage, disc_copies}, {record_name, data_backup_test}, {attributes, Attributes}, {storage_properties, [ {ets, [ {read_concurrency, true}, {write_concurrency, true} ]} ]} ]), ok = mria:wait_for_tables([data_backup_test]). apps_to_start() -> [ {emqx, #{override_env => [{boot_modules, [broker]}]}}, {emqx_conf, #{config => #{dashboard => #{listeners => #{http => #{bind => <<"0">>}}}}}}, emqx_psk, emqx_management, emqx_dashboard, emqx_auth, emqx_auth_http, emqx_auth_jwt, emqx_auth_mnesia, emqx_auth_mongodb, emqx_auth_mysql, emqx_auth_postgresql, emqx_auth_redis, emqx_rule_engine, emqx_retainer, emqx_prometheus, emqx_modules, emqx_gateway, emqx_exhook, emqx_bridge_http, emqx_bridge, emqx_auto_subscribe, % loaded only emqx_gateway_lwm2m, emqx_gateway_coap, emqx_gateway_exproto, emqx_gateway_stomp, emqx_gateway_mqttsn ].