API-Docker

 view release on metacpan or  search on metacpan

t/plugins.t  view on Meta::CPAN

# familiar and tagged, PluginReference fully qualified. No daemon reachable
# from this repo serves /plugins, so they are not a capture.
my $PLUGIN_JSON = '{'
  . '"Id":"5724e2c8652da337ab2eedd19fc6fc0ec908e4bd907c7421bf6a8dfc70c4c078",'
  . '"Name":"vieux/sshfs:latest",'
  . '"Enabled":true,'
  . '"PluginReference":"docker.io/vieux/sshfs:latest",'
  . '"Settings":{"Mounts":[],"Env":["DEBUG=0"],"Args":[],"Devices":[]},'
  . '"Config":{"Description":"sshFS plugin for Docker",'
  . '"Interface":{"Types":["docker.volumedriver/1.0"],"Socket":"sshfs.sock"}}'
  . '}';

# ---------------------------------------------------------------------------
subtest 'list: filters are JSON-encoded, and the filter name is "enabled"' => sub {
  my $c = fake_client('[]');
  $c->plugins->list(filters => { enabled => ['true'] });

  like request_line($c->written), qr{\AGET /v1\.41/plugins\?}, 'GET /plugins';
  is_deeply decode_json(query_param($c->written, 'filters')),
    { enabled => ['true'] },
    'the filters HashRef reaches the wire as a JSON map of string to array '
    . 'of string';

  # Not "enable". The Engine API reference documents that spelling, but the
  # daemon validates plugin filter names against {enabled, capability} and
  # refuses an unknown one outright, so copying the reference is a hard
  # error rather than a silently empty list.
  like query_param($c->written, 'filters'), qr/"enabled"/,
    'the name sent is enabled, not enable';
};

subtest 'list: no filters means no query string' => sub {
  my $c = fake_client('[]');
  my $got = $c->plugins->list;
  is request_line($c->written), 'GET /v1.41/plugins HTTP/1.1',
    'an empty params hash appends nothing';
  is_deeply $got, [], 'an engine with no plugins wraps to an empty ArrayRef';
};

# ---------------------------------------------------------------------------
subtest 'list wraps into API::Docker::Type::Plugin entities' => sub {
  my $c = fake_client('[' . $PLUGIN_JSON . ']');
  my $plugins = $c->plugins->list;

  is ref $plugins, 'ARRAY', 'an ArrayRef, one entry per plugin';
  my $plugin = $plugins->[0];
  isa_ok $plugin, 'API::Docker::Type::Plugin';

  is $plugin->id,
    '5724e2c8652da337ab2eedd19fc6fc0ec908e4bd907c7421bf6a8dfc70c4c078',
    'id';
  is $plugin->name, 'vieux/sshfs:latest', 'name';
  is $plugin->plugin_reference, 'docker.io/vieux/sshfs:latest',
    'plugin_reference -- the remote the plugin came from, which is not the '
    . 'local name once a plugin is installed under one';
  # Settings and Config used to be handed through as the HashRefs the engine
  # sent; the generated model inflates them into classes of their own, so
  # what they carry is reached by accessor a level down rather than by key.
  is_deeply $plugin->settings->env, ['DEBUG=0'],
    'settings is an API::Docker::Type::Plugin::Settings, and its env is the '
    . 'list of KEY=value strings configure() takes';
  is $plugin->config->interface->socket, 'sshfs.sock',
    'config likewise, two levels of generated class down';
  # The canned payload above is the Engine API reference's example and carries
  # no Config.Env, so the two shapes of that one key are asserted on the model
  # rather than on a payload invented to show them.
  my $settings_env = 'API::Docker::Type::Plugin::Settings'
    ->docker_attributes->{env}{isa}->display_name;
  my $config_env = 'API::Docker::Type::Plugin::Config'
    ->docker_attributes->{env}{isa}->display_name;
  like $settings_env, qr/Str/, 'Settings.Env is declared as strings';
  like $config_env, qr/PluginEnv/,
    'and Config.Env as PluginEnv objects -- same wire name, two shapes, one '
    . 'level apart';

  # This claim is the reverse of the one the hand-written entity carried.
  # API::Docker::Plugin mirrored the daemon verbatim, so Enabled stayed the
  # decoded JSON boolean and the test asserted is_bool on it. The generated
  # class declares the field as Bool and normalises it on the way in, so the
  # accessor is a plain 1/0 -- and TO_JSON turns it back into a JSON boolean,
  # which is where "nothing is lost" now lives.
  is $plugin->enabled, 1, 'enabled is 1 for an enabled plugin';
  ok !is_bool($plugin->enabled),
    'a plain Perl 1, not the decoded JSON boolean -- the model normalises a '
    . 'declared Bool rather than passing it through';
  ok is_bool($plugin->TO_JSON->{Enabled}),
    'and it goes back to the engine as a JSON boolean, so the round trip '
    . 'still says what the daemon said';

  # Without this the entity is inert: every method below reaches the engine
  # through the client, and a wrapper built without one dies on an
  # undefined invocant at the first call.
  is $plugin->client, $c, 'the client is threaded into the entity';
};

