NAME

Net::Async::Kubernetes - Async Kubernetes client for IO::Async

VERSION

version 0.009

SYNOPSIS

use IO::Async::Loop;
use Net::Async::Kubernetes;

my $loop = IO::Async::Loop->new;

# From kubeconfig (easiest)
my $kube = Net::Async::Kubernetes->new(
    kubeconfig => "$ENV{HOME}/.kube/config",
);
$loop->add($kube);

# In-cluster: auto-detects service account token (no config needed)
my $kube = Net::Async::Kubernetes->new;
$loop->add($kube);

# Or with explicit server/credentials
my $kube = Net::Async::Kubernetes->new(
    server      => { endpoint => 'https://kubernetes.local:6443' },
    credentials => { token => $token },
);
$loop->add($kube);

# Future-based CRUD
my $pods = $kube->list('Pod', namespace => 'default')->get;

my $pod = $kube->get('Pod', 'nginx', namespace => 'default')->get;

my $patched = $kube->patch('Pod', 'nginx',
    namespace => 'default',
    patch     => { metadata => { labels => { env => 'staging' } } },
)->get;

$kube->delete('Pod', 'nginx', namespace => 'default')->get;

# Pod logs (one-shot)
my $text = $kube->log('Pod', 'nginx',
    namespace => 'default',
    tailLines => 100,
)->get;

# Pod logs (streaming)
$kube->log('Pod', 'nginx',
    namespace => 'default',
    follow    => 1,
    on_line   => sub { my ($event) = @_; say $event->line },
)->get;

# Port-forward (built-in websocket duplex support)
my $pf = $kube->port_forward('Pod', 'nginx',
    namespace => 'default',
    ports     => [8080],
    on_frame  => sub { my ($channel, $payload) = @_; ... },
)->get;

$pf->write_channel(0, "GET / HTTP/1.1\r\n\r\n");
$pf->close(code => 1000);

# Pod exec (websocket duplex)
my $exec = $kube->exec('Pod', 'nginx',
    namespace => 'default',
    command   => ['sh', '-c', 'id'],
    on_frame  => sub { my ($channel, $payload) = @_; ... },
)->get;
$exec->write_stdin("id\n");
$exec->resize(width => 120, height => 40);

# Pod attach (websocket duplex)
my $attach = $kube->attach('Pod', 'nginx',
    namespace => 'default',
    container => 'app',
    stdin     => 1,
    stdout    => 1,
    stderr    => 1,
    tty       => 0,
    on_frame  => sub { my ($channel, $payload) = @_; ... },
)->get;
$attach->write_stdin("help\n");

# Copy local file to pod and back (built on exec)
$kube->cp_to_pod('Pod', 'nginx',
    namespace => 'default',
    local     => '/tmp/local.txt',
    remote    => '/tmp/remote.txt',
)->get;
$kube->cp_from_pod('Pod', 'nginx',
    namespace => 'default',
    remote    => '/tmp/remote.txt',
    local     => '/tmp/downloaded.txt',
)->get;

# Watcher with auto-reconnect
my $watcher = $kube->watcher('Pod',
    namespace   => 'default',
    on_added    => sub { my ($pod) = @_; say "Added: " . $pod->metadata->name },
    on_modified => sub { my ($pod) = @_; say "Modified: " . $pod->metadata->name },
    on_deleted  => sub { my ($pod) = @_; say "Deleted: " . $pod->metadata->name },
);

$loop->run;

DESCRIPTION

Net::Async::Kubernetes is an async Kubernetes client built on IO::Async. It extends IO::Async::Notifier and uses Net::Async::HTTP for non-blocking HTTP communication, plus Net::Async::WebSocket::Client for duplex subresources like pod port-forward.

All CRUD, log, port-forward, exec, attach, and cp helper methods return Future objects. The Net::Async::Kubernetes::Watcher provides auto-reconnecting event streaming with separate callbacks per event type.

Request preparation and response processing are delegated to Kubernetes::REST, so the same IO::K8s object inflation, short class names, and CRD support are available.

Authentication is automatically resolved in the following order:

