update_appup.escript 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. #!/usr/bin/env -S escript -c
  2. %% -*- erlang-indent-level:4 -*-
  3. usage() ->
  4. "A script that fills in boilerplate for appup files.
  5. Algorithm: this script compares md5s of beam files of each
  6. application, and creates a `{load_module, Module, brutal_purge,
  7. soft_purge, []}` action for the changed and new modules. For deleted
  8. modules it creates `{delete_module, M}` action. These entries are
  9. added to each patch release preceding the current release. If an entry
  10. for a module already exists, this module is ignored. The existing
  11. actions are kept.
  12. Please note that it only compares the current release with its
  13. predecessor, assuming that the upgrade actions for the older releases
  14. are correct.
  15. Note: The defaults are set up for emqx, but they can be tuned to
  16. support other repos too.
  17. Usage:
  18. update_appup.escript [--check] [--repo URL] [--remote NAME] [--skip-build] [--make-commad SCRIPT] [--release-dir DIR] <previous_release_tag>
  19. Options:
  20. --check Don't update the appfile, just check that they are complete
  21. --repo Upsteam git repo URL
  22. --remote Get upstream repo URL from the specified git remote
  23. --skip-build Don't rebuild the releases. May produce wrong results
  24. --make-command A command used to assemble the release
  25. --release-dir Release directory
  26. --src-dirs Directories where source code is found. Defaults to '{src,apps,lib-*}/**/'
  27. --binary-rel-url Binary release URL pattern. %TAG% variable is substituted with the release tag.
  28. E.g. \"https://github.com/emqx/emqx/releases/download/v%TAG%/emqx-centos7-%TAG%-amd64.zip\"
  29. ".
  30. -record(app,
  31. { modules :: #{module() => binary()}
  32. , version :: string()
  33. }).
  34. default_options() ->
  35. #{ clone_url => find_upstream_repo("origin")
  36. , make_command => "make emqx-rel"
  37. , beams_dir => "_build/emqx/rel/emqx/lib/"
  38. , check => false
  39. , prev_tag => undefined
  40. , src_dirs => "{src,apps,lib-*}/**/"
  41. , binary_rel_url => undefined
  42. }.
  43. %% App-specific actions that should be added unconditionally to any update/downgrade:
  44. app_specific_actions(_) ->
  45. [].
  46. ignored_apps() ->
  47. [emqx_dashboard, emqx_management] ++ otp_standard_apps().
  48. main(Args) ->
  49. #{prev_tag := Baseline} = Options = parse_args(Args, default_options()),
  50. init_globals(Options),
  51. main(Options, Baseline).
  52. parse_args([PrevTag = [A|_]], State) when A =/= $- ->
  53. State#{prev_tag => PrevTag};
  54. parse_args(["--check"|Rest], State) ->
  55. parse_args(Rest, State#{check => true});
  56. parse_args(["--skip-build"|Rest], State) ->
  57. parse_args(Rest, State#{make_command => "true"});
  58. parse_args(["--repo", Repo|Rest], State) ->
  59. parse_args(Rest, State#{clone_url => Repo});
  60. parse_args(["--remote", Remote|Rest], State) ->
  61. parse_args(Rest, State#{clone_url => find_upstream_repo(Remote)});
  62. parse_args(["--make-command", Command|Rest], State) ->
  63. parse_args(Rest, State#{make_command => Command});
  64. parse_args(["--release-dir", Dir|Rest], State) ->
  65. parse_args(Rest, State#{beams_dir => Dir});
  66. parse_args(["--src-dirs", Pattern|Rest], State) ->
  67. parse_args(Rest, State#{src_dirs => Pattern});
  68. parse_args(["--binary-rel-url", URL|Rest], State) ->
  69. parse_args(Rest, State#{binary_rel_url => {ok, URL}});
  70. parse_args(_, _) ->
  71. fail(usage()).
  72. main(Options, Baseline) ->
  73. {CurrRelDir, PrevRelDir} = prepare(Baseline, Options),
  74. log("~n===================================~n"
  75. "Processing changes..."
  76. "~n===================================~n"),
  77. CurrAppsIdx = index_apps(CurrRelDir),
  78. PrevAppsIdx = index_apps(PrevRelDir),
  79. %% log("Curr: ~p~nPrev: ~p~n", [CurrAppsIdx, PrevAppsIdx]),
  80. AppupChanges = find_appup_actions(CurrAppsIdx, PrevAppsIdx),
  81. case getopt(check) of
  82. true ->
  83. case AppupChanges of
  84. [] ->
  85. ok;
  86. _ ->
  87. Diffs =
  88. lists:filtermap(
  89. fun({App, {Upgrade, Downgrade, OldUpgrade, OldDowngrade}}) ->
  90. DiffUp = diff_appup_instructions(Upgrade, OldUpgrade),
  91. DiffDown = diff_appup_instructions(Downgrade, OldDowngrade),
  92. case {DiffUp, DiffDown} of
  93. {[], []} ->
  94. %% no diff for external dependency
  95. false;
  96. _ ->
  97. Diffs = #{ up => DiffUp
  98. , down => DiffDown
  99. },
  100. {true, {App, Diffs}}
  101. end
  102. end,
  103. AppupChanges),
  104. case Diffs =:= [] of
  105. true ->
  106. ok;
  107. false ->
  108. set_invalid(),
  109. log("ERROR: The appup files are incomplete. Missing changes:~n ~p",
  110. [Diffs])
  111. end
  112. end;
  113. false ->
  114. update_appups(AppupChanges)
  115. end,
  116. check_appup_files(),
  117. warn_and_exit(is_valid()).
  118. warn_and_exit(true) ->
  119. log("
  120. NOTE: Please review the changes manually. This script does not know about NIF
  121. changes, supervisor changes, process restarts and so on. Also the load order of
  122. the beam files might need updating.~n"),
  123. halt(0);
  124. warn_and_exit(false) ->
  125. log("~nERROR: Incomplete appups found. Please inspect the output for more details.~n"),
  126. halt(1).
  127. prepare(Baseline, Options = #{make_command := MakeCommand, beams_dir := BeamDir, binary_rel_url := BinRel}) ->
  128. log("~n===================================~n"
  129. "Baseline: ~s"
  130. "~n===================================~n", [Baseline]),
  131. log("Building the current version...~n"),
  132. bash(MakeCommand),
  133. log("Downloading and building the previous release...~n"),
  134. PrevRelDir =
  135. case BinRel of
  136. undefined ->
  137. {ok, PrevRootDir} = build_prev_release(Baseline, Options),
  138. filename:join(PrevRootDir, BeamDir);
  139. {ok, _URL} ->
  140. {ok, PrevRootDir} = download_prev_release(Baseline, Options),
  141. PrevRootDir
  142. end,
  143. {BeamDir, PrevRelDir}.
  144. build_prev_release(Baseline, #{clone_url := Repo, make_command := MakeCommand}) ->
  145. BaseDir = "/tmp/emqx-baseline/",
  146. Dir = filename:basename(Repo, ".git") ++ [$-|Baseline],
  147. %% TODO: shallow clone
  148. Script = "mkdir -p ${BASEDIR} &&
  149. cd ${BASEDIR} &&
  150. { [ -d ${DIR} ] || git clone --branch ${TAG} ${REPO} ${DIR}; } &&
  151. cd ${DIR} &&" ++ MakeCommand,
  152. Env = [{"REPO", Repo}, {"TAG", Baseline}, {"BASEDIR", BaseDir}, {"DIR", Dir}],
  153. bash(Script, Env),
  154. {ok, filename:join(BaseDir, Dir)}.
  155. download_prev_release(Tag, #{binary_rel_url := {ok, URL0}, clone_url := Repo}) ->
  156. URL = string:replace(URL0, "%TAG%", Tag, all),
  157. BaseDir = "/tmp/emqx-baseline-bin/",
  158. Dir = filename:basename(Repo, ".git") ++ [$-|Tag],
  159. Filename = filename:join(BaseDir, Dir),
  160. Script = "mkdir -p ${OUTFILE} &&
  161. wget -c -O ${OUTFILE}.zip ${URL} &&
  162. unzip -n -d ${OUTFILE} ${OUTFILE}.zip",
  163. Env = [{"TAG", Tag}, {"OUTFILE", Filename}, {"URL", URL}],
  164. bash(Script, Env),
  165. {ok, Filename}.
  166. find_upstream_repo(Remote) ->
  167. string:trim(os:cmd("git remote get-url " ++ Remote)).
  168. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  169. %% Appup action creation and updating
  170. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  171. find_appup_actions(CurrApps, PrevApps) ->
  172. maps:fold(
  173. fun(App, CurrAppIdx, Acc) ->
  174. case PrevApps of
  175. #{App := PrevAppIdx} -> find_appup_actions(App, CurrAppIdx, PrevAppIdx) ++ Acc;
  176. _ -> Acc %% New app, nothing to upgrade here.
  177. end
  178. end,
  179. [],
  180. CurrApps).
  181. find_appup_actions(_App, AppIdx, AppIdx) ->
  182. %% No changes to the app, ignore:
  183. [];
  184. find_appup_actions(App, CurrAppIdx, PrevAppIdx = #app{version = PrevVersion}) ->
  185. {OldUpgrade, OldDowngrade} = find_old_appup_actions(App, PrevVersion),
  186. Upgrade = merge_update_actions(App, diff_app(App, CurrAppIdx, PrevAppIdx), OldUpgrade),
  187. Downgrade = merge_update_actions(App, diff_app(App, PrevAppIdx, CurrAppIdx), OldDowngrade),
  188. if OldUpgrade =:= Upgrade andalso OldDowngrade =:= Downgrade ->
  189. %% The appup file has been already updated:
  190. [];
  191. true ->
  192. [{App, {Upgrade, Downgrade, OldUpgrade, OldDowngrade}}]
  193. end.
  194. %% For external dependencies, show only the changes that are missing
  195. %% in their current appup.
  196. diff_appup_instructions(ComputedChanges, PresentChanges) ->
  197. lists:foldr(
  198. fun({Vsn, ComputedActions}, Acc) ->
  199. case find_matching_version(Vsn, PresentChanges) of
  200. undefined ->
  201. [{Vsn, ComputedActions} | Acc];
  202. PresentActions ->
  203. DiffActions = ComputedActions -- PresentActions,
  204. case DiffActions of
  205. [] ->
  206. %% no diff
  207. Acc;
  208. _ ->
  209. [{Vsn, DiffActions} | Acc]
  210. end
  211. end
  212. end,
  213. [],
  214. ComputedChanges).
  215. %% TODO: handle regexes
  216. find_matching_version(Vsn, PresentChanges) ->
  217. proplists:get_value(Vsn, PresentChanges).
  218. find_old_appup_actions(App, PrevVersion) ->
  219. {Upgrade0, Downgrade0} =
  220. case locate(ebin_current, App, ".appup") of
  221. {ok, AppupFile} ->
  222. log("Found the previous appup file: ~s~n", [AppupFile]),
  223. {_, U, D} = read_appup(AppupFile),
  224. {U, D};
  225. undefined ->
  226. %% Fallback to the app.src file, in case the
  227. %% application doesn't have a release (useful for the
  228. %% apps that live outside the EMQX monorepo):
  229. case locate(src, App, ".appup.src") of
  230. {ok, AppupSrcFile} ->
  231. log("Using ~s as a source of previous update actions~n", [AppupSrcFile]),
  232. {_, U, D} = read_appup(AppupSrcFile),
  233. {U, D};
  234. undefined ->
  235. {[], []}
  236. end
  237. end,
  238. {ensure_version(PrevVersion, Upgrade0), ensure_version(PrevVersion, Downgrade0)}.
  239. merge_update_actions(App, Changes, Vsns) ->
  240. lists:map(fun(Ret = {<<".*">>, _}) ->
  241. Ret;
  242. ({Vsn, Actions}) ->
  243. {Vsn, do_merge_update_actions(App, Changes, Actions)}
  244. end,
  245. Vsns).
  246. do_merge_update_actions(App, {New0, Changed0, Deleted0}, OldActions) ->
  247. AppSpecific = app_specific_actions(App) -- OldActions,
  248. AlreadyHandled = lists:flatten(lists:map(fun process_old_action/1, OldActions)),
  249. New = New0 -- AlreadyHandled,
  250. Changed = Changed0 -- AlreadyHandled,
  251. Deleted = Deleted0 -- AlreadyHandled,
  252. [{load_module, M, brutal_purge, soft_purge, []} || M <- Changed ++ New] ++
  253. OldActions ++
  254. [{delete_module, M} || M <- Deleted] ++
  255. AppSpecific.
  256. %% @doc Process the existing actions to exclude modules that are
  257. %% already handled
  258. process_old_action({purge, Modules}) ->
  259. Modules;
  260. process_old_action({delete_module, Module}) ->
  261. [Module];
  262. process_old_action(LoadModule) when is_tuple(LoadModule) andalso
  263. element(1, LoadModule) =:= load_module ->
  264. element(2, LoadModule);
  265. process_old_action(_) ->
  266. [].
  267. ensure_version(Version, OldInstructions) ->
  268. OldVersions = [ensure_string(element(1, I)) || I <- OldInstructions],
  269. case lists:member(Version, OldVersions) of
  270. false ->
  271. [{Version, []}|OldInstructions];
  272. _ ->
  273. OldInstructions
  274. end.
  275. read_appup(File) ->
  276. %% NOTE: appup file is a script, it may contain variables or functions.
  277. case file:script(File, [{'VSN', "VSN"}]) of
  278. {ok, Terms} ->
  279. Terms;
  280. Error ->
  281. fail("Failed to parse appup file ~s: ~p", [File, Error])
  282. end.
  283. check_appup_files() ->
  284. AppupFiles = filelib:wildcard(getopt(src_dirs) ++ "/*.appup.src"),
  285. lists:foreach(fun read_appup/1, AppupFiles).
  286. update_appups(Changes) ->
  287. lists:foreach(
  288. fun({App, {Upgrade, Downgrade, OldUpgrade, OldDowngrade}}) ->
  289. do_update_appup(App, Upgrade, Downgrade, OldUpgrade, OldDowngrade)
  290. end,
  291. Changes).
  292. do_update_appup(App, Upgrade, Downgrade, OldUpgrade, OldDowngrade) ->
  293. case locate(src, App, ".appup.src") of
  294. {ok, AppupFile} ->
  295. render_appfile(AppupFile, Upgrade, Downgrade);
  296. undefined ->
  297. case create_stub(App) of
  298. {ok, AppupFile} ->
  299. render_appfile(AppupFile, Upgrade, Downgrade);
  300. false ->
  301. DiffUp = diff_appup_instructions(Upgrade, OldUpgrade),
  302. DiffDown = diff_appup_instructions(Downgrade, OldDowngrade),
  303. case {DiffUp, DiffDown} of
  304. {[], []} ->
  305. %% no diff for external dependency; ignore
  306. ok;
  307. _ ->
  308. set_invalid(),
  309. Diffs = #{ up => DiffUp
  310. , down => DiffDown
  311. },
  312. log("ERROR: Appup file for the external dependency '~p' is not complete.~n Missing changes: ~100p~n", [App, Diffs]),
  313. log("NOTE: Some changes above might be already covered by regexes.~n")
  314. end
  315. end
  316. end.
  317. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  318. %% Appup file creation
  319. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  320. render_appfile(File, Upgrade, Downgrade) ->
  321. IOList = io_lib:format("%% -*- mode: erlang -*-\n{VSN,~n ~p,~n ~p}.~n", [Upgrade, Downgrade]),
  322. ok = file:write_file(File, IOList).
  323. create_stub(App) ->
  324. case locate(src, App, Ext = ".app.src") of
  325. {ok, AppSrc} ->
  326. DirName = filename:dirname(AppSrc),
  327. AppupFile = filename:basename(AppSrc, Ext) ++ ".appup.src",
  328. Default = {<<".*">>, []},
  329. AppupFileFullpath = filename:join(DirName, AppupFile),
  330. render_appfile(AppupFileFullpath, [Default], [Default]),
  331. {ok, AppupFileFullpath};
  332. undefined ->
  333. false
  334. end.
  335. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  336. %% application and release indexing
  337. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  338. index_apps(ReleaseDir) ->
  339. Apps0 = maps:from_list([index_app(filename:join(ReleaseDir, AppFile)) ||
  340. AppFile <- filelib:wildcard("**/ebin/*.app", ReleaseDir)]),
  341. maps:without(ignored_apps(), Apps0).
  342. index_app(AppFile) ->
  343. {ok, [{application, App, Properties}]} = file:consult(AppFile),
  344. Vsn = proplists:get_value(vsn, Properties),
  345. %% Note: assuming that beams are always located in the same directory where app file is:
  346. EbinDir = filename:dirname(AppFile),
  347. Modules = hashsums(EbinDir),
  348. {App, #app{ version = Vsn
  349. , modules = Modules
  350. }}.
  351. diff_app(App, #app{version = NewVersion, modules = NewModules}, #app{version = OldVersion, modules = OldModules}) ->
  352. {New, Changed} =
  353. maps:fold( fun(Mod, MD5, {New, Changed}) ->
  354. case OldModules of
  355. #{Mod := OldMD5} when MD5 =:= OldMD5 ->
  356. {New, Changed};
  357. #{Mod := _} ->
  358. {New, [Mod|Changed]};
  359. _ ->
  360. {[Mod|New], Changed}
  361. end
  362. end
  363. , {[], []}
  364. , NewModules
  365. ),
  366. Deleted = maps:keys(maps:without(maps:keys(NewModules), OldModules)),
  367. NChanges = length(New) + length(Changed) + length(Deleted),
  368. if NewVersion =:= OldVersion andalso NChanges > 0 ->
  369. set_invalid(),
  370. log("ERROR: Application '~p' contains changes, but its version is not updated~n", [App]);
  371. NewVersion > OldVersion ->
  372. log("INFO: Application '~p' has been updated: ~p -> ~p~n", [App, OldVersion, NewVersion]),
  373. ok;
  374. true ->
  375. ok
  376. end,
  377. {New, Changed, Deleted}.
  378. -spec hashsums(file:filename()) -> #{module() => binary()}.
  379. hashsums(EbinDir) ->
  380. maps:from_list(lists:map(
  381. fun(Beam) ->
  382. File = filename:join(EbinDir, Beam),
  383. {ok, Ret = {_Module, _MD5}} = beam_lib:md5(File),
  384. Ret
  385. end,
  386. filelib:wildcard("*.beam", EbinDir)
  387. )).
  388. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  389. %% Global state
  390. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  391. init_globals(Options) ->
  392. ets:new(globals, [named_table, set, public]),
  393. ets:insert(globals, {valid, true}),
  394. ets:insert(globals, {options, Options}).
  395. getopt(Option) ->
  396. maps:get(Option, ets:lookup_element(globals, options, 2)).
  397. %% Set a global flag that something about the appfiles is invalid
  398. set_invalid() ->
  399. ets:insert(globals, {valid, false}).
  400. is_valid() ->
  401. ets:lookup_element(globals, valid, 2).
  402. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  403. %% Utility functions
  404. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  405. %% Locate a file in a specified application
  406. locate(ebin_current, App, Suffix) ->
  407. ReleaseDir = getopt(beams_dir),
  408. AppStr = atom_to_list(App),
  409. case filelib:wildcard(ReleaseDir ++ "/**/ebin/" ++ AppStr ++ Suffix) of
  410. [File] ->
  411. {ok, File};
  412. [] ->
  413. undefined
  414. end;
  415. locate(src, App, Suffix) ->
  416. AppStr = atom_to_list(App),
  417. SrcDirs = getopt(src_dirs),
  418. case filelib:wildcard(SrcDirs ++ AppStr ++ Suffix) of
  419. [File] ->
  420. {ok, File};
  421. [] ->
  422. undefined
  423. end.
  424. bash(Script) ->
  425. bash(Script, []).
  426. bash(Script, Env) ->
  427. log("+ ~s~n+ Env: ~p~n", [Script, Env]),
  428. case cmd("bash", #{args => ["-c", Script], env => Env}) of
  429. 0 -> true;
  430. _ -> fail("Failed to run command: ~s", [Script])
  431. end.
  432. %% Spawn an executable and return the exit status
  433. cmd(Exec, Params) ->
  434. case os:find_executable(Exec) of
  435. false ->
  436. fail("Executable not found in $PATH: ~s", [Exec]);
  437. Path ->
  438. Params1 = maps:to_list(maps:with([env, args, cd], Params)),
  439. Port = erlang:open_port( {spawn_executable, Path}
  440. , [ exit_status
  441. , nouse_stdio
  442. | Params1
  443. ]
  444. ),
  445. receive
  446. {Port, {exit_status, Status}} ->
  447. Status
  448. end
  449. end.
  450. fail(Str) ->
  451. fail(Str, []).
  452. fail(Str, Args) ->
  453. log(Str ++ "~n", Args),
  454. halt(1).
  455. log(Msg) ->
  456. log(Msg, []).
  457. log(Msg, Args) ->
  458. io:format(standard_error, Msg, Args).
  459. ensure_string(Str) when is_binary(Str) ->
  460. binary_to_list(Str);
  461. ensure_string(Str) when is_list(Str) ->
  462. Str.
  463. otp_standard_apps() ->
  464. [ssl, mnesia, kernel, asn1, stdlib].