API-Docker

 view release on metacpan or  search on metacpan

lib/API/Docker/API/Containers.pm  view on Meta::CPAN

  my %params;
  $params{follow}     = $opts{follow} ? 1 : 0 if defined $opts{follow};
  $params{stdout}     = defined $opts{stdout} ? ($opts{stdout} ? 1 : 0) : 1;
  $params{stderr}     = defined $opts{stderr} ? ($opts{stderr} ? 1 : 0) : 1;
  $params{since}      = $opts{since}      if defined $opts{since};
  $params{until}      = $opts{until}      if defined $opts{until};
  $params{timestamps} = $opts{timestamps} ? 1 : 0 if defined $opts{timestamps};
  $params{tail}       = $opts{tail}       if defined $opts{tail};
  # exists, not truth: an unset callback is a caller bug, and quietly falling
  # back to the buffered path for it would answer a follow with a hang.
  return $self->client->stream_frames('GET', "/containers/$id/logs",
    params => \%params,
    defined $opts{tty} ? ( tty => $opts{tty} ) : (),
    %{ $self->_request_options },
    exists $opts{on_frame} ? ( on_frame => $opts{on_frame} ) : (),
  );
}


# The guard behind attach's require_running, and a pre-flight check is all it
# is: it asks the engine what the container is doing now, and the container may
# still stop between that answer and the attach landing. That race is not
# closable from a client -- the engine offers no attach-if-running -- and the
# check earns its round trip anyway, because the condition it tests is exactly
# the condition that does the damage. Measured on Podman 5.4.2 (API 1.41) and
# Docker 29.7.2 (API 1.55), one container per row, each exiting with status 4:
#
#   attach to an ALREADY-EXITED container      Podman: status destroyed
#   attach while RUNNING, exits under the call Podman: status intact (4)
#   either of those                            Docker: status intact (4)
#
# So "running at the moment of the call" is the whole of the condition. A
# container that is still running when attach is sent stays safe even when it
# exits a millisecond later, which is why the pre-flight answer is worth having
# despite being one round trip stale.
#
# It fails open on anything it does not recognise: a State it cannot read is
# not evidence that the container is stopped, and a guard that is unsure must
# not be the thing that breaks a working call.
sub _assert_container_running {
  my ($self, $id) = @_;

  # A State the model could not use is one more shape the check does not
  # recognise, and it arrives as one: the generated classes type their fields
  # from the swagger, and a State that is not the object
  # ContainerInspectResponse declares -- the bare status string of the list
  # shape, say -- leaves ->state unset and keeps the raw value in
  # unknown_fields rather than taking the response down with it. So there is
  # nothing to catch here; an error that does reach this line, the daemon's
  # own 404 included, is the caller's and goes up.
  my $inspected = $self->inspect($id);

  # An API::Docker::Type::ContainerState, or undef where the daemon sent no
  # State at all -- which is the "does not recognise" case above, not a stopped
  # container.
  my $state = $inspected->state;
  return unless blessed($state) && defined $state->running;
  return if $state->running;

  my $status = $state->status;
  $status = 'not running' unless defined $status && length $status;

  croak __PACKAGE__ . '->attach refused: container ' . $id . ' is ' . $status
    . '. Attaching to a container that is not running destroys its exit status '
    . 'on Podman, irrecoverably -- the engine keeps no copy -- and with '
    . 'stream => 1 never returns on either engine. Read its output with logs() '
    . 'instead, or pass require_running => 0 to attach anyway';
}

