API-Docker
view release on metacpan or search on metacpan
t/stream_error.t view on Meta::CPAN
. 'is not lost with the return value';
is $err->events->[0]{stream}, "STEP 1/2: FROM alpine:3\n",
'in stream order, starting at the first step';
ok $err->events->[2]{errorDetail}, 'the error event is in there too';
# The message is trimmed for the exception text; the events must be the
# stream as the engine sent it, not a copy edited on the way past.
like $err->events->[2]{errorDetail}{message}, qr/\n\z/,
'the carried event keeps the trailing newline the engine sent';
unlike $err->message, qr/\n\z/, 'while the exception message is trimmed';
};
# ---------------------------------------------------------------------------
subtest 'a successful stream must not croak' => sub {
for my $fixture (qw(
images_build_stream.ndjson
images_build_quiet_stream.ndjson
images_pull_stream.ndjson
)) {
my $t = transport(load_fixture_raw($fixture));
my $events = eval { $t->_request('POST', '/build', ndjson => 1) };
is $@, '', "$fixture does not croak";
is ref $events, 'ARRAY', "$fixture still returns an ArrayRef";
}
};
subtest 'the word "error" in ordinary payload data is not a failure' => sub {
# A build that prints the word to stdout, and a pull whose status mentions
# it: the trigger is the errorDetail key, never the text.
my $body = encode_json({ stream => "npm ERR! errorDetail: nope\n" }) . "\n"
. encode_json({ status => 'error recovering, retrying' }) . "\n";
my $events = eval { transport($body)->_request('POST', '/build', ndjson => 1) };
is $@, '', 'no croak';
is scalar @$events, 2, 'both events returned';
};
subtest 'system->events never croaks on its own data' => sub {
# The /events feed opts out: an object in it records something that
# happened on the engine, so it is data even shaped like a failure.
my $t = transport(load_fixture_raw('system_events_stream.ndjson'));
my $events = eval { $t->system->events(since => 1, until => 2) };
is $@, '', 'a captured podman event stream does not croak';
is scalar @$events, 5, 'all five events';
my $hostile = encode_json({ Type => 'container', Action => 'die' }) . "\n"
. encode_json({ errorDetail => { message => 'this is event data' } }) . "\n";
$t = transport($hostile);
$events = eval { $t->system->events(since => 1, until => 2) };
is $@, '', 'and neither does one that literally carries errorDetail';
is scalar @$events, 2, 'the event is handed over as data';
};
# ---------------------------------------------------------------------------
subtest 'the stringification contract' => sub {
my $t = transport(load_fixture_raw('images_build_error_stream.ndjson'));
eval { $t->images->build(context => 'tar-bytes') };
my $err = $@;
# Everything else in this distribution croaks strings and consumers rely on
# it -- ../p5-dist-zilla-plugin-docker-api strips Carp's location tail off
# $@ with a substitution. All of this has to keep working unchanged.
like "$err", qr/ at \S+ line \d+\.?/,
'carries Carp\'s location suffix, exactly as the plain croak it replaces';
like $err, qr/exit status 7/, 'matches a regex without an explicit stringify';
ok $err, 'boolean-true, so if ($@) still detects it';
is $err . '', "$err", 'concatenation goes through the overload';
is sprintf('%s', $err), "$err", 'so does sprintf %s';
ok $err eq "$err", 'and string comparison';
is $err->message . $err->location, "$err",
'message and location are separable, and together they are the string';
unlike $err->message, qr/ at \S+ line \d+/,
'message on its own is the bare reason, no location';
# The consumer's exact treatment, from Dist::Zilla::Plugin::Docker::API.
my $copy = $err;
$copy =~ s/\s+at\s+\S+\s+line\s+\d+\.?//g;
$copy =~ s/\s+/ /g;
$copy =~ s/^\s+|\s+$//g;
is ref \$copy, 'SCALAR', 's/// on the exception yields a plain string';
like $copy, qr/exit status 7\z/,
'the location is stripped and the reason survives';
isa_ok $err, 'API::Docker::Error::Stream',
'the original is untouched by the copy\'s substitution';
};
subtest 'a message that ends in a newline still gets a location' => sub {
# Engine messages end in "\n" and Carp appends no location to a message
# that already does, so the trailing whitespace has to come off first.
my $body = encode_json({ errorDetail => { message => "boom\n" } }) . "\n";
eval { transport($body)->_request('POST', '/build', ndjson => 1) };
like "$@", qr/boom at \S+ line \d+\./,
'the trailing newline is trimmed and the suffix lands';
};
subtest 'errorDetail without a usable message falls back' => sub {
my $body = encode_json({ errorDetail => {}, error => 'flat error text' }) . "\n";
eval { transport($body)->_request('POST', '/build', ndjson => 1) };
like "$@", qr/flat error text/,
'the flat error key is the fallback when errorDetail has no message';
$body = encode_json({ errorDetail => {} }) . "\n";
eval { transport($body)->_request('POST', '/build', ndjson => 1) };
like "$@", qr/no message given/, 'and there is a last resort';
isa_ok $@, 'API::Docker::Error::Stream', 'still the exception class';
};
subtest 'the query string stays out of the message' => sub {
# /build carries buildargs in the query string, which can hold credentials.
my $body = encode_json({ errorDetail => { message => 'nope' } }) . "\n";
eval {
transport($body)->_request('POST', '/build',
ndjson => 1,
params => { t => 'app:v1', buildargs => { NPM_TOKEN => 'sekrit' } },
);
};
like "$@", qr{\QPOST /v1.41/build\E}, 'the endpoint is named';
unlike "$@", qr/sekrit/, 'the build args are not';
};
# ---------------------------------------------------------------------------
SKIP: {
skip 'mock routes are bypassed in live mode', 1 if is_live();
subtest 'which endpoints opt out of the check' => sub {
my %saw;
my $docker = test_docker(
'POST /build' => sub { $saw{build} = { @_[2 .. $#_] }; [] },
'POST /images/create' => sub { $saw{pull} = { @_[2 .. $#_] }; [] },
'POST /images/nginx/push' => sub { $saw{push} = { @_[2 .. $#_] }; [] },
'GET /events' => sub { $saw{events} = { @_[2 .. $#_] }; [] },
);
$docker->images->build(context => 'tar-bytes', t => 'x:1');
$docker->images->pull(fromImage => 'nginx');
$docker->images->push('nginx');
$docker->system->events(since => 1, until => 2);
# The check defaults on, so an operation endpoint says nothing at all --
# a new streaming endpoint gets the loud behaviour without being told.
ok !exists $saw{$_}{croak_on_error}, "$_ leaves the check at its default"
for qw( build pull push );
is $saw{events}{croak_on_error}, 0, 'events is the one endpoint opting out';
};
}
SKIP: {
skip 'live write tests disabled (API_DOCKER_TEST_WRITE=1 to enable)', 1
unless can_write();
subtest 'live: a build that fails croaks against a real engine' => sub {
# Measured, not assumed: the engine answers 200 and reports the failure
# inside the stream, and the build never commits an image. But rm=1 (the
# default) only removes the intermediate container on a *successful*
# build -- a failing RUN step leaves it behind, under an engine-chosen
# name (Docker: priceless_driscoll, ...) or, on Podman, as a buildah
# "working container" that GET /containers/json never lists at all
# (measured against 5.8.4). karr k63: a day of live runs stranded 14 such
# containers, on both engines, invisible to register_cleanup because none
# of them are named by the test -- the daemon creates them, not us.
#
# forcerm=1 is the engine's own answer to exactly this case ("always
# remove intermediate containers, even upon failure"). The build stream
# was checked first, as the ticket suggested: Docker's classic builder
# does put the id in a "Running in <id>" stream line, but Podman's
# buildah backend never mentions an id anywhere in the stream or the
# response headers, so scraping the stream cannot be the general fix.
# forcerm=1 was then measured directly (raw curl probes against both
# sockets, karr k63) to close the leak on both: Docker's stream gains a
# "Removed intermediate container" line and containers->list stays
# unchanged; Podman leaves no new buildah storage container (checked via
# `buildah containers`, since the compat API cannot see them either way).
my $docker = API::Docker->new(host => $ENV{API_DOCKER_TEST_HOST});
my $tag = 'apidocker-56-stream-error:test';
# Defensive: the build is expected to fail and tag nothing, but a broken
# rm=1/tag interaction should still not leave an image behind untidied.
register_cleanup(sub { eval { $docker->images->remove($tag, force => 1) } });
# Docker's compat /containers/json is what forcerm=1 is supposed to keep
# empty of anything new, so a before/after diff there is a real,
# mutation-testable check for that engine: drop forcerm=1 and it fails
# (measured). Podman's leak lives in buildah's storage layer, which that
# same endpoint never lists even with a leaked container present
# (measured: GET /containers/json?all=true stays [] regardless) -- there
# is no API surface left on that engine to assert against, so the check
# below is Docker-only rather than silently passing there too.
#
# Registered before the build call, and recomputing the diff itself at
# cleanup time rather than closing over a precomputed list, so this still
# runs -- and still finds the right containers -- even if an assertion
# below fails or something after this point dies unexpectedly.
my @before = live_engine() eq 'docker'
? map { $_->id } @{ $docker->containers->list(all => 1) }
: ();
if (live_engine() eq 'docker') {
my %seen_before = map { $_ => 1 } @before;
register_cleanup(sub {
my $after = eval { $docker->containers->list(all => 1) } || [];
eval { $docker->containers->remove($_->id, force => 1) }
for grep { !$seen_before{ $_->id } } @$after;
});
}
my $dockerfile = "FROM alpine:3\nRUN exit 7\n";
my $tar = _tar_context($dockerfile);
my $events = eval {
$docker->images->build(context => $tar, t => $tag, forcerm => 1)
};
my $err = $@;
ok !defined $events, 'no return value from the failed build';
isa_ok $err, 'API::Docker::Error::Stream';
ok scalar @{ $err->events } > 1, 'the build output came with it';
my ($error_event) = grep { ref $_ eq 'HASH' && $_->{errorDetail} } @{ $err->events };
ok $error_event, 'one of the carried events is the one that triggered the croak';
# The claim is that the RUN step's exit code reaches the caller -- not
# any particular sentence about it, which karr k50 already ruled out as
# a stable interface. Measured 2026-08-27: Docker 29.7.2/API 1.55 puts it
# in errorDetail structurally --
# {"code":7,"message":"...returned a non-zero code: 7"} -- while Podman
# 5.4.2/API 1.41 sends only {"message":"...exit status 7\n"}, no `code`
# key at all. So the exit code is genuinely only prose on Podman; assert
# the strongest form each engine actually offers rather than a wording
# that happens to appear on both (there isn't one).
if (live_engine() eq 'docker') {
is $error_event->{errorDetail}{code}, 7,
'Docker carries the RUN exit status structurally, in errorDetail.code';
( run in 1.506 second using v1.01-cache-2.11-cpan-6736b670a1e )