Virt.pm 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. package Trog::DataSource::Virt;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use List::Util();
  6. use File::Path();
  7. use Sys::Hostname::FQDN();
  8. use Sys::Virt();
  9. use Trog::Config();
  10. use Trog::DataSource();
  11. use Trog::Log qw{WARN INFO};
  12. =head1 Trog::DataSource::Virt
  13. Builds posts out of libvirt guests rather than out of the datastore.
  14. A post type says it wants this by naming it in its sidecar:
  15. "x-tcms-datasource": "Trog::DataSource::Virt"
  16. A series of that type then lists the guests of whichever hypervisor posts its
  17. relations pulled in, instead of listing posts somebody wrote. Nothing here is
  18. ever written to the datastore -- the guests are the source of truth, and the
  19. posts are rebuilt from them on every view.
  20. =head2 EDITABLE
  21. False. A guest is built from libvirt every time the page is drawn and has
  22. nothing behind it to save, so a post type backed by this wants no editor: a
  23. form posting to /post/save would write a real post into the datastore which
  24. would then sit alongside, and collide with, the synthesized ones.
  25. =cut
  26. sub EDITABLE { return 0 }
  27. =head2 CACHEABLE
  28. False. A guest's state is the whole point of the page: whether it is running,
  29. how much memory it has, what its console looks like right now. A static render
  30. of that is a photograph of a machine, and the reader wants the machine.
  31. There is also nothing that could invalidate it. A guest starting or stopping is
  32. not a post being saved, and libvirt is not going to tell us about it -- so a
  33. cached listing would not merely be stale, it would have no way of ever becoming
  34. right again.
  35. =cut
  36. sub CACHEABLE { return 0 }
  37. =head2 A NOTE ON SAFETY
  38. libvirt forbids virDomainScreenshot on a read-only connection, so the read path
  39. cannot protect itself by connecting read-only -- it holds a connection which is
  40. perfectly capable of destroying a guest.
  41. The discipline is therefore in the code: posts() and screenshot() call nothing
  42. but accessors, and every operation that changes a guest lives in act(), which
  43. is reached only from an explicit admin-gated POST. Keep it that way.
  44. =cut
  45. # How long a cached screenshot is considered fresh. These are console
  46. # captures, so they change slowly and are cheap to refetch.
  47. our $screenshot_ttl = 30;
  48. # Under www/assets like every other asset, in its own subtree because these are
  49. # not uploads somebody made -- they are cached datasource output, and anything
  50. # in here can be deleted at any time and will simply be refetched.
  51. #
  52. # Under private/ specifically because a console capture can show a login
  53. # prompt, a hostname, whatever the last command printed. That is the same
  54. # reasoning _handle_upload uses to put a private post's assets there, and the
  55. # screenshot route serves these to admins itself rather than relying on
  56. # anything about how www/assets is exposed.
  57. our $screenshot_dir = 'www/assets/private/datasource/virt';
  58. # Reconnecting per guest would be absurd -- one ssh handshake per hypervisor
  59. # per process is enough. Not a state var: a connection that has gone away
  60. # should be retried rather than remembered.
  61. my sub _connect ( $uri, $cache = {} ) {
  62. return $cache->{$uri} if $cache->{$uri};
  63. local $@;
  64. my $conn = eval { Sys::Virt->new( uri => $uri ) };
  65. if ( !$conn ) {
  66. my $why = "$@";
  67. $why =~ s/\n.*//s;
  68. WARN("Could not connect to hypervisor '$uri': $why");
  69. return ( undef, $why );
  70. }
  71. $cache->{$uri} = $conn;
  72. return $conn;
  73. }
  74. # libvirt's numeric domain states, in the order the constants define them.
  75. my @states = (
  76. 'no state', qw{running blocked paused},
  77. 'shutting down', 'shut off', 'crashed', 'suspended',
  78. );
  79. my sub state_name ($state) {
  80. return $states[$state] // 'unknown';
  81. }
  82. # What this machine calls itself. Not a state var so that a test can pretend
  83. # to be a different host without having to lie to Sys::Hostname.
  84. our $hostname;
  85. my sub hostname {
  86. return $hostname if defined $hostname;
  87. local $@;
  88. # Sys::Hostname croaks rather than returning undef when it cannot tell, and
  89. # the configured hostname is the next best thing we know about ourselves.
  90. $hostname =
  91. eval { Sys::Hostname::FQDN::fqdn() }
  92. || eval { Trog::Config->get()->param('general.hostname') }
  93. || '';
  94. return $hostname;
  95. }
  96. =head2 is_self($name)
  97. Whether a guest by this name is the machine we are running on.
  98. Such a guest is acting as dom0: it is serving the page the guest list appears
  99. on, so powering it off or destroying it takes the site down along with whatever
  100. asked for that.
  101. Returns true if the guest name is the same as the fqdn of this machine.
  102. Returns false otherwise.
  103. =cut
  104. sub is_self ($name) {
  105. my $me = lc( hostname() );
  106. $name = lc( $name // '' );
  107. return 1 if $name eq $me;
  108. return 0;
  109. }
  110. my sub unreachable_post ( $hypervisor, $series, $form, $why ) {
  111. return {
  112. id => "unreachable-$hypervisor->{id}",
  113. title => $hypervisor->{title} // 'hypervisor',
  114. form => $form,
  115. data => '',
  116. state => "unreachable: $why",
  117. is_active => 0,
  118. unreachable => 1,
  119. hypervisor => $hypervisor->{id},
  120. hypervisor_title => $hypervisor->{title},
  121. preview => '',
  122. local_href => '',
  123. href => '',
  124. tags => $series->{tags} // [],
  125. visibility => $series->{visibility} // 'private',
  126. created => time(),
  127. version => 0,
  128. user => $series->{user},
  129. method => 'GET',
  130. };
  131. }
  132. =head2 posts($series, $query) = @posts
  133. One post per guest, across every hypervisor the series' relations pulled in.
  134. A hypervisor we cannot reach becomes a single post saying so, rather than
  135. vanishing -- a page that silently omits half your infrastructure is worse than
  136. one that tells you the connection is broken.
  137. =cut
  138. sub posts ( $series, $ ) {
  139. my $hypervisors = $series->{ $series->{'x-tcms-datasource-relation'} // 'hypervisors' };
  140. return () unless ref $hypervisors eq 'ARRAY';
  141. my $form = $series->{child_form} // '';
  142. my %cache;
  143. my @out;
  144. foreach my $hypervisor (@$hypervisors) {
  145. my $uri = $hypervisor->{conn_uri};
  146. next unless $uri;
  147. my ( $conn, $why ) = _connect( $uri, \%cache );
  148. if ( !$conn ) {
  149. push( @out, unreachable_post( $hypervisor, $series, $form, $why ) );
  150. next;
  151. }
  152. local $@;
  153. my @domains = eval { $conn->list_all_domains() };
  154. if ($@) {
  155. my $err = "$@";
  156. $err =~ s/\n.*//s;
  157. push( @out, unreachable_post( $hypervisor, $series, $form, $err ) );
  158. next;
  159. }
  160. push( @out, map { post_for( $_, $hypervisor, $series, $form ) } @domains );
  161. }
  162. return @out;
  163. }
  164. =head2 filter($query, @posts) = @posts
  165. Apply the reader's search to the guest list.
  166. A guest post's body is empty -- everything about it is in named fields -- so the
  167. default, which searches a post's title and body, would only ever match the guest
  168. name. Search what the page actually shows instead: the guest name, its state,
  169. and the hypervisor it is on. Searching 'shut' for the stopped guests, or a
  170. hypervisor's title for everything on it, is the thing somebody typing in that
  171. box on this page is trying to do.
  172. Everything else about the query means what it does elsewhere, so it is left to
  173. Trog::DataSource::filter.
  174. =cut
  175. sub filter ( $query, @posts ) {
  176. $query //= {};
  177. if ( ( $query->{like} // q{} ) ne q{} ) {
  178. my $like = $query->{like};
  179. @posts = grep {
  180. Trog::DataSource::searchable_match(
  181. $like,
  182. $_->{title}, $_->{domain}, $_->{state}, $_->{hypervisor_title}, $_->{hypervisor},
  183. )
  184. } @posts;
  185. }
  186. # Without like, since that one is answered above.
  187. return Trog::DataSource::filter( { %$query, like => undef }, @posts );
  188. }
  189. =head2 lang() = STRING
  190. What the search box searches on a page of guests.
  191. =head2 help() = STRING
  192. Where to read about it.
  193. =cut
  194. sub lang { return 'Case insensitive substring of a guest name, state or hypervisor' }
  195. sub help { return 'https://en.wikipedia.org/wiki/Substring' }
  196. =head2 post_for($domain, $hypervisor, $series, $form) = HASHREF
  197. The post that stands for the guest $domain, a Sys::Virt::Domain, on
  198. $hypervisor, in the listing of $series, rendered with $form.
  199. =cut
  200. sub post_for ( $domain, $hypervisor, $series, $form ) {
  201. my $name = $domain->get_name();
  202. my $info = $domain->get_info();
  203. return {
  204. # The guest's own UUID: stable across renders, and what the control
  205. # routes look it back up by.
  206. id => $domain->get_uuid_string(),
  207. title => $name,
  208. form => $form,
  209. data => '',
  210. domain => $name,
  211. state => state_name( $info->{state} ),
  212. is_active => $domain->is_active() ? 1 : 0,
  213. is_self => is_self($name),
  214. memory => $info->{memory},
  215. vcpus => $info->{nrVirtCpu},
  216. hypervisor => $hypervisor->{id},
  217. hypervisor_title => $hypervisor->{title},
  218. # Fetched by the browser rather than inline, so ten guests don't mean
  219. # ten screenshots taken serially before the page can render.
  220. preview => "/guest/screenshot/$hypervisor->{id}/$name",
  221. # Enough of a post to survive being rendered like one.
  222. local_href => "/guest/$hypervisor->{id}/$name",
  223. href => '',
  224. tags => $series->{tags} // [],
  225. visibility => $series->{visibility} // 'private',
  226. created => time(),
  227. version => 0,
  228. user => $series->{user},
  229. method => 'GET',
  230. };
  231. }
  232. =head2 screenshot($hypervisor, $guest_name) = ($path, $error)
  233. Path to a reasonably fresh PNG of the guest's console.
  234. Cached on disk, because a page of guests means one of these per guest and they
  235. are only interesting to about the nearest half minute. libvirt hands back the
  236. mimetype; qemu gives us PNG already, so there is nothing to convert.
  237. Keyed by hypervisor as well as by guest -- guest names are only unique within
  238. one hypervisor, and two of them called 'staging' would otherwise show each
  239. other's console.
  240. =cut
  241. sub screenshot ( $hypervisor, $guest_name ) {
  242. my ($safe) = ( $guest_name // '' ) =~ m/^([\w.-]+)$/;
  243. return ( undef, 'bad guest name' ) unless $safe;
  244. my ($host) = ( $hypervisor->{id} // '' ) =~ m/^([\w.-]+)$/;
  245. return ( undef, 'bad hypervisor id' ) unless $host;
  246. my $conn_uri = $hypervisor->{conn_uri};
  247. return ( undef, 'hypervisor has no connection uri' ) unless $conn_uri;
  248. my $dir = "$screenshot_dir/$host";
  249. my $path = "$dir/$safe.png";
  250. # One stat rather than -f and then a second stat for the mtime. A missing
  251. # file gives the empty list, so $mtime is undef and we fall through to a
  252. # fresh capture.
  253. my $mtime = ( stat($path) )[9];
  254. return ( $path, undef ) if $mtime && ( time() - $mtime ) < $screenshot_ttl;
  255. my ( $conn, $why ) = _connect($conn_uri);
  256. return ( undef, $why ) unless $conn;
  257. local $@;
  258. my $data = eval {
  259. my ($domain) = grep { $_->get_name() eq $safe } $conn->list_all_domains();
  260. die "no such guest\n" unless $domain;
  261. my $stream = $conn->new_stream();
  262. $domain->screenshot( $stream, 0, 0 );
  263. my $buffer = '';
  264. $stream->recv_all( sub { my ( undef, $chunk ) = @_; $buffer .= $chunk; return length($chunk) } );
  265. $stream->finish();
  266. $buffer;
  267. };
  268. if ( !$data ) {
  269. my $err = "$@";
  270. $err =~ s/\n.*//s;
  271. return ( undef, $err );
  272. }
  273. File::Path::make_path($dir);
  274. open( my $fh, '>', $path ) or return ( undef, "could not cache the screenshot: $!" );
  275. binmode $fh;
  276. print {$fh} $data;
  277. close $fh;
  278. return ( $path, undef );
  279. }
  280. =head2 act($action, $conn_uri, $domain_name, $user) = ($ok, $message)
  281. The operations which change a guest.
  282. Deliberately the only sub here that does, and deliberately not reachable from
  283. the render path -- see the safety note at the top. Everything it does is
  284. logged with the user who asked for it.
  285. =cut
  286. sub act ( $action, $conn_uri, $domain_name, $user ) {
  287. my ($safe) = $domain_name =~ m/^([\w.-]+)$/;
  288. return ( 0, 'bad guest name' ) unless $safe;
  289. # The template draws neither of these buttons for this guest; this is what
  290. # actually stops it happening. /guest/act is a POST anybody holding the
  291. # admin acl can craft by hand, and if it went through there would be
  292. # nothing left running to report the mistake.
  293. if ( is_self($safe) && List::Util::any { $_ eq ( $action // '' ) } qw{poweroff destroy} ) {
  294. WARN("Refused guest action '$action' on '$safe': that is this server");
  295. return ( 0, "'$safe' is this server -- refusing to $action it" );
  296. }
  297. my ( $conn, $why ) = _connect($conn_uri);
  298. return ( 0, "could not reach the hypervisor: $why" ) unless $conn;
  299. local $@;
  300. my ($domain) = eval {
  301. grep { $_->get_name() eq $safe } $conn->list_all_domains();
  302. };
  303. return ( 0, "no guest called '$safe' on that hypervisor" ) unless $domain;
  304. INFO("Guest action '$action' on '$safe' requested by $user");
  305. my %actions = (
  306. poweron => sub { $_[0]->create() },
  307. poweroff => sub { $_[0]->shutdown() },
  308. snapshot => sub { $_[0]->create_snapshot() },
  309. destroy => sub { $_[0]->destroy() },
  310. );
  311. my $doit = $actions{ $action // '' };
  312. return ( 0, "'$action' is not something we do to a guest" ) unless $doit;
  313. eval { $doit->($domain); 1 } or do {
  314. my $err = "$@";
  315. $err =~ s/\n.*//s;
  316. WARN("Guest action '$action' on '$safe' failed: $err");
  317. return ( 0, $err );
  318. };
  319. return ( 1, "$action: ok" );
  320. }
  321. 1;