Ported from docs/migration/v1-to-v2.md in the repository. A Helm 3 → Helm 4
/ server-side-apply migration section is a separate future write-up, not
covered here.
Migration guide — platform library v1 → v2
This is a task-oriented upgrade path for a consumer chart moving from
the v1 platform-library to the v2 platform library. v2 is a pure
library chart with a single render entrypoint and capability-negotiated
API versions.
What broke, and why
-
The library no longer self-renders. v1 shipped roughly 25 non-underscore wrapper templates (
deployment.yaml,service.yaml,app.yaml, and so on) that emitted objects on their own. These were deleted. The chart is now purelytype: library, sohelm install platform-library ...correctly fails — a library chart cannot be installed on its own. Every template is now_-prefixed. -
Consumers must render explicitly. Instead of relying on the library to self-render, your chart calls the single public entrypoint from its own
templates/app.yaml:{{ include "platform.render" . }}platform.rendercomposes the opinionated tier-1 objects, the tier-2extraObjects, and the rawextraManifests. -
The chart was renamed and re-versioned. The dependency name is now
platform(source directoryplatform-library/), and it targetskubeVersion: ">=1.34.0-0 <1.37.0-0"(an n-2 support window that moves forward with each Kubernetes release). -
API versions are now negotiated, not hard-coded. HPA, Ingress, PDB, CronJob, Certificate, mTLS, Gateway API, ServiceMonitor, and PodMonitor all pick the best
apiVersionthe target cluster serves. CRD-backed objects skip themselves when their API is absent, instead of rendering a manifest that would fail at apply time. -
New values keys:
capabilities.apiVersions,extraObjects,extraManifests, plus everything documented on the values reference page that shipped after v2’s initial release (generatedSecrets,webhooks,tlsSelfSigned.mtls,rbac,resourceQuota,limitRange,prometheusRule,verticalAutoscaling, and more) — none of these existed in v1 at all.
Fast path: scaffold a fresh consumer chart
If you’d rather start clean than hand-edit an existing chart, generate a pre-wired consumer and move your overrides into it:
scripts/new-app-chart.sh my-service
# options: --dir <path> --repo <url, default file://../platform-library>
# --version <range, default ">=2.0.0-0"> --app-version <v>This emits a Chart.yaml with the platform dependency (including
import-values: [defaults]), a templates/app.yaml reading
{{ include "platform.render" . }}, an overrides-only values.yaml, a
.helmignore, and a values.schema.json copied from the library’s
reference schema so your root values are validated on every helm
invocation. The manual steps below describe the same end state if you’d
rather upgrade an existing chart in place.
Step 1 — update Chart.yaml
apiVersion: v2
name: my-service
version: 1.0.0
dependencies:
- name: platform
version: ">=2.0.0-0"
# OCI registry in normal use:
repository: oci://ghcr.io/caretak3r/charts
# ...or a local path for development:
# repository: file://../platform-library
import-values: [defaults] # MANDATORY — see the footgun belowStep 2 — add the render entrypoint
Create (or replace) templates/app.yaml with exactly one line:
{{ include "platform.render" . }}Delete any per-object wrapper templates you were carrying to work around
v1 — they’re unnecessary now and will produce duplicate output alongside
platform.render.
Step 3 — keep your tier-1 overrides as-is
The opinionated tier-1 blocks are unchanged in shape (service, ingress,
autoscaling, certificate, mtls, gatewayApi, serviceMonitor, and so
on). Your existing overrides continue to apply. No hard-coded apiVersion
is needed for any of these anymore — negotiation handles it.
The import-values: [defaults] footgun. This is the single most common
integration failure. The library ships all its defaults under
exports.defaults. Without import-values: [defaults] on the dependency,
those defaults never reach your root scope and every value renders
empty — you get either a near-empty render or nil template errors, with
no obvious cause pointing back at the missing line.
dependencies:
- name: platform
version: ">=2.0.0-0"
repository: oci://ghcr.io/caretak3r/charts
import-values: [defaults] # <-- without this, all values are emptyStep 4 — move custom resources into extraObjects
Anything you previously dropped as raw YAML in your own chart — RBAC,
quotas, priority classes, and similar objects — should move into the
tier-2 extraObjects map so it gets standard labels, namespace stamping,
and capability negotiation for free.
extraObjects is a map of Kind -> list of specs. Reserved keys per
spec are name, namespace, labels, annotations, apiVersion,
kind, clusterScoped; every other top-level key passes through
verbatim.
extraObjects:
Role:
- name: app-reader
rules:
- apiGroups: [""]
resources: ["configmaps", "secrets"]
verbs: ["get", "list", "watch"]
RoleBinding:
- name: app-reader-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: app-reader
subjects:
- kind: ServiceAccount
name: my-service
PriorityClass: # cluster-scoped: no namespace is stamped
- name: my-high
value: 1000000
globalDefault: false
description: "High priority"- Built-in Kinds (RBAC, quotas, priority classes, and so on) always render at their best available version.
- CRD Kinds placed here skip when their API is absent.
metadata.nameis required; a missing name fails withextraObjects.<Kind>[].name is required.- Set
clusterScoped: trueon a spec to force-suppress the namespace for a Kind the library doesn’t already know is cluster-scoped.
If you were relying on RBAC in extraObjects, note that v2 later grew a
dedicated first-class rbac block for namespaced Role/RoleBinding
generation tied to the chart’s own ServiceAccount — see
Values reference. It’s simpler for the common case;
extraObjects.Role/RoleBinding still works for anything more bespoke.
Step 5 — use extraManifests only for the truly bespoke
For anything the library doesn’t model and that you don’t want normalized,
use the raw escape hatch. It’s a list; you supply the full manifest.
String entries run through tpl (so they can contain template
expressions); map entries render verbatim. No labels, namespace, or
negotiation are added.
extraManifests:
- apiVersion: v1
kind: ConfigMap
metadata:
name: raw-config
data:
raw: "true"
# A template string is also allowed:
- |
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-templated
data:
ns: {{ .Release.Namespace }}Step 6 — rendering without a cluster (CI / helm template)
Under bare helm template (no cluster), Helm’s API discovery is minimal
and reports no CRDs. Built-in objects still render (they negotiate-or-fall
back to a default), but CRD-backed objects — Certificate, mTLS, Gateway
routes, ServiceMonitor, PodMonitor, PrometheusRule, and any CRD in
extraObjects — will skip unless you force-assume their groups:
capabilities:
apiVersions:
- gateway.networking.k8s.io/v1
- cert-manager.io/v1
- security.istio.io/v1beta1
- monitoring.coreos.com/v1Entries may be group/version or the fuller group/version/Kind. On a
real cluster that serves these APIs you don’t need the override — it’s for
local development and CI rendering only.
Render locally, directly or through the test harness:
helm dependency update .
helm template my-service . --kube-version 1.34Upgrade churn and immutability warnings
- Objects appear and disappear with CRDs. Because CRD-backed objects
skip when their API is absent, installing or removing a CRD (cert-manager,
for example) changes what renders. On the next
helm upgradethose objects will be created or pruned accordingly. This is expected — plan CRD install and removal around your release windows. - beta → GA can force a replace. If negotiation moves an existing
object from a beta to a GA
apiVersion(saypolicy/v1beta1→policy/v1), Helm or the API server may need to replace rather than update the resource. Expect brief churn for those specific objects on the first v2 upgrade. - Immutable fields. Never override immutable fields on live objects —
most importantly
spec.selector. As with v1, avoid putting mutable labels into a Service or workload selector: selectors are immutable, and changing one breaks matching against existing pods (a delete/recreate is then required).
Quick checklist
- Dependency renamed to
platform,version: ">=2.0.0-0". import-values: [defaults]present on the dependency.templates/app.yamlis exactly{{ include "platform.render" . }}.- Old per-object wrapper templates removed.
- Custom resources moved to
extraObjects(orextraManifestsfor the truly bespoke). capabilities.apiVersionsset for any CI/local render that needs CRD-backed objects.- Verified
helm dependency update && helm templaterenders as expected.