view release on metacpan or search on metacpan
passthrough/hash fields (fix in IO::K8s 1.008).
- Pod Port-Forward API: new port_forward() method in active v1 API.
Builds /portforward requests, supports multiple ports, and passes duplex
callbacks (on_open/on_frame/on_close/on_error) to IO backends.
- _prepare_request now supports array query parameters (repeated key=value)
and explicit header overrides/additions.
- Kubernetes::REST::Role::IO adds supports_duplex() capability probe and
documents optional call_duplex() transport hook.
1.100 2026-03-04 16:41:39Z
- Pod Log API: new log() method for retrieving and streaming pod logs.
Supports one-shot (returns full log text) and streaming mode (on_line
callback with Kubernetes::REST::LogEvent objects). Streaming mode supports
follow, tailLines, sinceSeconds, sinceTime, timestamps, previous,
limitBytes, and container parameters.
- New Kubernetes::REST::LogEvent class for typed log line events
- Public building block methods for async wrappers: build_path(),
prepare_request(), check_response(), inflate_object(), inflate_list(),
process_watch_chunk(), process_log_chunk(). These provide a stable public
API for event-based systems like Net::Async::Kubernetes to integrate
without relying on internal methods.
- Updated original author email and copyright holder
- Complete rewrite for v1 API
- Now uses IO::K8s for Kubernetes resource classes
- Simplified API: list(), get(), create(), update(), patch(), delete(), watch()
- Default HTTP backend switched from HTTP::Tiny to LWP::UserAgent
(enables LWP::ConsoleLogger for HTTP traffic debugging)
- New Kubernetes::REST::LWPIO backend (HTTPTinyIO still available as alternative)
- Pluggable IO architecture via Kubernetes::REST::Role::IO
- Patch support: strategic-merge-patch, JSON merge patch (RFC 7396),
JSON patch (RFC 6902)
- Watch API: streaming resource changes via Kubernetes Watch API
- Resumable watches via resourceVersion tracking
- Label and field selector support for list() and watch()
- Automatic URL building from IO::K8s class metadata
- Custom Resource Definition (CRD) support with the standard API
- Resource map for short class names (Pod -> IO::K8s::Api::Core::V1::Pod)
- Resource map '+' prefix for external classes ('+My::CRD' uses class as-is)
- Dynamic resource map loading from cluster (/openapi/v2)
- Kubeconfig support (token auth, client certs, exec credential plugins)
- New kube_watch CLI tool for watching Kubernetes resource events
- Backwards compatibility via deprecated v0 API wrappers (with warnings)
lib/Kubernetes/REST.pm view on Meta::CPAN
}
return @events;
}
# ============================================================================
# PUBLIC BUILDING BLOCKS FOR ASYNC WRAPPERS
#
# These methods expose the internal request/response pipeline as a stable API
# for async wrappers (e.g. Net::Async::Kubernetes) that need to build requests,
# process responses, and handle streaming without going through the sync
# convenience methods (list, get, watch, log, port_forward, exec, attach, etc.).
# ============================================================================
sub build_path {
my ($self, @args) = @_;
return $self->_build_path(@args);
}
lib/Kubernetes/REST.pm view on Meta::CPAN
my $data_callback = sub {
my ($chunk) = @_;
for my $result ($self->_process_watch_chunk($class, \$buffer, $chunk)) {
$last_rv = $result->{resourceVersion} if $result->{resourceVersion};
$got_410 = 1 if $result->{error_code} == 410;
$on_event->($result->{event});
}
};
my $response = $self->io->call_streaming($req, $data_callback);
$self->_check_response($response, "watch $short_class");
croak "Watch expired (410 Gone): resourceVersion too old, re-list to get a fresh resourceVersion"
if $got_410;
return $last_rv;
}
sub log {
lib/Kubernetes/REST.pm view on Meta::CPAN
my $req = $self->_prepare_request('GET', $path, parameters => \%params);
my $buffer = '';
my $data_callback = sub {
my ($chunk) = @_;
for my $event ($self->_process_log_chunk(\$buffer, $chunk)) {
$on_line->($event);
}
};
my $response = $self->io->call_streaming($req, $data_callback);
$self->_check_response($response, "log $short_class");
# Process any remaining data in buffer (last line without trailing newline)
if (length $buffer) {
$on_line->(Kubernetes::REST::LogEvent->new(line => $buffer));
}
return;
} else {
# One-shot mode
lib/Kubernetes/REST.pm view on Meta::CPAN
my $list = $api->inflate_list($class, $response);
Decode the JSON response body and inflate the C<items> array into an L<IO::K8s::List> of typed objects.
=head2 process_watch_chunk
my @results = $api->process_watch_chunk($class, \$buffer, $chunk);
Process a chunk of NDJSON watch data. Appends the chunk to the buffer, extracts complete lines, and returns a list of hashrefs with C<event> (L<Kubernetes::REST::WatchEvent>), C<resourceVersion>, C<is_error>, and C<error_code>.
This is a public API for async wrappers that handle streaming watch responses through their own event loop.
=head2 process_log_chunk
my @events = $api->process_log_chunk(\$buffer, $chunk);
Process a chunk of plain-text log data. Appends the chunk to the buffer, extracts complete lines, and returns a list of L<Kubernetes::REST::LogEvent> objects.
This is a public API for async wrappers that handle streaming log responses through their own event loop.
=head2 list
my $list = $api->list('Pod', namespace => 'default');
my $list = $api->list('Namespace', labelSelector => 'app=web');
List resources. Returns an L<IO::K8s::List> object.
Accepts short class names (C<Pod>) or full class paths. For namespaced resources, pass C<namespace> parameter. Omit C<namespace> to list cluster-scoped resources.
lib/Kubernetes/REST.pm view on Meta::CPAN
say $event->line;
},
);
Retrieve logs from a pod. Supports two modes:
B<One-shot> (without C<on_line>): Returns the full log text as a string.
B<Streaming> (with C<on_line>): Calls the callback for each log line with a L<Kubernetes::REST::LogEvent> object. Blocks until the stream ends (or the server closes the connection).
The streaming mode is designed for event-based systems like L<IO::Async> â see L<Net::Async::Kubernetes> for async integration.
=head2 port_forward
my $session = $api->port_forward('Pod', 'my-pod',
namespace => 'default',
ports => [8080, 8443],
on_frame => sub { my ($channel, $payload) = @_; ... },
);
Start a full-duplex pod port-forward session.
lib/Kubernetes/REST.pm view on Meta::CPAN
Required. Authentication credentials. Can be a hashref or a L<Kubernetes::REST::AuthToken>
object.
credentials => { token => $bearer_token }
=head2 io
Optional. HTTP backend for making requests. Must consume the
L<Kubernetes::REST::Role::IO> role (i.e. implement C<call($req)> and
C<call_streaming($req, $callback)>; optional C<call_duplex($req, %callbacks)> for
full-duplex subresources such as pod port-forward). Defaults to L<Kubernetes::REST::LWPIO>
(L<LWP::UserAgent>), which supports L<LWP::ConsoleLogger> for HTTP debugging.
To use the lighter L<HTTP::Tiny> backend instead:
use Kubernetes::REST::HTTPTinyIO;
my $api = Kubernetes::REST->new(
server => ...,
credentials => ...,
io => Kubernetes::REST::HTTPTinyIO->new(
lib/Kubernetes/REST.pm view on Meta::CPAN
backend for L<HTTP::Tiny> or an async backend (e.g. L<Net::Async::HTTP>)
without changing any API logic.
The pipeline for each API call:
1. prepare_request() - builds HTTPRequest (method, url, headers, body)
2. io->call() - executes request (pluggable backend)
3. check_response() - validates HTTP status
4. inflate_object/list - decodes JSON + inflates IO::K8s objects
For watch, step 2 uses C<io-E<gt>call_streaming()> and step 4 uses
C<process_watch_chunk()> which parses NDJSON and inflates each event.
For log, step 2 uses C<io-E<gt>call_streaming()> and step 4 uses
C<process_log_chunk()> which parses plain-text lines into L<Kubernetes::REST::LogEvent> objects.
To implement a custom IO backend, consume L<Kubernetes::REST::Role::IO>
and implement C<call($req)> and C<call_streaming($req, $callback)>.
See L<Kubernetes::REST::LWPIO> and L<Kubernetes::REST::HTTPTinyIO> for
reference implementations.
=head1 SEE ALSO
=head2 Related Modules
=over
=item * L<IO::K8s> - Kubernetes resource classes (required dependency)
lib/Kubernetes/REST/CLI.pm view on Meta::CPAN
sub execute {
my ($self, $args, $chain) = @_;
print "Usage: kube_client <command> [options]\n\n";
print "Commands:\n";
print " get <Kind> [name] Get resource(s)\n";
print " create -f <file> Create resource from file\n";
print " delete <Kind> <name> Delete resource\n";
print " raw <Group> <Method> Raw API call\n";
print "\nRun 'kube_client --help' for options.\n";
print "See also: kube_watch <Kind> for live event streaming.\n";
return 0;
}
1;
package Kubernetes::REST::CLI::Cmd::Get;
our $VERSION = '1.003';
use Moo;
lib/Kubernetes/REST/HTTPTinyIO.pm view on Meta::CPAN
(defined $req->content) ? (content => $req->content) : (),
}
);
return Kubernetes::REST::HTTPResponse->new(
status => $res->{ status },
(defined $res->{ content })?( content => $res->{ content } ) : (),
);
}
sub call_streaming {
my ($self, $req, $data_callback) = @_;
my $res = $self->ua->request(
$req->method,
$req->url,
{
headers => $req->headers,
data_callback => $data_callback,
}
lib/Kubernetes/REST/HTTPTinyIO.pm view on Meta::CPAN
=head2 ua
The underlying L<HTTP::Tiny> instance.
=head2 call
my $response = $io->call($req);
Execute an HTTP request. Receives a fully prepared L<Kubernetes::REST::HTTPRequest> (URL, headers, content all set). Returns a L<Kubernetes::REST::HTTPResponse>.
=head2 call_streaming
my $response = $io->call_streaming($req, sub { my ($chunk) = @_; ... });
Execute an HTTP request with streaming response. The C<$data_callback> is called with each chunk of data as it arrives.
Used internally by L<Kubernetes::REST/watch> for the Watch API.
=head1 SEE ALSO
=over
=item * L<Kubernetes::REST> - Main API client
=item * L<Kubernetes::REST::Role::IO> - IO interface role
lib/Kubernetes/REST/LWPIO.pm view on Meta::CPAN
);
my $res = $self->ua->request($http_req);
return Kubernetes::REST::HTTPResponse->new(
status => $res->code,
(length $res->decoded_content) ? ( content => $res->decoded_content ) : (),
);
}
sub call_streaming {
my ($self, $req, $data_callback) = @_;
my $http_req = HTTP::Request->new(
$req->method,
$req->url,
[ %{$req->headers} ],
);
my $res = $self->ua->request($http_req, sub {
lib/Kubernetes/REST/LWPIO.pm view on Meta::CPAN
=head2 ua
The underlying L<LWP::UserAgent> instance. Access this to attach middleware such as L<LWP::ConsoleLogger> for HTTP debugging.
=head2 call
my $response = $io->call($req);
Execute an HTTP request. Receives a fully prepared L<Kubernetes::REST::HTTPRequest> (URL, headers, content all set). Returns a L<Kubernetes::REST::HTTPResponse>.
=head2 call_streaming
my $response = $io->call_streaming($req, sub { my ($chunk) = @_; ... });
Execute an HTTP request with streaming response. The C<$data_callback> is called with each chunk of data as it arrives.
Used internally by L<Kubernetes::REST/watch> for the Watch API.
=head1 SEE ALSO
=over
=item * L<Kubernetes::REST> - Main API client
=item * L<Kubernetes::REST::Role::IO> - IO interface role
lib/Kubernetes/REST/Role/IO.pm view on Meta::CPAN
package Kubernetes::REST::Role::IO;
our $VERSION = '1.104';
# ABSTRACT: Interface role for HTTP backends
use Moo::Role;
requires 'call';
requires 'call_streaming';
sub supports_duplex {
my ($self) = @_;
return $self->can('call_duplex') ? 1 : 0;
}
1;
lib/Kubernetes/REST/Role/IO.pm view on Meta::CPAN
package My::AsyncIO;
use Moo;
with 'Kubernetes::REST::Role::IO';
sub call {
my ($self, $req) = @_;
# Execute HTTP request, return Kubernetes::REST::HTTPResponse
...
}
sub call_streaming {
my ($self, $req, $data_callback) = @_;
# Execute HTTP request with streaming callback
...
}
# Optional: full-duplex transport (WebSocket/SPDY)
sub call_duplex {
my ($self, $req, %callbacks) = @_;
...
}
=head1 DESCRIPTION
lib/Kubernetes/REST/Role/IO.pm view on Meta::CPAN
The default backend is L<Kubernetes::REST::LWPIO> (using L<LWP::UserAgent>). An alternative L<Kubernetes::REST::HTTPTinyIO> (using L<HTTP::Tiny>) is provided. To use an async event loop, implement this role with e.g. L<Net::Async::HTTP>.
=head2 call
my $response = $io->call($req);
Required. Execute an HTTP request. Receives a L<Kubernetes::REST::HTTPRequest> with C<method>, C<url>, C<headers>, and optionally C<content> already set.
Must return a L<Kubernetes::REST::HTTPResponse> with C<status> and C<content>.
=head2 call_streaming
my $response = $io->call_streaming($req, $data_callback);
Required. Execute an HTTP request with streaming response. The C<$data_callback> is called with each chunk of data as it arrives: C<< $data_callback->($chunk) >>.
Must return a L<Kubernetes::REST::HTTPResponse> when the stream ends.
=head2 supports_duplex
if ($io->supports_duplex) {
...
}
Optional capability probe for full-duplex protocols used by Kubernetes
t/13_io_backends.t view on Meta::CPAN
my $res = $io->call($req);
is $res->status, 201, 'POST returns 201';
like $res->content, qr/uid/, 'response contains uid';
# Verify the request body was passed
is $mock_ua->last_request->content, '{"metadata":{"name":"test"}}',
'request body preserved';
};
subtest 'LWPIO - call_streaming() with data callback' => sub {
my $io = Kubernetes::REST::LWPIO->new;
# For streaming, LWP calls the callback with chunks
my $mock_ua = Test::MockLWP->new(
code => 200,
content => '',
streaming_chunks => [
qq|{"type":"ADDED","object":{"kind":"Pod","metadata":{"name":"pod-1"}}}\n|,
qq|{"type":"MODIFIED","object":{"kind":"Pod","metadata":{"name":"pod-1"}}}\n|,
],
);
$io->{ua} = $mock_ua;
my $req = Kubernetes::REST::HTTPRequest->new(
method => 'GET',
url => 'http://mock.local/api/v1/namespaces/default/pods?watch=true',
headers => { Authorization => 'Bearer test-token' },
);
my @chunks;
my $res = $io->call_streaming($req, sub { push @chunks, $_[0] });
isa_ok $res, 'Kubernetes::REST::HTTPResponse';
is $res->status, 200, 'streaming returns 200';
is scalar @chunks, 2, 'received 2 chunks';
like $chunks[0], qr/ADDED/, 'first chunk is ADDED';
like $chunks[1], qr/MODIFIED/, 'second chunk is MODIFIED';
};
subtest 'LWPIO - call() with empty response' => sub {
my $io = Kubernetes::REST::LWPIO->new;
my $mock_ua = Test::MockLWP->new(
code => 204,
t/13_io_backends.t view on Meta::CPAN
my $req = Kubernetes::REST::HTTPRequest->new(
method => 'GET',
url => 'http://mock.local/api/v1/pods',
headers => {},
);
my $res = $io->call($req);
is $res->status, 200, 'status ok with no content';
};
subtest 'HTTPTinyIO - call_streaming() with data callback' => sub {
my $io = Kubernetes::REST::HTTPTinyIO->new;
my @delivered_chunks;
my $mock_ua = Test::MockHTTPTiny->new(
status => 200,
content => undef,
on_data_callback => sub {
my ($cb) = @_;
$cb->(qq|{"type":"ADDED","object":{"kind":"Pod"}}\n|);
$cb->(qq|{"type":"DELETED","object":{"kind":"Pod"}}\n|);
t/13_io_backends.t view on Meta::CPAN
);
$io->{ua} = $mock_ua;
my $req = Kubernetes::REST::HTTPRequest->new(
method => 'GET',
url => 'http://mock.local/api/v1/pods?watch=true',
headers => {},
);
my @chunks;
my $res = $io->call_streaming($req, sub { push @chunks, $_[0] });
is $res->status, 200, 'streaming returns 200';
is scalar @chunks, 2, 'received 2 chunks';
like $chunks[0], qr/ADDED/, 'first chunk is ADDED';
like $chunks[1], qr/DELETED/, 'second chunk is DELETED';
};
# ============================================================================
# Test that REST.pm uses LWPIO by default
# ============================================================================
subtest 'REST default IO is LWPIO' => sub {
t/13_io_backends.t view on Meta::CPAN
package Test::MockLWP;
use strict;
use warnings;
sub new {
my ($class, %args) = @_;
bless {
code => $args{code} // 200,
content => $args{content} // '',
streaming_chunks => $args{streaming_chunks} // [],
last_request => undef,
}, $class;
}
sub request {
my ($self, $req, $content_cb) = @_;
$self->{last_request} = $req;
if ($content_cb && ref $content_cb eq 'CODE') {
# Streaming mode: deliver chunks via callback
for my $chunk (@{$self->{streaming_chunks}}) {
$content_cb->($chunk);
}
}
return Test::MockLWP::Response->new(
code => $self->{code},
content => $self->{content},
);
}
t/13_io_backends.t view on Meta::CPAN
content => $args{content},
on_data_callback => $args{on_data_callback},
last_opts => undef,
}, $class;
}
sub request {
my ($self, $method, $url, $opts) = @_;
$self->{last_opts} = $opts // {};
# If there's a data_callback in opts and we have streaming setup, invoke it
if ($opts->{data_callback} && $self->{on_data_callback}) {
$self->{on_data_callback}->($opts->{data_callback});
}
return {
status => $self->{status},
(defined $self->{content} ? (content => $self->{content}) : ()),
};
}
]);
my $text = $api->log('Pod', 'nginx-abc', namespace => 'default');
like($text, qr/Starting nginx/, 'log contains first line');
like($text, qr/Listening on port 80/, 'log contains second line');
like($text, qr/Ready to accept connections/, 'log contains third line');
};
# === Test 2: Streaming log with on_line callback ===
subtest 'streaming log' => sub {
$mock_io->add_log_lines('/api/v1/namespaces/default/pods/nginx-abc/log', [
'line 1: hello',
'line 2: world',
'line 3: done',
]);
my @events;
$api->log('Pod', 'nginx-abc',
namespace => 'default',
follow => 1,
]);
my $text = $api->log('Pod', 'big-pod',
namespace => 'default',
limitBytes => 1024,
);
like($text, qr/limited output/, 'limitBytes log works');
};
# === Test 18: process_log_chunk with multiple chunks simulating real streaming ===
subtest 'process_log_chunk multi-chunk streaming' => sub {
my $buffer = '';
# First chunk: one complete line + start of second
my @events = $api->process_log_chunk(\$buffer, "complete line\npartial li");
is(scalar @events, 1, 'one complete line from first chunk');
is($events[0]->line, 'complete line', 'first chunk complete line');
is($buffer, 'partial li', 'buffer holds partial');
# Second chunk: rest of second line + third line
@events = $api->process_log_chunk(\$buffer, "ne here\nthird line\n");
my $json_line = '{"type":"ADDED","object":{"apiVersion":"v1","kind":"Pod","metadata":{"name":"watch-pod","namespace":"default","resourceVersion":"999"},"spec":{"containers":[{"name":"nginx","image":"nginx"}]},"status":{"phase":"Running"}}}' . "\n...
my @results = $api->process_watch_chunk($class, \$buffer, $json_line);
is(scalar @results, 1, 'one watch event from chunk');
is($results[0]->{event}->type, 'ADDED', 'event type is ADDED');
is($results[0]->{resourceVersion}, '999', 'resourceVersion tracked');
isa_ok($results[0]->{event}->object, 'IO::K8s::Api::Core::V1::Pod', 'inflated to Pod');
is($results[0]->{event}->object->metadata->name, 'watch-pod', 'pod name correct');
};
# === Test 24: log streaming returns undef ===
subtest 'log streaming returns undef' => sub {
$mock_io->add_log_lines('/api/v1/namespaces/default/pods/void-pod/log', [
'some output',
]);
my $result = $api->log('Pod', 'void-pod',
namespace => 'default',
on_line => sub {},
);
is($result, undef, 'streaming log returns undef');
};
# === Test 25: log one-shot returns string ===
subtest 'log one-shot returns string' => sub {
$mock_io->add_log_lines('/api/v1/namespaces/default/pods/string-pod/log', [
'line A',
'line B',
]);
my $text = $api->log('Pod', 'string-pod', namespace => 'default');
t/21_port_forward.t view on Meta::CPAN
{
package Test::PF::BasicIO;
use Moo;
with 'Kubernetes::REST::Role::IO';
sub call {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '{}');
}
sub call_streaming {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '');
}
}
{
package Test::PF::DuplexIO;
use Moo;
with 'Kubernetes::REST::Role::IO';
has last_req => (is => 'rw');
has last_opts => (is => 'rw');
sub call {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '{}');
}
sub call_streaming {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '');
}
sub call_duplex {
my ($self, $req, %opts) = @_;
$self->last_req($req);
$self->last_opts(\%opts);
return { ok => 1, type => 'duplex-session' };
}
}
t/22_exec.t view on Meta::CPAN
{
package Test::Exec::BasicIO;
use Moo;
with 'Kubernetes::REST::Role::IO';
sub call {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '{}');
}
sub call_streaming {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '');
}
}
{
package Test::Exec::DuplexIO;
use Moo;
with 'Kubernetes::REST::Role::IO';
has last_req => (is => 'rw');
has last_opts => (is => 'rw');
sub call {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '{}');
}
sub call_streaming {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '');
}
sub call_duplex {
my ($self, $req, %opts) = @_;
$self->last_req($req);
$self->last_opts(\%opts);
return { ok => 1, type => 'duplex-session' };
}
}
t/23_attach.t view on Meta::CPAN
{
package Test::Attach::BasicIO;
use Moo;
with 'Kubernetes::REST::Role::IO';
sub call {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '{}');
}
sub call_streaming {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '');
}
}
{
package Test::Attach::DuplexIO;
use Moo;
with 'Kubernetes::REST::Role::IO';
has last_req => (is => 'rw');
has last_opts => (is => 'rw');
sub call {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '{}');
}
sub call_streaming {
return Kubernetes::REST::HTTPResponse->new(status => 200, content => '');
}
sub call_duplex {
my ($self, $req, %opts) = @_;
$self->last_req($req);
$self->last_opts(\%opts);
return { ok => 1, type => 'duplex-session' };
}
}
t/lib/Test/Kubernetes/Mock.pm view on Meta::CPAN
sub add_watch_events {
my ($self, $path, $events) = @_;
$self->watch_events->{$path} = $events;
}
sub add_log_lines {
my ($self, $path, $lines) = @_;
$self->log_lines->{$path} = $lines;
}
sub call_streaming {
my ($self, $req, $callback) = @_;
my $path = $req->url // '';
$path =~ s{^https?://[^/]+}{};
# Strip query parameters for key lookup
$path =~ s{\?.*}{};
# Check for log lines first (log paths end with /log)
if (my $lines = $self->log_lines->{$path}) {
for my $line (@$lines) {
t/lib/Test/Kubernetes/Mock.pm view on Meta::CPAN
status => 200,
);
}
# Check for watch events
my $events = $self->watch_events->{$path};
unless ($events) {
return Test::Kubernetes::Mock::Response->new(
status => 404,
content => '{"kind":"Status","status":"Failure","message":"no streaming data for path"}',
);
}
my $json = JSON::MaybeXS->new;
for my $event (@$events) {
my $line = $json->encode($event) . "\n";
$callback->($line, undef);
}
return Test::Kubernetes::Mock::Response->new(