NAME
Kubernetes::REST - A Perl REST Client for the Kubernetes API
VERSION
version 1.109
SYNOPSIS
use Kubernetes::REST;
my $api = Kubernetes::REST->new(
server => {
endpoint => 'https://kubernetes.local:6443',
ssl_verify_server => 1,
ssl_ca_file => '/path/to/ca.crt',
},
credentials => { token => $token },
);
# List all namespaces
my $namespaces = $api->list('Namespace');
for my $ns (@{ $namespaces->items }) {
say $ns->metadata->name;
}
# List pods in a namespace
my $pods = $api->list('Pod', namespace => 'default');
# Get a specific pod
my $pod = $api->get('Pod', name => 'my-pod', namespace => 'default');
# Create a namespace
my $ns = $api->new_object(Namespace => {
metadata => { name => 'my-namespace' },
});
my $created = $api->create($ns);
# Create multiple namespaces
for my $i (1..10) {
$api->create($api->new_object(Namespace =>
metadata => { name => "test-ns-$i" },
));
}
# Update a resource (full replacement)
$pod->metadata->labels({ app => 'updated' });
my $updated = $api->update($pod);
# Patch a resource (partial update)
my $patched = $api->patch('Pod', 'my-pod',
namespace => 'default',
patch => { metadata => { labels => { env => 'staging' } } },
);
# Delete a resource
$api->delete($pod);
# or by name:
$api->delete('Pod', name => 'my-pod', namespace => 'default');
# Idempotent create-or-update (from a typed object or a manifest hashref)
$api->ensure($pod);
$api->ensure({
apiVersion => 'v1',
kind => 'Secret',
metadata => { name => 'my-secret', namespace => 'default' },
stringData => { password => 'hunter2' },
});
# Batch apply
$api->ensure_all(@objects);
# Apply a labeled set and prune anything with that label not in the set
$api->ensure_only(
label => 'app.kubernetes.io/component=queen',
objects => \@rbac_objects,
kinds => [qw(Role RoleBinding ClusterRoleBinding)],
namespaces => ['default', undef],
);
DESCRIPTION
This module provides a simple REST client for the Kubernetes API using IO::K8s resource classes. The IO::K8s classes know their own metadata (API version, kind, whether they're namespaced), so URL building is automatic.
server
Required. Kubernetes::REST::Server instance or hashref with server connection configuration.
server => { endpoint => 'https://kubernetes.local:6443' }
Automatically coerces hashrefs to Kubernetes::REST::Server objects.
credentials
Required. Authentication credentials. Can be a hashref, Kubernetes::REST::AuthToken, or any object with a token() method.
credentials => { token => $bearer_token }
Automatically coerces hashrefs to Kubernetes::REST::AuthToken objects.
io
HTTP backend for making requests. Must consume Kubernetes::REST::Role::IO. Defaults to Kubernetes::REST::LWPIO (LWP::UserAgent).
To use HTTP::Tiny instead:
use Kubernetes::REST::HTTPTinyIO;
my $api = Kubernetes::REST->new(
...,
io => Kubernetes::REST::HTTPTinyIO->new(...),
);
See "PLUGGABLE IO ARCHITECTURE" for custom backends.
with
Arrayref of external IO::K8s resource-map providers (CRD bundles), passed straight through to the inner "with" in IO::K8s. Each entry is a provider class name (or object, or plain hashref) whose typed classes are merged into the resource map, so the cluster's CRD Kinds resolve to real classes:
my $api = Kubernetes::REST->new(
server => { endpoint => 'https://k8s.local:6443' },
credentials => { token => $token },
with => ['IO::K8s::GatewayAPI'],
);
my $gw = $api->new_object(Gateway => { metadata => { name => 'gw' } });
# => IO::K8s::GatewayAPI::V1::Gateway
Defaults to []. See "with" in IO::K8s for the accepted provider forms.
k8s
IO::K8s instance configured with the same resource map. Automatically created when needed.
Provides delegated methods: new_object, inflate, json_to_object, struct_to_object, object_to_json, object_to_struct, load, load_yaml. (expand_class is implemented here rather than delegated, so that pure name resolution does not force the cluster resource-map fetch - see "expand_class".)
Delegation is a convenience only, every one of them behaves exactly as it does on IO::K8s, including its argument contract:
new_objecttakes a short or full class name and either a hashref or a flat hash of attributes:my $ns = $api->new_object(Namespace => { metadata => { name => 'foo' } }); my $ns = $api->new_object(Namespace => metadata => { name => 'foo' });inflatetakes a hashref, or JSON as UTF-8 bytes - its decoder isutf8 => 1.json_to_objectandstruct_to_objectauto-detect the class from the decodedkindfield when called with a single argument (JSON or hashref, respectively) - a leading class name is only needed to override that detection:my $pod = $api->json_to_object($json_with_kind); my $pod = $api->json_to_object('Pod', $json_string);object_to_jsonandobject_to_structare their inverses, serialising a typed object back to JSON or to a plain hashref.load_yamltakes a file name or YAML as characters. Handing it bytes turns every non-ASCII value into mojibake, so decode first (Encode::decode('UTF-8', $yaml)). A newline-free argument is taken as a file name.----separated multi-document YAML is supported - the common case for Kubernetes manifests - so this always returns an arrayref, even for a single document:for my $obj (@{ $api->load_yaml('deployment.yaml') }) { $api->create($obj); }loadreads a.pk8smanifest, which is Perl code and isevaled in-process:my $objects = $api->load('myapp.pk8s');Only load
.pk8sfiles you trust; for data-only manifests useload_yaml.
Not delegated: add, which registers extra classes in the IO::K8s resource map. That map is mirrored by this client's own "resource_map" attribute, and mutating one behind the other's back makes the two disagree - reach through $api->k8s->add(...) if you really mean to.
resource_map_from_cluster
Boolean. If true, dynamically loads the resource map from the cluster's OpenAPI spec. Defaults to 1.
Set to 0 to use IO::K8s built-in resource map instead (faster startup, but may not match your cluster version).
cluster_version
Read-only. The Kubernetes cluster version string (e.g., v1.31.0). Fetched automatically from the /version endpoint when first accessed.
resource_map
Hashref mapping short resource names to IO::K8s class paths. By default loads dynamically from the cluster (if resource_map_from_cluster is true) or uses IO::K8s built-in map.
When the cluster's discovery cannot be read, the built-in map is used instead, with a warning that names the reason - at the line of the call that first needed the map, whether that read resource_map or resolved a name.
Override for custom resources:
resource_map => {
%{ IO::K8s->default_resource_map },
MyResource => '+My::K8s::V1::MyResource',
}
The + prefix tells IO::K8s that this is a custom class (not in the IO::K8s:: namespace).
The client keeps a copy of the hashref it is given: the Kinds of the "with" providers are merged into that copy, never into your hash, and a change you make to your hash after construction is not seen. Change $api->resource_map itself instead:
$api->resource_map->{StaticWebSite} = '+My::StaticWebSite';
expand_class
my $class = $api->expand_class('Pod');
# => IO::K8s::Api::Core::V1::Pod
Resolve a short resource name ('Pod'), a domain-qualified name ('cilium.io/v2/NetworkPolicy'), a +-prefixed or an already fully-qualified class name to its IO::K8s class - the same contract as "expand_class" in IO::K8s, against this client's "resource_map".
With "resource_map_from_cluster" on (the default), a Kind no shipped class, "with" provider or AutoGen class resolves becomes IO::K8s::Unstructured when the cluster's discovery serves it. A bare Kind is looked up in whichever group serves it, at that group's preferred version. A qualified name ('example.org/v1/Widget', or ('Widget', 'example.org/v1')) counts only in exactly that group and version: when the cluster does not serve those, it stays unresolved (undef), even if another group or another version serves a Kind of the same name - nothing is ever sent there instead.
The methods that take a resource name - "list", "get", "patch", "patch_status", "delete", "watch", "log", "port_forward", "exec", "attach" and "compare_schema" - croak on a name that stays unresolved, before sending anything, and name it:
unknown resource 'other.org/v1/Widget': no IO::K8s class for this
apiVersion/kind (add it to resource_map if it is a CRD)
A bare Kind nothing resolves (expand_class answers with the class name IO::K8s::<Kind>, which does not exist) is reported the same way. When the cluster's discovery could not be read, the message goes on to say so and why - the cluster could not confirm the name, which is not the same as not serving it.
Pure name resolution does not cost a cluster roundtrip: as long as the resource map has not been fetched yet (and none was passed to the constructor), a name the built-in IO::K8s map resolves to a loadable class is answered from that map directly. Only a name the built-in map cannot answer falls through to the cluster-backed map, fetching it on first use exactly as before.
The result is a plain class name, without a +. Handed back to a method that resolves names again - "struct_to_object" in IO::K8s, "json_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; this client's own inflation already does.
fetch_resource_map
my $map = $api->fetch_resource_map;
Build the resource map from the cluster's aggregated discovery documents (GET /api and GET /apis). Returns a hashref mapping short resource names (e.g., Pod) to full IO::K8s class paths.
Called automatically if resource_map_from_cluster is enabled.
Discovery is fetched and cached once per instance (see "invalidate_discovery"; an async client can supply it instead, see "absorb_discovery"); calling this again rebuilds the map from the cached catalog rather than re-querying the cluster. It does not download /openapi/v2 - that spec is fetched lazily only when "schema_for" or "compare_schema" need it.
When discovery cannot be read, it croaks Could not load resource map from cluster: followed by the reason - for an HTTP error status the message of the Kubernetes::REST::APIError, Kubernetes API error (discovery GET /api): 401 ....
A cluster older than Kubernetes 1.27 answers with legacy discovery, which takes one more request per group and version. When one of those answers with an HTTP error status, that group/version's Kinds are missing from the map, and a warning names its apiVersion and the error - discovery: cannot read apiVersion 'metrics.k8s.io/v1beta1', its Kinds are missing from the resource map: Kubernetes API error (discovery GET /apis/metrics.k8s.io/v1beta1): 503 ...; the other groups are read on. A 404 there is silent: the group/version went away after the list of groups was read.
Version selection (D17). When a group serves a Kind in more than one version, the bare short name (Pod, ServiceCIDR) resolves to the version the cluster marks preferred for that group - not to a fixed "stable beats alpha/beta" preference. A Kind served only outside the preferred version still maps, to its first served version.
Only Kinds whose IO::K8s class this distribution actually ships are recorded in the map. A CRD group with no bundled class (cilium.io, cert-manager.io, ...) is deliberately omitted here, so that its Kinds resolve through the inner IO::K8s - a provider merged via "with", then AutoGen from the fetched /openapi/v2, and finally a fail-closed error - rather than being mapped to a class name that does not exist.
invalidate_discovery
$api->invalidate_discovery;
Discard the cached discovery catalog and the resource map built from it, so the next resolution re-queries the cluster. Use it after a CustomResourceDefinition is installed or changed, to make the new Kind visible to this client instance.
A resource map passed to the constructor is not built from the catalog and stays as it is, entries such as '+My::Class' included.
prepare_discovery_requests
my %requests = $api->prepare_discovery_requests;
# ('/api' => $request, '/apis' => $request)
Build the two discovery requests - GET /api and GET /apis, with the Accept header that asks for aggregated discovery (APIGroupDiscoveryList, apidiscovery.k8s.io/v2) - without sending them. Returns them as path/request pairs, /api first, each a Kubernetes::REST::HTTPRequest as "prepare_request" builds it: exactly what the client sends when it reads discovery through its own "io".
This and "absorb_discovery" are for async wrappers such as Net::Async::Kubernetes. With "resource_map_from_cluster" on, the first name resolution otherwise reads discovery through the synchronous "io", blocking the wrapper's event loop.
absorb_discovery
my $absorbed = $api->absorb_discovery(
'/api' => $api_response,
'/apis' => $apis_response,
);
Take the responses to the requests from "prepare_discovery_requests" - any objects with status and content (the undecoded body), such as Kubernetes::REST::HTTPResponse.
When both are aggregated discovery documents they become this client's discovery catalog, replacing any it had cached, as if the client had read them itself, and the method returns true. From then on "expand_class", "fetch_resource_map", "build_path" and the lazily built "resource_map" answer from that catalog without sending a request. A resource map built from an earlier catalog is rebuilt from this one; a map passed to the constructor stays as it is.
When either is a legacy discovery document - a cluster older than Kubernetes 1.27 ignores the Accept header - it returns false and caches nothing: reading legacy discovery takes a request per group and version, which only the synchronous path makes. The client then reads discovery itself when it first needs it, as it always did.
An HTTP error status dies with a Kubernetes::REST::APIError, as the client's own discovery read does, its context naming the document: Kubernetes API error (discovery GET /apis): 503 .... A missing response, or any key other than /api and /apis, croaks.
An async client hands the requests to its own transport and the responses back - here $send->($request) stands for whatever runs a Kubernetes::REST::HTTPRequest through the event loop and returns a Future of the response:
my %requests = $rest->prepare_discovery_requests;
my @roots = sort keys %requests;
my $ready = Future->needs_all(map { $send->($requests{$_}) } @roots)
->then(sub {
my %responses;
@responses{@roots} = @_;
# false: legacy discovery, read synchronously on first use
$rest->absorb_discovery(%responses);
return Future->done;
});
schema_for
my $schema = $api->schema_for('Pod');
Get the OpenAPI schema definition for a resource type from the cluster. Accepts short names (Pod), full class names (IO::K8s::Api::Core::V1::Pod), or OpenAPI definition names (io.k8s.api.core.v1.Pod).
A name resolves as in "expand_class", and the definition is looked up by the name its class maps onto (IO::K8s::Api::Core::V1::Pod becomes io.k8s.api.core.v1.Pod), which holds for the IO::K8s::Api:: classes. Where no definition has that name - apiextensions and apiregistration, whose definitions upstream names after their staging repositories, a CRD class of your own or from a "with" provider, IO::K8s::Unstructured - it is the definition whose x-kubernetes-group-version-kind names the class's API group, version and Kind: its api_version and kind, or for IO::K8s::Unstructured the Kind and the group/version the cluster's discovery confirmed for it.
Returns a hashref with the OpenAPI v2 schema definition, or undef when there is none - also for a name that resolves to no class at all.
The spec is fetched from /openapi/v2 on first use and kept. An HTTP error status there dies with a Kubernetes::REST::APIError whose context is fetch OpenAPI spec, as does "compare_schema"; nothing is kept, and the next call fetches again.
compare_schema
my $result = $api->compare_schema('Pod');
Compare the local IO::K8s class definition against the cluster's OpenAPI schema. Useful for detecting version skew between your IO::K8s installation and the cluster.
Returns the comparison result from $class->compare_to_schema, the method IO::K8s::Role::Resource provides on every resource class.
A name that resolves to IO::K8s::Unstructured (see "expand_class") croaks, before the spec is fetched: that class declares only apiVersion, kind and metadata and keeps every other field untyped, so it has no local schema to compare. "schema_for" still answers the cluster's definition for such a name.
build_path
my $class = $api->expand_class('Pod');
my $path = $api->build_path($class, name => 'my-pod', namespace => 'default');
# => /api/v1/namespaces/default/pods/my-pod
my $status = $api->build_path($class,
name => 'my-pod',
namespace => 'default',
subresource => 'status',
);
# => /api/v1/namespaces/default/pods/my-pod/status
Build the REST API URL path for a resource class. Takes a fully-qualified class name (from expand_class) and optional name/namespace arguments.
The optional subresource argument appends a subresource segment (status, log, exec, attach, portforward) to the resource path. A subresource always addresses one named resource, so subresource without name croaks rather than returning a path pointing at the collection endpoint.
build_path also accepts kind, api_version, resource and namespaced arguments, but they matter only for IO::K8s::Unstructured. For any other class they are ignored: api_version, pluralisation and namespaced-ness always come from the class itself. Unstructured has no such class identity - its Kind is data on the instance - so the path metadata has to come from somewhere else:
my $path = $api->build_path('IO::K8s::Unstructured',
kind => 'MyCRD',
api_version => 'example.com/v1',
resource => 'mycrds',
namespaced => 1,
name => 'my-instance',
namespace => 'default',
);
# => /apis/example.com/v1/namespaces/default/mycrds/my-instance
Passing api_version, resource and namespaced together, as above, resolves the path directly with no discovery lookup - the case for a caller (such as an async wrapper) that already knows the resource's metadata. Otherwise kind is required (build_path croaks without it), and resource/namespaced/apiVersion are looked up in the client's cached discovery catalog instead, preferring the cluster's preferred version unless api_version pins a specific group/version - then only that group/version counts, never another group or version that serves a Kind of the same name; build_path croaks if discovery has no entry for the Kind (in the pinned group/version, which the message then names), and equally if the catalog could not be fetched at all (cluster unreachable, expired token) - in that case the message names that failure rather than claiming a missing entry, and the fallback stays fail-closed either way.
This is a public API for async wrappers like Net::Async::Kubernetes that need to construct request paths independently.
prepare_request
my $req = $api->prepare_request('GET', $path,
parameters => \%params,
body => \%body,
);
Build a Kubernetes::REST::HTTPRequest with method, full URL, authorization headers, and optional query parameters or JSON body.
Query parameter values may be scalars or arrayrefs (arrayrefs are emitted as repeated key=value pairs). Extra request headers can be provided via headers => \%headers.
Keys and values are percent-encoded where they would otherwise split or change the query on its way to the API server: %, &, +, #, ;, space, control characters, and non-ASCII characters (as their UTF-8 bytes). Everything else is sent as written, so a selector such as app.kubernetes.io/name=web,tier!=db appears in the URL exactly as it was passed. Pass characters, not bytes - a value that already holds UTF-8 bytes is encoded twice. A query string that is already part of $path is left untouched.
This is a public API for async wrappers that execute HTTP requests through their own event loop.
check_response
$api->check_response($response, "get Pod");
Validate an HTTP response. Returns the response on success. On a status code >= 400 it dies with a Kubernetes::REST::APIError, which carries the status (code, is_not_found, is_conflict), the reason, message and details of a Kubernetes Status body, the decoded body, the context and the response. It stringifies to the message this method croaked with as a plain string before, Kubernetes API error (get Pod): 404 ..., naming the line that called check_response. See "ERROR HANDLING".
inflate_object
my $pod = $api->inflate_object($class, $response);
Decode the JSON response body and inflate it into a typed IO::K8s object.
$class is normally what "expand_class" resolved and "build_path" loaded. A loaded IO::K8s class is inflated as exactly that class - also a single-segment class of your own, registered as '+Gizmo', which IO::K8s would otherwise read as the Kind Gizmo. Any other name, short or qualified, is resolved first. "inflate_list" and "process_watch_chunk" treat $class the same way.
inflate_list
my $list = $api->inflate_list($class, $response);
Decode the JSON response body and inflate the items array into an IO::K8s::List of typed objects.
An item the object model rejects - typically because the installed IO::K8s does not know a field the cluster version serves - is dropped from the list, and a warning names every dropped item (index, metadata.name, the inflation error) so an incomplete list is never silent. Promote it to a fatal error with local $SIG{__WARN__} = sub { die @_ } if partial results are unacceptable to you.
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 event (Kubernetes::REST::WatchEvent), resourceVersion, is_error, and error_code.
This is a public API for async wrappers that handle streaming watch responses through their own event loop.
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 Kubernetes::REST::LogEvent objects.
This is a public API for async wrappers that handle streaming log responses through their own event loop.
list
my $list = $api->list('Pod', namespace => 'default');
my $list = $api->list('Namespace', labelSelector => 'app=web');
List resources. Returns an IO::K8s::List object.
Accepts short class names (Pod) or full class paths. For namespaced resources, pass namespace parameter. Omit namespace to list cluster-scoped resources.
Supports labelSelector and fieldSelector query parameters for server-side filtering.
Any other argument croaks before a request is sent, naming it: a misspelt option is not ignored. That includes name: the list endpoint selects one object with fieldSelector => 'metadata.name=NAME', and "get" fetches it.
get
my $pod = $api->get('Pod', name => 'my-pod', namespace => 'default');
# or shorthand:
my $pod = $api->get('Pod', 'my-pod', namespace => 'default');
Get a single resource by name. Returns a typed IO::K8s object.
my $pod = $api->get('Pod', 'my-pod', namespace => 'default',
subresource => 'status');
# GET /api/v1/namespaces/default/pods/my-pod/status
Takes name, namespace for namespaced resources, and subresource, which reads the named subresource of the object instead of the object. The response is inflated as the resource's own class, which fits a subresource that answers with the object itself, as status does. Any other argument croaks before a request is sent, naming it: a misspelt option is not ignored.
create
my $created = $api->create($pod);
Create a resource from an IO::K8s object. Returns the created object with server-assigned fields (UID, resourceVersion, etc.).
update
my $updated = $api->update($pod);
Update an existing resource. Replaces the entire object server-side. Returns the updated object.
For partial updates, use "patch" instead.
patch
my $patched = $api->patch('Pod', 'my-pod',
namespace => 'default',
patch => { metadata => { labels => { env => 'staging' } } },
);
# Or with an object:
my $patched = $api->patch($pod,
patch => { metadata => { labels => { env => 'staging' } } },
);
# JSON Patch (RFC 6902) instead: an array of operations, not a hashref
my $patched = $api->patch('Deployment', 'my-app',
namespace => 'default',
type => 'json',
patch => [
{ op => 'replace', path => '/spec/replicas', value => 3 },
{ op => 'add', path => '/metadata/labels/env', value => 'prod' },
],
);
Partially update a resource. Unlike update() which replaces the entire object, patch() only modifies specified fields.
Required: patch (a hashref, or an arrayref of operations when type is json) and, when passing a class rather than an object, name.
Optional: namespace (for namespaced resources) and type, the patch strategy:
strategic(default)-
Strategic Merge Patch. The Kubernetes-native patch type, which understands array merge semantics (e.g. adding a container to a pod spec without removing the existing ones).
merge-
JSON Merge Patch (RFC 7396). Simple recursive merge where
nullvalues delete keys; arrays are replaced entirely. json-
JSON Patch (RFC 6902): an array of operations, as in the third example above.
Returns the full updated object from the server.
Any other argument croaks before a request is sent, naming it: a misspelt option is not ignored. With an object, which names the resource itself, only patch and type are taken - name and namespace croak too.
patch_status
my $node = $api->patch_status('OCPNode', 'cp-1',
namespace => 'ocp',
patch => { status => { phase => 'Ready', ip => '10.0.0.7' } },
);
# Or with an object:
my $node = $api->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 strips the status stanza from every write to the main endpoint - create, update, patch and server-side apply alike - and still answers 2xx. The write appears to succeed and nothing is stored. Status has to go to /status, which is what this method addresses.
Takes the same arguments as "patch" (object or class plus name, both call forms, the same type values), croaks on any other as "patch" does, and returns the full object from the server. The patch document is passed through unchanged, so it carries its own status key.
The default patch type is merge, not strategic as in "patch": custom resources do not support strategic merge patch and the API server rejects it with 415, and merge works for built-in kinds as well. Pass type => 'strategic' explicitly when patching the status of a built-in resource and you need array merge semantics.
update_status
my $node = $api->update_status($node);
Replace a resource's status through the /status subresource. The whole object is sent, as with "update", but the server only takes its status and leaves spec and metadata untouched. Returns the updated object.
This is the read-modify-write counterpart to "patch_status": it needs a current resourceVersion and fails with a 409 conflict when the object changed in the meantime. Prefer "patch_status" when you are setting individual status fields.
delete
$api->delete($pod);
# or by name:
$api->delete('Pod', name => 'my-pod', namespace => 'default');
# or shorthand:
$api->delete('Pod', 'my-pod', namespace => 'default');
# a Job, and the Pods it created with it:
$api->delete($job, propagationPolicy => 'Background');
$api->delete('Job', 'nightly', namespace => 'default',
propagationPolicy => 'Foreground');
Delete a resource. Returns true on success.
The optional propagationPolicy decides what happens to the objects the deleted one owns, and is sent as the propagationPolicy query parameter (a DeleteOptions field):
Background-
The object is deleted at once; the garbage collector deletes its dependents afterwards.
Foreground-
The object stays, with a
deletionTimestamp, until its dependents are deleted. Orphan-
The dependents are kept and lose their owner reference.
Without it the server applies the resource's default - for a batch/v1 Job that is Orphan, which leaves its Pods behind. Any other value croaks, listing the three.
Any argument other than name, namespace and propagationPolicy - with an object, other than propagationPolicy - croaks before anything is sent, naming it: a misspelt option is not ignored.
ensure
my $obj = $api->ensure($pod);
# or from a plain hashref (treated as a Kubernetes manifest):
my $secret = $api->ensure({
apiVersion => 'v1',
kind => 'Secret',
metadata => { name => 'foo', namespace => 'default' },
stringData => { password => 'hunter2' },
});
Idempotent create-or-update. Fetches the resource by kind/name/namespace; if it exists, updates it (preserving resourceVersion), otherwise creates it. Returns the resulting IO::K8s object.
Accepts either a typed IO::K8s object or a plain hashref. A hashref must carry a kind field and is inflated to a typed object via "struct_to_object" in IO::K8s. Hashref keys follow the Kubernetes API convention (camelCase, e.g. 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. An apiVersion that resolves to no known class croaks, naming the Kind and the apiVersion, instead of falling back to the Kind's default version. A hashref without apiVersion resolves by its Kind alone, as "expand_class" does; a Kind that resolves to nothing croaks naming it (unknown resource 'Kind', as described under "expand_class") before any request is sent.
Handles common race conditions:
404 on initial get is treated as "does not exist" and falls through to create.
409 AlreadyExists on create (resource appeared between get and create) is re-fetched and handled like a resource that existed from the start: updated, or - for the special cases below - returned unchanged or recreated.
409 Conflict on update (resourceVersion changed server-side, e.g. a controller wrote status) is retried by re-fetching and re-applying.
Each case is recognised by the response status. Any other error status croaks with the API error, whatever its message happens to say.
Special-cases for kinds with server-side mutation constraints:
PersistentVolumeClaim(corev1) - spec is immutable after creation, so an existing PVC is returned unchanged.Job(batch/v1) - spec is immutable; an existing Job that is active or has succeeded is returned unchanged. A failed Job is deleted withpropagationPolicyBackground, so its Pods go with it, and recreated.
Both are recognised by apiVersion and Kind together: a typed object's api_version and kind, or an IO::K8s::Unstructured object's apiVersion and kind fields - 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.
It takes exactly one object or hashref. Anything after it croaks before a request is sent - an option is not ignored, and a second object is not applied silently or dropped; "ensure_all" takes several.
ensure_all
my @results = $api->ensure_all(@objects);
Batch version of "ensure". Applies create-or-update to each object in order and returns the list of resulting objects.
ensure_only
$api->ensure_only(
label => 'app.kubernetes.io/component=queen',
objects => \@objects,
kinds => [qw(Role RoleBinding ClusterRoleBinding)],
namespaces => ['default', 'kube-system', undef],
);
Like "ensure_all", but also deletes any resources matching the label selector in the given kinds and namespaces that are not present in objects. Use this for resources where stale objects must not survive (e.g. RBAC).
Pass undef inside namespaces to scan cluster-scoped resources. If namespaces is omitted, only cluster-scoped resources are scanned.
objects takes typed objects or hashrefs, resolved as in "ensure". A kinds entry may be a bare Kind or a qualified group/version/Kind (see "expand_class"). A listed resource counts as present when its API group, Kind, namespace and name match an object in objects - group and Kind come from a typed object's api_version and kind, or from an IO::K8s::Unstructured object's apiVersion and kind fields. 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 is deleted. The version is not compared, so an object applied as autoscaling/v1 is kept when the listing goes through autoscaling/v2.
Pruning goes on past a failure, and says so. When a kinds entry cannot be listed in one namespace - an HTTP error, or an entry that resolves to no class - that combination is skipped with a warning 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. Promote the warnings to a fatal error with local $SIG{__WARN__} = sub { die @_ } if a partial prune is unacceptable to you.
Pruned objects are deleted with propagationPolicy Background (see "delete"), as kubectl delete does, so a pruned Job takes its Pods with it. Pass propagationPolicy => 'Foreground' or 'Orphan' to change that; any other value croaks before anything is applied.
Returns the list of applied objects (from "ensure_all"), whether or not the pruning was complete.
Any argument other than label, objects, kinds, namespaces and propagationPolicy croaks before anything is applied, naming it: a misspelt option is not ignored.
ensure_crd
# one class == one single-version CRD
$api->ensure_crd('My::K8s::StaticWebSite');
# several distinct CRDs at once
$api->ensure_crd(@crd_classes);
# with options (pass the classes as an arrayref):
$api->ensure_crd(\@crd_classes, timeout => 60, poll_interval => 2);
# several classes that are versions of the SAME CRD -> one multi-version CRD
$api->ensure_crd(
[ 'My::K8s::V1beta1::Widget', 'My::K8s::V1::Widget' ],
storage => 'v1',
);
Install (create-or-update) one or more CustomResourceDefinitions from typed IO::K8s classes, wait for each to reach the Established condition, then "invalidate_discovery" so the new Kinds resolve on this client instance.
Each class's CRD manifest comes from $class->to_crd (IO::K8s::CRD). The assembled CRD is applied with "ensure", so re-running is idempotent. After every CRD is applied, each is polled with "get" by name until its status.conditions carries type => 'Established', status => 'True', subject to a timeout. Only then is the discovery cache invalidated, so the next create/list of a custom resource does not race the apiserver registering the Kind (the reason plain ensure of the CRD is not enough: the following call almost always creates a CR, which 404s until Established). A 404 while polling counts as not registered yet and is polled again; any other error status ends the wait at once and croaks with that API error.
Returns the list of established CustomResourceDefinition objects (the objects read back from the final poll, carrying their Established status).
Arguments: the CRD classes, either as a plain list (ensure_crd(@classes)) or, when options are needed, as an arrayref followed by named options (ensure_crd(\@classes, %opts)).
- timeout
-
Seconds to wait for the
Establishedcondition per CRD (default 30). On expiry the call croaks, naming the CRD and thatEstablishedwas not reached. - poll_interval
-
Seconds between polls (default 1). The current state is always checked once before the timeout is consulted, so
timeout => 0performs exactly one poll. - storage
-
Only consulted when two or more passed classes name the SAME CRD (same group, plural, kind and scope but different versions). Those are assembled into ONE multi-version CustomResourceDefinition via
IO::K8s::CRD->new(classes => [...], storage => ...)rather than applied as competing single-version CRDs. Because a class carries no marker for which version is authoritative, the storage version is not guessed: pass it as a bare version string (applies to the multi-version group) or as a hashref keyed by CRD name ({ 'widgets.example.com' => 'v1' }). A multi-version group with no storage version croaks, naming the CRD and the candidate versions.
Any other option croaks before anything is applied, naming it: a misspelt option is not ignored.
watch
my $last_rv = $api->watch('Pod',
namespace => 'default',
on_event => sub {
my ($event) = @_;
say $event->type . ": " . $event->object->metadata->name;
},
timeout => 300,
resourceVersion => '12345',
labelSelector => 'app=web',
fieldSelector => 'status.phase=Running',
);
Watch for changes to resources. Uses the Kubernetes Watch API with chunked transfer encoding to stream events. The call blocks until the server-side timeout expires.
Required: on_event, a callback invoked with a Kubernetes::REST::WatchEvent for each event.
Optional:
- timeout
-
Server-side timeout in seconds (default: 300). The API server closes the connection after this many seconds.
- resourceVersion
-
Resume watching from a specific resource version - pass the return value of a previous
watch()call to avoid missing events. - labelSelector
-
Filter by label selector (e.g.
'app=web,env=prod'). - fieldSelector
-
Filter by field selector (e.g.
'status.phase=Running'). - namespace
-
For namespaced resources, the namespace to watch.
Any other argument croaks before a request is sent, naming it: a misspelt option is not ignored. That includes name: to watch one object, pass fieldSelector => 'metadata.name=NAME'.
Returns the last resourceVersion seen. Croaks on 410 Gone once the given resourceVersion has expired - re-list to get a fresh one and resume from there:
my $rv;
while (1) {
$rv = eval {
$api->watch('Pod',
namespace => 'default',
resourceVersion => $rv,
on_event => \&handle_event,
);
};
if ($@ && $@ =~ /410 Gone/) {
# resourceVersion expired, re-list to get fresh version
my $list = $api->list('Pod', namespace => 'default');
$rv = undef; # start fresh
}
}
That croak is a plain string, not a Kubernetes::REST::APIError: the watch request itself was answered with 200, and the 410 arrived inside the stream as an ERROR event, which on_event has already been given. It calls for a re-list, not error handling. An HTTP error status on the watch request itself dies with an Kubernetes::REST::APIError as usual.
log
# One-shot: get full log as string
my $text = $api->log('Pod', 'my-pod',
namespace => 'default',
tailLines => 100,
);
# Streaming: callback per log line
$api->log('Pod', 'my-pod',
namespace => 'default',
follow => 1,
on_line => sub {
my ($event) = @_; # Kubernetes::REST::LogEvent
say $event->line;
},
);
Retrieve logs from a pod. Supports two modes:
One-shot (without on_line): Returns the full log text as a string.
Streaming (with on_line): Calls the callback for each log line with a Kubernetes::REST::LogEvent object. Blocks until the stream ends (or the server closes the connection).
Log output is returned as raw bytes in both modes - container output is not guaranteed to be UTF-8, or even text. Decode it yourself when you know it is: Encode::decode('UTF-8', $text). See "ENCODING".
The streaming mode is designed for event-based systems like IO::Async — see Net::Async::Kubernetes for async integration.
Also accepts, for namespaced resources, namespace; and as further optional arguments: container (name, for multi-container pods), sinceSeconds / sinceTime (show only recent output), timestamps (prepend a timestamp to each line), previous (logs from the container's previous run, after a restart), and limitBytes (byte cap on the response). Any other argument croaks before the request is sent, naming it: a misspelt option is not ignored.
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.
Required: name and ports - one port number or an arrayref of them (e.g. [8080, 8443]).
Optional: namespace (for namespaced resources), subprotocol (WebSocket subprotocol, default v4.channel.k8s.io), and the duplex transport callbacks on_open, on_frame, on_close, on_error, passed through to the IO backend. Any other argument croaks before a request is sent, naming it: a misspelt option is not ignored.
This method requires an IO backend that implements call_duplex. The default Kubernetes::REST::LWPIO and Kubernetes::REST::HTTPTinyIO backends do not currently provide duplex transport.
Returns whatever the IO backend returns for call_duplex (typically a session/handle object managed by that backend).
exec
my $session = $api->exec('Pod', 'my-pod',
namespace => 'default',
command => ['sh', '-c', 'echo hello'],
stdin => 0,
stdout => 1,
stderr => 1,
tty => 0,
on_frame => sub { my ($channel, $payload) = @_; ... },
);
Start a full-duplex pod exec session via the /exec subresource.
Required: name and command - a single string or an arrayref (e.g. ['sh', '-c', 'id']).
Optional: namespace, container (for multi-container pods), the stream toggles stdin/stdout/stderr/tty shown above (defaults: stdin and tty off, stdout and stderr on), subprotocol (WebSocket subprotocol, default v4.channel.k8s.io), and the duplex transport callbacks on_open, on_frame, on_close, on_error, passed through to the IO backend. Any other argument croaks before a request is sent, naming it: a misspelt option is not ignored.
This method requires an IO backend that implements call_duplex. The default Kubernetes::REST::LWPIO and Kubernetes::REST::HTTPTinyIO backends do not currently provide duplex transport.
Returns whatever the IO backend returns for call_duplex (typically a session/handle object managed by that backend).
attach
my $session = $api->attach('Pod', 'my-pod',
namespace => 'default',
container => 'app',
stdin => 1,
stdout => 1,
stderr => 1,
tty => 0,
on_frame => sub { my ($channel, $payload) = @_; ... },
);
Start a full-duplex pod attach session via the /attach subresource.
Required: name.
Optional: namespace, container (for multi-container pods), the stream toggles stdin/stdout/stderr/tty shown above (defaults: stdin and tty off, stdout and stderr on), subprotocol (WebSocket subprotocol, default v4.channel.k8s.io), and the duplex transport callbacks on_open, on_frame, on_close, on_error, passed through to the IO backend. Any other argument croaks before a request is sent, naming it: a misspelt option is not ignored.
This method requires an IO backend that implements call_duplex. The default Kubernetes::REST::LWPIO and Kubernetes::REST::HTTPTinyIO backends do not currently provide duplex transport.
Returns whatever the IO backend returns for call_duplex (typically a session/handle object managed by that backend).
ERROR HANDLING
When the API server answers with an HTTP error status (400 and up), the call dies with a Kubernetes::REST::APIError. It stringifies to the familiar message - Kubernetes API error (get Pod): 404 {...} at app.pl line 12. - so printing $@ or matching it against a regex works as it always did, and it carries the status for code that has to tell cases apart:
my $ok = eval { $api->delete('Pod', 'web', namespace => 'default'); 1 };
unless ($ok) {
my $err = $@;
die $err unless ref $err && $err->isa('Kubernetes::REST::APIError')
&& $err->is_not_found; # already gone is fine
}
Besides code, is_not_found and is_conflict it has the reason, message and details of the Kubernetes Status body, the decoded body, the context and the response.
That includes the /openapi/v2 fetch behind schema_for and compare_schema, and the discovery documents (GET /api, GET /apis, context discovery GET /api), which "absorb_discovery" dies with directly. Where the client reads discovery for itself, a failure shows inside another message instead - the warning that the resource map falls back to the built-in one, the croak of "fetch_resource_map", the croak for a name discovery could not confirm, the warning for a legacy group/version it could not read (see "fetch_resource_map") - which embeds the error's text.
Everything else croaks with a plain string: invalid arguments, a resource name nothing resolves, and an expired watch - its 410 arrives as an ERROR event in a stream the server answered with 200, not as an HTTP status (see "watch").
UPGRADING FROM 0.02
WARNING: Version 1.00 contains breaking changes!
This version has been completely rewritten. Key changes that may affect your code:
New simplified API
The old method-per-operation API (e.g.,
$api->Core->ListNamespacedPod(...)) has been replaced with a simple API:list,get,create,update,patch,patch_status,update_status,delete,ensure,ensure_all,ensure_only,watch,log,port_forward,exec,attach.Old API still works but deprecated
The old API is still available for backwards compatibility but will emit deprecation warnings. Set
$ENV{HIDE_KUBERNETES_REST_V0_API_WARNING}to suppress warnings.Uses IO::K8s classes
Results are now returned as typed IO::K8s objects instead of raw hashrefs. Lists are returned as IO::K8s::List objects.
Note: IO::K8s has also been completely rewritten (Moose to Moo, API objects updated to a current Kubernetes release). See "UPGRADING FROM PREVIOUS VERSIONS" in IO::K8s for details.
Short resource names
You can now use short names like
'Pod'instead of full class paths. Theresource_mapattribute controls this mapping.Dynamic resource map
Use
resource_map_from_cluster => 1to load the resource map from the cluster's OpenAPI spec, ensuring compatibility with any Kubernetes version.
BUILDING BLOCKS FOR ASYNC WRAPPERS
Async wrappers like Net::Async::Kubernetes need access to the request/response pipeline without going through the synchronous convenience methods. The following public methods provide this:
expand_class($short)- Resolve short name to full classbuild_path($class, %args)- Build REST API URL pathprepare_request($method, $path, %opts)- Build HTTP request with authcheck_response($response, $context)- Validate HTTP status (dies with a Kubernetes::REST::APIError on 400 and up)inflate_object($class, $response)- JSON to typed objectinflate_list($class, $response)- JSON to typed list (an item the object model rejects is dropped and carped about, not silently lost - see "inflate_list")process_watch_chunk($class, \$buf, $chunk)- Parse NDJSON watch streamprocess_log_chunk(\$buf, $chunk)- Parse plain-text log streamprepare_discovery_requestsandabsorb_discovery(%responses)- Read the cluster's discovery through your own event loop, so that resolving names needs no request of the client's own (see "absorb_discovery")
Example async integration:
# Build request using Kubernetes::REST
my $class = $rest->expand_class('Pod');
my $path = $rest->build_path($class,
name => $name,
namespace => $ns,
subresource => 'log',
);
my $req = $rest->prepare_request('GET', $path, parameters => { follow => 'true' });
# Execute through your own event loop
my $buffer = '';
$async_http->request($req->url, sub {
my ($chunk) = @_;
for my $event ($rest->process_log_chunk(\$buffer, $chunk)) {
$on_line->($event);
}
});
PLUGGABLE IO ARCHITECTURE
The HTTP transport is decoupled from request preparation and response processing. This makes it possible to swap the default LWP::UserAgent backend for HTTP::Tiny or an async backend (e.g. 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 io->call_streaming() and step 4 uses process_watch_chunk() which parses NDJSON and inflates each event.
For log, step 2 uses io->call_streaming() and step 4 uses process_log_chunk() which parses plain-text lines into Kubernetes::REST::LogEvent objects.
To implement a custom IO backend, consume Kubernetes::REST::Role::IO and implement call($req) and call_streaming($req, $callback). See Kubernetes::REST::LWPIO and Kubernetes::REST::HTTPTinyIO for reference implementations.
ENCODING
On the wire everything is bytes; in your program everything is characters. The boundary sits in this module, and you do not have to do anything for it:
Objects you pass to
create,update,patchand friends hold ordinary Perl character strings. They are UTF-8 encoded on the way into the request body, sodata => { note => "Caf\x{e9} \x{a7}" }is applied to the cluster unchanged.Objects you get back from
get,list,watchand friends hold decoded characters, solengthcounts characters and regexes match as expected.Exception messages from failed API calls are decoded too.
Two things stay bytes on purpose:
log- container output is an arbitrary byte stream, not necessarily UTF-8, and decoding it would corrupt anything binary. Decode it yourself if you know it is text:Encode::decode('UTF-8', $api->log('Pod', $name)). The same applies to$event->linein streaming mode.The
contentof Kubernetes::REST::HTTPRequest and Kubernetes::REST::HTTPResponse - these are the raw HTTP layer. Custom IO backends must honour that; see "Encoding contract" in Kubernetes::REST::Role::IO.
SEE ALSO
Related Modules
IO::K8s - Kubernetes resource classes (required dependency)
Net::Async::Kubernetes - Async Kubernetes client for IO::Async
Configuration and Authentication
Kubernetes::REST::Kubeconfig - Load settings from kubeconfig
Kubernetes::REST::Server - Server connection configuration
Kubernetes::REST::AuthToken - Authentication credentials
HTTP Backends
Kubernetes::REST::Role::IO - IO interface role
Kubernetes::REST::LWPIO - LWP::UserAgent backend (default)
Kubernetes::REST::HTTPTinyIO - HTTP::Tiny backend
LWP::ConsoleLogger - HTTP debugging for LWPIO
Data Objects
Kubernetes::REST::WatchEvent - Watch event object
Kubernetes::REST::LogEvent - Log event object
Kubernetes::REST::APIError - Error thrown for an HTTP error status
Kubernetes::REST::HTTPRequest - HTTP request object
Kubernetes::REST::HTTPResponse - HTTP response object
CLI Tools
Kubernetes::REST::CLI - CLI base class
Kubernetes::REST::CLI::Watch - kube_watch CLI tool
Kubernetes::REST::CLI::Role::Connection - Shared CLI options
Examples and Documentation
Kubernetes::REST::Example - Comprehensive examples with Minikube/K3s
https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/ - Kubernetes API reference
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/pplu/kubernetes-rest/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.
AUTHORS
Torsten Raudssus <getty@cpan.org>
Jose Luis Martinez Torres <jlmartin@cpan.org>
COPYRIGHT AND LICENSE
This software is Copyright (c) 2019-2026 by Jose Luis Martinez Torres <jlmartin@cpan.org>.
This is free software, licensed under:
The Apache License, Version 2.0, January 2004