1. Explicit server and credentials parameters
2. kubeconfig file (via Kubernetes::REST::Kubeconfig)
3. In-cluster service account token at /var/run/secrets/kubernetes.io/serviceaccount/token (automatic when running inside a Kubernetes pod)

configure

Internal IO::Async::Notifier configuration method. Handles initialization of kubeconfig, context, server, credentials, resource_map, resource_map_from_cluster and with parameters.

If kubeconfig is provided without explicit server or credentials, they are loaded automatically via Kubernetes::REST::Kubeconfig. So are they for a context without kubeconfig, from the default kubeconfig. Either way a kubeconfig or context that cannot be resolved croaks with the reason (a missing file, Context not found: ...).

When running inside a Kubernetes pod (no kubeconfig or server set), the service account token at /var/run/secrets/kubernetes.io/serviceaccount/token is used automatically for in-cluster authentication.

kubeconfig

Path to kubeconfig file. If provided, server and credentials are extracted automatically (via Kubernetes::REST::Kubeconfig).

context

Kubernetes context to use from the kubeconfig. Defaults to current-context. Without "kubeconfig" it is looked up in the default kubeconfig (KUBECONFIG, else ~/.kube/config); when it is not found there, or there is no kubeconfig at all and no in-cluster service account either, the constructor croaks with the reason.

resource_map

Optional. Custom resource map for short class names.

resource_map_from_cluster

Optional boolean, defaults to false. When true, Kubernetes::REST reads the cluster's discovery documents (GET /api, GET /apis) and, unless a "resource_map" is given, builds the resource map from them. Left to itself it fetches them once, on first use, through its own synchronous HTTP backend, not through this client's transport, so that first use blocks the loop. Await "discover" once at start-up to have them read through this client's transport instead:

my $kube = Net::Async::Kubernetes->new(
    kubeconfig                => "$ENV{HOME}/.kube/config",
    resource_map_from_cluster => 1,
);
$loop->add($kube);
$kube->discover->get;

It also makes custom resources usable without a class of their own: a Kind that no IO::K8s class or resource_map entry serves, but which discovery lists, resolves to IO::K8s::Unstructured. Requests for it take the resource's plural and scope from discovery, and a qualified 'group/version/Kind' name stays in that group and version. An IO::K8s::Unstructured object handed to "create", "update", "ensure" or another object form is addressed the same way, by its own kind and apiVersion -- without this option there is no discovery to find its path in.

That group and version are the only ones such a request goes to. When the cluster does not serve them, a qualified name is an unknown resource and an object or manifest with that apiVersion is refused, even if another group or another version serves a Kind of the same name -- nothing is listed, changed or deleted there instead.

A request for IO::K8s::Unstructured that has no path -- without this option, for the explicit class name IO::K8s::Unstructured (a class name carries no Kind), or for an object without kind -- is refused before it is sent, like any other bad argument: the Future fails with the reason, and "update", "update_status", "ensure" and a watcher starting up croak with it instead, as they do for their other argument errors.

with

Optional arrayref of IO::K8s resource-map providers (CRD bundles), passed on to "with" in Kubernetes::REST, so their Kinds resolve to typed classes -- in names, lists, watches and "new_object":

my $kube = Net::Async::Kubernetes->new(
    kubeconfig => "$ENV{HOME}/.kube/config",
    with       => ['IO::K8s::GatewayAPI'],
);
my $gateways = $kube->list('Gateway', namespace => 'default')->get;

Defaults to []. See "with" in IO::K8s for the accepted provider forms.

server

Returns the Kubernetes::REST::Server instance. Croaks if neither server nor kubeconfig was provided during initialization.

credentials

Returns the credentials object (typically Kubernetes::REST::AuthToken). Croaks if neither credentials nor kubeconfig was provided during initialization.

rest

my $rest = $kube->rest;

Returns the underlying lazily-built Kubernetes::REST instance used for request building and response processing. Exposed for advanced use -- most callers want the higher-level CRUD methods instead. The private _rest accessor used throughout the internals returns this same cached instance.

new_object

my $cm = $kube->new_object(ConfigMap =>
    metadata => { name => 'my-config' },
    data     => { key => 'value' },
);

