NAME

Kubernetes::Comb - A self-contained micro collection of Kubernetes parts as a live Perl instance

VERSION

version 0.001

SYNOPSIS

package MyApp::Comb::NATS;
use Moo;
extends 'Kubernetes::Comb';

sub endpoints { { name => 'client', port => 4222 } }

sub manifests {
  my ( $self ) = @_;
  return (
    { apiVersion => 'apps/v1', kind => 'Deployment', metadata => { name => 'nats' }, spec => { ... } },
    { apiVersion => 'v1',      kind => 'Service',    metadata => { name => 'nats' }, spec => { ... } }
  );
}

package main;

my $comb = Kubernetes::Comb->from_crd($cr,
  k8s      => $k8s,                             # default: Kubernetes::Comb::Client::Sync
  resolver => sub { $combs{ $_[0] } },
  stub     => sub { $_[0]->name eq 'mailer' }
);

$comb->deploy->get;
my $status = $comb->status->get;      # { phase => 'Running', healthy => 1, pods => [...] }
print $comb->logs(lines => 50)->get;
my $ep = $comb->endpoint('client')->get;
print $ep->cluster;                   # nats.platform.svc:4222

DESCRIPTION

A Comb is one cell of a honeycomb: a named set of Kubernetes parts that deploys itself, reports its status and publishes its endpoints. Controlling code builds the instance -- usually with "from_crd" from a Comb custom resource -- and from then on only calls its methods. All Kubernetes work happens in here, through the "k8s" client.

A Comb class extends this one and overrides the contract: "name", "depends_on", "endpoints", "manifests", "check", "optional", "bridge_manifests", "stub_class". It may also define a plain upstream method (where its service is borrowed from); this class deliberately defines none, so whether a class has one is visible to can.

Every lifecycle method -- "reconcile", "deploy", "status", "healthy", "logs", "restart", "stop", "describe", "endpoint" -- returns a Future and never throws: any error, including one in a contract method, is a failed Future. "reconcile" goes further: its Future never fails at all.

WHAT A MANAGER MUST DO

Kubernetes::Comb ships no manager or daemon: whoever drives Combs does it from their own program, sync or async, with one step repeated:

1. Watch or list Comb custom resources.
2. Build an instance for each with "from_crd", passing k8s, resolver, upstream and stub as the layering and stub choices call for.
3. Order the instances by "depends_on" (topological sort; a cycle is not an exception -- report it, and reconcile its Combs anyway: each stays Blocked on the other, and says so).
4. Call "reconcile" on each, repeatedly (a timer, on a CR change, or both).
5. Show or act on "status"; call "restart", "stop", "logs" on request.

examples/sync.pl and examples/async.pl do exactly this for three Combs -- nats local, db replicated (Kubernetes::Comb::Upstream::Static or Kubernetes::Comb::Upstream::K8s), mailer as a stub -- and print every state transition; run either with --help.

k8s

The Kubernetes client, anything doing Kubernetes::Comb::Role::Client. Defaults to a Kubernetes::Comb::Client::Sync on the current kube context; pass a Kubernetes::Comb::Client::Async to run on an IO::Async loop.

resolver

Coderef that turns a dependency reference (name or namespace/name) into the Comb instance. The only way a Comb finds its dependencies; supplied by the controlling code. has_resolver tells whether there is one.

upstream

Constructor argument: what the controlling code says about the upstream, the first source of the upstream resolution. A coderef, called with the Comb and returning what a class upstream method returns; or that answer given directly: an object doing Kubernetes::Comb::Role::Upstream, a hashref as in the custom resource ({ class => ..., ... }), an arrayref of the Perl helper form ([ K8s => ( context => 'dev' ) ]), or undef for an explicit "local". There is no reader of this name.

crd

The Comb custom resource this instance was built from, if any. Name, namespace, config and dependencies default to what it says, and the Comb writes its status into it. has_crd tells whether there is one.

crd_class

The custom resource class. Defaults to the class of "crd", else Kubernetes::Comb::CRD::Comb. A CR class for another API group is a subclass of that, see Kubernetes::Comb::CRD::Comb. Construction dies when a given "crd" is not an instance of it.

namespace

Where the Comb lives: its namespaced resources that name none get this one. Defaults to the namespace of "crd"; without either, every operation that needs it fails.

config

Free-form configuration for the class. Defaults to a copy of spec.config of "crd", else an empty hashref.

