update_appup.escript 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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. --repo Upsteam git repo URL
  21. --remote Get upstream repo URL from the specified git remote
  22. --skip-build Don't rebuild the releases. May produce wrong results
  23. --make-command A command used to assemble the release
  24. --release-dir Release directory
  25. ".
  26. default_options() ->
  27. #{ clone_url => find_upstream_repo("origin")
  28. , make_command => "make emqx-rel"
  29. , beams_dir => "_build/emqx/rel/emqx/lib/"
  30. }.
  31. main(Args) ->
  32. put(update_appup_valid, true),
  33. #{current_release := CurrentRelease} = Options = parse_args(Args, default_options()),
  34. case find_pred_tag(CurrentRelease) of
  35. {ok, Baseline} ->
  36. main(Options, Baseline);
  37. undefined ->
  38. log("No appup update is needed for this release, nothing to be done~n", []),
  39. ok
  40. end.
  41. parse_args([CurrentRelease = [A|_]], State) when A =/= $- ->
  42. State#{current_release => CurrentRelease};
  43. parse_args(["--skip-build"|Rest], State) ->
  44. parse_args(Rest, State#{make_command => "true"});
  45. parse_args(["--repo", Repo|Rest], State) ->
  46. parse_args(Rest, State#{clone_url => Repo});
  47. parse_args(["--remote", Remote|Rest], State) ->
  48. parse_args(Rest, State#{clone_url => find_upstream_repo(Remote)});
  49. parse_args(["--make-command", Command|Rest], State) ->
  50. parse_args(Rest, State#{make_command => Command});
  51. parse_args(["--release-dir", Dir|Rest], State) ->
  52. parse_args(Rest, State#{beams_dir => Dir});
  53. parse_args(_, _) ->
  54. fail(usage()).
  55. main(Options, Baseline) ->
  56. {CurrDir, PredDir} = prepare(Baseline, Options),
  57. log("~n===================================~n"
  58. "Processing changes..."
  59. "~n===================================~n"),
  60. CurrBeams = hashsums(find_beams(CurrDir)),
  61. PredBeams = hashsums(find_beams(PredDir)),
  62. Upgrade = diff_releases(CurrBeams, PredBeams),
  63. Downgrade = diff_releases(PredBeams, CurrBeams),
  64. Apps = maps:keys(Upgrade),
  65. lists:foreach( fun(App) ->
  66. %% TODO: Here we can find new and deleted apps and handle them accordingly
  67. #{App := AppUpgrade} = Upgrade,
  68. #{App := AppDowngrade} = Downgrade,
  69. process_app(Baseline, App, AppUpgrade, AppDowngrade)
  70. end
  71. , Apps
  72. ),
  73. warn_and_exit(is_valid()).
  74. warn_and_exit(true) ->
  75. log("
  76. NOTE: Please review the changes manually. This script does not know about NIF
  77. changes, supervisor changes, process restarts and so on. Also the load order of
  78. the beam files might need updating.~n"),
  79. halt(0);
  80. warn_and_exit(false) ->
  81. log("~nERROR: Incomplete appups found. Please inspect the output for more details.~n"),
  82. halt(1).
  83. process_app(_, App, {[], [], []}, {[], [], []}) ->
  84. %% No changes, just check the appup file if present:
  85. case locate(App, ".appup.src") of
  86. {ok, AppupFile} ->
  87. _ = read_appup(AppupFile),
  88. ok;
  89. undefined ->
  90. ok
  91. end;
  92. process_app(PredVersion, App, Upgrade, Downgrade) ->
  93. case locate(App, ".appup.src") of
  94. {ok, AppupFile} ->
  95. update_appup(PredVersion, AppupFile, Upgrade, Downgrade);
  96. undefined ->
  97. case create_stub(App) of
  98. false ->
  99. set_invalid(),
  100. log("ERROR: External dependency '~p' contains changes, but the appup.src file is NOT updated.
  101. Create a patch to the upstream to resolve this issue.~n", [App]),
  102. ok;
  103. AppupFile ->
  104. update_appup(PredVersion, AppupFile, Upgrade, Downgrade)
  105. end
  106. end.
  107. create_stub(App) ->
  108. case locate(App, ".app.src") of
  109. {ok, AppSrc} ->
  110. AppupFile = filename:basename(AppSrc) ++ ".appup.src",
  111. Default = {<<".*">>, []},
  112. render_appfile(AppupFile, [Default], [Default]),
  113. AppupFile;
  114. undefined ->
  115. false
  116. end.
  117. update_appup(PredVersion, File, UpgradeChanges, DowngradeChanges) ->
  118. log("INFO: Updating appup: ~s~n", [File]),
  119. {_, Upgrade0, Downgrade0} = read_appup(File),
  120. Upgrade = update_actions(PredVersion, UpgradeChanges, Upgrade0),
  121. Downgrade = update_actions(PredVersion, DowngradeChanges, Downgrade0),
  122. render_appfile(File, Upgrade, Downgrade),
  123. %% Check appup syntax:
  124. _ = read_appup(File).
  125. render_appfile(File, Upgrade, Downgrade) ->
  126. IOList = io_lib:format("%% -*- mode: erlang -*-\n{VSN,~n ~p,~n ~p}.~n", [Upgrade, Downgrade]),
  127. ok = file:write_file(File, IOList).
  128. update_actions(PredVersion, Changes, Actions) ->
  129. lists:map( fun(L) -> do_update_actions(Changes, L) end
  130. , ensure_pred_versions(PredVersion, Actions)
  131. ).
  132. do_update_actions(_, Ret = {<<".*">>, _}) ->
  133. Ret;
  134. do_update_actions(Changes, {Vsn, Actions}) ->
  135. {Vsn, process_changes(Changes, Actions)}.
  136. process_changes({New0, Changed0, Deleted0}, OldActions) ->
  137. AlreadyHandled = lists:flatten(lists:map(fun process_old_action/1, OldActions)),
  138. New = New0 -- AlreadyHandled,
  139. Changed = Changed0 -- AlreadyHandled,
  140. Deleted = Deleted0 -- AlreadyHandled,
  141. [{load_module, M, brutal_purge, soft_purge, []} || M <- Changed ++ New] ++
  142. OldActions ++
  143. [{delete_module, M} || M <- Deleted].
  144. %% @doc Process the existing actions to exclude modules that are
  145. %% already handled
  146. process_old_action({purge, Modules}) ->
  147. Modules;
  148. process_old_action({delete_module, Module}) ->
  149. [Module];
  150. process_old_action(LoadModule) when is_tuple(LoadModule) andalso
  151. element(1, LoadModule) =:= load_module ->
  152. element(2, LoadModule);
  153. process_old_action(_) ->
  154. [].
  155. ensure_pred_versions(PredVersion, Versions) ->
  156. {Maj, Min, Patch} = parse_semver(PredVersion),
  157. PredVersions = [semver(Maj, Min, P) || P <- lists:seq(0, Patch)],
  158. lists:foldl(fun ensure_version/2, Versions, PredVersions).
  159. ensure_version(Version, Versions) ->
  160. case lists:keyfind(Version, 1, Versions) of
  161. false ->
  162. [{Version, []}|Versions];
  163. _ ->
  164. Versions
  165. end.
  166. read_appup(File) ->
  167. case file:script(File, [{'VSN', "VSN"}]) of
  168. {ok, Terms} ->
  169. Terms;
  170. Error ->
  171. fail("Failed to parse appup file ~s: ~p", [File, Error])
  172. end.
  173. diff_releases(Curr, Old) ->
  174. Fun = fun(App, Modules, Acc) ->
  175. OldModules = maps:get(App, Old, #{}),
  176. Acc#{App => diff_app_modules(Modules, OldModules)}
  177. end,
  178. maps:fold(Fun, #{}, Curr).
  179. diff_app_modules(Modules, OldModules) ->
  180. {New, Changed} =
  181. maps:fold( fun(Mod, MD5, {New, Changed}) ->
  182. case OldModules of
  183. #{Mod := OldMD5} when MD5 =:= OldMD5 ->
  184. {New, Changed};
  185. #{Mod := _} ->
  186. {New, [Mod|Changed]};
  187. _ -> {[Mod|New], Changed}
  188. end
  189. end
  190. , {[], []}
  191. , Modules
  192. ),
  193. Deleted = maps:keys(maps:without(maps:keys(Modules), OldModules)),
  194. {New, Changed, Deleted}.
  195. find_beams(Dir) ->
  196. [filename:join(Dir, I) || I <- filelib:wildcard("**/ebin/*.beam", Dir)].
  197. prepare(Baseline, Options = #{make_command := MakeCommand, beams_dir := BeamDir}) ->
  198. log("~n===================================~n"
  199. "Baseline: ~s"
  200. "~n===================================~n", [Baseline]),
  201. log("Building the current version...~n"),
  202. bash(MakeCommand),
  203. log("Downloading and building the previous release...~n"),
  204. {ok, PredRootDir} = build_pred_release(Baseline, Options),
  205. {BeamDir, filename:join(PredRootDir, BeamDir)}.
  206. build_pred_release(Baseline, #{clone_url := Repo, make_command := MakeCommand}) ->
  207. BaseDir = "/tmp/emqx-baseline/",
  208. Dir = filename:basename(Repo, ".git") ++ [$-|Baseline],
  209. %% TODO: shallow clone
  210. Script = "mkdir -p ${BASEDIR} &&
  211. cd ${BASEDIR} &&
  212. { [ -d ${DIR} ] || git clone --branch ${TAG} ${REPO} ${DIR}; } &&
  213. cd ${DIR} &&" ++ MakeCommand,
  214. Env = [{"REPO", Repo}, {"TAG", Baseline}, {"BASEDIR", BaseDir}, {"DIR", Dir}],
  215. bash(Script, Env),
  216. {ok, filename:join(BaseDir, Dir)}.
  217. find_upstream_repo(Remote) ->
  218. string:trim(os:cmd("git remote get-url " ++ Remote)).
  219. find_pred_tag(CurrentRelease) ->
  220. {Maj, Min, Patch} = parse_semver(CurrentRelease),
  221. case Patch of
  222. 0 -> undefined;
  223. _ -> {ok, semver(Maj, Min, Patch - 1)}
  224. end.
  225. -spec hashsums(file:filename()) -> #{App => #{module() => binary()}}
  226. when App :: atom().
  227. hashsums(Files) ->
  228. hashsums(Files, #{}).
  229. hashsums([], Acc) ->
  230. Acc;
  231. hashsums([File|Rest], Acc0) ->
  232. [_, "ebin", Dir|_] = lists:reverse(filename:split(File)),
  233. {match, [AppStr]} = re(Dir, "^(.*)-[^-]+$"),
  234. App = list_to_atom(AppStr),
  235. {ok, {Module, MD5}} = beam_lib:md5(File),
  236. Acc = maps:update_with( App
  237. , fun(Old) -> Old #{Module => MD5} end
  238. , #{Module => MD5}
  239. , Acc0
  240. ),
  241. hashsums(Rest, Acc).
  242. %% Set a global flag that something about the appfiles is invalid
  243. set_invalid() ->
  244. put(update_appup_invalid, false).
  245. is_valid() ->
  246. get(update_appup_invalid).
  247. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  248. %% Utility functions
  249. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
  250. parse_semver(Version) ->
  251. case re(Version, "^([0-9]+)\\.([0-9]+)\\.([0-9]+)(\\.[0-9]+)?$") of
  252. {match, [Maj, Min, Patch|_]} ->
  253. {list_to_integer(Maj), list_to_integer(Min), list_to_integer(Patch)};
  254. _ ->
  255. error({not_a_semver, Version})
  256. end.
  257. semver(Maj, Min, Patch) ->
  258. lists:flatten(io_lib:format("~p.~p.~p", [Maj, Min, Patch])).
  259. %% Locate a file in a specified application
  260. locate(App, Suffix) ->
  261. AppStr = atom_to_list(App),
  262. case filelib:wildcard("{src,apps,lib-*}/**/" ++ AppStr ++ Suffix) of
  263. [File] ->
  264. {ok, File};
  265. [] ->
  266. undefined
  267. end.
  268. bash(Script) ->
  269. bash(Script, []).
  270. bash(Script, Env) ->
  271. case cmd("bash", #{args => ["-c", Script], env => Env}) of
  272. 0 -> true;
  273. _ -> fail("Failed to run command: ~s", [Script])
  274. end.
  275. %% Spawn an executable and return the exit status
  276. cmd(Exec, Params) ->
  277. case os:find_executable(Exec) of
  278. false ->
  279. fail("Executable not found in $PATH: ~s", [Exec]);
  280. Path ->
  281. Params1 = maps:to_list(maps:with([env, args, cd], Params)),
  282. Port = erlang:open_port( {spawn_executable, Path}
  283. , [ exit_status
  284. , nouse_stdio
  285. | Params1
  286. ]
  287. ),
  288. receive
  289. {Port, {exit_status, Status}} ->
  290. Status
  291. end
  292. end.
  293. fail(Str) ->
  294. fail(Str, []).
  295. fail(Str, Args) ->
  296. log(Str ++ "~n", Args),
  297. halt(1).
  298. re(Subject, RE) ->
  299. re:run(Subject, RE, [{capture, all_but_first, list}]).
  300. log(Msg) ->
  301. log(Msg, []).
  302. log(Msg, Args) ->
  303. io:format(standard_error, Msg, Args).