sub attach {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;

  # Pre-flight, and deliberately before the request is built: the call itself
  # is what destroys the exit status, so a check made afterwards could only
  # report the loss rather than prevent it. Turned off it costs nothing at all,
  # not even the round trip.
  my $require_running
    = defined $opts{require_running} ? $opts{require_running} : 1;
  # The pre-flight is a request the caller never wrote, and one that hangs is
  # exactly what a bound was set to prevent -- so it carries the same one. It
  # does that by itself here: the check runs on $self, which is the clone
  # ->using returned when there was one (karr k74).
  $self->_assert_container_running($id) if $require_running;

  my %params;
  $params{stream} = $opts{stream} ? 1 : 0;
  $params{logs}   = defined $opts{logs}   ? ($opts{logs}   ? 1 : 0) : 1;
  $params{stdout} = defined $opts{stdout} ? ($opts{stdout} ? 1 : 0) : 1;
  $params{stderr} = defined $opts{stderr} ? ($opts{stderr} ? 1 : 0) : 1;
  $params{stdin}  = $opts{stdin} ? 1 : 0 if defined $opts{stdin};
  return $self->client->stream_frames('POST', "/containers/$id/attach",
    params => \%params,
    defined $opts{tty} ? ( tty => $opts{tty} ) : (),
    %{ $self->_request_options },
    exists $opts{on_frame} ? ( on_frame => $opts{on_frame} ) : (),
  );
}


sub top {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my %params;
  $params{ps_args} = $opts{ps_args} if defined $opts{ps_args};
  return $self->client->get("/containers/$id/top",
    params => \%params,
    %{ $self->_request_options },
  );
}


# The Podman compatibility path, and deliberately only that. Podman answers
# GET /containers/{id}/stats for a container that is not running with an error
# object inside a response it has already committed to 200 --
# {"cause":"container is stopped","message":"container is stopped",
# "response":500}, chunked, for the one-shot call and for stream => 1 alike
# (measured on Podman 5.4.2, API 1.41). Neither guard in the transport sees
# it: the >= 400 croak reads the status line, which says 200, and the stream
# check triggers on errorDetail, which this object does not carry. Docker
# 29.7.2 (API 1.55) answers the same call with a real, zero-filled reading and

lib/API/Docker/API/Containers.pm  view on Meta::CPAN

# endpoint and the one engine it was measured on, and not in Role::HTTP, where
# it would be a heuristic on daemon prose sitting under all twelve modules.
#
# All four clauses have to hold. The narrowness is the point, not an accident:
#
#   1. the status was 2xx. True by construction on both paths this guards:
#      _request croaks before returning for >= 400, and
#      _read_streaming_response reads such a body whole rather than handing it
#      to a callback, so nothing that failed the status line reaches here
#   2. the decoded value is a HashRef
#   3. it carries all three of cause, message and response, exactly
#      lower-cased. Never case-insensitively, and this is the counter-example
#      that fixes it: POST /containers/{id}/wait answers its SUCCESS case with
#      a top-level `Error` key -- Podman sends "Error":null on every wait --
#      so a rule matching /error/i would turn every successful wait into a
#      failure. Measured over fifteen read endpoints per engine and every
#      fixture in t/fixtures: no 2xx body on either engine carries even one of
#      these three lower-cased at the top level
#   4. `response` is a non-ref scalar reading as an integer >= 400. That is
#      what makes the rule self-evidencing rather than a guess about prose:
#      the object is an error because Podman says so inside it. Known miss:
#      Podman's GET /plugins answers {"cause":"","message":"Path ... is not
#      supported","response":0}, which clause 4 rejects -- but it arrives with
#      404 on the status line and the transport croaks it long before this
#      runs, so the miss goes in the conservative direction and costs nothing
#
# A bare {message => ...} deliberately does not trigger: that is the ordinary
# Docker error body, and treating one inside a 2xx as a failure would be a
# guess about prose rather than a reading of what the engine said.
sub _podman_error_object {
  my ($self, $value) = @_;

  return unless ref $value eq 'HASH';
  return unless exists $value->{cause}
    && exists $value->{message}
    && exists $value->{response};

  my $response = $value->{response};
  return if ref $response;
  return unless defined $response && $response =~ /\A[0-9]+\z/;
  return unless $response >= 400;

  return $value;
}

