Virt.pm 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. package Trog::DataSource::Virt;
  2. use strict;
  3. use warnings;
  4. no warnings 'experimental';
  5. use feature qw{signatures state};
  6. use File::Path();
  7. use Sys::Virt();
  8. use Trog::Log qw{WARN INFO};
  9. =head1 Trog::DataSource::Virt
  10. Builds posts out of libvirt guests rather than out of the datastore.
  11. A post type says it wants this by naming it in its sidecar:
  12. "x-tcms-datasource": "Trog::DataSource::Virt"
  13. A series of that type then lists the guests of whichever hypervisor posts its
  14. relations pulled in, instead of listing posts somebody wrote. Nothing here is
  15. ever written to the datastore -- the guests are the source of truth, and the
  16. posts are rebuilt from them on every view.
  17. =head2 A NOTE ON SAFETY
  18. libvirt forbids virDomainScreenshot on a read-only connection, so the read path
  19. cannot protect itself by connecting read-only -- it holds a connection which is
  20. perfectly capable of destroying a guest.
  21. The discipline is therefore in the code: posts() and screenshot() call nothing
  22. but accessors, and every operation that changes a guest lives in act(), which
  23. is reached only from an explicit admin-gated POST. Keep it that way.
  24. =cut
  25. # How long a cached screenshot is considered fresh. These are console
  26. # captures, so they change slowly and are cheap to refetch.
  27. our $screenshot_ttl = 30;
  28. our $screenshot_dir = 'www/assets/guests';
  29. # Reconnecting per guest would be absurd -- one ssh handshake per hypervisor
  30. # per process is enough. Not a state var: a connection that has gone away
  31. # should be retried rather than remembered.
  32. sub _connect ( $uri, $cache = {} ) {
  33. return $cache->{$uri} if $cache->{$uri};
  34. local $@;
  35. my $conn = eval { Sys::Virt->new( uri => $uri ) };
  36. if ( !$conn ) {
  37. my $why = "$@";
  38. $why =~ s/\n.*//s;
  39. WARN("Could not connect to hypervisor '$uri': $why");
  40. return ( undef, $why );
  41. }
  42. $cache->{$uri} = $conn;
  43. return $conn;
  44. }
  45. # libvirt's numeric domain states, in the order the constants define them.
  46. my @states = (
  47. 'no state', 'running', 'blocked', 'paused',
  48. 'shutting down', 'shut off', 'crashed', 'suspended',
  49. );
  50. sub _state_name ($state) {
  51. return $states[$state] // 'unknown';
  52. }
  53. =head2 posts($series, $query) = @posts
  54. One post per guest, across every hypervisor the series' relations pulled in.
  55. A hypervisor we cannot reach becomes a single post saying so, rather than
  56. vanishing -- a page that silently omits half your infrastructure is worse than
  57. one that tells you the connection is broken.
  58. =cut
  59. sub posts ( $series, $query ) {
  60. my $hypervisors = $series->{ $series->{'x-tcms-datasource-relation'} // 'hypervisors' };
  61. return () unless ref $hypervisors eq 'ARRAY';
  62. my $form = $series->{child_form} // '';
  63. my %cache;
  64. my @out;
  65. foreach my $hypervisor (@$hypervisors) {
  66. my $uri = $hypervisor->{conn_uri};
  67. next unless $uri;
  68. my ( $conn, $why ) = _connect( $uri, \%cache );
  69. if ( !$conn ) {
  70. push( @out, _unreachable_post( $hypervisor, $series, $form, $why ) );
  71. next;
  72. }
  73. local $@;
  74. my @domains = eval { $conn->list_all_domains() };
  75. if ($@) {
  76. my $err = "$@";
  77. $err =~ s/\n.*//s;
  78. push( @out, _unreachable_post( $hypervisor, $series, $form, $err ) );
  79. next;
  80. }
  81. push( @out, map { _post_for( $_, $hypervisor, $series, $form ) } @domains );
  82. }
  83. return @out;
  84. }
  85. sub _post_for ( $domain, $hypervisor, $series, $form ) {
  86. my $name = $domain->get_name();
  87. my $info = $domain->get_info();
  88. return {
  89. # The guest's own UUID: stable across renders, and what the control
  90. # routes look it back up by.
  91. id => $domain->get_uuid_string(),
  92. title => $name,
  93. form => $form,
  94. data => '',
  95. domain => $name,
  96. state => _state_name( $info->{state} ),
  97. is_active => $domain->is_active() ? 1 : 0,
  98. memory => $info->{memory},
  99. vcpus => $info->{nrVirtCpu},
  100. hypervisor => $hypervisor->{id},
  101. hypervisor_title => $hypervisor->{title},
  102. # Fetched by the browser rather than inline, so ten guests don't mean
  103. # ten screenshots taken serially before the page can render.
  104. preview => "/guest/screenshot/$hypervisor->{id}/$name",
  105. # Enough of a post to survive being rendered like one.
  106. local_href => "/guest/$hypervisor->{id}/$name",
  107. href => '',
  108. tags => $series->{tags} // [],
  109. visibility => $series->{visibility} // 'private',
  110. created => time(),
  111. version => 0,
  112. user => $series->{user},
  113. method => 'GET',
  114. };
  115. }
  116. sub _unreachable_post ( $hypervisor, $series, $form, $why ) {
  117. return {
  118. id => "unreachable-$hypervisor->{id}",
  119. title => $hypervisor->{title} // 'hypervisor',
  120. form => $form,
  121. data => '',
  122. state => "unreachable: $why",
  123. is_active => 0,
  124. unreachable => 1,
  125. hypervisor => $hypervisor->{id},
  126. hypervisor_title => $hypervisor->{title},
  127. preview => '',
  128. local_href => '',
  129. href => '',
  130. tags => $series->{tags} // [],
  131. visibility => $series->{visibility} // 'private',
  132. created => time(),
  133. version => 0,
  134. user => $series->{user},
  135. method => 'GET',
  136. };
  137. }
  138. =head2 screenshot($conn_uri, $domain_name) = ($path, $error)
  139. Path to a reasonably fresh PNG of the guest's console.
  140. Cached on disk, because a page of guests means one of these per guest and they
  141. are only interesting to about the nearest half minute. libvirt hands back the
  142. mimetype; qemu gives us PNG already, so there is nothing to convert.
  143. =cut
  144. sub screenshot ( $conn_uri, $domain_name ) {
  145. my ($safe) = $domain_name =~ m/^([A-Za-z0-9._-]+)$/;
  146. return ( undef, 'bad domain name' ) unless $safe;
  147. my $path = "$screenshot_dir/$safe.png";
  148. return ( $path, undef ) if -f $path && ( time() - ( stat($path) )[9] ) < $screenshot_ttl;
  149. my ( $conn, $why ) = _connect($conn_uri);
  150. return ( undef, $why ) unless $conn;
  151. local $@;
  152. my $data = eval {
  153. my ($domain) = grep { $_->get_name() eq $safe } $conn->list_all_domains();
  154. die "no such guest\n" unless $domain;
  155. my $stream = $conn->new_stream();
  156. $domain->screenshot( $stream, 0, 0 );
  157. my $buffer = '';
  158. $stream->recv_all( sub { my ( undef, $chunk ) = @_; $buffer .= $chunk; return length($chunk) } );
  159. $stream->finish();
  160. $buffer;
  161. };
  162. if ( !$data ) {
  163. my $err = "$@";
  164. $err =~ s/\n.*//s;
  165. return ( undef, $err );
  166. }
  167. File::Path::make_path($screenshot_dir);
  168. open( my $fh, '>', $path ) or return ( undef, "could not cache the screenshot: $!" );
  169. binmode $fh;
  170. print {$fh} $data;
  171. close $fh;
  172. return ( $path, undef );
  173. }
  174. =head2 act($action, $conn_uri, $domain_name, $user) = ($ok, $message)
  175. The operations which change a guest.
  176. Deliberately the only sub here that does, and deliberately not reachable from
  177. the render path -- see the safety note at the top. Everything it does is
  178. logged with the user who asked for it.
  179. =cut
  180. sub act ( $action, $conn_uri, $domain_name, $user ) {
  181. my ($safe) = $domain_name =~ m/^([A-Za-z0-9._-]+)$/;
  182. return ( 0, 'bad guest name' ) unless $safe;
  183. my ( $conn, $why ) = _connect($conn_uri);
  184. return ( 0, "could not reach the hypervisor: $why" ) unless $conn;
  185. local $@;
  186. my ($domain) = eval {
  187. grep { $_->get_name() eq $safe } $conn->list_all_domains();
  188. };
  189. return ( 0, "no guest called '$safe' on that hypervisor" ) unless $domain;
  190. INFO("Guest action '$action' on '$safe' requested by $user");
  191. my %actions = (
  192. poweron => sub { $_[0]->create() },
  193. poweroff => sub { $_[0]->shutdown() },
  194. snapshot => sub { $_[0]->create_snapshot() },
  195. destroy => sub { $_[0]->destroy() },
  196. );
  197. my $doit = $actions{ $action // '' };
  198. return ( 0, "'$action' is not something we do to a guest" ) unless $doit;
  199. eval { $doit->($domain); 1 } or do {
  200. my $err = "$@";
  201. $err =~ s/\n.*//s;
  202. WARN("Guest action '$action' on '$safe' failed: $err");
  203. return ( 0, $err );
  204. };
  205. return ( 1, "$action: ok" );
  206. }
  207. 1;