emqx/apps/emqx_management/src/emqx_mgmt_api_configs.erl

490 lines
16 KiB
Erlang

%%--------------------------------------------------------------------
%% Copyright (c) 2020-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_api_configs).
-include_lib("hocon/include/hoconsc.hrl").
-include_lib("emqx/include/logger.hrl").
-behaviour(minirest_api).
-export([api_spec/0, namespace/0]).
-export([paths/0, schema/1, fields/1]).
-export([
config/3,
config_reset/3,
configs/3,
get_full_config/0,
global_zone_configs/3,
limiter/3
]).
-define(PREFIX, "/configs/").
-define(PREFIX_RESET, "/configs_reset/").
-define(ERR_MSG(MSG), list_to_binary(io_lib:format("~p", [MSG]))).
-define(OPTS, #{rawconf_with_defaults => true, override_to => cluster}).
-define(TAGS, ["Configs"]).
-if(?EMQX_RELEASE_EDITION == ee).
-define(ROOT_KEYS_EE, [
<<"file_transfer">>
]).
-else.
-define(ROOT_KEYS_EE, []).
-endif.
-define(ROOT_KEYS, [
<<"dashboard">>,
<<"alarm">>,
<<"sys_topics">>,
<<"sysmon">>,
<<"log">>
| ?ROOT_KEYS_EE
]).
%% erlfmt-ignore
-define(SYSMON_EXAMPLE,
<<"""
sysmon {
os {
cpu_check_interval = 60s
cpu_high_watermark = 80%
cpu_low_watermark = 60%
mem_check_interval = 60s
procmem_high_watermark = 5%
sysmem_high_watermark = 70%
}
vm {
busy_dist_port = true
busy_port = true
large_heap = 32MB
long_gc = disabled
long_schedule = 240ms
process_check_interval = 30s
process_high_watermark = 80%
process_low_watermark = 60%
}
}
""">>
).
api_spec() ->
emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}).
namespace() -> "configuration".
paths() ->
[
"/configs",
"/configs_reset/:rootname",
"/configs/global_zone",
"/configs/limiter"
] ++
lists:map(fun({Name, _Type}) -> ?PREFIX ++ binary_to_list(Name) end, config_list()).
schema("/configs") ->
#{
'operationId' => configs,
get => #{
tags => ?TAGS,
description => ?DESC(get_configs),
parameters => [
{key,
hoconsc:mk(
hoconsc:enum([binary_to_atom(K) || K <- emqx_conf_cli:keys()]),
#{in => query, example => <<"sysmon">>, required => false}
)},
{node,
hoconsc:mk(
typerefl:atom(),
#{
in => query,
required => false,
description => ?DESC(node_name),
hidden => true
}
)}
],
responses => #{
200 => #{
content =>
%% use proplists( not map) to make user text/plain is default in swagger
[
{'text/plain', #{
schema => #{type => string, example => ?SYSMON_EXAMPLE}
}},
{'application/json', #{
schema => #{type => object, example => #{<<"deprecated">> => true}}
}}
]
},
400 => emqx_dashboard_swagger:error_codes(['INVALID_ACCEPT']),
404 => emqx_dashboard_swagger:error_codes(['NOT_FOUND']),
500 => emqx_dashboard_swagger:error_codes(['BAD_NODE'])
}
},
put => #{
tags => ?TAGS,
description => ?DESC(update_configs),
parameters => [
{mode,
hoconsc:mk(
hoconsc:enum([replace, merge]),
#{in => query, default => merge, required => false}
)}
],
'requestBody' => #{
content =>
#{
'text/plain' =>
#{schema => #{type => string, example => ?SYSMON_EXAMPLE}}
}
},
responses => #{
200 => <<"Configurations updated">>,
400 => emqx_dashboard_swagger:error_codes(['UPDATE_FAILED'])
}
}
};
schema("/configs_reset/:rootname") ->
Paths = lists:map(fun({Path, _}) -> binary_to_atom(Path) end, config_list()),
#{
'operationId' => config_reset,
post => #{
tags => ?TAGS,
description => ?DESC(rest_conf_query),
%% We only return "200" rather than the new configs that has been changed, as
%% the schema of the changed configs is depends on the request parameter
%% `conf_path`, it cannot be defined here.
parameters => [
{rootname,
hoconsc:mk(
hoconsc:enum(Paths),
#{in => path, example => <<"sysmon">>}
)},
{conf_path,
hoconsc:mk(
typerefl:binary(),
#{
in => query,
required => false,
example => <<"os.sysmem_high_watermark">>,
desc => <<"The config path separated by '.' character">>
}
)}
],
responses => #{
200 => <<"Rest config successfully">>,
400 => emqx_dashboard_swagger:error_codes(['NO_DEFAULT_VALUE', 'REST_FAILED']),
403 => emqx_dashboard_swagger:error_codes(['REST_FAILED'])
}
}
};
schema("/configs/global_zone") ->
Schema = global_zone_schema(),
#{
'operationId' => global_zone_configs,
get => #{
tags => ?TAGS,
description => ?DESC(get_global_zone_configs),
responses => #{200 => Schema}
},
put => #{
tags => ?TAGS,
description => ?DESC(update_global_zone_configs),
'requestBody' => Schema,
responses => #{
200 => Schema,
400 => emqx_dashboard_swagger:error_codes(['UPDATE_FAILED']),
403 => emqx_dashboard_swagger:error_codes(['UPDATE_FAILED'])
}
}
};
schema("/configs/limiter") ->
#{
'operationId' => limiter,
get => #{
tags => ?TAGS,
hidden => true,
description => ?DESC(get_node_level_limiter_configs),
responses => #{
200 => hoconsc:mk(hoconsc:ref(emqx_limiter_schema, limiter)),
404 => emqx_dashboard_swagger:error_codes(['NOT_FOUND'], <<"config not found">>)
}
},
put => #{
tags => ?TAGS,
hidden => true,
description => ?DESC(update_node_level_limiter_configs),
'requestBody' => hoconsc:mk(hoconsc:ref(emqx_limiter_schema, limiter)),
responses => #{
200 => hoconsc:mk(hoconsc:ref(emqx_limiter_schema, limiter)),
400 => emqx_dashboard_swagger:error_codes(['UPDATE_FAILED']),
403 => emqx_dashboard_swagger:error_codes(['UPDATE_FAILED'])
}
}
};
schema(Path) ->
{RootKey, {_Root, Schema}} = find_schema(Path),
GetDesc = iolist_to_binary([
<<"Get the sub-configurations under *">>,
RootKey,
<<"*">>
]),
PutDesc = iolist_to_binary([
<<"Update the sub-configurations under *">>,
RootKey,
<<"*">>
]),
#{
'operationId' => config,
get => #{
tags => ?TAGS,
desc => GetDesc,
summary => GetDesc,
responses => #{
200 => Schema,
404 => emqx_dashboard_swagger:error_codes(['NOT_FOUND'], <<"config not found">>)
}
},
put => #{
tags => ?TAGS,
desc => PutDesc,
summary => PutDesc,
'requestBody' => Schema,
responses => #{
200 => Schema,
400 => emqx_dashboard_swagger:error_codes(['UPDATE_FAILED']),
403 => emqx_dashboard_swagger:error_codes(['UPDATE_FAILED'])
}
}
}.
find_schema(Path) ->
[_, _Prefix, Root | _] = string:split(Path, "/", all),
Configs = config_list(),
lists:keyfind(list_to_binary(Root), 1, Configs).
%% we load all configs from emqx_*_schema, some of them are defined as local ref
%% we need redirect to emqx_*_schema.
%% such as hoconsc:ref("node") to hoconsc:ref(emqx_*_schema, "node")
fields(Field) ->
Mod = emqx_conf:schema_module(),
apply(Mod, fields, [Field]).
%%%==============================================================================================
%% HTTP API Callbacks
config(get, _Params, Req) ->
[Path] = conf_path(Req),
{200, get_raw_config(Path)};
config(put, #{body := NewConf}, Req) ->
Path = conf_path(Req),
case emqx_conf:update(Path, NewConf, ?OPTS) of
{ok, #{raw_config := RawConf}} ->
{200, RawConf};
{error, Reason} ->
{400, #{code => 'UPDATE_FAILED', message => ?ERR_MSG(Reason)}}
end.
global_zone_configs(get, _Params, _Req) ->
{200, get_zones()};
global_zone_configs(put, #{body := Body}, _Req) ->
PrevZones = get_zones(),
Res =
maps:fold(
fun(Path, Value, Acc) ->
PrevValue = maps:get(Path, PrevZones),
case Value =/= PrevValue of
true ->
case emqx_conf:update([Path], Value, ?OPTS) of
{ok, #{raw_config := RawConf}} ->
Acc#{Path => RawConf};
{error, Reason} ->
?SLOG(error, #{
msg => "update_global_zone_failed",
reason => Reason,
path => Path,
value => Value
}),
Acc
end;
false ->
Acc#{Path => Value}
end
end,
#{},
Body
),
case maps:size(Res) =:= maps:size(Body) of
true -> {200, Res};
false -> {400, #{code => 'UPDATE_FAILED'}}
end.
config_reset(post, _Params, Req) ->
%% reset the config specified by the query string param 'conf_path'
Path = conf_path_reset(Req) ++ conf_path_from_querystr(Req),
case emqx_conf:reset(Path, ?OPTS) of
{ok, _} ->
{200};
{error, no_default_value} ->
{400, #{code => 'NO_DEFAULT_VALUE', message => <<"No Default Value.">>}};
{error, Reason} ->
{400, #{code => 'REST_FAILED', message => ?ERR_MSG(Reason)}}
end.
configs(get, #{query_string := QueryStr, headers := Headers}, _Req) ->
%% Should deprecated json v1 since 5.2.0
case find_suitable_accept(Headers, [<<"text/plain">>, <<"application/json">>]) of
{ok, <<"application/json">>} -> get_configs_v1(QueryStr);
{ok, <<"text/plain">>} -> get_configs_v2(QueryStr);
{error, _} = Error -> {400, #{code => 'INVALID_ACCEPT', message => ?ERR_MSG(Error)}}
end;
configs(put, #{body := Conf, query_string := #{<<"mode">> := Mode}}, _Req) ->
case emqx_conf_cli:load_config(Conf, #{mode => Mode, log => none}) of
ok -> {200};
{error, Msg} -> {400, #{<<"content-type">> => <<"text/plain">>}, Msg}
end.
find_suitable_accept(Headers, Preferences) when is_list(Preferences), length(Preferences) > 0 ->
AcceptVal = maps:get(<<"accept">>, Headers, <<"*/*">>),
%% Multiple types, weighted with the quality value syntax:
%% Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8
Accepts = lists:map(
fun(S) ->
[T | _] = binary:split(string:trim(S), <<";">>),
T
end,
re:split(AcceptVal, ",")
),
case lists:member(<<"*/*">>, Accepts) of
true ->
{ok, lists:nth(1, Preferences)};
false ->
Found = lists:filter(fun(Accept) -> lists:member(Accept, Accepts) end, Preferences),
case Found of
[] -> {error, no_suitable_accept};
_ -> {ok, lists:nth(1, Found)}
end
end.
%% To return a JSON formatted configuration file, which is used to be compatible with the already
%% implemented `GET /configs` in the old versions 5.0 and 5.1.
%%
%% In e5.1.1, we support to return a hocon configuration file by `get_configs_v2/1`. It's more
%% useful for the user to read or reload the configuration file via HTTP API.
%%
%% The `get_configs_v1/1` should be deprecated since 5.2.0.
get_configs_v1(QueryStr) ->
Node = maps:get(<<"node">>, QueryStr, node()),
case
lists:member(Node, emqx:running_nodes()) andalso
emqx_management_proto_v4:get_full_config(Node)
of
false ->
Message = list_to_binary(io_lib:format("Bad node ~p, reason not found", [Node])),
{404, #{code => 'NOT_FOUND', message => Message}};
{badrpc, R} ->
Message = list_to_binary(io_lib:format("Bad node ~p, reason ~p", [Node, R])),
{500, #{code => 'BAD_NODE', message => Message}};
Res ->
{200, Res}
end.
get_configs_v2(QueryStr) ->
Node = maps:get(<<"node">>, QueryStr, node()),
Conf =
case maps:find(<<"key">>, QueryStr) of
error ->
emqx_conf_proto_v3:get_hocon_config(Node);
{ok, Key} ->
emqx_conf_proto_v3:get_hocon_config(Node, atom_to_binary(Key))
end,
{
200,
#{<<"content-type">> => <<"text/plain">>},
iolist_to_binary(hocon_pp:do(Conf, #{}))
}.
limiter(get, _Params, _Req) ->
{200, format_limiter_config(get_raw_config(limiter))};
limiter(put, #{body := NewConf}, _Req) ->
case emqx_conf:update([limiter], NewConf, ?OPTS) of
{ok, #{raw_config := RawConf}} ->
{200, format_limiter_config(RawConf)};
{error, {permission_denied, Reason}} ->
{403, #{code => 'UPDATE_FAILED', message => Reason}};
{error, Reason} ->
{400, #{code => 'UPDATE_FAILED', message => ?ERR_MSG(Reason)}}
end.
format_limiter_config(RawConf) ->
Shorts = lists:map(fun erlang:atom_to_binary/1, emqx_limiter_schema:short_paths()),
maps:with(Shorts, RawConf).
conf_path_reset(Req) ->
<<"/api/v5", ?PREFIX_RESET, Path/binary>> = cowboy_req:path(Req),
string:lexemes(Path, "/ ").
get_full_config() ->
emqx_config:fill_defaults(
maps:with(
?ROOT_KEYS,
emqx:get_raw_config([])
),
#{obfuscate_sensitive_values => true}
).
get_raw_config(Path) ->
#{Path := Conf} =
emqx_config:fill_defaults(
#{Path => emqx:get_raw_config([Path])},
#{obfuscate_sensitive_values => true}
),
Conf.
get_zones() ->
lists:foldl(
fun(Path, Acc) ->
maps:merge(Acc, get_config_with_default(Path))
end,
#{},
global_zone_roots()
).
get_config_with_default(Path) ->
emqx_config:fill_defaults(#{Path => emqx:get_raw_config([Path])}).
conf_path_from_querystr(Req) ->
case proplists:get_value(<<"conf_path">>, cowboy_req:parse_qs(Req)) of
undefined -> [];
Path -> string:lexemes(Path, ". ")
end.
config_list() ->
Mod = emqx_conf:schema_module(),
Roots = hocon_schema:roots(Mod),
lists:foldl(fun(Key, Acc) -> [lists:keyfind(Key, 1, Roots) | Acc] end, [], ?ROOT_KEYS).
conf_path(Req) ->
<<"/api/v5", ?PREFIX, Path/binary>> = cowboy_req:path(Req),
string:lexemes(Path, "/ ").
global_zone_roots() ->
lists:map(fun({K, _}) -> list_to_binary(K) end, global_zone_schema()).
global_zone_schema() ->
emqx_zone_schema:global_zone_with_default().