Builds a typed IO::K8s object from a short class name (e.g. 'Pod', 'ConfigMap') and either a hashref or a hash of attributes. Delegates to "new_object" in Kubernetes::REST. This is the public path for constructing the objects passed to create and update.

expand_class

my $full_class = $kube->expand_class('Pod');
# Returns 'IO::K8s::Api::Core::V1::Pod'

Expands a short resource name (e.g., 'Pod', 'Deployment') to its full IO::K8s class name. Delegates to "expand_class" in Kubernetes::REST.

The name may also be qualified as 'group/version/Kind', which resolves to that exact API version instead of the historical default the bare Kind name carries -- the two forms can and do point at different classes:

$kube->expand_class('HorizontalPodAutoscaler');
# 'IO::K8s::Api::Autoscaling::V2::HorizontalPodAutoscaler' (bare-name default)

$kube->expand_class('autoscaling/v1/HorizontalPodAutoscaler');
# 'IO::K8s::Api::Autoscaling::V1::HorizontalPodAutoscaler' (pinned to v1)

This qualified form is accepted anywhere a resource name is, including list, get, and watcher.

The result is a plain class name, without a +. Handed back to a method that resolves names again -- "new_object", "struct_to_object" in IO::K8s -- a single-segment class of your own ('+Gizmo' in the "resource_map", returned as 'Gizmo') reads as the Kind Gizmo there. Prefix it with + when you do that yourself. The client's own methods already do, for the manifests "ensure" resolves and for every answer they inflate, a watch event included -- whichever Kubernetes::REST version is installed.

Croaks when the name cannot be resolved to an IO::K8s class -- a qualified name no class serves, and equally a bare Kind no class ships for ('Bogus'), which "expand_class" in Kubernetes::REST would hand back as a fabricated IO::K8s::Bogus. It also croaks when the name resolves to a class that cannot serve as a resource, with the real cause rather than "unknown resource": a class that does not load or compile (say, a typo in a '+Class' entry of "resource_map") croaks with its load error, and a class without an api_version of its own -- an IO::K8s helper such as IO::K8s::List that a bare 'List' lands on -- croaks as not being a Kubernetes resource class. This is the synchronous counterpart of the Future-returning methods below, which report the same conditions as a failed Future with the same message.

discover

$kube->discover->get;    # once, at start-up

Reads the cluster's discovery documents (GET /api, GET /apis) through this client's own asynchronous transport and hands them to Kubernetes::REST, which then resolves names -- a Kind only the cluster serves included (see "resource_map_from_cluster") -- and builds its resource map from them without a request of its own. Returns a Future that resolves, with no value, once that is done.

With "resource_map_from_cluster" on, await it once at start-up, before the first request: otherwise Kubernetes::REST reads discovery itself on first use, through its synchronous HTTP backend, and that first use blocks the loop. No other method calls discover for you. Calling it again reads discovery anew and replaces what was read before -- after installing a CustomResourceDefinition, for example.

  • Without "resource_map_from_cluster" there is no discovery to read: the Future is done at once and nothing is sent.

  • A cluster older than Kubernetes 1.27 answers with legacy discovery, which takes a further request per API group and version. Kubernetes::REST keeps nothing of it; the Future is done all the same, and discovery is read synchronously on first use, as without discover.

  • An error status (401, 403, 5xx) fails the Future as described in "ERRORS", ->fail($error, 'http', $response). A request that gets no response fails it the way the transport reports it, and a document that cannot be read (not JSON) with the reason.

list

my $future = $kube->list('Pod', namespace => 'default');
my $list = $future->get;
my @pods = @{ $list->items };

my $future = $kube->list('Pod', labelSelector => 'app=web');

List resources of the given type. Returns a Future that resolves to an IO::K8s::List. Its items accessor holds the ArrayRef of inflated IO::K8s objects.

labelSelector and fieldSelector are sent as query parameters, so filtering happens server-side rather than on the list that comes back. Any other option -- a misspelt labelselector would otherwise list every object -- fails the Future before a request is sent, naming the options list takes. So does an odd list of options, as Invalid arguments to list(): its last key would otherwise go out without a value.

