瀏覽代碼

Report what a reprovision is doing, since nothing waits on it

The process which reprovisions is deliberately orphaned so that it outlives the
worker, the request and the restart a save triggers.  That is the right shape,
but it means its exit status is reported to nobody: the page could say a
reprovision had been started and never say another word about it.

So the run now says what it is doing, in a status file beside its log, written by
rename so a page drawn mid-write sees the old status or the new one and never
half of either.  The guest's entry shows whether it is reprovisioning, or whether
the last attempt was provisioned, failed or interrupted, who asked for it, what
it exited with, and a link to read the whole log.

'Interrupted' is the case worth having.  A status still saying 'running' whose
process is gone means the run crashed, was killed, or took something down with
it -- so status() checks the pid is alive rather than believing the file, because
nothing else would ever correct it and the alternative is a page claiming a
provision is in progress forever.

While a run is going the button is not drawn and reprovision() refuses anyway, so
a second provision of one machine cannot be started by double clicking.

The log gets a route of its own: GET /guest/reprovision/log/$guest, admin only,
because it lives outside www/ and the output of a provisioner is a reasonable
place for a hostname, an IP plan or a package list to turn up.  It is capped at a
megabyte, keeping the end, since that is where the answer is.

Still nothing in the tests forks or runs a provisioner.  The status states are
driven by writing the file the run would have written, including the interrupted
case, which is checked against a pid chosen for being dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6Q1gRJ2Rr85S8FQ47EjnQ
George S. Baugh 1 月之前
父節點
當前提交
9684ffb0f3

+ 14 - 4
Readme.md

@@ -164,10 +164,20 @@ Leave those out and it degrades to a plain guest listing.
 Reprovisioning is not recoverable: it destroys the guest and builds it again, and
 whatever the recipe does not describe does not come back.  It is POST and admin
 only, it refuses outright for the guest tCMS is itself running on (as Virt already
-refuses to power that one off), and nothing it runs goes near a shell.  The
-provisioning itself is handed to a detached process and logged to
-logs/reprovision/$domain.log, whose tail is shown back on the page -- a provision
-takes minutes and an HTTP worker does not have minutes.
+refuses to power that one off), and nothing it runs goes near a shell.
+
+A provision takes minutes and an HTTP worker does not have minutes, so the work
+is handed to a detached process.  Nothing waits on it, which means it has to
+report on itself: it writes logs/reprovision/$domain.log and a status beside it,
+and the guest's entry shows which of
+
+    reprovisioning / provisioned / reprovision failed / reprovision interrupted
+
+it is, who asked for it, and a link to read the whole log.  While one is running
+the button is not offered, so a second cannot be started by double clicking.  A
+run whose status still says 'running' but whose process is gone reads as
+interrupted rather than as going on forever -- that is a guess from the only
+evidence there is, and it is the honest one.
 
 bin/new_config asks for your KeePass passphrase on stdin, because every recipe
 inherits secret: values from _base.  The form asks you for it per run and pipes it

+ 165 - 13
lib/Trog/DataSource/ProvisionedVirt.pm

@@ -5,11 +5,12 @@ use re '/aa';
 
 use parent qw{Trog::DataSource::Virt};
 
-use Cwd            ();
-use File::Basename ();
-use File::Path     ();
-use File::Slurper  ();
-use POSIX          ();
+use Cwd                 ();
+use File::Basename      ();
+use File::Path          ();
+use File::Slurper       ();
+use File::Slurper::Temp ();
+use POSIX               ();
 
 use Trog::Config     ();
 use Trog::DataSource ();
@@ -57,6 +58,14 @@ shell, so a guest name is an argument and can never be a command.
 our $recipe_dir = 'recipes.d';
 our $log_dir    = 'logs/reprovision';
 
+# What a reprovision can be, in the order it can be it.
+our %states = (
+    running     => 'reprovisioning',
+    ok          => 'provisioned',
+    failed      => 'reprovision failed',
+    interrupted => 'reprovision interrupted',
+);
+
 =head1 FUNCTIONS
 
 =head2 posts($series, $query) = @posts
@@ -64,11 +73,19 @@ our $log_dir    = 'logs/reprovision';
 The guests Trog::DataSource::Virt would have listed, each carrying what we know
 about how it was built:
 