# ---------------------------------------------------------------------------
subtest 'the plugin name reaches the path unescaped' => sub {
  my $c = fake_client('{"Name":"docker.io/vieux/sshfs:latest"}');
  $c->plugins->inspect('docker.io/vieux/sshfs:latest');

  is request_line($c->written),
    'GET /v1.41/plugins/docker.io/vieux/sshfs:latest/json HTTP/1.1',
    'registry host, repository slashes and the tag colon all survive raw';

  # The daemon routes this family as /plugins/{name:.*}/json, so the slashes
  # are part of the captured name and percent-encoding them would not match.
  unlike $c->written, qr/%2F|%3A/i, 'nothing in the name got percent-encoded';
};

subtest 'inspect wraps too' => sub {
  my $c = fake_client($PLUGIN_JSON);
  my $plugin = $c->plugins->inspect('vieux/sshfs:latest');

  isa_ok $plugin, 'API::Docker::Type::Plugin';
  is $plugin->name, 'vieux/sshfs:latest', 'the single object is wrapped, '
    . 'not returned as the HashRef it arrived as';
  ok $plugin->enabled, 'and its fields are reachable as accessors';

  my $again = $plugin->inspect;
  is request_line($c->written),
    'GET /v1.41/plugins/vieux/sshfs:latest/json HTTP/1.1',
    'the entity re-inspects itself by name';
  isa_ok $again, 'API::Docker::Type::Plugin';
};

subtest 'the entity threads its name back through the resource class' => sub {
  my $c = fake_client('[' . $PLUGIN_JSON . ']');
  my $plugin = $c->plugins->list->[0];

  # Each request writes to a fresh sink, so ->written is always the last one.
  $plugin->enable(timeout => 30);
  is request_line($c->written),
    'POST /v1.41/plugins/vieux/sshfs:latest/enable?timeout=30 HTTP/1.1',
    'enable: the name goes into the path and the option is passed on';

  $plugin->disable(force => 1);
  is request_line($c->written),
    'POST /v1.41/plugins/vieux/sshfs:latest/disable?force=1 HTTP/1.1',
    'disable';

  $plugin->configure(['DEBUG=1']);
  is request_line($c->written),
    'POST /v1.41/plugins/vieux/sshfs:latest/set HTTP/1.1', 'configure';
  is_deeply decode_json(request_body($c->written)), ['DEBUG=1'],
    'and the settings survive the extra hop as an array';

  $plugin->upgrade(privileges => $PRIVILEGES);
  is request_line($c->written),
    'POST /v1.41/plugins/vieux/sshfs:latest/upgrade?remote=vieux/sshfs:latest'
    . ' HTTP/1.1',
    'upgrade: remote defaults to the local name, which is why the entity '
    . 'carries plugin_reference for the renamed case';
  is_deeply decode_json(request_body($c->written)), $PRIVILEGES,
    'the privilege body is not swallowed by the delegation';

  # The socket is an in-memory sink; this reaches no registry, and there is
  # no live variant of this call anywhere in the suite.
  $plugin->push;
  is request_line($c->written),
    'POST /v1.41/plugins/vieux/sshfs:latest/push HTTP/1.1',
    'push, which shadows the builtin in both packages and is only ever '
    . 'called as a method';

  $plugin->remove(force => 1);
  is request_line($c->written),
    'DELETE /v1.41/plugins/vieux/sshfs:latest?force=1 HTTP/1.1', 'remove';
};

subtest 'remove: force is normalised to 1/0 and omitted when unset' => sub {
  my $c = fake_client('');
  $c->plugins->remove('vieux/sshfs:latest');
  is request_line($c->written),
    'DELETE /v1.41/plugins/vieux/sshfs:latest HTTP/1.1',
    'no force parameter when the caller passed none';

  $c->plugins->remove('vieux/sshfs:latest', force => 1);
  is query_param($c->written, 'force'), '1', 'force => 1';

  $c->plugins->remove('vieux/sshfs:latest', force => 0);
  is query_param($c->written, 'force'), '0',
    'an explicit false is still sent, as 0';
};

# ---------------------------------------------------------------------------
subtest 'enable: timeout is always sent' => sub {
  my $c = fake_client('');
  $c->plugins->enable('vieux/sshfs:latest');

  is request_line($c->written),
    'POST /v1.41/plugins/vieux/sshfs:latest/enable?timeout=0 HTTP/1.1',
    'timeout=0 is sent even though the caller passed no timeout';

  # This is not tidiness. The daemon reads the raw query value and parses it
  # with Go's strconv.Atoi, with no default: an absent timeout is parsed as
  # the empty string and the request fails with
  # `strconv.Atoi: parsing "": invalid syntax`. Making this parameter
  # conditional, the way force and filters are, would break every enable.
  $c->plugins->enable('vieux/sshfs:latest', timeout => 30);
  is query_param($c->written, 'timeout'), '30', 'an explicit timeout is used';

  $c->plugins->enable('vieux/sshfs:latest', timeout => 0);
  is query_param($c->written, 'timeout'), '0', 'an explicit 0 stays 0';
};