Arguments:

$short_class - Resource type (e.g., 'Pod', 'Deployment'), or a qualified 'group/version/Kind' name to pin a specific API version -- see "expand_class"
%args - Optional parameters: namespace, labelSelector, fieldSelector

get

my $future = $kube->get('Pod', 'nginx', namespace => 'default');
my $pod = $future->get;

Get a single resource by name. Returns a Future that resolves to an inflated IO::K8s object. An option other than name and namespace fails the Future before a request is sent, and so does an odd list of options, as Invalid arguments to get(). A reference in place of the name -- a manifest hashref, an IO::K8s object -- fails it as resource name must be a string, got a HASH reference, before it is stringified into the request path.

Arguments:

$short_class - Resource type (e.g., 'Pod'), or a qualified 'group/version/Kind' name -- see "expand_class"
$name - Resource name (required)
%args - Optional parameters: namespace

create

my $future = $kube->create($pod_object);
my $created = $future->get;

Create a resource from an IO::K8s object. Returns a Future that resolves to the created object with server-populated fields (resourceVersion, etc.).

An object that is no Kubernetes resource -- an IO::K8s::List, a nested type such as a PodSpec -- or anything that is not an IO::K8s object fails the Future before a request is sent.

Arguments:

$object - IO::K8s object instance (e.g., IO::K8s::Api::Core::V1::Pod)

update

my $future = $kube->update($modified_pod);
my $updated = $future->get;

Update an existing resource. The object must have metadata.name (and metadata.namespace if namespaced). Returns a Future that resolves to the updated object.

A missing metadata or metadata.name croaks synchronously, and so does an object that is no Kubernetes resource -- an IO::K8s::List, a nested type such as a PodSpec -- or anything that is not an IO::K8s object.

Arguments:

$object - Modified IO::K8s object with updated fields

update_status

my $future = $kube->update_status($node);
my $updated = $future->get;

Replace a resource's status through the /status subresource. The whole object is sent, as with "update", but the server only stores the status it carries and leaves spec and metadata untouched. Returns a Future that resolves to the updated object.

Needs a current resourceVersion and fails with a 409 conflict if the object changed on the server in the meantime. A missing metadata or metadata.name, or an object that is no Kubernetes resource, croaks synchronously, as with "update". Prefer "patch_status" when you are setting individual status fields.

Arguments:

$object - IO::K8s object with metadata.name (and namespace if namespaced) and the desired status

patch

# By class and name
my $future = $kube->patch('Pod', 'nginx',
    namespace => 'default',
    patch     => { metadata => { labels => { env => 'prod' } } },
    type      => 'strategic',  # or 'merge', 'json'
);

# Or by object
my $future = $kube->patch($pod_object,
    patch => { spec => { replicas => 3 } },
);

Patch an existing resource. Returns a Future that resolves to the patched object. Bad arguments -- among them an object that is no Kubernetes resource, such as an IO::K8s::List or a nested PodSpec, a plain reference such as a manifest hashref in place of the resource name, an odd list of options after the object or the name (Invalid arguments to patch()), and an option not listed below (in the object form name and namespace come from the object and are refused as options too) -- fail the Future before a request is sent.

Arguments:

$class_or_object - Resource class name or IO::K8s object
name - Resource name (required unless passing object)
namespace - Namespace (if namespaced)
patch - HashRef of changes to apply (required)
type - Patch type: 'strategic' (default), 'merge', or 'json'

patch_status

# By class and name
my $future = $kube->patch_status('OCPNode', 'cp-1',
    namespace => 'ocp',
    patch     => { status => { phase => 'Ready' } },
);

# Or by object
my $future = $kube->patch_status($node,
    patch => { status => { phase => 'Ready' } },
);

Partially update a resource's status through the /status subresource. Once a CustomResourceDefinition declares subresources: { status: {} }, the API server drops the status stanza from every write to the main endpoint and still answers 2xx -- so a status written via "patch" or "update" is silently discarded. This method writes to /status instead.

