diff --git a/json-schema/response.yaml b/json-schema/response.yaml index a8cc91018..db2631287 100644 --- a/json-schema/response.yaml +++ b/json-schema/response.yaml @@ -1054,6 +1054,23 @@ definitions: $ref: /definitions/uuid device_report_id: $ref: /definitions/uuid + ReportValidationResults: + type: object + additionalProperties: false + required: + - device_id + - validation_plan_id + - status + - results + properties: + device_id: + $ref: /definitions/device_id + validation_plan_id: + $ref: /definitions/uuid + status: + $ref: /definitions/validation_status + results: + $ref: /definitions/ValidationResults WorkspaceRelays: type: array uniqueItems: true diff --git a/lib/Conch/Controller/DeviceReport.pm b/lib/Conch/Controller/DeviceReport.pm index b0cc86c3f..5baa04731 100644 --- a/lib/Conch/Controller/DeviceReport.pm +++ b/lib/Conch/Controller/DeviceReport.pm @@ -30,11 +30,9 @@ Response uses the ValidationStateWithResults json schema. =cut sub process ($c) { - my $raw_report = $c->req->text; - my $unserialized_report = $c->validate_input('DeviceReport'); if (not $unserialized_report) { - $c->log->debug('Device report input failed validation'); + $c->log->debug('Device report input did not match json schema specification'); if (not $c->db_devices->active->search({ id => $c->stash('device_id') })->exists) { $c->log->debug('Device id '.$c->stash('device_id').' does not exist; cannot store bad report'); @@ -44,7 +42,7 @@ sub process ($c) { # the "report" may not even be valid json, so we cannot store it in a jsonb field. my $device_report = $c->db_device_reports->create({ device_id => $c->stash('device_id'), - invalid_report => $raw_report, + invalid_report => $c->req->text, }); $c->log->debug('Stored invalid device report for device id '.$c->stash('device_id')); return; @@ -57,39 +55,11 @@ sub process ($c) { }); } - my $hw; # Make sure that the remote side is telling us about a hardware product we understand - if ($unserialized_report->{device_type} && $unserialized_report->{device_type} eq "switch") { - $hw = $c->db_hardware_products->active->search( - { name => $unserialized_report->{product_name} }, - { prefetch => 'hardware_product_profile' }, - )->single; - } else { - $hw = $c->db_hardware_products->active->search( - { sku => $unserialized_report->{sku} }, - { prefetch => 'hardware_product_profile' }, - )->single; - - if(not $hw) { - # this will warn if more than one matching row is found - $hw = $c->db_hardware_products->active->search( - { legacy_product_name => $unserialized_report->{product_name}, }, - { prefetch => 'hardware_product_profile' }, - )->single; - } - } - - if(not $hw) { - return $c->render(status => 409, json => { - error => "Could not locate hardware product" - }); - } - - if(not $hw->hardware_product_profile) { - return $c->render(status => 409, json => { - error => "Hardware product does not contain a profile" - }); - } + my $hw = $c->_get_hardware_product($unserialized_report); + return $c->status(409, { error => 'Could not locate hardware product' }) if not $hw; + return $c->status(409, { error => 'Hardware product does not contain a profile' }) + if not $hw->hardware_product_profile; if ($unserialized_report->{relay} and my $relay_serial = $unserialized_report->{relay}{serial}) { # TODO: relay id should be a uuid @@ -135,7 +105,7 @@ sub process ($c) { $c->log->debug("Creating device report"); my $device_report = $device->create_related('device_reports', { - report => $raw_report, + report => $c->req->text, # this is the raw json string # we will always keep this report if the previous report failed, or this is the first # report (in its phase). !$previous_report_status || $previous_report_status ne 'pass' ? ( retain => 1 ) : (), @@ -153,41 +123,23 @@ sub process ($c) { # Time for validations http://www.space.ca/wp-content/uploads/2017/05/giphy-1.gif - my $validation_name = 'Conch v1 Legacy Plan: Server'; - - if ( $unserialized_report->{device_type} - && $unserialized_report->{device_type} eq "switch" ) - { - $validation_name = 'Conch v1 Legacy Plan: Switch'; - } - - $c->log->debug("Attempting to validate with plan '$validation_name'"); - - my $validation_plan = $c->db_ro_validation_plans->active->search({ name => $validation_name })->single; - - return $c->status(500, { error => "failed to find validation plan" }) if not $validation_plan; - - $c->log->debug("Running validation plan ".$validation_plan->id); + my $validation_plan = $c->_get_validation_plan($unserialized_report); + return $c->status(500, { error => 'failed to find validation plan' }) if not $validation_plan; + $c->log->debug('Running validation plan '.$validation_plan->id.': '.$validation_plan->name.'"'); my $validation_state = Conch::ValidationSystem->new( schema => $c->schema, log => $c->log, )->run_validation_plan( validation_plan => $validation_plan, + # TODO: to eliminate needless db queries, we should prefetch all the relationships + # that various validations will request, e.g. device_location, hardware_product etc device => $c->db_ro_devices->find($device->id), device_report => $device_report, ); + return $c->status(400, { error => 'no validations ran' }) if not $validation_state; $c->log->debug("Validations ran with result: ".$validation_state->status); - # prime the resultset cache for the serializer - # (this is gross because has-multi accessors always go to the db, so there is no - # non-private way of extracting related rows from the result) - for my $members ($validation_state->{_relationship_data}{validation_state_members}) { - $_->related_resultset('validation_result')->set_cache([ $_->validation_result ]) - foreach $members->@*; - $validation_state->related_resultset('validation_state_members')->set_cache($members); - } - # calculate the device health based on the validation results. # currently, since there is just one (hardcoded) plan per device, we can simply copy it # from the validation_state, but in the future we should query for the most recent @@ -213,6 +165,9 @@ sub process ($c) { } } + # prime the resultset cache for the serializer + $validation_state->prefetch_validation_results; + $c->status( 200, $validation_state ); } @@ -453,6 +408,116 @@ sub get ($c) { return $c->status(200, $c->stash('device_report_rs')->single); } +=head2 validate_report + +Process a device report without writing anything to the database; otherwise behaves like +L. The described device does not have to exist. + +Response uses the ReportValidationResults json schema. + +=cut + +sub validate_report ($c) { + my $unserialized_report = $c->validate_input('DeviceReport'); + if (not $unserialized_report) { + $c->log->debug('Device report input did not match json schema specification'); + return; + } + + my $hw = $c->_get_hardware_product($unserialized_report); + return $c->status(409, { error => 'Could not locate hardware product' }) if not $hw; + return $c->status(409, { error => 'Hardware product does not contain a profile' }) + if not $hw->hardware_product_profile; + + my $validation_plan = $c->_get_validation_plan($unserialized_report); + return $c->status(500, { error => 'failed to find validation plan' }) if not $validation_plan; + $c->log->debug('Running validation plan '.$validation_plan->id.': '.$validation_plan->name.'"'); + + my ($status, @validation_results); + $c->txn_wrapper(sub ($c) { + my $device = $c->db_devices->update_or_create({ + id => $unserialized_report->{serial_number}, + system_uuid => $unserialized_report->{system_uuid}, + hardware_product_id => $hw->id, + state => $unserialized_report->{state}, + health => 'unknown', + last_seen => \'now()', + uptime_since => $unserialized_report->{uptime_since}, + hostname => $unserialized_report->{os}{hostname}, + updated => \'now()', + deactivated => undef, + }); + + # we do not call _record_device_configuration, because no validations + # should be using that information, instead choosing to respect the report data. + + ($status, @validation_results) = Conch::ValidationSystem->new( + schema => $c->ro_schema, + log => $c->log, + )->run_validation_plan( + validation_plan => $validation_plan, + device => $device, + data => $unserialized_report, + no_save_db => 1, + ); + + die 'rollback: device used for report validation should not be persisted'; + }); + + return $c->status(400, { error => 'no validations ran' }) if not @validation_results; + + $c->status(200, { + device_id => $unserialized_report->{serial_number}, + validation_plan_id => $validation_plan->id, + status => $status, + results => \@validation_results, + }); +} + +=head2 _get_hardware_product + +Find the hardware product for the device referenced by the report. + +=cut + +sub _get_hardware_product ($c, $unserialized_report) { + if ($unserialized_report->{device_type} and $unserialized_report->{device_type} eq 'switch') { + return $c->db_hardware_products->active + ->search({ name => $unserialized_report->{product_name} }) + ->prefetch('hardware_product_profile') + ->single; + } + + # search by sku first + my $hw = $c->db_hardware_products->active + ->search({ sku => $unserialized_report->{sku} }) + ->prefetch('hardware_product_profile') + ->single; + return $hw if $hw; + + # fall back to legacy_product_name - this will warn if more than one matching row is found + return $c->db_hardware_products->active + ->search({ legacy_product_name => $unserialized_report->{product_name} }) + ->prefetch('hardware_product_profile') + ->single; +} + +=head2 _get_validation_plan + +Find the validation plan that should be used to validate the the device referenced by the +report. + +=cut + +sub _get_validation_plan ($c, $unserialized_report) { + my $validation_name = + $unserialized_report->{device_type} && $unserialized_report->{device_type} eq 'switch' + ? 'Conch v1 Legacy Plan: Switch' + : 'Conch v1 Legacy Plan: Server'; + + return $c->db_ro_validation_plans->active->search({ name => $validation_name })->single; +} + 1; __END__ diff --git a/lib/Conch/Controller/DeviceValidation.pm b/lib/Conch/Controller/DeviceValidation.pm index 9bb196702..9058ba39e 100644 --- a/lib/Conch/Controller/DeviceValidation.pm +++ b/lib/Conch/Controller/DeviceValidation.pm @@ -113,7 +113,7 @@ sub run_validation_plan ($c) { return; } - my @validation_results = Conch::ValidationSystem->new( + my ($status, @validation_results) = Conch::ValidationSystem->new( schema => $c->ro_schema, log => $c->log, )->run_validation_plan( diff --git a/lib/Conch/DB/Result/ValidationState.pm b/lib/Conch/DB/Result/ValidationState.pm index cd4d18215..bc5383b3a 100644 --- a/lib/Conch/DB/Result/ValidationState.pm +++ b/lib/Conch/DB/Result/ValidationState.pm @@ -206,6 +206,8 @@ __PACKAGE__->add_columns( '+completed' => { retrieve_on_insert => 1 }, ); +use experimental 'signatures'; + sub TO_JSON { my $self = shift; @@ -226,6 +228,24 @@ sub TO_JSON { return $data; } +=head2 prefetch_validation_results + +Add validation_state_members, validation_result rows to the resultset cache. This allows those +rows to be included in serialized data (see L). + +The implementation is gross because has-multi accessors always go to the db, so there is no +non-private way of extracting related rows from the result. + +=cut + +sub prefetch_validation_results ($self) { + for my $members ($self->{_relationship_data}{validation_state_members}) { + $_->related_resultset('validation_result')->set_cache([ $_->validation_result ]) + foreach $members->@*; + $self->related_resultset('validation_state_members')->set_cache($members); + } +} + 1; __END__ diff --git a/lib/Conch/Route/DeviceReport.pm b/lib/Conch/Route/DeviceReport.pm index ad3458d4a..ae1c87629 100644 --- a/lib/Conch/Route/DeviceReport.pm +++ b/lib/Conch/Route/DeviceReport.pm @@ -14,6 +14,7 @@ Conch::Route::DeviceReport Sets up the routes for /device_report: + POST /device_report GET /device_report/:device_report_id =cut @@ -22,6 +23,8 @@ sub routes { my $class = shift; my $device_report = shift; # secured, under /device_report + $device_report->post('/')->to('device_report#validate_report'); + # chainable action that extracts and looks up device_report_id from the path # and device_id from the device_report my $with_device_report = $device_report->under('/:device_report_id') diff --git a/lib/Conch/ValidationSystem.pm b/lib/Conch/ValidationSystem.pm index c5556d5da..3890d2237 100644 --- a/lib/Conch/ValidationSystem.pm +++ b/lib/Conch/ValidationSystem.pm @@ -54,6 +54,12 @@ Returns the name of all modules successfully loaded. =cut sub check_validation_plan ($self, $validation_plan) { + if ($validation_plan->deactivated) { + $self->log->warn('validation plan id '.$validation_plan->id + .' "'.$validation_plan->name.'" is inactive'); + return; + } + my %validation_modules; my $valid_plan = 1; foreach my $validation ($validation_plan->validations) { @@ -187,9 +193,9 @@ Runs the provided validation_plan against the provided device. All provided data objects can and should be read-only (fetched with a ro db handle). -If C<< no_save_db => 1 >> is passed, the validation records are returned, without writing them -to the database. Otherwise, a validation_state record is created and validation_result records -saved with deduplication logic applied. +If C<< no_save_db => 1 >> is passed, the validation records are returned (along with the +overall result status), without writing them to the database. Otherwise, a validation_state +record is created and validation_result records saved with deduplication logic applied. Takes options as a hash: @@ -244,7 +250,11 @@ sub run_validation_plan ($self, %options) { } $validator->validation_results; } - return @validation_results if $options{no_save_db}; + # maybe no validations ran? this is a problem. + if (not @validation_results) { + $self->log->warn('validations did not produce a result'); + return; + } my $status = reduce { $a eq 'error' || $b eq 'error' ? 'error' @@ -253,6 +263,8 @@ sub run_validation_plan ($self, %options) { : $a; # pass } map { $_->status } @validation_results; + return ($status, @validation_results) if $options{no_save_db}; + return $self->schema->resultset('validation_state')->create({ device_id => $device->id, device_report_id => $device_report->id, diff --git a/t/integration/device-reports.t b/t/integration/device-reports.t new file mode 100644 index 000000000..f5e16ccbd --- /dev/null +++ b/t/integration/device-reports.t @@ -0,0 +1,53 @@ +use v5.26; +use Mojo::Base -strict, -signatures; + +use Test::More; +use Test::Warnings; +use Path::Tiny; +use Test::Deep; +use Test::Conch; + +my $t = Test::Conch->new; + +my $ro_user = $t->load_fixture('ro_user_global_workspace')->user_account; +$t->authenticate(user => $ro_user->email); + +# matches report's product_name = Joyent-G1 +$t->load_fixture('hardware_product_profile_compute'); +my $hardware_product = $t->load_fixture('hardware_product_compute'); + +# create a validation plan with all current validations in it +Conch::ValidationSystem->new(log => $t->app->log, schema => $t->app->schema)->load_validations; +my @validations = $t->app->db_validations->all; +my ($validation_plan) = $t->load_validation_plans([{ + name => 'Conch v1 Legacy Plan: Server', + description => 'Test Plan', + validations => [ map $_->module, @validations ], +}]); + +subtest 'run report without an existing device' => sub { + my $report = path('t/integration/resource/passing-device-report.json')->slurp_utf8; + $t->post_ok('/device_report', { 'Content-Type' => 'application/json' }, $report) + ->status_is(200) + ->json_schema_is('ReportValidationResults') + ->json_cmp_deeply({ + device_id => 'TEST', + validation_plan_id => $validation_plan->id, + status => any(qw(error fail pass)), # likely some validations will hate this report. + # validations each produce one or more results + results => array_each(any(map +{ + id => undef, + validation_id => $_->id, + category => $_->module->category, + component_id => ignore, + device_id => 'TEST', + hardware_product_id => $hardware_product->id, + hint => ignore, + message => ignore, + order => ignore, + status => any(qw(error fail pass)), + }, @validations)), + }); +}; + +done_testing; diff --git a/t/validation-system/run_validations.t b/t/validation-system/run_validations.t index 78c9ebe70..dd6f3c718 100644 --- a/t/validation-system/run_validations.t +++ b/t/validation-system/run_validations.t @@ -69,13 +69,15 @@ subtest 'run_validation_plan, without saving state' => sub { schema => $t->app->ro_schema, ); - my @validation_results = $validation_system->run_validation_plan( + my ($status, @validation_results) = $validation_system->run_validation_plan( validation_plan => $validation_plan, device => $device, device_report => $device_report, no_save_db => 1, ); + is($status, 'pass', 'calculated the overall result from the plan'); + cmp_deeply( \@validation_results, all(