diff --git a/23-fedora-messaging.t b/23-fedora-messaging.t new file mode 100644 index 0000000..bc10f67 --- /dev/null +++ b/23-fedora-messaging.t @@ -0,0 +1,274 @@ +#! /usr/bin/perl + +# Copyright (C) 2016-2019 SUSE LLC +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +BEGIN { + unshift @INC, 'lib'; +} + +use Mojo::Base; +use Mojo::IOLoop; + +use FindBin; +use lib "$FindBin::Bin/lib"; +use OpenQA::Client; +use OpenQA::Jobs::Constants; +use OpenQA::Test::Database; +use Test::MockModule; +use Test::More; +use Test::Mojo; +use Test::Warnings; +use Mojo::File qw(tempdir path); +use OpenQA::WebAPI::Plugin::AMQP; + +my %published; +my $mock_callcount; + +# we mock the parent class here so we can test the child class +my $plugin_mock = Test::MockModule->new('OpenQA::WebAPI::Plugin::AMQP'); +$plugin_mock->mock( + publish_amqp => sub { + my ($self, $topic, $body, $headers) = @_; + # ignore the non-fedoraci messages, makes it easier to + # understand the expected call counts + if ($topic =~ /^ci\./) { + $mock_callcount++; + # strip the time-based bits, at least till + # https://github.com/cho45/Test-Time/issues/14 is done + delete $body->{'generated_at'}; + delete $headers->{'sent-at'}; + $published{$topic} = [$body, $headers]; + } + }); + +OpenQA::Test::Database->new->create(); + +# this test also serves to test plugin loading via config file +my $conf = << 'EOF'; +[global] +plugins=FedoraMessaging +base_url=https://openqa.stg.fedoraproject.org +EOF + +my $tempdir = tempdir; +$ENV{OPENQA_CONFIG} = $tempdir; +path($ENV{OPENQA_CONFIG})->make_path->child("openqa.ini")->spurt($conf); + +my $t = Test::Mojo->new('OpenQA::WebAPI'); + +# XXX: Test::Mojo loses its app when setting a new ua +# https://github.com/kraih/mojo/issues/598 +my $app = $t->app; +$t->ua( + OpenQA::Client->new(apikey => 'PERCIVALKEY02', apisecret => 'PERCIVALSECRET02')->ioloop(Mojo::IOLoop->singleton)); +$t->app($app); + +my $settings = { + DISTRI => 'Unicorn', + FLAVOR => 'pink', + VERSION => '42', + BUILD => 'Fedora-Rawhide-20180129.n.0', + TEST => 'rainbow', + ISO => 'whatever.iso', + DESKTOP => 'DESKTOP', + KVM => 'KVM', + ISO_MAXSIZE => 1, + MACHINE => "RainbowPC", + ARCH => 'x86_64', + SUBVARIANT => 'workstation' +}; + +my $expected_artifact = { + id => 'Fedora-Rawhide-20180129.n.0', + iso => 'whatever.iso', + type => 'productmd-compose', + compose_type => 'nightly', +}; + +my $expected_contact = { + name => 'Fedora openQA', + team => 'Fedora QA', + url => 'https://openqa.stg.fedoraproject.org', + docs => 'https://fedoraproject.org/wiki/OpenQA', + irc => '#fedora-qa', + email => 'qa-devel@lists.fedoraproject.org', +}; + +my $expected_pipeline = { + id => 'openqa.Fedora-Rawhide-20180129.n.0.rainbow.RainbowPC.pink.x86_64', + name => 'openqa.Fedora-Rawhide-20180129.n.0.rainbow.RainbowPC.pink.x86_64', +}; + +my $expected_run = { + url => '', + log => '', + id => '', +}; + +my $expected_system = { + os => 'fedora-42', + provider => 'openqa', + architecture => 'x86_64', + variant => 'workstation', +}; + +my $expected_test = { + category => 'validation', + type => 'rainbow RainbowPC pink x86_64', + namespace => 'compose', + lifetime => 240, +}; + +my $expected_error; + +my $expected_version = '0.2.1'; + +sub get_expected { + my $expected = { + artifact => $expected_artifact, + contact => $expected_contact, + pipeline => $expected_pipeline, + run => $expected_run, + system => $expected_system, + test => $expected_test, + version => $expected_version, + }; + $expected->{'error'} = $expected_error if ($expected_error); + return $expected; +} + +# create a job via API +my $job; +my $newjob; +subtest 'create job' => sub { + # reset the call count + $mock_callcount = 0; + $t->post_ok("/api/v1/jobs" => form => $settings)->status_is(200); + ok($job = $t->tx->res->json->{id}, 'got ID of new job'); + ok($mock_callcount == 1, 'mock was called'); + my ($body, $headers) = @{$published{'ci.productmd-compose.test.queued'}}; + $expected_run = { + url => "https://openqa.stg.fedoraproject.org/tests/$job", + log => "https://openqa.stg.fedoraproject.org/tests/$job/file/autoinst-log.txt", + id => $job, + }; + my $expected = get_expected; + my $expected_headers = { + fedora_messaging_severity => 20, + fedora_messaging_schema => 'fedora_messaging.message:Message', + }; + is_deeply($body, $expected, 'job create triggers standardized amqp'); + is_deeply($headers, $expected_headers, 'amqp headers are as expected'); +}; + +subtest 'mark job as done' => sub { + $mock_callcount = 0; + $t->post_ok("/api/v1/jobs/$job/set_done")->status_is(200); + ok($mock_callcount == 1, 'mock was called'); + my ($body, $headers) = @{$published{'ci.productmd-compose.test.complete'}}; + $expected_test->{'result'} = 'failed'; + delete $expected_test->{'lifetime'}; + my $expected = get_expected; + is_deeply($body, $expected, 'job done (failed) triggers standardized amqp'); +}; + +subtest 'duplicate and cancel job' => sub { + $mock_callcount = 0; + $t->post_ok("/api/v1/jobs/$job/duplicate")->status_is(200); + $newjob = $t->tx->res->json->{id}; + ok($mock_callcount == 1, 'mock was called'); + my ($body, $headers) = @{$published{'ci.productmd-compose.test.queued'}}; + $expected_run = { + clone_of => $job, + url => "https://openqa.stg.fedoraproject.org/tests/$newjob", + log => "https://openqa.stg.fedoraproject.org/tests/$newjob/file/autoinst-log.txt", + id => $newjob, + }; + $expected_test->{'lifetime'} = 240; + delete $expected_test->{'result'}; + my $expected = get_expected; + is_deeply($body, $expected, 'job duplicate triggers standardized amqp'); + + $mock_callcount = 0; + $t->post_ok("/api/v1/jobs/$newjob/cancel")->status_is(200); + ok($mock_callcount == 1, 'mock was called'); + my ($body, $headers) = @{$published{'ci.productmd-compose.test.error'}}; + $expected_error = {reason => 'user_cancelled',}; + delete $expected_test->{'lifetime'}; + delete $expected_run->{'clone_of'}; + my $expected = get_expected; + is_deeply($body, $expected, 'job cancel triggers standardized amqp'); +}; + +subtest 'duplicate and pass job' => sub { + $mock_callcount = 0; + $t->post_ok("/api/v1/jobs/$newjob/duplicate")->status_is(200); + my $newerjob = $t->tx->res->json->{id}; + # explicitly set job as passed + $t->post_ok("/api/v1/jobs/$newerjob/set_done?result=passed")->status_is(200); + ok($mock_callcount == 2, 'mock was called'); + my ($body, $headers) = @{$published{'ci.productmd-compose.test.complete'}}; + $expected_run = { + url => "https://openqa.stg.fedoraproject.org/tests/$newerjob", + log => "https://openqa.stg.fedoraproject.org/tests/$newerjob/file/autoinst-log.txt", + id => $newerjob, + }; + $expected_test->{'result'} = 'passed'; + $expected_error = ''; + my $expected = get_expected; + is_deeply($body, $expected, 'job done (passed) triggers standardized amqp'); +}; + +subtest 'create update job' => sub { + $mock_callcount = 0; + diag("Count: $mock_callcount"); + $settings->{BUILD} = 'Update-FEDORA-2018-3c876babb9'; + # let's test HDD_* here too + $settings->{HDD_1} = 'disk_f40_minimal.qcow2'; + $settings->{HDD_2} = 'someotherdisk.img'; + $settings->{BOOTFROM} = 'c'; + delete $settings->{ISO}; + $t->post_ok("/api/v1/jobs" => form => $settings)->status_is(200); + ok(my $updatejob = $t->tx->res->json->{id}, 'got ID of update job'); + diag("Count: $mock_callcount"); + ok($mock_callcount == 1, 'mock was called'); + my ($body, $headers) = @{$published{'ci.fedora-update.test.queued'}}; + $expected_artifact = { + id => 'FEDORA-2018-3c876babb9', + type => 'fedora-update', + release => '42', + hdd_1 => 'disk_f40_minimal.qcow2', + hdd_2 => 'someotherdisk.img', + }; + $expected_pipeline = { + id => 'openqa.Update-FEDORA-2018-3c876babb9.rainbow.RainbowPC.pink.x86_64', + name => 'openqa.Update-FEDORA-2018-3c876babb9.rainbow.RainbowPC.pink.x86_64', + }; + $expected_run = { + url => "https://openqa.stg.fedoraproject.org/tests/$updatejob", + log => "https://openqa.stg.fedoraproject.org/tests/$updatejob/file/autoinst-log.txt", + id => $updatejob, + }; + $expected_system->{'os'} = 'fedora-40'; + $expected_test->{'namespace'} = 'update'; + $expected_test->{'lifetime'} = 240; + delete $expected_test->{'result'}; + my $expected = get_expected; + is_deeply($body, $expected, 'update job create triggers standardized amqp'); +}; + +done_testing(); diff --git a/FedoraMessaging.pm b/FedoraMessaging.pm new file mode 100644 index 0000000..dcd3d84 --- /dev/null +++ b/FedoraMessaging.pm @@ -0,0 +1,244 @@ +# Copyright (C) Red Hat Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + +# This is a plugin for publishing messages to fedora-messaging, Fedora's +# AMQP message broker. It piggybacks on the upstream AMQP plugin but +# includes headers required by the fedora-messaging spec and publishes +# messages in the "CI Messages" spec: https://pagure.io/fedora-ci/messages +# as well as more 'native' style messages. + +package OpenQA::WebAPI::Plugin::FedoraMessaging; + +use POSIX qw(strftime); + +use Mojo::Base 'OpenQA::WebAPI::Plugin::AMQP'; +use OpenQA::Jobs::Constants; +use OpenQA::Utils; + +sub _iso8601_now { + # we do this twice, so factor it out + my $now = strftime("%Y-%m-%dT%H:%M:%S", gmtime()) . 'Z'; + return $now; +} + +sub publish_amqp { + my ($self, $topic, $event_data, $headers) = @_; + my $sentat = _iso8601_now; + # default fedora-messaging compliant headers + my %fullheaders = ( + fedora_messaging_severity => 20, + fedora_messaging_schema => 'fedora_messaging.message:Message', + "sent-at" => $sentat, + ); + # merge in the passed headers to allow overriding + $headers //= {}; + %fullheaders = (%fullheaders, %$headers); + # call parent method + $self->SUPER::publish_amqp($topic, $event_data, \%fullheaders); +} + +sub log_event_fedora_ci_messages { + # this is for publishing messages in the "CI Messages" format: + # https://pagure.io/fedora-ci/messages + # This is a Fedora/Red Hat-ish thing in a way, but in theory + # anyone could adopt it + my ($self, $event, $job, $baseurl) = @_; + my $stdevent; + my $clone_of; + my $job_id; + # first, get the standard 'state' (from 'queued', 'running', + # 'complete', 'error'; we cannot do 'running' at present + if ($event eq 'openqa_job_create') { + $stdevent = 'queued'; + $job_id = $job->id; + } + elsif ($event eq 'openqa_job_restart' || $event eq 'openqa_job_duplicate') { + $stdevent = 'queued'; + $clone_of = $job->id; + $job_id = $job->clone_id; + } + elsif ($event eq 'openqa_job_cancel') { + $stdevent = 'error'; + $job_id = $job->id; + } + elsif ($event eq 'openqa_job_done') { + $job_id = $job->id; + # lifecycle note: any job cancelled directly via the web API will + # see both job_cancel and job_done with result USER_CANCELLED, so + # we emit duplicate standardized fedmsgs in this case. This is + # kinda unavoidable, though, as it's possible for a job to wind up + # USER_CANCELLED *without* an openqa_job_cancel event happening, + # so we can't just throw away all openqa_job_done USER_CANCELLED + # events... + $stdevent = (grep { $job->result eq $_ } INCOMPLETE_RESULTS) ? 'error' : 'complete'; + } + else { + return undef; + } + + # we need this for the system dict; it should be the release of + # the system-under-test (the VM in which the test runs) at the + # *start* of the test, I think. We're trying to capture info about + # the environment in which the test runs + my $sysrelease = $job->VERSION; + my $hdd1; + my $bootfrom; + $hdd1 = $job->settings_hash->{HDD_1} if ($job->settings_hash->{HDD_1}); + $bootfrom = $job->settings_hash->{BOOTFROM} if ($job->settings_hash->{BOOTFROM}); + if ($hdd1 && $bootfrom) { + $sysrelease = $1 if ($hdd1 =~ /disk_f(\d+)/ && $bootfrom eq 'c'); + } + + # next, get the 'artifact' (type of thing we tested) + my $artifact; + my $artifact_id; + my $artifact_release; + my $compose_type; + my $test_namespace; + # current date/time in ISO 8601 format + my $generated_at = _iso8601_now; + + # this is used as a 'pipeline ID', see + # https://pagure.io/fedora-ci/messages/blob/master/f/schemas/pipeline.yaml + my $pipeid = join('.', "openqa", $job->BUILD, $job->TEST, $job->MACHINE, $job->FLAVOR, $job->ARCH); + + my $build = $job->BUILD; + if ($build =~ /^Fedora/) { + $artifact = 'productmd-compose'; + $artifact_id = $build; + $compose_type = 'production'; + $compose_type = 'nightly' if ($build =~ /\.n\./); + $compose_type = 'test' if ($build =~ /\.t\./); + $test_namespace = 'compose'; + } + elsif ($build =~ /^Update-FEDORA/) { + $artifact = 'fedora-update'; + $artifact_id = $build; + $artifact_id =~ s/^Update-//; + $artifact_release = $job->VERSION; + $test_namespace = 'update'; + } + else { + # unhandled artifact type + return undef; + } + + # finally, construct the message content + my %msg_data = ( + contact => { + name => 'Fedora openQA', + team => 'Fedora QA', + url => $baseurl, + docs => 'https://fedoraproject.org/wiki/OpenQA', + irc => '#fedora-qa', + email => 'qa-devel@lists.fedoraproject.org', + }, + run => { + url => "$baseurl/tests/$job_id", + log => "$baseurl/tests/$job_id/file/autoinst-log.txt", + id => $job_id, + }, + artifact => { + type => $artifact, + id => $artifact_id, + }, + pipeline => { + # per https://pagure.io/fedora-ci/messages/issue/61 this + # is meant to be unique per test scenario *and* artifact, + # so we construct it out of BUILD and the scenario keys. + # 'name' is supposed to be a 'human readable name', well, + # this is human readable, so we'll just use it twice + id => $pipeid, + name => $pipeid, + }, + test => { + # openQA tests are pretty much always validation + category => 'validation', + # test identifier: test name plus scenario keys + type => join(' ', $job->TEST, $job->MACHINE, $job->FLAVOR, $job->ARCH), + namespace => $test_namespace, + }, + system => { + # it's interesting whether we should record info on the + # *worker host itself* or the *SUT* (the VM run on top of + # the worker host environment) here...on the whole I think + # SUT is more in line with expectations, so let's do that + os => "fedora-${sysrelease}", + # openqa provisions itself...we *could* I guess set this + # to 'createhdds' if we booted a disk image, but ehhhh + provider => 'openqa', + architecture => $job->ARCH, + variant => $job->settings_hash->{SUBVARIANT}, + }, + generated_at => $generated_at, + version => "0.2.1", + ); + + # add keys that don't exist in all cases to the message + if ($stdevent eq 'complete') { + $msg_data{test}{result} = $job->result; + $msg_data{test}{result} = 'info' if $job->result eq 'softfailed'; + } + elsif ($stdevent eq 'error') { + $msg_data{error} = {}; + $msg_data{error}{reason} = $job->result; + } + elsif ($stdevent eq 'queued') { + # this is a hint to consumers that the job probably went away + # if they don't get a 'complete' or 'error' in 4 hours + # FIXME: we should set this as 2 hours on 'running', but we + # can't emit running because there is no internal event for + # it, there is no job_running event or anything like it - + # this is part of https://progress.opensuse.org/issues/31069 + $msg_data{test}{lifetime} = 240; + } + $msg_data{run}{clone_of} = $clone_of if ($clone_of); + + $msg_data{artifact}{release} = $artifact_release if ($artifact_release); + + $msg_data{artifact}{compose_type} = $compose_type if ($compose_type); + + $msg_data{artifact}{iso} = $job->settings_hash->{ISO} if ($job->settings_hash->{ISO}); + # 9 hard disks ought to be enough for anyone + for my $i (1 .. 9) { + $msg_data{artifact}{"hdd_$i"} = $job->settings_hash->{"HDD_$i"} if ($job->settings_hash->{"HDD_$i"}); + } + + # create the topic + my $topic = "ci.$artifact.test.$stdevent"; + + # finally, send the message + log_debug("Sending CI Messages AMQP message for $event"); + # FIXME: we should set fedora_messaging_schema header here, but the + # ci-messages schemas are not currently provided as fedora-messaging + # Python classes anywhere, so we kinda can't. See: + # https://pagure.io/fedora-ci/messages/issue/33 + $self->publish_amqp($topic, \%msg_data); +} + +sub on_job_event { + # do just enough work to send the 'CI messaging' spec message + # (unfortunately a bit of duplication is inevitable) + my ($self, $args) = @_; + my ($user_id, $connection_id, $event, $event_data) = @$args; + my $jobs = $self->{app}->schema->resultset('Jobs'); + my $job = $jobs->find({id => $event_data->{id}}); + my $baseurl = $self->{config}->{global}->{base_url} || "http://UNKNOWN"; + $self->log_event_fedora_ci_messages($event, $job, $baseurl); + # call parent method to send 'native' message + $self->SUPER::on_job_event($args); +} + +1;