Trog-DataSource-ProvisionedVirt.t 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. #!/usr/bin/env perl
  2. # Reprovisioning destroys a machine and builds it again, so the whole of this
  3. # file is about what has to be true before anything runs. _spawn is mocked
  4. # throughout: nothing here forks, and nothing here executes a provisioner.
  5. use strict;
  6. use warnings;
  7. use FindBin;
  8. use lib "$FindBin::Bin/../lib";
  9. use Test::More;
  10. use Test::MockModule qw{strict};
  11. use Test::Fatal qw{exception};
  12. our ( $REPO, $ROOT, $OLDCWD );
  13. BEGIN {
  14. require Cwd;
  15. require File::Copy;
  16. require File::Path;
  17. require File::Temp;
  18. $REPO = Cwd::abs_path("$FindBin::Bin/..");
  19. $OLDCWD = Cwd::getcwd();
  20. $ROOT = File::Temp::tempdir( 'tcms-provisioned-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
  21. $ENV{HOME} = $ROOT;
  22. File::Path::make_path("$ROOT/$_") for qw{config logs www/assets};
  23. # Stand-ins for the two repositories, with the two programs the lifecycle
  24. # runs. Never executed -- _spawn is mocked -- but they have to be there and
  25. # executable, because reprovision() checks that before it commits to
  26. # anything.
  27. File::Path::make_path("$ROOT/provisioners/$_") for qw{bin recipes.d};
  28. File::Path::make_path("$ROOT/trog-provisioner/bin");
  29. foreach my $program ( "$ROOT/provisioners/bin/new_config", "$ROOT/trog-provisioner/bin/provision" ) {
  30. open( my $fh, '>', $program ) or die $!;
  31. print {$fh} "#!/bin/sh\nexit 0\n";
  32. close $fh;
  33. chmod( 0755, $program );
  34. }
  35. open( my $recipe, '>', "$ROOT/provisioners/recipes.d/guest.example.com.yaml" ) or die $!;
  36. print {$recipe} "---\n_global:\n registrar: secret:x/y/z\n";
  37. close $recipe;
  38. chdir($ROOT) or die "could not chdir to the sandbox: $!";
  39. }
  40. END {
  41. chdir($OLDCWD) if $OLDCWD;
  42. }
  43. sub write_config {
  44. my (%section) = @_;
  45. open( my $fh, '>', "$ROOT/config/default.cfg" ) or die $!;
  46. print {$fh} "[general]\n data_model=FlatFile\n[provisioner]\n";
  47. print {$fh} " $_=$section{$_}\n" foreach sort keys %section;
  48. close $fh;
  49. # Trog::Config memoises, which is what a running server wants and a test
  50. # emphatically does not.
  51. no warnings qw{once redefine};
  52. require Config::Simple;
  53. my $conf = Config::Simple->new("$ROOT/config/default.cfg");
  54. local $@;
  55. *Trog::Config::get = sub { return $conf };
  56. return;
  57. }
  58. require_ok('Trog::DataSource::ProvisionedVirt') or BAIL_OUT("Can't find SUT");
  59. my $log = Test::MockModule->new('Trog::DataSource::ProvisionedVirt');
  60. $log->redefine( WARN => sub { note(shift) } );
  61. $log->redefine( INFO => sub { note(shift) } );
  62. # Nothing in this file forks, and nothing in it runs a provisioner.
  63. our @SPAWNED;
  64. $log->redefine(
  65. _spawn => sub {
  66. my ( $domain, $passphrase, $lifecycle, $user ) = @_;
  67. push( @SPAWNED, { domain => $domain, passphrase => $passphrase, lifecycle => $lifecycle, user => $user } );
  68. return ( 1, 'started' );
  69. }
  70. );
  71. # Nor reaches a hypervisor.
  72. my $virt = Test::MockModule->new('Trog::DataSource::Virt');
  73. $virt->redefine( _is_self => sub { return $_[0] eq 'thisserver.example.com' ? 1 : 0 } );
  74. write_config(
  75. provisioners => "$ROOT/provisioners",
  76. trog_provisioner => "$ROOT/trog-provisioner",
  77. );
  78. sub reprovision {
  79. @SPAWNED = ();
  80. return Trog::DataSource::ProvisionedVirt::reprovision( passphrase => 'hunter2', user => 'admin', @_ );
  81. }
  82. subtest 'it is Virt, with more' => sub {
  83. ok( Trog::DataSource::ProvisionedVirt->isa('Trog::DataSource::Virt'), 'it is a Virt' );
  84. # can() resolves through @ISA, which is how the datasource dispatcher finds
  85. # the halves this does not override.
  86. foreach my $inherited (qw{filter lang help}) {
  87. my $mine = Trog::DataSource::ProvisionedVirt->can($inherited);
  88. my $theirs = Trog::DataSource::Virt->can($inherited);
  89. is( $mine, $theirs, "$inherited is the one Virt already had" );
  90. }
  91. # Virt has no order() of its own, so this correctly finds none and the
  92. # dispatcher falls back to Trog::DataSource::order.
  93. ok( !Trog::DataSource::ProvisionedVirt->can('order'), 'and the shared default ordering still applies' );
  94. # posts() is the one thing it does replace.
  95. isnt(
  96. Trog::DataSource::ProvisionedVirt->can('posts'),
  97. Trog::DataSource::Virt->can('posts'),
  98. 'posts() is its own, since that is where the recipe is added'
  99. );
  100. is( Trog::DataSource::ProvisionedVirt->EDITABLE, 0, 'still nothing to edit' );
  101. is( Trog::DataSource::ProvisionedVirt->CACHEABLE, 0, 'and still never cached, since a guest\'s state is live' );
  102. };
  103. subtest 'the recipe lands on the guest' => sub {
  104. $virt->redefine(
  105. posts => sub {
  106. return (
  107. { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 },
  108. { title => 'norecipe.example.com', domain => 'norecipe.example.com', is_self => 0 },
  109. { title => 'thisserver.example.com', domain => 'thisserver.example.com', is_self => 1 },
  110. { title => 'a hypervisor', unreachable => 1, state => 'nope' },
  111. );
  112. }
  113. );
  114. my @posts = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  115. is( scalar(@posts), 4, 'every guest Virt listed is still listed' );
  116. my ($provisioned) = grep { ( $_->{domain} // '' ) eq 'guest.example.com' } @posts;
  117. ok( $provisioned->{has_recipe}, 'the one with a recipe says so' );
  118. like( $provisioned->{recipe}, qr/registrar/, 'and carries the recipe itself' );
  119. like( $provisioned->{recipe_path}, qr{recipes\.d/guest\.example\.com\.yaml$}, 'and where it came from' );
  120. is( $provisioned->{can_reprovision}, 1, 'and may be reprovisioned' );
  121. my ($bare) = grep { ( $_->{domain} // '' ) eq 'norecipe.example.com' } @posts;
  122. ok( !$bare->{has_recipe}, 'a guest with no recipe says so' );
  123. is( $bare->{can_reprovision}, 0, 'and may not be reprovisioned' );
  124. ok( exists $bare->{title}, 'but is listed all the same, since plenty of guests were not built by this' );
  125. my ($self) = grep { ( $_->{domain} // '' ) eq 'thisserver.example.com' } @posts;
  126. is( $self->{can_reprovision}, 0, 'the server drawing the page may never be reprovisioned' );
  127. my ($dead) = grep { $_->{unreachable} } @posts;
  128. ok( !$dead->{has_recipe}, 'an unreachable hypervisor is left exactly as it was' );
  129. };
  130. subtest 'what it refuses to reprovision' => sub {
  131. my ( $ok, $why ) = reprovision( domain => 'thisserver.example.com' );
  132. is( $ok, 0, 'the server it is running on' );
  133. like( $why, qr/this server/, 'and says why' );
  134. is_deeply( \@SPAWNED, [], 'without running anything' );
  135. ( $ok, $why ) = reprovision( domain => 'norecipe.example.com' );
  136. is( $ok, 0, 'a guest with no recipe' );
  137. like( $why, qr/no recipe/, 'and says why' );
  138. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => '' );
  139. is( $ok, 0, 'a run with no passphrase, which new_config would just hang waiting for' );
  140. like( $why, qr/passphrase/, 'and says why' );
  141. # The name reaches the filesystem and an argument list.
  142. foreach my $bad ( '../../etc/passwd', 'guest;rm -rf /', 'guest example.com', '', 'a' x 300, '.hidden' ) {
  143. my ( $refused, $message ) = reprovision( domain => $bad );
  144. is( $refused, 0, "'$bad' is refused" );
  145. like( $message, qr/bad guest name|no recipe/, '...before anything is run' );
  146. }
  147. is_deeply( \@SPAWNED, [], 'and none of that ran a provisioner' );
  148. };
  149. subtest 'the lifecycle it would run' => sub {
  150. my ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
  151. is( $ok, 1, 'a guest with a recipe is accepted' ) or diag($why);
  152. like( $why, qr/started/, 'and reports that it started rather than that it finished' );
  153. is( scalar(@SPAWNED), 1, 'exactly one run' );
  154. my $run = $SPAWNED[0];
  155. is( $run->{domain}, 'guest.example.com', 'for the guest asked about' );
  156. is( $run->{user}, 'admin', 'recording who asked' );
  157. # new_config in provisioners, then provision in trog-provisioner, each in
  158. # its own repository.
  159. is( scalar( @{ $run->{lifecycle} } ), 2, 'two steps' );
  160. my ( $first, $second ) = @{ $run->{lifecycle} };
  161. is( $first->[0], "$ROOT/provisioners", 'the first runs in provisioners' );
  162. like( $first->[1], qr{/bin/new_config$}, 'and is new_config' );
  163. is( $first->[2], 'guest.example.com', 'passed the guest as an argument' );
  164. is( $second->[0], "$ROOT/trog-provisioner", 'the second runs in trog-provisioner' );
  165. like( $second->[1], qr{/bin/provision$}, 'and is provision' );
  166. is( $second->[2], 'guest.example.com', 'passed the same' );
  167. # Arguments, not a command line: there is no shell between us and these, so
  168. # a guest name can never be read as one.
  169. foreach my $step ( @{ $run->{lifecycle} } ) {
  170. is( scalar(@$step), 3, 'a directory, a program and one argument -- nothing to be parsed' );
  171. }
  172. };
  173. subtest 'without the repositories configured it is just Virt' => sub {
  174. write_config();
  175. my ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
  176. is( $ok, 0, 'nothing to run' );
  177. like( $why, qr/not configured/, 'and says so plainly' );
  178. is_deeply( \@SPAWNED, [], 'having run nothing' );
  179. $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
  180. my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  181. ok( !$post->{has_recipe}, 'the listing still works' );
  182. is( $post->{can_reprovision}, 0, 'it simply offers no button' );
  183. # A path that is not there is a typo, and better found here than halfway
  184. # through a reprovision.
  185. write_config( provisioners => "$ROOT/nonesuch", trog_provisioner => "$ROOT/trog-provisioner" );
  186. ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
  187. is( $ok, 0, 'a configured path which is not there is refused' );
  188. write_config(
  189. provisioners => "$ROOT/provisioners",
  190. trog_provisioner => "$ROOT/trog-provisioner",
  191. );
  192. };
  193. subtest 'the log is read back onto the page' => sub {
  194. no warnings qw{once};
  195. File::Path::make_path("$ROOT/logs/reprovision");
  196. open( my $fh, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
  197. print {$fh} ( 'x' x 9000 ) . "\n=== finished ===\n";
  198. close $fh;
  199. my $tail = Trog::DataSource::ProvisionedVirt::_log_tail('guest.example.com');
  200. ok( length($tail) <= $Trog::DataSource::ProvisionedVirt::log_tail_bytes, 'only the tail of it' );
  201. like( $tail, qr/=== finished ===/, 'which is the part that says how it went' );
  202. is( Trog::DataSource::ProvisionedVirt::_log_tail('norecipe.example.com'), undef, 'nothing when it has never run' );
  203. is( Trog::DataSource::ProvisionedVirt::_log_tail('../../etc/passwd'), undef, 'and a name that is not one reads nothing' );
  204. };
  205. subtest 'the state a reprovision leaves behind' => sub {
  206. no warnings qw{once};
  207. File::Path::make_path("$ROOT/logs/reprovision");
  208. my $status = "$ROOT/logs/reprovision/guest.example.com.status";
  209. my $write = sub {
  210. open( my $fh, '>', $status ) or die $!;
  211. print {$fh} join( '', map { "$_\n" } @_ );
  212. close $fh;
  213. };
  214. # Nothing waits on the process which does the work, so this file is the
  215. # whole of what it can say for itself.
  216. $write->( 'state=ok', 'started=100', 'finished=200', 'exit=0', 'user=admin' );
  217. my $got = Trog::DataSource::ProvisionedVirt::status('guest.example.com');
  218. is( $got->{state}, 'ok', 'a finished run reads back as finished' );
  219. is( $got->{exit}, '0', 'with what it exited' );
  220. is( $got->{user}, 'admin', 'and who asked for it' );
  221. is( $got->{finished}, '200', 'and when it stopped' );
  222. $write->( 'state=failed', 'exit=3', 'failed=/x/bin/provision' );
  223. is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'failed', 'a failed one reads back failed' );
  224. # A live pid means it really is going on. Ours is alive by definition.
  225. $write->( 'state=running', "pid=$$", 'started=100', 'user=admin' );
  226. is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'running', 'a run whose process is alive is running' );
  227. # And a dead one means it stopped without saying so, which is the case that
  228. # would otherwise leave the page claiming a provision forever.
  229. my $dead = 999999;
  230. $dead++ while kill( 0, $dead ) && $dead < 4194304;
  231. $write->( 'state=running', "pid=$dead", 'started=100' );
  232. is(
  233. Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state},
  234. 'interrupted',
  235. 'a run whose process is gone reads as interrupted, not as still going'
  236. );
  237. $write->('state=running');
  238. is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'interrupted', 'and so does one with no pid at all' );
  239. # Half a file is not a status.
  240. $write->('garbage');
  241. is( Trog::DataSource::ProvisionedVirt::status('guest.example.com'), undef, 'an unparseable status is no status' );
  242. is( Trog::DataSource::ProvisionedVirt::status('never.example.com'), undef, 'nothing for a guest that has never run' );
  243. is( Trog::DataSource::ProvisionedVirt::status('../../etc/passwd'), undef, 'and a name that is not one reads nothing' );
  244. unlink($status);
  245. };
  246. subtest 'the listing says what is going on' => sub {
  247. no warnings qw{once};
  248. File::Path::make_path("$ROOT/logs/reprovision");
  249. my $status = "$ROOT/logs/reprovision/guest.example.com.status";
  250. open( my $log, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
  251. print {$log} "some output\n";
  252. close $log;
  253. $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
  254. open( my $fh, '>', $status ) or die $!;
  255. print {$fh} "state=running\npid=$$\nstarted=100\nuser=admin\n";
  256. close $fh;
  257. my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  258. is( $post->{reprovision_state}, 'running', 'the guest says it is reprovisioning' );
  259. is( $post->{reprovision_status}, 'reprovisioning', 'in words a person reads' );
  260. is( $post->{is_reprovisioning}, 1, 'and is flagged as busy' );
  261. is( $post->{reprovision_by}, 'admin', 'naming who started it' );
  262. is( $post->{can_reprovision}, 0, 'and offers no button while one is going on' );
  263. is( $post->{reprovision_log_href}, '/guest/reprovision/log/guest.example.com', 'with a link to read the log' );
  264. # Starting a second one is refused for the same reason.
  265. my ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
  266. is( $ok, 0, 'and a second run is refused while the first is going' );
  267. like( $why, qr/already running/, 'saying so' );
  268. is_deeply( \@SPAWNED, [], 'having started nothing' );
  269. open( $fh, '>', $status ) or die $!;
  270. print {$fh} "state=failed\nexit=3\nstarted=100\nfinished=200\nuser=admin\n";
  271. close $fh;
  272. ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  273. is( $post->{reprovision_state}, 'failed', 'a failed run says so' );
  274. is( $post->{reprovision_status}, 'reprovision failed', 'in words' );
  275. is( $post->{reprovision_exit}, '3', 'with what it exited' );
  276. is( $post->{is_reprovisioning}, 0, 'and is not busy' );
  277. is( $post->{can_reprovision}, 1, 'so it may be tried again' );
  278. unlink($status);
  279. ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  280. ok( !$post->{reprovision_state}, 'a guest which has never been reprovisioned says nothing about it' );
  281. is( $post->{can_reprovision}, 1, 'and may be' );
  282. };
  283. subtest 'reading the log' => sub {
  284. no warnings qw{once};
  285. is( Trog::DataSource::ProvisionedVirt::log_for('never.example.com'), undef, 'nothing when there has been no run' );
  286. is( Trog::DataSource::ProvisionedVirt::log_for('../../etc/passwd'), undef, 'and a name that is not one reads nothing' );
  287. open( my $fh, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
  288. print {$fh} "the whole log\n";
  289. close $fh;
  290. like( Trog::DataSource::ProvisionedVirt::log_for('guest.example.com'), qr/the whole log/, 'and the whole of it when there has' );
  291. # A provisioner can be chatty, and this goes out in one response.
  292. local $Trog::DataSource::ProvisionedVirt::log_max_bytes = 64;
  293. open( $fh, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
  294. print {$fh} ( 'x' x 500 ) . "END\n";
  295. close $fh;
  296. my $capped = Trog::DataSource::ProvisionedVirt::log_for('guest.example.com');
  297. like( $capped, qr/truncated/, 'a huge one is truncated' );
  298. like( $capped, qr/END/, 'keeping the end, which is where the answer is' );
  299. };
  300. subtest 'the route' => sub {
  301. no warnings qw{once};
  302. require Trog::Routes::HTML;
  303. my $route = $Trog::Routes::HTML::routes{'/guest/reprovision'};
  304. ok( $route, 'is registered' );
  305. is( $route->{method}, 'POST', 'POST only, since it is not a thing to do by following a link' );
  306. is( $route->{auth}, 1, 'and requires a login' );
  307. ok( $route->{nocache}, 'and is never cached' );
  308. ok( $route->{noindex}, 'nor indexed' );
  309. my $tpsgi = bless {}, 'FakeTPSGI3';
  310. {
  311. no warnings qw{once};
  312. *FakeTPSGI3::see_also = sub { return [ 303, [ Location => $_[1] ], [''] ] };
  313. *FakeTPSGI3::forbidden = sub { return [ 403, [], ['no'] ] };
  314. *FakeTPSGI3::notfound = sub { return [ 404, [], ['gone'] ] };
  315. }
  316. # Admin only, like every other thing that changes a guest.
  317. my $res = Trog::Routes::HTML::guest_reprovision( { user => 'bob', user_acls => ['public'], tpsgi => $tpsgi } );
  318. is( $res->[0], 403, 'a non-admin is refused' );
  319. is_deeply( \@SPAWNED, [], 'without running anything' );
  320. $res = Trog::Routes::HTML::guest_reprovision( { user => '', user_acls => [], tpsgi => $tpsgi } );
  321. is( $res->[0], 303, 'and somebody logged out is sent to log in' );
  322. # The passphrase must not survive the request: this hash gets cloned, logged
  323. # around and handed to renderers.
  324. @SPAWNED = ();
  325. my $query = { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'guest.example.com', passphrase => 'hunter2', to => '/vms' };
  326. Trog::Routes::HTML::guest_reprovision($query);
  327. is( scalar(@SPAWNED), 1, 'an admin gets their run' );
  328. is( $SPAWNED[0]{passphrase}, 'hunter2', 'the passphrase reaches the lifecycle' );
  329. ok( !exists $query->{passphrase}, 'and is gone from the query afterwards' );
  330. # The log route. It lives in logs/ rather than under www/, and the output
  331. # of a provisioner is a fine place for a hostname or an IP plan to turn up.
  332. my $logroute = $Trog::Routes::HTML::routes{'/guest/reprovision/log/(.*)'};
  333. ok( $logroute, 'the log route is registered' );
  334. is( $logroute->{method}, 'GET', 'as a GET, since it only reads' );
  335. is( $logroute->{auth}, 1, 'behind a login' );
  336. is_deeply( $logroute->{captures}, ['guest'], "capturing 'guest', which survives routing" );
  337. $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'bob', user_acls => ['public'], tpsgi => $tpsgi } );
  338. is( $res->[0], 403, 'a non-admin cannot read it' );
  339. $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'never.example.com' } );
  340. is( $res->[0], 404, 'and a guest with no log is a 404 rather than an empty page' );
  341. };
  342. done_testing();