Skip to content

Production Typed Migration

Franklin García edited this page Sep 13, 2026 · 1 revision

Production Typed-Only Migration

This guide is for teams migrating an existing Helm repository to Timonel while explicitly forbidding addManifest(), addTemplateManifest(), any, as unknown as ..., and equivalent coercion tricks.

The goal is not to reproduce template text line for line. The goal is to preserve the rendered Kubernetes behavior, chart packaging, values contract, and Helm lifecycle using typed TypeScript constructs wherever Timonel and its cdk8s dependencies provide a real typed path.

Migration rule

Use this order for every resource or chart feature:

  1. cdk8s-plus-33 construct;
  2. generated/typed cdk8s ApiObject for an API without a cdk8s-plus construct;
  3. focused Timonel typed abstraction;
  4. stop the migration for that feature if the installed Timonel version has no typed path.

Do not make a migration appear complete by casting a ValuesRef, weakening a resource body to any, or moving raw Kubernetes YAML into a helper template.

Inventory the existing Helm repository first

Before converting code, inventory all chart behavior that must survive the migration:

  • Chart.yaml metadata: version, appVersion, type, kubeVersion, maintainers, sources, dependencies, aliases, conditions, tags, and import values;
  • values files and values.schema.json;
  • Kubernetes resources and CRDs;
  • hooks and hook weights/delete policies;
  • RBAC, service accounts, and workload identity annotations;
  • _helpers.tpl named templates;
  • parent/child chart relationships and external dependencies;
  • vendored dependencies under charts/;
  • non-template files accessed through .Files;
  • map iteration, dynamic keys, index, hasKey, range, and with usage;
  • release/chart/capability built-ins;
  • every values combination used by CI, production, and upgrades.

Keep a rendered baseline from the existing chart for representative value sets. That becomes the behavioral oracle for the migration.

1. Native Kubernetes resources

Use Rutter.getChart() as the construct scope and prefer cdk8s-plus resources:

import * as kplus from "cdk8s-plus-33";
import { Rutter } from "timonel";

const chart = new Rutter({
  meta: {
    name: "payments",
    version: "1.0.0",
  },
});

const account = new kplus.ServiceAccount(chart.getChart(), "PaymentsAccount", {
  metadata: { name: "payments" },
});

const deployment = new kplus.Deployment(chart.getChart(), "Payments", {
  metadata: { name: "payments" },
  containers: [
    {
      name: "payments",
      image: "example/payments:1.0.0",
      portNumber: 8080,
    },
  ],
  serviceAccount: account,
});

Keep normal Kubernetes structure in constructs. Values and Helm expressions should only enter at properties for which your Timonel version provides a supported typed binding.

2. ValuesRef compatibility matrix

valuesRef<T>() provides typed references to .Values, but a Helm reference is not the same thing as a JavaScript primitive. Whether it can be assigned directly depends on the destination API.

Compatibility matrix:

  • Timonel string: HelmValueRef<string> at a Helm-aware serializer boundary.
  • Timonel number/boolean: typed ValuesRef where the installed version supports it.
  • Native scalar: use the scalar-binding API; never cast the reference.
  • Nested object: keep typed nested fields; do not replace it with a raw manifest.
  • Array: use range() and its typed scoped item.
  • String-keyed map: use rangeEntries() / index() when the version supports them.

For native cdk8s/cdk8s-plus scalar properties, use the explicit Timonel scalar-binding API available in your version. Stable releases that predate that binding cannot safely accept a ValuesRef there. For string-keyed maps, older stable versions may likewise require waiting for typed map operations rather than using casts.

Example values model:

interface Values {
  replicaCount: number;
  feature: {
    enabled: boolean;
  };
  image: {
    repository: string;
    tag: string;
  };
  env: Record<string, string>;
}

const v = valuesRef<Values>();

v.image.tag.quote();
v.feature.enabled.not();
v.env.hasKey("DATABASE_URL");

If TypeScript rejects a ValuesRef assignment to a native number, boolean, or string property, treat that as a real unsupported boundary in that Timonel version. Do not silence it with a cast.