label_prefix

Prefix of the label and annotation keys the Comb sets, default comb.internal/: the name label is <prefix>comb, the namespace label <prefix>comb-namespace, the restart annotation <prefix>restartedAt, the digest annotation <prefix>applied-digest. Include the trailing /.

managed_by

Value of the app.kubernetes.io/managed-by label on every resource the Comb deploys. Default kubernetes-comb.

max_upstream_depth

The most layers an upstream chain may have, default 16. An upstream whose via names more is taken for a loop -- Blocked, see "reconcile" -- and its via is recorded cut to this length, so a loop never makes it grow. Kube context names cannot tell a loop: layers may share one context (namespaces of one cluster), and kubeconfigs name contexts alike.

cluster_domain

The DNS domain of the cluster, default cluster.local. The default "bridge_manifests" appends it to a host ending in .svc before it becomes the externalName of a Service: the cluster DNS answers that with a CNAME, which a Pod's resolver does not expand with its search domains.

io_k8s

The IO::K8s instance that tells the Comb what a manifest hashref is: whether its Kind is namespaced, and its apiVersion when it has none. Defaults to one that knows the built-in Kinds and the Comb CR. Pass one with your CRD providers (IO::K8s->new(with => [...])) when manifests contain cluster-scoped custom resources; a Kind it does not know counts as namespaced.

stub_of

The original Comb this instance stands in for, when it was built as its stub (see "from_crd"). Construction checks the contract: a stub that lacks any endpoint name of its original dies, naming the missing ones. is_stub tells whether there is one.

A class named Foo::Stub that is a Foo -- the default "stub_class" of Foo -- is checked against Foo however it was selected: built without stub_of, e.g. because spec.class names it directly, it builds a Foo from the same arguments to check against. That Foo is not kept: stub_of stays unset.

name

The name of the Comb. Defaults to metadata.name of "crd"; a class used without a custom resource overrides it. It goes into a label value on every resource, so construction dies on a name that cannot be one: more than 63 characters, or other than letters, digits, -, _ and . with a letter or digit at both ends.

depends_on

List of the Combs this one needs, each name or namespace/name. Defaults to spec.dependsOn of "crd".

endpoints

List of what the Comb offers, each a hashref or a Kubernetes::Comb::Endpoint. A hashref has name -- a DNS-1123 label, see "name_problem" in Kubernetes::Comb::Endpoint -- and port, optionally protocol (default tcp), service (the Service it is reached through, default "name"), external and cluster (both host:port; cluster defaults to <service>.<namespace>.svc:<port>). Default: none.

manifests

List of the resources the Comb consists of, as IO::K8s objects or hashrefs -- or a Future of that list. Default: none.

check

List of the prerequisites that are missing, as human-readable strings -- or a Future of that list. Empty means the Comb can go ahead. Default: nothing missing.

optional

Whether the Comb stays off until it is switched on: with spec.enabled unset, "reconcile" leaves an optional Comb Disabled and runs any other one. Default false. Works as class and as instance method.

bridge_manifests

my @manifests = $comb->bridge_manifests(@endpoints)->get;

The resources that make the upstream reachable under the local names while an upstream is active: for each Service the Comb would have locally, the one that points at the upstream instead, so consumers keep using <service>:<port>. Called with the redirected endpoints (Kubernetes::Comb::Endpoint), whose cluster is the upstream address to point at; returns the manifests, or a Future of them. Override it when the service needs more. A Future that fails with category bridge says the upstream cannot be bridged -- "reconcile" reports that as Blocked and deploys nothing -- any other failure is an Error.

The default groups the endpoints by their Service (service of "endpoints", default "name"), each port named after its endpoint:

  • all host names: a Service of type ExternalName. It cannot map ports, so an upstream port that differs from the local one, or endpoints pointing at different hosts, cannot be bridged. A host ending in .svc gets "cluster_domain" appended.

  • all IP addresses of one family: a selector-less Service (ipFamilies that family) plus an EndpointSlice per address (discovery.k8s.io/v1, kubernetes.io/service-name label), which may point at another port.

  • a mix of names and addresses, or of IPv4 and IPv6: cannot be bridged.

stub_class

The class that stands in for this one when a stub is asked for. Defaults to <class>::Stub if that class exists or loads -- a stub that fails to compile dies --, else undef. Works as class and as instance method.

