Implement Hooks per #726

This commit is contained in:
Oliver Gorwits
2020-11-28 14:45:56 +00:00
parent 225f9824b2
commit 669eec46db
11 changed files with 177 additions and 6 deletions

View File

@@ -270,6 +270,7 @@ sub jq_complete {
log => $job->log, log => $job->log,
started => $job->started, started => $job->started,
finished => $job->finished, finished => $job->finished,
(($job->action eq 'hook') ? (subaction => undef) : ()),
}); });
}); });
$happy = true; $happy = true;
@@ -284,6 +285,7 @@ sub jq_complete {
sub jq_log { sub jq_log {
return schema('netdisco')->resultset('Admin')->search({ return schema('netdisco')->resultset('Admin')->search({
{ 'me.action' => { '-not_like' => 'hook::%' } },
-or => [ -or => [
{ 'me.log' => undef }, { 'me.log' => undef },
{ 'me.log' => { '-not_like' => 'duplicate of %' } }, { 'me.log' => { '-not_like' => 'duplicate of %' } },

View File

@@ -29,8 +29,8 @@ subroutines.
=head2 check_acl_no( $ip | $instance, $setting_name | $acl_entry | \@acl ) =head2 check_acl_no( $ip | $instance, $setting_name | $acl_entry | \@acl )
Given an IP address or object instance, returns true if the configuration Given an IP address or object instance, returns true if the configuration
setting C<$setting_name> matches, else returns false. If the setting is setting C<$setting_name> matches, else returns false. If the content of the
undefined or empty, then C<check_acl_no> also returns false. setting is undefined or empty, then C<check_acl_no> also returns false.
If C<$setting_name> is a valid setting, then it will be resolved to the access If C<$setting_name> is a valid setting, then it will be resolved to the access
control list, else we assume you passed an ACL entry or ACL. control list, else we assume you passed an ACL entry or ACL.
@@ -51,8 +51,8 @@ sub check_acl_no {
=head2 check_acl_only( $ip | $instance, $setting_name | $acl_entry | \@acl ) =head2 check_acl_only( $ip | $instance, $setting_name | $acl_entry | \@acl )
Given an IP address or object instance, returns true if the configuration Given an IP address or object instance, returns true if the configuration
setting C<$setting_name> matches, else returns false. If the setting is setting C<$setting_name> matches, else returns false. If the content of the
undefined or empty, then C<check_acl_only> also returns true. setting is undefined or empty, then C<check_acl_only> also returns true.
If C<$setting_name> is a valid setting, then it will be resolved to the access If C<$setting_name> is a valid setting, then it will be resolved to the access
control list, else we assume you passed an ACL entry or ACL. control list, else we assume you passed an ACL entry or ACL.

View File

@@ -0,0 +1,34 @@
package App::Netdisco::Util::Worker;
use Dancer ':syntax';
use App::Netdisco::JobQueue 'jq_insert';
use Encode 'encode';
use MIME::Base64 'encode_base64';
use Storable 'dclone';
use Data::Visitor::Tiny;
use base 'Exporter';
our @EXPORT = ('queue_hook');
sub queue_hook {
my ($hook, $conf) = @_;
my $extra = { action_conf => dclone ($conf->{'with'} || {}),
event_data => dclone (vars->{'hook_data'} || {}) };
# remove scalar references which to_json cannot handle
visit( $extra->{'event_data'}, sub {
my ($key, $valueref) = @_;
$$valueref = '' if ref $$valueref eq 'SCALAR';
});
jq_insert({
action => ('hook::'. lc($conf->{'type'})),
extra => encode_base64( encode('UTF-8', to_json( $extra )) ),
});
return 1;
}
true;

View File

@@ -32,6 +32,8 @@ sub add_job {
foreach my $action (@{ setting('job_prio')->{high} }, foreach my $action (@{ setting('job_prio')->{high} },
@{ setting('job_prio')->{normal} }) { @{ setting('job_prio')->{normal} }) {
next if $action and $action =~ m/^hook::/; # skip hooks
ajax "/ajax/control/admin/$action" => require_role admin => sub { ajax "/ajax/control/admin/$action" => require_role admin => sub {
add_job($action, param('device'), param('extra')) add_job($action, param('device'), param('extra'))
or send_error('Bad device', 400); or send_error('Bad device', 400);

View File

@@ -46,7 +46,7 @@ register 'register_worker' => sub {
# support part-actions via action::namespace # support part-actions via action::namespace
if ($job->only_namespace and $workerconf->{phase} ne 'check') { if ($job->only_namespace and $workerconf->{phase} ne 'check') {
return unless $workerconf->{namespace} eq lc( $job->only_namespace ) return unless $workerconf->{namespace} eq lc( $job->only_namespace )
or (($workerconf->{phase} eq 'early') or (($job->only_namespace ne 'hooks') and ($workerconf->{phase} eq 'early')
and ($job->device and not $job->device->in_storage)); and ($job->device and not $job->device->in_storage));
} }

View File

@@ -0,0 +1,32 @@
package App::Netdisco::Worker::Plugin::Discover::Hooks;
use Dancer ':syntax';
use App::Netdisco::Worker::Plugin;
use aliased 'App::Netdisco::Worker::Status';
use App::Netdisco::Util::Worker;
use App::Netdisco::Util::Permission qw/check_acl_no check_acl_only/;
register_worker({ phase => 'late' }, sub {
my ($job, $workerconf) = @_;
my $count = 0;
foreach my $conf (@{ setting('hooks') }) {
my $no = ($conf->{'filter'}->{'no'} || []);
my $only = ($conf->{'filter'}->{'only'} || []);
next if check_acl_no( $job->device, $no );
next unless check_acl_only( $job->device, $only);
$count += queue_hook('new_device', $conf)
if vars->{'new_device'} and $conf->{'event'} eq 'new_device';
$count += queue_hook('discover', $conf)
if $conf->{'event'} eq 'discover';
}
return Status
->info(sprintf ' [%s] hooks - %d queued', $job->device, $count);
});
true;

View File

@@ -71,6 +71,13 @@ register_worker({ phase => 'early', driver => 'snmp' }, sub {
} }
} }
# support for Hooks
vars->{'hook_data'} = { $device->get_columns };
delete vars->{'hook_data'}->{'snmp_comm'}; # for privacy
# support for new_device Hook
vars->{'new_device'} = 1 if not $device->in_storage;
schema('netdisco')->txn_do(sub { schema('netdisco')->txn_do(sub {
$device->update_or_insert(undef, {for => 'update'}); $device->update_or_insert(undef, {for => 'update'});
return Status->done("Ended discover for $device"); return Status->done("Ended discover for $device");
@@ -149,6 +156,9 @@ register_worker({ phase => 'early', driver => 'snmp' }, sub {
push @$resolved_aliases, { alias => $device->ip, dns => $device->dns } push @$resolved_aliases, { alias => $device->ip, dns => $device->dns }
if 0 == scalar grep {$_->{alias} eq $device->ip} @aliases; if 0 == scalar grep {$_->{alias} eq $device->ip} @aliases;
# support for Hooks
vars->{'hook_data'}->{'device_ips'} = $resolved_aliases;
schema('netdisco')->txn_do(sub { schema('netdisco')->txn_do(sub {
my $gone = $device->device_ips->delete; my $gone = $device->device_ips->delete;
debug sprintf ' [%s] device - removed %d aliases', debug sprintf ' [%s] device - removed %d aliases',
@@ -228,7 +238,7 @@ register_worker({ phase => 'early', driver => 'snmp' }, sub {
if (exists $i_ignore->{$entry}) { if (exists $i_ignore->{$entry}) {
debug sprintf ' [%s] interfaces - ignoring %s (%s) (%s)', debug sprintf ' [%s] interfaces - ignoring %s (%s) (%s)',
$device->ip, $entry, $port, $i_type->{$entry}; $device->ip, $entry, $port, ($i_type->{$entry} || '');
next; next;
} }
@@ -298,6 +308,9 @@ register_worker({ phase => 'early', driver => 'snmp' }, sub {
$interfaces{$master}->{is_master} = 'true'; $interfaces{$master}->{is_master} = 'true';
} }
# support for Hooks
vars->{'hook_data'}->{'ports'} = [values %interfaces];
schema('netdisco')->resultset('DevicePort')->txn_do_locked(sub { schema('netdisco')->resultset('DevicePort')->txn_do_locked(sub {
my $gone = $device->ports->delete({keep_nodes => 1}); my $gone = $device->ports->delete({keep_nodes => 1});
debug sprintf ' [%s] interfaces - removed %d interfaces', debug sprintf ' [%s] interfaces - removed %d interfaces',

View File

@@ -124,6 +124,9 @@ register_worker({ phase => 'main', driver => 'snmp' }, sub {
}; };
} }
# support for Hooks
vars->{'hook_data'}->{'vlans'} = \@devicevlans;
schema('netdisco')->txn_do(sub { schema('netdisco')->txn_do(sub {
my $gone = $device->vlans->delete; my $gone = $device->vlans->delete;
debug sprintf ' [%s] vlans - removed %d device VLANs', debug sprintf ' [%s] vlans - removed %d device VLANs',

View File

@@ -0,0 +1,16 @@
package App::Netdisco::Worker::Plugin::Hook;
use Dancer ':syntax';
use App::Netdisco::Worker::Plugin;
use aliased 'App::Netdisco::Worker::Status';
register_worker({ phase => 'check' }, sub {
my ($job, $workerconf) = @_;
return Status->error('can only run a specific hook')
unless $job->action eq 'hook' and defined $job->only_namespace;
return Status->done('Hook is able to run.');
});
true;

View File

@@ -0,0 +1,63 @@
package App::Netdisco::Worker::Plugin::Hook::HTTP;
use Dancer ':syntax';
use App::Netdisco::Worker::Plugin;
use aliased 'App::Netdisco::Worker::Status';
use MIME::Base64 'decode_base64';
use HTTP::Tiny;
use Template;
register_worker({ phase => 'main' }, sub {
my ($job, $workerconf) = @_;
my $extra = from_json( decode_base64( $job->extra || '' ) );
my $event_data = $extra->{'event_data'};
my $action_conf = $extra->{'action_conf'};
$action_conf->{'body'} ||= to_json($event_data);
return Status->error('missing url parameter to http Hook')
if !defined $action_conf->{'url'};
my $tt = Template->new({ ENCODING => 'utf8' });
my $http = HTTP::Tiny
->new( timeout => (($action_conf->{'timeout'} || 5000) / 1000) );
$action_conf->{'custom_headers'} ||= {};
$action_conf->{'custom_headers'}->{'Content-Type'}
||= 'application/json; charset=UTF-8';
$action_conf->{'custom_headers'}->{'Authorization'}
= ('Bearer '. $action_conf->{'bearer_token'})
if $action_conf->{'bearer_token'};
my ($orig_url, $url) = ($action_conf->{'url'}, undef);
$action_conf->{'url_is_template'} ||= 1
if !exists $action_conf->{'url_is_template'};
$tt->process(\$orig_url, $event_data, \$url)
if $action_conf->{'url_is_template'};
$url ||= $orig_url;
my ($orig_body, $body) = ($action_conf->{'body'} , undef);
$action_conf->{'body_is_template'} ||= 1
if !exists $action_conf->{'body_is_template'};
$tt->process(\$orig_body, $event_data, \$body)
if $action_conf->{'body_is_template'};
$body ||= $orig_body;
my $response = $http->request(
($action_conf->{'method'} || 'POST'), $url,
{ headers => $action_conf->{'custom_headers'},
content => $body },
);
if ($action_conf->{'ignore_failure'} or $response->{'success'}) {
return Status->done(sprintf 'HTTP Hook: %s %s',
$response->{'status'}, $response->{'reason'});
}
else {
return Status->error(sprintf 'HTTP Hook: %s %s',
$response->{'status'}, $response->{'reason'});
}
});
true;

View File

@@ -347,6 +347,8 @@ dns:
hosts_file: '/etc/hosts' hosts_file: '/etc/hosts'
no: ['group:__LOCAL_ADDRESSES__','169.254.0.0/16'] no: ['group:__LOCAL_ADDRESSES__','169.254.0.0/16']
hooks: []
schedule: schedule:
discoverall: discoverall:
when: '5 7 * * *' when: '5 7 * * *'
@@ -364,6 +366,7 @@ schedule:
job_prio: job_prio:
high: high:
- hook::http
- location - location
- contact - contact
- portcontrol - portcontrol
@@ -401,11 +404,14 @@ worker_plugins:
- 'Discover::VLANs' - 'Discover::VLANs'
- 'Discover::Wireless' - 'Discover::Wireless'
- 'Discover::WithNodes' - 'Discover::WithNodes'
- 'Discover::Hooks'
- 'DiscoverAll' - 'DiscoverAll'
- 'DumpConfig' - 'DumpConfig'
- 'Expire' - 'Expire'
- 'ExpireNodes' - 'ExpireNodes'
- 'Graph' - 'Graph'
- 'Hook'
- 'Hook::HTTP'
- 'Location' - 'Location'
- 'Macsuck' - 'Macsuck'
- 'Macsuck::Nodes' - 'Macsuck::Nodes'