3. Conditions around whole typed resources

For versions that include typed resource conditions, keep the Kubernetes resource typed and apply the Helm condition at the Timonel synthesis boundary:

const account = new kplus.ServiceAccount(chart.getChart(), "RestartAccount", {
  metadata: { name: "restart-manager" },
});

chart.when(v.restart.enabled, account);

Composed conditions remain typed:

chart.when(v.restart.enabled.or(v.restart.forced.not()), account);

If the installed release predates Rutter.when(), there is no strict typed-only equivalent for wrapping an arbitrary construct in {{ if ... }}. Upgrade to a release containing resource conditions instead of falling back to raw template YAML.

4. Hooks and RBAC

Model the hook resource itself as a typed construct, then attach Helm hook metadata through the supported lifecycle/hook API. Keep service accounts, Roles, ClusterRoles, RoleBindings, and ClusterRoleBindings as normal typed Kubernetes resources.

Typical migration order:

  1. create the ServiceAccount;
  2. create the Role/ClusterRole;
  3. bind the account;
  4. create the Job or other hook workload;
  5. apply hook event, weight, and delete policy;
  6. render with upgrade/install values and compare against the original hook manifests.

See Helm Hooks and Lifecycle for the supported hook API and examples.

5. Custom resources and CRDs

Prefer generated cdk8s imports when a CRD schema is available. Generated types give the strongest compile-time contract and attach directly to Rutter.getChart().

For extension APIs without a generated construct, use Timonel's typed custom-resource abstraction when your installed version provides it:

interface ServiceMonitorBody {
  spec: {
    selector: {
      matchLabels: Record<string, string>;
    };
    endpoints: Array<{
      port: string;
      interval: string;
    }>;
  };
}

new TypedCustomResource<ServiceMonitorBody>(chart.getChart(), "ApiMonitor", {
  apiVersion: "monitoring.coreos.com/v1",
  kind: "ServiceMonitor",
  metadata: { name: "api-monitor" },
  body: {
    spec: {
      selector: { matchLabels: { app: "api" } },
      endpoints: [{ port: "metrics", interval: "30s" }],
    },
  },
});

apiVersion, kind, and metadata belong to the resource wrapper and must not be redefined in the body.

If your installed stable release does not contain TypedCustomResource, use a generated cdk8s API class. If neither exists, strict typed parity is not available yet; do not disguise an arbitrary object manifest as a typed migration.

6. Dynamic maps

Many production charts iterate environment-variable or secret maps:

{{- range $key, $value := .Values.env }}
- name: {{ $key }}
  value: {{ $value | quote }}
{{- end }}

In versions with typed map operations:

const env = v.env.rangeEntries((key, value) => ({
  name: key,
  value: value.quote(),
}));

Dynamic lookup stays typed:

const selected = v.env.index(v.selectedEnvKey);
const exists = v.env.hasKey(v.selectedEnvKey);

Timonel anchors values-map lookups to Helm's root context so they remain correct inside nested range or with scopes. Nested map ranges use distinct internal variables to avoid shadowing captured outer entries.

Older releases without these operations cannot provide strict typed parity for arbitrary dynamic map access.

7. Umbrella charts and dependencies

Separate three cases:

Timonel-generated child chart

Use a child Rutter and an UmbrellaRutter entry.

Remote dependency

For Timonel versions with dependency-only subcharts, declare the dependency metadata without creating a fake child Rutter:

{
  name: "ingress-nginx",
  version: "4.15.1",
  repository: "https://kubernetes.github.io/ingress-nginx",
  condition: "ingress-nginx.enabled",
}

Vendored dependency

Point the dependency at an existing chart directory using the vendored-source API available in the same feature set. Timonel copies the chart without regenerating it and rejects symlinks or source / destination overlap.

Do not create fake Kubernetes resources merely to satisfy an umbrella API. Stable versions that require a Rutter for every dependency do not yet offer strict typed parity for dependency-only subcharts.

