Kubernetes resource plugin
AKS targets can name their cluster instead of describing it. endpoint
and certificateAuthority are now optional on AKSAuth: give it a resource
group and cluster name and the plugin reads both from Azure. Stating them
still works and always wins, which is the path for a private endpoint or
anything formae does not model. subscriptionId is new, falling back to the
agent's AZURE_SUBSCRIPTION_ID, because a hosted agent can span
subscriptions.
AKS is the only cloud auth type that does this, and the reason is specific to
Azure: the CA is not on the ManagedCluster resource at all. It is reachable
only through the credentials endpoints, which the azure plugin calls during
Read and which silently yields nothing when the call is denied or the cluster
sets disableLocalAccounts. A discovered cluster then carries an empty CA
with no indication why, and the failure surfaces later as a bare 401. Reading
it at connect time makes that a real error naming the permission to grant.
The lookup prefers ListClusterUserCredentials (AAD-based, needs only
listClusterUserCredential/action, survives disableLocalAccounts) and
falls back to admin; only the server URL and CA are taken from the returned
kubeconfig, and the token still comes from the agent's own Azure identity.
EKS, GKE, OVH and OKE are unchanged and still require both fields. Their endpoints and CAs are already on the resources formae discovers, so there is nothing to repair and no reason to spend an API call or a permission on it.
GKEAuth can name its cluster. The Pkl class exposed only endpoint
and certificateAuthority, while GKEAuthConfig read ProjectId,
Location and ClusterName and CacheKey composed all three. Nothing
could set them, so the Go fields were dead and every GKE target keyed its
token cache as GKE|<endpoint>|||, telling two clusters apart by network
address alone. The three fields are now optional on GKEAuth, and
examples/clusters/gcp.pkl sets them.
examples/connect-matrix/ — a cluster/connect file pair per cloud
(AWS, GCP, Azure) that applies the cluster in one forma and the Kubernetes
target in a separate one, which is how a hosted agent has to do it when the
cluster already exists or was discovered. Every shipped example emits
cluster and target together, so that path had no coverage. The README
carries the per-cloud matrix, the three scenarios (formae creates it, it
already exists, discover then adopt then connect) and what each cloud's
discovery does and does not persist.
examples/ (scripts/eval-examples.sh, 34
files). The pkl-validate job only ever evaluated formae-plugin.pkl, so
schema drift in a plugin dependency could break an example for months without
a red check.hasProviderDefault schema annotation now carries a recorded
disposition in schema/provider-default-dispositions.json, enforced by a
unit test: new annotations fail CI until classified, and rows for removed
fields fail as stale. Object metadata labels and annotations are recorded
as co-owned, because Kubernetes writes into both alongside the user; every
other annotation starts as pending, and classifications land per field as
the provider-default audit reaches them.A target config carrying an unresolved reference now fails by name.
formae replaces every reference it resolves with the scalar value before a
plugin sees it, and passes the ones it could not resolve through
structurally intact with no $value. The plugin's ResolvedString read
$value out of such an envelope, found nothing, and produced "" — an EKS
token minted with an empty x-k8s-aws-id, an AKS token against an empty
resource group, and a 401 from the API server naming none of it. The type
is gone; every auth field is a plain string again, and FromTargetConfig
rejects an Auth block still carrying a $res/$ref object, naming every
offending field at once.
A cloud auth block missing a required identifier is rejected. An EKS
config without ClusterName was accepted all the way through and signed
with an empty x-k8s-aws-id. Every cloud auth type now checks the fields
its provider cannot work without, matching what the Pkl schema already
marks non-optional, and reports them together:
EKS auth config missing required field(s): ClusterName.
The examples/lgtm-observability/ formae evaluate again. All five
(local, aws, azure, gcp, oci) set username/password on the
Grafana Target's Config, which the published grafana schema
(grafana@0.1.3, the pinned dependency) has never defined — pkl eval
failed with Cannot find property 'username' in object of type 'grafana#Config'. The Target now authenticates with the agent's
GRAFANA_AUTH env var, which is what examples/lgtm-observability/README.md
already documented. Sourcing the password from the managed
lgtm-grafana-admin Secret needs a Config.auth block; that lands when a
grafana schema carrying it is published to the hub.
A K8S::Custom::Resource no longer fails when its CRD arrives late in the
same apply. The apply path already reset the RESTMapper and
retried on no matches for kind, but the loop gave up after 30s — too short
for a K8S::Helm::Release that installs CRDs alongside its controller
(cert-manager's took ~66s to establish). The wait now defaults to 180s and is
a plugin setting on the agent's formae.conf.pkl entry:
agent {
resourcePlugins {
new k8s.PluginConfig { crdEstablishTimeoutSeconds = 300 }
}
}A cert-manager Release plus its ClusterIssuers no longer has to be split across two applies.
A live uninstall is no longer reported as abandoned. Delete started its
Helm uninstall without registering it in the in-flight registry, and
"a release record this plugin owns with no operation behind it" is exactly how
an abandoned uninstall is recognised. So the first Status poll — 20 seconds
after Delete under the default statusCheckInterval — declared every
uninstall slower than that abandoned, with a recoverable error code that asks
the agent to re-drive Delete, starting a second concurrent uninstall of the
same release. Slower than 20s is ordinary: a pre-delete hook, or Wait=true
sitting through a Pod's terminationGracePeriodSeconds.
Only podinfo-sized charts escaped it, which is why the conformance destroy step
passed throughout: its record is purged before the first poll. No test chart in
the repo declares a delete hook, and the kratos scenarios call
formae destroy from a trap EXIT cleanup that swallows failures.
A release whose objects never become ready now fails instead of polling
forever. Under Wait=false Helm records deployed as soon as the apiserver
accepts the manifests, and that record never changes again — so a Pod stuck in
ImagePullBackOff from a typo'd tag, or one no node has room for, left Status
answering InProgress for eternity. Nothing above caught it either: the agent
fails an operation when a plugin goes silent, never because it keeps
reporting progress, and there is no cap on how long an operation may run. The
readiness wait is now bounded by the timeout recorded on the release, the same
clock that already bounds a pending release, and the failure names the object
that never came up. An operation this process is still running is exempt, so a
slow hook is never cut short.
An uninstall is bounded by the release's own timeout, not the package
default. A release given timeoutSeconds = 1800 had its uninstall cut off at
600s while the stalled-release verdict waited twice 1800s before saying so,
leaving the command InProgress for the best part of an hour on work nothing
was doing.
Upgrading a chart with subcharts no longer fails to render. Re-applying the
deployed version reuses the chart stored in the release record instead of
fetching it, but chart.Chart.dependencies is unexported and carries no JSON
tag (helm/pkg/chart/chart.go:56), so Helm's own storage drops every subchart
on the way in — while Metadata.Dependencies, which is serialized, goes on
listing them. Rendering that remnant failed on any helper a dependency defines,
which for an ory chart is the whole templates directory:
template: no template "ory.extraEnvContainsEnvName" associated with template "gotpl"
Such a chart is re-fetched now — but only when there is somewhere to fetch
from. An adopted release has no repoURL, because Helm never records where a
chart came from, so insisting on a fetch there would fail every upgrade of an
adopted subchart chart outright. With nothing to fetch from, the incomplete
stored chart is used anyway: rendering it may well succeed, and when a dropped
subchart really is needed Helm names the template it cannot find. Trading a
possible failure for a certain one is not an improvement.
The no-op check that stops a re-driven Create re-running hooks is unaffected —
it compares a version and a set of values and renders nothing, so an incomplete
stored chart tells it nothing.
The plugin's own release labels no longer leak into resource state.
formae.dev/managed — and now formae.dev/timeout-seconds — were reported
back in metadata.labels, which put them into formae extract output and made
them read as drift against a forma that never declared them.
HelmChart.pkl and its per-version wrapper trees are gone — 281 files,
~39k lines: schema/pkl-helm/ (the HelmChart module, 17 api-group mappers
and the gen-versioned-helm codegen) and the generated
schema/pkl/helm/v1.21…v1.36/ trees, plus the make targets
generate-versioned-helm-schemas, verify-helm-schemas and the CI job that
ran the latter.
Breaking for anyone importing @k8s/helm/v<X.Y>/HelmChart.pkl. Migrate to
K8S::Helm::Release: one resource per chart, Helm applies the objects. There
is no mechanical rewrite — HelmChart produced N typed resources in formae
state and Release produces one, so the release adopts what the chart already
installed rather than inheriting per-object state.
It was removed rather than deprecated because it could not honour hooks.
helm template emits hook-annotated manifests with no orchestration, so a
pre-install Job became a permanent resource that never re-ran, hook-weight
was ignored, finished hooks accumulated, and test hooks were applied on every
reconcile. Charts that relied on hooks applied silently wrong, which is a worse
failure than not being supported.
Removing it also drops the pkl-readers/helm@0.1.2 package dependency and the
pkl-reader-helm external-reader declaration from the example projects:
nothing renders a chart at Pkl-eval time any more.
Deleted with it: the seven HelmChart-based single-file examples
(nginx*.pkl, postgresql-v1.31.pkl, memcached-v1.31.pkl,
create-namespace-test.pkl, imagepullsecrets-test.pkl) and two already-dead
make targets — chart-test, whose script had been removed, and
conformance-test-charts, whose *-chart filter matched no remaining fixture.
examples/flux/flux-helm.pkl is ported to a Release, so Flux still installs
with one formae apply.
K8S::Helm::Release — Helm charts driven by the embedded Helm SDK. Formae
manages the release; Helm manages the objects the chart renders. Hooks, hook
weights, hook delete policies, CRD install ordering and revision history all
work because Helm implements them, not because the plugin reimplements them.
The release is a genuine Helm release, so helm list, helm history and
helm rollback see it.
Create/Update submit with Wait=false and return InProgress once Helm
has written the release record; Status polls the record and then checks every
rendered object with Helm's own ReadyChecker. The plugin stores nothing — all
state lives in the release Secret and the cluster.
A release is only recorded by formae once it is fully deployed. Create
returns no NativeID, so a half-installed release is never put under
management; Status supplies it after the release reaches deployed and every
rendered object passes readiness. The RequestID carries namespace, name and
target revision, and is what Status uses to find the release meanwhile.
Update is exempt — the resource is already in state.
Consequence to be aware of: a first install that fails leaves formae with no
handle on the release, so formae destroy cannot clean it up. Re-applying the
same forma does recover it — see the crash-recovery entry below.
Complements HelmChart.pkl, which renders client-side and decomposes into
typed resources. Prefer Release for charts with hooks, CRDs or subcharts;
prefer HelmChart when per-object formae state matters more than chart
fidelity.
A crash mid-install no longer needs manual recovery. Helm has no
server-side operation controller: the install runs in the plugin process, so
when that process dies the work dies with it, and Helm's pending-install
status is left behind as a lock with no owner. Helm refuses both install and
upgrade on a release in that state, and its documented way out is
helm uninstall — which destroys the objects and re-runs pre-install hooks
to get back to where it already was.
The plugin now clears that lock itself on the next operation, doing what
Helm's own failRelease does: set the status, write the record back. Whether
it settles on deployed or failed is decided by the cluster, because Helm
writes deployed last — after the hooks have run and every object exists:
Releases.Create(pending-install) -> hooks -> create objects -> SetStatus(deployed)
Dying anywhere in that middle stretch leaves an identical record, whether the
work finished or never started. So recovery checks: every object the release
renders present and ready means the install did complete and only the record
was lost — it is recorded deployed and reported as success, with no second
Helm operation and no hooks re-run. Anything missing means failed, which an
upgrade is allowed to run over, three-way merging what is absent.
The guarantee, whatever died:
| The command reaches a verdict | It never sits in InProgress reporting work nothing is doing |
| A failure says why | e.g. objects are incomplete (ConfigMap/app-config is absent) |
| The next apply converges | No operator, no helm uninstall |
Two limitations worth knowing before you rely on this:
Status again and the plugin never gets
the chance to clear the record. The release stays pending-install until
something applies again — which then recovers it automatically.K8S::Helm::Release moved to @k8s/helm/Release.pkl — one copy, not
sixteen. The module shipped in every v<X.Y>/ tree as 16 byte-identical
copies; no field on a Helm release has a shape that depends on the apiserver
minor, so it now ships once at the package root.
Breaking for the import path only:
import "@k8s/v1.33/helm/Release.pkl" as helm // before
import "@k8s/helm/Release.pkl" as helm // afterNothing else changes — same type, same fields, same state. Every other schema
import keeps its v<X.Y>/ segment.
This needs formae >= 0.89.0. The hoist was tried and reverted once because
formae extract globbed only @k8s/*.pkl plus @k8s/v<ver>/**/*.pkl, so a
resource module in a root-level subdirectory was invisible to it and extract
died with Cannot find key "K8S::Helm::Release". formae#584 widened the glob
to cover version-independent subtrees, which is what makes this possible.
tools/gen-versioned-reflect grew a versionIndependentDirs set for this and
rejects a @K8sVersion gate inside one: with no per-version copy left there is
nothing to filter, and the gate would otherwise be silently ignored.
spec now updates as one whole value. Changing any
part of the spec sends the complete document to the apiserver in a single
replacement, the way kubectl apply of the full manifest behaves, instead
of a series of per-field edits formae computed from a document whose grammar
it cannot know. minFormaeVersion is raised to 0.89.0 accordingly.Empty objects and lists in a custom resource spec reach the cluster
exactly as written. For many custom resources an empty member is itself
the configuration: a cert-manager ClusterIssuer selects the selfSigned
issuer type with selfSigned = new Dynamic {}. Previously formae cleaned
empty objects and lists out of the spec before writing, so the apiserver
received an empty spec and admission webhooks rejected it, and adding a
placeholder inside the empty member did not help because the cleanup
collapsed it again. The spec is now preserved byte for byte. A custom
resource that already lost empty members this way is repaired by the next
apply, and a placeholder value added to work around the old behavior can be
removed.
Paused Deployments settle instead of polling forever. A Deployment with
spec.paused: true never converges its replica counts — the controller stops
reconciling by design — so Status() reported InProgress until the operation
timed out. A paused Deployment is now Success once the apiserver has observed
the paused spec.
StatefulSet OnDelete and partitioned rollouts settle. With
updateStrategy.type: OnDelete, pods are only replaced when deleted by hand, so
status.updatedReplicas never advances and the rollout looked stuck forever.
OnDelete now gates on readiness of the desired set only. With
rollingUpdate.partition: N, only ordinals >= N are updated, so the reachable
updated count is replicas - partition (floored at 0) rather than replicas.
DaemonSet OnDelete rollouts settle. Same root cause as the StatefulSet
case: status.updatedNumberScheduled never reaches
desiredNumberScheduled under OnDelete, so status now gates on
numberReady alone.
No more false drift against an HPA. When a HorizontalPodAutoscaler scales
a Deployment, ReplicaSet, or StatefulSet, the HPA — not formae — owns
spec.replicas. formae still read the live count back and reported it as drift
against a forma that deliberately omitted replicas, every reconcile. The
plugin now consults metadata.managedFields and strips spec.replicas from
reported state whenever the formae field manager does not own it. A forma
that does declare replicas is unaffected — formae owns the field and it
keeps drifting as before, which is the intended behavior for that case.
Over-marked createOnly fields no longer force a destructive replace. Every
createOnly field makes formae plan a delete-then-create replace when the value
changes. Five fields were marked immutable but are in fact accepted in place by
the apiserver, so formae was destroying resources for changes Kubernetes would
have taken: CSIDriver.spec.requiresRepublish, CSIDriver.spec.tokenRequests
(both mutable since Kubernetes 1.22), PriorityClass.globalDefault,
RuntimeClass.overhead, and RuntimeClass.scheduling. Each verdict was
verified against a live apiserver. The fields that are genuinely immutable keep
createOnly, and their per-field docstrings say so.
StatusMessage on every non-Failure result, so the per-resource reason row
stayed empty during a rollout. Provisioner messages now pass through on
InProgress (e.g. replicas: 2/3 ready) and are blanked only on terminal
Success, where a lingering message is just noise.--watch flag from the example commands in the README,
CONTRIBUTING, the helm/flux/crossplane/bookstore/custom-resource docs, and
the example file headers. formae apply/destroy are submit-then-poll.K8S::Core::Secret adopts formae's first-class map-shaped secret types. A
Secret's value can now be referenced one key at a time with
secret.res.secretValue.at("key"), resolved live at the plugin-call
boundary so a consumer such as a target credential picks it up without an
agent restart. The decoded value is hashed at rest and excluded from drift
detection. Requires formae 0.89.0 or later; minFormaeVersion is raised to
0.89.0.examples/rollout-safety/ — one folder per case (paused Deployment,
OnDelete StatefulSet, partitioned StatefulSet, HPA coexistence), each with
create.pkl/update.pkl and the old-vs-new plugin behavior in the header.Read against an
unreachable apiserver (connection refused, DNS failure, dial/read timeout)
fell through to a raw error the host rendered as UnforeseenError, which
carries no health signal — so the target reaper never saw the cluster as
unreachable and never reaped it. The Read funnel now maps a genuine
client-side transport failure to NetworkFailure/ServiceTimeout.
Auth/credential failures are deliberately excluded: a bad token surfaces as a
*url.Error that satisfies net.Error even though the apiserver is
reachable, so classification unwraps to concrete net types and skips the
auth path — a healthy cluster is never reaped over a bad credential.client-go bumped to v0.36.3 (k8s.io/api and k8s.io/apimachinery
moved in lockstep), staying pinned to the highest supported K8s minor (1.36).go-version pins now match the go 1.26.0
declared in go.mod.minFormaeVersion remains 0.86.0.name/namespace.
The metadata of pod and job templates (spec.template.metadata on Deployment,
StatefulSet, DaemonSet, ReplicaSet and Job, and spec.jobTemplate.metadata
on CronJob) changes type from ObjectMeta to PodTemplateMetadata, which holds
only labels and annotations. Kubernetes accepts name/namespace on templates
but never uses them (pods get generated names), so the schema previously forced you
to invent a value with no effect. Formas that construct the metadata class explicitly
inside a template need a one-line migration: swap
new k8s.ObjectMeta { name = "..."; labels { ... } } for
new k8s.PodTemplateMetadata { labels { ... } } and drop the name. Because the
cluster stored the template name, the first apply after upgrading that includes the
pod template removes that stored field, changing the template hash and performing one
rolling update of the workload, so plan for it as you would any rolling restart.
Unaffected: the amend style (metadata { labels { ... } }), top-level resource
metadata (its name is still required), and StatefulSet volumeClaimTemplates metadata.spec.template.metadata.name, a field Kubernetes ignores and virtually no Helm chart
or kubectl-applied manifest sets. As a result most Deployments, StatefulSets and
CronJobs created outside formae were silently missing from discovery: no error surfaced,
the resources simply never appeared in the inventory. With the template-metadata change
above, these workloads are discovered and can be brought under management.Certificate, Argo
Application, Flux GitRepository, and so on) with no per-CRD Go code or
generated schema, through a generic catch-all type. K8S::Custom::Resource
reads apiVersion/kind from the manifest, resolves the GVR against the live
cluster, and applies via Server-Side Apply, the same mechanics as the built-in
typed resources. The body (spec and any top-level fields) is free-form, so it
works for arbitrary CRD schemas. This is an escape-hatch model: no field-level
validation or autocomplete, by design. Identity is a composite formaeId
(<apiVersion>/<kind>/<namespace>/<name>), unique across kinds since a single
type spans every CRD.K8S::Apiextensions::CustomResourceDefinition manages CRDs themselves (a CRD
is just an apiextensions.k8s.io/v1 object). Backed by the same generic
provisioner, it lets a CRD and an instance of the kind it defines live in one
forma and deploy in a single formae apply, with no kubectl bootstrap. The
CRD provisioner blocks until the CRD's Established condition is True, and
the instance's apply retries (re-discovering the RESTMapper) until its kind is
served, so the two converge in one apply with no explicit ordering, and survive
destroy/recreate cycles.HelmChart now maps
chart-rendered kinds that have no typed provisioner (the CRDs an operator
ships) through K8S::Custom::Resource instead of skipping them. A single
HelmChart therefore installs a complete operator: controllers and RBAC as
typed resources, the operator's CRDs via the catch-all.serviceAccountName as a plain string, so formae applied
Deployments concurrently with their ServiceAccounts, and Pods then failed with
serviceaccount not found until the SA caught up. The mapper now emits
serviceAccountName as a resolvable referencing the SA resource, so formae
creates the ServiceAccount first and resolves the name. The reference
round-trips to the SA name, so there is no drift. (The cluster-default
default SA is left a plain string to avoid a dangling reference.)JobSpec.restartPolicy. The Helm batch mapper emitted
restartPolicy at the JobSpec level, where the field does not exist (it
belongs on the pod template). Charts that ship a Job (e.g. install hooks) now
render correctly.MutatingAdmissionPolicy (GA in
1.36) on a 1.33 cluster. Discovery still called List for it, the apiserver
returned the server could not find the requested resource, and it was logged
on every discovery pass. Operations are now gated on whether the type is served
by the target's Kubernetes version. The plugin resolves the target's version
and, for a type that version doesn't serve, handles each operation accordingly:
discovery List returns empty (no error), Create/Update fail with a clear
message naming the type and version, Read/Status report not-found, and
Delete is a no-op. Resolution is fail-safe: if the cluster version can't be
determined, operations proceed as before.kind v0.32.0 ships kindest/node:v1.36.1,
so K8s 1.36 now runs the full conformance suite instead of being schema-only.
The per-minor chain on main extends to 1.36 down to 1.21, the PR conformance
suite exercises 1.36 against kindest/node:v1.36.1, and the nightly cluster
moves to the same image. This closes the 1.36 wire-up tracked in 0.1.3.K8S::Admissionregistration::MutatingAdmissionPolicy
(admissionregistration.k8s.io/v1, GA in K8s 1.36,
KEP-3962), the in-tree, CEL-based successor to
mutating admission webhooks. It supports the full CRUD lifecycle and models
matchConstraints, variables, matchConditions, and ApplyConfiguration /
JSONPatch mutations. The whole module is gated introducedIn = "1.36", so it
materializes only in the @k8s/v1.36+ schema trees; referencing it with
kubernetesVersion set to an earlier minor fails at pkl eval time. This
raises the supported resource count to 36 types across 13 API groups.pkl eval-time field validation for that whole
range. The runtime support window is 1.31 to 1.36 (MinSupportedK8sVersion /
MaxSupportedK8sVersion in pkg/config/version.go); applying against a live
cluster outside this window returns a clear preflight error. So schema trees
exist for 1.21 to 1.30, but those minors are below the runtime floor: you can
author and eval against them, yet the plugin will refuse to drive a live
cluster older than 1.31. The floor has been 1.31 since the per-version schema
system was introduced; only the ceiling has moved (1.34 to 1.36, in lockstep
with the pinned client-go). Targeting a cluster below 1.31 is not supported.kubernetesVersion field on the K8s Config. Each
per-version subtree under @k8s/v<X.Y>/ carries only fields that are valid for
that minor; formae evaluates against the matching subtree at extract and apply
time, surfacing field-availability errors before any RPC reaches the cluster.
The client-go dependency is pinned to v0.36.0 in lockstep with the highest
supported minor. The plugin's pkg/config/version.go records
MinSupportedK8sVersion = "1.31" and MaxSupportedK8sVersion = "1.36"; users
on a cluster outside that window get a clear preflight error.core/Service now exposes its assigned
LoadBalancer endpoint through resolvables: lbIngressIp, lbIngressHostname,
and lbIngressUrl. The URL form is synthesized by the plugin as
http://<host>[:port] from the first ingress address and the first service
port, so a cross-plugin Target can take its endpoint directly from a $ref on
the Service.$ref on the cluster
endpoint, and the Grafana target's URL is a $ref on the Grafana Service's
LoadBalancer ingress URL.Conformance K8s 1.35 workflow runs on
every push to main, joining the existing per-minor chain (1.35 → 1.34 → ... → 1.21). The PR conformance suite now exercises 1.35 against
kindest/node:v1.35.1. K8s 1.36 conformance is tracked separately and will
land once kind publishes a 1.36 node image; see
issue #9
for the wire-up checklist.schema/pkl/ tree now splits responsibility
cleanly between the package root and the per-version subtrees. target.pkl is
the version-agnostic package root, carrying Config (including
kubernetesVersion) and the Auth hierarchy (KubeconfigAuth, EKSAuth,
GKEAuth, AKSAuth, OVHAuth, OCIAuth). v<X.Y>/k8s.pkl is the per-version
SubResource module (PodSpec, Container, EnvVar, ObjectMeta, and every
other inline type whose accepted field set varies per K8s minor); each
per-version file extends "../target.pkl", so importing @k8s/v1.34/k8s.pkl
also gives you Config + Auth via inheritance. Resource files
(@k8s/v<X.Y>/<api-group>/<Kind>.pkl) sit under each per-version subtree and
import the matching k8s.pkl for their subresource dependencies.forma.pkl files under
examples/formations/ now import @k8s/k8s-subresources.pkl as k8s against the
master schema, restoring chart-conformance test compatibility that broke during
the earlier schema split. Per-version example files
(examples/helm/{nginx,memcached,postgresql}-v1.{31,34}.pkl) import the
per-version @k8s/v<X.Y>/k8s.pkl subresources file.