NAME

IO::K8s - Objects representing things found in the Kubernetes API

VERSION

version 1.110

SYNOPSIS

use IO::K8s;

my $k8s = IO::K8s->new;

# Load .pk8s manifest files (Perl DSL)
my $resources = $k8s->load('myapp.pk8s');

# Load YAML manifests and validate declared field types
my $resources = $k8s->load_yaml('deployment.yaml');

# Also reject fields the current model does not declare
my $strict_k8s = IO::K8s->new(strict => 1);
my $strict_resources = $strict_k8s->load_yaml('deployment.yaml');

# Validate with error collection
my ($objs, $errors) = $k8s->load_yaml($yaml, collect_errors => 1);

# Create objects programmatically
my $pod = $k8s->new_object('Pod',
    metadata => { name => 'my-pod', namespace => 'default' },
    spec => { containers => [{ name => 'app', image => 'nginx' }] }
);

# Export to YAML and save
print $pod->to_yaml;
$pod->save('pod.yaml');

# Inflate JSON/struct into typed objects
my $svc = $k8s->json_to_object('Service', '{"kind":"Service",...}');
my $obj = $k8s->inflate($json_with_kind);  # Auto-detect class from 'kind'

# Serialize back
my $json = $k8s->object_to_json($svc);
my $struct = $k8s->object_to_struct($pod);

# With OpenAPI spec for Custom Resources (CRDs)
my $k8s = IO::K8s->new(openapi_spec => $spec_from_cluster);
my $helmchart = $k8s->inflate($helmchart_json);  # Auto-generates class!

# With external resource map providers (e.g. IO::K8s::Cilium)
my $k8s = IO::K8s->new(with => ['IO::K8s::Cilium']);

# Or add at runtime
$k8s->add('IO::K8s::Cilium');           # class name
$k8s->add(IO::K8s::Cilium->new);        # instance
$k8s->add({ MyThing => '+My::Thing' }); # raw hashref

# Disambiguate colliding kind names (e.g. both core and Cilium have NetworkPolicy)
$k8s->new_object('NetworkPolicy', { ... });                  # core (first-registered)
$k8s->new_object('NetworkPolicy', { ... }, 'cilium.io/v2');  # Cilium
$k8s->new_object('cilium.io/v2/NetworkPolicy', { ... });     # domain-qualified

# inflate() auto-uses apiVersion from the data for disambiguation
$k8s->inflate('{"kind":"NetworkPolicy","apiVersion":"cilium.io/v2",...}');

DESCRIPTION

This module provides objects and serialization / deserialization methods that represent the structures found in the Kubernetes API https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.37/

Kubernetes API is strict about input types. When a value is expected to be an integer, sending it as a string will cause rejection. This module ensures correct value types in JSON that can be sent to Kubernetes.

It also inflates JSON returned by Kubernetes into typed Perl objects.

add_crd

my $registered = $k8s->add_crd('crds/knobs.yaml', $crd_object, \%crd_hash, ...);

Loads each argument through "load" in IO::K8s::CRD, generates one class per served version in this instance's AutoGen namespace, and registers them: every version under its domain-qualified key (group/version/Kind), the storage version under the bare Kind -- through "add", so a class already holding the short name (a provider merged earlier) keeps it and the CRD's class stays reachable by its qualified key. Returns { $Kind => { $api_version => $class, ..., storage => $api_version } }. Classes are cached by group/version/Kind within this instance's AutoGen namespace ("generate" in IO::K8s::CRD), so re-adding an edited manifest on the same $k8s silently returns the class generated the first time -- call add_crd on a fresh instance to pick up schema changes; classes generated by any call live for the life of the process regardless, so a reload loop that builds a fresh $k8s per iteration (rather than calling add_crd again on the same one) accumulates classes rather than freeing the old ones -- add_crd is meant to run once at startup, not inside a polling loop.

Two CRDs can legitimately share a bare Kind across groups (their domain-qualified keys, not the Kind alone, are what actually disambiguate them) -- calling add_crd for both merges the second's version entries into the first's return value under that Kind rather than replacing it outright, and storage stays whichever the FIRST registration reported, mirroring "add"'s own first-registration-wins rule for a short-name collision.

A trailing hashref of options is forwarded to "generate" in IO::K8s::CRD (and from there to "get_or_generate" in IO::K8s::AutoGen) for every CRD in this call:

$k8s->add_crd('crds/knobs.yaml', { reuse_core => 0 });

Distinguished from a CRD document by the absence of a kind key -- every CRD hashref "load" in IO::K8s::CRD accepts has one -- so it must be the LAST argument and only one is read per call.

NAME

IO::K8s - Objects representing things found in the Kubernetes API

CLASS ARCHITECTURE

IO::K8s uses a layered architecture. Understanding these layers helps when working with built-in resources or writing your own CRD classes.

IO::K8s::Resource (setup layer)

Declaring a class with use IO::K8s::Resource imports Moo, installs the k8s DSL, and composes IO::K8s::Role::Resource. It does not make that class isa('IO::K8s::Resource'). It provides:

  • Moo class setup

  • The k8s DSL for declaring attributes with Kubernetes types

  • TO_JSON / to_json serialization

  • Type registry for inflation (JSON -> objects)

