API-Docker

 view release on metacpan or  search on metacpan

t/containers_endpoints.t  view on Meta::CPAN

#!/usr/bin/env perl
use strict;
use warnings;
use Test::More;
use FindBin;
use lib "$FindBin::Bin/lib";
use Test::API::Docker::Mock;
use JSON::MaybeXS qw( encode_json );
use MIME::Base64 qw( encode_base64 );
use API::Docker;

# The container endpoints this client did not expose:
#
#   karr k18  GET/PUT/HEAD /containers/{id}/archive  -- what docker cp is
#   karr k19  POST /containers/{id}/attach           -- the one-way variant
#   karr k23  changes, export, resize
#
# Measured against the rootless Podman socket (5.4.2, API 1.41): all five
# routes are served there. A nonexistent container answers 404 on archive,
# export, resize and attach -- and 500 with "layer not known" on changes,
# which is why changes documents that difference.
#
# karr k36 closed the remaining gap: the bytes of a real archive, a real
# attach stream, and the X-Docker-Container-Path-Stat header are now captured
# from apidocker-fixture-* containers on that same socket rather than assumed.
# See the fixture-loading comments below for what each measurement found.

check_live_access();

# GET /containers/{id}/archive?path=/etc/hostname, captured from a running
# apidocker-fixture-archive container on Podman 5.4.2 (API 1.41) -- karr k36
# replaced the hand-built ustar that stood in here before. Measured
# differences from the hand-built version: uname/gname were populated
# ('root'/'root', not empty) on that 5.4.2 socket, devmajor/devminor are the
# ASCII string '0000000' rather than left as raw NUL bytes, and mode reflects
# the file's real permissions (0644, not the guessed 0664). Block size (512),
# the two trailing all-zero blocks that end the archive, the ustar
# magic/version, and the empty prefix field were already right in the
# hand-built one.
#
# karr k62 re-measured the same archive live on Podman 5.8.4 (API 1.44):
# uname/gname now come back NUL rather than 'root', byte-identical to a
# Docker 29.7.2 capture of the same file -- Podman changed to match Docker
# here, so this is no longer a difference between the two engines. The
# fixture below is kept as the 5.4.2 capture rather than recaptured: nothing
# in this file asserts uname/gname (only length, the ustar magic, the member
# name and byte-exact roundtrip through the transport are checked), so the
# 5.4.2 bytes still exercise exactly what this file tests.
my $TAR = load_fixture_raw('containers_archive.tar');

# The one-way attach stream is byte-identical to the logs stream, which is the
# whole claim of karr k19 -- and now measured, not just documented: karr k36
# attached live to an apidocker-fixture-attach-live container across its run
# (POST .../attach?stream=1&stdout=1&stderr=1, connected before the container
# started so the daemon had output to send) and diffed the bytes against
# GET .../logs?stdout=1&stderr=1 on an equivalent run; both came back as this
# same 24-byte frame pair, byte for byte. This is the captured logs fixture
# rather than a second file holding the same bytes: it is real engine output,
# and a copy made by hand would only look like one.
#
# A related hazard the measurement also turned up: attaching with stream=1 to
# a container that has *already* exited still sends the same 24 bytes, but
# Podman never closes the connection afterward -- no Content-Length, no
# chunked encoding, and no close even when the client sends Connection: close
# itself, which _request always does. Reading blocks until EOF, so that call
# hangs forever.
#
# karr k52 narrowed that down: it is stream=1 that hangs, not attach as such.
# Re-measured on Podman 5.4.2 (API 1.41) against one exited container:
# ?logs=1&stdout=1&stderr=1&stream=0 answers 200, sends the 24 bytes and
# closes after 13ms; the same request with stream=1 sends the identical bytes
# and hangs; with Upgrade: tcp it answers 101 UPGRADED and hangs the same
# way; and stream=1 against a container still *running*, which exits three
# seconds later, closes cleanly after 3s. The spec explains it -- stream is
# "from the time the request was made onwards" and its only terminator is the
# container ending, which for a stopped container already happened. So this
# client now follows the engine's own default of stream=0 and defaults logs=1
# instead. Docker was unverified when this was written; it has since been
# measured (29.7.2, API 1.55) and hangs identically, so the hang is not a
# Podman quirk but behaviour the reference leaves unspecified for both.
#
# The live subtests below still never call attach: the transport buffers, and
# an explicit stream => 1 is still a hang waiting to happen.
my $FRAMES = load_fixture_raw('containers_logs_multiplexed.bin');

