Developer-Dashboard
view release on metacpan or search on metacpan
t/141-smart-router-guard-container-reclaim.t view on Meta::CPAN
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use Cwd qw(abs_path getcwd);
use Errno ();
use File::Spec;
use Test::More;
use lib 't/lib';
use Local::DockerGuard;
my $ROOT = abs_path( getcwd() );
# A fixed "now" and a fixed staleness window keep every decision below a pure
# function of its inputs, so this file never depends on a docker daemon, on the
# host clock, or on which pids happen to exist while it runs.
my $NOW = 1_786_000_000;
my $STALE_AFTER = 3600;
my $OWN = 'dd-smart-router-two-stage-4242-1785999900';
# Purpose: build the pid-liveness probe the decision function consults, from an
# explicit set of pids the caller wants to pretend are still running.
# Input: a list of pids to report as alive.
# Output: a coderef taking one pid and returning true only for those pids.
sub alive_only {
my (@alive) = @_;
my %alive = map { $_ => 1 } @alive;
return sub {
my ($pid) = @_;
return $alive{$pid} ? 1 : 0;
};
}
subtest 'the reclaim decision only ever targets this guard\'s own container names' => sub {
my @names = (
'dd-smart-router-two-stage-111-1785000000', # a real leak
'dd-smart-router-two-stage', # prefix without the pid/epoch tail
'dd-smart-router-two-stage-111', # pid but no epoch
'dd-smart-router-two-stage-abc-1785000000', # non-numeric pid
'dd-smart-router-two-stage-111-xyz', # non-numeric epoch
'my-dd-smart-router-two-stage-111-1785000000', # merely contains the prefix
'dd-blank-env-integration-111-1785000000', # another dashboard container
'postgres', # somebody else's container entirely
);
my @stale = Local::DockerGuard::stale_guard_containers(
names => \@names,
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
);
is_deeply(
\@stale,
['dd-smart-router-two-stage-111-1785000000'],
'only names matching the guard\'s own dd-smart-router-two-stage-<pid>-<epoch> shape are reclaimed',
);
};
subtest 'the container this run owns is never reclaimed' => sub {
my @stale = Local::DockerGuard::stale_guard_containers(
names => [ $OWN, 'dd-smart-router-two-stage-111-1785000000' ],
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
);
is_deeply(
\@stale,
['dd-smart-router-two-stage-111-1785000000'],
'the running guard\'s own container is excluded even though its pid probe is not consulted',
);
};
subtest 'a container whose owning process is gone is reclaimed immediately' => sub {
# The leak this ticket exists for: the run was SIGKILLed, so its END block
# never ran, and its pid is long gone. It must not have to age out first.
my $fresh_leak = sprintf 'dd-smart-router-two-stage-%d-%d', 777, $NOW - 5;
my @stale = Local::DockerGuard::stale_guard_containers(
names => [$fresh_leak],
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
);
is_deeply(
\@stale,
[$fresh_leak],
'a seconds-old container whose embedded pid is dead is reclaimed without waiting for the age window',
);
};
subtest 'a concurrently running guard is left alone' => sub {
my $concurrent = sprintf 'dd-smart-router-two-stage-%d-%d', 888, $NOW - 60;
my @stale = Local::DockerGuard::stale_guard_containers(
names => [$concurrent],
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(888),
);
is_deeply( \@stale, [], 'a recent container whose owning pid is still alive is never removed underneath it' );
};
subtest 'the age window backstops pid recycling' => sub {
# A dead run's pid can be handed to an unrelated live process, which would
# make the liveness probe vouch for a container nobody owns. No guard run
# lasts an hour, so age settles it.
my $recycled = sprintf 'dd-smart-router-two-stage-%d-%d', 999, $NOW - $STALE_AFTER;
my @stale = Local::DockerGuard::stale_guard_containers(
names => [$recycled],
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(999),
);
is_deeply(
\@stale,
[$recycled],
'a container older than the staleness window is reclaimed even when its embedded pid resolves to a live process',
);
my $just_inside = sprintf 'dd-smart-router-two-stage-%d-%d', 999, $NOW - $STALE_AFTER + 1;
my @kept = Local::DockerGuard::stale_guard_containers(
names => [$just_inside],
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(999),
);
is_deeply( \@kept, [], 'a live-owned container one second inside the window is still left alone' );
};
subtest 'a container stamped in the future is judged by its owner, not by its clock' => sub {
my $skewed = sprintf 'dd-smart-router-two-stage-%d-%d', 555, $NOW + 120;
my @alive_owner = Local::DockerGuard::stale_guard_containers(
names => [$skewed],
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(555),
);
is_deeply( \@alive_owner, [], 'a future-stamped container with a live owner is kept' );
my @dead_owner = Local::DockerGuard::stale_guard_containers(
names => [$skewed],
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
);
is_deeply( \@dead_owner, [$skewed], 'a future-stamped container with a dead owner is still reclaimed' );
};
subtest 'reclaim_guard_containers lists, removes, and verifies through one injected runner' => sub {
my $leak_one = 'dd-smart-router-two-stage-111-1785000000';
my $leak_two = 'dd-smart-router-two-stage-222-1785000001';
my @calls;
my @listings = (
join( "\n", $leak_one, $OWN, 'postgres', $leak_two ) . "\n",
"$OWN\npostgres\n",
);
my @removed = Local::DockerGuard::reclaim_guard_containers(
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
runner => sub {
my (@argv) = @_;
push @calls, [@argv];
return ( shift(@listings), q{}, 0 ) if $argv[1] eq 'ps';
return ( q{}, q{}, 0 );
},
);
is_deeply( [ sort @removed ], [ sort $leak_one, $leak_two ], 'both leaked containers are reported as reclaimed' );
is_deeply(
$calls[0],
[ 'docker', 'ps', '-a', '--format', '{{.Names}}' ],
'the container inventory comes from docker ps -a over names only',
);
is_deeply( $calls[1], [ 'docker', 'rm', '-f', $leak_one ], 'the first leak is force-removed by name' );
is_deeply( $calls[2], [ 'docker', 'rm', '-f', $leak_two ], 'the second leak is force-removed by name' );
is_deeply(
$calls[3],
[ 'docker', 'ps', '-a', '--format', '{{.Names}}' ],
'removal is verified by re-reading the inventory instead of trusting the exit status',
);
is( scalar @calls, 4, 'no other docker command is issued' );
};
subtest 'nothing is removed and no verification pass runs when there is no leak' => sub {
my @calls;
my @removed = Local::DockerGuard::reclaim_guard_containers(
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
runner => sub {
my (@argv) = @_;
push @calls, [@argv];
return ( "$OWN\npostgres\n", q{}, 0 );
},
);
is_deeply( \@removed, [], 'a clean host reclaims nothing' );
is( scalar @calls, 1, 'a clean host issues exactly one docker ps and no docker rm' );
};
subtest 'a container that survives its removal is a loud failure, not a silent one' => sub {
my $leak = 'dd-smart-router-two-stage-111-1785000000';
my $error = do {
local $@;
eval {
Local::DockerGuard::reclaim_guard_containers(
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
runner => sub {
my (@argv) = @_;
return ( "$leak\n", q{}, 0 ) if $argv[1] eq 'ps';
return ( q{}, "permission denied while removing $leak\n", 1 );
},
);
1;
};
$@;
};
like( $error, qr/\Q$leak\E/, 'the surviving container is named in the failure' );
like( $error, qr/permission denied/, 'the docker error text is surfaced rather than swallowed' );
};
subtest 'a removal that races to success is not reported as a failure' => sub {
# docker rm -f can exit non-zero because the container had already gone.
# The outcome is what matters, so a non-zero exit with the container
# actually absent afterwards must not fail the guard.
my $leak = 'dd-smart-router-two-stage-111-1785000000';
my @listings = ( "$leak\n", q{} );
my @removed = Local::DockerGuard::reclaim_guard_containers(
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
runner => sub {
my (@argv) = @_;
return ( shift(@listings), q{}, 0 ) if $argv[1] eq 'ps';
return ( q{}, "Error: No such container: $leak\n", 1 );
},
);
is_deeply( \@removed, [$leak], 'a container that is gone afterwards counts as reclaimed whatever docker rm exited with' );
};
subtest 'an unreadable container inventory stops the guard instead of pretending the host is clean' => sub {
my $error = do {
local $@;
eval {
Local::DockerGuard::reclaim_guard_containers(
own => $OWN,
now => $NOW,
stale_after => $STALE_AFTER,
pid_alive => alive_only(),
runner => sub { return ( q{}, "cannot connect to the docker daemon\n", 1 ) },
);
1;
};
$@;
};
like( $error, qr/docker ps/, 'the failing command is named' );
like( $error, qr/cannot connect to the docker daemon/, 'the daemon error text is surfaced' );
};
subtest 'the default pid probe distinguishes a missing process from an unkillable one' => sub {
ok( Local::DockerGuard::pid_alive($$), 'this test process is reported alive' );
ok( Local::DockerGuard::pid_alive(1), 'pid 1 is reported alive even though this uid may not signal it' );
ok( !Local::DockerGuard::pid_alive(0), 'pid 0 is never treated as an owner' );
ok( !Local::DockerGuard::pid_alive(-1), 'a negative pid is never treated as an owner' );
ok( !Local::DockerGuard::pid_alive(undef), 'an undefined pid is never treated as an owner' );
my $unused = _unused_pid();
SKIP: {
skip 'no unused pid could be found on this host', 1 if !$unused;
ok( !Local::DockerGuard::pid_alive($unused), 'a pid with no process behind it is reported dead' );
}
};
subtest 'the smart-router guard actually reclaims before it creates its own container' => sub {
my $guard = _slurp( File::Spec->catfile( $ROOT, 't', '44-smart-router-two-stage.t' ) );
like(
$guard,
qr/unshift \s+ \@INC, \s* File::Spec->catdir\( \s* \$ROOT, \s* 't', \s* 'lib' \s* \);/mx,
'the guard puts the repository test library on @INC by absolute path, not relative to the caller\'s cwd',
);
like( $guard, qr/^use \s+ Local::DockerGuard;/mx, 'the guard loads the reclaim helper' );
like(
$guard,
qr/Local::DockerGuard::reclaim_guard_containers\(/,
'the guard calls the reclaim helper',
);
my ($reclaim_at) = $guard =~ /(.*?)Local::DockerGuard::reclaim_guard_containers\(/s;
my ($run_at) = $guard =~ /(.*?)'docker',\s*'run',\s*'-d',/s;
ok(
defined $reclaim_at && defined $run_at && length($reclaim_at) < length($run_at),
'the reclaim runs before this run creates its own container, so a leak is collected by the next run',
);
like(
$guard,
qr/^END \s* \{/mx,
'the END-block teardown is kept as well, because reclaiming on start does not excuse leaking on a clean exit',
);
};
done_testing();
# Purpose: read a repository file in full so the guard's own source can be
# asserted against.
# Input: an absolute path to a readable file.
# Output: the file's contents as one string; dies when the file cannot be read.
sub _slurp {
my ($path) = @_;
open my $fh, '<', $path or die "Unable to read $path: $!";
local $/;
my $contents = <$fh>;
close $fh or die "Unable to close $path: $!";
return $contents;
}
# Purpose: find a pid that has no process behind it, so the dead-owner branch of
# the default liveness probe can be exercised for real.
# Input: none.
# Output: an unused pid, or undef when every probed pid is in use.
sub _unused_pid {
for my $candidate ( 4_000_000 .. 4_000_200 ) {
next if kill 0, $candidate;
next if $! != Errno::ESRCH();
return $candidate;
}
return undef;
}
( run in 0.443 second using v1.01-cache-2.11-cpan-14f38c9f855 )