# API::Docker::Error::HTTP rather than ::Stream: the one-shot call is not a
# stream at all, so "Docker API stream error" would be the wrong sentence for
# it and ->events would be a fabricated list. What the caller wants instead is
# exactly what this class carries -- ->status for the code Podman named, and
# ->data for the object, whose `cause` key that attribute's own POD already
# points at. Two of its attributes are left at their defaults on this path, on
# purpose: ->reason, because the status line's reason phrase was "OK" and
# putting that on a 500 would mislead, and ->body, because the bytes were
# decoded by the transport before this check ever saw them.
sub _assert_no_podman_error {
  my ($self, $endpoint, $value) = @_;

  my $error = $self->_podman_error_object($value) or return $value;

  my $reason = $error->{message};
  $reason = $error->{cause}    unless defined $reason && length $reason;
  $reason = 'no message given' unless defined $reason && length $reason;
  # Carp appends no location to a message that already ends in a newline.
  $reason =~ s/\s+\z//;

  # The object goes into a variable first: `croak CLASS->new(...)` is indirect
  # object syntax and parses as CLASS->croak(new(...)). Carp hands a reference
  # straight back rather than decorating it, so the location is captured by
  # hand, naming the frame a croak of a plain string would have named.
  my $err = API::Docker::Error::HTTP->new(
    message  => 'Docker API error (' . $error->{response} . '): ' . $reason
      . ' -- reported inside a 200 response to ' . $endpoint,
    location => shortmess(''),
    status   => $error->{response},
    data     => $error,
  );
  croak $err;
}

sub stats {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my $stream = $opts{stream} ? 1 : 0;
  my %params = ( stream => $stream );
  # one-shot asks the engine not to wait for a second sampling cycle, which
  # only means anything to a single reading. It is sent for the one-shot call
  # alone, the way it always was, and never beside stream => 1.
  $params{'one-shot'} = 1 unless $stream;

  my $endpoint = 'GET /containers/' . $id . '/stats';

  # The guard has to sit on both sides of the callback split, because the same
  # body arrives either way: buffered it is the return value, streamed it goes
  # to the callback and is never returned at all. Wrapping puts the check in
  # front of the caller's callback, so no caller is handed the error object as
  # though it were a reading. Only a CodeRef is wrapped -- anything else is
  # passed through untouched, so the transport still raises its own "on_event
  # option must be a CodeRef" instead of this method dying on a closure it
  # built around a non-callback.
  my $on_event = $opts{on_event};
  if (exists $opts{on_event} && ref $on_event eq 'CODE') {
    my $cb = $on_event;
    $on_event = sub {
      my ($reading, $stop) = @_;
      $self->_assert_no_podman_error($endpoint, $reading);
      return $cb->($reading, $stop);
    };
  }

  my $result = $self->client->get("/containers/$id/stats",
    params => \%params,
    %{ $self->_request_options },
    exists $opts{on_event} ? ( on_event => $on_event )
      : $stream            ? ( ndjson   => 1 )
      : (),
  );

  # A HashRef for the one-shot call and an ArrayRef of readings for
  # stream => 1 without a callback -- both are the buffered body and both can
  # be that error object. With a callback the return value is the summary
  # HashRef, which carries none of the three keys and passes untouched.
  $self->_assert_no_podman_error($endpoint, $_)

lib/API/Docker/API/Containers.pm  view on Meta::CPAN

  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my %params;
  $params{condition} = $opts{condition} if defined $opts{condition};
  return $self->client->post("/containers/$id/wait", undef,
    params => \%params,
    %{ $self->_request_options },
  );
}


sub pause {
  my ($self, $id) = @_;
  croak "Container ID required" unless $id;
  return $self->_state_change("/containers/$id/pause",
    %{ $self->_request_options },
  );
}


sub unpause {
  my ($self, $id) = @_;
  croak "Container ID required" unless $id;
  return $self->_state_change("/containers/$id/unpause",
    %{ $self->_request_options },
  );
}


sub rename {
  my ($self, $id, $name) = @_;
  croak "Container ID required" unless $id;
  croak "New name required" unless $name;
  return $self->client->post("/containers/$id/rename", undef,
    params => { name => $name },
    %{ $self->_request_options },
  );
}


