view release on metacpan or search on metacpan
* Run daemons as target binary's owning user (supports run control)
* Clean up library path fiddling across all scripts
* Rename housekeeping expiry task to be expire
* Refactor nbtstat to group probes by device where node was arped
* Add note about pgtune to install docs
[BUG FIXES]
* Suggest installation of perl-core RPM (arguably Red Hat's bug not ours!)
* FATAL warnings considered harmful
* Only show STP blocked icon if port is UP
* Store device model and serial via UTF8 conversion
* Only set housekeeping default if user has enabled
2.024004 - 2014-03-04
[BUG FIXES]
* Fix Login and Logout that were broken with SSL
2.024003_001 - 2014-03-03
lib/App/Netdisco/Backend/Role/Manager.pm view on Meta::CPAN
package App::Netdisco::Backend::Role::Manager;
use Dancer qw/:moose :syntax :script/;
use List::Util 'sum';
use Proc::ProcessTable;
use App::Netdisco::Util::MCE;
use App::Netdisco::Backend::Job;
use App::Netdisco::JobQueue
qw/jq_locked jq_getsome jq_lock jq_warm_thrusters/;
use Role::Tiny;
use namespace::clean;
sub worker_begin {
my $self = shift;
my $wid = $self->wid;
return debug "mgr ($wid): no need for manager... skip begin"
if setting('workers')->{'no_manager'};
lib/App/Netdisco/Backend/Role/Manager.pm view on Meta::CPAN
# job queue initialisation
#Â the expensive parts of this were moved to primeskiplist job
jq_warm_thrusters;
#Â queue a job to rebuild the device action skip list
$self->{queue}->enqueuep(200,
App::Netdisco::Backend::Job->new({ job => 0, action => 'primeskiplist' }));
# requeue jobs locally
debug "mgr ($wid): searching for jobs booked to this processing node";
my @jobs = jq_locked;
if (scalar @jobs) {
info sprintf "mgr (%s): found %s jobs booked to this processing node",
$wid, scalar @jobs;
$self->{queue}->enqueuep(100, @jobs);
}
}
# creates a 'signature' for each job so that we can check for duplicates ...
# it happens from time to time due to the distributed nature of the job queue
lib/App/Netdisco/DB/ExplicitLocking.pm view on Meta::CPAN
);
}
use constant \%lock_modes;
use base 'Exporter';
our @EXPORT = ();
our @EXPORT_OK = (keys %lock_modes);
our %EXPORT_TAGS = (modes => \@EXPORT_OK);
sub txn_do_locked {
my ($self, $table, $mode, $sub) = @_;
my $sql_fmt = q{LOCK TABLE %s IN %%s MODE};
my $schema = $self;
if ($self->can('result_source')) {
# ResultSet component
$sub = $mode;
$mode = $table;
$table = $self->result_source->from;
$schema = $self->result_source->schema;
}
$schema->throw_exception('missing Table name to txn_do_locked()')
unless $table;
$table = [$table] if ref '' eq ref $table;
my $table_fmt = join ', ', ('%s' x scalar @$table);
my $sql = sprintf $sql_fmt, $table_fmt;
if (ref '' eq ref $mode and $mode) {
scalar grep {$_ eq $mode} values %lock_modes
or $schema->throw_exception('bad LOCK_MODE to txn_do_locked()');
}
else {
$sub = $mode;
$mode = 'ACCESS EXCLUSIVE';
}
$schema->txn_do(sub {
my @params = map {$schema->storage->dbh->quote_identifier($_)} @$table;
$schema->storage->dbh->do(sprintf $sql, @params, $mode);
$sub->();
lib/App/Netdisco/DB/ExplicitLocking.pm view on Meta::CPAN
=head1 SYNOPSIS
In your L<DBIx::Class> schema:
package My::Schema;
__PACKAGE__->load_components('+App::Netdisco::DB::ExplicitLocking');
Then, in your application code:
use App::Netdisco::DB::ExplicitLocking ':modes';
$schema->txn_do_locked($table, MODE_NAME, sub { ... });
This also works for the ResultSet:
package My::Schema::ResultSet::TableName;
__PACKAGE__->load_components('+App::Netdisco::DB::ExplicitLocking');
Then, in your application code:
use App::Netdisco::DB::ExplicitLocking ':modes';
$schema->resultset('TableName')->txn_do_locked(MODE_NAME, sub { ... });
=head1 DESCRIPTION
This L<DBIx::Class> component provides an easy way to execute PostgreSQL table
locks before a transaction block.
You can load the component in either the Schema class or ResultSet class (or
both) and then use an interface very similar to C<DBIx::Class>'s C<txn_do()>.
The package also exports constants for each of the table lock modes supported
lib/App/Netdisco/DB/ExplicitLocking.pm view on Meta::CPAN
=item * C<SHARE_ROW_EXCLUSIVE>
=item * C<EXCLUSIVE>
=item * C<ACCESS_EXCLUSIVE>
=back
=head1 METHODS
=head2 C<< $schema->txn_do_locked($table|\@tables, MODE_NAME?, $subref) >>
This is the method signature used when the component is loaded into your
Schema class. The reason you might want to use this over the ResultSet version
(below) is to specify multiple tables to be locked before the transaction.
The first argument is one or more tables, and is required. Note that these are
the real table names in PostgreSQL, and not C<DBIx::Class> ResultSet aliases
or anything like that.
The mode name is optional, and defaults to C<ACCESS EXCLUSIVE>. You must use
one of the exported constants in this parameter.
Finally pass a subroutine reference, just as you would to the normal
C<DBIx::Class> C<txn_do()> method. Note that additional arguments are not
supported.
=head2 C<< $resultset->txn_do_locked(MODE_NAME?, $subref) >>
This is the method signature used when the component is loaded into your
ResultSet class. If you don't yet have a ResultSet class (which is the default
- normally only Result classes are created) then you can create a stub which
simply loads this component (and inherits from C<DBIx::Class::ResultSet>).
This is the simplest way to use this module if you only want to lock one table
before your transaction block.
The first argument is the optional mode name, which defaults to C<ACCESS
lib/App/Netdisco/JobQueue.pm view on Meta::CPAN
use Module::Load ();
Module::Load::load
'App::Netdisco::JobQueue::' . $ENV{ND_QUEUE_ENGINE} => ':all';
use base 'Exporter';
our @EXPORT = ();
our @EXPORT_OK = qw/
jq_warm_thrusters
jq_getsome
jq_locked
jq_queued
jq_lock
jq_defer
jq_complete
jq_log
jq_userlog
jq_insert
jq_delete
/;
our %EXPORT_TAGS = ( all => \@EXPORT_OK );
lib/App/Netdisco/JobQueue.pm view on Meta::CPAN
subroutines.
=head1 EXPORT_OK
=head2 jq_getsome( $num? )
Returns a list of randomly selected queued jobs. Default is to return one job,
unless C<$num> is provided. Jobs are returned as objects which implement the
Netdisco job instance interface (see below).
=head2 jq_locked()
Returns the list of jobs currently booked out to this processing node (denoted
by the local hostname). Jobs are returned as objects which implement the
Netdisco job instance interface (see below).
=head2 jq_queued( $job_type )
Returns a list of IP addresses of devices which currently have a job of the
given C<$job_type> queued (e.g. C<discover>, C<arpnip>, etc).
lib/App/Netdisco/JobQueue/PostgreSQL.pm view on Meta::CPAN
use App::Netdisco::DB::ExplicitLocking ':modes';
use JSON::PP ();
use Try::Tiny;
use base 'Exporter';
our @EXPORT = ();
our @EXPORT_OK = qw/
jq_warm_thrusters
jq_getsome
jq_locked
jq_queued
jq_lock
jq_defer
jq_complete
jq_log
jq_userlog
jq_insert
jq_delete
/;
our %EXPORT_TAGS = ( all => \@EXPORT_OK );
lib/App/Netdisco/JobQueue/PostgreSQL.pm view on Meta::CPAN
while (my $job = $tasty->next) {
if ($job->device
and not scalar grep {$job->action eq $_} @{ setting('job_targets_prefix') }) {
# need to handle device discovered since backend daemon started
# and the skiplist was primed. these should be checked against
# the various acls and have device_skip entry added if needed,
# and return false if it should have been skipped.
my @badactions = get_denied_actions($job->device);
if (scalar @badactions) {
schema(vars->{'tenant'})->resultset('DeviceSkip')->txn_do_locked(EXCLUSIVE, sub {
schema(vars->{'tenant'})->resultset('DeviceSkip')->find_or_create({
backend => setting('workers')->{'BACKEND'}, device => $job->device,
},{ key => 'device_skip_pkey' })->add_to_actionset(@badactions);
});
# will now not be selected in a future _getsome()
next if scalar grep {$_ eq $job->action} @badactions;
}
}
lib/App/Netdisco/JobQueue/PostgreSQL.pm view on Meta::CPAN
}, { for => 'update' })
->update({ status => 'info', log => (sprintf 'duplicate of %s', $job->id) });
debug sprintf 'getsome: cancelled %s duplicate(s) of job %s', ($gone || 0), $job->id;
push @returned, App::Netdisco::Backend::Job->new({ $job->get_columns });
}
return @returned;
}
sub jq_locked {
my @returned = ();
my $rs = schema(vars->{'tenant'})->resultset('Admin')->search({
status => 'queued',
backend => setting('workers')->{'BACKEND'},
started => \[q/> (LOCALTIMESTAMP - ?::interval)/, setting('jobs_stale_after')],
});
while (my $job = $rs->next) {
push @returned, App::Netdisco::Backend::Job->new({ $job->get_columns });
}
lib/App/Netdisco/JobQueue/PostgreSQL.pm view on Meta::CPAN
# arpnip (and every other action) will be prevented on the device.
# seeing as defer is only triggered by an SNMP connect failure, this
# behaviour seems reasonable, to me (or desirable, perhaps).
#Â the deferrable_actions setting exists as a workaround to this behaviour
#Â should it be needed by any action (that is, per-device action but
#Â do not increment deferrals count and simply try to run again).
try {
schema(vars->{'tenant'})->resultset('DeviceSkip')->txn_do_locked(EXCLUSIVE, sub {
if ($job->device
and not scalar grep { $job->action eq $_ }
@{ setting('deferrable_actions') || [] }) {
schema(vars->{'tenant'})->resultset('DeviceSkip')->find_or_create({
backend => setting('workers')->{'BACKEND'}, device => $job->device,
},{ key => 'device_skip_pkey' })->increment_deferrals;
}
debug sprintf 'defer: job %s', ($job->id || 'unknown');
lib/App/Netdisco/JobQueue/PostgreSQL.pm view on Meta::CPAN
my $job = shift;
my $happy = false;
# lock db row and update to show job is done/error
# now that SNMP connect failures are deferrals and not errors, any complete
# status, whether success or failure, indicates an SNMP connect. reset the
# connection failures counter to forget about occasional connect glitches.
try {
schema(vars->{'tenant'})->resultset('DeviceSkip')->txn_do_locked(EXCLUSIVE, sub {
if ($job->device and not $job->is_offline
and not scalar grep {$job->action eq $_} @{ setting('job_targets_prefix') }) {
schema(vars->{'tenant'})->resultset('DeviceSkip')->find_or_create({
backend => setting('workers')->{'BACKEND'}, device => $job->device,
},{ key => 'device_skip_pkey' })->update({ deferrals => 0 });
}
schema(vars->{'tenant'})->resultset('Admin')
->search({ job => $job->id }, { for => 'update' })
lib/App/Netdisco/Web/Plugin/AdminTask/JobQueue.pm view on Meta::CPAN
ajax '/ajax/content/admin/jobqueue' => require_role admin => sub {
content_type('text/html');
my @backends = schema(vars->{'tenant'})->resultset('DeviceSkip')
->search({device => '255.255.255.255'})->hri->all;
my $num_backends = scalar @backends;
my $tot_workers = 0;
$tot_workers += $_->{deferrals} for @backends;
my $jq_locked = schema(vars->{'tenant'})->resultset('Admin')
->search({status => 'queued', backend => { '!=' => undef }})->count();
my $jq_backlog = schema(vars->{'tenant'})->resultset('Admin')
->search({status => 'queued', backend => undef })->count();
my $jq_done = schema(vars->{'tenant'})->resultset('Admin')
->search({status => 'done'})->count();
my $jq_errored = schema(vars->{'tenant'})->resultset('Admin')
->search({status => 'error'})->count();
lib/App/Netdisco/Web/Plugin/AdminTask/JobQueue.pm view on Meta::CPAN
backend => { '!=' => undef },
started => \[q/<= (LOCALTIMESTAMP - ?::interval)/, setting('jobs_stale_after')],
})->count();
my $jq_total = schema(vars->{'tenant'})->resultset('Admin')->count();
template 'ajax/admintask/jobqueue.tt', {
num_backends => commify($num_backends || '?'),
tot_workers => commify($tot_workers || '?'),
jq_running => commify($jq_locked - $jq_stale),
jq_backlog => commify($jq_backlog),
jq_done => commify($jq_done),
jq_errored => commify($jq_errored),
jq_stale => commify($jq_stale),
jq_total => commify($jq_total),
results => [ jq_log ],
}, { layout => undef };
};
lib/App/Netdisco/Web/Plugin/AdminTask/TimedOutDevices.pm view on Meta::CPAN
register_admin_task({
tag => 'timedoutdevices',
label => 'SNMP Connect Failures',
});
ajax '/ajax/control/admin/timedoutdevices/del' => require_role admin => sub {
send_error('Missing backend', 400) unless param('backend');
send_error('Missing device', 400) unless param('device');
schema(vars->{'tenant'})->resultset('DeviceSkip')->txn_do_locked(EXCLUSIVE, sub {
schema(vars->{'tenant'})->resultset('DeviceSkip')->find_or_create({
backend => param('backend'), device => param('device'),
},{ key => 'device_skip_pkey' })->update({ deferrals => 0 });
});
return '';
};
ajax '/ajax/content/admin/timedoutdevices' => require_role admin => sub {
my @set = schema(vars->{'tenant'})->resultset('DeviceSkip')->search({
lib/App/Netdisco/Web/Plugin/AdminTask/UndiscoveredNeighbors.pm view on Meta::CPAN
use App::Netdisco::Web::Plugin;
register_admin_task(
{ tag => 'undiscoveredneighbors',
label => 'Undiscovered Neighbors',
provides_csv => 1,
}
);
# just to note a problem with this query:
# using DeviceSkip to see if discover is blocked, but that table only shows
#Â blocked actions on backends not permitted, so there may be a backend running
#Â that permits the action, we would not know.
get '/ajax/content/admin/undiscoveredneighbors' => require_role admin => sub {
my @results
= schema(vars->{'tenant'})->resultset('Virtual::UndiscoveredNeighbors')->hri->all;
return unless scalar @results;
if ( request->is_ajax ) {
template 'ajax/admintask/undiscoveredneighbors.tt',
{ results => \@results, },
lib/App/Netdisco/Web/Plugin/Report/PortBlocking.pm view on Meta::CPAN
use Dancer ':syntax';
use Dancer::Plugin::DBIC;
use Dancer::Plugin::Auth::Extensible;
use App::Netdisco::Web::Plugin;
register_report(
{ category => 'Port',
tag => 'portblocking',
label => 'Blocked - Spanning Tree',
provides_csv => 1,
api_endpoint => 1,
}
);
get '/ajax/content/report/portblocking' => require_login sub {
my @results = schema(vars->{'tenant'})->resultset('Device')->search(
{ 'stp' => [ 'blocking', 'broken' ], 'up' => { '!=', 'down' } },
{ select => [ 'ip', 'dns', 'name' ],
join => ['ports'],
lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm view on Meta::CPAN
$device->ip, $port, $field, $oldval, $newval;
last PORTDIFF;
}
}
}
}
# support for Hooks
vars->{'hook_data'}->{'ports'} = [values %deviceports];
schema('netdisco')->resultset('DevicePort')->txn_do_locked(ACCESS_EXCLUSIVE, sub {
my $coder = JSON::PP->new->utf8(0)->allow_nonref(1)->allow_unknown(1);
#Â backup the custom_fields
my %fields = map {($_->port => $coder->decode(Encode::encode('UTF-8',$_->custom_fields || '{}')))}
grep {exists $deviceports{$_->port}}
values %{ vars->{'device_ports'} };
my %ok_fields = map {$_ => 1}
grep {defined}
map {$_->{name}}
lib/App/Netdisco/Worker/Plugin/Expire.pm view on Meta::CPAN
if (setting('expire_nodeip_orphans')) {
schema('netdisco')->resultset('NodeIp')->search({
mac => { -in => schema('netdisco')->resultset('NodeIp')->search(
{ port => undef },
{ join => 'nodes', select => [{ distinct => 'me.mac' }], }
)->as_query },
})->delete;
}
if (setting('expire_jobs') and setting('expire_jobs') > 0) {
schema('netdisco')->txn_do_locked('admin', EXCLUSIVE, sub {
schema('netdisco')->resultset('Admin')->search({
entered => \[q/< (LOCALTIMESTAMP - ?::interval)/,
(setting('expire_jobs') * 86400)],
})->delete();
});
}
if (setting('expire_userlog') and setting('expire_userlog') > 0) {
schema('netdisco')->txn_do_locked('admin', EXCLUSIVE, sub {
schema('netdisco')->resultset('UserLog')->search({
creation => \[q/< (LOCALTIMESTAMP - ?::interval)/,
(setting('expire_userlog') * 86400)],
})->delete();
});
}
# now update stats
update_stats();
share/config.yml view on Meta::CPAN
vendor,
os,
version
FROM
ranked_devices
WHERE
rn = 1
ORDER BY
device_name, serial
- tag: portserrordisabled
label: 'Blocked - Error-Disabled'
category: Port
columns:
- { ip: Device, _searchable: true }
- { dns: DNS }
- { port: Port }
- { name: Description }
- { reason: Reason }
query: |
SELECT dp.ip, d.dns, dp.port, dp.name, properties.error_disable_cause AS reason
FROM device_port dp
share/contrib/raddb/dictionary.shiva view on Meta::CPAN
VALUE Shiva-Disconnect-Reason Remote 1
VALUE Shiva-Disconnect-Reason Error 2
VALUE Shiva-Disconnect-Reason Idle-Timeout 3
VALUE Shiva-Disconnect-Reason Session-Timeout 4
VALUE Shiva-Disconnect-Reason Admin-Disconnect 5
VALUE Shiva-Disconnect-Reason Dialback 6
VALUE Shiva-Disconnect-Reason Virtual-Connection 7
VALUE Shiva-Disconnect-Reason Bandwidth-On-Demand 8
VALUE Shiva-Disconnect-Reason Failed-Authentication 9
VALUE Shiva-Disconnect-Reason Preempted 10
VALUE Shiva-Disconnect-Reason Blocked 11
VALUE Shiva-Disconnect-Reason Tariff-Management 12
VALUE Shiva-Disconnect-Reason Backup 13
# Shiva Function Values
VALUE Shiva-Function Unknown 0
VALUE Shiva-Function Dialin 1
VALUE Shiva-Function Dialout 2
VALUE Shiva-Function Lan-To-Lan 3
share/contrib/raddb/dictionary.usr view on Meta::CPAN
VALUE USR-Call-Event-Code noFreeIGW 10
VALUE USR-Call-Event-Code igwRejectCall 11
VALUE USR-Call-Event-Code igwSetupTimeout 12
VALUE USR-Call-Event-Code noFreeTdmts 13
VALUE USR-Call-Event-Code bcReject 14
VALUE USR-Call-Event-Code ieReject 15
VALUE USR-Call-Event-Code chidReject 16
VALUE USR-Call-Event-Code progReject 17
VALUE USR-Call-Event-Code callingPartyReject 18
VALUE USR-Call-Event-Code calledPartyReject 19
VALUE USR-Call-Event-Code blocked 20
VALUE USR-Call-Event-Code analogBlocked 21
VALUE USR-Call-Event-Code digitalBlocked 22
VALUE USR-Call-Event-Code outOfService 23
VALUE USR-Call-Event-Code busy 24
VALUE USR-Call-Event-Code congestion 25
VALUE USR-Call-Event-Code protocolError 26
VALUE USR-Call-Event-Code noFreeBchannel 27
VALUE USR-Call-Event-Code inOutCallCollision 28
VALUE USR-Call-Event-Code inCallArrival 29
VALUE USR-Call-Event-Code outCallArrival 30
VALUE USR-Call-Event-Code inCallConnect 31
VALUE USR-Call-Event-Code outCallConnect 32
share/public/swagger-ui/swagger-ui-bundle.js view on Meta::CPAN
/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(...
share/public/swagger-ui/swagger-ui-bundle.js.map view on Meta::CPAN
{"version":3,"file":"swagger-ui-bundle.js","mappings":";CAAA,SAAUA,iCAAiCC,EAAMC,GAC1B,iBAAZC,SAA0C,iBAAXC,OACxCA,OAAOD,QAAUD,IACQ,mBAAXG,QAAyBA,OAAOC,IAC9CD,OAAO,GAAIH,GACe,iBAAZC,QACdA,QAAyB,gBAAID,IAE7BD,EAAsB,gBAAIC,GAC3B,CATD,CASGK,MAAM,cCRLC,EA...
share/public/swagger-ui/swagger-ui.css view on Meta::CPAN
.swagger-ui{color:#3b4151;container-name:swagger-ui;container-type:inline-size;/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */font-family:sans-serif}.swagger-ui html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text...
/*# sourceMappingURL=swagger-ui.css.map*/
share/public/swagger-ui/swagger-ui.css.map view on Meta::CPAN
{"version":3,"file":"swagger-ui.css","mappings":"AAAA,YCII,aCcU,CFfd,yBACE,2BACA,4EGEF,CFJE,sBEIF,CDWc,iCCCZ,0BACA,8BACA,kBAUF,QACE,iHAOF,aAME,gBAQF,aACE,eACA,4DAWF,aAGE,oBAOF,eACE,gBAQF,sBACE,SACA,iBACA,iBAQF,+BACE,cACA,eAWF,4BACE,qCACA,yBAQF,kBACE,...
xt/21-check_acl_no_ipaddr_only.t view on Meta::CPAN
use_ok( 'App::Netdisco::Util::DNS', 'hostname_from_ip' );
}
use Dancer qw/:script !pass/;
# this is needed so that test works in github action container
# and actually ends up cutting out live DNS anyway ð
config->{'dns'} = { 'ETCHOSTS' => { localhost => [ [ '127.0.0.1' ] ] } };
config->{'dns'}->{'no'} = ['::1','fe80::/10','127.0.0.0/8','169.254.0.0/16'];
is(hostname_from_ip('127.0.0.1'), undef, '127.0.0.1 blocked');
config->{'dns'}->{'no'} = ['::1','fe80::/10','169.254.0.0/16'];
is(hostname_from_ip('127.0.0.1'), 'localhost', '127.0.0.1 allowed');
done_testing;
xt/32-swagger-ui-guard.t view on Meta::CPAN
# This file asserts the CONTENT of the shipped Swagger UI page, not its
# behaviour, and the distinction matters when reading a pass.
#
# The failure it guards is a re-vendor: dropping in a fresh upstream index.html
# reverts every local change below at once and silently, which is exactly how the
# petstore default arrived in the first place. A content assertion catches that.
#
# It cannot prove the page is safe, because it shares its input with the code it
# checks. The behavioural evidence is a browser driven against the page with
# off-host requests blocked and recorded, and it does not live in this suite,
# because the distribution has no working JavaScript test path.
my $page = catfile( $FindBin::Bin, updir(),
'share', 'public', 'swagger-ui', 'index.html' );
my $html = do {
open my $fh, '<', $page or die "cannot read $page: $!";
local $/;
<$fh>;
};
xt/51-updated-device-hook.t view on Meta::CPAN
package FakePortsRS; # stands in for a DBIC ResultSet of DevicePort rows
sub new { my ($class, @rows) = @_; return bless { rows => [@rows] }, $class; }
sub reset { return $_[0] }
sub all { return @{ $_[0]->{rows} } }
sub delete { return 0 }
sub populate { return 1 }
package FakeDevicePortRS; # only result_source (metadata) and locking are used
sub new { return bless {}, shift }
sub result_source { return App::Netdisco::DB->resultset('DevicePort')->result_source }
sub txn_do_locked { my ($self, $mode, $code) = @_; return $code->() }
package FakeSchema;
sub txn_do { my ($self, $code) = @_; return $code->() }
sub resultset {
my ($self, $name) = @_;
return FakeDevicePortRS->new if $name eq 'DevicePort';
return App::Netdisco::DB->resultset($name);
}
package main;