emqx_bpapi_static_checks.erl 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  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]).
  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, Dump2) ->
  94. check_api_immutability(Dump1, Dump2),
  95. typecheck_apis(Dump1, Dump2).
  96. %% It's not allowed to change BPAPI modules. Check that no changes
  97. %% have been made. (sets nok flag)
  98. -spec check_api_immutability(fulldump(), fulldump()) -> ok.
  99. check_api_immutability(#{release := Rel1, api := APIs1}, #{release := Rel2, api := APIs2}) when
  100. Rel2 >= Rel1
  101. ->
  102. %% TODO: Handle API deprecation
  103. _ = maps:map(
  104. fun(Key = {API, Version}, Val) ->
  105. case maps:get(Key, APIs2, undefined) of
  106. Val ->
  107. ok;
  108. undefined ->
  109. setnok(),
  110. logger:error(
  111. "API ~p v~p was removed in release ~p without being deprecated.",
  112. [API, Version, Rel2]
  113. );
  114. _Val ->
  115. setnok(),
  116. logger:error(
  117. "API ~p v~p was changed between ~p and ~p. Backplane API should be immutable.",
  118. [API, Version, Rel1, Rel2]
  119. )
  120. end
  121. end,
  122. APIs1
  123. ),
  124. ok;
  125. check_api_immutability(_, _) ->
  126. ok.
  127. %% Note: sets nok flag
  128. -spec typecheck_apis(fulldump(), fulldump()) -> ok.
  129. typecheck_apis(
  130. #{release := CallerRelease, api := CallerAPIs, signatures := CallerSigs},
  131. #{release := CalleeRelease, signatures := CalleeSigs}
  132. ) ->
  133. AllCalls = lists:flatten([
  134. [Calls, Casts]
  135. || #{calls := Calls, casts := Casts} <- maps:values(CallerAPIs)
  136. ]),
  137. lists:foreach(
  138. fun({From, To}) ->
  139. Caller = get_param_types(CallerSigs, From),
  140. Callee = get_param_types(CalleeSigs, To),
  141. %% TODO: check return types
  142. case typecheck_rpc(Caller, Callee) of
  143. [] ->
  144. ok;
  145. TypeErrors ->
  146. setnok(),
  147. [
  148. logger:error(
  149. "Incompatible RPC call: "
  150. "type of the parameter ~p of RPC call ~s in release ~p "
  151. "is not a subtype of the target function ~s in release ~p.~n"
  152. "Caller type: ~s~nCallee type: ~s~n",
  153. [
  154. Var,
  155. format_call(From),
  156. CallerRelease,
  157. format_call(To),
  158. CalleeRelease,
  159. erl_types:t_to_string(CallerType),
  160. erl_types:t_to_string(CalleeType)
  161. ]
  162. )
  163. || {Var, CallerType, CalleeType} <- TypeErrors
  164. ]
  165. end
  166. end,
  167. AllCalls
  168. ).
  169. -spec typecheck_rpc(param_types(), param_types()) -> [{emqx_bpapi:var_name(), _Type, _Type}].
  170. typecheck_rpc(Caller, Callee) ->
  171. maps:fold(
  172. fun(Var, CalleeType, Acc) ->
  173. #{Var := CallerType} = Caller,
  174. case erl_types:t_is_subtype(CallerType, CalleeType) of
  175. true -> Acc;
  176. false -> [{Var, CallerType, CalleeType} | Acc]
  177. end
  178. end,
  179. [],
  180. Callee
  181. ).
  182. -spec get_param_types(dialyzer_dump(), emqx_bpapi:call()) -> param_types().
  183. get_param_types(Signatures, {M, F, A}) ->
  184. Arity = length(A),
  185. #{{M, F, Arity} := {_RetType, AttrTypes}} = Signatures,
  186. % assert
  187. Arity = length(AttrTypes),
  188. maps:from_list(lists:zip(A, AttrTypes)).
  189. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  190. %% Functions related to BPAPI dumping
  191. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  192. dump() ->
  193. case
  194. {
  195. filelib:wildcard(project_root_dir() ++ "/*_plt"),
  196. filelib:wildcard(project_root_dir() ++ "/_build/check/lib")
  197. }
  198. of
  199. {[PLT | _], [RelDir | _]} ->
  200. dump(#{
  201. plt => PLT,
  202. reldir => RelDir
  203. });
  204. _ ->
  205. error("failed to guess run options")
  206. end.
  207. %% Collect the local BPAPI modules to a dump file
  208. -spec dump(dump_options()) -> boolean().
  209. dump(Opts) ->
  210. put(bpapi_ok, true),
  211. PLT = prepare(Opts),
  212. %% First we run XREF to find all callers of any known RPC backend:
  213. Callers = find_remote_calls(Opts),
  214. {BPAPICalls, NonBPAPICalls} = lists:partition(fun is_bpapi_call/1, Callers),
  215. warn_nonbpapi_rpcs(NonBPAPICalls),
  216. APIDump = collect_bpapis(BPAPICalls),
  217. DialyzerDump = collect_signatures(PLT, APIDump),
  218. [Release | _] = string:split(emqx_app:get_release(), "-"),
  219. dump_api(#{api => APIDump, signatures => DialyzerDump, release => Release}),
  220. dump_versions(APIDump),
  221. xref:stop(?XREF),
  222. erase(bpapi_ok).
  223. prepare(#{reldir := RelDir, plt := PLT}) ->
  224. logger:info("Starting xref...", []),
  225. xref:start(?XREF),
  226. filelib:wildcard(RelDir ++ "/*/ebin/") =:= [] andalso
  227. error("No applications found in the release directory. Wrong directory?"),
  228. xref:set_default(?XREF, [{warnings, false}]),
  229. xref:add_release(?XREF, RelDir),
  230. %% Now to the dialyzer stuff:
  231. logger:info("Loading PLT...", []),
  232. dialyzer_plt:from_file(PLT).
  233. %% erlfmt-ignore
  234. find_remote_calls(_Opts) ->
  235. Query =
  236. "XC | (A - ["?IGNORED_APPS"]:App - ["?IGNORED_MODULES"]:Mod - ["?EXEMPTIONS"])
  237. || ((["?RPC_MODULES"] : Mod + ["?RPC_FUNCTIONS"]) - ["?IGNORED_RPC_CALLS"])",
  238. {ok, Calls} = xref:q(?XREF, Query),
  239. logger:info("Calls to RPC modules ~p", [Calls]),
  240. {Callers, _Callees} = lists:unzip(Calls),
  241. Callers.
  242. -spec warn_nonbpapi_rpcs([mfa()]) -> ok.
  243. warn_nonbpapi_rpcs([]) ->
  244. ok;
  245. warn_nonbpapi_rpcs(L) ->
  246. setnok(),
  247. lists:foreach(
  248. fun({M, F, A}) ->
  249. logger:error(
  250. "~p:~p/~p does a remote call outside of a dedicated "
  251. "backplane API module. "
  252. "It may break during rolling cluster upgrade",
  253. [M, F, A]
  254. )
  255. end,
  256. L
  257. ).
  258. -spec is_bpapi_call(mfa()) -> boolean().
  259. is_bpapi_call({Module, _Function, _Arity}) ->
  260. case catch Module:bpapi_meta() of
  261. #{api := _} -> true;
  262. _ -> false
  263. end.
  264. -spec dump_api(fulldump()) -> ok.
  265. dump_api(Term = #{api := _, signatures := _, release := Release}) ->
  266. Filename = filename:join(dumps_dir(), Release ++ ".bpapi"),
  267. ok = filelib:ensure_dir(Filename),
  268. file:write_file(Filename, io_lib:format("~0p.", [Term])).
  269. -spec dump_versions(api_dump()) -> ok.
  270. dump_versions(APIs) ->
  271. Filename = versions_file(),
  272. logger:notice("Dumping API versions to ~p", [Filename]),
  273. ok = filelib:ensure_dir(Filename),
  274. {ok, FD} = file:open(Filename, [write]),
  275. lists:foreach(
  276. fun(API) ->
  277. ok = io:format(FD, "~p.~n", [API])
  278. end,
  279. lists:sort(maps:keys(APIs))
  280. ),
  281. file:close(FD).
  282. -spec collect_bpapis([mfa()]) -> api_dump().
  283. collect_bpapis(L) ->
  284. Modules = lists:usort([M || {M, _F, _A} <- L]),
  285. lists:foldl(
  286. fun(Mod, Acc) ->
  287. #{
  288. api := API,
  289. version := Vsn,
  290. calls := Calls,
  291. casts := Casts
  292. } = Mod:bpapi_meta(),
  293. Acc#{
  294. {API, Vsn} => #{
  295. calls => Calls,
  296. casts => Casts
  297. }
  298. }
  299. end,
  300. #{},
  301. Modules
  302. ).
  303. -spec collect_signatures(_PLT, api_dump()) -> dialyzer_dump().
  304. collect_signatures(PLT, APIs) ->
  305. maps:fold(
  306. fun(_APIAndVersion, #{calls := Calls, casts := Casts}, Acc0) ->
  307. Acc1 = lists:foldl(fun enrich/2, {Acc0, PLT}, Calls),
  308. {Acc, PLT} = lists:foldl(fun enrich/2, Acc1, Casts),
  309. Acc
  310. end,
  311. #{},
  312. APIs
  313. ).
  314. %% Add information about the call types from the PLT
  315. -spec enrich(emqx_bpapi:rpc(), {dialyzer_dump(), _PLT}) -> {dialyzer_dump(), _PLT}.
  316. enrich({From0, To0}, {Acc0, PLT}) ->
  317. From = call_to_mfa(From0),
  318. To = call_to_mfa(To0),
  319. case {dialyzer_plt:lookup_contract(PLT, From), dialyzer_plt:lookup(PLT, To)} of
  320. {{value, #contract{args = FromArgs}}, {value, TTo}} ->
  321. %% TODO: Check return type
  322. FromRet = erl_types:t_any(),
  323. Acc = Acc0#{
  324. From => {FromRet, FromArgs},
  325. To => TTo
  326. },
  327. {Acc, PLT};
  328. {{value, _}, none} ->
  329. setnok(),
  330. logger:critical(
  331. "Backplane API function ~s calls a missing remote function ~s",
  332. [format_call(From0), format_call(To0)]
  333. ),
  334. error(missing_target)
  335. end.
  336. -spec call_to_mfa(emqx_bpapi:call()) -> mfa().
  337. call_to_mfa({M, F, A}) ->
  338. {M, F, length(A)}.
  339. format_call({M, F, A}) ->
  340. io_lib:format("~p:~p/~p", [M, F, length(A)]).
  341. setnok() ->
  342. put(bpapi_ok, false).
  343. dumps_dir() ->
  344. filename:join(project_root_dir(), "apps/emqx/test/emqx_static_checks_data").
  345. project_root_dir() ->
  346. string:trim(os:cmd("git rev-parse --show-toplevel")).
  347. versions_file() ->
  348. filename:join(project_root_dir(), "apps/emqx/priv/bpapi.versions").