Takes the same call forms and arguments as "patch" (object, or class plus name in either the shorthand or fully-keyed form), and refuses any other option, and an odd list of options, the same way. The patch document is sent unchanged and carries its own status key.

The default patch type is merge, not strategic as in "patch": custom resources reject strategic merge patch with a 415, and merge works for built-in kinds too. Pass type => 'strategic' explicitly to patch the status of a built-in resource when you need array merge semantics.

Returns a Future that resolves to the patched object; bad arguments, an unknown patch type, or a server error fail the Future, as with "patch".

Arguments:

$class_or_object - Resource class name or IO::K8s object
name - Resource name (required unless passing object)
namespace - Namespace (if namespaced)
patch - HashRef with a status key (or ArrayRef of operations when type is json)
type - Patch type: 'merge' (default), 'strategic', or 'json'

delete

# By class and name
my $future = $kube->delete('Pod', 'nginx', namespace => 'default');
$future->get;

# Or by object
my $future = $kube->delete($pod_object);
$future->get;

# A Job together with its Pods
$kube->delete('Job', 'nightly', namespace => 'default',
    propagationPolicy => 'Background')->get;
$kube->delete($job, propagationPolicy => 'Foreground')->get;

Delete a resource. Returns a Future that resolves to 1 on success. In the object form, an object without metadata.name, one that is no Kubernetes resource (an IO::K8s::List, a nested PodSpec), or a reference that is not an IO::K8s object fails the Future. In the class form, a reference in place of the name -- delete('Pod', $manifest) -- fails it as resource name must be a string, got a HASH reference, before it is stringified into the request path.

propagationPolicy is sent as a query parameter and decides what happens to the objects the deleted one owns: Background deletes them after it, Foreground before it, Orphan leaves them. Without it the API server applies the resource's own default -- for a Job that orphans its Pods. Any other value, and any option delete does not know (a misspelt propagationPolicy would otherwise be dropped silently), fails the Future before a request is sent, worded as Kubernetes::REST words it: Unknown propagationPolicy 'x' for delete() (use: Background, Foreground, Orphan), or Unknown argument 'x' to delete() (allowed: ...) naming the first unknown option.

Arguments:

$class_or_object - Resource class name or IO::K8s object
$name - Resource name (required unless passing object)
namespace - Namespace (if namespaced; not in the object form, which takes it from the object)
propagationPolicy - 'Background', 'Foreground' or 'Orphan'; optional

ensure

my $future = $kube->ensure($pod);
my $obj = $future->get;

# or from a plain hashref (treated as a Kubernetes manifest):
my $future = $kube->ensure({
    apiVersion => 'v1',
    kind       => 'Secret',
    metadata   => { name => 'foo', namespace => 'default' },
    stringData => { password => 'hunter2' },
});

Idempotent create-or-update. GETs the object by kind/name/namespace: if it is missing, creates it; if it exists, updates it at the server's resourceVersion, which this method writes back into the object passed in. Returns a Future that resolves to the resulting IO::K8s object.

Accepts a typed IO::K8s object or a plain hashref; a hashref must carry a kind field and uses manifest-style camelCase keys (stringData, not string_data).

A hashref's apiVersion, when present, selects the class: an autoscaling/v1 HorizontalPodAutoscaler stays autoscaling/v1 and goes to that endpoint, although the bare Kind resolves to autoscaling/v2. A hashref without apiVersion (or with an empty one) resolves by its Kind alone, as "expand_class" does.

Handles the create/update race: a 409 on update (something else changed the object between GET and PUT) refetches once and retries the update; a 409 on create (something else created it between GET and POST) refetches it and handles it like an object that existed from the start -- updated, with the same one retry, or, for the two special cases below, returned unchanged or deleted and recreated.

Two kinds get special handling because their spec is immutable after creation: an existing core v1 PersistentVolumeClaim is left unchanged, and an existing batch/v1 Job is left unchanged while it is active or has succeeded, and deleted and recreated otherwise -- deleted with propagationPolicy Background, so its Pods go with it instead of being orphaned. Both are recognised by apiVersion and Kind together -- the object's api_version and kind -- never by the class name. A custom resource that reuses one of these Kind names in its own group is ensured like any other object, and so is a Job under any apiVersion other than batch/v1.

