emqx_bpapi_static_checks.erl 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. %%--------------------------------------------------------------------
  2. %% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
  3. %%
  4. %% Licensed under the Apache License, Version 2.0 (the "License");
  5. %% you may not use this file except in compliance with the License.
  6. %% You may obtain a copy of the License at
  7. %%
  8. %% http://www.apache.org/licenses/LICENSE-2.0
  9. %%
  10. %% Unless required by applicable law or agreed to in writing, software
  11. %% distributed under the License is distributed on an "AS IS" BASIS,
  12. %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. %% See the License for the specific language governing permissions and
  14. %% limitations under the License.
  15. %%--------------------------------------------------------------------
  16. -module(emqx_bpapi_static_checks).
  17. -export([run/0, dump/1, dump/0, check_compat/1]).
  18. -include_lib("emqx/include/logger.hrl").
  19. %% Using an undocumented API here :(
  20. -include_lib("dialyzer/src/dialyzer.hrl").
  21. -type api_dump() :: #{{emqx_bpapi:api(), emqx_bpapi:api_version()} =>
  22. #{ calls := [emqx_bpapi:rpc()]
  23. , casts := [emqx_bpapi:rpc()]
  24. }}.
  25. -type dialyzer_spec() :: {_Type, [_Type]}.
  26. -type dialyzer_dump() :: #{mfa() => dialyzer_spec()}.
  27. -type fulldump() :: #{ api => api_dump()
  28. , signatures => dialyzer_dump()
  29. , release => string()
  30. }.
  31. -type dump_options() :: #{ reldir := file:name()
  32. , plt := file:name()
  33. }.
  34. -type param_types() :: #{emqx_bpapi:var_name() => _Type}.
  35. %% Applications and modules we wish to ignore in the analysis:
  36. -define(IGNORED_APPS, "gen_rpc, recon, redbug, observer_cli, snabbkaffe, ekka, mria").
  37. -define(IGNORED_MODULES, "emqx_rpc").
  38. %% List of known RPC backend modules:
  39. -define(RPC_MODULES, "gen_rpc, erpc, rpc, emqx_rpc").
  40. %% List of known functions also known to do RPC:
  41. -define(RPC_FUNCTIONS, "emqx_cluster_rpc:multicall/3, emqx_cluster_rpc:multicall/5").
  42. %% List of functions in the RPC backend modules that we can ignore:
  43. -define(IGNORED_RPC_CALLS, "gen_rpc:nodes/0").
  44. -define(XREF, myxref).
  45. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  46. %% Functions related to BPAPI compatibility checking
  47. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  48. -spec run() -> boolean().
  49. run() ->
  50. dump(), %% TODO: check return value
  51. Dumps = filelib:wildcard(dumps_dir() ++ "/*.bpapi"),
  52. case Dumps of
  53. [] ->
  54. ?ERROR("No BPAPI dumps are found in ~s, abort", [dumps_dir()]),
  55. false;
  56. _ ->
  57. ?NOTICE("Running API compatibility checks for ~p", [Dumps]),
  58. check_compat(Dumps)
  59. end.
  60. -spec check_compat([file:filename()]) -> boolean().
  61. check_compat(DumpFilenames) ->
  62. put(bpapi_ok, true),
  63. Dumps = lists:map(fun(FN) ->
  64. {ok, [Dump]} = file:consult(FN),
  65. Dump
  66. end,
  67. DumpFilenames),
  68. [check_compat(I, J) || I <- Dumps, J <- Dumps],
  69. erase(bpapi_ok).
  70. %% Note: sets nok flag
  71. -spec check_compat(fulldump(), fulldump()) -> ok.
  72. check_compat(Dump1, Dump2) ->
  73. check_api_immutability(Dump1, Dump2),
  74. typecheck_apis(Dump1, Dump2).
  75. %% It's not allowed to change BPAPI modules. Check that no changes
  76. %% have been made. (sets nok flag)
  77. -spec check_api_immutability(fulldump(), fulldump()) -> ok.
  78. check_api_immutability(#{release := Rel1, api := APIs1}, #{release := Rel2, api := APIs2})
  79. when Rel2 >= Rel1 ->
  80. %% TODO: Handle API deprecation
  81. _ = maps:map(
  82. fun(Key = {API, Version}, Val) ->
  83. case maps:get(Key, APIs2, undefined) of
  84. Val ->
  85. ok;
  86. undefined ->
  87. setnok(),
  88. ?ERROR("API ~p v~p was removed in release ~p without being deprecated.",
  89. [API, Version, Rel2]);
  90. _Val ->
  91. setnok(),
  92. ?ERROR("API ~p v~p was changed between ~p and ~p. Backplane API should be immutable.",
  93. [API, Version, Rel1, Rel2])
  94. end
  95. end,
  96. APIs1),
  97. ok;
  98. check_api_immutability(_, _) ->
  99. ok.
  100. %% Note: sets nok flag
  101. -spec typecheck_apis(fulldump(), fulldump()) -> ok.
  102. typecheck_apis( #{release := CallerRelease, api := CallerAPIs, signatures := CallerSigs}
  103. , #{release := CalleeRelease, signatures := CalleeSigs}
  104. ) ->
  105. AllCalls = lists:flatten([[Calls, Casts]
  106. || #{calls := Calls, casts := Casts} <- maps:values(CallerAPIs)]),
  107. lists:foreach(fun({From, To}) ->
  108. Caller = get_param_types(CallerSigs, From),
  109. Callee = get_param_types(CalleeSigs, To),
  110. %% TODO: check return types
  111. case typecheck_rpc(Caller, Callee) of
  112. [] ->
  113. ok;
  114. TypeErrors ->
  115. setnok(),
  116. [?ERROR("Incompatible RPC call: "
  117. "type of the parameter ~p of RPC call ~s on release ~p "
  118. "is not a subtype of the target function ~s on release ~p.~n"
  119. "Caller type: ~s~nCallee type: ~s~n",
  120. [Var, format_call(From), CallerRelease,
  121. format_call(To), CalleeRelease,
  122. erl_types:t_to_string(CallerType),
  123. erl_types:t_to_string(CalleeType)])
  124. || {Var, CallerType, CalleeType} <- TypeErrors]
  125. end
  126. end,
  127. AllCalls).
  128. -spec typecheck_rpc(param_types(), param_types()) -> [{emqx_bpapi:var_name(), _Type, _Type}].
  129. typecheck_rpc(Caller, Callee) ->
  130. maps:fold(fun(Var, CalleeType, Acc) ->
  131. #{Var := CallerType} = Caller,
  132. case erl_types:t_is_subtype(CallerType, CalleeType) of
  133. true -> Acc;
  134. false -> [{Var, CallerType, CalleeType}|Acc]
  135. end
  136. end,
  137. [],
  138. Callee).
  139. -spec get_param_types(dialyzer_dump(), emqx_bpapi:call()) -> param_types().
  140. get_param_types(Signatures, {M, F, A}) ->
  141. Arity = length(A),
  142. #{{M, F, Arity} := {_RetType, AttrTypes}} = Signatures,
  143. Arity = length(AttrTypes), % assert
  144. maps:from_list(lists:zip(A, AttrTypes)).
  145. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  146. %% Functions related to BPAPI dumping
  147. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  148. dump() ->
  149. case { filelib:wildcard(project_root_dir() ++ "/*_plt")
  150. , filelib:wildcard(project_root_dir() ++ "/_build/emqx*/lib")
  151. } of
  152. {[PLT|_], [RelDir|_]} ->
  153. dump(#{ plt => PLT
  154. , reldir => RelDir
  155. });
  156. _ ->
  157. error("failed to guess run options")
  158. end.
  159. %% Collect the local BPAPI modules to a dump file
  160. -spec dump(dump_options()) -> boolean().
  161. dump(Opts) ->
  162. put(bpapi_ok, true),
  163. PLT = prepare(Opts),
  164. %% First we run XREF to find all callers of any known RPC backend:
  165. Callers = find_remote_calls(Opts),
  166. {BPAPICalls, NonBPAPICalls} = lists:partition(fun is_bpapi_call/1, Callers),
  167. warn_nonbpapi_rpcs(NonBPAPICalls),
  168. APIDump = collect_bpapis(BPAPICalls),
  169. DialyzerDump = collect_signatures(PLT, APIDump),
  170. [Release|_] = string:split(emqx_app:get_release(), "-"),
  171. dump_api(#{api => APIDump, signatures => DialyzerDump, release => Release}),
  172. xref:stop(?XREF),
  173. erase(bpapi_ok).
  174. prepare(#{reldir := RelDir, plt := PLT}) ->
  175. ?INFO("Starting xref...", []),
  176. xref:start(?XREF),
  177. filelib:wildcard(RelDir ++ "/*/ebin/") =:= [] andalso
  178. error("No applications found in the release directory. Wrong directory?"),
  179. xref:set_default(?XREF, [{warnings, false}]),
  180. xref:add_release(?XREF, RelDir),
  181. %% Now to the dialyzer stuff:
  182. ?INFO("Loading PLT...", []),
  183. dialyzer_plt:from_file(PLT).
  184. find_remote_calls(_Opts) ->
  185. Query = "XC | (A - [" ?IGNORED_APPS "]:App - [" ?IGNORED_MODULES "] : Mod)
  186. || (([" ?RPC_MODULES "] : Mod + [" ?RPC_FUNCTIONS "]) - " ?IGNORED_RPC_CALLS ")",
  187. {ok, Calls} = xref:q(?XREF, Query),
  188. ?INFO("Calls to RPC modules ~p", [Calls]),
  189. {Callers, _Callees} = lists:unzip(Calls),
  190. Callers.
  191. -spec warn_nonbpapi_rpcs([mfa()]) -> ok.
  192. warn_nonbpapi_rpcs([]) ->
  193. ok;
  194. warn_nonbpapi_rpcs(L) ->
  195. setnok(),
  196. lists:foreach(fun({M, F, A}) ->
  197. ?ERROR("~p:~p/~p does a remote call outside of a dedicated "
  198. "backplane API module. "
  199. "It may break during rolling cluster upgrade",
  200. [M, F, A])
  201. end,
  202. L).
  203. -spec is_bpapi_call(mfa()) -> boolean().
  204. is_bpapi_call({Module, _Function, _Arity}) ->
  205. case catch Module:bpapi_meta() of
  206. #{api := _} -> true;
  207. _ -> false
  208. end.
  209. -spec dump_api(fulldump()) -> ok.
  210. dump_api(Term = #{api := _, signatures := _, release := Release}) ->
  211. Filename = filename:join(dumps_dir(), Release ++ ".bpapi"),
  212. ok = filelib:ensure_dir(Filename),
  213. file:write_file(Filename, io_lib:format("~0p.", [Term])).
  214. -spec collect_bpapis([mfa()]) -> api_dump().
  215. collect_bpapis(L) ->
  216. Modules = lists:usort([M || {M, _F, _A} <- L]),
  217. lists:foldl(fun(Mod, Acc) ->
  218. #{ api := API
  219. , version := Vsn
  220. , calls := Calls
  221. , casts := Casts
  222. } = Mod:bpapi_meta(),
  223. Acc#{{API, Vsn} => #{ calls => Calls
  224. , casts => Casts
  225. }}
  226. end,
  227. #{},
  228. Modules).
  229. -spec collect_signatures(_PLT, api_dump()) -> dialyzer_dump().
  230. collect_signatures(PLT, APIs) ->
  231. maps:fold(fun(_APIAndVersion, #{calls := Calls, casts := Casts}, Acc0) ->
  232. Acc1 = lists:foldl(fun enrich/2, {Acc0, PLT}, Calls),
  233. {Acc, PLT} = lists:foldl(fun enrich/2, Acc1, Casts),
  234. Acc
  235. end,
  236. #{},
  237. APIs).
  238. %% Add information about the call types from the PLT
  239. -spec enrich(emqx_bpapi:rpc(), {dialyzer_dump(), _PLT}) -> {dialyzer_dump(), _PLT}.
  240. enrich({From0, To0}, {Acc0, PLT}) ->
  241. From = call_to_mfa(From0),
  242. To = call_to_mfa(To0),
  243. case {dialyzer_plt:lookup_contract(PLT, From), dialyzer_plt:lookup(PLT, To)} of
  244. {{value, #contract{args = FromArgs}}, {value, TTo}} ->
  245. %% TODO: Check return type
  246. FromRet = erl_types:t_any(),
  247. Acc = Acc0#{ From => {FromRet, FromArgs}
  248. , To => TTo
  249. },
  250. {Acc, PLT};
  251. {{value, _}, none} ->
  252. setnok(),
  253. ?CRITICAL("Backplane API function ~s calls a missing remote function ~s",
  254. [format_call(From0), format_call(To0)]),
  255. error(missing_target)
  256. end.
  257. -spec call_to_mfa(emqx_bpapi:call()) -> mfa().
  258. call_to_mfa({M, F, A}) ->
  259. {M, F, length(A)}.
  260. format_call({M, F, A}) ->
  261. io_lib:format("~p:~p/~p", [M, F, length(A)]).
  262. setnok() ->
  263. put(bpapi_ok, false).
  264. dumps_dir() ->
  265. filename:join(project_root_dir(), "apps/emqx/test/emqx_bpapi_suite_data").
  266. project_root_dir() ->
  267. string:trim(os:cmd("git rev-parse --show-toplevel")).