Usmrad.vala 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. /**
  2. * usmrad — USM Rapid Application Deployment supervisor.
  3. *
  4. * Tracks a git branch, builds and installs the branch's manifest into an
  5. * A/B slot layout under the application directory, and keeps an instance of
  6. * the application's executable running from the active slot. A deployment
  7. * failure never disturbs a running instance; a successful deployment
  8. * terminates the old instance and launches the new one. When the supervised
  9. * executable exits, usmrad exits with its exit code.
  10. *
  11. * Configuration is entirely environmental; see {@link Config}.
  12. */
  13. namespace Usm.Rad {
  14. /**
  15. * Failure modes of the rapid-deployment supervisor.
  16. */
  17. public errordomain RadError {
  18. /** A USMRAD_* environment variable is missing or malformed */
  19. CONFIGURATION,
  20. /** A git operation (clone, fetch, reset) failed */
  21. GIT,
  22. /** A build or install step failed */
  23. DEPLOY,
  24. /** A dependency resource could not be satisfied by the SPM or USM repositories */
  25. DEPENDENCY
  26. }
  27. /**
  28. * Bracketed-origin logging for the usmrad supervisor, per STYLE.md.
  29. */
  30. public class Log {
  31. /**
  32. * Prints an informational line to stdout with the {@link Log} origin
  33. * prefix.
  34. */
  35. public static void info(string message) {
  36. print("[usmrad] %s\n", message);
  37. }
  38. /**
  39. * Prints a warning line to stderr with the {@link Log} origin prefix.
  40. */
  41. public static void warning(string message) {
  42. printerr("[usmrad] %s\n", message);
  43. }
  44. }
  45. /**
  46. * argv construction for spawned commands and joined argument strings.
  47. *
  48. * Invercargill's {@link Invercargill.DataStructures.Vector.to_array} returns
  49. * exactly-sized arrays without the NULL sentinel GLib's spawn helpers and
  50. * {@link string.joinv} require — an unterminated argv crashes inside
  51. * g_subprocess_newv's g_strdupv. Every vector-built argument list therefore
  52. * flows through {@link from_vector}, whose trailing zeroed slot terminates
  53. * the array the same way a Vala literal or explicit `new string[n]` fill
  54. * does.
  55. */
  56. public class Argv {
  57. /**
  58. * A NULL-terminated copy of {@link command}: one slot longer than the
  59. * vector, with the final slot left zeroed as the sentinel.
  60. */
  61. public static string[] from_vector(Invercargill.DataStructures.Vector<string> command) {
  62. var size = (int)command.length;
  63. var argv = new string[size + 1];
  64. var index = 0;
  65. foreach(var argument in command) {
  66. argv[index++] = argument;
  67. }
  68. return argv;
  69. }
  70. }
  71. /**
  72. * usmrad configuration, parsed from the USMRAD_* environment.
  73. */
  74. public class Config : Object {
  75. /** Git repository URL to deploy (USMRAD_REPO, required). */
  76. public string repo { get; set; }
  77. /** Branch to track (USMRAD_BRANCH, default "master"). */
  78. public string branch { get; set; default = "master"; }
  79. /** Poll interval in minutes; 0 disables polling (USMRAD_POLL). */
  80. public double poll_minutes { get; set; default = 0; }
  81. /** Application directory (USMRAD_APPDIR, default "/app"). */
  82. public string appdir { get; set; default = "/app"; }
  83. /** USMR URIs wired into usm at startup (USMRAD_USM_REPOS, semicolon-delimited). */
  84. public string[] usm_repos { get; set; default = new string[] {}; }
  85. /** Seconds between SIGTERM and SIGKILL when terminating an instance (USMRAD_TERM_GRACE). */
  86. public int term_grace_seconds { get; set; default = 10; }
  87. /**
  88. * Poll interval in whole seconds; 0 when polling is disabled. Fractional
  89. * minutes round down, so USMRAD_POLL=0.25 polls every 15 seconds.
  90. */
  91. public uint poll_interval_seconds {
  92. get {
  93. return (uint)(poll_minutes * 60.0);
  94. }
  95. }
  96. /**
  97. * Reads the USMRAD_* environment applying defaults, throwing
  98. * {@link RadError.CONFIGURATION} with a user-facing message when a
  99. * variable is missing or malformed.
  100. */
  101. public static Config from_environment() throws RadError {
  102. var config = new Config();
  103. var repo = Environment.get_variable("USMRAD_REPO");
  104. if(repo == null || repo.strip().length == 0) {
  105. throw new RadError.CONFIGURATION("USMRAD_REPO is required (a git repository URL)");
  106. }
  107. config.repo = repo.strip();
  108. var branch = Environment.get_variable("USMRAD_BRANCH");
  109. if(branch != null && branch.strip().length > 0) {
  110. config.branch = branch.strip();
  111. }
  112. var poll = Environment.get_variable("USMRAD_POLL");
  113. if(poll != null && poll.strip().length > 0) {
  114. double parsed_poll;
  115. if(!double.try_parse(poll.strip(), out parsed_poll) || parsed_poll < 0) {
  116. throw new RadError.CONFIGURATION(@"USMRAD_POLL must be a non-negative number of minutes, got \"$poll\"");
  117. }
  118. config.poll_minutes = parsed_poll;
  119. }
  120. var appdir = Environment.get_variable("USMRAD_APPDIR");
  121. if(appdir != null && appdir.strip().length > 0) {
  122. config.appdir = appdir.strip();
  123. }
  124. var repos = Environment.get_variable("USMRAD_USM_REPOS");
  125. if(repos != null && repos.strip().length > 0) {
  126. var entries = new Invercargill.DataStructures.Vector<string>();
  127. foreach(var entry in repos.split(";")) {
  128. if(entry.strip().length > 0) {
  129. entries.add(entry.strip());
  130. }
  131. }
  132. config.usm_repos = entries.to_array();
  133. }
  134. var grace = Environment.get_variable("USMRAD_TERM_GRACE");
  135. if(grace != null && grace.strip().length > 0) {
  136. int64 parsed_grace;
  137. if(!int64.try_parse(grace.strip(), out parsed_grace) || parsed_grace < 0) {
  138. throw new RadError.CONFIGURATION(@"USMRAD_TERM_GRACE must be a non-negative number of seconds, got \"$grace\"");
  139. }
  140. config.term_grace_seconds = (int)parsed_grace;
  141. }
  142. return config;
  143. }
  144. }
  145. Config rad_config;
  146. Slots rad_slots;
  147. Source rad_source;
  148. System rad_system;
  149. Deployer rad_deployer;
  150. Supervisor rad_supervisor;
  151. MainLoop rad_loop;
  152. string rad_exec_name;
  153. string[] rad_exec_args;
  154. int rad_exit_code = 0;
  155. bool rad_cycle_busy = false;
  156. bool rad_instance_running = false;
  157. // The test target links every rad source for pure-logic coverage; only the
  158. // real executable may own the process entry point
  159. #if !USMRAD_TESTS
  160. public static int main(string[] args) {
  161. if(args.length < 2 || args[1].has_prefix("-")) {
  162. usage();
  163. return 255;
  164. }
  165. rad_exec_name = args[1];
  166. rad_exec_args = args[2:args.length];
  167. try {
  168. rad_config = Config.from_environment();
  169. }
  170. catch(RadError e) {
  171. Log.warning(e.message);
  172. usage();
  173. return 255;
  174. }
  175. rad_slots = new Slots(rad_config.appdir);
  176. try {
  177. rad_slots.ensure_layout();
  178. }
  179. catch(Error e) {
  180. Log.warning(@"Could not create the appdir layout in \"$(rad_config.appdir)\": $(e.message)");
  181. return 255;
  182. }
  183. var usm_binary = find_usm_binary();
  184. if(usm_binary == null) {
  185. Log.warning("Could not locate the usm executable next to usmrad or on PATH");
  186. return 255;
  187. }
  188. rad_source = new Source(rad_config.repo, rad_config.branch, rad_slots.repo_dir);
  189. rad_system = new System(rad_config, usm_binary);
  190. rad_deployer = new Deployer(rad_config, rad_slots, rad_source, rad_system, usm_binary);
  191. rad_supervisor = new Supervisor(rad_slots.pwd, rad_config.term_grace_seconds);
  192. rad_loop = new MainLoop();
  193. rad_supervisor.exited.connect(status => {
  194. rad_exit_code = status;
  195. rad_loop.quit();
  196. });
  197. rad_supervisor.shutdown.connect(signal_number => {
  198. rad_exit_code = 128 + signal_number;
  199. rad_loop.quit();
  200. });
  201. rad_supervisor.run();
  202. // Launch the previous good instance first so a running deployment cycle
  203. // never adds downtime for an instance that already works
  204. rad_instance_running = launch_from_current();
  205. if(rad_instance_running) {
  206. Log.info(@"Launched previous instance from slot \"$(rad_slots.read_current_slot())\"");
  207. }
  208. Idle.add(() => {
  209. startup_cycle.begin();
  210. return false;
  211. });
  212. if(rad_config.poll_interval_seconds > 0) {
  213. Timeout.add_seconds(rad_config.poll_interval_seconds, () => {
  214. run_cycle.begin();
  215. return true;
  216. });
  217. }
  218. rad_loop.run();
  219. return rad_exit_code;
  220. }
  221. /**
  222. * Runs the startup check cycle: enrollment, repository wiring and one
  223. * deployment check. With no previous instance a failed first deployment is
  224. * fatal (exit 251); with one, failures merely warn.
  225. */
  226. private static async void startup_cycle() {
  227. try {
  228. rad_system.ensure_enrolled();
  229. }
  230. catch(RadError e) {
  231. Log.warning(@"usm enroll failed: $(e.message)");
  232. rad_exit_code = 245;
  233. rad_loop.quit();
  234. return;
  235. }
  236. try {
  237. rad_system.wire_repos();
  238. }
  239. catch(RadError e) {
  240. Log.warning(@"Could not wire USMRAD_USM_REPOS repositories: $(e.message); continuing");
  241. }
  242. var deployed = yield run_cycle();
  243. if(!deployed && !rad_instance_running) {
  244. Log.warning("First deployment failed and no previous instance exists");
  245. rad_exit_code = 251;
  246. rad_loop.quit();
  247. }
  248. }
  249. /**
  250. * Runs one check cycle, guarded against re-entry: synchronise the source
  251. * repository, refresh and update USM packages, and when new commits or
  252. * package updates are available, deploy into the inactive slot and swap.
  253. * Returns true when a deployment went through and a new instance launched.
  254. */
  255. private static async bool run_cycle() {
  256. if(rad_cycle_busy) {
  257. return false;
  258. }
  259. rad_cycle_busy = true;
  260. var deployed = false;
  261. try {
  262. deployed = yield run_cycle_inner();
  263. }
  264. finally {
  265. rad_cycle_busy = false;
  266. }
  267. return deployed;
  268. }
  269. private static async bool run_cycle_inner() {
  270. try {
  271. rad_source.ensure_cloned();
  272. rad_source.fetch();
  273. }
  274. catch(RadError e) {
  275. Log.warning(@"Could not synchronise \"$(rad_config.repo)\": $(e.message); skipping cycle");
  276. return false;
  277. }
  278. var deployed_commit = rad_slots.read_deployed_commit();
  279. string remote_head = null;
  280. try {
  281. remote_head = rad_source.remote_head();
  282. }
  283. catch(RadError e) {
  284. Log.warning(@"Could not resolve the remote head: $(e.message); skipping cycle");
  285. return false;
  286. }
  287. var new_commits = deployed_commit == null || deployed_commit != remote_head;
  288. var deps_changed = false;
  289. try {
  290. deps_changed = yield rad_system.refresh_and_update();
  291. }
  292. catch(RadError e) {
  293. Log.warning(@"usm update failed: $(e.message); continuing with the commit check");
  294. }
  295. if(!new_commits && !deps_changed) {
  296. return false;
  297. }
  298. Log.info(new_commits
  299. ? @"New commits on \"$(rad_config.branch)\" ($remote_head)"
  300. : "Upstream USM package updates detected");
  301. var inactive_slot = rad_slots.inactive_slot() ?? "a";
  302. try {
  303. yield rad_deployer.deploy(inactive_slot);
  304. }
  305. catch(RadError e) {
  306. Log.warning(@"Deployment into slot \"$inactive_slot\" failed: $(e.message)");
  307. return false;
  308. }
  309. if(rad_instance_running) {
  310. Log.info("Terminating previous instance");
  311. yield rad_supervisor.terminate();
  312. rad_instance_running = false;
  313. }
  314. if(launch_from_current()) {
  315. rad_instance_running = true;
  316. Log.info(@"Launched new instance from slot \"$(rad_slots.read_current_slot())\" ($remote_head)");
  317. return true;
  318. }
  319. // The deployment succeeded but nothing launchable came out of it; the
  320. // container supervisor is better placed to recover than a dead poll loop
  321. Log.warning(@"\"$(rad_exec_name)\" does not resolve or launch from the deployed slot");
  322. rad_exit_code = 251;
  323. rad_loop.quit();
  324. return true;
  325. }
  326. /**
  327. * Launches the configured exec from the current slot. Returns false without
  328. * ceremony when no slot is current; warns when a current slot exists but the
  329. * exec does not resolve or launch within it.
  330. */
  331. private static bool launch_from_current() {
  332. var current_slot = rad_slots.read_current_slot();
  333. if(current_slot == null) {
  334. return false;
  335. }
  336. var executable = rad_slots.resolve_exec(current_slot, rad_exec_name);
  337. if(executable == null) {
  338. Log.warning(@"\"$(rad_exec_name)\" does not resolve in slot \"$current_slot\" (PATH-style search within the slot)");
  339. return false;
  340. }
  341. try {
  342. rad_supervisor.launch(executable, rad_exec_args);
  343. }
  344. catch(Error e) {
  345. Log.warning(@"Could not launch \"$executable\": $(e.message)");
  346. return false;
  347. }
  348. return true;
  349. }
  350. /**
  351. * Locates the usm executable: a sibling of usmrad's own executable first
  352. * (both install into the same bindir), then the PATH.
  353. */
  354. private static string? find_usm_binary() {
  355. try {
  356. var own_path = FileUtils.read_link("/proc/self/exe");
  357. var candidate = Path.build_filename(Path.get_dirname(own_path), "usm");
  358. if(FileUtils.test(candidate, FileTest.IS_EXECUTABLE)) {
  359. return candidate;
  360. }
  361. }
  362. catch(Error e) {
  363. // Fall through to the PATH search
  364. }
  365. return Environment.find_program_in_path("usm");
  366. }
  367. private static void usage() {
  368. printerr("USAGE:\n\tusmrad <exec> [args...]\n\nEnvironment:\n");
  369. printerr("\tUSMRAD_REPO (required) git repository URL\n");
  370. printerr("\tUSMRAD_BRANCH branch to track (default: master)\n");
  371. printerr("\tUSMRAD_POLL poll interval in minutes, 0 disables (default: 0)\n");
  372. printerr("\tUSMRAD_APPDIR application directory (default: /app)\n");
  373. printerr("\tUSMRAD_USM_REPOS semicolon-delimited .usmr URIs\n");
  374. printerr("\tUSMRAD_TERM_GRACE seconds between SIGTERM and SIGKILL (default: 10)\n");
  375. }
  376. #endif
  377. }