Errors that are known before any request is made -- a hashref without kind, a value that is neither an object nor a hashref, an object that is no Kubernetes resource (an IO::K8s::List, a nested type such as a PodSpec), an object missing metadata/metadata.name, an unknown kind, a kind whose class does not load or is no resource class (see "expand_class"), or an apiVersion that resolves to no known class (the message names both the Kind and the apiVersion) -- croak synchronously, as with "update". Anything that goes wrong during the request flow itself fails the Future instead.

Arguments:

$object - IO::K8s object or hashref manifest (must have kind if a hashref)

ensure_all

my $future = $kube->ensure_all(@objects);
my @results = $future->get;

Batch form of "ensure". Applies each object in order, one at a time -- object N+1 is only started once object N has resolved, so a later object may depend on an earlier one (a Namespace before what lives in it). Returns a Future that resolves to the list of results in input order.

If any object fails -- including a croak from "ensure", which becomes a failure here -- the Future fails and no later object is started. ensure_all itself never croaks synchronously.

Arguments:

@objects - IO::K8s objects or hashref manifests, as accepted by "ensure"

ensure_only

my $future = $kube->ensure_only(
    label      => 'app.kubernetes.io/component=queen',
    objects    => \@objects,
    kinds      => [qw(Role RoleBinding ClusterRoleBinding)],
    namespaces => ['default', 'kube-system', undef],
);
my @applied = $future->get;

Like "ensure_all", but also deletes anything matching the label selector in the given kinds and namespaces that is not present in objects. Use this for resources where stale objects must not survive (e.g. RBAC). Croaks synchronously if label is missing, if propagationPolicy is none of the values "delete" accepts, on any option not listed below -- a namespace meant as namespaces would otherwise prune at cluster scope only -- and on an odd list of options (Invalid arguments to ensure_only()), whose last key would otherwise be taken as given without a value: a stray objects would prune everything carrying the label.

Hashrefs in objects are resolved as in "ensure", all of them before the first request: one without kind or with an apiVersion no class serves croaks, and nothing is applied or deleted.

Applies objects via "ensure_all", then for each kind in kinds and each namespace in namespaces, lists resources of that kind carrying the label and deletes any that do not match one of the just-applied objects by API group, Kind, namespace and name. Group and Kind are each object's own -- the group from its api_version, the Kind from its kind -- so a qualified 'group/version/Kind' entry in kinds still recognises the objects it lists rather than deleting them. The same Kind name in another group is another resource: with Istio's networking.istio.io Gateway in objects, a labelled Gateway API gateway.networking.k8s.io Gateway of the same name and namespace is deleted. The version is not compared: an object applied as autoscaling/v1 is kept when the listing goes through autoscaling/v2. A namespaces entry of undef scans cluster-scoped resources; if namespaces is omitted, only cluster-scoped resources are scanned.

Stale objects are deleted with propagationPolicy Background unless propagationPolicy says otherwise, so a pruned Job or Deployment takes its Pods with it; the API server's own default would leave a Job's Pods behind.

Pruning goes on past a failure, and says so. When a kinds entry cannot be listed in one namespace -- the API server rejects the request, the request fails without a response, or the entry resolves to no usable class -- that combination is skipped with a warning (carp) naming the entry, the namespace (or cluster scope) and the reason; anything stale there survives this run. A 404 is silent: the cluster does not serve that Kind, so there is nothing to prune. A delete that fails warns with the Kind, name, namespace and reason, and the next object is tried; a 404 there means the object is already gone and is silent too. A $SIG{__WARN__} handler that dies while the prune runs fails the returned Future with the warning instead.

Returns a Future that resolves to the list of applied objects (from "ensure_all"), whether or not the pruning was complete.

Arguments:

label - Label selector matching stale objects to delete (required)
objects - ArrayRef of objects/hashrefs to apply, as for "ensure_all"
kinds - ArrayRef of resource kinds to scan for stale objects
namespaces - ArrayRef of namespaces to scan, undef for cluster-scoped; defaults to cluster-scoped only
propagationPolicy - How stale objects are deleted: 'Background' (default), 'Foreground' or 'Orphan', as for "delete"