-    recipe            the recipe's YAML, as text, for the page to show
-    recipe_path       where that came from
-    has_recipe        whether there was one at all
-    can_reprovision   whether the button should be drawn
-    reprovision_log   the tail of the last run, when there has been one
+    recipe                the recipe's YAML, as text, for the page to show
+    recipe_path           where that came from
+    has_recipe            whether there was one at all
+    can_reprovision       whether the button should be drawn
+    reprovision_state     running, ok, failed, interrupted, or absent
+    reprovision_status    that, in words a person reads
+    reprovision_started   when the last run began
+    reprovision_finished  when it ended, if it has
+    reprovision_exit      what it exited with, if it has
+    reprovision_by        who asked for it
+    is_reprovisioning     whether one is going on right now
+    reprovision_log       the tail of the last run
+    reprovision_log_href  where to read all of it
 
 A guest with no recipe is still listed.  Plenty of guests on a hypervisor were
 not built by this, and saying nothing about them is better than hiding them.
@@ -106,11 +123,97 @@ sub _with_recipe ( $post, $config ) {
     $post->{can_reprovision} =
       ( $post->{has_recipe} && $config->{provisioners} && $config->{trog_provisioner} && !$post->{is_self} ) ? 1 : 0;
 
-    $post->{reprovision_log} = _log_tail($domain);
+    my $status = status($domain);
+    if ($status) {
+        $post->{reprovision_state}    = $status->{state};
+        $post->{reprovision_status}   = $states{ $status->{state} } // $status->{state};
+        $post->{reprovision_started}  = $status->{started};
+        $post->{reprovision_finished} = $status->{finished};
+        $post->{reprovision_exit}     = $status->{exit};
+        $post->{reprovision_by}       = $status->{user};
+        $post->{is_reprovisioning}    = $status->{state} eq 'running' ? 1 : 0;
+    }
+
+    # One at a time.  Two provisions of the same machine at once is not a thing
+    # anybody wants to have started by double clicking.
+    $post->{can_reprovision} = 0 if $post->{is_reprovisioning};
+
+    $post->{reprovision_log}      = _log_tail($domain);
+    $post->{reprovision_log_href} = $post->{reprovision_log} ? "/guest/reprovision/log/$domain" : undef;
 
     return $post;
 }
 
