ProvisionedVirt.pm 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. package Trog::DataSource::ProvisionedVirt;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use parent qw{Trog::DataSource::Virt};
  6. use Cwd ();
  7. use File::Path ();
  8. use File::Slurper ();
  9. use File::Slurper::Temp ();
  10. use POSIX ();
  11. use Trog::Config ();
  12. use Trog::DataSource ();
  13. use Trog::DataSource::Virt();
  14. use Trog::Auth ();
  15. use Trog::Vault ();
  16. use Trog::Log qw{WARN INFO};
  17. =head1 Trog::DataSource::ProvisionedVirt
  18. Trog::DataSource::Virt, plus the recipe each guest was built from and a button to
  19. build it again.
  20. Everything about listing guests is inherited unchanged -- this is the same
  21. libvirt connection, the same posts, the same search and the same refusal to be
  22. cached. What it adds is the other half of the story: a guest on a Troglodyne
  23. hypervisor was provisioned from a recipe, and the recipe is on disk next to the
  24. hypervisor rather than anywhere libvirt can tell you about.
  25. One repository owns that lifecycle. trog-provisioner's bin/provision generates
  26. a guest's configuration from its recipe and then builds the machine out of it.
  27. The generator used to be a second repository and a second run; the two had to
  28. become one program once which hypervisor a guest lands on became a choice rather
  29. than whichever machine you happened to be sat at.
  30. Say where that checkout is and this becomes useful; leave it out and it degrades
  31. to plain Virt, which is what it is.
  32. [provisioner]
  33. trog_provisioner = /home/you/Code/trog-provisioner
  34. The recipes are not in the checkout. They describe an installation rather than
  35. the software, so trog-provisioner keeps them in /etc/trog-provisioner, or
  36. wherever TROG_PROVISIONER_CONFIG says instead. We find them by that same rule,
  37. which is one fewer thing to tell tCMS and one fewer thing to disagree about.
  38. =head1 A NOTE ON SAFETY
  39. Reprovisioning is not a recoverable operation. It destroys the guest and builds
  40. it again from its recipe, and whatever was on it that the recipe does not
  41. describe does not come back.
  42. The discipline is the same as its parent's, and for the same reasons. It is
  43. POST only and admin only; it refuses outright for the guest tCMS is itself
  44. running on, whatever the template chose to draw; and nothing it runs goes near a
  45. shell, so a guest name is an argument and can never be a command.
  46. =cut
  47. our $recipe_dir = 'recipes.d';
  48. our $log_dir = 'logs/reprovision';
  49. # What the provisioner's two passwords are called in a user's vault. One name
  50. # per person rather than one per guest: the passphrase is to their own secrets
  51. # database, and the sudo password is theirs on the hypervisor rather than
  52. # anything to do with a particular guest.
  53. our $secret_name = 'provisioner';
  54. our $sudo_name = 'provisioner_sudo';
  55. # Where an installation's own files live, which is trog-provisioner's rule
  56. # rather than ours -- see its Trog::Config.
  57. our $provisioner_config = '/etc/trog-provisioner';
  58. # What a reprovision can be, in the order it can be it.
  59. our %states = (
  60. running => 'reprovisioning',
  61. ok => 'provisioned',
  62. failed => 'reprovision failed',
  63. interrupted => 'reprovision interrupted',
  64. );
  65. # A guest name reaches the filesystem here, so it is a hostname or it is nothing.
  66. my sub safe_domain ($domain) {
  67. return undef if !$domain || length $domain >= 254;
  68. return undef if $domain =~ m/(?:^|\.)\.\.?(?:\.|$)/;
  69. my ($safe) = $domain =~ m/^([[:alnum:]][\w.-]*)$/;
  70. return $safe;
  71. }
  72. # An installation's recipes are where they are whether tCMS knows about the
  73. # provisioner or not, but reading them is the feature, so it is off with it.
  74. my sub _recipe_path ( $config, $domain ) {
  75. return undef unless $config->{trog_provisioner};
  76. my $safe = safe_domain($domain);
  77. return undef unless $safe;
  78. my $path = "$config->{recipes}/$safe.yaml";
  79. return -f $path ? $path : undef; ## no critic (ProhibitFiletest_f) -- is there a recipe for this guest
  80. }
  81. my sub with_recipe ( $post, $config ) {
  82. return $post if $post->{unreachable};
  83. my $domain = $post->{domain} // '';
  84. my $path = _recipe_path( $config, $domain );
  85. if ($path) {
  86. my $recipe = eval { File::Slurper::read_text($path) };
  87. if ( defined $recipe ) {
  88. $post->{recipe} = $recipe;
  89. $post->{recipe_path} = $path;
  90. $post->{has_recipe} = 1;
  91. }
  92. else {
  93. WARN("Could not read the recipe for '$domain' at $path: $@");
  94. }
  95. }
  96. # There has to be something to run it with, and we will not rebuild the
  97. # machine we are answering the request from -- see act() in the parent,
  98. # which refuses to power that one off for the same reason.
  99. $post->{can_reprovision} =
  100. ( $post->{has_recipe} && $config->{trog_provisioner} && !$post->{is_self} ) ? 1 : 0;
  101. # Which of the things the button has to ask for.
  102. $post->{passphrase_remembered} = $config->{remembered};
  103. $post->{sudo_remembered} = $config->{sudo_remembered};
  104. $post->{can_remember} = $config->{can_remember};
  105. my $status = status($domain);
  106. if ($status) {
  107. $post->{reprovision_state} = $status->{state};
  108. $post->{reprovision_status} = $states{ $status->{state} } // $status->{state};
  109. $post->{reprovision_started} = $status->{started};
  110. $post->{reprovision_finished} = $status->{finished};
  111. $post->{reprovision_exit} = $status->{exit};
  112. $post->{reprovision_by} = $status->{user};
  113. $post->{is_reprovisioning} = $status->{state} eq 'running' ? 1 : 0;
  114. }
  115. # One at a time. Two provisions of the same machine at once is not a thing
  116. # anybody wants to have started by double clicking.
  117. $post->{can_reprovision} = 0 if $post->{is_reprovisioning};
  118. $post->{reprovision_log} = log_tail($domain);
  119. $post->{reprovision_log_href} = $post->{reprovision_log} ? "/guest/reprovision/log/$domain" : undef;
  120. return $post;
  121. }
  122. =head2 config() = HASHREF
  123. Where the provisioner is checked out, from the tCMS configuration, and where the
  124. recipes it reads are, from trog-provisioner's own rule for that.
  125. trog_provisioner the checkout, or undef
  126. recipes the directory the guests' recipes are in
  127. Undef for the checkout means the feature is off rather than broken: a site which
  128. has not configured it gets a plain guest listing, which is what it asked for.
  129. =cut
  130. my sub config {
  131. my $conf = Trog::Config::get();
  132. # Nothing to configure here: the provisioner reads this env var or that
  133. # directory, so a recipe we showed from anywhere else would be a recipe it
  134. # is not going to build from.
  135. my %config = ( recipes => ( $ENV{TROG_PROVISIONER_CONFIG} // $provisioner_config ) . "/$recipe_dir" );
  136. my $dir = $conf->param('provisioner.trog_provisioner');
  137. return \%config if !$dir || ref $dir;
  138. # Resolved, and checked to be there. A path in a config file which does not
  139. # exist is a typo, and finding that out here beats finding it out halfway
  140. # through a reprovision.
  141. my $absolute = Cwd::abs_path($dir);
  142. if ( !$absolute || !-d $absolute ) {
  143. WARN("provisioner.trog_provisioner is set to '$dir', which is not a directory here");
  144. return \%config;
  145. }
  146. $config{trog_provisioner} = $absolute;
  147. return \%config;
  148. }
  149. =head1 FUNCTIONS
  150. =head2 posts($series, $query) = @posts
  151. The guests Trog::DataSource::Virt would have listed, each carrying what we know
  152. about how it was built:
  153. recipe the recipe's YAML, as text, for the page to show
  154. recipe_path where that came from
  155. has_recipe whether there was one at all
  156. can_reprovision whether the button should be drawn
  157. reprovision_state running, ok, failed, interrupted, or absent
  158. reprovision_status that, in words a person reads
  159. reprovision_started when the last run began
  160. reprovision_finished when it ended, if it has
  161. reprovision_exit what it exited with, if it has
  162. reprovision_by who asked for it
  163. is_reprovisioning whether one is going on right now
  164. reprovision_log the tail of the last run
  165. reprovision_log_href where to read all of it
  166. A guest with no recipe is still listed. Plenty of guests on a hypervisor were
  167. not built by this, and saying nothing about them is better than hiding them.
  168. =cut
  169. sub posts ( $series, $query ) {
  170. my @guests = Trog::DataSource::Virt::posts( $series, $query );
  171. my $config = config();
  172. # Asked once for the page rather than once per guest, and asked rather than
  173. # opened: what the form needs to know is which box to draw.
  174. my $user = $query->{user} // '';
  175. $config->{can_remember} = ( $user && Trog::Vault::has_key() ) ? 1 : 0;
  176. $config->{remembered} = ( $config->{can_remember} && Trog::Vault::has( $user, $secret_name ) ) ? 1 : 0;
  177. $config->{sudo_remembered} = ( $config->{can_remember} && Trog::Vault::has( $user, $sudo_name ) ) ? 1 : 0;
  178. return map { with_recipe( $_, $config ) } @guests;
  179. }
  180. =head2 status($domain) = HASHREF or undef
  181. What the last reprovision of this guest is doing, or did.
  182. state running, ok, failed or interrupted
  183. pid the process doing it, while one is
  184. started / finished / exit / user
  185. Nothing waits on the process which does the work -- it is deliberately orphaned,
  186. so that it outlives the worker that started it -- which means its exit status is
  187. not reported to anybody. This file is how it says what happened, and the last
  188. thing the run does is write it.
  189. A run whose status still says 'running' but whose process is gone therefore
  190. crashed, was killed, or took the machine down with it, and reads as interrupted.
  191. That is a guess from the one piece of evidence there is, and it is the honest
  192. one: the alternative is a page that says a provision is in progress forever.
  193. =cut
  194. sub status ($domain) {
  195. my $safe = safe_domain($domain);
  196. return undef unless $safe;
  197. my $path = "$log_dir/$safe.status";
  198. return undef unless -f $path; ## no critic (ProhibitFiletest_f) -- has this ever run
  199. my $raw = eval { File::Slurper::read_text($path) };
  200. return undef unless defined $raw;
  201. my %status;
  202. foreach my $line ( split( /\n/, $raw ) ) {
  203. my ( $key, $value ) = $line =~ m/^(\w+)=(\N*)$/;
  204. $status{$key} = $value if defined $key;
  205. }
  206. return undef unless $status{state};
  207. # kill 0 rather than trusting the file: the process is orphaned, so nothing
  208. # else would ever correct a 'running' that stopped being true. Same uid, so
  209. # this is a real answer. A recycled pid could fool it, which would show a
  210. # finished run as still going rather than the other way about.
  211. if ( $status{state} eq 'running' ) {
  212. my $alive = $status{pid} && kill( 0, $status{pid} );
  213. $status{state} = 'interrupted' unless $alive;
  214. }
  215. return \%status;
  216. }
  217. # Written by the run itself, at the two moments worth recording. By rename, so
  218. # that a page drawn while the run is writing sees the old status or the new one
  219. # and never half of either -- the reader would ignore a torn file, but ignoring
  220. # it means the page says nothing was ever run.
  221. my sub write_status ( $domain, %fields ) {
  222. my $path = "$log_dir/$domain.status";
  223. my $out = join( '', map { "$_=" . ( $fields{$_} // '' ) . "\n" } sort keys %fields );
  224. local $@;
  225. eval {
  226. # Beside the file it replaces, or the rename is a copy across
  227. # filesystems and stops being atomic.
  228. local $File::Slurper::Temp::FILE_TEMP_DIR = $log_dir;
  229. local $File::Slurper::Temp::FILE_TEMP_PERMS = oct('644');
  230. File::Slurper::Temp::write_text( $path, $out );
  231. 1;
  232. } or WARN("Could not record the reprovision status for '$domain': $@");
  233. return;
  234. }
  235. =head2 log_tail($domain) = STRING
  236. The last of whatever the previous reprovision said, or nothing.
  237. The run happens in a detached process which nobody is waiting on, so this file
  238. is the only account of it there is.
  239. =cut
  240. our $log_tail_bytes = 4096;
  241. sub log_tail ($domain) {
  242. my $safe = safe_domain($domain);
  243. return undef unless $safe;
  244. my $path = "$log_dir/$safe.log";
  245. return undef unless -f $path; ## no critic (ProhibitFiletest_f) -- has this ever run
  246. my $text = eval { File::Slurper::read_text($path) };
  247. return undef unless defined $text;
  248. return length($text) > $log_tail_bytes ? substr( $text, -$log_tail_bytes ) : $text;
  249. }
  250. =head2 log_for($domain) = STRING or undef
  251. The whole reprovision log for a guest, for the route which serves it.
  252. Undef when there has never been a run, or when the name is not a guest name --
  253. this reaches the filesystem, so it is checked here rather than trusted from the
  254. route that called it.
  255. =cut
  256. our $log_max_bytes = 1_048_576; # 1 MiB
  257. sub log_for ($domain) {
  258. my $safe = safe_domain($domain);
  259. return undef unless $safe;
  260. my $path = "$log_dir/$safe.log";
  261. return undef unless -f $path; ## no critic (ProhibitFiletest_f) -- has this ever run
  262. my $text = eval { File::Slurper::read_text($path) };
  263. return undef unless defined $text;
  264. # A provisioner can be chatty and this goes out in one response.
  265. return length($text) > $log_max_bytes
  266. ? "[ truncated: showing the last " . int( $log_max_bytes / 1024 ) . "KB ]\n" . substr( $text, -$log_max_bytes )
  267. : $text;
  268. }
  269. # File away what they typed, so the next run is a code.
  270. #
  271. # Said rather than refused when it does not work: they asked for a reprovision
  272. # and gave us what it needs, and failing to keep a copy is not a reason to not do
  273. # the thing they asked for.
  274. my sub _remember ( $user, $credentials ) {
  275. my %vault_name = ( keepass => $secret_name, sudo => $sudo_name );
  276. foreach my $name ( sort keys %$credentials ) {
  277. my ( $ok, $why ) = Trog::Vault::set( $user, $vault_name{$name}, $credentials->{$name} );
  278. WARN("Could not remember the provisioner's $name for $user: $why") unless $ok;
  279. }
  280. return;
  281. }
  282. =head2 credentials(%args) = ($credentials, $why)
  283. What to hand the provisioner, out of whatever the form sent: a hashref of the
  284. names Trog::Credentials knows, or undef and a reason.
  285. A code is only ever exchanged for passwords that are already stored -- it is
  286. proof of presence, not a password, and there is nothing to derive from six
  287. digits. Typed passwords are used as they are, and stored on the way past if
  288. they asked for that, so the next run is a code.
  289. The two are independent. A sudo password typed into the form wins over a stored
  290. one, because somebody typing it has a reason to; and a hypervisor with
  291. passwordless sudo simply never has one, in the form or the vault, which is the
  292. ordinary case and not a missing answer.
  293. The reason, when there is one, is what the person reads -- so it has to tell
  294. 'that code is stale' apart from 'there is nothing stored to unlock'.
  295. =cut
  296. my sub credentials (%args) {
  297. my $user = $args{user} // '';
  298. my %credentials;
  299. # What they typed, as opposed to what came back out of the vault. Only the
  300. # typed ones are worth filing away, and only they can be: a value fetched
  301. # from the vault is already in it.
  302. my %typed;
  303. $typed{sudo} = $args{sudo} if $args{sudo};
  304. if ( $args{totp} ) {
  305. return ( undef, 'a code identifies somebody, and nobody is logged in' ) unless $user;
  306. # Asked before the code is spent, and the reason is that a code is
  307. # spent whether or not the thing it authorized worked. Somebody whose
  308. # key has gone would otherwise burn a code to be told there is nothing
  309. # to unlock, then wait thirty seconds to be told it again. has() opens
  310. # the row to answer, so this is the same answer get() is about to give.
  311. return (
  312. undef,
  313. "there is no provisioning passphrase this installation can open for $user." . " Store it again under /secrets, or reprovision with the passphrase itself."
  314. ) unless Trog::Vault::has( $user, $secret_name );
  315. my ( $ok, $why ) = Trog::Auth::spend_totp( $user, $args{totp} );
  316. return ( undef, $why ) unless $ok;
  317. my $stored = Trog::Vault::get( $user, $secret_name );
  318. return ( undef, "the stored provisioning passphrase for $user could not be read" ) unless $stored;
  319. $credentials{keepass} = $stored;
  320. }
  321. else {
  322. return ( undef, 'a passphrase is required, as the provisioner asks for one' ) unless $args{passphrase};
  323. $credentials{keepass} = $args{passphrase};
  324. $typed{keepass} = $args{passphrase};
  325. }
  326. # A typed sudo password beats a stored one -- somebody typing it has a
  327. # reason to -- and no sudo password at all is the ordinary answer, for a
  328. # hypervisor whose login has passwordless sudo.
  329. my $sudo = $typed{sudo} // ( $user ? Trog::Vault::get( $user, $sudo_name ) : undef );
  330. $credentials{sudo} = $sudo if $sudo;
  331. _remember( $user, \%typed ) if $args{remember} && $user && %typed;
  332. return ( \%credentials, '' );
  333. }
  334. # One 'name: value' per line is the format, so a value with a newline in it is
  335. # two lines -- and the second one could name a credential nobody asked us to
  336. # send. Refused rather than escaped or trimmed: no password anybody meant to
  337. # type has a newline in the middle of it, and quietly changing what somebody
  338. # typed into their own password field is worse than saying no.
  339. my sub sendable ($credentials) {
  340. foreach my $name ( sort keys %$credentials ) {
  341. next unless $credentials->{$name} =~ m/[\r\n]/;
  342. return "the $name has a line break in it, which is not something this can send";
  343. }
  344. return '';
  345. }
  346. =head2 reprovision(%args) = ($ok, $message)
  347. Run bin/provision for one guest, which generates its configuration from its
  348. recipe and then builds the machine.
  349. domain which guest. Required, and validated as a hostname.
  350. user who asked. For the log, and whose vault to look in.
  351. passphrase the KeePass passphrase the recipe's secrets need.
  352. sudo their sudo password on the hypervisor, if it wants one.
  353. totp a code, instead of those, when they have been stored.
  354. remember store what they just typed, so that next time is a code.
  355. Returns immediately. Provisioning a machine takes minutes and an HTTP worker
  356. does not have minutes, so the work is handed to a detached process and the
  357. caller is told it started rather than told it finished. What happened is in
  358. logs/reprovision/$domain.log, which is what log_tail reads back onto the page.
  359. =head3 WHY IT ASKS FOR ANYTHING AT ALL
  360. Two passwords, for two different reasons.
  361. Every recipe inherits secret: values from _base, so the provisioner opens a
  362. KeePass database on its way past and wants the passphrase to it. The
  363. alternative to asking is keeping the master password for every secret an
  364. operator holds in a file the webserver can read, which is worse than asking.
  365. And almost everything it does on the hypervisor needs root. Where the login
  366. there has passwordless sudo -- which is how ours are set up, and what we would
  367. recommend -- there is nothing to ask for. Where it does not, sudo over ssh has
  368. no terminal to ask at, and a run that finds that out dies several minutes in on
  369. a prompt nobody will ever see. So it is asked for here instead, and left empty
  370. by anybody who does not need it.
  371. Asking every time has its own cost, though: a passphrase somebody has to have to
  372. hand is a passphrase that gets written down, and then it is in a text file
  373. instead of a config file, which is not an improvement. So it can be stored --
  374. see Trog::Vault, where it is sealed under a key the database does not contain --
  375. and then what this asks for is a TOTP code, which proves the person is here
  376. without their having to be holding anything.
  377. The code is spent either way, so it authorizes this one reprovision rather than
  378. every reprovision inside its window.
  379. However they arrive, they go down the child's stdin as a credentials block --
  380. see Trog::Credentials in trog-provisioner -- and are written nowhere: not the
  381. log, not the process table, not the configuration.
  382. =cut
  383. sub reprovision (%args) {
  384. my $safe = safe_domain( $args{domain} );
  385. return ( 0, 'bad guest name' ) unless $safe;
  386. # The template does not draw the button for this guest. This is what
  387. # actually stops it: /guest/reprovision is a POST anybody holding the admin
  388. # acl can craft by hand, and there would be nothing left to report to.
  389. if ( Trog::DataSource::Virt::is_self($safe) ) {
  390. WARN("Refused to reprovision '$safe': that is this server");
  391. return ( 0, "'$safe' is this server -- refusing to reprovision it" );
  392. }
  393. my $config = config();
  394. return ( 0, 'provisioner.trog_provisioner is not configured' ) unless $config->{trog_provisioner};
  395. my $recipe = _recipe_path( $config, $safe );
  396. return ( 0, "there is no recipe for '$safe' in $config->{recipes}" ) unless $recipe;
  397. my ( $credentials, $why ) = credentials(%args);
  398. return ( 0, $why ) unless $credentials;
  399. my $unsendable = sendable($credentials);
  400. return ( 0, $unsendable ) if $unsendable;
  401. # One at a time. Two provisions of one machine racing each other is not
  402. # something anybody should be able to start by double clicking.
  403. my $running = status($safe);
  404. return ( 0, 'a reprovision of this guest is already running' ) if $running && $running->{state} eq 'running';
  405. # --credentials, because nothing in there reads stdin unless it is told to.
  406. my @command = ( "$config->{trog_provisioner}/bin/provision", '--credentials', $safe );
  407. return ( 0, "$command[0] is not there to run" ) unless -x $command[0]; ## no critic (ProhibitFiletest_rwxRWX) -- refusing early beats finding out mid-provision
  408. INFO( "Reprovision of '$safe' requested by " . ( $args{user} // 'somebody' ) );
  409. my ( $ok, $failed ) = spawn( $safe, $credentials, $config->{trog_provisioner}, \@command, $args{user} );
  410. return ( 0, $failed ) unless $ok;
  411. return ( 1, "reprovision started; watch $log_dir/$safe.log" );
  412. }
  413. =head2 spawn($domain, $credentials, $dir, $command, $user)
  414. Fork the provisioner off where nothing is waiting for it.
  415. Double forked and setsid'd on purpose: a save signals the parent to reload the
  416. routing table, and a provision which died because a worker was recycled halfway
  417. through would leave a half-built machine. This has to outlive the request, the
  418. worker, and a restart.
  419. It runs through open() with a list, so there is no shell between us and the
  420. program, and the guest name is an argument rather than a thing that could be
  421. read as one.
  422. The passwords go down its stdin as a credentials block, which is
  423. Trog::Credentials' format over there: one C<name: value> per line, and a blank
  424. line to say that is all of them. A value with a newline in it would be two
  425. lines, and the second could name a credential we were not asked to send, so one
  426. is refused rather than escaped -- there is no password anybody meant to type
  427. that has a newline in the middle of it.
  428. =cut
  429. sub spawn ( $domain, $credentials, $dir, $command, $user ) {
  430. my $log = "$log_dir/$domain.log";
  431. local $@;
  432. eval { File::Path::make_path($log_dir); 1 } or return ( 0, "could not make $log_dir: $@" );
  433. my $pid = fork();
  434. return ( 0, "could not fork: $!" ) unless defined $pid;
  435. return ( 1, 'started' ) if $pid;
  436. # First child: detach, fork again, and let the middle process go so that
  437. # nothing is left for the worker to reap or be blocked by.
  438. POSIX::setsid();
  439. my $grandchild = fork();
  440. POSIX::_exit(0) if !defined $grandchild || $grandchild;
  441. # From here on we are nobody's child and nothing reads our exit status, so
  442. # the log is the only way to say anything.
  443. open( my $fh, '>>', $log ) or POSIX::_exit(1); ## no critic (InputOutput::RequireBriefOpen) -- the child's STDOUT and STDERR from here on
  444. $fh->autoflush(1);
  445. my $started = time();
  446. print {$fh} "\n=== reprovision of $domain by " . ( $user // 'somebody' ) . ' at ' . localtime($started) . " ===\n";
  447. # $$ is this process, which is the one that will still be here in ten
  448. # minutes -- the two before it have already gone. status() checks it is
  449. # alive, so it has to be the pid of whatever is actually doing the work.
  450. write_status( $domain, state => 'running', pid => $$, started => $started, user => ( $user // '' ) );
  451. print {$fh} "\n--- $command->[0] $command->[1] (in $dir)\n";
  452. # In the checkout, so that anything the provisioner writes relative to where
  453. # it was run lands there rather than in the middle of the website.
  454. if ( !chdir($dir) ) {
  455. print {$fh} "could not chdir to $dir: $!\n";
  456. POSIX::_exit(1);
  457. }
  458. # The child's own stdout and stderr are the log, so whatever the program
  459. # says lands there without a shell redirect to get wrong.
  460. open( STDOUT, '>&', $fh ) or POSIX::_exit(1);
  461. open( STDERR, '>&', $fh ) or POSIX::_exit(1);
  462. # List form on purpose, and the reason the whole thing is shaped this way: a
  463. # piped open with a list never involves a shell, so the guest name is an
  464. # argument and cannot be read as a command. The single argument form of
  465. # this would be the bug.
  466. my $ok = open( my $stdin, '|-', @$command ); ## no critic (ProhibitPipeOpen) -- list form, so no shell
  467. if ( !$ok ) {
  468. print {$fh} "could not run $command->[0]: $!\n";
  469. POSIX::_exit(1);
  470. }
  471. # What the provisioner would otherwise have nobody to ask for. Closed
  472. # afterwards, so the read on the far side ends rather than waiting on us.
  473. my $unsent = ( print {$stdin} join( '', map { "$_: $credentials->{$_}\n" } sort keys %$credentials ) . "\n" ) ? undef : "$!";
  474. my $closed = close($stdin);
  475. # A write to a provisioner that has gone fails, rather than kills us,
  476. # because the server ignores SIGPIPE and we inherit that. The print or the
  477. # close() that flushes it can be the write that fails. close() also waits
  478. # for the provisioner, and is false when it exited non-zero, with $! 0.
  479. $unsent //= "$!" if !$closed && $!;
  480. if ( $unsent || $? ) {
  481. my $status = $unsent ? 1 : $? >> 8;
  482. print {$fh} $unsent ? "\n--- could not write to $command->[0]: $unsent\n" : "\n--- $command->[0] exited $status\n";
  483. write_status(
  484. $domain,
  485. state => 'failed',
  486. started => $started,
  487. finished => time(),
  488. exit => $status,
  489. failed => $command->[0],
  490. user => ( $user // '' ),
  491. );
  492. POSIX::_exit($status);
  493. }
  494. print {$fh} "\n=== finished ===\n";
  495. write_status( $domain, state => 'ok', started => $started, finished => time(), exit => 0, user => ( $user // '' ) );
  496. POSIX::_exit(0);
  497. }
  498. 1;