/** * usmrad — USM Rapid Application Deployment supervisor. * * Tracks a git branch, builds and installs the branch's manifest into an * A/B slot layout under the application directory, and keeps an instance of * the application's executable running from the active slot. A deployment * failure never disturbs a running instance; a successful deployment * terminates the old instance and launches the new one. When the supervised * executable exits, usmrad exits with its exit code. * * Configuration is entirely environmental; see {@link Config}. */ namespace Usm.Rad { /** * Failure modes of the rapid-deployment supervisor. */ public errordomain RadError { /** A USMRAD_* environment variable is missing or malformed */ CONFIGURATION, /** A git operation (clone, fetch, reset) failed */ GIT, /** A build or install step failed */ DEPLOY, /** A dependency resource could not be satisfied by the SPM or USM repositories */ DEPENDENCY } /** * Bracketed-origin logging for the usmrad supervisor, per STYLE.md. */ public class Log { /** * Prints an informational line to stdout with the {@link Log} origin * prefix. */ public static void info(string message) { print("[usmrad] %s\n", message); } /** * Prints a warning line to stderr with the {@link Log} origin prefix. */ public static void warning(string message) { printerr("[usmrad] %s\n", message); } } /** * argv construction for spawned commands and joined argument strings. * * Invercargill's {@link Invercargill.DataStructures.Vector.to_array} returns * exactly-sized arrays without the NULL sentinel GLib's spawn helpers and * {@link string.joinv} require — an unterminated argv crashes inside * g_subprocess_newv's g_strdupv. Every vector-built argument list therefore * flows through {@link from_vector}, whose trailing zeroed slot terminates * the array the same way a Vala literal or explicit `new string[n]` fill * does. */ public class Argv { /** * A NULL-terminated copy of {@link command}: one slot longer than the * vector, with the final slot left zeroed as the sentinel. */ public static string[] from_vector(Invercargill.DataStructures.Vector command) { var size = (int)command.length; var argv = new string[size + 1]; var index = 0; foreach(var argument in command) { argv[index++] = argument; } return argv; } } /** * usmrad configuration, parsed from the USMRAD_* environment. */ public class Config : Object { /** Git repository URL to deploy (USMRAD_REPO, required). */ public string repo { get; set; } /** Branch to track (USMRAD_BRANCH, default "master"). */ public string branch { get; set; default = "master"; } /** Poll interval in minutes; 0 disables polling (USMRAD_POLL). */ public double poll_minutes { get; set; default = 0; } /** Application directory (USMRAD_APPDIR, default "/app"). */ public string appdir { get; set; default = "/app"; } /** USMR URIs wired into usm at startup (USMRAD_USM_REPOS, semicolon-delimited). */ public string[] usm_repos { get; set; default = new string[] {}; } /** Seconds between SIGTERM and SIGKILL when terminating an instance (USMRAD_TERM_GRACE). */ public int term_grace_seconds { get; set; default = 10; } /** * Poll interval in whole seconds; 0 when polling is disabled. Fractional * minutes round down, so USMRAD_POLL=0.25 polls every 15 seconds. */ public uint poll_interval_seconds { get { return (uint)(poll_minutes * 60.0); } } /** * Reads the USMRAD_* environment applying defaults, throwing * {@link RadError.CONFIGURATION} with a user-facing message when a * variable is missing or malformed. */ public static Config from_environment() throws RadError { var config = new Config(); var repo = Environment.get_variable("USMRAD_REPO"); if(repo == null || repo.strip().length == 0) { throw new RadError.CONFIGURATION("USMRAD_REPO is required (a git repository URL)"); } config.repo = repo.strip(); var branch = Environment.get_variable("USMRAD_BRANCH"); if(branch != null && branch.strip().length > 0) { config.branch = branch.strip(); } var poll = Environment.get_variable("USMRAD_POLL"); if(poll != null && poll.strip().length > 0) { double parsed_poll; if(!double.try_parse(poll.strip(), out parsed_poll) || parsed_poll < 0) { throw new RadError.CONFIGURATION(@"USMRAD_POLL must be a non-negative number of minutes, got \"$poll\""); } config.poll_minutes = parsed_poll; } var appdir = Environment.get_variable("USMRAD_APPDIR"); if(appdir != null && appdir.strip().length > 0) { config.appdir = appdir.strip(); } var repos = Environment.get_variable("USMRAD_USM_REPOS"); if(repos != null && repos.strip().length > 0) { var entries = new Invercargill.DataStructures.Vector(); foreach(var entry in repos.split(";")) { if(entry.strip().length > 0) { entries.add(entry.strip()); } } config.usm_repos = entries.to_array(); } var grace = Environment.get_variable("USMRAD_TERM_GRACE"); if(grace != null && grace.strip().length > 0) { int64 parsed_grace; if(!int64.try_parse(grace.strip(), out parsed_grace) || parsed_grace < 0) { throw new RadError.CONFIGURATION(@"USMRAD_TERM_GRACE must be a non-negative number of seconds, got \"$grace\""); } config.term_grace_seconds = (int)parsed_grace; } return config; } } Config rad_config; Slots rad_slots; Source rad_source; System rad_system; Deployer rad_deployer; Supervisor rad_supervisor; MainLoop rad_loop; string rad_exec_name; string[] rad_exec_args; int rad_exit_code = 0; bool rad_cycle_busy = false; bool rad_instance_running = false; // The test target links every rad source for pure-logic coverage; only the // real executable may own the process entry point #if !USMRAD_TESTS public static int main(string[] args) { if(args.length < 2 || args[1].has_prefix("-")) { usage(); return 255; } rad_exec_name = args[1]; rad_exec_args = args[2:args.length]; try { rad_config = Config.from_environment(); } catch(RadError e) { Log.warning(e.message); usage(); return 255; } rad_slots = new Slots(rad_config.appdir); try { rad_slots.ensure_layout(); } catch(Error e) { Log.warning(@"Could not create the appdir layout in \"$(rad_config.appdir)\": $(e.message)"); return 255; } var usm_binary = find_usm_binary(); if(usm_binary == null) { Log.warning("Could not locate the usm executable next to usmrad or on PATH"); return 255; } rad_source = new Source(rad_config.repo, rad_config.branch, rad_slots.repo_dir); rad_system = new System(rad_config, usm_binary); rad_deployer = new Deployer(rad_config, rad_slots, rad_source, rad_system, usm_binary); rad_supervisor = new Supervisor(rad_slots.pwd, rad_config.term_grace_seconds); rad_loop = new MainLoop(); rad_supervisor.exited.connect(status => { rad_exit_code = status; rad_loop.quit(); }); rad_supervisor.shutdown.connect(signal_number => { rad_exit_code = 128 + signal_number; rad_loop.quit(); }); rad_supervisor.run(); // Launch the previous good instance first so a running deployment cycle // never adds downtime for an instance that already works rad_instance_running = launch_from_current(); if(rad_instance_running) { Log.info(@"Launched previous instance from slot \"$(rad_slots.read_current_slot())\""); } Idle.add(() => { startup_cycle.begin(); return false; }); if(rad_config.poll_interval_seconds > 0) { Timeout.add_seconds(rad_config.poll_interval_seconds, () => { run_cycle.begin(); return true; }); } rad_loop.run(); return rad_exit_code; } /** * Runs the startup check cycle: enrollment, repository wiring and one * deployment check. With no previous instance a failed first deployment is * fatal (exit 251); with one, failures merely warn. */ private static async void startup_cycle() { try { rad_system.ensure_enrolled(); } catch(RadError e) { Log.warning(@"usm enroll failed: $(e.message)"); rad_exit_code = 245; rad_loop.quit(); return; } try { rad_system.wire_repos(); } catch(RadError e) { Log.warning(@"Could not wire USMRAD_USM_REPOS repositories: $(e.message); continuing"); } var deployed = yield run_cycle(); if(!deployed && !rad_instance_running) { Log.warning("First deployment failed and no previous instance exists"); rad_exit_code = 251; rad_loop.quit(); } } /** * Runs one check cycle, guarded against re-entry: synchronise the source * repository, refresh and update USM packages, and when new commits or * package updates are available, deploy into the inactive slot and swap. * Returns true when a deployment went through and a new instance launched. */ private static async bool run_cycle() { if(rad_cycle_busy) { return false; } rad_cycle_busy = true; var deployed = false; try { deployed = yield run_cycle_inner(); } finally { rad_cycle_busy = false; } return deployed; } private static async bool run_cycle_inner() { try { rad_source.ensure_cloned(); rad_source.fetch(); } catch(RadError e) { Log.warning(@"Could not synchronise \"$(rad_config.repo)\": $(e.message); skipping cycle"); return false; } var deployed_commit = rad_slots.read_deployed_commit(); string remote_head = null; try { remote_head = rad_source.remote_head(); } catch(RadError e) { Log.warning(@"Could not resolve the remote head: $(e.message); skipping cycle"); return false; } var new_commits = deployed_commit == null || deployed_commit != remote_head; var deps_changed = false; try { deps_changed = yield rad_system.refresh_and_update(); } catch(RadError e) { Log.warning(@"usm update failed: $(e.message); continuing with the commit check"); } if(!new_commits && !deps_changed) { return false; } Log.info(new_commits ? @"New commits on \"$(rad_config.branch)\" ($remote_head)" : "Upstream USM package updates detected"); var inactive_slot = rad_slots.inactive_slot() ?? "a"; try { yield rad_deployer.deploy(inactive_slot); } catch(RadError e) { Log.warning(@"Deployment into slot \"$inactive_slot\" failed: $(e.message)"); return false; } if(rad_instance_running) { Log.info("Terminating previous instance"); yield rad_supervisor.terminate(); rad_instance_running = false; } if(launch_from_current()) { rad_instance_running = true; Log.info(@"Launched new instance from slot \"$(rad_slots.read_current_slot())\" ($remote_head)"); return true; } // The deployment succeeded but nothing launchable came out of it; the // container supervisor is better placed to recover than a dead poll loop Log.warning(@"\"$(rad_exec_name)\" does not resolve or launch from the deployed slot"); rad_exit_code = 251; rad_loop.quit(); return true; } /** * Launches the configured exec from the current slot. Returns false without * ceremony when no slot is current; warns when a current slot exists but the * exec does not resolve or launch within it. */ private static bool launch_from_current() { var current_slot = rad_slots.read_current_slot(); if(current_slot == null) { return false; } var executable = rad_slots.resolve_exec(current_slot, rad_exec_name); if(executable == null) { Log.warning(@"\"$(rad_exec_name)\" does not resolve in slot \"$current_slot\" (PATH-style search within the slot)"); return false; } try { rad_supervisor.launch(executable, rad_exec_args); } catch(Error e) { Log.warning(@"Could not launch \"$executable\": $(e.message)"); return false; } return true; } /** * Locates the usm executable: a sibling of usmrad's own executable first * (both install into the same bindir), then the PATH. */ private static string? find_usm_binary() { try { var own_path = FileUtils.read_link("/proc/self/exe"); var candidate = Path.build_filename(Path.get_dirname(own_path), "usm"); if(FileUtils.test(candidate, FileTest.IS_EXECUTABLE)) { return candidate; } } catch(Error e) { // Fall through to the PATH search } return Environment.find_program_in_path("usm"); } private static void usage() { printerr("USAGE:\n\tusmrad [args...]\n\nEnvironment:\n"); printerr("\tUSMRAD_REPO (required) git repository URL\n"); printerr("\tUSMRAD_BRANCH branch to track (default: master)\n"); printerr("\tUSMRAD_POLL poll interval in minutes, 0 disables (default: 0)\n"); printerr("\tUSMRAD_APPDIR application directory (default: /app)\n"); printerr("\tUSMRAD_USM_REPOS semicolon-delimited .usmr URIs\n"); printerr("\tUSMRAD_TERM_GRACE seconds between SIGTERM and SIGKILL (default: 10)\n"); } #endif }