NAME

IO::K8s::AutoGen - Dynamically generate IO::K8s classes from OpenAPI schema

VERSION

version 1.110

SYNOPSIS

use IO::K8s::AutoGen;

# Generate a class from OpenAPI schema
my $class = IO::K8s::AutoGen::get_or_generate(
    'helm.cattle.io.v1.HelmChart',
    $schema_definition,
    $all_definitions,
    'IO::K8s::_AUTOGEN_abc123',  # namespace
);

# The class is now available and works like any IO::K8s class
my $obj = $class->new(metadata => $meta, spec => $spec);
my $json = $obj->TO_JSON;

DESCRIPTION

This module dynamically generates Moo classes for Kubernetes custom resources that don't have pre-generated IO::K8s classes.

Generated classes use IO::K8s::Resource as their base, so they have:

  • The k8s DSL for attribute definitions

  • TO_JSON / to_json serialization

  • _k8s_attr_info for inflate support

  • All standard IO::K8s behavior

Generated classes are placed in a unique namespace per IO::K8s instance to avoid collisions:

IO::K8s::_AUTOGEN_abc123::helm::cattle::io::v1::HelmChart

Each OpenAPI property also carries its field options (D3 of the CRD design) into the generated class, through the same k8s option hash a hand-written class would use (see "k8s" in IO::K8s::Resource): the schema's required list becomes the field's required => 'schema' option, so a generated class records which fields the schema demands -- available to to_crd and to "_k8s_attr_info" in IO::K8s::Resource -- without enforcing them at construction. An OpenAPI-required field can still be absent from a real cluster document (a server-side default, a status object not yet populated), and rejecting it at inflate would reject valid data; use required => 1 for a field that must always be enforced (see "k8s" in IO::K8s::Resource). enum, minimum, maximum, pattern, default, description, nullable and x-kubernetes-preserve-unknown-fields are carried the same way, so a generated class enforces enum, range and pattern values exactly like a hand-written one that declares the same options. For an array property, enum/minimum/maximum/pattern are read off items and lifted onto the field's own options, since it is each element that is constrained, not the array itself. nullable and x-kubernetes-preserve-unknown-fields are read as JSON booleans, not Perl truthiness, so the wire string "false" is false; a default of JSON null -- common on a nullable: true field -- is treated as no default at all, not as a default of undef, which the DSL's own field-option check would otherwise refuse. A nullable: true property behaves as nullable does on a hand-written field: an explicit null in a document is kept and written back, and the property gets has_<accessor> and clear_<accessor>. A property whose predicate or clearer name the class already answers to fails the generation run, like any other declaration collision (see "k8s" in IO::K8s::Resource).

A malformed schema option is dropped rather than failing the whole class: the client-side check is a convenience, the API server validates every value regardless, and dropping it loses no data. This covers an enum that is empty, has duplicate entries, or contains a JSON null; a pattern that does not compile as a Perl regex; a minimum/maximum where either bound is not a number or minimum exceeds maximum; and a default the field cannot hold -- the wrong type, or a value outside its own enum or range.

OpenAPI type: number becomes Num, for a scalar property and for an array's items alike, so those values stay JSON numbers on the wire rather than turning into strings; type: integer likewise becomes Int and [Int]. x-kubernetes-int-or-string: true (or type: string with format: int-or-string) becomes IntOrStr and, on items, [IntOrStr], so an element 8080 stays a JSON number and '25%' a string; type: string with format: date-time becomes Time and [Time]. A map whose additionalProperties is one of these scalar schemas becomes a typed map: type: string gives HashRef[Str], and integer, number, boolean, int-or-string and date-time give HashRef[Int], HashRef[Num], HashRef[Bool], HashRef[IntOrStr] and HashRef[Time].

A $ref to one of the apimachinery scalar definitions -- resource.Quantity, intstr.IntOrString, meta.v1.Time and meta.v1.MicroTime -- is typed as that scalar wherever it sits: a property becomes Quantity, IntOrStr or Time, an array's items [Quantity], [IntOrStr] or [Time], and a map's additionalProperties HashRef[Quantity], HashRef[IntOrStr] or HashRef[Time], whether or not the spec carries the definition. Before, the items and additionalProperties forms were generated from the definition, a bare type: string, as an empty class that no quantity or timestamp could inflate into.

The Time and Quantity of a generated class accept any string, as a scalar field and as an array element alike: a custom resource's date-time is stored as written, and RFC 3339 allows a lowercase t and z the strict check of a hand-written Time field refuses, so a generated class never fails to inflate what the API server returned. The API server validates the format regardless. A hand-written or emitted class keeps the strict checks, and so does the typed map above -- the DSL has only the one HashRef[Quantity] and HashRef[Time] -- which only a swagger $ref reaches, whose values the API server writes in canonical form.

