| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564 |
- package Trog::DataSource::ProvisionedVirt;
- use v5.36;
- use re '/aa';
- use parent qw{Trog::DataSource::Virt};
- use Cwd ();
- use File::Basename ();
- use File::Path ();
- use File::Slurper ();
- use File::Slurper::Temp ();
- use POSIX ();
- use Trog::Config ();
- use Trog::DataSource ();
- use Trog::DataSource::Virt();
- use Trog::Auth ();
- use Trog::Vault ();
- use Trog::Log qw{WARN INFO};
- =head1 Trog::DataSource::ProvisionedVirt
- Trog::DataSource::Virt, plus the recipe each guest was built from and a button to
- build it again.
- Everything about listing guests is inherited unchanged -- this is the same
- libvirt connection, the same posts, the same search and the same refusal to be
- cached. What it adds is the other half of the story: a guest on a Troglodyne
- hypervisor was provisioned from a recipe, and the recipe is on disk next to the
- hypervisor rather than anywhere libvirt can tell you about.
- One repository owns that lifecycle. trog-provisioner's bin/provision generates
- a guest's configuration from its recipe and then builds the machine out of it.
- The generator used to be a second repository and a second run; the two had to
- become one program once which hypervisor a guest lands on became a choice rather
- than whichever machine you happened to be sat at.
- Say where that checkout is and this becomes useful; leave it out and it degrades
- to plain Virt, which is what it is.
- [provisioner]
- trog_provisioner = /home/you/Code/trog-provisioner
- The recipes are not in the checkout. They describe an installation rather than
- the software, so trog-provisioner keeps them in /etc/trog-provisioner, or
- wherever TROG_PROVISIONER_CONFIG says instead. We find them by that same rule,
- which is one fewer thing to tell tCMS and one fewer thing to disagree about.
- =head1 A NOTE ON SAFETY
- Reprovisioning is not a recoverable operation. It destroys the guest and builds
- it again from its recipe, and whatever was on it that the recipe does not
- describe does not come back.
- The discipline is the same as its parent's, and for the same reasons. It is
- POST only and admin only; it refuses outright for the guest tCMS is itself
- running on, whatever the template chose to draw; and nothing it runs goes near a
- shell, so a guest name is an argument and can never be a command.
- =cut
- our $recipe_dir = 'recipes.d';
- our $log_dir = 'logs/reprovision';
- # What a stored provisioning passphrase is called in a user's vault. One name
- # per person rather than one per guest: it is the passphrase to their secrets
- # database, and there is one of those.
- our $secret_name = 'provisioner';
- # Where an installation's own files live, which is trog-provisioner's rule
- # rather than ours -- see its Trog::Config.
- our $provisioner_config = '/etc/trog-provisioner';
- # 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
- 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_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.
- =cut
- sub posts ( $series, $query ) {
- my @guests = Trog::DataSource::Virt::posts( $series, $query );
- my $config = _config();
- # Asked once for the page rather than once per guest, and asked rather than
- # opened: what the form needs to know is which box to draw.
- my $user = $query->{user} // '';
- $config->{can_remember} = ( $user && Trog::Vault::has_key() ) ? 1 : 0;
- $config->{remembered} = ( $config->{can_remember} && Trog::Vault::has( $user, $secret_name ) ) ? 1 : 0;
- return map { _with_recipe( $_, $config ) } @guests;
- }
- sub _with_recipe ( $post, $config ) {
- return $post if $post->{unreachable};
- my $domain = $post->{domain} // '';
- my $path = _recipe_path( $config, $domain );
- if ($path) {
- my $recipe = eval { File::Slurper::read_text($path) };
- if ( defined $recipe ) {
- $post->{recipe} = $recipe;
- $post->{recipe_path} = $path;
- $post->{has_recipe} = 1;
- }
- else {
- WARN("Could not read the recipe for '$domain' at $path: $@");
- }
- }
- # There has to be something to run it with, and we will not rebuild the
- # machine we are answering the request from -- see act() in the parent,
- # which refuses to power that one off for the same reason.
- $post->{can_reprovision} =
- ( $post->{has_recipe} && $config->{trog_provisioner} && !$post->{is_self} ) ? 1 : 0;
- # Which of the two things the button has to ask for.
- $post->{passphrase_remembered} = $config->{remembered};
- $post->{can_remember} = $config->{can_remember};
- 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 provisioner is checked out, from the tCMS configuration, and where the
- recipes it reads are, from trog-provisioner's own rule for that.
- trog_provisioner the checkout, or undef
- recipes the directory the guests' recipes are in
- Undef for the checkout means the feature is off rather than broken: a site which
- has not configured it gets a plain guest listing, which is what it asked for.
- =cut
- sub _config {
- my $conf = Trog::Config::get();
- # Nothing to configure here: the provisioner reads this env var or that
- # directory, so a recipe we showed from anywhere else would be a recipe it
- # is not going to build from.
- my %config = ( recipes => ( $ENV{TROG_PROVISIONER_CONFIG} // $provisioner_config ) . "/$recipe_dir" );
- my $dir = $conf->param('provisioner.trog_provisioner');
- return \%config unless $dir && !ref $dir;
- # Resolved, and checked to be there. A path in a config file which does not
- # exist is a typo, and finding that out here beats finding it out halfway
- # through a reprovision.
- my $absolute = Cwd::abs_path($dir);
- if ( !$absolute || !-d $absolute ) {
- WARN("provisioner.trog_provisioner is set to '$dir', which is not a directory here");
- return \%config;
- }
- $config{trog_provisioner} = $absolute;
- return \%config;
- }
- # A guest name reaches the filesystem here, so it is a hostname or it is nothing.
- sub _safe_domain ($domain) {
- return undef unless defined $domain && length($domain) && length($domain) < 254;
- return undef if $domain =~ m/(?:^|\.)\.\.?(?:\.|$)/;
- my ($safe) = $domain =~ m/^([A-Za-z0-9][A-Za-z0-9._-]*)$/;
- return $safe;
- }
- # An installation's recipes are where they are whether tCMS knows about the
- # provisioner or not, but reading them is the feature, so it is off with it.
- sub _recipe_path ( $config, $domain ) {
- return undef unless $config->{trog_provisioner};
- my $safe = _safe_domain($domain);
- return undef unless $safe;
- my $path = "$config->{recipes}/$safe.yaml";
- return -f $path ? $path : undef; ## no critic (ProhibitFiletest_f) -- is there a recipe for this guest
- }
- =head2 _log_tail($domain) = STRING
- The last of whatever the previous reprovision said, or nothing.
- The run happens in a detached process which nobody is waiting on, so this file
- is the only account of it there is.
- =cut
- our $log_tail_bytes = 4096;
- sub _log_tail ($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;
- 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 bin/provision for one guest, which generates its configuration from its
- recipe and then builds the machine.
- domain which guest. Required, and validated as a hostname.
- user who asked. For the log, and whose vault to look in.
- passphrase the KeePass passphrase the recipe's secrets need, on stdin.
- totp a code, instead of the passphrase, when they have stored one.
- remember store the passphrase they just typed, so that next time is a code.
- Returns immediately. Provisioning a machine takes minutes and an HTTP worker
- does not have minutes, so the work is handed to a detached process and the
- caller is told it started rather than told it finished. What happened is in
- logs/reprovision/$domain.log, which is what _log_tail reads back onto the page.
- =head3 WHY IT ASKS FOR ANYTHING AT ALL
- Every recipe inherits secret: values from _base, so the provisioner opens a
- KeePass database on its way past and wants the passphrase to it on stdin. The
- alternative to asking is keeping the master password for every secret an
- operator holds in a file the webserver can read, which is worse than asking.
- Asking every time has its own cost, though: a passphrase somebody has to have to
- hand is a passphrase that gets written down, and then it is in a text file
- instead of a config file, which is not an improvement. So it can be stored --
- see Trog::Vault, where it is sealed under a key the database does not contain --
- and then what this asks for is a TOTP code, which proves the person is here
- without their having to be holding anything.
- The code is spent either way, so it authorizes this one reprovision rather than
- every reprovision inside its window.
- Whichever it arrives as, the passphrase goes down the child's stdin and is
- written nowhere: not the log, not the process table, not the configuration.
- =cut
- sub reprovision (%args) {
- my $safe = _safe_domain( $args{domain} );
- return ( 0, 'bad guest name' ) unless $safe;
- # The template does not draw the button for this guest. This is what
- # actually stops it: /guest/reprovision is a POST anybody holding the admin
- # acl can craft by hand, and there would be nothing left to report to.
- if ( Trog::DataSource::Virt::_is_self($safe) ) {
- WARN("Refused to reprovision '$safe': that is this server");
- return ( 0, "'$safe' is this server -- refusing to reprovision it" );
- }
- my $config = _config();
- return ( 0, 'provisioner.trog_provisioner is not configured' ) unless $config->{trog_provisioner};
- my $recipe = _recipe_path( $config, $safe );
- return ( 0, "there is no recipe for '$safe' in $config->{recipes}" ) unless $recipe;
- my ( $passphrase, $why ) = _passphrase(%args);
- return ( 0, $why ) unless defined $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 @command = ( "$config->{trog_provisioner}/bin/provision", $safe );
- return ( 0, "$command[0] is not there to run" ) unless -x $command[0]; ## no critic (ProhibitFiletest_rwxRWX) -- refusing early beats finding out mid-provision
- INFO( "Reprovision of '$safe' requested by " . ( $args{user} // 'somebody' ) );
- my ( $ok, $failed ) = _spawn( $safe, $passphrase, $config->{trog_provisioner}, \@command, $args{user} );
- return ( 0, $failed ) unless $ok;
- return ( 1, "reprovision started; watch $log_dir/$safe.log" );
- }
- =head2 _passphrase(%args) = ($passphrase, $why)
- The passphrase to hand the provisioner, out of whichever of the two things the
- form sent.
- A code is only ever exchanged for a passphrase that is already stored -- it is
- proof of presence, not a password, and there is nothing to derive from it. A
- typed passphrase is used as it is, and stored on the way past if they asked for
- it to be, so that the next run is a code.
- Undef with a reason for anything else, and the reason is what the person sees:
- this is the message that has to distinguish 'that code is stale' from 'there is
- nothing stored to unlock'.
- =cut
- sub _passphrase (%args) {
- my $user = $args{user} // '';
- if ( length( $args{totp} // '' ) ) {
- return ( undef, 'a code identifies somebody, and nobody is logged in' ) unless $user;
- my ( $ok, $why ) = Trog::Auth::spend_totp( $user, $args{totp} );
- return ( undef, $why ) unless $ok;
- my $stored = Trog::Vault::get( $user, $secret_name );
- return ( undef, "there is no provisioning passphrase stored for $user" ) unless defined $stored && length($stored);
- return ( $stored, '' );
- }
- return ( undef, 'a passphrase is required, as the provisioner asks for one' ) unless length( $args{passphrase} // '' );
- if ( $args{remember} && $user ) {
- my ( $ok, $why ) = Trog::Vault::set( $user, $secret_name, $args{passphrase} );
- # Said rather than refused: they asked for a reprovision and gave us
- # what it needs, and failing to file the passphrase away is not a
- # reason not to do the thing they asked for.
- WARN("Could not remember the provisioning passphrase for $user: $why") unless $ok;
- }
- return ( $args{passphrase}, '' );
- }
- =head2 _spawn($domain, $passphrase, $dir, $command, $user)
- Fork the provisioner off where nothing is waiting for it.
- Double forked and setsid'd on purpose: a save signals the parent to reload the
- routing table, and a provision which died because a worker was recycled halfway
- through would leave a half-built machine. This has to outlive the request, the
- worker, and a restart.
- It runs through open() with a list, so there is no shell between us and the
- program, and the guest name is an argument rather than a thing that could be
- read as one.
- =cut
- sub _spawn ( $domain, $passphrase, $dir, $command, $user ) {
- my $log = "$log_dir/$domain.log";
- local $@;
- eval { File::Path::make_path($log_dir); 1 } or return ( 0, "could not make $log_dir: $@" );
- my $pid = fork();
- return ( 0, "could not fork: $!" ) unless defined $pid;
- return ( 1, 'started' ) if $pid;
- # First child: detach, fork again, and let the middle process go so that
- # nothing is left for the worker to reap or be blocked by.
- POSIX::setsid();
- my $grandchild = fork();
- POSIX::_exit(0) if !defined $grandchild || $grandchild;
- # From here on we are nobody's child and nothing reads our exit status, so
- # the log is the only way to say anything.
- open( my $fh, '>>', $log ) or POSIX::_exit(1);
- $fh->autoflush(1);
- 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 // '' ) );
- print {$fh} "\n--- $command->[0] $command->[1] (in $dir)\n";
- # In the checkout, so that anything the provisioner writes relative to where
- # it was run lands there rather than in the middle of the website.
- if ( !chdir($dir) ) {
- print {$fh} "could not chdir to $dir: $!\n";
- POSIX::_exit(1);
- }
- # The child's own stdout and stderr are the log, so whatever the program
- # says lands there without a shell redirect to get wrong.
- open( STDOUT, '>&', $fh ) or POSIX::_exit(1);
- open( STDERR, '>&', $fh ) or POSIX::_exit(1);
- # List form on purpose, and the reason the whole thing is shaped this way: a
- # piped open with a list never involves a shell, so the guest name is an
- # argument and cannot be read as a command. The single argument form of
- # this would be the bug.
- my $ok = open( my $stdin, '|-', @$command ); ## no critic (ProhibitPipeOpen) -- list form, so no shell
- if ( !$ok ) {
- print {$fh} "could not run $command->[0]: $!\n";
- POSIX::_exit(1);
- }
- # What the provisioner prompts for on its way to the secrets database.
- # Closing this instead would make anything that asks hang forever.
- print {$stdin} "$passphrase\n";
- close($stdin);
- if ($?) {
- my $status = $? >> 8;
- print {$fh} "\n--- $command->[0] exited $status\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);
- }
- 1;
|