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
Combcustom resources. - 2. Build an instance for each with "from_crd", passing
k8s,resolver,upstreamandstubas 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
Blockedon 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.svcgets "cluster_domain" appended.all IP addresses of one family: a selector-less Service (
ipFamiliesthat family) plus anEndpointSliceper address (discovery.k8s.io/v1,kubernetes.io/service-namelabel), 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.upstreamof "crd" (an explicitnullis "local"), a class methodupstream; else local. The coderef and the class method return nothing (local), an object doing Kubernetes::Comb::Role::Upstream,Name => (%args)forKubernetes::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 anError. - 2. Enabled
-
spec.enabledfalse, 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 (
nameornamespace/name), with the Comb as second argument; it returns the Comb instance orundef. 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
managedResourcesnaming just what the manifests render:Running, nothing applied.Not healthy: "deploy", then prune: every namespaced resource this Comb recorded in
managedResourcesthat 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 (propagationPolicyBackground: 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 theReadymessage says. ThenPending. A deploy that fails half-way is anErrorthat 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.configthe manifests render from: deploy and prune all the same, thenPending, theReadymessage naming the resources; the next step finds the CombRunning, 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
v1PersistentVolumeClaim, and abatch/v1Job 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, theReadymessage 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:Blockedwith its message. Aviaof more than "max_upstream_depth" layers is taken for a loop:Blocked(UpstreamLoop), the recordedviacut to that length.The upstream's
endpoints, matched by name to "endpoints": each declared endpoint gets the upstream'sclusteraddress (else itsexternalone) andexternal-- the redirected endpoints, which "endpoint" returns andstatus.endpointspublishes. 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
Runningwhen the upstream isRunning, elsePendingwith its phase.
A failing
statusorendpoints, an answer that is none, or a bridge that does not render:Error. Whatever the upstream'sstatussaid is recorded instatus.upstream. - 6. Record
-
Phase, conditions, the resolved endpoints -- the redirected ones while borrowing, once they are known --,
status.upstreamwhile an upstream is active,managedResourcesandobservedGenerationgo throughupdate_statusinto "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 aStatusWrittencondition that says why.A step that changes nothing that serves carries
managedResources, the endpoints andstatus.upstreamof the previous status forward: one that stops before step 5 (Disabled,Blockedby its dependencies,NeedsConfig, anErrorthere), 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.upstreamgoes 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
Kubernetes::Comb::CRD::Comb -- the custom resource
Kubernetes::Comb::Role::Client -- the client surface
Kubernetes::Comb::Role::Upstream, Kubernetes::Comb::Upstream::K8s, Kubernetes::Comb::Upstream::Static -- layering
Kubernetes::Comb::Static -- a Comb whose manifests are files
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.