t/plugins.t  view on Meta::CPAN

  is query_param($c->written, 'remote'), 'vieux/sshfs:v2',
    'the remote reference is in the query';
};

subtest 'upgrade: accept_privileges asks about the remote, not the local name' => sub {
  my $docker = API::Docker->new(
    host        => 'unix:///nonexistent.sock',
    api_version => '1.41',
  );

  my @calls;
  no warnings 'redefine';
  local *API::Docker::_request = sub {
    my ($self, $method, $path, %opts) = @_;
    push @calls, { path => $path, %opts };
    return $PRIVILEGES if $path eq '/plugins/privileges';
    return [];
  };

  $docker->plugins->upgrade('sshfs', remote => 'vieux/sshfs:v2',
    accept_privileges => 1);

  is $calls[0]{params}{remote}, 'vieux/sshfs:v2',
    'an upgrade is where the demands can change, so the new reference is '
    . 'what gets asked about';
};

subtest 'upgrade: privileges are required too' => sub {
  my $docker = API::Docker->new(
    host        => 'unix:///nonexistent.sock',
    api_version => '1.41',
  );
  eval { $docker->plugins->upgrade('vieux/sshfs:latest') };
  like $@, qr/upgrade requires privileges/, 'refused, naming the method';
};

# ---------------------------------------------------------------------------
subtest 'push: request assembly only' => sub {
  # This never reaches a registry -- the socket is an in-memory sink. There
  # is no live variant of this subtest and there must not be one.
  my $c = fake_client(qq({"status":"Pushing"}\n));
  $c->plugins->push('myrepo/sshfs:v1', auth => { username => 'u', password => 'p' });

  is request_line($c->written),
    'POST /v1.41/plugins/myrepo/sshfs:v1/push HTTP/1.1',
    'POST /plugins/{name}/push, no query string';
  my ($hdr) = $c->written =~ /^X-Registry-Auth: (\S+)\r$/m;
  is_deeply decode_json(b64url_decode($hdr)), { username => 'u', password => 'p' },
    'credentials in X-Registry-Auth, which the reference does not document '
    . 'on this endpoint but the daemon reads';
};

subtest 'push: anonymous sends no auth header' => sub {
  my $c = fake_client(qq({"status":"Pushing"}\n));
  $c->plugins->push('myrepo/sshfs:v1');
  unlike $c->written, qr/X-Registry-Auth/i,
    'unlike images->push, which must always send one';
};

# ---------------------------------------------------------------------------
subtest 'configure: settings are a JSON array of strings' => sub {
  my $c = fake_client('');
  $c->plugins->configure('vieux/sshfs:latest', ['DEBUG=1', 'sshkey.source=/tmp']);

  is request_line($c->written),
    'POST /v1.41/plugins/vieux/sshfs:latest/set HTTP/1.1',
    'POST /plugins/{name}/set';
  is_deeply decode_json(request_body($c->written)),
    ['DEBUG=1', 'sshkey.source=/tmp'], 'body is the settings array';

  $c->plugins->configure('vieux/sshfs:latest', 'DEBUG=1');
  is_deeply decode_json(request_body($c->written)), ['DEBUG=1'],
    'a bare list is accepted and still sent as an array, so a single '
    . 'setting cannot become a JSON string by accident';
};

subtest 'configure: validation' => sub {
  my $docker = API::Docker->new(
    host        => 'unix:///nonexistent.sock',
    api_version => '1.41',
  );

  eval { $docker->plugins->configure('vieux/sshfs:latest') };
  like $@, qr/requires at least one setting/, 'no settings is refused';

  eval { $docker->plugins->configure('vieux/sshfs:latest', []) };
  like $@, qr/requires at least one setting/, 'an empty ArrayRef too';

  eval { $docker->plugins->configure('vieux/sshfs:latest', { DEBUG => 1 }) };
  like $@, qr/settings must be plain strings/,
    'a HashRef is refused rather than encoded as an object';

  eval { $docker->plugins->configure() };
  like $@, qr/plugin name required/, 'and the name is required';
};

# ---------------------------------------------------------------------------
subtest 'every name-taking method croaks without a name' => sub {
  my $docker = API::Docker->new(
    host        => 'unix:///nonexistent.sock',
    api_version => '1.41',
  );

  for my $method (qw( inspect remove enable disable push )) {
    eval { $docker->plugins->$method(undef) };
    like $@, qr/plugin name required/, "$method croaks on an undefined name";
  }

  eval { $docker->plugins->upgrade(undef, privileges => []) };
  like $@, qr/plugin name required/, 'upgrade croaks on an undefined name';

  eval { $docker->plugins->privileges(undef) };
  like $@, qr/remote reference required/,
    'privileges croaks on an undefined remote';
};

done_testing;



( run in 0.356 second using v1.01-cache-2.11-cpan-aadc1410aed )