The k8s DSL supports these type specifications:

k8s name     => 'Str';                   # string attribute
k8s replicas => 'Int';                   # integer attribute
k8s ready    => 'Bool';                  # boolean attribute
k8s spec     => 'Core::V1::PodSpec';     # nested IO::K8s object
k8s ports    => ['Core::V1::ServicePort']; # array of objects
k8s labels   => { Str => 1 };            # map of strings
k8s data     => HashRef[Str];            # map of strings (strict)
k8s raw      => Opaque;                  # free map, values untyped
k8s items    => ['+Full::Class::Name'];  # array with full class (+ prefix)

IO::K8s::APIObject (top-level resources)

IO::K8s::APIObject uses the same setup for top-level API objects (Pod, Deployment, Service, etc.) and additionally composes IO::K8s::Role::APIObject. It adds:

  • metadata attribute (IO::K8s::Apimachinery::Pkg::Apis::Meta::V1::ObjectMeta)

  • api_version() - derived from class name for built-in types, or set via import parameter for CRDs

  • kind() - derived from the last segment of the class name

  • resource_plural() - the plural Kubernetes addresses the Kind by (pods, networkpolicies, ingresses), from a table generated off the upstream spec for built-in types; CRDs declare their own

  • to_yaml() - serialize to YAML suitable for kubectl apply -f

  • save($file) - write YAML to file

IO::K8s::Role::Namespaced (marker role)

IO::K8s::Role::Namespaced is a marker role for namespace-scoped resources. Kubernetes::REST checks this to build the correct URL path (with or without /namespaces/{ns}/).

WRITING CRD CLASSES

To use Custom Resource Definitions with Kubernetes::REST, write a Perl class using IO::K8s::APIObject. It gives a custom class the same Moo/DSL setup and top-level APIObject role composition as built-in Kubernetes types like Pod, Deployment, and Service.

Minimal CRD class

package My::StaticWebSite;
use IO::K8s::APIObject
    api_version     => 'homelab.example.com/v1',
    resource_plural => 'staticwebsites';
with 'IO::K8s::Role::Namespaced';

k8s spec   => Opaque;
k8s status => Opaque;
1;

That's it - 6 lines of actual code. This class now supports:

my $site = My::StaticWebSite->new(
    metadata => $meta_object,
    spec     => { domain => 'blog.example.com', image => 'nginx' },
);
$site->kind;          # "StaticWebSite"
$site->api_version;   # "homelab.example.com/v1"
$site->to_yaml;       # full YAML output
$site->TO_JSON;       # hashref for JSON encoding

Import parameters

use IO::K8s::APIObject accepts these parameters:

api_version (required for CRDs)

The CRD's group/version, e.g. 'homelab.example.com/v1'. For built-in types this is derived from the class name (IO::K8s::Api::Core::V1::Pod gives v1), but CRDs must specify it explicitly since their class names don't follow the IO::K8s::Api::* convention.

resource_plural (recommended for CRDs)

The plural resource name for URL building and RBAC resources: rules, e.g. 'staticwebsites'. Must match the CRD's spec.names.plural. An explicit value always wins over anything IO::K8s knows.

Built-in Kinds do not need this: their plurals come from a table generated off the upstream OpenAPI spec's REST paths. CRDs do, because there is no spec to read them from. If omitted, resource_plural() returns undef and the caller is left to pluralize the kind name itself (StaticWebSite -> staticwebsites) -- that heuristic does not work for all names, which is exactly why declaring it is recommended.

subresources (optional)

Declares which subresources (status, scale) the CRD version serves; "to_crd" in IO::K8s::Role::APIObject writes the declaration into spec.versions[].subresources. Becomes a fixed identity class method like api_version. See IO::K8s::APIObject for the accepted shape and its validation.

An unknown import parameter, an odd number of import arguments, or an api_version/resource_plural given as undef or an empty string all croak at the use line, before anything is set up; see IO::K8s::APIObject for the exact messages.

Namespaced vs cluster-scoped

Apply IO::K8s::Role::Namespaced for namespace-scoped CRDs (the common case). Omit it for cluster-scoped CRDs:

# Namespaced CRD (most CRDs):
package My::StaticWebSite;
use IO::K8s::APIObject api_version => 'homelab.example.com/v1', ...;
with 'IO::K8s::Role::Namespaced';

# Cluster-scoped CRD (rare):
package My::ClusterBackupPolicy;
use IO::K8s::APIObject api_version => 'backup.example.com/v1', ...;
# No 'with Namespaced' - this is cluster-wide

Registering with Kubernetes::REST

Register your CRD class in the resource map using the + prefix (which means "use this full class name as-is"):

use Kubernetes::REST::Kubeconfig;
use My::StaticWebSite;

my $api = Kubernetes::REST::Kubeconfig->new->api;
$api->resource_map->{StaticWebSite} = '+My::StaticWebSite';