log

# One-shot mode (Future resolves to full text)
my $text = $kube->log('Pod', 'my-pod',
    namespace => 'default',
    tailLines => 100,
)->get;

# Streaming mode (Future resolves when stream ends)
$kube->log('Pod', 'my-pod',
    namespace => 'default',
    follow    => 1,
    on_line   => sub {
        my ($event) = @_;  # Kubernetes::REST::LogEvent
        say $event->line;
    },
)->get;

Retrieve logs from a pod.

Without on_line, returns a Future that resolves to the full log text.

With on_line, opens a streaming request and invokes the callback once per line with Kubernetes::REST::LogEvent objects. The returned Future resolves when the stream ends.

Besides name, namespace and on_line it takes the options container, follow, tailLines, sinceSeconds, sinceTime, timestamps, previous and limitBytes, sent as query parameters. Any other -- tail_lines would otherwise fetch the whole log -- fails the Future before a request is sent.

port_forward

my $f = $kube->port_forward('Pod', 'my-pod',
    namespace => 'default',
    ports     => [8080, 8443],
    on_frame  => sub { my ($channel, $payload) = @_; ... },
);
my $session = $f->get;

Create an async pod port-forward session request.

Returns a Future that resolves to the duplex session object returned by the transport backend. The default transport returns a Net::Async::Kubernetes::PortForwardSession object.

The session helper supports write_channel, write_stdin, resize, and close.

on_open receives the created session object.

on_frame receives ($channel, $payload) where the first byte of each binary websocket frame is decoded as Kubernetes channel id.

It takes name, namespace, ports (one port or an arrayref of them, required), subprotocol (default v4.channel.k8s.io) and the callbacks on_open, on_frame, on_close and on_error. Any other option -- a misspelt onFrame would otherwise be dropped, a subresource would even land in the request path -- fails the Future before a request is sent, naming the options port_forward takes.

exec

my $f = $kube->exec('Pod', 'my-pod',
    namespace => 'default',
    command   => ['sh', '-c', 'id'],
    on_frame  => sub { my ($channel, $payload) = @_; ... },
);
my $session = $f->get;

Create an async pod exec session request.

Returns a Future that resolves to the duplex session object returned by the transport backend. The default transport returns a Net::Async::Kubernetes::PortForwardSession object.

The session helper supports write_channel, write_stdin, resize, and close.

on_open receives the created session object.

on_frame receives ($channel, $payload) where the first byte of each binary websocket frame is decoded as Kubernetes channel id.

Besides name, namespace, command (a string or an arrayref, required), subprotocol and the callbacks it takes container, stdin, stdout, stderr and tty, sent as query parameters (stdout and stderr default to true, the others to false). Any other option -- a misspelt container would otherwise run the command in the pod's default container -- fails the Future before a request is sent, naming the options exec takes.

attach

my $f = $kube->attach('Pod', 'my-pod',
    namespace => 'default',
    container => 'app',
    stdin     => 1,
    stdout    => 1,
    stderr    => 1,
    tty       => 0,
    on_frame  => sub { my ($channel, $payload) = @_; ... },
);
my $session = $f->get;

Create an async pod attach session request.

Returns a Future that resolves to the duplex session object returned by the transport backend. The default transport returns a Net::Async::Kubernetes::PortForwardSession object.

The session helper supports write_channel, write_stdin, resize, and close.

on_open receives the created session object.

on_frame receives ($channel, $payload) where the first byte of each binary websocket frame is decoded as Kubernetes channel id.

It takes name, namespace, container, stdin, stdout, stderr, tty, subprotocol and the callbacks, as "exec" does, but no command. Any other option, command among them, fails the Future before a request is sent, naming the options attach takes.

cp_to_pod

my $f = $kube->cp_to_pod('Pod', 'my-pod',
    namespace => 'default',
    container => 'app',
    local     => '/tmp/local.txt',
    remote    => '/tmp/remote.txt',
);
my $result = $f->get;