+=head2 status($domain) = HASHREF or undef
+
+What the last reprovision of this guest is doing, or did.
+
+    state     running, ok, failed or interrupted
+    pid       the process doing it, while one is
+    started   / finished / exit / user
+
+Nothing waits on the process which does the work -- it is deliberately orphaned,
+so that it outlives the worker that started it -- which means its exit status is
+not reported to anybody.  This file is how it says what happened, and the last
+thing the run does is write it.
+
+A run whose status still says 'running' but whose process is gone therefore
+crashed, was killed, or took the machine down with it, and reads as interrupted.
+That is a guess from the one piece of evidence there is, and it is the honest
+one: the alternative is a page that says a provision is in progress forever.
+
+=cut
+
+sub status ($domain) {
+    my $safe = _safe_domain($domain);
+    return undef unless $safe;
+
+    my $path = "$log_dir/$safe.status";
+    return undef unless -f $path;    ## no critic (ProhibitFiletest_f) -- has this ever run
+
+    my $raw = eval { File::Slurper::read_text($path) };
+    return undef unless defined $raw;
+
+    my %status;
+    foreach my $line ( split( "\n", $raw ) ) {
+        my ( $key, $value ) = $line =~ m/^(\w+)=(.*)$/;
+        $status{$key} = $value if defined $key;
+    }
+    return undef unless $status{state};
+
+    # kill 0 rather than trusting the file: the process is orphaned, so nothing
+    # else would ever correct a 'running' that stopped being true.  Same uid, so
+    # this is a real answer.  A recycled pid could fool it, which would show a
+    # finished run as still going rather than the other way about.
+    if ( $status{state} eq 'running' ) {
+        my $alive = $status{pid} && kill( 0, $status{pid} );
+        $status{state} = 'interrupted' unless $alive;
+    }
+
+    return \%status;
+}
+
+# Written by the run itself, at the two moments worth recording.  By rename, so
+# that a page drawn while the run is writing sees the old status or the new one
+# and never half of either -- the reader would ignore a torn file, but ignoring
+# it means the page says nothing was ever run.
+sub _write_status ( $domain, %fields ) {
+    my $path = "$log_dir/$domain.status";
+    my $out  = join( '', map { "$_=" . ( $fields{$_} // '' ) . "\n" } sort keys %fields );
+
+    local $@;
+    eval {
+        # Beside the file it replaces, or the rename is a copy across
+        # filesystems and stops being atomic.
+        local $File::Slurper::Temp::FILE_TEMP_DIR   = $log_dir;
+        local $File::Slurper::Temp::FILE_TEMP_PERMS = oct('644');
+        File::Slurper::Temp::write_text( $path, $out );
+        1;
+    } or WARN("Could not record the reprovision status for '$domain': $@");
+
+    return;
+}
+
 =head2 _config() = HASHREF
 
 Where the two repositories are, from the tCMS configuration.
@@ -186,6 +289,34 @@ sub _log_tail ($domain) {
     return length($text) > $log_tail_bytes ? substr( $text, -$log_tail_bytes ) : $text;
 }
 
+=head2 log_for($domain) = STRING or undef
+
+The whole reprovision log for a guest, for the route which serves it.
+
+Undef when there has never been a run, or when the name is not a guest name --
+this reaches the filesystem, so it is checked here rather than trusted from the
+route that called it.
+
+=cut
+
+our $log_max_bytes = 1024 * 1024;
+
+sub log_for ($domain) {
+    my $safe = _safe_domain($domain);
+    return undef unless $safe;
+
+    my $path = "$log_dir/$safe.log";
+    return undef unless -f $path;    ## no critic (ProhibitFiletest_f) -- has this ever run
+
+    my $text = eval { File::Slurper::read_text($path) };
+    return undef unless defined $text;
+
+    # A provisioner can be chatty and this goes out in one response.
+    return length($text) > $log_max_bytes
+      ? "[ truncated: showing the last " . int( $log_max_bytes / 1024 ) . "KB ]\n" . substr( $text, -$log_max_bytes )
+      : $text;
+}
+
 =head2 reprovision(%args) = ($ok, $message)
 
 Run the full lifecycle for one guest: bin/new_config in provisioners, and then
@@ -229,6 +360,11 @@ sub reprovision (%args) {
 
     return ( 0, 'a passphrase is required, as new_config asks for one' ) unless length( $args{passphrase} // '' );
 
+    # One at a time.  Two provisions of one machine racing each other is not
+    # something anybody should be able to start by double clicking.
+    my $running = status($safe);
+    return ( 0, 'a reprovision of this guest is already running' ) if $running && $running->{state} eq 'running';
+
     my @lifecycle = (
         [ $config->{provisioners},     "$config->{provisioners}/bin/new_config",    $safe ],
         [ $config->{trog_provisioner}, "$config->{trog_provisioner}/bin/provision", $safe ],
@@ -283,7 +419,13 @@ sub _spawn ( $domain, $passphrase, $lifecycle, $user ) {
     open( my $fh, '>>', $log ) or POSIX::_exit(1);
     $fh->autoflush(1);
 
-    print {$fh} "\n=== reprovision of $domain by " . ( $user // 'somebody' ) . ' at ' . localtime() . " ===\n";
+    my $started = time();
+    print {$fh} "\n=== reprovision of $domain by " . ( $user // 'somebody' ) . ' at ' . localtime($started) . " ===\n";
+
+    # $$ is this process, which is the one that will still be here in ten
+    # minutes -- the two before it have already gone.  status() checks it is
+    # alive, so it has to be the pid of whatever is actually doing the work.
+    _write_status( $domain, state => 'running', pid => $$, started => $started, user => ( $user // '' ) );
 
     foreach my $step (@$lifecycle) {
         my ( $dir, @command ) = @$step;
@@ -317,12 +459,22 @@ sub _spawn ( $domain, $passphrase, $lifecycle, $user ) {
 
         if ($?) {
             my $status = $? >> 8;
-            print {$fh} "\n--- $command[0] exited $status; stopping here\n";
+            print {$fh} "\n--- $command[0] exited $status, stopping here\n";
+            _write_status(
+                $domain,
+                state    => 'failed',
+                started  => $started,
+                finished => time(),
+                exit     => $status,
+                failed   => $command[0],
+                user     => ( $user // '' ),
+            );
             POSIX::_exit($status);
         }
     }
 
     print {$fh} "\n=== finished ===\n";
+    _write_status( $domain, state => 'ok', started => $started, finished => time(), exit => 0, user => ( $user // '' ) );
     POSIX::_exit(0);
 }
 

+ 44 - 0
lib/Trog/Routes/HTML.pm

@@ -185,6 +185,19 @@ our %routes = (
         noindex  => 1,
         nocache  => 1,
     },
+    '/guest/reprovision/log/(.*)' => {
+        method   => 'GET',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::guest_reprovision_log,
+
+        # 'guest', not 'domain', for the reason the screenshot route captures it
+        # that way -- the router overwrites $query->{domain} with the request's
+        # own host after applying captures.
+        captures   => [qw{guest}],
+        robot_name => '/guest/reprovision/log/',
+        noindex    => 1,
+        nocache    => 1,
+    },
     '/admin/wyzzerdd' => {
         method   => 'GET',
         auth     => 1,
@@ -2352,6 +2365,37 @@ sub guest_reprovision ($query) {
     return _feedback_redirect( $query, $to, $ok ? 0 : 1, ( $query->{guest} // 'guest' ) . ": $message" );
 }
 
+=head2 guest_reprovision_log
+
+Implements GET /guest/reprovision/log/$guest.  Admin only.
+
+The whole log of a guest's last reprovision, which the listing shows only the
+tail of.  Nobody is waiting on the process which writes it, so this is the only
+account of what happened there is.
+
+Admin only and served rather than linked directly, because it lives in logs/
+rather than under www/ -- and because the output of a provisioner is a
+reasonable place for a hostname, an IP plan or a package list to turn up.
+
+=cut
+
+sub guest_reprovision_log ($query) {
+    return $query->{tpsgi}->see_also('/login') unless $query->{user};
+    return $query->{tpsgi}->forbidden($query)  unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
+
+    require Trog::DataSource::ProvisionedVirt;
+
+    my $log = Trog::DataSource::ProvisionedVirt::log_for( $query->{guest} );
+    return $query->{tpsgi}->notfound($query) unless defined $log;
+
+    return Trog::Renderer->render(
+        template    => 'reprovision_log.tx',
+        contenttype => 'text/plain',
+        code        => 200,
+        data        => { %$query, body => $log },
+    );
+}
+
 # The hypervisor post a guest route was asked about.  Looked up rather than
 # taken from the request: the connection URI is not something a form gets to
 # hand us.

+ 133 - 0
t/Trog-DataSource-ProvisionedVirt.t

@@ -251,6 +251,124 @@ subtest 'the log is read back onto the page' => sub {
     is( Trog::DataSource::ProvisionedVirt::_log_tail('../../etc/passwd'),     undef, 'and a name that is not one reads nothing' );
 };
 
+subtest 'the state a reprovision leaves behind' => sub {
+    no warnings qw{once};
+    File::Path::make_path("$ROOT/logs/reprovision");
+
+    my $status = "$ROOT/logs/reprovision/guest.example.com.status";
+    my $write  = sub {
+        open( my $fh, '>', $status ) or die $!;
+        print {$fh} join( '', map { "$_\n" } @_ );
+        close $fh;
+    };
+
+    # Nothing waits on the process which does the work, so this file is the
+    # whole of what it can say for itself.
+    $write->( 'state=ok', 'started=100', 'finished=200', 'exit=0', 'user=admin' );
+    my $got = Trog::DataSource::ProvisionedVirt::status('guest.example.com');
+    is( $got->{state},    'ok',    'a finished run reads back as finished' );
+    is( $got->{exit},     '0',     'with what it exited' );
+    is( $got->{user},     'admin', 'and who asked for it' );
+    is( $got->{finished}, '200',   'and when it stopped' );
+
+    $write->( 'state=failed', 'exit=3', 'failed=/x/bin/provision' );
+    is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'failed', 'a failed one reads back failed' );
+
+    # A live pid means it really is going on.  Ours is alive by definition.
+    $write->( 'state=running', "pid=$$", 'started=100', 'user=admin' );
+    is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'running', 'a run whose process is alive is running' );
+
+    # And a dead one means it stopped without saying so, which is the case that
+    # would otherwise leave the page claiming a provision forever.
+    my $dead = 999999;
+    $dead++ while kill( 0, $dead ) && $dead < 4194304;
+    $write->( 'state=running', "pid=$dead", 'started=100' );
+    is(
+        Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state},
+        'interrupted',
+        'a run whose process is gone reads as interrupted, not as still going'
+    );
+
+    $write->('state=running');
+    is( Trog::DataSource::ProvisionedVirt::status('guest.example.com')->{state}, 'interrupted', 'and so does one with no pid at all' );
+
+    # Half a file is not a status.
+    $write->('garbage');
+    is( Trog::DataSource::ProvisionedVirt::status('guest.example.com'), undef, 'an unparseable status is no status' );
+
+    is( Trog::DataSource::ProvisionedVirt::status('never.example.com'), undef, 'nothing for a guest that has never run' );
+    is( Trog::DataSource::ProvisionedVirt::status('../../etc/passwd'),  undef, 'and a name that is not one reads nothing' );
+
+    unlink($status);
+};
+
+subtest 'the listing says what is going on' => sub {
+    no warnings qw{once};
+    File::Path::make_path("$ROOT/logs/reprovision");
+
+    my $status = "$ROOT/logs/reprovision/guest.example.com.status";
+    open( my $log, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
+    print {$log} "some output\n";
+    close $log;
+
+    $virt->redefine( posts => sub { return ( { title => 'guest.example.com', domain => 'guest.example.com', is_self => 0 } ) } );
+
+    open( my $fh, '>', $status ) or die $!;
+    print {$fh} "state=running\npid=$$\nstarted=100\nuser=admin\n";
+    close $fh;
+
+    my ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
+    is( $post->{reprovision_state},    'running',                                  'the guest says it is reprovisioning' );
+    is( $post->{reprovision_status},   'reprovisioning',                           'in words a person reads' );
+    is( $post->{is_reprovisioning},    1,                                          'and is flagged as busy' );
+    is( $post->{reprovision_by},       'admin',                                    'naming who started it' );
+    is( $post->{can_reprovision},      0,                                          'and offers no button while one is going on' );
+    is( $post->{reprovision_log_href}, '/guest/reprovision/log/guest.example.com', 'with a link to read the log' );
+
+    # Starting a second one is refused for the same reason.
+    my ( $ok, $why ) = reprovision( domain => 'guest.example.com' );
+    is( $ok, 0, 'and a second run is refused while the first is going' );
+    like( $why, qr/already running/, 'saying so' );
+    is_deeply( \@SPAWNED, [], 'having started nothing' );
+
+    open( $fh, '>', $status ) or die $!;
+    print {$fh} "state=failed\nexit=3\nstarted=100\nfinished=200\nuser=admin\n";
+    close $fh;
+
+    ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
+    is( $post->{reprovision_state},  'failed',             'a failed run says so' );
+    is( $post->{reprovision_status}, 'reprovision failed', 'in words' );
+    is( $post->{reprovision_exit},   '3',                  'with what it exited' );
+    is( $post->{is_reprovisioning},  0,                    'and is not busy' );
+    is( $post->{can_reprovision},    1,                    'so it may be tried again' );
+
+    unlink($status);
+    ($post) = Trog::DataSource::ProvisionedVirt::posts( {}, {} );
+    ok( !$post->{reprovision_state}, 'a guest which has never been reprovisioned says nothing about it' );
+    is( $post->{can_reprovision}, 1, 'and may be' );
+};
+
+subtest 'reading the log' => sub {
+    no warnings qw{once};
+
+    is( Trog::DataSource::ProvisionedVirt::log_for('never.example.com'), undef, 'nothing when there has been no run' );
+    is( Trog::DataSource::ProvisionedVirt::log_for('../../etc/passwd'),  undef, 'and a name that is not one reads nothing' );
+
+    open( my $fh, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
+    print {$fh} "the whole log\n";
+    close $fh;
+    like( Trog::DataSource::ProvisionedVirt::log_for('guest.example.com'), qr/the whole log/, 'and the whole of it when there has' );
+
+    # A provisioner can be chatty, and this goes out in one response.
+    local $Trog::DataSource::ProvisionedVirt::log_max_bytes = 64;
+    open( $fh, '>', "$ROOT/logs/reprovision/guest.example.com.log" ) or die $!;
+    print {$fh} ( 'x' x 500 ) . "END\n";
+    close $fh;
+    my $capped = Trog::DataSource::ProvisionedVirt::log_for('guest.example.com');
+    like( $capped, qr/truncated/, 'a huge one is truncated' );
+    like( $capped, qr/END/,       'keeping the end, which is where the answer is' );
+};
+
 subtest 'the route' => sub {
     no warnings qw{once};
     require Trog::Routes::HTML;
@@ -267,6 +385,7 @@ subtest 'the route' => sub {
         no warnings qw{once};
         *FakeTPSGI3::see_also  = sub { return [ 303, [ Location => $_[1] ], [''] ] };
         *FakeTPSGI3::forbidden = sub { return [ 403, [], ['no'] ] };
+        *FakeTPSGI3::notfound  = sub { return [ 404, [], ['gone'] ] };
     }
 
     # Admin only, like every other thing that changes a guest.
@@ -285,6 +404,20 @@ subtest 'the route' => sub {
     is( scalar(@SPAWNED),        1,         'an admin gets their run' );
     is( $SPAWNED[0]{passphrase}, 'hunter2', 'the passphrase reaches the lifecycle' );
     ok( !exists $query->{passphrase}, 'and is gone from the query afterwards' );
+
+    # The log route.  It lives in logs/ rather than under www/, and the output
+    # of a provisioner is a fine place for a hostname or an IP plan to turn up.
+    my $logroute = $Trog::Routes::HTML::routes{'/guest/reprovision/log/(.*)'};
+    ok( $logroute, 'the log route is registered' );
+    is( $logroute->{method}, 'GET', 'as a GET, since it only reads' );
+    is( $logroute->{auth},   1,     'behind a login' );
+    is_deeply( $logroute->{captures}, ['guest'], "capturing 'guest', which survives routing" );
+
+    $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'bob', user_acls => ['public'], tpsgi => $tpsgi } );
+    is( $res->[0], 403, 'a non-admin cannot read it' );
+
+    $res = Trog::Routes::HTML::guest_reprovision_log( { user => 'admin', user_acls => ['admin'], tpsgi => $tpsgi, guest => 'never.example.com' } );
+    is( $res->[0], 404, 'and a guest with no log is a 404 rather than an empty page' );
 };
 
 done_testing();

+ 30 - 1
www/templates/html/components/forms/provisioned_guests.tx

@@ -16,6 +16,14 @@
                 : if ( !$post.has_recipe ) {
                 <span class="tag guest-state" title="no recipes.d entry for this guest">unprovisioned</span>
                 : }
+                : # Nothing waits on the process which reprovisions, so its own
+                : # status file is the only account of it there is.
+                : if ( $post.reprovision_state ) {
+                <span class="tag guest-state reprovision-<: $post.reprovision_state :>"
+                      title="<: $post.reprovision_by ? 'asked for by ' ~ $post.reprovision_by : '' :>">
+                    <: $post.reprovision_status :>
+                </span>
+                : }
             </h3>
             <div class="postData responsive-text" id="postData-<: $post.id :>">
                 : # Declared private in the sidecar, so a reader who cannot edit
@@ -37,8 +45,16 @@
 
                 : if ( $can_edit && $post.reprovision_log ) {
                 <details class="guest-reprovision-log">
-                    <summary>Last reprovision</summary>
+                    <summary>
+                        Last reprovision &mdash; <: $post.reprovision_status :>
+                        : if ( $post.reprovision_exit ) {
+                        (exit <: $post.reprovision_exit :>)
+                        : }
+                    </summary>
                     <pre class="guest-recipe-body"><: $post.reprovision_log :></pre>
+                    : if ( $post.reprovision_log_href ) {
+                    <a href="<: $post.reprovision_log_href :>" class="coolbutton" target="_blank" rel="noopener">Read the whole log</a>
+                    : }
                 </details>
                 : }
 
@@ -67,6 +83,19 @@
 
                 : # Separate form, because this one carries a passphrase and has
                 : # no business sharing a submit with the buttons above.
+                : # can_reprovision is false while one is running, so this is
+                : # the only thing drawn for a guest mid-provision.
+                : if ( $post.is_reprovisioning ) {
+                <p class="guest-reprovisioning">
+                    Reprovisioning now
+                    : if ( $post.reprovision_by ) {
+                    &mdash; started by <: $post.reprovision_by :>
+                    : }
+                    : if ( $post.reprovision_log_href ) {
+                    &mdash; <a href="<: $post.reprovision_log_href :>" target="_blank" rel="noopener">watch the log</a>
+                    : }
+                </p>
+                : }
                 : if ( $post.can_reprovision ) {
                 <form class="Submissions guest-reprovision" action="/guest/reprovision" method="POST" autocomplete="off">
                     <input type="hidden" name="guest" value="<: $post.domain :>" />

+ 1 - 0
www/templates/text/reprovision_log.tx

@@ -0,0 +1 @@
+<: $body | mark_raw :>