emqx_bpapi_static_checks.erl 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. %%--------------------------------------------------------------------
  2. %% Copyright (c) 2022-2024 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, dump_file_extension/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,
  41. "gen_rpc, recon, redbug, observer_cli, snabbkaffe, ekka, mria, amqp_client, rabbit_common, esaml, ra"
  42. ).
  43. -define(IGNORED_MODULES, "emqx_rpc").
  44. -define(FORCE_DELETED_MODULES, [
  45. emqx_statsd,
  46. emqx_statsd_proto_v1,
  47. emqx_persistent_session_proto_v1
  48. ]).
  49. -define(FORCE_DELETED_APIS, [
  50. {emqx_statsd, 1},
  51. {emqx_plugin_libs, 1},
  52. {emqx_persistent_session, 1},
  53. {emqx_ds, 3}
  54. ]).
  55. %% List of known RPC backend modules:
  56. -define(RPC_MODULES, "gen_rpc, erpc, rpc, emqx_rpc").
  57. %% List of known functions also known to do RPC:
  58. -define(RPC_FUNCTIONS,
  59. "emqx_cluster_rpc:multicall/3, emqx_cluster_rpc:multicall/5"
  60. ).
  61. %% List of functions in the RPC backend modules that we can ignore:
  62. % TODO: handle pmap
  63. -define(IGNORED_RPC_CALLS, "gen_rpc:nodes/0, emqx_rpc:unwrap_erpc/1").
  64. %% List of business-layer functions that are exempt from the checks:
  65. %% erlfmt-ignore
  66. -define(EXEMPTIONS,
  67. % Reason: legacy code. A fun and a QC query are
  68. % passed in the args, it's futile to try to statically
  69. % check it
  70. "emqx_mgmt_api:do_query/2, emqx_mgmt_api:collect_total_from_tail_nodes/2"
  71. ).
  72. %% Only the APIs for the features that haven't reached General
  73. %% Availability can be added here:
  74. -define(EXPERIMENTAL_APIS, [
  75. {emqx_ds, 4}
  76. ]).
  77. -define(XREF, myxref).
  78. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  79. %% Functions related to BPAPI compatibility checking
  80. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  81. -spec run() -> boolean().
  82. run() ->
  83. case dump() of
  84. true ->
  85. Dumps = filelib:wildcard(dumps_dir() ++ "/*" ++ dump_file_extension()),
  86. case Dumps of
  87. [] ->
  88. logger:error("No BPAPI dumps are found in ~s, abort", [dumps_dir()]),
  89. false;
  90. _ ->
  91. logger:notice("Running API compatibility checks for ~p", [Dumps]),
  92. check_compat(Dumps)
  93. end;
  94. false ->
  95. logger:critical("Backplane API violations found on the current branch.", []),
  96. false
  97. end.
  98. -spec check_compat([file:filename()]) -> boolean().
  99. check_compat(DumpFilenames) ->
  100. put(bpapi_ok, true),
  101. Dumps = lists:map(
  102. fun(FN) ->
  103. {ok, [Dump]} = file:consult(FN),
  104. Dump#{release => filename:basename(FN)}
  105. end,
  106. DumpFilenames
  107. ),
  108. [check_compat(I, J) || I <- Dumps, J <- Dumps],
  109. erase(bpapi_ok).
  110. %% Note: sets nok flag
  111. -spec check_compat(fulldump(), fulldump()) -> ok.
  112. check_compat(Dump1 = #{release := Rel1}, Dump2 = #{release := Rel2}) when Rel2 >= Rel1 ->
  113. check_api_immutability(Dump1, Dump2),
  114. typecheck_apis(Dump1, Dump2);
  115. check_compat(_, _) ->
  116. ok.
  117. %% It's not allowed to change BPAPI modules. Check that no changes
  118. %% have been made. (sets nok flag)
  119. -spec check_api_immutability(fulldump(), fulldump()) -> ok.
  120. check_api_immutability(#{release := Rel1, api := APIs1}, #{release := Rel2, api := APIs2}) ->
  121. %% TODO: Handle API deprecation
  122. _ = maps:map(
  123. fun(Key, Val) ->
  124. case lists:member(Key, ?EXPERIMENTAL_APIS) of
  125. true ->
  126. ok;
  127. false ->
  128. do_check_api_immutability(Rel1, Rel2, APIs2, Key, Val)
  129. end
  130. end,
  131. APIs1
  132. ),
  133. ok.
  134. do_check_api_immutability(Rel1, Rel2, APIs2, Key = {API, Version}, Val) ->
  135. case maps:get(Key, APIs2, undefined) of
  136. Val ->
  137. ok;
  138. undefined ->
  139. case lists:member(Key, ?FORCE_DELETED_APIS) of
  140. true ->
  141. ok;
  142. false ->
  143. setnok(),
  144. logger:error(
  145. "API ~p v~p was removed in release ~p without being deprecated. "
  146. "Old release: ~p",
  147. [API, Version, Rel2, Rel1]
  148. )
  149. end;
  150. OldVal ->
  151. setnok(),
  152. logger:error(
  153. "API ~p v~p was changed between ~p and ~p. Backplane API should be immutable.",
  154. [API, Version, Rel1, Rel2]
  155. ),
  156. D21 = maps:get(calls, Val) -- maps:get(calls, OldVal),
  157. D12 = maps:get(calls, OldVal) -- maps:get(calls, Val),
  158. logger:error("Added calls:~n ~p", [D21]),
  159. logger:error("Removed calls:~n ~p", [D12])
  160. end.
  161. filter_calls(Calls) ->
  162. F = fun({{Mf, _, _}, {Mt, _, _}}) ->
  163. (not lists:member(Mf, ?FORCE_DELETED_MODULES)) andalso
  164. (not lists:member(Mt, ?FORCE_DELETED_MODULES))
  165. end,
  166. lists:filter(F, Calls).
  167. %% Note: sets nok flag
  168. -spec typecheck_apis(fulldump(), fulldump()) -> ok.
  169. typecheck_apis(
  170. #{release := CallerRelease, api := CallerAPIs, signatures := CallerSigs},
  171. #{release := CalleeRelease, signatures := CalleeSigs}
  172. ) ->
  173. AllCalls0 = lists:flatten([
  174. [Calls, Casts]
  175. || #{calls := Calls, casts := Casts} <- maps:values(CallerAPIs)
  176. ]),
  177. AllCalls = filter_calls(AllCalls0),
  178. lists:foreach(
  179. fun({From, To}) ->
  180. Caller = get_param_types(CallerSigs, From, From),
  181. Callee = get_param_types(CalleeSigs, From, To),
  182. %% TODO: check return types
  183. case typecheck_rpc(Caller, Callee) of
  184. [] ->
  185. ok;
  186. TypeErrors ->
  187. setnok(),
  188. [
  189. logger:error(
  190. "Incompatible RPC call: "
  191. "type of the parameter ~p of RPC call ~s in release ~p "
  192. "is not a subtype of the target function ~s in release ~p.~n"
  193. "Caller type: ~s~nCallee type: ~s~n",
  194. [
  195. Var,
  196. format_call(From),
  197. CallerRelease,
  198. format_call(To),
  199. CalleeRelease,
  200. erl_types:t_to_string(CallerType),
  201. erl_types:t_to_string(CalleeType)
  202. ]
  203. )
  204. || {Var, CallerType, CalleeType} <- TypeErrors
  205. ]
  206. end
  207. end,
  208. AllCalls
  209. ).
  210. -spec typecheck_rpc(param_types(), param_types()) -> [{emqx_bpapi:var_name(), _Type, _Type}].
  211. typecheck_rpc(Caller, Callee) ->
  212. maps:fold(
  213. fun(Var, CalleeType, Acc) ->
  214. #{Var := CallerType} = Caller,
  215. case erl_types:t_is_subtype(CallerType, CalleeType) of
  216. true -> Acc;
  217. false -> [{Var, CallerType, CalleeType} | Acc]
  218. end
  219. end,
  220. [],
  221. Callee
  222. ).
  223. %%-spec get_param_types(dialyzer_dump(), emqx_bpapi:call()) -> param_types().
  224. get_param_types(Signatures, From, {M, F, A}) ->
  225. Arity = length(A),
  226. case Signatures of
  227. #{{M, F, Arity} := {_RetType, AttrTypes}} ->
  228. % assert
  229. Arity = length(AttrTypes),
  230. maps:from_list(lists:zip(A, AttrTypes));
  231. _ ->
  232. logger:critical("Call ~p:~p/~p from ~p is not found in PLT~n", [M, F, Arity, From]),
  233. error({badkey, {M, F, A}})
  234. end.
  235. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  236. %% Functions related to BPAPI dumping
  237. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  238. dump() ->
  239. RootDir = project_root_dir(),
  240. TryRelDir = RootDir ++ "/_build/check/lib",
  241. case {filelib:wildcard(RootDir ++ "/*_plt"), filelib:wildcard(TryRelDir)} of
  242. {[PLT | _], [RelDir | _]} ->
  243. dump(#{
  244. plt => PLT,
  245. reldir => RelDir
  246. });
  247. {[], _} ->
  248. logger:error(
  249. "No usable PLT files found in \"~s\", abort ~n"
  250. "Try running `rebar3 as check dialyzer` at least once first",
  251. [RootDir]
  252. ),
  253. error(run_failed);
  254. {_, []} ->
  255. logger:error(
  256. "No built applications found in \"~s\", abort ~n"
  257. "Try running `rebar3 as check compile` at least once first",
  258. [TryRelDir]
  259. ),
  260. error(run_failed)
  261. end.
  262. %% Collect the local BPAPI modules to a dump file
  263. -spec dump(dump_options()) -> boolean().
  264. dump(Opts) ->
  265. put(bpapi_ok, true),
  266. PLT = prepare(Opts),
  267. %% First we run XREF to find all callers of any known RPC backend:
  268. Callers = find_remote_calls(Opts),
  269. {BPAPICalls, NonBPAPICalls} = lists:partition(fun is_bpapi_call/1, Callers),
  270. warn_nonbpapi_rpcs(NonBPAPICalls),
  271. APIDump = collect_bpapis(BPAPICalls),
  272. DialyzerDump = collect_signatures(PLT, APIDump),
  273. dump_api(#{api => APIDump, signatures => DialyzerDump, release => "master"}),
  274. dump_versions(APIDump),
  275. xref:stop(?XREF),
  276. erase(bpapi_ok).
  277. prepare(#{reldir := RelDir, plt := PLT}) ->
  278. logger:info("Starting xref...", []),
  279. xref:start(?XREF),
  280. filelib:wildcard(RelDir ++ "/*/ebin/") =:= [] andalso
  281. error("No applications found in the release directory. Wrong directory?"),
  282. xref:set_default(?XREF, [{warnings, false}]),
  283. xref:add_release(?XREF, RelDir),
  284. %% Now to the dialyzer stuff:
  285. logger:info("Loading PLT...", []),
  286. load_plt(PLT).
  287. %% erlfmt-ignore
  288. find_remote_calls(_Opts) ->
  289. Query =
  290. "XC | (A - ["?IGNORED_APPS"]:App - ["?IGNORED_MODULES"]:Mod - ["?EXEMPTIONS"])
  291. || ((["?RPC_MODULES"] : Mod + ["?RPC_FUNCTIONS"]) - ["?IGNORED_RPC_CALLS"])",
  292. {ok, Calls} = xref:q(?XREF, Query),
  293. logger:info("Calls to RPC modules ~p", [Calls]),
  294. {Callers, _Callees} = lists:unzip(Calls),
  295. Callers.
  296. -spec warn_nonbpapi_rpcs([mfa()]) -> ok.
  297. warn_nonbpapi_rpcs([]) ->
  298. ok;
  299. warn_nonbpapi_rpcs(L) ->
  300. setnok(),
  301. lists:foreach(
  302. fun({M, F, A}) ->
  303. logger:error(
  304. "~p:~p/~p does a remote call outside of a dedicated "
  305. "backplane API module. "
  306. "It may break during rolling cluster upgrade",
  307. [M, F, A]
  308. )
  309. end,
  310. L
  311. ).
  312. -spec is_bpapi_call(mfa()) -> boolean().
  313. is_bpapi_call({Module, _Function, _Arity}) ->
  314. case catch Module:bpapi_meta() of
  315. #{api := _} -> true;
  316. _ -> false
  317. end.
  318. -spec dump_api(fulldump()) -> ok.
  319. dump_api(Term = #{api := _, signatures := _, release := Release}) ->
  320. Filename = filename:join(dumps_dir(), Release ++ dump_file_extension()),
  321. ok = filelib:ensure_dir(Filename),
  322. file:write_file(Filename, io_lib:format("~0p.~n", [Term])).
  323. -spec dump_versions(api_dump()) -> ok.
  324. dump_versions(APIs) ->
  325. Filename = versions_file(),
  326. logger:notice("Dumping API versions to ~p", [Filename]),
  327. ok = filelib:ensure_dir(Filename),
  328. {ok, FD} = file:open(Filename, [write]),
  329. io:format(
  330. FD, "%% This file is automatically generated by `make static_checks`, do not edit.~n", []
  331. ),
  332. lists:foreach(
  333. fun(API) ->
  334. ok = io:format(FD, "~p.~n", [API])
  335. end,
  336. lists:sort(maps:keys(APIs))
  337. ),
  338. file:close(FD).
  339. -spec collect_bpapis([mfa()]) -> api_dump().
  340. collect_bpapis(L) ->
  341. Modules = lists:usort([M || {M, _F, _A} <- L]),
  342. lists:foldl(
  343. fun(Mod, Acc) ->
  344. #{
  345. api := API,
  346. version := Vsn,
  347. calls := Calls,
  348. casts := Casts
  349. } = Mod:bpapi_meta(),
  350. Acc#{
  351. {API, Vsn} => #{
  352. calls => Calls,
  353. casts => Casts
  354. }
  355. }
  356. end,
  357. #{},
  358. Modules
  359. ).
  360. -spec collect_signatures(_PLT, api_dump()) -> dialyzer_dump().
  361. collect_signatures(PLT, APIs) ->
  362. maps:fold(
  363. fun(_APIAndVersion, #{calls := Calls, casts := Casts}, Acc0) ->
  364. Acc1 = lists:foldl(fun enrich/2, {Acc0, PLT}, Calls),
  365. {Acc, PLT} = lists:foldl(fun enrich/2, Acc1, Casts),
  366. Acc
  367. end,
  368. #{},
  369. APIs
  370. ).
  371. %% Add information about the call types from the PLT
  372. -spec enrich(emqx_bpapi:rpc(), {dialyzer_dump(), _PLT}) -> {dialyzer_dump(), _PLT}.
  373. enrich({From0, To0}, {Acc0, PLT}) ->
  374. From = call_to_mfa(From0),
  375. To = call_to_mfa(To0),
  376. case {dialyzer_plt:lookup_contract(PLT, From), dialyzer_plt:lookup(PLT, To)} of
  377. {{value, #contract{args = FromArgs}}, {value, TTo}} ->
  378. %% TODO: Check return type
  379. FromRet = erl_types:t_any(),
  380. Acc = Acc0#{
  381. From => {FromRet, FromArgs},
  382. To => TTo
  383. },
  384. {Acc, PLT};
  385. {{value, _}, none} ->
  386. setnok(),
  387. logger:critical(
  388. "Backplane API function ~s calls a missing remote function ~s",
  389. [format_call(From0), format_call(To0)]
  390. ),
  391. error(missing_target)
  392. end.
  393. -spec call_to_mfa(emqx_bpapi:call()) -> mfa().
  394. call_to_mfa({M, F, A}) ->
  395. {M, F, length(A)}.
  396. format_call({M, F, A}) ->
  397. io_lib:format("~p:~p/~p", [M, F, length(A)]).
  398. setnok() ->
  399. put(bpapi_ok, false).
  400. dumps_dir() ->
  401. filename:join(emqx_app_dir(), "test/emqx_static_checks_data").
  402. versions_file() ->
  403. filename:join(emqx_app_dir(), "priv/bpapi.versions").
  404. emqx_app_dir() ->
  405. Info = ?MODULE:module_info(compile),
  406. case proplists:get_value(source, Info) of
  407. Source when is_list(Source) ->
  408. filename:dirname(filename:dirname(Source));
  409. undefined ->
  410. "apps/emqx"
  411. end.
  412. project_root_dir() ->
  413. filename:dirname(filename:dirname(emqx_app_dir())).
  414. -if(?OTP_RELEASE >= 26).
  415. load_plt(File) ->
  416. dialyzer_cplt:from_file(File).
  417. dump_file_extension() ->
  418. %% OTP26 changes the internal format for the types:
  419. ".bpapi2".
  420. -else.
  421. load_plt(File) ->
  422. dialyzer_plt:from_file(File).
  423. dump_file_extension() ->
  424. ".bpapi".
  425. -endif.