Timonel is a TypeScript library for generating complete Helm charts programmatically on top of cdk8s and cdk8s-plus. Kubernetes resources stay typed in application code while Timonel handles Helm chart structure, values files, helpers, environment variants, policy validation, and final chart output.
The library API is the primary product. The tl CLI is a convenience layer for scaffolding,
synthesis, Helm validation, deployment, and umbrella-chart workflows.
Timonel is intentionally typed-first. Use Kubernetes resource APIs in this order:
cdk8s-plus-33when it already provides the resource abstraction;- generated cdk8s CRD constructs when an upstream schema is available;
TypedCustomResource<TBody>or another focused typed Timonel abstraction when no generated construct fits.
Rutter does not expose raw manifest ingestion APIs. addManifest(), addTemplateManifest(),
addConditionalManifest(), and raw synthesized-asset injection are intentionally absent. Helm
helpers remain available for names, labels, computed values, and reusable fragments, but Timonel
rejects helpers that embed complete Kubernetes manifests so resources cannot bypass the typed
cdk8s/cdk8s-plus path through _helpers.tpl.
- Node.js
^22.22.2,^24.15.0, or>=26.0.0 - pnpm
>=9 - Helm 3 when using
tl validate,tl deploy, or manual chart validation
pnpm add timonel cdk8s cdk8s-plus-33 constructsnpm and other compatible package managers can also install the package.
import * as kplus from 'cdk8s-plus-33';
import { Rutter } from 'timonel';
const chart = new Rutter({
meta: {
name: 'my-app',
version: '1.0.0',
description: 'Typed Helm chart generated by Timonel',
},
defaultValues: {
environment: 'production',
},
});
const deployment = new kplus.Deployment(chart.getChart(), 'App', {
metadata: { name: 'my-app' },
containers: [
{
name: 'app',
image: 'nginx:1.27',
portNumber: 80,
resources: {
cpu: { request: kplus.Cpu.millis(100) },
},
},
],
});
deployment.exposeViaService();
new kplus.HorizontalPodAutoscaler(chart.getChart(), 'AppHpa', {
target: deployment,
minReplicas: 1,
maxReplicas: 5,
});
await chart.write('./dist/my-app');The generated chart contains Chart.yaml, values.yaml, templates/, _helpers.tpl, and
.helmignore.
Validate the final Helm output:
helm lint ./dist/my-app
helm template my-app ./dist/my-appRutter owns a cdk8s Chart and converts its resource tree into Helm chart assets.
const chart = new Rutter({
meta: { name: 'orders', version: '1.0.0' },
namespace: 'orders',
defaultValues: {
replicas: 2,
},
envValues: {
production: {
replicas: 4,
},
},
});Useful methods include:
| API | Purpose |
|---|---|
getChart() |
Access the real cdk8s Chart for native typed constructs |
when(condition, resource) |
Conditionally render a complete typed resource |
bindHelmValue(resource, pointer, value) |
Bind a typed Helm value to a synthesized scalar field |
write(outDir) |
Synthesize and write the complete Helm chart |
toSynthArray() |
Asynchronously synthesize Helm assets |
getMeta() |
Read chart metadata |
getDefaultValues() |
Read default values |
getEnvValues() |
Read environment-specific values |
addChartFile(asset) |
Package an arbitrary non-manifest chart file |
getChartFiles() |
Read configured non-manifest chart files |
toSynthArraySync() remains for compatibility and is deprecated. It cannot be used with a policy
engine.
Use chartFiles or addChartFile() for non-manifest files that must ship inside the Helm chart,
such as JSON consumed through .Files.Get, scripts, dashboards, or binary assets:
const chart = new Rutter({
meta: { name: 'orders', version: '1.0.0' },
chartFiles: [
{
destination: 'files/service-inputs.json',
content: JSON.stringify({ queue: 'orders' }),
},
],
});
chart.addChartFile({
destination: 'files/apply.mjs',
content: 'export default true;\n',
});Destinations are chart-relative, traversal is rejected, and generated chart files such as
Chart.yaml cannot be overwritten. Uint8Array content is supported for binary files.
Pass scope when Timonel should participate in an existing cdk8s/constructs tree:
import { App } from 'cdk8s';
import * as kplus from 'cdk8s-plus-33';
import { Rutter } from 'timonel';
const app = new App();
const chart = new Rutter({
scope: app,
meta: { name: 'shared-tree', version: '1.0.0' },
});
new kplus.ConfigMap(chart.getChart(), 'Config', {
metadata: { name: 'shared-tree' },
data: { mode: 'production' },
});Gate a complete cdk8s/cdk8s-plus resource with a typed Helm values condition without switching to a manifest fallback:
import * as kplus from 'cdk8s-plus-33';
import { Rutter, valuesRef } from 'timonel';
const v = valuesRef<{ restart: { enabled: boolean } }>();
const chart = new Rutter({
meta: { name: 'orders', version: '1.0.0' },
defaultValues: { restart: { enabled: true } },
});
const account = new kplus.ServiceAccount(chart.getChart(), 'RestartAccount', {
metadata: { name: 'restart-manager' },
});
chart.when(v.restart.enabled, account);when() wraps the synthesized resource with a Helm if block while keeping the resource itself on
the typed construct path.
valuesRef<T>() exposes Helm values using the shape of a TypeScript type.
import { valuesRef } from 'timonel';
interface Values {
environment: string;
replicas: number;
image: {
repository: string;
tag: string;
};
autoscaling: {
enabled: boolean;
};
env: Array<{
name: string;
value: string;
}>;
envByName: Record<string, string>;
selectedEnvKey: string;
}
const v = valuesRef<Values>();
const imageTag = v.image.tag.quote();
const multipleReplicas = v.replicas.gt(1);
const production = v.environment.eq('production');
const fixedTag = v.image.tag.default('1.0.0');
const env = v.env.range((item) => ({
name: item.name,
value: item.value,
}));
const selectedEnv = v.envByName.index(v.selectedEnvKey);
const hasSelectedEnv = v.envByName.hasKey(v.selectedEnvKey);
const envEntries = v.envByName.rangeEntries((key, value) => ({
name: key,
value: value.quote(),
}));
const replicasField = v.replicas.if(v.autoscaling.enabled.not(), v.replicas);The compiler rejects value paths that do not exist in Values. String-keyed maps also support
rangeEntries(), typed index(), and hasKey() with either literal keys or HelmValueRef<string>
keys. Dynamic map lookups are anchored to Helm's root context, so they remain valid inside
range() and with() scopes.
Names used by the proxy API, such as default, range, and with, and root helpers such as
release, chart, and capabilities, are accessed with the typed at() method when those names
also exist in your values schema:
interface Values {
release: { name: string };
settings: { default: string };
}
const v = valuesRef<Values>();
const releaseName = v.at('release').name;
const defaultSetting = v.settings.at('default');at() accepts only keys from the current TypeScript values type.
When cdk8s-plus does not expose an extension API, keep normal construct usage with
TypedCustomResource<TBody> rather than falling back to a manifest body:
import { TypedCustomResource, valuesRef, type HelmExpression } from 'timonel';
interface ServiceMonitorBody {
spec: {
selector: { matchLabels: Record<string, string> };
endpoints: Array<{ port: string; interval: string | HelmExpression }>;
};
}
const v = valuesRef<{ monitoring: { interval: string } }>();
new TypedCustomResource<ServiceMonitorBody>(chart.getChart(), 'OrdersServiceMonitor', {
apiVersion: 'monitoring.coreos.com/v1',
kind: 'ServiceMonitor',
metadata: { name: 'orders' },
body: {
spec: {
selector: { matchLabels: { app: 'orders' } },
endpoints: [{ port: 'http', interval: v.monitoring.interval.toExpression() }],
},
},
});The generic body is preserved in the published declarations, so invalid CRD fields fail consumer
compilation. It also supports APIs with non-spec top-level fields, such as OpenShift SCC, by
modeling those fields in TBody.
For CRDs generated with cdk8s import, instantiate the generated construct directly under
chart.getChart(). Timonel discovers the resulting ApiObject during normal synthesis with no
adapter. Prefer generated CRD classes when an upstream schema is available; use
TypedCustomResource<TBody> when you own or maintain the TypeScript contract.
Prometheus Operator resources that previously existed as manifest-producing helpers now have typed
ServiceMonitor and PrometheusRule constructs exported by Timonel.
Do not switch a standard Kubernetes resource to raw YAML merely because one field contains Helm logic. Prefer typed constructs and Timonel's Helm-value helpers where they fit.
UmbrellaRutter combines generated Timonel subcharts, remote Helm dependencies, and
already-vendored local charts:
import * as kplus from 'cdk8s-plus-33';
import { Rutter, UmbrellaRutter } from 'timonel';
function serviceChart(name: string): Rutter {
const chart = new Rutter({
meta: { name, version: '1.0.0' },
});
new kplus.ConfigMap(chart.getChart(), 'Config', {
metadata: { name: `${name}-config` },
});
return chart;
}
const umbrella = new UmbrellaRutter({
meta: {
name: 'commerce',
version: '1.0.0',
},
subcharts: [
{ name: 'catalog', version: '1.0.0', rutter: serviceChart('catalog') },
{
name: 'ingress-nginx',
version: '4.15.1',
repository: 'https://kubernetes.github.io/ingress-nginx',
condition: 'ingress-nginx.enabled',
},
{
name: 'fluent-bit',
version: '1.2.3',
sourceDirectory: './vendor/fluent-bit',
condition: 'fluent-bit.enabled',
},
],
});
await umbrella.write('./dist/commerce');Entries with rutter are generated into charts/. Remote dependency-only entries are written only
to the parent Chart.yaml for Helm dependency tooling. Entries with sourceDirectory are copied
verbatim into charts/<name> without Timonel regenerating the third-party chart; symbolic links are
rejected during the copy to avoid packaging paths outside the vendored source tree.
The CLI also supports dependency and inline umbrella synthesis modes.
Timonel includes focused helpers for Kubernetes-side AWS integrations:
- EBS and EFS
StorageClassresources; - IRSA and ECR-oriented
ServiceAccountresources; - ALB
Ingressconfiguration; - Karpenter
NodePool,NodeClaim, andEC2NodeClassresources; - convenience NodePool builders for disruption and scheduling policies.
Example:
chart.addAWSIRSAServiceAccount({
name: 'orders',
roleArn: 'arn:aws:iam::123456789012:role/orders',
});These APIs generate Kubernetes resources. They do not create IAM roles, storage systems, ECR repositories, controllers, cluster networking, or Karpenter CRDs in AWS.
The optional Policy Engine validates synthesized Kubernetes manifests through user-supplied plugins before Timonel writes the Helm chart.
import type { PolicyPlugin } from 'timonel';
import { PolicyEngine, Rutter } from 'timonel';
const requireMetadataName: PolicyPlugin = {
name: 'require-metadata-name',
version: '1.0.0',
async validate(manifests) {
return manifests.flatMap((manifest) => {
if (!manifest || typeof manifest !== 'object') return [];
const object = manifest as {
kind?: string;
metadata?: { name?: string };
};
if (object.metadata?.name) return [];
return [
{
plugin: 'require-metadata-name',
severity: 'error' as const,
message: `${object.kind ?? 'Resource'} must have metadata.name`,
},
];
});
},
};
const policyEngine = new PolicyEngine({
timeout: 5000,
parallel: true,
});
await policyEngine.use(requireMetadataName);
const chart = new Rutter({
meta: { name: 'validated', version: '1.0.0' },
policyEngine,
});Policy options include plugin timeouts, retries, graceful degradation, caching, parallel execution, inline plugin configuration, schema validation, and environment-variable configuration.
ConfigurationLoader currently accepts file-related options, but file-backed policy configuration
loading is not implemented. Do not rely on configurationFiles to load plugin configuration from
disk in the current release.
The environment-variable loader reads YAML/JSON configuration and generates Kubernetes env
entries:
import { loadAndGenerateEnvVars } from 'timonel';
const env = loadAndGenerateEnvVars({
configPath: './env-config.yaml',
defaultScope: 'global.env',
});It can generate literal Helm values and Kubernetes secretKeyRef entries. It does not fetch values
from Vault, AWS Secrets Manager, or other external secret stores.
Run the locally installed CLI with pnpm exec tl or expose the package binary through your package
manager.
tl init <chart-name>
tl synth [chartDir] [outDir]
tl validate
tl deploy <release> [namespace]
tl templates
tl umbrella init <name>
tl umbrella add <subchart>
tl umbrella synth [outDir]
Common flags:
--dry-run
--silent
--env <environment>
--set <key=value>
--mode <dependencies|inline>
--help, -h
--dry-run validates the requested operation and reports what would happen without writing chart
files or invoking Helm. Combine it with --silent when the preview itself should also be suppressed.
tl validate and tl deploy execute the Helm CLI, so Helm must be installed and the current working
directory must point at the chart you intend to validate or deploy.
A normal chart written by Rutter looks like:
my-app/
├── Chart.yaml
├── values.yaml
├── values-production.yaml # when envValues.production exists
├── .helmignore
└── templates/
├── _helpers.tpl
├── App.yaml
├── AppHpa.yaml
└── ...
Resource filenames use stable cdk8s construct identifiers where possible.
The GitHub wiki contains the full documentation set:
- Home
- Quick Start
- Architecture
- API Reference
- Type-Safe Helm Helpers
- Umbrella Charts
- AWS Resources
- Policy Engine
- CLI Reference
- Migration Guide
- Contributing
- Release and Versioning
pnpm install --frozen-lockfile
pnpm ci:check
pnpm test:unit
pnpm test:integration
pnpm test:coverage
pnpm md:lint
pnpm doc:coverage:validate
pnpm security:audit
pnpm packContribution and agent rules are defined in AGENTS.md.
Development follows a trunk-based model centered on main:
- pull requests target
main; - successful main CI can publish an npm
canarybuild; - stable publication is an explicit workflow with production approval;
- npm publication uses Trusted Publishing/OIDC and provenance.
See the release guide for the current workflow and operational constraints.
MIT