endpoint_class

The class "endpoint" builds, Kubernetes::Comb::Endpoint.

from_crd

my $comb = Kubernetes::Comb->from_crd($cr,
  k8s      => $k8s,
  resolver => sub { ... },
  upstream => sub { ... },
  stub     => sub { my ( $comb ) = @_; ... }
);

Builds the Comb for a Comb custom resource: loads spec.class and constructs it with crd => $cr plus the options, all of which but stub go to the constructor. stub is called with that instance; when it returns true, the instance's "stub_class" is built instead, with the original as "stub_of" -- which checks the stub keeps the original's endpoints. A spec.class naming a stub directly is checked as well, see "stub_of". Dies when spec.class is not a subclass of the invocant, a stub is asked for and there is none, or construction dies.

comb_label

Key of the label that carries the Comb name: <label_prefix>comb.

comb_namespace_label

Key of the label that carries the Comb namespace: <label_prefix>comb-namespace. Next to the name it tells the resources of same-named Combs in different namespaces apart -- the layers of one cluster -- where they share a namespace or are cluster-scoped.

comb_labels

Hashref of the labels every resource of the Comb gets, Pod templates of its workloads included: "comb_label" with the name, "comb_namespace_label" with the "namespace", and app.kubernetes.io/managed-by with "managed_by".

label_selector

Label selector for everything of this Comb: <comb_label>=<name>,<comb_namespace_label>=<namespace>.

restart_annotation

Pod template annotation "restart" sets: <label_prefix>restartedAt.

applied_digest_annotation

Annotation "deploy" puts on every resource, holding the digest of the manifest it was applied from: <label_prefix>applied-digest.

reconcile

my $status = $comb->reconcile->get;
print $status->phase;

One step towards what the Comb class describes. Future of the new recorded status, a Kubernetes::Comb::CRD::CombStatus -- also what "recorded_status" returns afterwards. The Future never fails: whatever goes wrong, a dying contract method or a broken client included, becomes phase Error with the reason in the Ready condition. Run one step at a time per Comb. In order:

1. Upstream

Resolved from the first source that exists, whose answer is final even when it is "local": the "upstream" constructor argument, spec.upstream of "crd" (an explicit null is "local"), a class method upstream; else local. The coderef and the class method return nothing (local), an object doing Kubernetes::Comb::Role::Upstream, Name => (%args) for Kubernetes::Comb::Upstream::Name->new(%args), or '+Full::Class' => (%args) for that class -- or a Future of that. The custom resource and a hashref say { class => 'Full::Class', %args }, the class always fully qualified. An answer that does not resolve is an Error.

2. Enabled

spec.enabled false, or unset while the class is "optional": Disabled. A disabled Comb touches no resource -- what runs keeps running.

3. Dependencies

Each of "depends_on" goes to the "resolver" as written (name or namespace/name), with the Comb as second argument; it returns the Comb instance or undef. One not found, one whose "healthy" is false or fails, a dying resolver, or none at all: Blocked, every problem in the message.

4. Check

"check" reports missing prerequisites: NeedsConfig.

5a. Local

Without an upstream. "status" healthy, every resource applied as it is rendered now, and managedResources naming just what the manifests render: Running, nothing applied.

Not healthy: "deploy", then prune: every namespaced resource this Comb recorded in managedResources that it no longer renders -- compared by group, kind, namespace and name, so a new API version is no orphan -- is deleted if it still carries "label_selector", name and namespace of this Comb, together with what it owns (propagationPolicy Background: the Pods of a Job, the ReplicaSets of a Deployment). One that does not -- it lost the labels, or a same-named Comb of another namespace applied it last -- is left alone and dropped from the record, one that is gone is dropped, one that fails to delete stays for the next step. A cluster-scoped one is never deleted: a Namespace or a CustomResourceDefinition takes far more with it, and a same-named Comb of another namespace may render it too. It is dropped from the record and left in place, which the Ready message says. Then Pending. A deploy that fails half-way is an Error that records what it applied on top of the previous record, and prunes nothing.