# The update body is Resources + RestartPolicy; the booleans are the two
# Resources flags. Normalised on the way out, as for create.
my @UPDATE_BOOLS = qw( Init OomKillDisable );

sub update {
  my ($self, $id, %config) = @_;
  croak "Container ID required" unless $id;
  $self->_json_bools(\%config, @UPDATE_BOOLS);
  return $self->client->post("/containers/$id/update", \%config);
}


# The engine reports what a path is in a response header rather than a body,
# so both GET and HEAD carry it and only HEAD has nothing else to say. The
# header is base64-encoded JSON; handing the caller the base64 would make
# every one of them write this.
sub _decode_path_stat {
  my ($self, $response) = @_;

  my $header = $response->{headers}{'x-docker-container-path-stat'};
  return undef unless defined $header && length $header;

  # Docker encodes this one with Go's base64.StdEncoding -- unlike
  # X-Registry-Auth, which is URLEncoding. Decoded tolerantly rather than
  # strictly: translating the two URL-safe characters first costs nothing and
  # means an engine that reached for the other alphabet is still read.
  $header =~ tr{-_}{+/};
  my $stat = eval { decode_json(decode_base64($header)) };
  croak "Cannot decode X-Docker-Container-Path-Stat header: $@"
    unless ref $stat eq 'HASH';

  return $stat;
}

sub get_archive {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  croak "Path required" unless defined $opts{path} && length $opts{path};
  croak "The stat option must be a HashRef"
    if exists $opts{stat} && ref $opts{stat} ne 'HASH';

  my %response;
  my $tar = $self->client->get("/containers/$id/archive",
    params   => { path => $opts{path} },
    raw      => 1,
    response => \%response,
    %{ $self->_request_options },
  );

  if (my $out = $opts{stat}) {
    %$out = %{ $self->_decode_path_stat(\%response) // {} };
  }

  return $tar;
}


sub put_archive {
  my ($self, $id, $tar, %opts) = @_;
  croak "Container ID required" unless $id;
  croak "Path required" unless defined $opts{path} && length $opts{path};
  croak "Tar archive required (raw bytes or a scalar ref)" unless defined $tar;

  my %params = ( path => $opts{path} );
  $params{noOverwriteDirNonDir} = $opts{noOverwriteDirNonDir} ? 1 : 0
    if defined $opts{noOverwriteDirNonDir};
  $params{copyUIDGID} = $opts{copyUIDGID} ? 1 : 0
    if defined $opts{copyUIDGID};

  my $raw = ref $tar eq 'SCALAR' ? $$tar : $tar;

  return $self->client->put("/containers/$id/archive", undef,
    params       => \%params,
    raw_body     => $raw,
    content_type => 'application/x-tar',
    %{ $self->_request_options },
  );
}


sub stat_archive {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  croak "Path required" unless defined $opts{path} && length $opts{path};

  my %response;
  $self->client->head("/containers/$id/archive",
    params   => { path => $opts{path} },
    response => \%response,
    %{ $self->_request_options },
  );

  return $self->_decode_path_stat(\%response);
}


sub prune {
  my ($self, %opts) = @_;
  my %params;
  $params{filters} = $self->_normalise_filters($opts{filters})
    if defined $opts{filters};
  return $self->client->post('/containers/prune', undef,
    params => \%params,
    %{ $self->_request_options },
  );
}



1;

__END__

=pod

=encoding UTF-8

=head1 NAME

API::Docker::API::Containers - Docker Engine Containers API

=head1 VERSION

version 0.004

=head1 SYNOPSIS

    my $docker = API::Docker->new;

    # List containers
    my $containers = $docker->containers->list(all => 1);
    for my $container (@$containers) {
        say $container->id;
        say $container->status;
    }

    # Create and start a container
    my $result = $docker->containers->create(
        Image => 'nginx:latest',
        name  => 'my-nginx',
        ExposedPorts => { '80/tcp' => {} },
    );
    $docker->containers->start($result->{Id});



( run in 3.769 seconds using v1.01-cache-2.11-cpan-54e63673c56 )