# X-Docker-Container-Path-Stat for /etc/hostname, decoded from a real header
# captured alongside the archive above (karr k36) -- against the Podman
# socket, so this models Podman's shape specifically, not "the" shape. A
# later side-by-side against a real Docker daemon (29.7.2, API 1.55) on the
# same file confirmed what had only been a guess here: Podman's key names
# match the Docker Engine API reference for five of them (name, size, mode,
# mtime, linkTarget); the sixth, isDir, is Podman's own addition -- Docker
# never sends it, not even for a directory. Two more measured differences
# from Docker: linkTarget is populated here even for a plain regular file
# (Podman echoes the resolved path rather than leaving it empty, which is
# what Docker does), and mode is Go's os.FileMode, not a POSIX stat.st_mode
# word -- for this regular file the two are numerically identical (0644, no
# type bits), but they diverge for a directory. See the live subtest below
# for the Docker-side numbers next to these, and for the case that tells
# FileMode and st_mode apart.
my %STAT = (
  # Podman's answer for /etc/hostname. Docker's answer for the same file
  # omits isDir and reports linkTarget as '' rather than the resolved path;
  # see the live subtest below.
  name       => 'hostname',
  size       => 13,
  mode       => 420,
  mtime      => '2026-08-27T15:36:51.589296398Z',
  linkTarget => '/etc/hostname',
);
my $STAT_HEADER = encode_base64(encode_json(\%STAT), '');

# ---------------------------------------------------------------------------
# A client whose socket is an in-memory sink and whose response is canned, so
# the real _request runs -- and with it raw => 1, raw_body, the query string
# and the verb. Same pattern as t/images_tar.t and t/streaming_shape.t; the
# mock harness replaces _request wholesale and can reach none of it.
package Test::ContainersEndpoints::FakeTransport;
use Moo;
extends 'API::Docker';

has canned => (is => 'rw', default => sub { [200, 'OK', {}, ''] });
has _sink  => (is => 'rw');

sub _build__socket {
  my ($self) = @_;
  my $sink = '';
  $self->_sink(\$sink);
  open my $fh, '>', \$sink or die "open: $!";
  binmode $fh;
  return $fh;
}

sub _read_response { return $_[0]->canned }

sub written { return ${ $_[0]->_sink } }

sub request_line {
  my ($line) = $_[0]->written =~ /\A([^\r\n]+)\r\n/;
  return $line;
}

sub request_body {
  my ($body) = $_[0]->written =~ /\r\n\r\n(.*)\z/s;
  return $body;
}

package main;

sub fake_client {
  return Test::ContainersEndpoints::FakeTransport->new(
    host        => 'unix:///nonexistent.sock',
    api_version => '1.41',
  );
}

# ---------------------------------------------------------------------------
subtest 'the tar fixture really is a tar, so byte-exactness means something' => sub {
  is length($TAR) % 512, 0, 'a whole number of 512-byte blocks';
  is substr($TAR, 257, 5), 'ustar', 'ustar magic in the header block';
  is unpack('Z100', $TAR), 'hostname', 'one member, named after the basename';
  like $TAR, qr/\0/, 'carries NUL bytes -- it is not text';
};

# ===========================================================================
# karr k18 -- the archive endpoints
# ===========================================================================

subtest 'get_archive: asks for raw bytes and hands them back untouched' => sub {
  plan skip_all => 'route assertions are fixture-only' if is_live();

  my %seen;
  my $docker = test_docker(
    'GET /containers/deadbeef/archive' => sub {
      my ($method, $path, %opts) = @_;
      %seen = %opts;
      return $TAR;
    },
  );

  my $out = $docker->containers->get_archive('deadbeef', path => '/etc/hostname');

  ok $seen{raw}, 'the request asked the transport for raw bytes';
  ok !$seen{ndjson}, 'and not for a decoded event stream';
  is_deeply $seen{params}, { path => '/etc/hostname' },
    'path is the only query parameter';
  is $out, $TAR, 'the daemon bytes come back verbatim';
  is length($out), length($TAR), 'no truncation';
};

subtest 'get_archive: raw bytes survive the real _request' => sub {
  my $t = fake_client();
  $t->canned([200, 'OK', { 'content-type' => 'application/x-tar' }, $TAR]);

  my $out = $t->containers->get_archive('deadbeef', path => '/var/log/app.log');

  is $out, $TAR, 'byte-exact through _request';
  is $t->request_line,
    'GET /v1.41/containers/deadbeef/archive?path=/var/log/app.log HTTP/1.1',
    'GET on the versioned path, the path parameter keeping its slashes';
};

subtest 'get_archive: a body that looks like JSON is still not decoded' => sub {
  # The transport tries decode_json on any body starting with { or [ unless
  # raw is set. A tar cannot start that way, but the guarantee is "never
  # decoded", not "never decodable" -- so assert it directly.
  my $t = fake_client();
  $t->canned([200, 'OK', {}, '{"name":"not really a tar"}']);

  is ref $t->containers->get_archive('deadbeef', path => '/x'), '',
    'a JSON-shaped body comes back as a plain string, not a HashRef';



( run in 0.659 second using v1.01-cache-2.11-cpan-364913b4093 )