8. files/ and arbitrary chart files

Helm .Files reads packaged chart files, not Kubernetes manifests. Do not encode these files as ConfigMaps just to make Timonel emit them unless that changes the chart intentionally.

In versions with chart-file assets, write each chart-relative file through the dedicated chart-file API. Destinations must stay inside the chart and must not collide with generated files.

Examples include:

files/config/default.json
files/dashboards/api.json
LICENSES/component.txt

If your stable Timonel version predates chart-file assets, arbitrary .Files content is a real packaging gap. Upgrade to a release containing that feature instead of adding raw manifest APIs.

9. Library charts and helpers

Use type: library plus HelmChartWriter.helpersTpl for shared named templates. Keep helpers for naming, labels, annotations, and reusable Helm fragments; do not hide complete Kubernetes resource YAML inside helpers.

See Type-Safe Helm Helpers for createHelper(), getDefaultHelpers(), helper aliasing, and library-chart validation.

10. Preserve complete Chart.yaml metadata

Do not migrate only name and version. Preserve every metadata field required by the original chart, especially:

appVersion
type
kubeVersion
keywords
home
sources
maintainers
icon
dependencies

Use the full Rutter chart metadata surface in versions that expose it. Older stable releases may require HelmChartWriter for metadata that Rutter does not yet expose. If the migration standard requires one typed Rutter path, upgrade rather than silently dropping metadata.

11. Preserve values schemas and defaults

Treat the existing values contract as public API:

  • preserve key names, including reserved-looking keys such as release or default;
  • use v.at("key") for ValuesRef API name collisions;
  • preserve default values and environment-specific values;
  • preserve values.schema.json validation;
  • render with the same realistic values used by production and CI.

Do not rename values solely to make migration code easier unless you intentionally accept a chart breaking change.

12. Production validation loop

A typed compile is necessary but not sufficient. Validate both TypeScript and Helm behavior for every migrated chart.

TypeScript / package checks

pnpm typecheck
pnpm build

For a Timonel contribution, also run the repository's pnpm ci:check and consumer declaration tests.

Helm syntax and rendering

helm lint ./dist/payments
helm template payments ./dist/payments \
  --values ./test-values/production.yaml

Run additional templates for meaningful branches:

feature enabled / disabled
autoscaling enabled / disabled
install / upgrade hooks
empty / populated maps
optional child chart enabled / disabled

Behavioral comparison

Compare the rendered output with the original chart by Kubernetes identity (apiVersion, kind, namespace, name), then compare meaningful fields rather than relying only on text ordering.

At minimum verify:

  • resource count and identity;
  • selectors/labels/annotations;
  • RBAC subjects and role rules;
  • workload images, ports, probes, volumes, and security context;
  • hook annotations and weights;
  • conditions and optional resources;
  • chart dependencies and packaged files;
  • values schema behavior.

Strict-parity release checklist

Before declaring a chart migrated without manifest fallbacks, all answers should be yes:

  • Every Kubernetes resource has a typed cdk8s/cdk8s-plus/Timonel representation.
  • No addManifest() or addTemplateManifest() remains.
  • No any or double-cast is used to force Helm references into primitive properties.
  • Dynamic values maps use a supported typed operation.
  • Custom resources use generated types or a typed extension construct.
  • External/vendored dependencies are represented without fake Rutters.
  • .Files inputs are packaged through a supported chart-file API.
  • Full Chart.yaml metadata is preserved.
  • Hooks and RBAC render equivalently.
  • pnpm typecheck and build checks pass.
  • helm lint passes.
  • helm template passes for representative production values.
  • Rendered manifests have been compared with the pre-migration baseline.

Version-boundary rule

Timonel evolves these typed boundaries incrementally. A feature may exist on main or in a canary before it reaches the stable npm tag. Always verify the API in the exact version your chart consumes.

If the installed release lacks a required typed capability, the strict migration is blocked on that capability. The correct response is to upgrade when the supporting release is available or defer that part of the migration—not to hide the gap with casts or raw templates.

Clone this wiki locally