# Now use it like any built-in resource
my $site = $api->create($api->new_object(StaticWebSite =>
    metadata => { name => 'my-blog', namespace => 'default' },
    spec     => { domain => 'blog.example.com', image => 'nginx' },
));

See Kubernetes::REST::Example for complete CRUD examples with CRDs.

AUTO-GENERATION

IO::K8s can automatically generate classes for Custom Resources and other types not included in the built-in classes. This is an alternative to writing CRD classes by hand.

From cluster OpenAPI spec

Provide the cluster's OpenAPI spec and IO::K8s will auto-generate classes on demand:

use IO::K8s;
use Kubernetes::REST::Kubeconfig;

# Get OpenAPI spec from cluster
my $api = Kubernetes::REST::Kubeconfig->new->api;
my $resp = $api->_request('GET', '/openapi/v2');
my $spec = JSON::MaybeXS->new->decode($resp->content);

# Create IO::K8s with auto-generation enabled
my $k8s = IO::K8s->new(openapi_spec => $spec);

# An unknown Kind with an unambiguous GVK definition in this spec auto-generates a class
my $addon = $k8s->inflate($k3s_addon_json);   # k3s.cattle.io/v1 Addon
my $chart = $k8s->inflate($helmchart_json);   # helm.cattle.io/v1 HelmChart

Auto-generated classes are placed in a unique namespace per IO::K8s instance (e.g., IO::K8s::_AUTOGEN_abc123::...) to avoid collisions.

From a CustomResourceDefinition manifest

Loading a CRD manifest directly -- see "add_crd" -- generates one class per served version and goes further than the OpenAPI-spec path above: every inline type: object schema below the top level, which is how a CRD schema is written everywhere below its Kind, becomes its own nested class named after its place in the parent (<Kind>::<Prop>, with an Item / Value suffix for array items and map values) instead of a free Opaque map, so field options and the unknown-field bag apply at every level. Hash-style access on such a field still works -- a Moo object is a blessed hash keyed by attribute name -- so code that reads $obj->{spec}{mode} does not need to change either way. Only a property-less object (or one with x-kubernetes-preserve-unknown-fields) stays Opaque, and an additionalProperties-only map becomes a HashRef[X] (nothing underneath to attach options to). IO::K8s::CRD::Emitter renders the same classes as checked-in source.

Explicit generation with IO::K8s::AutoGen

For more control, use IO::K8s::AutoGen directly:

use IO::K8s::AutoGen;

my $class = IO::K8s::AutoGen::get_or_generate(
    'com.example.homelab.v1.StaticWebSite',  # definition name
    $schema,                                   # OpenAPI schema
    {},                                        # all definitions
    'MyApp::K8s',                              # namespace
    api_version     => 'homelab.example.com/v1',
    kind            => 'StaticWebSite',
    resource_plural => 'staticwebsites',
    is_namespaced   => 1,
);

# Register with Kubernetes::REST
$api->resource_map->{StaticWebSite} = "+$class";

Custom Class Namespaces

You can provide your own pre-built classes that take precedence over both built-in and auto-generated classes:

my $k8s = IO::K8s->new(
    class_namespaces => ['MyApp::K8s'],
    openapi_spec => $spec,
);

With this configuration, the class lookup order is:

1. MyApp::K8s::...          (your classes)
2. IO::K8s::...             (built-in classes)
3. IO::K8s::_AUTOGEN_...    (auto-generated)

This lets you create optimized or customized classes for specific resources while falling back to auto-generation for everything else.

ATTRIBUTES

with

Optional. ArrayRef of external resource map providers to merge at construction time. Each entry can be a class name (string) or an object instance. Classes must consume IO::K8s::Role::ResourceMap or otherwise provide a resource_map() method.

my $k8s = IO::K8s->new(with => ['IO::K8s::Cilium']);

When kinds collide (e.g. both core and Cilium have NetworkPolicy), the first-registered entry keeps the short name. All entries are always reachable via domain-qualified names (api_version/Kind).

strict

Optional. Boolean, default 0. Governs what happens when a constructor key matches no declared attribute, at any nesting level. With the default 0 the field is kept and re-emitted by TO_JSON (see "UNKNOWN FIELDS" in IO::K8s::Role::Resource); with 1 it dies instead, with Unknown field '<name>' for <class>.

my $k8s = IO::K8s->new(strict => 1);
$k8s->new_object('Pod', { spec => { bogusField => 1 } });
# dies: Unknown field 'bogusField' for IO::K8s::Api::Core::V1::PodSpec

strict is read by "inflate", "new_object", "json_to_object" and "struct_to_object"; "load" and "load_yaml" inherit it because both build on inflate/new_object. It applies for the duration of that one call, including every nested object it constructs along the way.

Since 1.108, IO::K8s::List, the envelope a list Kind inflates to, also composes IO::K8s::Role::Resource: its own top-level keys are preserved and checked under strict exactly like any other resource's, alongside the objects inside items, each through its own class.

unknown_kinds

