emqx_bpapi_static_checks.erl 15 KB

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