emqx_bpapi_static_checks.erl 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  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, versions_file/0, dumps_dir/0]).
  18. %% Using an undocumented API here :(
  19. -include_lib("dialyzer/src/dialyzer.hrl").
  20. -type api_dump() :: #{
  21. {emqx_bpapi:api(), emqx_bpapi:api_version()} =>
  22. #{
  23. calls := [emqx_bpapi:rpc()],
  24. casts := [emqx_bpapi:rpc()]
  25. }
  26. }.
  27. -type dialyzer_spec() :: {_Type, [_Type]}.
  28. -type dialyzer_dump() :: #{mfa() => dialyzer_spec()}.
  29. -type fulldump() :: #{
  30. api => api_dump(),
  31. signatures => dialyzer_dump(),
  32. release => string()
  33. }.
  34. -type dump_options() :: #{
  35. reldir := file:name(),
  36. plt := file:name()
  37. }.
  38. -type param_types() :: #{emqx_bpapi:var_name() => _Type}.
  39. %% Applications and modules we wish to ignore in the analysis:
  40. -define(IGNORED_APPS, "gen_rpc, recon, redbug, observer_cli, snabbkaffe, ekka, mria").
  41. -define(IGNORED_MODULES, "emqx_rpc").
  42. %% List of known RPC backend modules:
  43. -define(RPC_MODULES, "gen_rpc, erpc, rpc, emqx_rpc").
  44. %% List of known functions also known to do RPC:
  45. -define(RPC_FUNCTIONS,
  46. "emqx_cluster_rpc:multicall/3, emqx_cluster_rpc:multicall/5"
  47. ).
  48. %% List of functions in the RPC backend modules that we can ignore:
  49. % TODO: handle pmap
  50. -define(IGNORED_RPC_CALLS, "gen_rpc:nodes/0, emqx_rpc:unwrap_erpc/1").
  51. %% List of business-layer functions that are exempt from the checks:
  52. %% erlfmt-ignore
  53. -define(EXEMPTIONS,
  54. "emqx_mgmt_api:do_query/6" % Reason: legacy code. A fun and a QC query are
  55. % passed in the args, it's futile to try to statically
  56. % check it
  57. ).
  58. -define(XREF, myxref).
  59. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  60. %% Functions related to BPAPI compatibility checking
  61. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  62. -spec run() -> boolean().
  63. run() ->
  64. case dump() of
  65. true ->
  66. Dumps = filelib:wildcard(dumps_dir() ++ "/*.bpapi"),
  67. case Dumps of
  68. [] ->
  69. logger:error("No BPAPI dumps are found in ~s, abort", [dumps_dir()]),
  70. false;
  71. _ ->
  72. logger:notice("Running API compatibility checks for ~p", [Dumps]),
  73. check_compat(Dumps)
  74. end;
  75. false ->
  76. logger:critical("Backplane API violations found on the current branch.", []),
  77. false
  78. end.
  79. -spec check_compat([file:filename()]) -> boolean().
  80. check_compat(DumpFilenames) ->
  81. put(bpapi_ok, true),
  82. Dumps = lists:map(
  83. fun(FN) ->
  84. {ok, [Dump]} = file:consult(FN),
  85. Dump
  86. end,
  87. DumpFilenames
  88. ),
  89. [check_compat(I, J) || I <- Dumps, J <- Dumps],
  90. erase(bpapi_ok).
  91. %% Note: sets nok flag
  92. -spec check_compat(fulldump(), fulldump()) -> ok.
  93. check_compat(Dump1 = #{release := Rel1}, Dump2 = #{release := Rel2}) ->
  94. check_api_immutability(Dump1, Dump2),
  95. Rel2 >= Rel1 andalso
  96. typecheck_apis(Dump1, Dump2).
  97. %% It's not allowed to change BPAPI modules. Check that no changes
  98. %% have been made. (sets nok flag)
  99. -spec check_api_immutability(fulldump(), fulldump()) -> ok.
  100. check_api_immutability(#{release := Rel1, api := APIs1}, #{release := Rel2, api := APIs2}) when
  101. Rel2 >= Rel1
  102. ->
  103. %% TODO: Handle API deprecation
  104. _ = maps:map(
  105. fun(Key = {API, Version}, Val) ->
  106. case maps:get(Key, APIs2, undefined) of
  107. Val ->
  108. ok;
  109. undefined ->
  110. setnok(),
  111. logger:error(
  112. "API ~p v~p was removed in release ~p without being deprecated.",
  113. [API, Version, Rel2]
  114. );
  115. _Val ->
  116. setnok(),
  117. logger:error(
  118. "API ~p v~p was changed between ~p and ~p. Backplane API should be immutable.",
  119. [API, Version, Rel1, Rel2]
  120. )
  121. end
  122. end,
  123. APIs1
  124. ),
  125. ok;
  126. check_api_immutability(_, _) ->
  127. ok.
  128. %% Note: sets nok flag
  129. -spec typecheck_apis(fulldump(), fulldump()) -> ok.
  130. typecheck_apis(
  131. #{release := CallerRelease, api := CallerAPIs, signatures := CallerSigs},
  132. #{release := CalleeRelease, signatures := CalleeSigs}
  133. ) ->
  134. AllCalls = lists:flatten([
  135. [Calls, Casts]
  136. || #{calls := Calls, casts := Casts} <- maps:values(CallerAPIs)
  137. ]),
  138. lists:foreach(
  139. fun({From, To}) ->
  140. Caller = get_param_types(CallerSigs, From),
  141. Callee = get_param_types(CalleeSigs, To),
  142. %% TODO: check return types
  143. case typecheck_rpc(Caller, Callee) of
  144. [] ->
  145. ok;
  146. TypeErrors ->
  147. setnok(),
  148. [
  149. logger:error(
  150. "Incompatible RPC call: "
  151. "type of the parameter ~p of RPC call ~s in release ~p "
  152. "is not a subtype of the target function ~s in release ~p.~n"
  153. "Caller type: ~s~nCallee type: ~s~n",
  154. [
  155. Var,
  156. format_call(From),
  157. CallerRelease,
  158. format_call(To),
  159. CalleeRelease,
  160. erl_types:t_to_string(CallerType),
  161. erl_types:t_to_string(CalleeType)
  162. ]
  163. )
  164. || {Var, CallerType, CalleeType} <- TypeErrors
  165. ]
  166. end
  167. end,
  168. AllCalls
  169. ).
  170. -spec typecheck_rpc(param_types(), param_types()) -> [{emqx_bpapi:var_name(), _Type, _Type}].
  171. typecheck_rpc(Caller, Callee) ->
  172. maps:fold(
  173. fun(Var, CalleeType, Acc) ->
  174. #{Var := CallerType} = Caller,
  175. case erl_types:t_is_subtype(CallerType, CalleeType) of
  176. true -> Acc;
  177. false -> [{Var, CallerType, CalleeType} | Acc]
  178. end
  179. end,
  180. [],
  181. Callee
  182. ).
  183. -spec get_param_types(dialyzer_dump(), emqx_bpapi:call()) -> param_types().
  184. get_param_types(Signatures, {M, F, A}) ->
  185. Arity = length(A),
  186. case Signatures of
  187. #{{M, F, Arity} := {_RetType, AttrTypes}} ->
  188. % assert
  189. Arity = length(AttrTypes),
  190. maps:from_list(lists:zip(A, AttrTypes));
  191. _ ->
  192. logger:critical("Call ~p:~p/~p is not found in PLT~n", [M, F, Arity]),
  193. error(badkey)
  194. end.
  195. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  196. %% Functions related to BPAPI dumping
  197. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  198. dump() ->
  199. case
  200. {
  201. filelib:wildcard(project_root_dir() ++ "/*_plt"),
  202. filelib:wildcard(project_root_dir() ++ "/_build/check/lib")
  203. }
  204. of
  205. {[PLT | _], [RelDir | _]} ->
  206. dump(#{
  207. plt => PLT,
  208. reldir => RelDir
  209. });
  210. _ ->
  211. error("failed to guess run options")
  212. end.
  213. %% Collect the local BPAPI modules to a dump file
  214. -spec dump(dump_options()) -> boolean().
  215. dump(Opts) ->
  216. put(bpapi_ok, true),
  217. PLT = prepare(Opts),
  218. %% First we run XREF to find all callers of any known RPC backend:
  219. Callers = find_remote_calls(Opts),
  220. {BPAPICalls, NonBPAPICalls} = lists:partition(fun is_bpapi_call/1, Callers),
  221. warn_nonbpapi_rpcs(NonBPAPICalls),
  222. APIDump = collect_bpapis(BPAPICalls),
  223. DialyzerDump = collect_signatures(PLT, APIDump),
  224. dump_api(#{api => APIDump, signatures => DialyzerDump, release => "master"}),
  225. dump_versions(APIDump),
  226. xref:stop(?XREF),
  227. erase(bpapi_ok).
  228. prepare(#{reldir := RelDir, plt := PLT}) ->
  229. logger:info("Starting xref...", []),
  230. xref:start(?XREF),
  231. filelib:wildcard(RelDir ++ "/*/ebin/") =:= [] andalso
  232. error("No applications found in the release directory. Wrong directory?"),
  233. xref:set_default(?XREF, [{warnings, false}]),
  234. xref:add_release(?XREF, RelDir),
  235. %% Now to the dialyzer stuff:
  236. logger:info("Loading PLT...", []),
  237. dialyzer_plt:from_file(PLT).
  238. %% erlfmt-ignore
  239. find_remote_calls(_Opts) ->
  240. Query =
  241. "XC | (A - ["?IGNORED_APPS"]:App - ["?IGNORED_MODULES"]:Mod - ["?EXEMPTIONS"])
  242. || ((["?RPC_MODULES"] : Mod + ["?RPC_FUNCTIONS"]) - ["?IGNORED_RPC_CALLS"])",
  243. {ok, Calls} = xref:q(?XREF, Query),
  244. logger:info("Calls to RPC modules ~p", [Calls]),
  245. {Callers, _Callees} = lists:unzip(Calls),
  246. Callers.
  247. -spec warn_nonbpapi_rpcs([mfa()]) -> ok.
  248. warn_nonbpapi_rpcs([]) ->
  249. ok;
  250. warn_nonbpapi_rpcs(L) ->
  251. setnok(),
  252. lists:foreach(
  253. fun({M, F, A}) ->
  254. logger:error(
  255. "~p:~p/~p does a remote call outside of a dedicated "
  256. "backplane API module. "
  257. "It may break during rolling cluster upgrade",
  258. [M, F, A]
  259. )
  260. end,
  261. L
  262. ).
  263. -spec is_bpapi_call(mfa()) -> boolean().
  264. is_bpapi_call({Module, _Function, _Arity}) ->
  265. case catch Module:bpapi_meta() of
  266. #{api := _} -> true;
  267. _ -> false
  268. end.
  269. -spec dump_api(fulldump()) -> ok.
  270. dump_api(Term = #{api := _, signatures := _, release := Release}) ->
  271. Filename = filename:join(dumps_dir(), Release ++ ".bpapi"),
  272. ok = filelib:ensure_dir(Filename),
  273. file:write_file(Filename, io_lib:format("~0p.~n", [Term])).
  274. -spec dump_versions(api_dump()) -> ok.
  275. dump_versions(APIs) ->
  276. Filename = versions_file(),
  277. logger:notice("Dumping API versions to ~p", [Filename]),
  278. ok = filelib:ensure_dir(Filename),
  279. {ok, FD} = file:open(Filename, [write]),
  280. io:format(
  281. FD, "%% This file is automatically generated by `make static_checks`, do not edit.~n", []
  282. ),
  283. lists:foreach(
  284. fun(API) ->
  285. ok = io:format(FD, "~p.~n", [API])
  286. end,
  287. lists:sort(maps:keys(APIs))
  288. ),
  289. file:close(FD).
  290. -spec collect_bpapis([mfa()]) -> api_dump().
  291. collect_bpapis(L) ->
  292. Modules = lists:usort([M || {M, _F, _A} <- L]),
  293. lists:foldl(
  294. fun(Mod, Acc) ->
  295. #{
  296. api := API,
  297. version := Vsn,
  298. calls := Calls,
  299. casts := Casts
  300. } = Mod:bpapi_meta(),
  301. Acc#{
  302. {API, Vsn} => #{
  303. calls => Calls,
  304. casts => Casts
  305. }
  306. }
  307. end,
  308. #{},
  309. Modules
  310. ).
  311. -spec collect_signatures(_PLT, api_dump()) -> dialyzer_dump().
  312. collect_signatures(PLT, APIs) ->
  313. maps:fold(
  314. fun(_APIAndVersion, #{calls := Calls, casts := Casts}, Acc0) ->
  315. Acc1 = lists:foldl(fun enrich/2, {Acc0, PLT}, Calls),
  316. {Acc, PLT} = lists:foldl(fun enrich/2, Acc1, Casts),
  317. Acc
  318. end,
  319. #{},
  320. APIs
  321. ).
  322. %% Add information about the call types from the PLT
  323. -spec enrich(emqx_bpapi:rpc(), {dialyzer_dump(), _PLT}) -> {dialyzer_dump(), _PLT}.
  324. enrich({From0, To0}, {Acc0, PLT}) ->
  325. From = call_to_mfa(From0),
  326. To = call_to_mfa(To0),
  327. case {dialyzer_plt:lookup_contract(PLT, From), dialyzer_plt:lookup(PLT, To)} of
  328. {{value, #contract{args = FromArgs}}, {value, TTo}} ->
  329. %% TODO: Check return type
  330. FromRet = erl_types:t_any(),
  331. Acc = Acc0#{
  332. From => {FromRet, FromArgs},
  333. To => TTo
  334. },
  335. {Acc, PLT};
  336. {{value, _}, none} ->
  337. setnok(),
  338. logger:critical(
  339. "Backplane API function ~s calls a missing remote function ~s",
  340. [format_call(From0), format_call(To0)]
  341. ),
  342. error(missing_target)
  343. end.
  344. -spec call_to_mfa(emqx_bpapi:call()) -> mfa().
  345. call_to_mfa({M, F, A}) ->
  346. {M, F, length(A)}.
  347. format_call({M, F, A}) ->
  348. io_lib:format("~p:~p/~p", [M, F, length(A)]).
  349. setnok() ->
  350. put(bpapi_ok, false).
  351. dumps_dir() ->
  352. filename:join(project_root_dir(), "apps/emqx/test/emqx_static_checks_data").
  353. project_root_dir() ->
  354. string:trim(os:cmd("git rev-parse --show-toplevel")).
  355. versions_file() ->
  356. filename:join(project_root_dir(), "apps/emqx/priv/bpapi.versions").