Optional. String, default ''. Governs what "inflate" and "new_object" do when a document's apiVersion/kind (or an explicit api_version argument) amount to a GVK request that resolves to no registered class -- built-in, CRD-registered via add()/add_crd(), or AutoGen'd from openapi_spec. With the default '' this keeps failing closed exactly as without the option, dying with:

Cannot resolve Kubernetes GVK: kind '<kind>', apiVersion '<apiVersion>'

With unknown_kinds => 'unstructured', that failure instead builds an IO::K8s::Unstructured from the document: apiVersion, kind and metadata land on typed attributes, everything else round-trips through the _unknown_fields bag (D1) exactly as an undeclared field on any other class does.

my $k8s = IO::K8s->new(unknown_kinds => 'unstructured');
my $obj = $k8s->inflate($document);   # an IO::K8s::Unstructured, not a die

Any value other than the literal string 'unstructured' -- including one left unset -- keeps the fail-closed default; this is strictly opt-in. strict => 1 combined with this option still dies on a registered Kind with an unexpected field the normal way; the Unstructured envelope itself is exempt from strict, since every field beyond apiVersion/kind/metadata is precisely what it exists to preserve.

openapi_spec

Optional. The OpenAPI v2 specification from a Kubernetes cluster. When provided, enables auto-generation of classes for types not found in the built-in classes.

An error from generating such a class -- a $ref no definition answers, say, and the remembered failure IO::K8s::AutoGen rethrows when the same class is asked for again -- names the line that called "expand_class", "new_object", "inflate" or whichever entry point asked for the class, not a line inside IO::K8s.

class_namespaces

Optional. ArrayRef of namespace prefixes to search for classes before checking IO::K8s built-ins. Useful for providing your own implementations.

resource_map

HashRef mapping short names (like Pod) and domain-qualified names (like networking.k8s.io/v1/NetworkPolicy) to class paths. Defaults to built-in mappings for standard Kubernetes resources. Each instance gets its own copy, so modifications via add() do not affect other instances.

json

A JSON::MaybeXS encoder/decoder configured with utf8 => 1 and canonical => 1. Used by "object_to_json", "json_to_object" and "inflate" for their default encoding/decoding. Override at construction when the caller needs a different encoder (for example, to disable canonical for tighter output, or to swap in a different backend):

my $k8s = IO::K8s->new(json => JSON::MaybeXS->new(utf8 => 1));

METHODS

add

$k8s->add('IO::K8s::Cilium');             # class name
$k8s->add(IO::K8s::Cilium->new);          # instance
$k8s->add({ MyKind => '+My::Class' });     # raw hashref
$k8s->add($provider1, $provider2);         # multiple at once

Merge external resource maps into this instance. Accepts class names, object instances with a resource_map() method, or plain hashrefs.

When a kind name already exists in the resource map (collision), the first-registered entry keeps the short name. Both the existing and new entries are registered under domain-qualified names (api_version/Kind) so they remain reachable.

Returns $self for chaining.

load

my $resources = $k8s->load('myapp.pk8s');
my $resources = $k8s->load('myapp.pk8s', vars => { name => 'web', replicas => 3 });

Load a .pk8s manifest file and return an ArrayRef of IO::K8s objects.

Trust boundary: A .pk8s manifest is Perl code, not data. The loader evals the file content in-process, so a .pk8s file can execute arbitrary code with the full privileges of the running program. Only load .pk8s files from sources you trust. For data-only manifests (YAML or JSON), use load_yaml, which parses without executing any code.

The .pk8s file format is Perl code with a DSL for defining Kubernetes resources:

# myapp.pk8s
ConfigMap {
    name => 'my-config',
    namespace => 'default',
    data => { key => 'value' }
};

Deployment {
    name => 'my-app',
    namespace => 'default',
    spec => {
        replicas => 3,
        selector => { matchLabels => { app => 'my-app' } },
        template => {
            metadata => { labels => { app => 'my-app' } },
            spec => {
                containers => [{
                    name => 'app',
                    image => 'my-app:latest',
                }],
            },
        },
    }
};

Inside {} blocks, name, namespace, labels, and annotations are automatically moved to metadata.

Values can be handed to a manifest with the vars option. Inside the file, var($name) returns the value passed under that name and var($name, $default) falls back to $default when none was passed; a name with neither dies, naming the file and the name. A value passed as undef counts as passed. References pass through as they are. var is lower case, so it never collides with a Kind function:

# myapp.pk8s
Deployment {
    name      => var('name'),
    namespace => var('namespace', 'default'),
    spec      => {
        replicas => var('replicas', 1),
        ...
    },
};

my $resources = $k8s->load('myapp.pk8s', vars => { name => 'web', replicas => 3 });

vars must be a hash reference, and it is the only option -- any other key dies rather than leaving every var on its default. Without vars, var($name, $default) still works and var($name) dies.

The file is read as UTF-8, so a non-ASCII string literal in it gives the same characters as the same value in a YAML file read by "load_yaml"; a manifest that says use utf8; itself keeps working. The file is evaluated in a package of its own that is removed again once load returns, also when loading fails, so reloading manifests in a long-running process does not grow it. Subs and closures the manifest hands out in its data keep working after that.

