Trog-DataSource-ProvisionedVirt.t 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  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 5.040;
  6. use re '/aasx';
  7. use warnings FATAL => 'all';
  8. use FindBin;
  9. use FindBin::libs;
  10. use Test::More;
  11. use Test::MockModule qw{strict};
  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 schema logs totp www/assets};
  23. # A vault, so that the half of this which is about not having to type a
  24. # passphrase every time has somewhere to keep one. The key arrives the way
  25. # a deployment hands one over, and Trog::Vault takes it out of %ENV as it
  26. # loads -- so this has to be set before the SUT is required, below.
  27. File::Copy::copy( "$REPO/schema/auth.schema", "$ROOT/schema/auth.schema" ) or die $!;
  28. require Crypt::Misc;
  29. require Crypt::PRNG;
  30. $ENV{TPSGI_VAULT_KEY} = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
  31. # A stand-in for the checkout, with the one program a reprovision runs.
  32. # Never executed -- spawn is mocked -- but it has to be there and
  33. # executable, because reprovision() checks that before it commits to
  34. # anything.
  35. File::Path::make_path("$ROOT/trog-provisioner/bin");
  36. my $program = "$ROOT/trog-provisioner/bin/provision";
  37. open( my $fh, '>', $program ) or die $!;
  38. print {$fh} "#!/bin/sh\nexit 0\n";
  39. close $fh;
  40. chmod( 0755, $program );
  41. # And a stand-in for the installation's own files, which live where the
  42. # provisioner keeps them rather than anywhere tCMS is told about.
  43. $ENV{TROG_PROVISIONER_CONFIG} = "$ROOT/etc";
  44. File::Path::make_path("$ROOT/etc/recipes.d");
  45. open( my $recipe, '>', "$ROOT/etc/recipes.d/guest.example.com.yaml" ) or die $!;
  46. print {$recipe} "---\n_global:\n registrar: secret:x/y/z\n";
  47. close $recipe;
  48. chdir($ROOT) or die "could not chdir to the sandbox: $!";
  49. }
  50. END {
  51. chdir($OLDCWD) if $OLDCWD;
  52. }
  53. # Trog::Config memoises, which is what a running server wants and a test
  54. # emphatically does not.
  55. my $config_mock;
  56. sub write_config {
  57. my (%section) = @_;
  58. open( my $fh, '>', "$ROOT/config/default.cfg" ) or die $!;
  59. print {$fh} "[general]\n data_model=FlatFile\n[provisioner]\n";
  60. print {$fh} " $_=$section{$_}\n" foreach sort keys %section;
  61. close $fh;
  62. require Config::Simple;
  63. my $conf = Config::Simple->new("$ROOT/config/default.cfg");
  64. local $@;
  65. $config_mock //= Test::MockModule->new('Trog::Config');
  66. $config_mock->redefine( get => sub { return $conf } );
  67. return;
  68. }
  69. require_ok('Trog::DataSource::ProvisionedVirt') or BAIL_OUT("Can't find SUT");
  70. Trog::Auth::useradd( 'admin', 'The Admin', 'hunter2', ['admin'], 'admin@example.com' )
  71. or BAIL_OUT('could not make a user to test with');
  72. my $log = Test::MockModule->new('Trog::DataSource::ProvisionedVirt');
  73. $log->redefine( WARN => sub { note(shift) } );
  74. $log->redefine( INFO => sub { note(shift) } );
  75. # Nothing in this file forks, and nothing in it runs a provisioner.
  76. our @SPAWNED;
  77. $log->redefine(
  78. spawn => sub {
  79. my ( $domain, $credentials, $dir, $command, $user ) = @_;
  80. push( @SPAWNED, { domain => $domain, credentials => $credentials, dir => $dir, command => $command, user => $user } );
  81. return ( 1, 'started' );
  82. }
  83. );
  84. # Nor reaches a hypervisor.
  85. my $virt = Test::MockModule->new('Trog::DataSource::Virt');
  86. $virt->redefine( is_self => sub { return $_[0] eq 'thisserver.example.com' ? 1 : 0 } );
  87. write_config( trog_provisioner => "$ROOT/trog-provisioner" );
  88. sub reprovision {
  89. @SPAWNED = ();
  90. return Trog::DataSource::ProvisionedVirt::reprovision( passphrase => 'hunter2', user => 'admin', @_ );
  91. }
  92. require Trog::Auth;
  93. require Trog::Vault;
  94. subtest 'it is Virt, with more' => sub {
  95. ok( Trog::DataSource::ProvisionedVirt->isa('Trog::DataSource::Virt'), 'it is a Virt' );
  96. # can() resolves through @ISA, which is how the datasource dispatcher finds
  97. # the halves this does not override.
  98. foreach my $inherited (qw{filter lang help}) {
  99. my $mine = Trog::DataSource::ProvisionedVirt->can($inherited);
  100. my $theirs = Trog::DataSource::Virt->can($inherited);
  101. is( $mine, $theirs, "$inherited is the one Virt already had" );
  102. }
  103. # Virt has no order() of its own, so this correctly finds none and the
  104. # dispatcher falls back to Trog::DataSource::order.
  105. ok( !Trog::DataSource::ProvisionedVirt->can('order'), 'and the shared default ordering still applies' );
  106. # posts() is the one thing it does replace.
  107. isnt(
  108. Trog::DataSource::ProvisionedVirt->can('posts'),
  109. Trog::DataSource::Virt->can('posts'),
  110. 'posts() is its own, since that is where the recipe is added'
  111. );
  112. is( Trog::DataSource::ProvisionedVirt->EDITABLE, 0, 'still nothing to edit' );
  113. is( Trog::DataSource::ProvisionedVirt->CACHEABLE, 0, 'and still never cached, since a guest\'s state is live' );
  114. };
  115. subtest 'the recipe lands on the guest' => sub {
  116. $virt->redefine(
  117. posts => sub {
  118. return (
  119. { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 },
  120. { title => 'norecipe.example.com', domain => 'norecipe.example.com', is_self => 0 },
  121. { title => 'thisserver.example.com', domain => 'thisserver.example.com', is_self => 1 },
  122. { title => 'a hypervisor', unreachable => 1, state => 'nope' },
  123. );
  124. }
  125. );
  126. my @posts = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  127. is( scalar(@posts), 4, 'every guest Virt listed is still listed' );
  128. my ($provisioned) = grep { ( $_->{domain} // '' ) eq 'guest.example.com' } @posts;
  129. ok( $provisioned->{has_recipe}, 'the one with a recipe says so' );
  130. like( $provisioned->{recipe}, qr/registrar/, 'and carries the recipe itself' );
  131. like( $provisioned->{recipe_path}, qr{recipes\.d/guest\.example\.com\.yaml$}, 'and where it came from' );
  132. is( $provisioned->{can_reprovision}, 1, 'and may be reprovisioned' );
  133. my ($bare) = grep { ( $_->{domain} // '' ) eq 'norecipe.example.com' } @posts;
  134. ok( !$bare->{has_recipe}, 'a guest with no recipe says so' );
  135. is( $bare->{can_reprovision}, 0, 'and may not be reprovisioned' );
  136. ok( exists $bare->{title}, 'but is listed all the same, since plenty of guests were not built by this' );
  137. my ($self) = grep { ( $_->{domain} // '' ) eq 'thisserver.example.com' } @posts;
  138. is( $self->{can_reprovision}, 0, 'the server drawing the page may never be reprovisioned' );
  139. my ($dead) = grep { $_->{unreachable} } @posts;
  140. ok( !$dead->{has_recipe}, 'an unreachable hypervisor is left exactly as it was' );
  141. };
  142. subtest 'what it refuses to reprovision' => sub {
  143. my ( $ok, $why ) = reprovision( domain => 'thisserver.example.com' );
  144. is( $ok, 0, 'the server it is running on' );
  145. like( $why, qr/this[ ]server/, 'and says why' );
  146. is_deeply( \@SPAWNED, [], 'without running anything' );
  147. ( $ok, $why ) = reprovision( domain => 'norecipe.example.com' );
  148. is( $ok, 0, 'a guest with no recipe' );
  149. like( $why, qr/no[ ]recipe/, 'and says why' );
  150. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => '' );
  151. is( $ok, 0, 'a run with no passphrase, which the provisioner would just hang waiting for' );
  152. like( $why, qr/passphrase/, 'and says why' );
  153. # The name reaches the filesystem and an argument list.
  154. foreach my $bad ( '../../etc/passwd', 'guest;rm -rf /', 'guest example.com', '', 'a' x 300, '.hidden' ) {
  155. my ( $refused, $message ) = reprovision( domain => $bad );
  156. is( $refused, 0, "'$bad' is refused" );
  157. like( $message, qr/bad[ ]guest[ ]name|no[ ]recipe/, '...before anything is run' );
  158. }
  159. is_deeply( \@SPAWNED, [], 'and none of that ran a provisioner' );
  160. };
  161. subtest 'what it would run' => sub {
  162. my ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
  163. is( $ok, 1, 'a guest with a recipe is accepted' ) or diag($why);
  164. like( $why, qr/started/, 'and reports that it started rather than that it finished' );
  165. is( scalar(@SPAWNED), 1, 'exactly one run' );
  166. my $run = $SPAWNED[0];
  167. is( $run->{domain}, 'guest.example.com', 'for the guest asked about' );
  168. is( $run->{user}, 'admin', 'recording who asked' );
  169. # One program does the whole of it now: bin/provision generates the guest's
  170. # configuration from its recipe and then builds the machine.
  171. is( $run->{dir}, "$ROOT/trog-provisioner", 'run from the checkout' );
  172. like( $run->{command}[0], qr{/bin/provision$}, 'and it is bin/provision' );
  173. is( $run->{command}[1], '--credentials', 'told that its passwords are coming on stdin' );
  174. is( $run->{command}[2], 'guest.example.com', 'and passed the guest as an argument' );
  175. # Arguments, not a command line: there is no shell between us and this, so a
  176. # guest name can never be read as one.
  177. is( scalar( @{ $run->{command} } ), 3, 'a program, a flag and one argument -- nothing to be parsed' );
  178. };
  179. subtest 'a stored passphrase, and a code instead of one' => sub {
  180. no warnings qw{once};
  181. # Nothing stored yet, so the form asks for the passphrase and offers to keep
  182. # it -- there is a vault key in this sandbox, so keeping it is on the table.
  183. $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
  184. my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
  185. is( $post->{passphrase_remembered}, 0, 'with nothing stored the form asks for the passphrase' );
  186. is( $post->{can_remember}, 1, 'and can offer to remember it' );
  187. # A code is worth nothing when there is nothing stored to unlock: it is
  188. # proof somebody is here, not a password.
  189. my ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => '123456' );
  190. is( $ok, 0, 'a code with nothing stored behind it does not provision' );
  191. is_deeply( \@SPAWNED, [], 'having run nothing' );
  192. # Reprovision the old way, and ask for it to be kept.
  193. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', remember => 1 );
  194. is( $ok, 1, 'a typed passphrase still provisions' ) or diag($why);
  195. is( $SPAWNED[0]{credentials}{keepass}, 'correct horse', 'and is what reaches the provisioner' );
  196. ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
  197. is( $post->{passphrase_remembered}, 1, 'after which the form asks for a code instead' );
  198. # And now the point of all of it: a code, spent, exchanged for what was
  199. # stored. Enrol first -- a code is only meaningful for somebody enrolled.
  200. my ( $uri, undef, undef, $message, $totp ) = Trog::Auth::totp( 'admin', 'example.com' );
  201. ok( $uri, 'the user is enrolled' ) or diag($message);
  202. my $code = $totp->expected_totp_code( time() );
  203. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $code );
  204. is( $ok, 1, 'a code provisions' ) or diag($why);
  205. is( $SPAWNED[0]{credentials}{keepass}, 'correct horse', 'handing the provisioner the passphrase that was stored' );
  206. # Spent, so it is worth this one reprovision and not every reprovision
  207. # somebody can click inside the window.
  208. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $code );
  209. is( $ok, 0, 'and cannot be used again' );
  210. like( $why, qr/already[ ]been[ ]used/, 'saying so' );
  211. is_deeply( \@SPAWNED, [], 'without running a second one' );
  212. Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name );
  213. };
  214. subtest 'the sudo password on the hypervisor' => sub {
  215. no warnings qw{once};
  216. $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
  217. Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name );
  218. Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::sudo_name );
  219. my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
  220. is( $post->{sudo_remembered}, 0, 'with none stored the form asks for one' );
  221. # The ordinary case, and the reason the field is not required: the login on
  222. # the hypervisor has passwordless sudo, so there is nothing to send.
  223. my ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse' );
  224. is( $ok, 1, 'a reprovision with no sudo password still runs' ) or diag($why);
  225. ok( !exists $SPAWNED[0]{credentials}{sudo}, 'and sends none, rather than sending an empty one' );
  226. # And the case this is for: a hypervisor that wants one. sudo over ssh has
  227. # no terminal to ask at, so the run would otherwise die minutes in.
  228. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', sudo => 'hunter2' );
  229. is( $ok, 1, 'one with a sudo password runs too' ) or diag($why);
  230. is( $SPAWNED[0]{credentials}{sudo}, 'hunter2', 'and it reaches the provisioner' );
  231. is( $SPAWNED[0]{credentials}{keepass}, 'correct horse', 'alongside the passphrase, not instead of it' );
  232. # Kept, so the next run is a code and neither has to be typed again.
  233. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', sudo => 'hunter2', remember => 1 );
  234. is( $ok, 1, 'both can be remembered' ) or diag($why);
  235. ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
  236. is( $post->{passphrase_remembered}, 1, 'after which the passphrase is stored' );
  237. is( $post->{sudo_remembered}, 1, 'and so is the sudo password' );
  238. my ( undef, undef, undef, undef, $totp ) = Trog::Auth::totp( 'admin', 'example.com' );
  239. require Trog::SQLite;
  240. Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' )->do("DELETE FROM totp_spent WHERE username='admin'");
  241. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $totp->expected_totp_code( time() ) );
  242. is( $ok, 1, 'a code unlocks the pair' ) or diag($why);
  243. is( $SPAWNED[0]{credentials}{keepass}, 'correct horse', 'the passphrase' );
  244. is( $SPAWNED[0]{credentials}{sudo}, 'hunter2', 'and the sudo password' );
  245. # Typed beats stored: somebody typing it has a reason to, and it is usually
  246. # that the stored one stopped working.
  247. Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' )->do("DELETE FROM totp_spent WHERE username='admin'");
  248. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, sudo => 'changed it', totp => $totp->expected_totp_code( time() ) );
  249. is( $ok, 1, 'a code and a typed sudo password together' ) or diag($why);
  250. is( $SPAWNED[0]{credentials}{sudo}, 'changed it', 'send what was typed' );
  251. Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name );
  252. Trog::Vault::forget( 'admin', $Trog::DataSource::ProvisionedVirt::sudo_name );
  253. };
  254. subtest 'a password with a line break in it is refused' => sub {
  255. # One 'name: value' per line is the format on the far side, so a newline in
  256. # a value is a second line -- and the second line could name a credential
  257. # nobody asked us to send.
  258. my ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => "correct horse\nsudo: injected" );
  259. is( $ok, 0, 'a passphrase carrying a line break does not run' );
  260. like( $why, qr/line[ ]break/, 'saying so' );
  261. is_deeply( \@SPAWNED, [], 'having run nothing' );
  262. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'correct horse', sudo => "hunter2\nkeepass: not this" );
  263. is( $ok, 0, 'and neither does a sudo password carrying one' );
  264. like( $why, qr/line[ ]break/, 'saying so too' );
  265. is_deeply( \@SPAWNED, [], 'having run nothing' );
  266. };
  267. subtest 'and when the vault key has gone' => sub {
  268. no warnings qw{once};
  269. # The key lives and dies with the machine, so this is the ordinary
  270. # consequence of rebuilding one rather than a disaster. What must not
  271. # happen is the page going on asking for a code it cannot honour.
  272. Trog::Vault::set( 'admin', $Trog::DataSource::ProvisionedVirt::secret_name, 'correct horse' );
  273. $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
  274. my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
  275. is( $post->{passphrase_remembered}, 1, 'with the key here, the form asks for a code' );
  276. local $Trog::Vault::master = Crypt::PRNG::random_bytes(32);
  277. ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
  278. is( $post->{passphrase_remembered}, 0, 'and with it gone, back to asking for the passphrase' );
  279. is( $post->{can_reprovision}, 1, 'the button is still offered, since it still works that way' );
  280. # Posted at directly, as somebody would from a page drawn a moment before
  281. # the key went away.
  282. my ( undef, undef, undef, undef, $totp ) = Trog::Auth::totp( 'admin', 'example.com' );
  283. # The subtest above spent this window's codes. Standing here for thirty
  284. # seconds would say the same thing and cost thirty seconds.
  285. require Trog::SQLite;
  286. Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' )->do("DELETE FROM totp_spent WHERE username='admin'");
  287. my $code = $totp->expected_totp_code( time() );
  288. my ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => undef, totp => $code );
  289. is( $ok, 0, 'a code is refused rather than exploding' );
  290. like( $why, qr/can[ ]open/, 'saying the passphrase cannot be opened' );
  291. like( $why, qr{/secrets}, 'and where to go about it' );
  292. is_deeply( \@SPAWNED, [], 'having provisioned nothing' );
  293. # And the refusal came before the code was spent, so the same code is still
  294. # good for the thing that does work. Being told to go and re-store a
  295. # passphrase should not also cost somebody thirty seconds.
  296. ( $ok, $why ) = Trog::Auth::spend_totp( 'admin', $code );
  297. is( $ok, 1, 'the code was not spent on being told no' ) or diag($why);
  298. ( $ok, $why ) = reprovision( domain => 'guest.example.com', passphrase => 'typed it again', remember => 1 );
  299. is( $ok, 1, 'and the passphrase still provisions' ) or diag($why);
  300. is( $SPAWNED[0]{credentials}{keepass}, 'typed it again', 'with what was typed' );
  301. ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, { user => 'admin' } );
  302. is( $post->{passphrase_remembered}, 1, 'stored again under the key that is here now' );
  303. };
  304. subtest 'without the provisioner configured it is just Virt' => sub {
  305. write_config();
  306. my ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
  307. is( $ok, 0, 'nothing to run' );
  308. like( $why, qr/not[ ]configured/, 'and says so plainly' );
  309. is_deeply( \@SPAWNED, [], 'having run nothing' );
  310. $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
  311. my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  312. ok( !$post->{has_recipe}, 'the listing still works' );
  313. is( $post->{can_reprovision}, 0, 'it simply offers no button' );
  314. # A path that is not there is a typo, and better found here than halfway
  315. # through a reprovision.
  316. write_config( trog_provisioner => "$ROOT/nonesuch" );
  317. ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
  318. is( $ok, 0, 'a configured path which is not there is refused' );
  319. like( $why, qr/not[ ]configured/, 'the same as never having configured one' );
  320. write_config( trog_provisioner => "$ROOT/trog-provisioner" );
  321. };
  322. subtest 'the log is read back onto the page' => sub {
  323. no warnings qw{once};
  324. File::Path::make_path("$ROOT/logs/reprovision");
  325. open( my $fh, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
  326. print {$fh} ( 'x' x 9000 ) . "\n=== finished ===\n";
  327. close $fh;
  328. my $tail = Trog::DataSource::ProvisionedVirt::log_tail('guest.example.com');
  329. ok( length($tail) <= $Trog::DataSource::ProvisionedVirt::log_tail_bytes, 'only the tail of it' );
  330. like( $tail, qr/===[ ]finished[ ]===/, 'which is the part that says how it went' );
  331. is( Trog::DataSource::ProvisionedVirt::log_tail('norecipe.example.com'), undef, 'nothing when it has never run' );
  332. is( Trog::DataSource::ProvisionedVirt::log_tail('../../etc/passwd'), undef, 'and a name that is not one reads nothing' );
  333. };
  334. subtest 'the state a reprovision leaves behind' => sub {
  335. no warnings qw{once};
  336. File::Path::make_path("$ROOT/logs/reprovision");
  337. my $status = "$ROOT/logs/reprovision/guest.example.com.status";
  338. my $write = sub {
  339. open( my $fh, '>', $status ) or die $!;
  340. print {$fh} join( '', map { "$_\n" } @_ );
  341. close $fh;
  342. };
  343. # Nothing waits on the process which does the work, so this file is the
  344. # whole of what it can say for itself.
  345. $write->(qw{state=ok started=100 finished=200 exit=0 user=admin});
  346. my $got = Trog::DataSource::ProvisionedVirt::status('guest.example.com');
  347. is( $got->{state}, 'ok', 'a finished run reads back as finished' );
  348. is( $got->{exit}, '0', 'with what it exited' );
  349. is( $got->{user}, 'admin', 'and who asked for it' );
  350. is( $got->{finished}, '200', 'and when it stopped' );
  351. $write->(qw{state=failed exit=3 failed=/x/bin/provision});
  352. is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'failed', 'a failed one reads back failed' );
  353. # A live pid means it really is going on. Ours is alive by definition.
  354. $write->( 'state=running', "pid=$$", 'started=100', 'user=admin' );
  355. is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'running', 'a run whose process is alive is running' );
  356. # And a dead one means it stopped without saying so, which is the case that
  357. # would otherwise leave the page claiming a provision forever.
  358. my $dead = 999999;
  359. $dead++ while kill( 0, $dead ) && $dead < 4194304;
  360. $write->( 'state=running', "pid=$dead", 'started=100' );
  361. is(
  362. Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state},
  363. 'interrupted',
  364. 'a run whose process is gone reads as interrupted, not as still going'
  365. );
  366. $write->('state=running');
  367. is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'interrupted', 'and so does one with no pid at all' );
  368. # Half a file is not a status.
  369. $write->('garbage');
  370. is( Trog::DataSource::ProvisionedVirt::status('guest.example.com'), undef, 'an unparseable status is no status' );
  371. is( Trog::DataSource::ProvisionedVirt::status('never.example.com'), undef, 'nothing for a guest that has never run' );
  372. is( Trog::DataSource::ProvisionedVirt::status('../../etc/passwd'), undef, 'and a name that is not one reads nothing' );
  373. unlink($status);
  374. };
  375. subtest 'the listing says what is going on' => sub {
  376. no warnings qw{once};
  377. File::Path::make_path("$ROOT/logs/reprovision");
  378. my $status = "$ROOT/logs/reprovision/guest.example.com.status";
  379. open( my $out, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
  380. print {$out} "some output\n";
  381. close $out;
  382. $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
  383. open( my $fh, '>', $status ) or die $!;
  384. print {$fh} "state=running\npid=$$\nstarted=100\nuser=admin\n";
  385. close $fh;
  386. my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  387. is( $post->{reprovision_state}, 'running', 'the guest says it is reprovisioning' );
  388. is( $post->{reprovision_status}, 'reprovisioning', 'in words a person reads' );
  389. is( $post->{is_reprovisioning}, 1, 'and is flagged as busy' );
  390. is( $post->{reprovision_by}, 'admin', 'naming who started it' );
  391. is( $post->{can_reprovision}, 0, 'and offers no button while one is going on' );
  392. is( $post->{reprovision_log_href}, '/guest/reprovision/log/guest.example.com', 'with a link to read the log' );
  393. # Starting a second one is refused for the same reason.
  394. my ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
  395. is( $ok, 0, 'and a second run is refused while the first is going' );
  396. like( $why, qr/already[ ]running/, 'saying so' );
  397. is_deeply( \@SPAWNED, [], 'having started nothing' );
  398. open( $fh, '>', $status ) or die $!;
  399. print {$fh} "state=failed\nexit=3\nstarted=100\nfinished=200\nuser=admin\n";
  400. close $fh;
  401. ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  402. is( $post->{reprovision_state}, 'failed', 'a failed run says so' );
  403. is( $post->{reprovision_status}, 'reprovision failed', 'in words' );
  404. is( $post->{reprovision_exit}, '3', 'with what it exited' );
  405. is( $post->{is_reprovisioning}, 0, 'and is not busy' );
  406. is( $post->{can_reprovision}, 1, 'so it may be tried again' );
  407. unlink($status);
  408. ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
  409. ok( !$post->{reprovision_state}, 'a guest which has never been reprovisioned says nothing about it' );
  410. is( $post->{can_reprovision}, 1, 'and may be' );
  411. };
  412. subtest 'reading the log' => sub {
  413. no warnings qw{once};
  414. is( Trog::DataSource::ProvisionedVirt::log_for('never.example.com'), undef, 'nothing when there has been no run' );
  415. is( Trog::DataSource::ProvisionedVirt::log_for('../../etc/passwd'), undef, 'and a name that is not one reads nothing' );
  416. open( my $fh, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
  417. print {$fh} "the whole log\n";
  418. close $fh;
  419. like( Trog::DataSource::ProvisionedVirt::log_for('guest.example.com'), qr/the[ ]whole[ ]log/, 'and the whole of it when there has' );
  420. # A provisioner can be chatty, and this goes out in one response.
  421. local $Trog::DataSource::ProvisionedVirt::log_max_bytes = 64;
  422. open( $fh, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
  423. print {$fh} ( 'x' x 500 ) . "END\n";
  424. close $fh;
  425. my $capped = Trog::DataSource::ProvisionedVirt::log_for('guest.example.com');
  426. like( $capped, qr/truncated/, 'a huge one is truncated' );
  427. like( $capped, qr/END/, 'keeping the end, which is where the answer is' );
  428. };
  429. subtest 'the route' => sub {
  430. no warnings qw{once};
  431. require Trog::Routes::HTML;
  432. my $route = $Trog::Routes::HTML::routes{'/guest/reprovision'};
  433. ok( $route, 'is registered' );
  434. is( $route->{method}, 'POST', 'POST only, since it is not a thing to do by following a link' );
  435. is( $route->{auth}, 1, 'and requires a login' );
  436. ok( $route->{nocache}, 'and is never cached' );
  437. ok( $route->{noindex}, 'nor indexed' );
  438. my $tpsgi = bless {}, 'FakeTPSGI3';
  439. {
  440. no warnings qw{once};
  441. *FakeTPSGI3::see_also = sub { return [ 303, [ Location => $_[1] ], [''] ] };
  442. *FakeTPSGI3::forbidden = sub { return [ 403, [], ['no'] ] };
  443. *FakeTPSGI3::notfound = sub { return [ 404, [], ['gone'] ] };
  444. }
  445. # Admin only, like every other thing that changes a guest.
  446. my $res = Trog::Routes::HTML::guest_reprovision( { user => 'bob', user_acls => ['public'], tpsgi => $tpsgi } );
  447. is( $res->[0], 403, 'a non-admin is refused' );
  448. is_deeply( \@SPAWNED, [], 'without running anything' );
  449. $res = Trog::Routes::HTML::guest_reprovision( { user => '', user_acls => [], tpsgi => $tpsgi } );
  450. is( $res->[0], 303, 'and somebody logged out is sent to log in' );
  451. # The passphrase must not survive the request: this hash gets cloned, logged
  452. # around and handed to renderers.
  453. @SPAWNED = ();
  454. my $query = { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'guest.example.com', passphrase => 'hunter2', sudo => 'letmein', to => '/vms' };
  455. Trog::Routes::HTML::guest_reprovision($query);
  456. is( scalar(@SPAWNED), 1, 'an admin gets their run' );
  457. is( $SPAWNED[0]{credentials}{keepass}, 'hunter2', 'the passphrase reaches the provisioner' );
  458. is( $SPAWNED[0]{credentials}{sudo}, 'letmein', 'and so does the sudo password' );
  459. # This hash is cloned, logged around and handed to renderers.
  460. ok( !exists $query->{$_}, "the $_ is gone from the query afterwards" ) foreach qw{passphrase sudo totp};
  461. # The log route. It lives in logs/ rather than under www/, and the output
  462. # of a provisioner is a fine place for a hostname or an IP plan to turn up.
  463. my $logroute = $Trog::Routes::HTML::routes{'/guest/reprovision/log/(.*)'};
  464. ok( $logroute, 'the log route is registered' );
  465. is( $logroute->{method}, 'GET', 'as a GET, since it only reads' );
  466. is( $logroute->{auth}, 1, 'behind a login' );
  467. is_deeply( $logroute->{captures}, ['guest'], "capturing 'guest', which survives routing" );
  468. $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'bob', user_acls => ['public'], tpsgi => $tpsgi } );
  469. is( $res->[0], 403, 'a non-admin cannot read it' );
  470. $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'never.example.com' } );
  471. is( $res->[0], 404, 'and a guest with no log is a 404 rather than an empty page' );
  472. };
  473. done_testing();