Healthy, but a resource was applied from another manifest than the one rendered now -- a new image in the class, a changed spec.config the manifests render from: deploy and prune all the same, then Pending, the Ready message naming the resources; the next step finds the Comb Running, or not. The live "applied_digest_annotation" tells, compared with the digest of the rendered manifest (see "deploy"). A resource that carries none -- applied by a version before there was one -- counts as changed, which deploys the Comb once. What is compared is what the Comb renders, never the live object: a change made to that by hand is not detected, and a rolling "restart" is no change. Two kinds of resources never deploy a Comb by their digest, since a deploy would not change them and so every step would deploy:

  • What the client does not replace once it exists (see "ensure" in Kubernetes::Comb::Role::Client): a v1 PersistentVolumeClaim, and a batch/v1 Job that runs or has succeeded. They keep the digest they were created with. A Job that failed makes the Comb unhealthy, so it is deployed, which replaces the Job by what is rendered now.

  • What a same-named Comb of another namespace applied last -- it carries that Comb's "comb_namespace_label", and that Comb's digest.

Healthy and applied as rendered, but the record names other resources than the manifests render -- one dropped from the manifests, a delete that failed, one that runs but was never recorded: deploy and prune all the same, and stay Running, the Ready message saying so. Everything the manifests render was live and healthy before.

A workload scaled below its manifest counts as not healthy (see "status"), so a reconcile after "stop", or after scaling a Deployment down by hand, deploys again, which scales the workloads back up. The first step after borrowing from an upstream deploys even when "status" looks healthy, since the bridge may stand where the local resources belong.

5b. Upstream

With an active upstream (see Kubernetes::Comb::Role::Upstream), the Comb runs nothing of its own and borrows the service instead:

  • The upstream's status. Not reachable: Blocked with its message. A via of more than "max_upstream_depth" layers is taken for a loop: Blocked (UpstreamLoop), the recorded via cut to that length.

  • The upstream's endpoints, matched by name to "endpoints": each declared endpoint gets the upstream's cluster address (else its external one) and external -- the redirected endpoints, which "endpoint" returns and status.endpoints publishes. One the upstream has no address for: Blocked.

  • "bridge_manifests" for the redirected endpoints. A bridge that cannot be: Blocked, nothing deployed.

  • replicate_into, when the upstream has it. A failure: Error.

  • The bridge is deployed and pruned as the manifests are in the local path, so the local workloads go and a local Service of a bridged name is changed in place. Then Running when the upstream is Running, else Pending with its phase.

A failing status or endpoints, an answer that is none, or a bridge that does not render: Error. Whatever the upstream's status said is recorded in status.upstream.

6. Record

Phase, conditions, the resolved endpoints -- the redirected ones while borrowing, once they are known --, status.upstream while an upstream is active, managedResources and observedGeneration go through update_status into "crd", which is replaced by what the API server returns; without a custom resource the status is kept in memory. A failed write is retried once on a freshly read custom resource; if that fails too the status is kept in memory and carries a StatusWritten condition that says why.

A step that changes nothing that serves carries managedResources, the endpoints and status.upstream of the previous status forward: one that stops before step 5 (Disabled, Blocked by its dependencies, NeedsConfig, an Error there), and a local step that fails before it is through. So a bridge stays recorded -- and replaced by the next local step -- until the local path has deployed in its place; status.upstream goes once it has. On the very first step there is nothing to carry, and a local Comb publishes its own endpoints. The upstream path records what its upstream says now, and endpoints only once the upstream offers an address for each.

