update_appup.escript 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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] <current_release_tag>
  19. Options:
  20. --check Don't update the appfile, just check that they are complete
  21. --prev-tag Specify the previous release tag. Otherwise the previous patch version is used
  22. --repo Upsteam git repo URL
  23. --remote Get upstream repo URL from the specified git remote
  24. --skip-build Don't rebuild the releases. May produce wrong results
  25. --make-command A command used to assemble the release
  26. --release-dir Release directory
  27. --src-dirs Directories where source code is found. Defaults to '{src,apps,lib-*}/**/'
  28. ".
  29. -record(app,
  30. { modules :: #{module() => binary()}
  31. , version :: string()
  32. }).
  33. default_options() ->
  34. #{ clone_url => find_upstream_repo("origin")
  35. , make_command => "make emqx-rel"
  36. , beams_dir => "_build/emqx/rel/emqx/lib/"
  37. , check => false
  38. , prev_tag => undefined
  39. , src_dirs => "{src,apps,lib-*}/**/"
  40. }.
  41. main(Args) ->
  42. #{current_release := CurrentRelease} = Options = parse_args(Args, default_options()),
  43. init_globals(Options),
  44. case find_pred_tag(CurrentRelease) of
  45. {ok, Baseline} ->
  46. main(Options, Baseline);
  47. undefined ->
  48. log("No appup update is needed for this release, nothing to be done~n", []),
  49. ok
  50. end.
  51. parse_args([CurrentRelease = [A|_]], State) when A =/= $- ->
  52. State#{current_release => CurrentRelease};
  53. parse_args(["--check"|Rest], State) ->
  54. parse_args(Rest, State#{check => true});
  55. parse_args(["--skip-build"|Rest], State) ->
  56. parse_args(Rest, State#{make_command => "true"});
  57. parse_args(["--repo", Repo|Rest], State) ->
  58. parse_args(Rest, State#{clone_url => Repo});
  59. parse_args(["--remote", Remote|Rest], State) ->
  60. parse_args(Rest, State#{clone_url => find_upstream_repo(Remote)});
  61. parse_args(["--make-command", Command|Rest], State) ->
  62. parse_args(Rest, State#{make_command => Command});
  63. parse_args(["--release-dir", Dir|Rest], State) ->
  64. parse_args(Rest, State#{beams_dir => Dir});
  65. parse_args(["--src-dirs", Pattern|Rest], State) ->
  66. parse_args(Rest, State#{src_dirs => Pattern});
  67. parse_args(["--prev-tag", Tag|Rest], State) ->
  68. parse_args(Rest, State#{prev_tag => Tag});
  69. parse_args(_, _) ->
  70. fail(usage()).
  71. main(Options, Baseline) ->
  72. {CurrRelDir, PrevRelDir} = prepare(Baseline, Options),
  73. log("~n===================================~n"
  74. "Processing changes..."
  75. "~n===================================~n"),
  76. CurrAppsIdx = index_apps(CurrRelDir),
  77. PrevAppsIdx = index_apps(PrevRelDir),
  78. %% log("Curr: ~p~nPrev: ~p~n", [CurrApps, PrevApps]),
  79. AppupChanges = find_appup_actions(CurrAppsIdx, PrevAppsIdx),
  80. case getopt(check) of
  81. true ->
  82. case AppupChanges of
  83. [] ->
  84. ok;
  85. _ ->
  86. set_invalid(),
  87. log("ERROR: The appup files are incomplete. Missing changes:~n ~p", [AppupChanges])
  88. end;
  89. false ->
  90. update_appups(AppupChanges)
  91. end,
  92. check_appup_files(),
  93. warn_and_exit(is_valid()).
  94. warn_and_exit(true) ->
  95. log("
  96. NOTE: Please review the changes manually. This script does not know about NIF
  97. changes, supervisor changes, process restarts and so on. Also the load order of
  98. the beam files might need updating.~n"),
  99. halt(0);
  100. warn_and_exit(false) ->
  101. log("~nERROR: Incomplete appups found. Please inspect the output for more details.~n"),
  102. halt(1).
  103. prepare(Baseline, Options = #{make_command := MakeCommand, beams_dir := BeamDir}) ->
  104. log("~n===================================~n"
  105. "Baseline: ~s"
  106. "~n===================================~n", [Baseline]),
  107. log("Building the current version...~n"),
  108. bash(MakeCommand),
  109. log("Downloading and building the previous release...~n"),
  110. {ok, PrevRootDir} = build_pred_release(Baseline, Options),
  111. {BeamDir, filename:join(PrevRootDir, BeamDir)}.
  112. build_pred_release(Baseline, #{clone_url := Repo, make_command := MakeCommand}) ->
  113. BaseDir = "/tmp/emqx-baseline/",
  114. Dir = filename:basename(Repo, ".git") ++ [$-|Baseline],
  115. %% TODO: shallow clone
  116. Script = "mkdir -p ${BASEDIR} &&
  117. cd ${BASEDIR} &&
  118. { [ -d ${DIR} ] || git clone --branch ${TAG} ${REPO} ${DIR}; } &&
  119. cd ${DIR} &&" ++ MakeCommand,
  120. Env = [{"REPO", Repo}, {"TAG", Baseline}, {"BASEDIR", BaseDir}, {"DIR", Dir}],
  121. bash(Script, Env),
  122. {ok, filename:join(BaseDir, Dir)}.
  123. find_upstream_repo(Remote) ->
  124. string:trim(os:cmd("git remote get-url " ++ Remote)).
  125. find_pred_tag(CurrentRelease) ->
  126. case getopt(prev_tag) of
  127. undefined ->
  128. {Maj, Min, Patch} = parse_semver(CurrentRelease),
  129. case Patch of
  130. 0 -> undefined;
  131. _ -> {ok, semver(Maj, Min, Patch - 1)}
  132. end;
  133. Tag ->
  134. {ok, Tag}
  135. end.
  136. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  137. %% Appup action creation and updating
  138. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  139. find_appup_actions(CurrApps, PrevApps) ->
  140. maps:fold(
  141. fun(App, CurrAppIdx, Acc) ->
  142. case PrevApps of
  143. #{App := PrevAppIdx} -> find_appup_actions(App, CurrAppIdx, PrevAppIdx) ++ Acc;
  144. _ -> Acc %% New app, nothing to upgrade here.
  145. end
  146. end,
  147. [],
  148. CurrApps).
  149. find_appup_actions(_App, AppIdx, AppIdx) ->
  150. %% No changes to the app, ignore:
  151. [];
  152. find_appup_actions(App, CurrAppIdx, PrevAppIdx = #app{version = PrevVersion}) ->
  153. {OldUpgrade, OldDowngrade} = find_old_appup_actions(App, PrevVersion),
  154. Upgrade = merge_update_actions(diff_app(App, CurrAppIdx, PrevAppIdx), OldUpgrade),
  155. Downgrade = merge_update_actions(diff_app(App, PrevAppIdx, CurrAppIdx), OldDowngrade),
  156. if OldUpgrade =:= Upgrade andalso OldDowngrade =:= Downgrade ->
  157. %% The appup file has been already updated:
  158. [];
  159. true ->
  160. [{App, {Upgrade, Downgrade}}]
  161. end.
  162. find_old_appup_actions(App, PrevVersion) ->
  163. {Upgrade0, Downgrade0} =
  164. case locate(App, ".appup.src") of
  165. {ok, AppupFile} ->
  166. {_, U, D} = read_appup(AppupFile),
  167. {U, D};
  168. undefined ->
  169. {[], []}
  170. end,
  171. {ensure_version(PrevVersion, Upgrade0), ensure_version(PrevVersion, Downgrade0)}.
  172. merge_update_actions(Changes, Vsns) ->
  173. lists:map(fun(Ret = {<<".*">>, _}) ->
  174. Ret;
  175. ({Vsn, Actions}) ->
  176. {Vsn, do_merge_update_actions(Changes, Actions)}
  177. end,
  178. Vsns).
  179. do_merge_update_actions({New0, Changed0, Deleted0}, OldActions) ->
  180. AlreadyHandled = lists:flatten(lists:map(fun process_old_action/1, OldActions)),
  181. New = New0 -- AlreadyHandled,
  182. Changed = Changed0 -- AlreadyHandled,
  183. Deleted = Deleted0 -- AlreadyHandled,
  184. [{load_module, M, brutal_purge, soft_purge, []} || M <- Changed ++ New] ++
  185. OldActions ++
  186. [{delete_module, M} || M <- Deleted].
  187. %% @doc Process the existing actions to exclude modules that are
  188. %% already handled
  189. process_old_action({purge, Modules}) ->
  190. Modules;
  191. process_old_action({delete_module, Module}) ->
  192. [Module];
  193. process_old_action(LoadModule) when is_tuple(LoadModule) andalso
  194. element(1, LoadModule) =:= load_module ->
  195. element(2, LoadModule);
  196. process_old_action(_) ->
  197. [].
  198. ensure_version(Version, Versions) ->
  199. case lists:keyfind(Version, 1, Versions) of
  200. false ->
  201. [{Version, []}|Versions];
  202. _ ->
  203. Versions
  204. end.
  205. read_appup(File) ->
  206. %% NOTE: appup file is a script, it may contain variables or functions.
  207. case file:script(File, [{'VSN', "VSN"}]) of
  208. {ok, Terms} ->
  209. Terms;
  210. Error ->
  211. fail("Failed to parse appup file ~s: ~p", [File, Error])
  212. end.
  213. check_appup_files() ->
  214. AppupFiles = filelib:wildcard(getopt(src_dirs) ++ "/*.appup.src"),
  215. lists:foreach(fun read_appup/1, AppupFiles).
  216. update_appups(Changes) ->
  217. lists:foreach(
  218. fun({App, {Upgrade, Downgrade}}) ->
  219. do_update_appup(App, Upgrade, Downgrade)
  220. end,
  221. Changes).
  222. do_update_appup(App, Upgrade, Downgrade) ->
  223. case locate(App, ".appup.src") of
  224. {ok, AppupFile} ->
  225. render_appfile(AppupFile, Upgrade, Downgrade);
  226. undefined ->
  227. case create_stub(App) of
  228. {ok, AppupFile} ->
  229. render_appfile(AppupFile, Upgrade, Downgrade);
  230. false ->
  231. set_invalid(),
  232. log("ERROR: Appup file for the external dependency '~p' is not complete.~n Missing changes: ~p", [App, Upgrade])
  233. end
  234. end.
  235. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  236. %% Appup file creation
  237. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  238. render_appfile(File, Upgrade, Downgrade) ->
  239. IOList = io_lib:format("%% -*- mode: erlang -*-\n{VSN,~n ~p,~n ~p}.~n", [Upgrade, Downgrade]),
  240. ok = file:write_file(File, IOList).
  241. create_stub(App) ->
  242. case locate(App, ".app.src") of
  243. {ok, AppSrc} ->
  244. AppupFile = filename:basename(AppSrc) ++ ".appup.src",
  245. Default = {<<".*">>, []},
  246. render_appfile(AppupFile, [Default], [Default]),
  247. AppupFile;
  248. undefined ->
  249. false
  250. end.
  251. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  252. %% application and release indexing
  253. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  254. index_apps(ReleaseDir) ->
  255. maps:from_list([index_app(filename:join(ReleaseDir, AppFile)) ||
  256. AppFile <- filelib:wildcard("**/ebin/*.app", ReleaseDir)]).
  257. index_app(AppFile) ->
  258. {ok, [{application, App, Properties}]} = file:consult(AppFile),
  259. Vsn = proplists:get_value(vsn, Properties),
  260. %% Note: assuming that beams are always located in the same directory where app file is:
  261. EbinDir = filename:dirname(AppFile),
  262. Modules = hashsums(EbinDir),
  263. {App, #app{ version = Vsn
  264. , modules = Modules
  265. }}.
  266. diff_app(App, #app{version = NewVersion, modules = NewModules}, #app{version = OldVersion, modules = OldModules}) ->
  267. {New, Changed} =
  268. maps:fold( fun(Mod, MD5, {New, Changed}) ->
  269. case OldModules of
  270. #{Mod := OldMD5} when MD5 =:= OldMD5 ->
  271. {New, Changed};
  272. #{Mod := _} ->
  273. {New, [Mod|Changed]};
  274. _ ->
  275. {[Mod|New], Changed}
  276. end
  277. end
  278. , {[], []}
  279. , NewModules
  280. ),
  281. Deleted = maps:keys(maps:without(maps:keys(NewModules), OldModules)),
  282. NChanges = length(New) + length(Changed) + length(Deleted),
  283. if NewVersion =:= OldVersion andalso NChanges > 0 ->
  284. set_invalid(),
  285. log("ERROR: Application '~p' contains changes, but its version is not updated", [App]);
  286. true ->
  287. ok
  288. end,
  289. {New, Changed, Deleted}.
  290. -spec hashsums(file:filename()) -> #{module() => binary()}.
  291. hashsums(EbinDir) ->
  292. maps:from_list(lists:map(
  293. fun(Beam) ->
  294. File = filename:join(EbinDir, Beam),
  295. {ok, Ret = {_Module, _MD5}} = beam_lib:md5(File),
  296. Ret
  297. end,
  298. filelib:wildcard("*.beam", EbinDir)
  299. )).
  300. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  301. %% Global state
  302. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  303. init_globals(Options) ->
  304. ets:new(globals, [named_table, set, public]),
  305. ets:insert(globals, {valid, true}),
  306. ets:insert(globals, {options, Options}).
  307. getopt(Option) ->
  308. maps:get(Option, ets:lookup_element(globals, options, 2)).
  309. %% Set a global flag that something about the appfiles is invalid
  310. set_invalid() ->
  311. ets:insert(globals, {valid, false}).
  312. is_valid() ->
  313. ets:lookup_element(globals, valid, 2).
  314. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  315. %% Utility functions
  316. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  317. parse_semver(Version) ->
  318. case re(Version, "^([0-9]+)\\.([0-9]+)\\.([0-9]+)(\\.[0-9]+)?$") of
  319. {match, [Maj, Min, Patch|_]} ->
  320. {list_to_integer(Maj), list_to_integer(Min), list_to_integer(Patch)};
  321. _ ->
  322. error({not_a_semver, Version})
  323. end.
  324. semver(Maj, Min, Patch) ->
  325. lists:flatten(io_lib:format("~p.~p.~p", [Maj, Min, Patch])).
  326. %% Locate a file in a specified application
  327. locate(App, Suffix) ->
  328. AppStr = atom_to_list(App),
  329. SrcDirs = getopt(src_dirs),
  330. case filelib:wildcard(SrcDirs ++ AppStr ++ Suffix) of
  331. [File] ->
  332. {ok, File};
  333. [] ->
  334. undefined
  335. end.
  336. bash(Script) ->
  337. bash(Script, []).
  338. bash(Script, Env) ->
  339. case cmd("bash", #{args => ["-c", Script], env => Env}) of
  340. 0 -> true;
  341. _ -> fail("Failed to run command: ~s", [Script])
  342. end.
  343. %% Spawn an executable and return the exit status
  344. cmd(Exec, Params) ->
  345. case os:find_executable(Exec) of
  346. false ->
  347. fail("Executable not found in $PATH: ~s", [Exec]);
  348. Path ->
  349. Params1 = maps:to_list(maps:with([env, args, cd], Params)),
  350. Port = erlang:open_port( {spawn_executable, Path}
  351. , [ exit_status
  352. , nouse_stdio
  353. | Params1
  354. ]
  355. ),
  356. receive
  357. {Port, {exit_status, Status}} ->
  358. Status
  359. end
  360. end.
  361. fail(Str) ->
  362. fail(Str, []).
  363. fail(Str, Args) ->
  364. log(Str ++ "~n", Args),
  365. halt(1).
  366. re(Subject, RE) ->
  367. re:run(Subject, RE, [{capture, all_but_first, list}]).
  368. log(Msg) ->
  369. log(Msg, []).
  370. log(Msg, Args) ->
  371. io:format(standard_error, Msg, Args).