An inline type: object schema with its own non-empty properties also becomes a typed class now (D10), named after its place in the parent -- <Parent>::<Prop>, with an Item / Value suffix for array items and map values shaped the same way -- so its properties get field options exactly like a class built from a $ref. A property-less type: object (or one with x-kubernetes-preserve-unknown-fields) becomes Opaque, and an additionalProperties-only map (no properties of their own) a HashRef[X], with nothing underneath to attach options to. Scalar properties carry their options at every level regardless.

A path-derived nested class name that would run past Perl's 251-character limit on a fully qualified identifier (cert-manager's CRDs inline a full PodTemplateSpec several levels into a Challenge's spec, and the namespace prefix an AutoGen instance generates under adds still more) is shortened instead of failing class generation: <root>::_<10 hex chars>, the root being the top-level generated class (the Kind) the nesting started from and the hex digits a Digest::SHA::sha1_hex of the full, unshortened logical name. The logical path is not lost -- "class_path($class)" and "class_root($class)" recover it -- and a name collision between two different schema keys is still detected against that full logical name, never against the (much smaller) space of possibly-shortened names.

A nested object -- or an array's items, or a map's additionalProperties -- whose property set exactly matches a shipped core or apimachinery class's own key set is typed as that class instead of a new nested one (D5, reuse_core, default on): a CRD's inline LabelSelector or HTTPHeader-shaped struct becomes the real IO::K8s class rather than a per-provider copy. get_or_generate's reuse_core => 0 option turns this off and restores the pre-D5 behavior (every inline object becomes its own nested class, per D10 above).