Errors name the manifest's file and its own line numbers: a die, a warning, a syntax error, a var without value and an error raised for a Kind call -- a field of the wrong shape, a missing required field or a value of the wrong type -- all report at myapp.pk8s line 12, and a fatal one comes prefixed with Error loading myapp.pk8s:. A file name with a double quote, a line break or characters outside printable ASCII cannot be written into Perl's #line directive; for such a file the line numbers are still right, the location reads (eval N) instead of the name, and the prefix still names the file. A manifest that cannot be opened croaks Cannot open myapp.pk8s: ... at the line that called load.

The manifest is compiled under use strict and use warnings and sees var and one function per Kind, but none of the loader's own variables: an undeclared $file or $m in it is a compile error, not a quiet reach into the loader, and @_ is empty at its top level.

With CRDs (requires openapi_spec):

my $k8s = IO::K8s->new(openapi_spec => $spec);
my $resources = $k8s->load('helmchart.pk8s');

load_yaml

my $resources = $k8s->load_yaml('manifest.yaml');
my $resources = $k8s->load_yaml($yaml_string);

Load a YAML manifest file (or YAML string) and return an ArrayRef of IO::K8s objects. Supports multi-document YAML (separated by ---).

An argument without a newline that names an existing file is read from that file, as UTF-8. Anything else is taken as YAML text, which is expected as decoded characters (not UTF-8 bytes) and is parsed as given. A one-line argument that is no existing file and parses as YAML to plain scalars only -- typically a mistyped path -- dies instead of returning an empty list, and so does a directory:

load_yaml: 'manifests/app.yaml' is neither an existing file nor YAML text

Empty or whitespace-only text still returns an empty ArrayRef, and a one-line YAML mapping such as {kind: Namespace, metadata: {name: x}} is still YAML text.

This method validates declared fields against the Kubernetes types. A declared field with the wrong type throws an error. By default, an undeclared field is kept for forward-compatible round-tripping; construct IO::K8s with strict => 1 to reject it instead. This is useful for validating manifests before applying them to a cluster.

# Validate a manifest file and reject undeclared fields
my $strict_k8s = IO::K8s->new(strict => 1);
eval {
    my $objs = $strict_k8s->load_yaml('deployment.yaml');
    say "Valid! Contains " . scalar(@$objs) . " resources";
};
if ($@) {
    say "Invalid manifest: $@";
}

Options:

collect_errors => 1

Collect all validation errors instead of stopping at the first one. Returns a list of (objects, errors) where objects contains successfully parsed resources and errors is an ArrayRef of error messages.

my ($objs, $errors) = $k8s->load_yaml($yaml, collect_errors => 1);
if (@$errors) {
    say "Found " . scalar(@$errors) . " errors:";
    say "  - $_" for @$errors;
}

new_object

my $pod = $k8s->new_object('Pod', %args);
my $pod = $k8s->new_object('Pod', \%args);
my $np  = $k8s->new_object('NetworkPolicy', \%args, 'cilium.io/v2');
my $np  = $k8s->new_object('cilium.io/v2/NetworkPolicy', \%args);

Create a new Kubernetes object of the given type. The type can be a short name (like Pod), a domain-qualified name (like cilium.io/v2/NetworkPolicy), or a full class path (like My::StaticWebSite).

A bare one-word name is always read as a Kubernetes Kind, never as a package name, so it resolves through the resource map, class_namespaces, IO::K8s::<Kind> and auto-generation -- a same-named top-level distribution that happens to be installed is not consulted. To name a single-segment class of your own, prefix it: $k8s->new_object('+Widget', \%args).

An optional third argument specifies the api_version to disambiguate when multiple providers register the same kind name.

An apiVersion key inside the params hash is honoured symmetrically with "inflate": it is treated as the exact GVK the caller wants, and the short name resolves against it instead of whichever version the class defaults to. When a positional api_version is also given and the two disagree -- including one being defined and the other undef -- this croaks rather than picking one:

new_object: conflicting apiVersion for kind 'NetworkPolicy' --
params hash says 'cilium.io/v2', positional argument says 'networking.k8s.io/v1'

If the name is domain-qualified (like cilium.io/v2/NetworkPolicy) or an explicit api_version argument is given, it is a GVK (Group/Version/Kind) request. When such a request cannot be resolved to a class, the call dies rather than silently falling back to a different version or a similarly-named class:

Cannot resolve Kubernetes GVK: kind 'UnknownKind', apiVersion 'nonexistent.io/v1'