Copy a single local file into a pod. Reads the entire local file into memory, then runs sh -c 'head -c "$1" "$2"'> inside the pod via exec() and streams the bytes over stdin.

This is a single-file copy, not a tar-based transfer: there is no recursive directory copy, and the whole file is held in memory, so it is not suitable for very large files.

Returns a Future resolving to a hashref containing local, remote, bytes, stderr, and status.

It takes name, namespace, container, local, remote and chunk_size (bytes per stdin write, default 65536). Any other option fails the Future before a request is sent, naming the options cp_to_pod takes.

cp_from_pod

my $f = $kube->cp_from_pod('Pod', 'my-pod',
    namespace => 'default',
    container => 'app',
    remote    => '/tmp/remote.txt',
    local     => '/tmp/local.txt',
);
my $result = $f->get;

Copy a single file out of a pod. Runs cat $remote inside the pod via exec(), buffers the entire stdout stream in memory, then writes it to the local file.

This is a single-file copy, not a tar-based transfer: there is no recursive directory copy, and the whole file is held in memory, so it is not suitable for very large files.

Returns a Future resolving to a hashref containing local, remote, bytes, stderr, and status.

It takes name, namespace, container, remote and local. Any other option -- chunk_size belongs to "cp_to_pod" only -- fails the Future before a request is sent, naming the options cp_from_pod takes.

watcher

my $watcher = $kube->watcher('Pod',
    namespace      => 'default',
    label_selector => 'app=web',
    on_added       => sub { my ($pod) = @_; ... },
    on_modified    => sub { my ($pod) = @_; ... },
    on_deleted     => sub { my ($pod) = @_; ... },
);

Create and register a Net::Async::Kubernetes::Watcher for the specified resource type. The watcher is added as a child notifier and will start automatically when the parent is added to a loop.

Returns the watcher object. An odd list of parameters croaks, as Invalid arguments to watcher(), before the watcher is created -- as an unknown parameter does.

Arguments:

$resource - Resource type to watch (e.g., 'Pod', 'Deployment')
%args - Watcher parameters (namespace, label_selector, callbacks, etc.)

See Net::Async::Kubernetes::Watcher for all available parameters.

controller

my $controller = $kube->controller(
    on_reconcile => sub {
        my ($ctx) = @_;
        ...
    },
);

Create and register a Net::Async::Kubernetes::Controller runtime bound to this client. The controller is added as a child notifier and can register resource watches, queue reconcile work, and patch object status.

Returns the controller object. An odd list of parameters croaks, as Invalid arguments to controller(), before the controller is created.

ERRORS

The Future-returning methods report every failure through the returned Future:

  • Bad arguments -- an unknown resource, an object that is no Kubernetes resource, a missing name, an unknown option -- fail it with a message before any request is sent. "expand_class", "update", "update_status", "ensure" and a watcher that starts croak instead, as each of them documents.

  • A response the API server refuses (status 400 and up) fails it following Future's convention for failure details: ->fail($error, 'http', $response). $error is exactly what "check_response" in Kubernetes::REST throws, a Kubernetes::REST::APIError object, which stringifies to the message (Kubernetes API error (get Pod): 404 ...). $response is the Kubernetes::REST::HTTPResponse, whose status tells a 404 from a 409 without parsing the message:

    $kube->delete('Pod', 'web', namespace => 'default')->catch(http => sub {
        my ($error, $category, $response) = @_;
        return Future->done if $response->status == 404;   # already gone
        return Future->fail(@_);
    })->get;

    "ensure" and "ensure_all" pass the failure of the request that failed on in the same form.

  • A request that gets no response at all (connection refused, a TLS error) fails it the way the HTTP transport reports it.

A Net::Async::Kubernetes::Watcher reports failures to its on_error callback instead, with the HTTP status in the code of the Status it passes.

SEE ALSO

Net::Async::Kubernetes::Watcher, Net::Async::Kubernetes::Controller, Net::Async::Kubernetes::PortForwardSession, Kubernetes::REST, IO::Async, IO::K8s, Net::Async::WebSocket::Client

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-net-async-kubernetes/issues.

IRC

Join #kubernetes on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <torsten@raudssus.de>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.