ProvisionedVirt.pm 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. package Trog::DataSource::ProvisionedVirt;
  2. use v5.36;
  3. use re '/aa';
  4. use parent qw{Trog::DataSource::Virt};
  5. use Cwd ();
  6. use File::Basename ();
  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::Log qw{WARN INFO};
  15. =head1 Trog::DataSource::ProvisionedVirt
  16. Trog::DataSource::Virt, plus the recipe each guest was built from and a button to
  17. build it again.
  18. Everything about listing guests is inherited unchanged -- this is the same
  19. libvirt connection, the same posts, the same search and the same refusal to be
  20. cached. What it adds is the other half of the story: a guest on a Troglodyne
  21. hypervisor was provisioned from a recipe, and the recipe is on disk next to the
  22. hypervisor rather than anywhere libvirt can tell you about.
  23. Two repositories between them own that lifecycle:
  24. provisioners recipes.d/$domain.yaml, and bin/new_config which turns
  25. a recipe into a configuration package
  26. trog-provisioner bin/provision, which turns that package into a machine
  27. Say where they are in the tCMS configuration and this becomes useful; leave them
  28. out and it degrades to plain Virt, which is what it is.
  29. [provisioner]
  30. provisioners = /home/you/Code/provisioners
  31. trog_provisioner = /home/you/Code/trog-provisioner
  32. =head1 A NOTE ON SAFETY
  33. Reprovisioning is not a recoverable operation. It destroys the guest and builds
  34. it again from its recipe, and whatever was on it that the recipe does not
  35. describe does not come back.
  36. The discipline is the same as its parent's, and for the same reasons. It is
  37. POST only and admin only; it refuses outright for the guest tCMS is itself
  38. running on, whatever the template chose to draw; and nothing it runs goes near a
  39. shell, so a guest name is an argument and can never be a command.
  40. =cut
  41. our $recipe_dir = 'recipes.d';
  42. our $log_dir = 'logs/reprovision';
  43. # What a reprovision can be, in the order it can be it.
  44. our %states = (
  45. running => 'reprovisioning',
  46. ok => 'provisioned',
  47. failed => 'reprovision failed',
  48. interrupted => 'reprovision interrupted',
  49. );
  50. =head1 FUNCTIONS
  51. =head2 posts($series, $query) = @posts
  52. The guests Trog::DataSource::Virt would have listed, each carrying what we know
  53. about how it was built:
  54. recipe the recipe's YAML, as text, for the page to show
  55. recipe_path where that came from
  56. has_recipe whether there was one at all
  57. can_reprovision whether the button should be drawn
  58. reprovision_state running, ok, failed, interrupted, or absent
  59. reprovision_status that, in words a person reads
  60. reprovision_started when the last run began
  61. reprovision_finished when it ended, if it has
  62. reprovision_exit what it exited with, if it has
  63. reprovision_by who asked for it
  64. is_reprovisioning whether one is going on right now
  65. reprovision_log the tail of the last run
  66. reprovision_log_href where to read all of it
  67. A guest with no recipe is still listed. Plenty of guests on a hypervisor were
  68. not built by this, and saying nothing about them is better than hiding them.
  69. =cut
  70. sub posts ( $series, $query ) {
  71. my @guests = Trog::DataSource::Virt::posts( $series, $query );
  72. my $config = _config();
  73. return map { _with_recipe( $_, $config ) } @guests;
  74. }
  75. sub _with_recipe ( $post, $config ) {
  76. return $post if $post->{unreachable};
  77. my $domain = $post->{domain} // '';
  78. my $path = _recipe_path( $config, $domain );
  79. if ($path) {
  80. my $recipe = eval { File::Slurper::read_text($path) };
  81. if ( defined $recipe ) {
  82. $post->{recipe} = $recipe;
  83. $post->{recipe_path} = $path;
  84. $post->{has_recipe} = 1;
  85. }
  86. else {
  87. WARN("Could not read the recipe for '$domain' at $path: $@");
  88. }
  89. }
  90. # Both halves have to be there to run the lifecycle, and we will not rebuild
  91. # the machine we are answering the request from -- see act() in the parent,
  92. # which refuses to power that one off for the same reason.
  93. $post->{can_reprovision} =
  94. ( $post->{has_recipe} && $config->{provisioners} && $config->{trog_provisioner} && !$post->{is_self} ) ? 1 : 0;
  95. my $status = status($domain);
  96. if ($status) {
  97. $post->{reprovision_state} = $status->{state};
  98. $post->{reprovision_status} = $states{ $status->{state} } // $status->{state};
  99. $post->{reprovision_started} = $status->{started};
  100. $post->{reprovision_finished} = $status->{finished};
  101. $post->{reprovision_exit} = $status->{exit};
  102. $post->{reprovision_by} = $status->{user};
  103. $post->{is_reprovisioning} = $status->{state} eq 'running' ? 1 : 0;
  104. }
  105. # One at a time. Two provisions of the same machine at once is not a thing
  106. # anybody wants to have started by double clicking.
  107. $post->{can_reprovision} = 0 if $post->{is_reprovisioning};
  108. $post->{reprovision_log} = _log_tail($domain);
  109. $post->{reprovision_log_href} = $post->{reprovision_log} ? "/guest/reprovision/log/$domain" : undef;
  110. return $post;
  111. }
  112. =head2 status($domain) = HASHREF or undef
  113. What the last reprovision of this guest is doing, or did.
  114. state running, ok, failed or interrupted
  115. pid the process doing it, while one is
  116. started / finished / exit / user
  117. Nothing waits on the process which does the work -- it is deliberately orphaned,
  118. so that it outlives the worker that started it -- which means its exit status is
  119. not reported to anybody. This file is how it says what happened, and the last
  120. thing the run does is write it.
  121. A run whose status still says 'running' but whose process is gone therefore
  122. crashed, was killed, or took the machine down with it, and reads as interrupted.
  123. That is a guess from the one piece of evidence there is, and it is the honest
  124. one: the alternative is a page that says a provision is in progress forever.
  125. =cut
  126. sub status ($domain) {
  127. my $safe = _safe_domain($domain);
  128. return undef unless $safe;
  129. my $path = "$log_dir/$safe.status";
  130. return undef unless -f $path; ## no critic (ProhibitFiletest_f) -- has this ever run
  131. my $raw = eval { File::Slurper::read_text($path) };
  132. return undef unless defined $raw;
  133. my %status;
  134. foreach my $line ( split( "\n", $raw ) ) {
  135. my ( $key, $value ) = $line =~ m/^(\w+)=(.*)$/;
  136. $status{$key} = $value if defined $key;
  137. }
  138. return undef unless $status{state};
  139. # kill 0 rather than trusting the file: the process is orphaned, so nothing
  140. # else would ever correct a 'running' that stopped being true. Same uid, so
  141. # this is a real answer. A recycled pid could fool it, which would show a
  142. # finished run as still going rather than the other way about.
  143. if ( $status{state} eq 'running' ) {
  144. my $alive = $status{pid} && kill( 0, $status{pid} );
  145. $status{state} = 'interrupted' unless $alive;
  146. }
  147. return \%status;
  148. }
  149. # Written by the run itself, at the two moments worth recording. By rename, so
  150. # that a page drawn while the run is writing sees the old status or the new one
  151. # and never half of either -- the reader would ignore a torn file, but ignoring
  152. # it means the page says nothing was ever run.
  153. sub _write_status ( $domain, %fields ) {
  154. my $path = "$log_dir/$domain.status";
  155. my $out = join( '', map { "$_=" . ( $fields{$_} // '' ) . "\n" } sort keys %fields );
  156. local $@;
  157. eval {
  158. # Beside the file it replaces, or the rename is a copy across
  159. # filesystems and stops being atomic.
  160. local $File::Slurper::Temp::FILE_TEMP_DIR = $log_dir;
  161. local $File::Slurper::Temp::FILE_TEMP_PERMS = oct('644');
  162. File::Slurper::Temp::write_text( $path, $out );
  163. 1;
  164. } or WARN("Could not record the reprovision status for '$domain': $@");
  165. return;
  166. }
  167. =head2 _config() = HASHREF
  168. Where the two repositories are, from the tCMS configuration.
  169. Undef for either means the feature is off rather than broken: a site which has
  170. not configured them gets a plain guest listing, which is what it asked for.
  171. =cut
  172. sub _config {
  173. my $conf = Trog::Config::get();
  174. my %config;
  175. foreach my $key (qw{provisioners trog_provisioner}) {
  176. my $dir = $conf->param("provisioner.$key");
  177. next unless $dir && !ref $dir;
  178. # Resolved, and checked to be there. A path in a config file which does
  179. # not exist is a typo, and finding that out here beats finding it out
  180. # halfway through a reprovision.
  181. my $absolute = Cwd::abs_path($dir);
  182. if ( !$absolute || !-d $absolute ) {
  183. WARN("provisioner.$key is set to '$dir', which is not a directory here");
  184. next;
  185. }
  186. $config{$key} = $absolute;
  187. }
  188. return \%config;
  189. }
  190. # A guest name reaches the filesystem here, so it is a hostname or it is nothing.
  191. sub _safe_domain ($domain) {
  192. return undef unless defined $domain && length($domain) && length($domain) < 254;
  193. return undef if $domain =~ m/(?:^|\.)\.\.?(?:\.|$)/;
  194. my ($safe) = $domain =~ m/^([A-Za-z0-9][A-Za-z0-9._-]*)$/;
  195. return $safe;
  196. }
  197. sub _recipe_path ( $config, $domain ) {
  198. return undef unless $config->{provisioners};
  199. my $safe = _safe_domain($domain);
  200. return undef unless $safe;
  201. my $path = "$config->{provisioners}/$recipe_dir/$safe.yaml";
  202. return -f $path ? $path : undef; ## no critic (ProhibitFiletest_f) -- is there a recipe for this guest
  203. }
  204. =head2 _log_tail($domain) = STRING
  205. The last of whatever the previous reprovision said, or nothing.
  206. The run happens in a detached process which nobody is waiting on, so this file
  207. is the only account of it there is.
  208. =cut
  209. our $log_tail_bytes = 4096;
  210. sub _log_tail ($domain) {
  211. my $safe = _safe_domain($domain);
  212. return undef unless $safe;
  213. my $path = "$log_dir/$safe.log";
  214. return undef unless -f $path; ## no critic (ProhibitFiletest_f) -- has this ever run
  215. my $text = eval { File::Slurper::read_text($path) };
  216. return undef unless defined $text;
  217. return length($text) > $log_tail_bytes ? substr( $text, -$log_tail_bytes ) : $text;
  218. }
  219. =head2 log_for($domain) = STRING or undef
  220. The whole reprovision log for a guest, for the route which serves it.
  221. Undef when there has never been a run, or when the name is not a guest name --
  222. this reaches the filesystem, so it is checked here rather than trusted from the
  223. route that called it.
  224. =cut
  225. our $log_max_bytes = 1024 * 1024;
  226. sub log_for ($domain) {
  227. my $safe = _safe_domain($domain);
  228. return undef unless $safe;
  229. my $path = "$log_dir/$safe.log";
  230. return undef unless -f $path; ## no critic (ProhibitFiletest_f) -- has this ever run
  231. my $text = eval { File::Slurper::read_text($path) };
  232. return undef unless defined $text;
  233. # A provisioner can be chatty and this goes out in one response.
  234. return length($text) > $log_max_bytes
  235. ? "[ truncated: showing the last " . int( $log_max_bytes / 1024 ) . "KB ]\n" . substr( $text, -$log_max_bytes )
  236. : $text;
  237. }
  238. =head2 reprovision(%args) = ($ok, $message)
  239. Run the full lifecycle for one guest: bin/new_config in provisioners, and then
  240. bin/provision in trog-provisioner.
  241. domain which guest. Required, and validated as a hostname.
  242. passphrase the KeePass passphrase new_config asks for on stdin.
  243. user who asked, for the log.
  244. Returns immediately. Provisioning a machine takes minutes and an HTTP worker
  245. does not have minutes, so the work is handed to a detached process and the
  246. caller is told it started rather than told it finished. What happened is in
  247. logs/reprovision/$domain.log, which is what _log_tail reads back onto the page.
  248. The passphrase is passed down the child's stdin and is never written anywhere:
  249. not to the log, not to the process table, not to the configuration. new_config
  250. prompts for it because every recipe inherits secret: values from _base, and the
  251. alternative to asking each time is keeping the master password for every secret
  252. the operator holds in a file the webserver can read.
  253. =cut
  254. sub reprovision (%args) {
  255. my $safe = _safe_domain( $args{domain} );
  256. return ( 0, 'bad guest name' ) unless $safe;
  257. # The template does not draw the button for this guest. This is what
  258. # actually stops it: /guest/reprovision is a POST anybody holding the admin
  259. # acl can craft by hand, and there would be nothing left to report to.
  260. if ( Trog::DataSource::Virt::_is_self($safe) ) {
  261. WARN("Refused to reprovision '$safe': that is this server");
  262. return ( 0, "'$safe' is this server -- refusing to reprovision it" );
  263. }
  264. my $config = _config();
  265. return ( 0, 'provisioner.provisioners is not configured' ) unless $config->{provisioners};
  266. return ( 0, 'provisioner.trog_provisioner is not configured' ) unless $config->{trog_provisioner};
  267. my $recipe = _recipe_path( $config, $safe );
  268. return ( 0, "there is no recipe for '$safe' in $recipe_dir" ) unless $recipe;
  269. return ( 0, 'a passphrase is required, as new_config asks for one' ) unless length( $args{passphrase} // '' );
  270. # One at a time. Two provisions of one machine racing each other is not
  271. # something anybody should be able to start by double clicking.
  272. my $running = status($safe);
  273. return ( 0, 'a reprovision of this guest is already running' ) if $running && $running->{state} eq 'running';
  274. my @lifecycle = (
  275. [ $config->{provisioners}, "$config->{provisioners}/bin/new_config", $safe ],
  276. [ $config->{trog_provisioner}, "$config->{trog_provisioner}/bin/provision", $safe ],
  277. );
  278. foreach my $step (@lifecycle) {
  279. my ( undef, $program ) = @$step;
  280. return ( 0, "$program is not there to run" ) unless -x $program; ## no critic (ProhibitFiletest_rwxRWX) -- refusing early beats finding out mid-lifecycle
  281. }
  282. INFO( "Reprovision of '$safe' requested by " . ( $args{user} // 'somebody' ) );
  283. my ( $ok, $why ) = _spawn( $safe, $args{passphrase}, \@lifecycle, $args{user} );
  284. return ( 0, $why ) unless $ok;
  285. return ( 1, "reprovision started; watch $log_dir/$safe.log" );
  286. }
  287. =head2 _spawn($domain, $passphrase, $lifecycle, $user)
  288. Fork the lifecycle off where nothing is waiting for it.
  289. Double forked and setsid'd on purpose: a save signals the parent to reload the
  290. routing table, and a provision which died because a worker was recycled halfway
  291. through would leave a half-built machine. This has to outlive the request, the
  292. worker, and a restart.
  293. Each step runs through open() with a list, so there is no shell between us and
  294. the program, and the guest name is an argument rather than a thing that could be
  295. read as one.
  296. =cut
  297. sub _spawn ( $domain, $passphrase, $lifecycle, $user ) {
  298. my $log = "$log_dir/$domain.log";
  299. local $@;
  300. eval { File::Path::make_path($log_dir); 1 } or return ( 0, "could not make $log_dir: $@" );
  301. my $pid = fork();
  302. return ( 0, "could not fork: $!" ) unless defined $pid;
  303. return ( 1, 'started' ) if $pid;
  304. # First child: detach, fork again, and let the middle process go so that
  305. # nothing is left for the worker to reap or be blocked by.
  306. POSIX::setsid();
  307. my $grandchild = fork();
  308. POSIX::_exit(0) if !defined $grandchild || $grandchild;
  309. # From here on we are nobody's child and nothing reads our exit status, so
  310. # the log is the only way to say anything.
  311. open( my $fh, '>>', $log ) or POSIX::_exit(1);
  312. $fh->autoflush(1);
  313. my $started = time();
  314. print {$fh} "\n=== reprovision of $domain by " . ( $user // 'somebody' ) . ' at ' . localtime($started) . " ===\n";
  315. # $$ is this process, which is the one that will still be here in ten
  316. # minutes -- the two before it have already gone. status() checks it is
  317. # alive, so it has to be the pid of whatever is actually doing the work.
  318. _write_status( $domain, state => 'running', pid => $$, started => $started, user => ( $user // '' ) );
  319. foreach my $step (@$lifecycle) {
  320. my ( $dir, @command ) = @$step;
  321. print {$fh} "\n--- $command[0] $command[1] (in $dir)\n";
  322. if ( !chdir($dir) ) {
  323. print {$fh} "could not chdir to $dir: $!\n";
  324. POSIX::_exit(1);
  325. }
  326. # The child's own stdout and stderr are the log, so whatever the program
  327. # says lands there without a shell redirect to get wrong.
  328. open( STDOUT, '>&', $fh ) or POSIX::_exit(1);
  329. open( STDERR, '>&', $fh ) or POSIX::_exit(1);
  330. # List form on purpose, and the reason the whole thing is shaped this
  331. # way: a piped open with a list never involves a shell, so the guest
  332. # name is an argument and cannot be read as a command. The single
  333. # argument form of this would be the bug.
  334. my $ok = open( my $stdin, '|-', @command ); ## no critic (ProhibitPipeOpen) -- list form, so no shell
  335. if ( !$ok ) {
  336. print {$fh} "could not run $command[0]: $!\n";
  337. POSIX::_exit(1);
  338. }
  339. # What new_config prompts for. The second step ignores it, and giving
  340. # it a closed stdin instead would make anything that asks hang forever.
  341. print {$stdin} "$passphrase\n";
  342. close($stdin);
  343. if ($?) {
  344. my $status = $? >> 8;
  345. print {$fh} "\n--- $command[0] exited $status, stopping here\n";
  346. _write_status(
  347. $domain,
  348. state => 'failed',
  349. started => $started,
  350. finished => time(),
  351. exit => $status,
  352. failed => $command[0],
  353. user => ( $user // '' ),
  354. );
  355. POSIX::_exit($status);
  356. }
  357. }
  358. print {$fh} "\n=== finished ===\n";
  359. _write_status( $domain, state => 'ok', started => $started, finished => time(), exit => 0, user => ( $user // '' ) );
  360. POSIX::_exit(0);
  361. }
  362. 1;