A name match alone is not enough to reuse a class -- it only decides which classes "core_class_for_shape(\@json_keys)" lists as candidates. The reuse decision applies three further checks: a shape under two keys is never reused (a single shared key name -- {value}, {name}, ... -- is common enough by accident that this alone rules out most of it); every remaining candidate must be type-compatible with the schema, key by key (a schema string field can't reuse a class that declares the same-named field as an array, for instance -- see "core_class_for_shape(\@json_keys)" below for the full compatibility table); and where several candidates survive that filter, they are reused as the preferred one (apimachinery's LabelSelector family first, then the rest of Meta::V1, then Core::V1, then alphabetically) only when they are wire-identical -- the same type per key (ignoring required-ness: a field being optional on one shipped class and mandatory on another doesn't change what value it holds) and the same referenced class per key, where a key has one. {name,value} matches three unrelated Core::V1 leaf structs -- HTTPHeader, PodDNSConfigOption, Sysctl -- wire-identical despite one of the three having two optional fields where the other two have two required ones, so it reuses the preferred one, HTTPHeader. {key,operator,values} matches LabelSelectorRequirement and FieldSelectorRequirement (Meta::V1) as well as NodeSelectorRequirement (Core::V1) -- also wire-identical despite spanning two API areas -- so it reuses LabelSelectorRequirement. A shape shared by candidates that are NOT wire-identical -- an optional field naming a different referenced class, e.g. {metadata,spec} matching PodTemplateSpec, JobTemplateSpec, ResourceClaimTemplateSpec and others, each with spec typed differently -- stays a nested class rather than guess which one is meant.

Surviving all of that is still not enough: the chosen class must also hold what the schema describes all the way down, not merely match it key by key at the top level -- an array field's items, a map's additionalProperties values, and any nested object field are checked the same way, recursively. A $ref met along the way is resolved read-only against the same definitions the schema's own $refs resolve against; one that does not resolve counts as not held, never as a pass. An Opaque field on the candidate matches only a schema fragment that would itself become an Opaque map -- no properties, no scalar or structured additionalProperties -- with one exception: a bare {type: object} field matches a candidate field typed as ObjectMeta, since that is how controller-gen renders an embedded metav1.ObjectMeta below a CRD's root. A nested schema fragment that never states its own required list makes no requiredness claim at that level either, the same rule the top-level check above follows. This is a final gate on the pick above, not a sharper filter ahead of it: it can only withdraw a reuse the checks above already chose, never manufacture a new one -- LabelSelectorRequirement's {key,operator,values} shape stops being reused the moment a schema's own values turns out to hold an array of objects rather than scalars; that schema gets its own nested class instead.

A $ref to one of the apiextensions union types -- io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.JSON, ...v1.JSONSchemaPropsOrArray, ...v1.JSONSchemaPropsOrBool and ...v1.JSONSchemaPropsOrStringArray -- is typed as the class IO::K8s ships for it (Apiextensions::V1::JSON and so on), as a property, as an array's items or as a map's values. Upstream describes these definitions without any properties, because none of them is a JSON object (any JSON value; a schema or an array of schemas; a schema or a boolean; a schema or a string array); the shipped classes inflate through FROM_STRUCT and write the value back exactly as it came, where a class generated from the empty definition could not hold default: "foo" at all. This holds whatever reuse_core says, and whether or not the spec carries the definitions -- the names are resolved like the apimachinery IntOrString, Quantity and Time. IO::K8s ships no v1beta1 apiextensions classes: the same four names under v1beta1 are carried opaquely by the v1 JSON class, which keeps any value unchanged but does not type a schema inside it.

NAME

IO::K8s::AutoGen - Dynamically generate IO::K8s classes from OpenAPI schema

FUNCTIONS

get_or_generate($def_name, $schema, $all_defs, $namespace)

Generate (or return cached) class for the given OpenAPI definition.

Extra positional options after $namespace pin the identity of a top-level object (api_version => ..., kind => ..., resource_plural => ..., is_namespaced => ...); the generated class then also composes IO::K8s::Role::APIObject. reuse_core => 0|1 (default 1) controls D5's core-class reuse; see above.

When api_version (or kind / resource_plural) is supplied, the generated class installs fixed-value methods for each. These are fixed identity, not writable fields: passing an argument croaks rather than silently retargeting the object -- the same contract the hand-written CRD template installs via IO::K8s::APIObject.

subresources => { ... }, which "generate" in IO::K8s::CRD passes for a CRD version that has any, installs the subresources identity method the use IO::K8s::APIObject parameter of that name installs, on a top-level class, so its to_crd writes them back. It is checked the same way and croaks naming the class and the key; like a failed GVK selection, that happens before the class is marked as begun, so it builds and poisons nothing.

This function fails closed on input it cannot generate a faithful class from, rather than dropping fields or inventing a wrong type. It croaks when:

  • a property, an array's items, or an additionalProperties schema carries a $ref to a definition not present in $all_defs -- other than the apimachinery scalars and the apiextensions union types, which are resolved by name (see "DESCRIPTION"). A partial spec that references definitions it does not ship used to generate the class anyway, minus those fields -- losing their data on every round-trip. It now dies naming the $ref and where it appeared.

  • additionalProperties is a reference that is neither a schema object nor a JSON boolean; the message names the class and field.

  • the schema's x-kubernetes-group-version-kind metadata is ambiguous for the requested api_version, or names no entry matching it -- the GVK selection fails closed rather than pick a version.

  • a top-level class -- one with an api_version and a kind -- has a property whose accessor would take the name of a method of IO::K8s::Role::APIObject (label, save, is_ready, ..., and the spec_* methods of IO::K8s::Role::SpecBuilder, which it composes) or of the identity methods api_version and resource_plural. The identity methods and the role are in place before the first property is declared, the order use IO::K8s::APIObject gives a hand-written class, so the declaration check of "k8s" in IO::K8s::Resource refuses the property with k8s: field 'label' of <class> collides with the method 'label' of <class>, which is not an attribute. Before, such a property silently replaced the role method on the generated class. The exceptions are that check's own: a conditions property takes over the role's condition helper, and apiVersion, kind and metadata are not declared from the schema at all, since the class already supplies them. A nested class composes no role, so these names are ordinary fields there. Nothing is renamed.

A failure anywhere in this process is a failure of the whole generation run, not just of the one class being built: the root class, every $ref'd definition generated on the way, and every nested class -- the "run" -- succeed or fail together. The one exception is the GVK-selection failure above (the third bullet): it happens before the class is marked as begun, so it builds, and poisons, nothing -- a retry with a matching api_version resolves to the same class name and works normally. Any other failure during the run -- an unresolved $ref surfacing from a nested class several levels down, a bad additionalProperties, or anything else the run's own code raises -- marks every class the run began, in this call's $namespace, as permanently failed with that run's original error, even a dependency whose own fields had already finished cleanly (it may well point back at the class that died). Nothing already built is rolled back -- the packages and their Moo internals stay exactly as the run left them -- but a later request for any of those classes, whether asked for directly or reached again as another class's dependency, re-raises the original error behind a prefix explaining that the class failed earlier and stays failed, rather than handing back a half-typed package or silently rebuilding it. This failure record survives "clear_cache()"; the only way to retry is to load a repaired schema into a fresh IO::K8s instance, which gets its own AutoGen namespace and so a class name the earlier failure never touched. add_crd in IO::K8s::CRD, which calls this function, inherits the same contract: a CRD that fails to generate stays failed for that IO::K8s instance.

One partial-spec shape still generates successfully by design: a top-level CRD schema whose metadata $refs the standard ObjectMeta without shipping its definition. metadata is supplied by the role and is skipped before its $ref is looked at, so this common single-schema hand-in does not trip the unresolved-$ref refusal. A side effect of that skip: when $all_defs does carry ObjectMeta and nothing else references it, it no longer appears in "generated_classes()".

def_to_class($def_name, $namespace)

Convert OpenAPI definition name to Perl class name.

class_to_def($class)

Convert Perl class name back to OpenAPI definition name.

is_autogen($class)

Returns true if the class was auto-generated.

clear_cache()

Clear the generated class cache. Classes generated before the call keep working -- their packages already exist and nothing here touches them -- but regenerating the same names into the same namespace afterward is unsupported: Moo cannot rebuild an existing package, and "class_path($class)" / "class_root($class)" forget what they knew about the classes this cleared.

A class that failed to generate (see "get_or_generate($def_name, $schema, $all_defs, $namespace)") is not reset by this call -- its failure record is kept on purpose, so a later request for it still re-raises the run's original error instead of handing back the half-built package the failed run left behind. The only way to retry that class is to generate it into a fresh IO::K8s instance's namespace.

generated_classes()

List the class names whose generation run completed successfully. A class begun by a run that later failed is never listed here, even though its package may still exist in memory and even though it can never be generated again in this namespace (see "get_or_generate($def_name, $schema, $all_defs, $namespace)").

class_description($class)

The schema description a generated class was built from, or undef when the schema carried none. Used by IO::K8s::CRD::Emitter to fill in a rendered class's # ABSTRACT line.

class_root($class)

The top-level generated class (the Kind class) $class's nesting started from, or $class itself when it already is a root -- including when $class is not something AutoGen generated at all. Never undef.

class_path($class)

The ::-joined field path $class sits at below its "class_root($class)" (Spec::Acme::SolversItem::...), recorded even when $class's own Perl name had to be shortened past Perl's identifier limit. undef for a root class or for a class AutoGen did not generate through nested-object handling.

core_class_for_shape(\@json_keys)

my @classes = IO::K8s::AutoGen::core_class_for_shape([qw(key operator values)]);

Every shipped core / apimachinery class (under IO::K8s::Api and IO::K8s::Apimachinery) whose own key set is exactly @json_keys, most preferred first (apimachinery's LabelSelector, then the rest of Meta::V1, then Core::V1, then alphabetically). Empty when no shipped class has that exact shape. A class's own metadata counts as part of its shape only when the class is an embedded type (PodTemplateSpec: {metadata,spec}, a real schema-visible field); a top-level Kind's metadata is supplied by IO::K8s::Role::APIObject outside the schema's own properties and is dropped, so e.g. Pod's indexed shape is {spec,status}. This is a name match only, by key set alone -- it says nothing about whether reusing any listed class is actually safe for a given schema; that is D5's reuse_core reuse decision (see above), which consults this same index but additionally requires a type-compatible candidate (a per-key check against the schema: string -- including format: date-time -- matches is_str/is_int_or_string/is_quantity/is_time; int-or-string -- x-kubernetes-int-or-string, or type: string with format: int-or-string -- matches is_int_or_string or is_quantity (the form controller-gen gives a resource.Quantity), never a Str field that would write 8080 back as "8080" nor a Time; integer matches is_int or is_int_or_string; number matches is_num; boolean matches is_bool; array matches any is_array_of_*; object, whether the schema property has properties of its own or is a map, matches is_object, is_inline_struct or any is_hash_of_*), and -- when several type-compatible candidates remain -- requires them to be wire-identical before picking the preferred one. Picking a class this way is still not the final word: it must also hold the schema all the way down -- array items, map values, nested object fields, checked recursively -- or nothing is reused after all (see above for the full rule, including the $ref, opaque-map and ObjectMeta special cases).

The index itself is precomputed and shipped as IO::K8s::AutoGen::CoreShapes, regenerated by maint/core-shape-index-gen.pl and checked against the shipped classes by t/85_core_shape_index.t; where that module is missing it is rebuilt on first call by loading every class under the two trees instead, which costs seconds rather than milliseconds but produces the same index. Either way, only the classes a looked-up shape actually names are loaded -- the returned names are always loadable, loaded classes, since the reuse decision reads their attribute registries.

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