update_appup.escript 15 KB

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