Conditions: Ready (True when Running; its reason and message are the step's), DependenciesReady and ConfigReady (each Unknown with reason NotChecked when the step did not get that far), and StatusWritten -- present only when writing the status failed (see step 6), False with the reason. Their lastTransitionTime changes only when their status does.

deploy

my @stored = $comb->deploy->get;

Renders "manifests", labels every resource (and the Pod templates of workloads) with "comb_labels", puts "namespace" on namespaced resources that name none and "applied_digest_annotation" on every one, and creates or updates them one after the other. Future of the objects as stored. A failure stops at that resource; the Future fails with the message, category deploy and { applied => [...], failed => $manifest }. Manifests that do not render fail it with category manifests.

An update replaces the object with what the manifest says, so the Pod template of a live Deployment, StatefulSet or DaemonSet would lose the "restart_annotation" "restart" put there -- rolling its Pods once more, or undoing a restart under way. Deploy keeps it: the rendered Pod template gets the live value. It reads those workloads first, one list by "comb_label" per kind ("reconcile" passes on what it has read anyway); when that fails, so does the deploy -- category deploy, nothing applied.

The "applied_digest_annotation" holds sha256: and the SHA-256, in hex, of the manifest as it is applied -- what the class rendered, with the labels, the namespace and the apiVersion deploy adds -- but for that annotation itself and the restart annotation carried over. It is taken of the canonical JSON of the manifest with every number and string as a string, so it does not depend on how Perl holds a value: replicas: 2 and replicas: "2" digest alike. A manifest holding what is no data (a code reference, an object without TO_JSON) does not render. "reconcile" compares the digest; the bridge of a borrowing Comb carries one too, which nothing compares.

status

my $status = $comb->status->get;

Future of what the cluster shows of the Comb right now:

{
  phase   => 'Running',   # Running, Pending, Error, Stopped or NotDeployed
  healthy => 1,
  reason  => '...',       # when there is a problem: the first one's reason
  message => '...',       # every problem, joined with '; '
  pods    => [ { name, phase, ready, restarts, reason, message }, ... ]
}

It looks for every rendered resource (by "comb_label" alone: where a same-named Comb of another namespace renders the same resource, the one that applied it last owns it, and both find it) and at the Pods of the Comb (by "label_selector"). NotDeployed: none of the resources exists. Stopped: as "stop" leaves the Comb -- every Deployment and StatefulSet at 0 replicas, every CronJob suspended, at least one of them against its manifest. What stop leaves running (a DaemonSet, a ReplicaSet, a bare Pod) does not keep a Comb from Stopped; a manifest that says replicas: 0 or suspend: true itself is at rest as rendered, not stopped. Running: every resource exists, every Pod that should run has all its containers ready, the replicated workloads have their ready replicas and the Jobs completed. A replicated workload wants the replicas of its manifest (default 1, what "deploy" sets); scaled below that, by hand or by "stop", it is short of them (ReplicasNotReady, "scaled to" in the message), scaled beyond it -- by hand, by an autoscaler -- it wants its scale. A Comb without workloads is running once its resources exist. Waiting and terminated container reasons, restart counts and PodScheduled=False make up the reasons and messages; a reason that does not pass by waiting (CrashLoopBackOff, ImagePullBackOff, a failed Job, a failed Pod of its own) makes the phase Error. Completed Pods never count against the Comb; a failed Pod that belongs to a controller is left to it.

With an active upstream (resolved as in "reconcile") the Comb runs no Pods; the status is that of the borrowed service instead, with pods empty and an upstream key holding what status.upstream would. Running: the upstream is reachable and Running and every resource of the bridge ("bridge_manifests") exists. Pending or NotDeployed while that is not so; Blocked or Error where "reconcile" would stop with that phase.

healthy

Future of a boolean: whether "status" is Running.

logs

print $comb->logs(lines => 50)->get;

Future of the last lines (default 100) log lines of every container of the Comb's Pods. With more than one container, each gets a ==> pod/container <== header. A container in CrashLoopBackOff shows its previous instance, marked (previous). A container whose log cannot be read shows why instead of failing the whole Future.

restart

Rolling restart: sets "restart_annotation" to the current time (RFC 3339) on the Pod template of every Deployment, StatefulSet and DaemonSet of the Comb; deletes its Jobs together with their Pods. Future of the list of what it touched, as Kind/name. "deploy" -- and so "reconcile" -- keeps the annotation.

stop

Scales the Deployments and StatefulSets of the Comb to 0, suspends its CronJobs and deletes its Jobs together with their Pods. Future of the list of what it touched, as Kind/name. "status" is Stopped then; the next "reconcile" deploys the Comb again.

describe

Future of a hashref with everything about the Comb, plain data: name, class, namespace, depends_on, endpoints (resolved, as hashrefs), status (see "status"), recorded (the "recorded_status", when there is one) and stub_of (class of the original, for a stub).

endpoint

my $ep = $comb->endpoint('client')->get;

Future of the Kubernetes::Comb::Endpoint of that name, with its resolved addresses. Fails for a name the Comb does not offer. With an active upstream the addresses are the redirected ones (see "reconcile"), and it fails with the reason when the upstream offers none.

recorded_status

The status the Comb last recorded, a Kubernetes::Comb::CRD::CombStatus or undef: status of "crd", without a custom resource the one kept in memory. Unlike "status" this reads nothing from the cluster.

SEE ALSO

SUPPORT

Issues

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

IRC

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

CONTRIBUTING

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

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

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

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