A bare unqualified name is not a GVK request and is exempt from this check -- as described above, it falls back to IO::K8s::<Kind>, and if that class doesn't exist either, the failure is Perl's own module-loading error (Can't locate ... in @INC), not the GVK error.

This same fail-closed behaviour applies uniformly across new_object, inflate, json_to_object and struct_to_object.

Independently of GVK resolution, a defined value at an object-bearing position -- a nested field such as spec or metadata anywhere inside the params, or (via "struct_to_object" and "json_to_object") the top-level value itself -- must be a hashref, or an already-inflated object of the right class; anything else (an arrayref, a plain string, a code or scalar reference) dies naming the target class, the shape actually received, and, for a nested field, the field itself:

Cannot inflate IO::K8s::Api::Core::V1::Pod: expected a hash (a JSON object), got a reference of type ARRAY
Cannot inflate IO::K8s::Api::Core::V1::PodSpec: expected a hash (a JSON object), got a plain scalar while inflating IO::K8s::Api::Core::V1::Pod field spec

A blessed value of another class is read through its TO_JSON, which has to return a hashref; a blessed value without TO_JSON -- such as the JSON boolean "metadata": true decodes to -- or with a TO_JSON that returns anything else dies the same way, naming the value's class:

Cannot inflate IO::K8s::Apimachinery::Pkg::Apis::Meta::V1::ObjectMeta: expected a hash (a JSON object), got an object of class JSON::PP::Boolean while inflating IO::K8s::Api::Core::V1::Pod field metadata

An IO::K8s object of a different class at an object position is therefore converted, not refused: a core IO::K8s::Apimachinery::Pkg::Apis::Meta::V1::LabelSelector handed to a CRD field typed with a generated selector class of the same shape keeps working. Fields the target class does not declare are kept (and die under strict) exactly as if its TO_JSON hash had been passed.

An array field -- of objects, such as containers, or of scalars, such as args -- takes an arrayref, and a hash field -- of objects, or a map such as labels or limits -- takes a hashref. Any other defined value dies naming the class being inflated, the field, the expected container and, for objects, the element class:

Cannot inflate IO::K8s::Api::Core::V1::PodSpec field containers: expected an array (a JSON array) of IO::K8s::Api::Core::V1::Container, got a plain scalar

Only the container is checked; the contents of a free-form map such as labels or annotations stay as unconstrained as before.

A list payload (kind: PodList and the like, inflated into IO::K8s::List) follows the same rules: the list itself has to be a hash, items an array -- named as IO::K8s::List field items -- and each item a hash, named by its index:

Cannot inflate IO::K8s::Api::Core::V1::Pod: expected a hash (a JSON object), got a plain scalar while inflating IO::K8s::List field items at element 1

All of this applies uniformly across new_object, inflate, json_to_object, struct_to_object, "FROM_HASH" in IO::K8s::Role::Resource and the nested coercion of a class's constructor, on every class, and independently of strict -- strict only governs a constructor key no attribute claims, not the shape of a value that is present. undef and an omitted field are unaffected and remain allowed.

A JSON null (undef) for a field counts as the field being omitted -- except for a field declared nullable (see "Field options" in IO::K8s::Resource), where it is kept: the attribute exists with undef, its has_<accessor> is true, and TO_JSON writes the null back. That too holds on every entry point listed above and at any depth.

An error the constructor of a class raises while it is built -- Moo's Missing required arguments, or a Type::Tiny exception for a value of the wrong type -- names the line that called new_object, inflate, struct_to_object, json_to_object or "load_yaml", and in a .pk8s manifest the line of the Kind call (see "load"), not a line inside IO::K8s. That holds at any depth, for every Kind that resolves to a class -- shipped, generated or your own:

Missing required arguments: selector, template at deploy.pl line 12.

The message is otherwise the constructor's own, and a Type::Tiny exception stays the same Error::TypeTiny::Assertion object, its context (package, file and line) moved to that caller. An error a class raises from its own code, such as a BUILD that dies, keeps its own location, and a direct $class->new(...) is not an entry point of IO::K8s and reports as it always has.

The same goes for the routes that inflate through IO::K8s on your behalf: "FROM_HASH" in IO::K8s::Role::Resource and from_json, which the unknown_kinds => 'unstructured' fallback of new_object and inflate goes through as well; a hash that spec_set or spec_merge of IO::K8s::Role::SpecBuilder writes into a typed field; and a schema arm the JSONSchemaPropsOr* union classes inflate. A shape error, a missing required field or a value of the wrong type met on those routes names the line that called them.

inflate

my $obj = $k8s->inflate($json_string);
my $obj = $k8s->inflate(\%hashref);

Inflate a JSON string or hashref into a typed IO::K8s object. The class is auto-detected from the kind field in the data. When external resource maps have been added via add(), the apiVersion field is used to disambiguate colliding kind names.

If kind/apiVersion amount to a GVK request that cannot be resolved, this dies with the same fail-closed error as "new_object" -- see there for the exact message and the bare-Kind exemption.

Independently of that, a defined non-hash value at an object-bearing position anywhere in the data also fails closed -- see "new_object" for the exact message.

The data itself has to be a hash: a hashref, or JSON text that decodes to an object. JSON text for anything else -- [], a string, null -- and any other Perl value -- an arrayref, undef, an already-inflated object -- dies naming what it received:

Cannot inflate: expected a hash (a JSON object), got a reference of type ARRAY

A hash without a kind croaks, naming the line that called inflate:

Cannot inflate: missing 'kind' field in data at deploy.pl line 12.

json_to_object

my $obj = $k8s->json_to_object($json_with_kind);
my $obj = $k8s->json_to_object('Pod', $json_string);

Convert JSON to an IO::K8s object. With one argument, auto-detects the class from kind exactly as "inflate" does, and like there the JSON has to decode to an object. With two arguments, uses the specified class.

The specified class name goes through "expand_class" exactly as it would for "new_object": a bare one-word name is always read as a Kubernetes Kind and looked up in the resource map, never as a package name -- Gizmo resolves as the resource map's Gizmo entry, not as a literal package called Gizmo. A class name that has already been resolved -- for example, the return value of a previous "expand_class" call -- is not exempt from this and must be prefixed with + to be used verbatim instead of being looked up again:

my $class = $k8s->expand_class('Gizmo');    # already a full class name
$k8s->json_to_object('+'.$class, $json);     # '+' skips re-resolution

When the class argument is a GVK request (domain-qualified, or paired with an api_version) that cannot be resolved, this dies with the same fail-closed error as "new_object" -- see there for the exact message and the bare-Kind exemption.

Independently of that, a defined non-hash value at an object-bearing position anywhere in the data also fails closed -- see "new_object" for the exact message.

struct_to_object

my $obj = $k8s->struct_to_object(\%hashref_with_kind);
my $obj = $k8s->struct_to_object('Pod', \%hashref);

Convert a Perl hashref to an IO::K8s object. With one argument, auto-detects the class from kind. With two arguments, uses the specified class.

The specified class name goes through "expand_class" exactly as it would for "new_object": a bare one-word name is always read as a Kubernetes Kind and looked up in the resource map, never as a package name -- Gizmo resolves as the resource map's Gizmo entry, not as a literal package called Gizmo. A class name that has already been resolved -- for example, the return value of a previous "expand_class" call -- is not exempt from this and must be prefixed with + to be used verbatim instead of being looked up again:

my $class = $k8s->expand_class('Gizmo');      # already a full class name
$k8s->struct_to_object('+'.$class, $hashref);  # '+' skips re-resolution

When the class argument is a GVK request (domain-qualified, or paired with an api_version) that cannot be resolved, this dies with the same fail-closed error as "new_object" -- see there for the exact message and the bare-Kind exemption.

Independently of that, a defined non-hash value at an object-bearing position anywhere in the data also fails closed -- see "new_object" for the exact message.

If the target class provides a FROM_STRUCT class method, it is called as $class->FROM_STRUCT($struct, $k8s) and its return value is used as-is, bypassing the generic field-by-field inflation. This is the hook for union types that serialize as a bare alternative rather than as a hashref of attributes -- see IO::K8s::ApiextensionsApiserver::Pkg::Apis::Apiextensions::V1::JSONSchemaPropsOrBool and its siblings, where additionalProperties: false has to stay a boolean instead of collapsing into an empty object. A class implementing FROM_STRUCT is responsible for its own TO_JSON as well, so that the two directions stay symmetric.

object_to_json

my $json = $k8s->object_to_json($obj);

Serialize an IO::K8s object to JSON.

object_to_struct

my $hashref = $k8s->object_to_struct($obj);

Convert an IO::K8s object to a plain Perl hashref.

expand_class

my $class = $k8s->expand_class('Pod');
my $class = $k8s->expand_class('cilium.io/v2/NetworkPolicy');
my $class = $k8s->expand_class('NetworkPolicy', 'cilium.io/v2');

Resolve a name to a Perl class. The name can be a short Kind, a domain-qualified api_version/Kind, or a full class path (prefix with + for a verbatim class name, or write the IO::K8s:: prefix in full). Returns the class name as a string -- this method does not load the class.

An explicit api_version makes the lookup an exact GVK request: when no class can confirm the requested version, returns undef. The qualified api_version/Kind form is checked first against the resource map, then a short-name key whose mapped class itself reports the requested api_version, then the openapi_spec for an auto-generated class. Anything else fails closed rather than substituting a different version.

A bare unqualified name is not a GVK request: it falls through to IO::K8s::<Kind> and then to auto-generation, and a name that resolves to nothing fails with the usual module-loading exception, not the GVK error.

load_class

$k8s->load_class('IO::K8s::Api::Core::V1::Pod');

Load (require) a class by name. Used internally after "expand_class" to make sure the class is in %INC before the caller hands it to $class->new. Dies with the usual Can't locate ... in @INC message when the class is not installable.

Successful loads are remembered process-wide, so the second and every later call for the same name costs a hash lookup instead of a require. Failures are not remembered: a name that did not load is attempted again on the next call, which is what keeps a package that only becomes available later -- one defined at runtime and registered in %INC, or a module installed mid-process -- reachable.

CILIUM CRD SUPPORT

IO::K8s includes IO::K8s::Cilium with 31 resource-map entries: 22 short-name Kinds (17 cilium.io/v2 + 5 cilium.io/v2alpha1) and 9 domain-qualified back-compat tracks for v2alpha1 BGP/CIDR/LoadBalancerIPPool, CiliumBGPPeeringPolicy, and CiliumExternalWorkload. The compatibility tracks remain reachable for older clusters without displacing the current short-name Kind. These are not loaded by default -- opt in at construction:

my $k8s = IO::K8s->new(with => ['IO::K8s::Cilium']);

my $cnp = $k8s->new_object('CiliumNetworkPolicy',
    metadata => { name => 'allow-dns', namespace => 'kube-system' },
    spec => { endpointSelector => { matchLabels => { app => 'dns' } } },
);

print $cnp->to_yaml;

All Cilium kinds are Cilium-prefixed, so there are no collisions with core Kubernetes kind names.

EXTERNAL RESOURCE MAPS

IO::K8s supports merging resource maps from external packages (like IO::K8s::Cilium for Cilium CRDs). This allows multiple packages to provide typed Kubernetes objects that work together.

Writing a resource map provider

Create a class that consumes IO::K8s::Role::ResourceMap:

package My::CRD::Provider;
use Moo;
with 'IO::K8s::Role::ResourceMap';

sub resource_map {
    return {
        MyCustomKind => '+My::CRD::V1::MyCustomKind',
    };
}

See IO::K8s::Cilium for a real-world provider with 22 current short-name Kinds and nine domain-qualified compatibility tracks.

Collision handling

When two providers register the same kind name, the first-registered entry keeps the short name. Both entries are always reachable via domain-qualified names (api_version/Kind):

my $k8s = IO::K8s->new(with => ['My::Firewall::Provider']);

# Short name -> core (first-registered)
$k8s->expand_class('NetworkPolicy');
# -> IO::K8s::Api::Networking::V1::NetworkPolicy

# Domain-qualified -> specific version
$k8s->expand_class('firewall.example.com/v1/NetworkPolicy');
# -> My::Firewall::V1::NetworkPolicy

# api_version parameter for disambiguation
$k8s->expand_class('NetworkPolicy', 'firewall.example.com/v1');
# -> My::Firewall::V1::NetworkPolicy

Disambiguation in pk8s DSL

In .pk8s manifest files, pass the api_version as a second argument:

# Core NetworkPolicy (default)
NetworkPolicy { name => 'deny-all', spec => { ... } };

# Firewall NetworkPolicy (disambiguated, no comma - like grep/map syntax)
NetworkPolicy { name => 'deny-all', spec => { ... } } 'firewall.example.com/v1';

UPGRADING FROM PREVIOUS VERSIONS

WARNING: Version 1.00 contains breaking changes!

This version has been completely rewritten. Key changes that may affect your code:

  • Moose to Moo migration

    All classes now use Moo instead of Moose. This means faster startup and lighter dependencies, but Moose-specific features (meta introspection, etc.) are no longer available.

  • List classes removed

    Individual *List classes (e.g., IO::K8s::Api::Core::V1::PodList) have been replaced with the unified IO::K8s::List class. The old class names emitted deprecation warnings for a while; as of this release they have been dropped from this distribution entirely. If you need the old name to fail loudly instead of silently resolving to a stale prior release, install IO::K8s::Deprecated, which ships CPAN redirect stubs for all 76 of them.

  • Updated to Kubernetes v1.37 API

    API objects have been updated from v1.14 to v1.37. Some fields may have changed, been added, or removed according to upstream Kubernetes API changes.

  • New Role for namespaced resources

    Resources that are namespaced now consume IO::K8s::Role::Namespaced. Use $class->does('IO::K8s::Role::Namespaced') to check if a resource is namespace-scoped.

SEE ALSO

Kubernetes::REST - REST client for the Kubernetes API, uses IO::K8s for typed request/response objects

IO::K8s::Deprecated - CPAN redirect stubs for IO::K8s module names that were renamed or removed

Bundled CRD providers: IO::K8s::Cilium, IO::K8s::Traefik, IO::K8s::CertManager, IO::K8s::K3s, IO::K8s::GatewayAPI, IO::K8s::AgentSandbox, IO::K8s::PrometheusOperator, IO::K8s::VolumeSnapshot, and IO::K8s::ExternalSecrets

Kubernetes::REST::Example - Comprehensive examples for using Kubernetes::REST with IO::K8s against a real cluster (Minikube, K3s, etc.)

https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.37/

BUGS and SOURCE

The source code is located here: https://github.com/pplu/io-k8s-p5

Please report bugs to: https://github.com/pplu/io-k8s-p5/issues

COPYRIGHT and LICENSE

Copyright (c) 2018 by Jose Luis Martinez Copyright (c) 2026 by Torsten Raudssus

This code is distributed under the Apache 2 License. The full text of the license can be found in the LICENSE file included with this module.

AUTHORS

  • Torsten Raudssus <torsten@raudssus.de> (current maintainer)

  • Jose Luis Martinez <jlmartin@cpan.org> (original author)

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/pplu/io-k8s-p5/issues.

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) 2018-2026 by Jose Luis Martinez Torres <jlmartin@cpan.org>.

This is free software, licensed under:

The Apache License, Version 2.0, January 2004