From c616312ed90feae649649497a08688fc11fde461 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Thu, 27 Nov 2025 22:22:18 +0300 Subject: [PATCH 1/3] docs: add Go templates alternative proposal Signed-off-by: Ilya Lesikov --- docs/proposals/go-templates-alternative.md | 158 +++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/proposals/go-templates-alternative.md diff --git a/docs/proposals/go-templates-alternative.md b/docs/proposals/go-templates-alternative.md new file mode 100644 index 00000000..1a2dd30c --- /dev/null +++ b/docs/proposals/go-templates-alternative.md @@ -0,0 +1,158 @@ +# Feature: Go templates alternative + +## Why + +Go templates are fine for simple cases, but scale poorly: +1. Templating YAML (structured data, sensitive to whitespace) with text templating engine is hard and fundamentally wrong. YAML, as JSON, should be manipulated in a structured way. +1. Go templates are very primitive: even proper functions cannot be defined. +1. The standard Helm library is basic and cannot be extended by the end user. Third-party function libraries are not possible. +1. Lots of gotchas, like `{{ if (include "always-returns-false" .) }}` will always be true. +1. Debugging complex Helm templates is notoriously difficult. +1. Poor tooling support (IDEs, linters, etc.). +1. Issues with performance. +1. Issues with mutability of Values and more. + +## What + +Nelm should provide an alternative to Go templates for generating or templating Kubernetes manifests. + +Helm only supports Go templates. It also has post-renderers, but they are a poor fit for an alternative to Go templates, because: +1. They need to be shipped and installed as plugins. +1. Binaries might need to be installed separately. +1. They might have system dependencies. +1. They might require configuration. +1. Some clever magic needed to go into subcharts to get files they need to render manifests. This is because they know only about rendered manifests, not about charts or values. +1. There might be dozens of different post-renderers, each with its own way of doing things. It doesn't exactly help when a single release might require multiple different post-renderers for its subcharts. + +Helm succeeded because of Helm charts. And the success of Helm charts is in Go templating. Not because it's good, but because it's simple: no dependencies, no configuration, no alternative. + +Helm 2 made it possible to implement alternative templating engines in Helm core. [No one used it](https://github.com/helm/helm/issues/9855#issuecomment-867565430). + +Another option would be to package an application written in any language as a WASM binary, and treat it as a Helm chart basically. This WASM binary must accept values on stdin and return rendered manifests on stdout, so that Nelm will run it to render manifests. Even if this can be implemented, the issue stays: no one is going to work with charts in dozens of different languages. + +So what we do? We can provide an alternative to Go templates, but it must be: +1. Single language (or maybe two, at most), not dozens. +1. Embedded in Nelm. +1. No plugins. +1. No additional binaries. +1. No additional system dependencies. +1. No configuration. +1. Receive values and its source files as an input. +1. Return rendered manifests. +1. Work on a per-chart basis, respect chart dependencies. +1. Maintain feature parity with Go templates. + +## Possible solutions + +### Improving Go templates + +Rejected. + +Text templating of YAML is fundamentally broken. Too many aspects of Go templates cannot be fixed without breaking backward compatibility. Improving besides adding new functions is difficult. + +### Using another text templating engine + +Rejected. + +Using another text templating engine, e.g. Jinja, does not solve the fundamental issue of trying to template structured data in a non-structured way. Most other issues, like lack of third-party libraries and difficult debugging, still apply. + +### Using a configuration language + +Not decided yet. + +Specialized configuration languages, like Jsonnet or CUE, are designed to work with structured data. They solve some issues of Go templates, but not all of them, namely: +1. Still not as flexible as general-purpose programming languages (Jsonnet). Sometimes not Turing-complete (CUE). +1. Usually no support for third-party libraries or a very limited number of libraries. +1. Poor tooling support (IDEs/editors, etc.). +1. Often issues with debugging. +1. Often issues with performance. + +On top of this, configuration languages have their own drawbacks: +1. Some are weird, making adoption and onboarding difficult (CUE). +1. Generally poor adoption. +1. Small community, lack of learning resources. +1. Easily might end up abandoned. + +### Using a general-purpose language + +Preferred. + +In comparison to configuration languages, a popular general-purpose programming language like TypeScript has these advantages: +1. Very flexible and powerful. Good typing system. +1. Thousands of third-party libraries, including specialized (cdk8s, kubernetes client, etc.). +1. Great tooling support (IDEs/editors, linters, formatters, etc.). +1. Alright dependency management. +1. Good, stable performance. +1. Conventional, not weird (like CUE). +1. Easy debugging. +1. Easy testing. +1. Mature, proven. +1. Wide adoption. +1. Big community, lots of learning resources. +1. Not going to be abandoned any time soon. +1. Useful skill to learn in general. Can be used for other purposes. + +Cons: +1. More difficult to learn if no prior programming experience. +1. Can be too flexible, making things complicated. +1. Hermeticity and determinism not guaranteed. Require skills, discipline, tooling. +1. Complicated tooling, e.g. package managers and build systems. +1. Less secure, less isolated (mitigated by WASM). + +### Currently preferred solution + +It seems that if you need to pick only one alternative to Go templates, a general-purpose language will provide more value. Configuration languages suffer from many of the same issues as Go templates. + +If you look at this in terms of scalability, then: +* Go templates are poorly scalable, but conventional, widely adopted and easy to use for simple cases. +* Configuration languages are moderately scalable, but unconventional, poorly adopted and more complicated. +* General-purpose languages are highly scalable, conventional, widely adopted, but the most complicated. + +Makes more sense to provide very scalable + non-scalable options, rather than moderately scalable + non-scalable. This way users can start easy (Helm templates) and then move to highly scalable (TypeScript) when they really struggle. Any such migration is a big resource sink, so I honestly don't see that much value in migrating to Jsonnet or CUE when the user already does everything in Helm templates. + +We evaluated TypeScript, and it seems fit (pros and cons listed above). + +## Specification + +The chart structure with TypeScript support will look like this: +``` +Chart.yaml +values.yaml +templates/ +ts/ + package.json + tsconfig.json + node_modules/ + vendor/ + src/ + index.ts + deployment.ts + service.ts +``` + +Here the only change is that the new `ts` directory is added. This directory basically represents a TypeScript application. The application must accept Helm root context data (values, chart info, etc.) as input and return rendered manifests as output. The application must be possible to run with `npm start`. + +`node_modules` directory must be in .gitignore. During chart publishing all dependencies must be bundled into `vendor/libs.js`, which can be done with esbuild, which is to be embedded in Nelm. + +Nelm must include Wazero (WASM runtime), QuickJS (JS runtime) and [esbuild](https://github.com/evanw/esbuild) (JS transpiler and bundler). [QJS](https://github.com/fastschema/qjs) might work for Wazero + QuickJS. + +Development workflow: +1. NodeJS, NPM and Nelm must be installed for local development. +1. Command `nelm chart ts init .` creates `ts` directory with boilerplate files. +1. `ts` directory opened as a TypeScript NodeJS project in IDE/editor. +1. Work as you would with a TypeScript project. +1. Run `npm TODO` to execute application with NodeJS runtime and render manifests to stdout. +1. Run `npm TODO` to run tests. +1. Run `nelm chart render` to render manifests with the QuickJS runtime. +1. Run `nelm chart upload` to publish the chart. Nelm will bundle dependencies into `vendor/libs.js` during publishing with embedded esbuild. + +Deployment workflow: +1. Only Nelm must be installed. No NodeJS, QuickJS, npm, esbuild or anything else needed. +1. Command `nelm release install myrepo/mychart` installs previously published chart as usual. +1. Under the hood, Nelm will grab dependencies from `ts/vendor` and transpile `ts/src/index.ts` to JS with embedded esbuild, then pass JS code to embedded QuickJS runtime working in WASM via Wazero. This will render manifests that will be appended to templated manifests from `templates` directory. Then everything will be deployed as usual. + +QuickJS probably doesn't have the best tooling support, so you are going to develop with NodeJS. But QuickJS doesn't support everything NodeJS does. Mitigation: `nelm chart render/lint` will help with testing the code under QuickJS. + +WASM has capabilities for network/fs isolation for better security and reproducibility. + +## Links From 21af6599af860b244d479789ca330e9bbe56c2a3 Mon Sep 17 00:00:00 2001 From: ZverGuy Date: Mon, 1 Dec 2025 01:41:04 +0300 Subject: [PATCH 2/3] docs: add TypeScript charts specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detailed specification for TypeScript as Go templates alternative: - HelmContext API and types - SDK package structure - Development and deployment workflow - CLI commands - Design decisions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../go-template-alternative/README.md | 45 ++++ docs/proposals/go-template-alternative/api.md | 241 ++++++++++++++++++ docs/proposals/go-template-alternative/cli.md | 100 ++++++++ .../go-template-alternative/decisions.md | 114 +++++++++ docs/proposals/go-template-alternative/sdk.md | 204 +++++++++++++++ .../go-template-alternative/workflow.md | 219 ++++++++++++++++ 6 files changed, 923 insertions(+) create mode 100644 docs/proposals/go-template-alternative/README.md create mode 100644 docs/proposals/go-template-alternative/api.md create mode 100644 docs/proposals/go-template-alternative/cli.md create mode 100644 docs/proposals/go-template-alternative/decisions.md create mode 100644 docs/proposals/go-template-alternative/sdk.md create mode 100644 docs/proposals/go-template-alternative/workflow.md diff --git a/docs/proposals/go-template-alternative/README.md b/docs/proposals/go-template-alternative/README.md new file mode 100644 index 00000000..5c516766 --- /dev/null +++ b/docs/proposals/go-template-alternative/README.md @@ -0,0 +1,45 @@ +# TypeScript Charts for Nelm + +Alternative to Go templates for generating Kubernetes manifests. + +## Documents + +- [decisions.md](./decisions.md) — Accepted design decisions +- [api.md](./api.md) — HelmContext API and types +- [sdk.md](./sdk.md) — @nelm/sdk package structure +- [workflow.md](./workflow.md) — Development and deployment workflow +- [cli.md](./cli.md) — CLI commands + +## Overview + +TypeScript charts provide a type-safe, scalable alternative to Go templates while maintaining Helm compatibility. + +```typescript +import { HelmContext, Manifest } from '@nelm/sdk' +import { Values } from './values.types' + +export default function render(ctx: HelmContext): Manifest[] { + return [ + { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + }, + spec: { + replicas: ctx.Values.replicas, + // ... + }, + }, + ] +} +``` + +## Key Principles + +1. **Pure functions** — `render(ctx) → Manifest[]` +2. **Explicit context** — everything via `ctx`, no globals +3. **Isolation** — subcharts render independently +4. **Type safety** — Values types generated from schema +5. **No magic** — predictable, testable code diff --git a/docs/proposals/go-template-alternative/api.md b/docs/proposals/go-template-alternative/api.md new file mode 100644 index 00000000..63d11d77 --- /dev/null +++ b/docs/proposals/go-template-alternative/api.md @@ -0,0 +1,241 @@ +# HelmContext API + +## Main Interface + +```typescript +interface HelmContext { + // Data + Values: V + Release: Release + Chart: Chart + Capabilities: Capabilities + Files: Files + + // Functions (injected from Go) + lookup(apiVersion: string, kind: string, namespace: string, name: string): T | null + + // Serialization + toYaml(obj: unknown): string + fromYaml(str: string): T + toJson(obj: unknown): string + fromJson(str: string): T + + // Encoding + b64encode(str: string): string + b64decode(str: string): string + + // Hashing + sha256(str: string): string + sha1(str: string): string + md5(str: string): string + + // String manipulation (Helm-compatible) + indent(str: string, spaces: number): string + nindent(str: string, spaces: number): string + trim(str: string): string + trimPrefix(str: string, prefix: string): string + trimSuffix(str: string, suffix: string): string + upper(str: string): string + lower(str: string): string + title(str: string): string + quote(str: string): string + squote(str: string): string + + // ... other Helm helpers +} +``` + +## Release + +```typescript +interface Release { + Name: string + Namespace: string + IsUpgrade: boolean + IsInstall: boolean + Revision: number + Service: string // "Helm" or "Nelm" +} +``` + +## Chart + +```typescript +interface Chart { + Name: string + Version: string + AppVersion: string + Description: string + Keywords: string[] + Home: string + Sources: string[] + Icon: string + Deprecated: boolean + Type: string // "application" or "library" +} +``` + +## Capabilities + +```typescript +interface Capabilities { + KubeVersion: KubeVersion + APIVersions: APIVersions + HelmVersion: HelmVersion +} + +interface KubeVersion { + Major: string + Minor: string + GitVersion: string // e.g., "v1.28.3" + + // Semver comparison helpers + gte(version: string): boolean + gt(version: string): boolean + lte(version: string): boolean + lt(version: string): boolean + eq(version: string): boolean +} + +interface APIVersions { + list: string[] + has(apiVersion: string): boolean +} + +interface HelmVersion { + Version: string + GitCommit: string + GoVersion: string +} +``` + +## Files + +```typescript +interface Files { + get(path: string): string + getBytes(path: string): Uint8Array + glob(pattern: string): Map // path -> content + lines(path: string): string[] + asConfig(pattern?: string): Record + asSecrets(pattern?: string): Record // base64 encoded +} +``` + +## Manifest + +```typescript +interface Manifest { + apiVersion: string + kind: string + metadata: ObjectMeta + [key: string]: unknown +} + +interface ObjectMeta { + name: string + namespace?: string + labels?: Record + annotations?: Record + ownerReferences?: OwnerReference[] + finalizers?: string[] +} +``` + +## Usage Example + +```typescript +import { HelmContext, Manifest } from '@nelm/sdk' +import { Values } from './values.types' +import { when } from '@nelm/sdk' + +export default function render(ctx: HelmContext): Manifest[] { + const labels = { + 'app.kubernetes.io/name': ctx.Chart.Name, + 'app.kubernetes.io/instance': ctx.Release.Name, + 'app.kubernetes.io/version': ctx.Chart.AppVersion, + } + + return [ + // Deployment + { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + labels, + }, + spec: { + replicas: ctx.Values.replicas, + selector: { matchLabels: labels }, + template: { + metadata: { labels }, + spec: { + containers: [{ + name: ctx.Chart.Name, + image: `${ctx.Values.image.repository}:${ctx.Values.image.tag}`, + }], + }, + }, + }, + }, + + // Service + { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + labels, + }, + spec: { + selector: labels, + ports: [{ port: 80, targetPort: 8080 }], + }, + }, + + // Conditional Ingress + ...when(ctx.Values.ingress.enabled, [{ + apiVersion: 'networking.k8s.io/v1', + kind: 'Ingress', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + }, + spec: { + rules: [{ + host: ctx.Values.ingress.host, + http: { + paths: [{ + path: '/', + pathType: 'Prefix', + backend: { + service: { + name: ctx.Release.Name, + port: { number: 80 }, + }, + }, + }], + }, + }], + }, + }]), + + // Conditional: check if CRD exists + ...when(ctx.Capabilities.APIVersions.has('monitoring.coreos.com/v1'), [{ + apiVersion: 'monitoring.coreos.com/v1', + kind: 'ServiceMonitor', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + }, + spec: { + selector: { matchLabels: labels }, + endpoints: [{ port: 'http' }], + }, + }]), + ] +} +``` diff --git a/docs/proposals/go-template-alternative/cli.md b/docs/proposals/go-template-alternative/cli.md new file mode 100644 index 00000000..bbd7d260 --- /dev/null +++ b/docs/proposals/go-template-alternative/cli.md @@ -0,0 +1,100 @@ +# CLI Commands + +## TypeScript Chart Commands + +### nelm chart ts init + +Initialize TypeScript support in a chart. + +```bash +nelm chart ts init [path] +``` + +**Arguments:** +- `path` — Chart directory (default: current directory) + +**Creates:** +- `ts/package.json` +- `ts/tsconfig.json` +- `ts/src/index.ts` + +**Output:** +``` +Created ts/package.json +Created ts/tsconfig.json +Created ts/src/index.ts + +Next steps: + cd ts + npm install + npm run generate-types # if values.schema.json exists +``` + +### nelm chart render + +Render chart manifests (Go templates + TypeScript). + +```bash +nelm chart render [path] [flags] +``` + +**Flags:** +- `--values, -f` — Values file +- `--set` — Set values on command line +- `--output, -o` — Output format (yaml, json) + +**Behavior:** +1. Renders Go templates (if `templates/` exists) +2. Renders TypeScript (if `ts/` exists) +3. Combines and outputs manifests + +### nelm chart publish + +Package and publish chart to registry. + +```bash +nelm chart publish [path] [flags] +``` + +**Behavior:** +1. Bundles TypeScript with esbuild → `ts/vendor/bundle.js` +2. Packages chart +3. Uploads to registry + +## Existing Commands (unchanged) + +These commands work with both Go templates and TypeScript charts: + +```bash +nelm release install [flags] +nelm release upgrade [flags] +nelm release uninstall [flags] +nelm release list [flags] +``` + +## Example Session + +```bash +# Create new chart +mkdir mychart && cd mychart +nelm chart create . + +# Add TypeScript support +nelm chart ts init . + +# Install dependencies +cd ts && npm install + +# Generate types from schema +npm run generate-types + +# Develop... +# Edit src/index.ts + +# Test render +cd .. +nelm chart render . --values my-values.yaml + +# Publish +nelm chart publish . --repo myrepo +``` diff --git a/docs/proposals/go-template-alternative/decisions.md b/docs/proposals/go-template-alternative/decisions.md new file mode 100644 index 00000000..c129f8f6 --- /dev/null +++ b/docs/proposals/go-template-alternative/decisions.md @@ -0,0 +1,114 @@ +# Design Decisions + +## 1. JS Runtime + +**Decision:** Use quickjs-go (CGO bindings) instead of WASM. + +**Rationale:** +- Simpler architecture +- No need for Wazero +- CGO is acceptable for Nelm + +## 2. Isolation + +**Decision:** No network/fs access in JS context. + +**Rationale:** +- Security +- Reproducibility +- Only Go-injected functions for external access (lookup, Files) + +## 3. Render API + +**Decision:** Return-based, not emit-based. + +```typescript +// Chosen approach +export default function render(ctx: HelmContext): Manifest[] { + return [manifest1, manifest2, ...] +} +``` + +**Rationale:** +- Clear, explicit output +- Easy to see what's being created +- Better readability +- Predictable + +**Helper for conditionals:** +```typescript +import { when } from '@nelm/sdk' + +return [ + createDeployment(ctx), + ...when(ctx.Values.ingress.enabled, [ + createIngress(ctx), + ]), +] +``` + +## 4. Context Design + +**Decision:** Everything in `ctx`, no imports for runtime functions. + +```typescript +// All via ctx +ctx.Values +ctx.Release +ctx.Files.get("config.ini") +ctx.lookup("v1", "Secret", "default", "name") +ctx.toYaml(obj) +ctx.b64encode("data") +``` + +**Rationale:** +- Single source of truth +- Easy to mock in tests +- Clear what comes from outside +- Consistent with Helm (`.Values`, `.Files`, etc.) + +## 5. Subcharts + +**Decision:** Full isolation, no cross-chart access. + +**Rationale:** +- Pure functions: each chart `Context → Manifest[]` +- Nelm orchestrates, charts don't know about each other +- Any combination of Go templates + TS works +- Predictable, testable + +## 6. SDK Structure + +**Decision:** SDK provides only types + `when()` helper. + +**Rationale:** +- All runtime functions injected by Go (toYaml, b64encode, sha256, etc.) +- SDK is essentially devDependency (types only) +- Minimal bundle size + +## 7. Values Type Generation + +**Decision:** Via npm script using json-schema-to-typescript. + +```json +{ + "scripts": { + "generate-types": "json2ts ../values.schema.json -o src/values.types.ts" + } +} +``` + +**Rationale:** +- Node.js already required for development +- No need to embed in Nelm +- Proven library +- Developer controls when to regenerate + +## 8. Helm Helpers Source + +**Decision:** All Helm-equivalent helpers (toYaml, b64encode, sha256, etc.) provided by Go. + +**Rationale:** +- Single serializer (Go YAML lib) for consistency +- Fewer JS dependencies +- Sync functions (no async issues) diff --git a/docs/proposals/go-template-alternative/sdk.md b/docs/proposals/go-template-alternative/sdk.md new file mode 100644 index 00000000..b646ba54 --- /dev/null +++ b/docs/proposals/go-template-alternative/sdk.md @@ -0,0 +1,204 @@ +# @nelm/sdk Package + +## Overview + +Minimal SDK providing TypeScript types and one helper function. All runtime functions are injected by Nelm (Go) into the JS context. + +## Package Structure + +``` +@nelm/sdk/ + package.json + index.ts + index.d.ts + types/ + context.ts # HelmContext, Release, Chart, etc. + manifest.ts # Manifest, ObjectMeta + capabilities.ts + files.ts +``` + +## Source Code + +### index.ts + +```typescript +// Re-export all types +export * from './types/context' +export * from './types/manifest' +export * from './types/capabilities' +export * from './types/files' + +// The only runtime helper +export function when(condition: boolean, items: T[]): T[] { + return condition ? items : [] +} +``` + +### types/context.ts + +```typescript +import { Capabilities } from './capabilities' +import { Files } from './files' + +export interface HelmContext { + Values: V + Release: Release + Chart: Chart + Capabilities: Capabilities + Files: Files + + // Functions injected from Go + lookup(apiVersion: string, kind: string, namespace: string, name: string): T | null + toYaml(obj: unknown): string + fromYaml(str: string): T + toJson(obj: unknown): string + fromJson(str: string): T + b64encode(str: string): string + b64decode(str: string): string + sha256(str: string): string + sha1(str: string): string + md5(str: string): string + indent(str: string, spaces: number): string + nindent(str: string, spaces: number): string + trim(str: string): string + trimPrefix(str: string, prefix: string): string + trimSuffix(str: string, suffix: string): string + upper(str: string): string + lower(str: string): string + title(str: string): string + quote(str: string): string + squote(str: string): string +} + +export interface Release { + Name: string + Namespace: string + IsUpgrade: boolean + IsInstall: boolean + Revision: number + Service: string +} + +export interface Chart { + Name: string + Version: string + AppVersion: string + Description: string + Keywords: string[] + Home: string + Sources: string[] + Icon: string + Deprecated: boolean + Type: string +} +``` + +### types/capabilities.ts + +```typescript +export interface Capabilities { + KubeVersion: KubeVersion + APIVersions: APIVersions + HelmVersion: HelmVersion +} + +export interface KubeVersion { + Major: string + Minor: string + GitVersion: string + + gte(version: string): boolean + gt(version: string): boolean + lte(version: string): boolean + lt(version: string): boolean + eq(version: string): boolean +} + +export interface APIVersions { + list: string[] + has(apiVersion: string): boolean +} + +export interface HelmVersion { + Version: string + GitCommit: string + GoVersion: string +} +``` + +### types/files.ts + +```typescript +export interface Files { + get(path: string): string + getBytes(path: string): Uint8Array + glob(pattern: string): Map + lines(path: string): string[] + asConfig(pattern?: string): Record + asSecrets(pattern?: string): Record +} +``` + +### types/manifest.ts + +```typescript +export interface Manifest { + apiVersion: string + kind: string + metadata: ObjectMeta + [key: string]: unknown +} + +export interface ObjectMeta { + name: string + namespace?: string + labels?: Record + annotations?: Record + ownerReferences?: OwnerReference[] + finalizers?: string[] +} + +export interface OwnerReference { + apiVersion: string + kind: string + name: string + uid: string + controller?: boolean + blockOwnerDeletion?: boolean +} +``` + +## package.json + +```json +{ + "name": "@nelm/sdk", + "version": "1.0.0", + "description": "TypeScript SDK for Nelm charts", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "typescript": "^5.0.0" + }, + "keywords": ["nelm", "helm", "kubernetes", "typescript"], + "license": "Apache-2.0" +} +``` + +## Distribution + +Published to npm as `@nelm/sdk`. + +Developers install as devDependency since it's primarily types: + +```bash +npm install --save-dev @nelm/sdk +``` diff --git a/docs/proposals/go-template-alternative/workflow.md b/docs/proposals/go-template-alternative/workflow.md new file mode 100644 index 00000000..3cc0d2d2 --- /dev/null +++ b/docs/proposals/go-template-alternative/workflow.md @@ -0,0 +1,219 @@ +# Development and Deployment Workflow + +## Chart Structure + +``` +mychart/ + Chart.yaml + values.yaml + values.schema.json # Optional, for type generation + templates/ # Go templates (optional) + ts/ # TypeScript source + package.json + tsconfig.json + node_modules/ # .gitignore + src/ + index.ts # Entry point + values.types.ts # Generated from schema + deployment.ts # Helper modules + service.ts + vendor/ + bundle.js # Bundled for distribution +``` + +## Development Workflow + +### 1. Initialize TypeScript in Chart + +```bash +cd mychart +nelm chart ts init . +``` + +Creates: +``` +ts/ + package.json + tsconfig.json + src/ + index.ts +``` + +Output: +``` +Created ts/package.json +Created ts/tsconfig.json +Created ts/src/index.ts + +Next steps: + cd ts + npm install + npm run generate-types # if values.schema.json exists +``` + +### 2. Install Dependencies + +```bash +cd ts +npm install +``` + +### 3. Generate Types (if schema exists) + +```bash +npm run generate-types +``` + +Creates `src/values.types.ts` from `../values.schema.json`. + +### 4. Develop + +Edit `src/index.ts` and other modules. IDE provides full TypeScript support. + +```typescript +import { HelmContext, Manifest, when } from '@nelm/sdk' +import { Values } from './values.types' + +export default function render(ctx: HelmContext): Manifest[] { + return [ + // ... + ] +} +``` + +### 5. Test Locally with Node.js + +```bash +npm run dev +# Executes with Node.js, outputs manifests to stdout +``` + +### 6. Test with QuickJS (Nelm) + +```bash +nelm chart render . +# Uses embedded QuickJS runtime +``` + +### 7. Type Check + +```bash +npm run typecheck +# tsc --noEmit +``` + +## Publishing Workflow + +### 1. Bundle for Distribution + +```bash +nelm chart publish . +``` + +Nelm internally runs: +```bash +esbuild ts/src/index.ts --bundle --outfile=ts/vendor/bundle.js --format=esm +``` + +### 2. Upload to Registry + +Chart is uploaded with `ts/vendor/bundle.js` included. + +`node_modules/` is NOT included (in .gitignore). + +## Deployment Workflow + +### 1. Install Chart + +```bash +nelm release install myrepo/mychart +``` + +### 2. Nelm Renders + +Under the hood: +1. Nelm loads `ts/vendor/bundle.js` +2. Passes context (Values, Release, etc.) to QuickJS +3. Executes `render(ctx)` +4. Receives manifests array +5. Serializes to YAML +6. Combines with Go templates output (if any) +7. Deploys to cluster + +**No Node.js required on deployment machine.** + +## Generated Files + +### package.json + +```json +{ + "name": "mychart-ts", + "private": true, + "type": "module", + "scripts": { + "generate-types": "json2ts ../values.schema.json -o src/values.types.ts", + "typecheck": "tsc --noEmit", + "dev": "tsx src/index.ts", + "build": "esbuild src/index.ts --bundle --outfile=vendor/bundle.js --format=esm" + }, + "devDependencies": { + "@nelm/sdk": "^1.0.0", + "typescript": "^5.0.0", + "json-schema-to-typescript": "^15.0.0", + "tsx": "^4.0.0" + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} +``` + +### src/index.ts (template) + +```typescript +import { HelmContext, Manifest, when } from '@nelm/sdk' +// import { Values } from './values.types' // Uncomment after generate-types + +type Values = Record // Remove after generate-types + +export default function render(ctx: HelmContext): Manifest[] { + return [ + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + }, + data: { + example: 'value', + }, + }, + ] +} +``` + +## .gitignore Additions + +```gitignore +ts/node_modules/ +``` + +Note: `ts/vendor/bundle.js` IS committed for published charts. From e0c620c65570b2de58ef2d7459c8ef09fe3932f4 Mon Sep 17 00:00:00 2001 From: ZverGuy Date: Mon, 1 Dec 2025 18:43:06 +0300 Subject: [PATCH 3/3] docs: update TS charts proposal with PR feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key changes: - Switch from quickjs-go to goja (pure Go, no CGO) - Add data mechanism for external data fetching - Remove lookup from render phase (deterministic renders) - Remove helper functions (users use npm libraries) - Add esbuild embedded in Nelm notes - Add @nelm/types package structure with K8s types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../go-template-alternative/README.md | 57 ++- docs/proposals/go-template-alternative/api.md | 154 ++++---- docs/proposals/go-template-alternative/cli.md | 8 +- .../go-template-alternative/data-mechanism.md | 367 ++++++++++++++++++ .../go-template-alternative/decisions.md | 139 ++++--- docs/proposals/go-template-alternative/sdk.md | 294 +++++++------- .../go-template-alternative/workflow.md | 113 +++--- 7 files changed, 767 insertions(+), 365 deletions(-) create mode 100644 docs/proposals/go-template-alternative/data-mechanism.md diff --git a/docs/proposals/go-template-alternative/README.md b/docs/proposals/go-template-alternative/README.md index 5c516766..c6945c06 100644 --- a/docs/proposals/go-template-alternative/README.md +++ b/docs/proposals/go-template-alternative/README.md @@ -6,40 +6,59 @@ Alternative to Go templates for generating Kubernetes manifests. - [decisions.md](./decisions.md) — Accepted design decisions - [api.md](./api.md) — HelmContext API and types -- [sdk.md](./sdk.md) — @nelm/sdk package structure +- [sdk.md](./sdk.md) — npm packages and types - [workflow.md](./workflow.md) — Development and deployment workflow - [cli.md](./cli.md) — CLI commands +- [data-mechanism.md](./data-mechanism.md) — External data fetching ## Overview TypeScript charts provide a type-safe, scalable alternative to Go templates while maintaining Helm compatibility. ```typescript -import { HelmContext, Manifest } from '@nelm/sdk' -import { Values } from './values.types' +import { HelmContext, Manifest } from '@nelm/types' +import { Deployment } from '@nelm/types/apps/v1' +import { Values } from './generated/values.types' export default function render(ctx: HelmContext): Manifest[] { - return [ - { - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { - name: ctx.Release.Name, - namespace: ctx.Release.Namespace, - }, - spec: { - replicas: ctx.Values.replicas, - // ... + var deployment: Deployment = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + }, + spec: { + replicas: ctx.Values.replicas, + selector: { matchLabels: { app: ctx.Release.Name } }, + template: { + metadata: { labels: { app: ctx.Release.Name } }, + spec: { + containers: [{ + name: 'app', + image: ctx.Values.image.repository + ':' + ctx.Values.image.tag, + }], + }, }, }, - ] + } + + return [deployment] } ``` ## Key Principles 1. **Pure functions** — `render(ctx) → Manifest[]` -2. **Explicit context** — everything via `ctx`, no globals -3. **Isolation** — subcharts render independently -4. **Type safety** — Values types generated from schema -5. **No magic** — predictable, testable code +2. **Deterministic** — no network/fs in render, external data via data mechanism +3. **Type safety** — types from `@nelm/types` + generators +4. **Isolation** — subcharts render independently +5. **ES5 target** — goja compatibility, no async/await + +## npm Packages + +| Package | Purpose | +|---------|---------| +| `@nelm/types` | HelmContext, Manifest, K8s resources | +| `@nelm/crd-to-ts` | Generate types from CRD | +| `json-schema-to-typescript` | Generate Values types | diff --git a/docs/proposals/go-template-alternative/api.md b/docs/proposals/go-template-alternative/api.md index 63d11d77..b12ca7ae 100644 --- a/docs/proposals/go-template-alternative/api.md +++ b/docs/proposals/go-template-alternative/api.md @@ -3,48 +3,19 @@ ## Main Interface ```typescript -interface HelmContext { - // Data +interface HelmContext { + // Data only, no functions Values: V Release: Release Chart: Chart Capabilities: Capabilities Files: Files - - // Functions (injected from Go) - lookup(apiVersion: string, kind: string, namespace: string, name: string): T | null - - // Serialization - toYaml(obj: unknown): string - fromYaml(str: string): T - toJson(obj: unknown): string - fromJson(str: string): T - - // Encoding - b64encode(str: string): string - b64decode(str: string): string - - // Hashing - sha256(str: string): string - sha1(str: string): string - md5(str: string): string - - // String manipulation (Helm-compatible) - indent(str: string, spaces: number): string - nindent(str: string, spaces: number): string - trim(str: string): string - trimPrefix(str: string, prefix: string): string - trimSuffix(str: string, suffix: string): string - upper(str: string): string - lower(str: string): string - title(str: string): string - quote(str: string): string - squote(str: string): string - - // ... other Helm helpers + Data: D // Results from data() phase } ``` +**Note:** No helper functions in ctx. Define your own as needed. + ## Release ```typescript @@ -88,18 +59,10 @@ interface KubeVersion { Major: string Minor: string GitVersion: string // e.g., "v1.28.3" - - // Semver comparison helpers - gte(version: string): boolean - gt(version: string): boolean - lte(version: string): boolean - lt(version: string): boolean - eq(version: string): boolean } interface APIVersions { list: string[] - has(apiVersion: string): boolean } interface HelmVersion { @@ -115,13 +78,25 @@ interface HelmVersion { interface Files { get(path: string): string getBytes(path: string): Uint8Array - glob(pattern: string): Map // path -> content + glob(pattern: string): Record // path -> content lines(path: string): string[] - asConfig(pattern?: string): Record - asSecrets(pattern?: string): Record // base64 encoded } ``` +## Data (from data mechanism) + +```typescript +type DataResults = Record + +type DataResult = + | KubernetesResource + | KubernetesList + | boolean + | null +``` + +See [data-mechanism.md](./data-mechanism.md) for details. + ## Manifest ```typescript @@ -145,58 +120,65 @@ interface ObjectMeta { ## Usage Example ```typescript -import { HelmContext, Manifest } from '@nelm/sdk' -import { Values } from './values.types' -import { when } from '@nelm/sdk' +import { HelmContext, Manifest } from '@nelm/types' +import { Deployment } from '@nelm/types/apps/v1' +import { Service } from '@nelm/types/core/v1' +import { Values } from './generated/values.types' + +// User-defined helper +function when(condition: boolean, items: T[]): T[] { + return condition ? items : [] +} export default function render(ctx: HelmContext): Manifest[] { - const labels = { + var labels = { 'app.kubernetes.io/name': ctx.Chart.Name, 'app.kubernetes.io/instance': ctx.Release.Name, 'app.kubernetes.io/version': ctx.Chart.AppVersion, } - return [ - // Deployment - { - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { - name: ctx.Release.Name, - namespace: ctx.Release.Namespace, - labels, - }, - spec: { - replicas: ctx.Values.replicas, - selector: { matchLabels: labels }, - template: { - metadata: { labels }, - spec: { - containers: [{ - name: ctx.Chart.Name, - image: `${ctx.Values.image.repository}:${ctx.Values.image.tag}`, - }], - }, + var deployment: Deployment = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + labels: labels, + }, + spec: { + replicas: ctx.Values.replicas, + selector: { matchLabels: labels }, + template: { + metadata: { labels: labels }, + spec: { + containers: [{ + name: ctx.Chart.Name, + image: ctx.Values.image.repository + ':' + ctx.Values.image.tag, + }], }, }, }, + } - // Service - { - apiVersion: 'v1', - kind: 'Service', - metadata: { - name: ctx.Release.Name, - namespace: ctx.Release.Namespace, - labels, - }, - spec: { - selector: labels, - ports: [{ port: 80, targetPort: 8080 }], - }, + var service: Service = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + labels: labels, + }, + spec: { + selector: labels, + ports: [{ port: 80, targetPort: 8080 }], }, + } + + return [ + deployment, + service, - // Conditional Ingress + // Conditional based on values ...when(ctx.Values.ingress.enabled, [{ apiVersion: 'networking.k8s.io/v1', kind: 'Ingress', @@ -223,8 +205,8 @@ export default function render(ctx: HelmContext): Manifest[] { }, }]), - // Conditional: check if CRD exists - ...when(ctx.Capabilities.APIVersions.has('monitoring.coreos.com/v1'), [{ + // Conditional based on data mechanism + ...when(ctx.Data.serviceMonitorCRDExists === true, [{ apiVersion: 'monitoring.coreos.com/v1', kind: 'ServiceMonitor', metadata: { diff --git a/docs/proposals/go-template-alternative/cli.md b/docs/proposals/go-template-alternative/cli.md index bbd7d260..9fea3b8a 100644 --- a/docs/proposals/go-template-alternative/cli.md +++ b/docs/proposals/go-template-alternative/cli.md @@ -27,7 +27,7 @@ Created ts/src/index.ts Next steps: cd ts npm install - npm run generate-types # if values.schema.json exists + npm run generate:values # if values.schema.json exists ``` ### nelm chart render @@ -57,10 +57,12 @@ nelm chart publish [path] [flags] ``` **Behavior:** -1. Bundles TypeScript with esbuild → `ts/vendor/bundle.js` +1. Bundles TypeScript with embedded esbuild → `ts/vendor/bundle.js` 2. Packages chart 3. Uploads to registry +**Note:** esbuild is embedded in Nelm CLI. + ## Existing Commands (unchanged) These commands work with both Go templates and TypeScript charts: @@ -86,7 +88,7 @@ nelm chart ts init . cd ts && npm install # Generate types from schema -npm run generate-types +npm run generate:values # Develop... # Edit src/index.ts diff --git a/docs/proposals/go-template-alternative/data-mechanism.md b/docs/proposals/go-template-alternative/data-mechanism.md new file mode 100644 index 00000000..60aaf36f --- /dev/null +++ b/docs/proposals/go-template-alternative/data-mechanism.md @@ -0,0 +1,367 @@ +# Data Mechanism Proposal + +## Overview + +Mechanism for fetching external data BEFORE render phase, keeping render deterministic and isolated. + +**Key constraints:** +- All code is synchronous (no async/await) +- Bundle target: ES5 for goja compatibility + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Nelm CLI (Go) │ +├─────────────────────────────────────────────────────────────┤ +│ 1. Load bundle.js (ES5) │ +│ 2. Check if data() export exists │ +│ 3. If exists: execute data(ctx) in goja │ +│ 4. Execute requests (Go, network access) │ +│ 5. Execute render(ctx) with ctx.Data = results │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Usage + +### ts/src/index.ts + +```typescript +import { DataContext, DataRequest, HelmContext, Manifest } from '@nelm/types' +import { Values } from './generated/values.types' + +// Optional export — if not needed, don't export +export function data(ctx: DataContext): DataRequest[] { + var requests: DataRequest[] = [] + + // Fetch existing secret if specified + if (ctx.Values.existingSecret.name) { + requests.push({ + name: 'existingSecret', + type: 'kubernetesResource', + apiVersion: 'v1', + kind: 'Secret', + namespace: ctx.Values.existingSecret.namespace || ctx.Release.Namespace, + resourceName: ctx.Values.existingSecret.name, + }) + } + + // Check if Prometheus CRD exists + if (ctx.Values.monitoring.enabled) { + requests.push({ + name: 'prometheusCRDExists', + type: 'resourceExists', + apiVersion: 'apiextensions.k8s.io/v1', + kind: 'CustomResourceDefinition', + resourceName: 'prometheuses.monitoring.coreos.com', + }) + } + + return requests +} + +// Required export +export default function render(ctx: HelmContext): Manifest[] { + var manifests: Manifest[] = [] + + // Use collected data + if (ctx.Data.existingSecret) { + var secret = ctx.Data.existingSecret as KubernetesResource<'v1', 'Secret'> + // use secret... + } else { + manifests.push(createSecret(ctx)) + } + + if (ctx.Values.monitoring.enabled && ctx.Data.prometheusCRDExists) { + manifests.push(createServiceMonitor(ctx)) + } + + return manifests +} +``` + +## Build + +```bash +esbuild src/index.ts --bundle --target=es5 --format=iife --outfile=vendor/bundle.js +``` + +- **target=es5** — goja compatibility +- **format=iife** — single bundle +- **No async/await** — everything synchronous + +## Types + +### DataContext + +Context available during data() phase. Subset of HelmContext. + +```typescript +interface DataContext { + Values: V + Release: Release + Chart: Chart + Capabilities: Capabilities + // Note: Files NOT available in data phase + // Note: Data NOT available (not yet collected) +} +``` + +### DataRequest + +Union type of all supported data requests. + +```typescript +type DataRequest = + | KubernetesResourceRequest + | KubernetesListRequest + | ResourceExistsRequest + +interface BaseDataRequest { + /** Unique name to reference in ctx.Data */ + name: string +} +``` + +### KubernetesResourceRequest + +Fetch a single Kubernetes resource. + +```typescript +interface KubernetesResourceRequest extends BaseDataRequest { + type: 'kubernetesResource' + apiVersion: string + kind: string + namespace: string + resourceName: string +} +``` + +**Result:** `KubernetesResource | null` + +### KubernetesListRequest + +Fetch a list of Kubernetes resources. + +```typescript +interface KubernetesListRequest extends BaseDataRequest { + type: 'kubernetesList' + apiVersion: string + kind: string + namespace?: string + labelSelector?: Record + fieldSelector?: string + limit?: number +} +``` + +**Result:** `KubernetesList` (items may be empty array) + +### ResourceExistsRequest + +Check if a resource or API exists. + +```typescript +interface ResourceExistsRequest extends BaseDataRequest { + type: 'resourceExists' + apiVersion: string + kind: string + namespace?: string + resourceName?: string +} +``` + +**Result:** `boolean` + +## Result Types + +### KubernetesResource + +```typescript +interface KubernetesResource< + ApiVersion extends string = string, + Kind extends string = string +> { + apiVersion: ApiVersion + kind: Kind + metadata: ObjectMeta + spec?: unknown + status?: unknown + data?: unknown + [key: string]: unknown +} + +interface ObjectMeta { + name: string + namespace?: string + uid: string + resourceVersion: string + creationTimestamp: string + labels?: Record + annotations?: Record + ownerReferences?: OwnerReference[] + finalizers?: string[] +} +``` + +### KubernetesList + +```typescript +interface KubernetesList< + ApiVersion extends string = string, + Kind extends string = string +> { + apiVersion: ApiVersion + kind: string + metadata: ListMeta + items: Array> +} + +interface ListMeta { + resourceVersion: string + continue?: string + remainingItemCount?: number +} +``` + +## HelmContext with Data + +```typescript +interface HelmContext { + Values: V + Release: Release + Chart: Chart + Capabilities: Capabilities + Files: Files + Data: D +} + +type DataResults = Record + +type DataResult = + | KubernetesResource + | KubernetesList + | boolean + | null +``` + +## Type-Safe Data Access + +Users can define their own Data interface: + +```typescript +interface MyChartData { + existingSecret: KubernetesResource<'v1', 'Secret'> | null + prometheusCRDExists: boolean +} + +export default function render(ctx: HelmContext): Manifest[] { + ctx.Data.existingSecret // typed as Secret | null + ctx.Data.prometheusCRDExists // typed as boolean +} +``` + +## Behavior + +### If data() not exported + +- Data phase skipped +- `ctx.Data` is empty object `{}` + +### If resource not found + +| Request type | Result | +|--------------|--------| +| `kubernetesResource` | `null` | +| `kubernetesList` | `{ items: [] }` | +| `resourceExists` | `false` | + +### Errors + +- Network errors → Nelm fails with error +- RBAC errors → Nelm fails with error +- Invalid request → Nelm fails with error + +## Execution Order + +``` +1. nelm release install mychart +2. Load and merge Values +3. Bundle index.ts with esbuild (target=es5) +4. Load bundle.js in goja +5. If data export exists: + a. Execute data(ctx) + b. Validate DataRequest[] + c. Execute requests against Kubernetes API (Go) + d. Collect results +6. Execute render(ctx) with ctx.Data populated +7. Serialize Manifest[] to YAML +8. Deploy to cluster +``` + +## Security + +1. **No network in JS** — requests executed by Go +2. **Explicit** — only declared data is fetched +3. **RBAC** — subject to user's Kubernetes permissions +4. **Read-only** — no write operations +5. **Synchronous** — no async operations, predictable execution + +## Examples + +### Check CRD before creating CR + +```typescript +export function data(ctx: DataContext): DataRequest[] { + return [{ + name: 'serviceMonitorCRD', + type: 'resourceExists', + apiVersion: 'apiextensions.k8s.io/v1', + kind: 'CustomResourceDefinition', + resourceName: 'servicemonitors.monitoring.coreos.com', + }] +} + +export default function render(ctx: HelmContext): Manifest[] { + var manifests = [createDeployment(ctx), createService(ctx)] + + if (ctx.Data.serviceMonitorCRD) { + manifests.push(createServiceMonitor(ctx)) + } + + return manifests +} +``` + +### Use existing or create new secret + +```typescript +export function data(ctx: DataContext): DataRequest[] { + if (!ctx.Values.existingSecretName) return [] + + return [{ + name: 'existingSecret', + type: 'kubernetesResource', + apiVersion: 'v1', + kind: 'Secret', + namespace: ctx.Release.Namespace, + resourceName: ctx.Values.existingSecretName, + }] +} + +export default function render(ctx: HelmContext): Manifest[] { + if (ctx.Data.existingSecret) { + // Use existing secret name in deployment + } else { + // Create new secret + } +} +``` + +## Future Extensions (Not in v1) + +- `httpRequest` — external HTTP APIs +- `awsSecret` — AWS Secrets Manager +- `vaultSecret` — HashiCorp Vault +- Caching with TTL +- Parallel fetching diff --git a/docs/proposals/go-template-alternative/decisions.md b/docs/proposals/go-template-alternative/decisions.md index c129f8f6..5200aed5 100644 --- a/docs/proposals/go-template-alternative/decisions.md +++ b/docs/proposals/go-template-alternative/decisions.md @@ -2,28 +2,44 @@ ## 1. JS Runtime -**Decision:** Use quickjs-go (CGO bindings) instead of WASM. +**Decision:** Use goja (pure Go) instead of quickjs-go or WASM. **Rationale:** -- Simpler architecture +- Pure Go, no CGO +- Simpler cross-compilation +- No external dependencies - No need for Wazero -- CGO is acceptable for Nelm -## 2. Isolation +## 2. Build Target + +**Decision:** ES5 target, IIFE format, no async/await. esbuild embedded in Nelm. + +```bash +# Nelm runs internally: +esbuild src/index.ts --bundle --target=es5 --format=iife --outfile=vendor/bundle.js +``` + +**Rationale:** +- Maximum compatibility with goja +- Synchronous execution only +- Predictable, deterministic behavior +- esbuild embedded in Nelm — no need to install separately + +## 3. Isolation **Decision:** No network/fs access in JS context. **Rationale:** - Security - Reproducibility -- Only Go-injected functions for external access (lookup, Files) +- Deterministic renders +- External data via data mechanism (separate phase) -## 3. Render API +## 4. Render API **Decision:** Return-based, not emit-based. ```typescript -// Chosen approach export default function render(ctx: HelmContext): Manifest[] { return [manifest1, manifest2, ...] } @@ -35,39 +51,48 @@ export default function render(ctx: HelmContext): Manifest[] { - Better readability - Predictable -**Helper for conditionals:** -```typescript -import { when } from '@nelm/sdk' - -return [ - createDeployment(ctx), - ...when(ctx.Values.ingress.enabled, [ - createIngress(ctx), - ]), -] -``` +## 5. Context Design -## 4. Context Design - -**Decision:** Everything in `ctx`, no imports for runtime functions. +**Decision:** ctx contains only data, no helper functions. ```typescript -// All via ctx +// ctx contains only data ctx.Values ctx.Release -ctx.Files.get("config.ini") -ctx.lookup("v1", "Secret", "default", "name") -ctx.toYaml(obj) -ctx.b64encode("data") +ctx.Chart +ctx.Capabilities +ctx.Files +ctx.Data // from data mechanism ``` **Rationale:** -- Single source of truth -- Easy to mock in tests -- Clear what comes from outside -- Consistent with Helm (`.Values`, `.Files`, etc.) +- Minimal API surface +- User defines own helpers +- Flexibility +- Smaller bundle + +## 6. No lookup in render -## 5. Subcharts +**Decision:** No lookup() function in render phase. Use data mechanism instead. + +**Rationale:** +- Deterministic renders +- No network calls during render +- Better testability +- GitOps friendly (can see diff before deploy) +- See [data-mechanism.md](./data-mechanism.md) for external data + +## 7. No built-in helpers + +**Decision:** No toYaml, b64encode, sha256, when, etc. in ctx or package. + +**Rationale:** +- User defines own helpers as needed +- Output is Manifest[] objects, not YAML strings +- Nelm serializes to YAML +- Minimal package + +## 8. Subcharts **Decision:** Full isolation, no cross-chart access. @@ -77,38 +102,64 @@ ctx.b64encode("data") - Any combination of Go templates + TS works - Predictable, testable -## 6. SDK Structure +## 9. Types via npm Packages -**Decision:** SDK provides only types + `when()` helper. +**Decision:** Types as npm packages, no helper functions. + +| Package | Purpose | +|---------|---------| +| `@nelm/types` | HelmContext, Manifest, K8s resources | +| `@nelm/crd-to-ts` | CLI generator for types from CRD | +| `json-schema-to-typescript` | Values types generation | **Rationale:** -- All runtime functions injected by Go (toYaml, b64encode, sha256, etc.) -- SDK is essentially devDependency (types only) -- Minimal bundle size +- npm ecosystem — familiar for TS developers +- Package versioning +- K8s types generated from OpenAPI spec +- Single package for all types -## 7. Values Type Generation +## 10. Values Type Generation **Decision:** Via npm script using json-schema-to-typescript. ```json { "scripts": { - "generate-types": "json2ts ../values.schema.json -o src/values.types.ts" + "generate:values": "json2ts ../values.schema.json -o src/generated/values.types.ts" } } ``` **Rationale:** - Node.js already required for development -- No need to embed in Nelm - Proven library - Developer controls when to regenerate -## 8. Helm Helpers Source +## 11. Data Mechanism + +**Decision:** Optional `data()` export for external data, executed before render. + +```typescript +export function data(ctx: DataContext): DataRequest[] { + return [{ name: 'secret', type: 'kubernetesResource', ... }] +} + +export default function render(ctx: HelmContext): Manifest[] { + // ctx.Data.secret available here +} +``` + +**Rationale:** +- Separates data fetching from rendering +- Render stays deterministic +- Explicit data dependencies +- See [data-mechanism.md](./data-mechanism.md) + +## 12. K8s Types Generation -**Decision:** All Helm-equivalent helpers (toYaml, b64encode, sha256, etc.) provided by Go. +**Decision:** Generate K8s types from OpenAPI spec in CI. **Rationale:** -- Single serializer (Go YAML lib) for consistency -- Fewer JS dependencies -- Sync functions (no async issues) +- Single source of truth (OpenAPI spec) +- Always up-to-date with K8s versions +- Version managed in CI pipeline diff --git a/docs/proposals/go-template-alternative/sdk.md b/docs/proposals/go-template-alternative/sdk.md index b646ba54..4f428194 100644 --- a/docs/proposals/go-template-alternative/sdk.md +++ b/docs/proposals/go-template-alternative/sdk.md @@ -1,204 +1,180 @@ -# @nelm/sdk Package +# Types & Packages -## Overview +## npm Packages -Minimal SDK providing TypeScript types and one helper function. All runtime functions are injected by Nelm (Go) into the JS context. +| Package | Type | Purpose | +|---------|------|---------| +| `@nelm/types` | Types | HelmContext, Manifest, K8s resources | +| `@nelm/crd-to-ts` | CLI generator | Generate types from CRD | +| `json-schema-to-typescript` | CLI generator | Generate Values types from schema | -## Package Structure +## @nelm/types -``` -@nelm/sdk/ - package.json - index.ts - index.d.ts - types/ - context.ts # HelmContext, Release, Chart, etc. - manifest.ts # Manifest, ObjectMeta - capabilities.ts - files.ts -``` +Single package with all types. No helper functions. -## Source Code - -### index.ts +### Nelm Types ```typescript -// Re-export all types -export * from './types/context' -export * from './types/manifest' -export * from './types/capabilities' -export * from './types/files' - -// The only runtime helper -export function when(condition: boolean, items: T[]): T[] { - return condition ? items : [] -} +import { HelmContext, Manifest, DataContext, DataRequest } from '@nelm/types' ``` -### types/context.ts +### Kubernetes Types (Generated from OpenAPI) ```typescript -import { Capabilities } from './capabilities' -import { Files } from './files' - -export interface HelmContext { - Values: V - Release: Release - Chart: Chart - Capabilities: Capabilities - Files: Files - - // Functions injected from Go - lookup(apiVersion: string, kind: string, namespace: string, name: string): T | null - toYaml(obj: unknown): string - fromYaml(str: string): T - toJson(obj: unknown): string - fromJson(str: string): T - b64encode(str: string): string - b64decode(str: string): string - sha256(str: string): string - sha1(str: string): string - md5(str: string): string - indent(str: string, spaces: number): string - nindent(str: string, spaces: number): string - trim(str: string): string - trimPrefix(str: string, prefix: string): string - trimSuffix(str: string, suffix: string): string - upper(str: string): string - lower(str: string): string - title(str: string): string - quote(str: string): string - squote(str: string): string -} +import { Deployment, StatefulSet, DaemonSet } from '@nelm/types/apps/v1' +import { ConfigMap, Secret, Service, Pod } from '@nelm/types/core/v1' +import { Ingress, NetworkPolicy } from '@nelm/types/networking/v1' +import { Job, CronJob } from '@nelm/types/batch/v1' +``` -export interface Release { - Name: string - Namespace: string - IsUpgrade: boolean - IsInstall: boolean - Revision: number - Service: string -} +### Package Structure -export interface Chart { - Name: string - Version: string - AppVersion: string - Description: string - Keywords: string[] - Home: string - Sources: string[] - Icon: string - Deprecated: boolean - Type: string -} +``` +@nelm/types/ + index.ts # HelmContext, Manifest, DataRequest, etc. + apps/ + v1.ts # Deployment, StatefulSet, DaemonSet, ReplicaSet + core/ + v1.ts # ConfigMap, Secret, Service, Pod, PVC, etc. + networking/ + v1.ts # Ingress, NetworkPolicy, IngressClass + batch/ + v1.ts # Job, CronJob + rbac.authorization.k8s.io/ + v1.ts # Role, ClusterRole, RoleBinding, etc. + autoscaling/ + v2.ts # HorizontalPodAutoscaler + policy/ + v1.ts # PodDisruptionBudget + ... # Generated from K8s OpenAPI spec ``` -### types/capabilities.ts - -```typescript -export interface Capabilities { - KubeVersion: KubeVersion - APIVersions: APIVersions - HelmVersion: HelmVersion -} +### Generation -export interface KubeVersion { - Major: string - Minor: string - GitVersion: string +K8s types generated from OpenAPI spec in CI. Version managed in CI pipeline. - gte(version: string): boolean - gt(version: string): boolean - lte(version: string): boolean - lt(version: string): boolean - eq(version: string): boolean -} +## @nelm/crd-to-ts -export interface APIVersions { - list: string[] - has(apiVersion: string): boolean -} +CLI for generating TypeScript types from Kubernetes CRD. -export interface HelmVersion { - Version: string - GitCommit: string - GoVersion: string -} -``` +```bash +# From cluster +npx @nelm/crd-to-ts --crd prometheuses.monitoring.coreos.com -o src/generated/ -### types/files.ts +# From file +npx @nelm/crd-to-ts --file crds/my-crd.yaml -o src/generated/ -```typescript -export interface Files { - get(path: string): string - getBytes(path: string): Uint8Array - glob(pattern: string): Map - lines(path: string): string[] - asConfig(pattern?: string): Record - asSecrets(pattern?: string): Record -} +# From URL +npx @nelm/crd-to-ts --url https://raw.githubusercontent.com/.../crd.yaml -o src/generated/ ``` -### types/manifest.ts - +Generates: ```typescript -export interface Manifest { - apiVersion: string - kind: string +// src/generated/prometheus.types.ts + +export interface Prometheus { + apiVersion: 'monitoring.coreos.com/v1' + kind: 'Prometheus' metadata: ObjectMeta - [key: string]: unknown + spec: PrometheusSpec + status?: PrometheusStatus } -export interface ObjectMeta { - name: string - namespace?: string - labels?: Record - annotations?: Record - ownerReferences?: OwnerReference[] - finalizers?: string[] +export interface PrometheusSpec { + replicas?: number + serviceAccountName?: string + serviceMonitorSelector?: LabelSelector + // ... from OpenAPI schema in CRD } +``` + +## Project Structure -export interface OwnerReference { - apiVersion: string - kind: string - name: string - uid: string - controller?: boolean - blockOwnerDeletion?: boolean -} +``` +ts/ + src/ + generated/ + values.types.ts # json-schema-to-typescript + prometheus.types.ts # @nelm/crd-to-ts + index.ts + package.json + tsconfig.json + vendor/ + bundle.js # ES5 bundle ``` ## package.json ```json { - "name": "@nelm/sdk", - "version": "1.0.0", - "description": "TypeScript SDK for Nelm charts", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "files": [ - "dist" - ], + "name": "mychart-ts", + "private": true, "scripts": { - "build": "tsc", - "prepublishOnly": "npm run build" + "generate:values": "json2ts ../values.schema.json -o src/generated/values.types.ts", + "generate:crd": "crd-to-ts --crd servicemonitors.monitoring.coreos.com -o src/generated/", + "typecheck": "tsc --noEmit", + "build": "esbuild src/index.ts --bundle --target=es5 --format=iife --outfile=vendor/bundle.js" }, "devDependencies": { - "typescript": "^5.0.0" - }, - "keywords": ["nelm", "helm", "kubernetes", "typescript"], - "license": "Apache-2.0" + "@nelm/types": "^1.0.0", + "@nelm/crd-to-ts": "^1.0.0", + "typescript": "^5.0.0", + "json-schema-to-typescript": "^15.0.0" + } + // Note: esbuild embedded in Nelm, not needed here } ``` -## Distribution +## Usage Example -Published to npm as `@nelm/sdk`. +```typescript +import { HelmContext, Manifest } from '@nelm/types' +import { Deployment } from '@nelm/types/apps/v1' +import { Service } from '@nelm/types/core/v1' +import { ConfigMap } from '@nelm/types/core/v1' +import { Values } from './generated/values.types' -Developers install as devDependency since it's primarily types: +function when(condition: boolean, items: T[]): T[] { + return condition ? items : [] +} -```bash -npm install --save-dev @nelm/sdk +export default function render(ctx: HelmContext): Manifest[] { + var labels = { + 'app.kubernetes.io/name': ctx.Chart.Name, + 'app.kubernetes.io/instance': ctx.Release.Name, + } + + var deployment: Deployment = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: ctx.Release.Name, + namespace: ctx.Release.Namespace, + labels: labels, + }, + spec: { + replicas: ctx.Values.replicas, + selector: { matchLabels: labels }, + template: { + metadata: { labels: labels }, + spec: { + containers: [{ + name: ctx.Chart.Name, + image: ctx.Values.image.repository + ':' + ctx.Values.image.tag, + }], + }, + }, + }, + } + + return [ + deployment, + ...when(ctx.Values.service.enabled, [{ + apiVersion: 'v1', + kind: 'Service', + metadata: { name: ctx.Release.Name }, + spec: { selector: labels, ports: [{ port: 80 }] }, + }]), + ] +} ``` diff --git a/docs/proposals/go-template-alternative/workflow.md b/docs/proposals/go-template-alternative/workflow.md index 3cc0d2d2..8aa2668f 100644 --- a/docs/proposals/go-template-alternative/workflow.md +++ b/docs/proposals/go-template-alternative/workflow.md @@ -6,19 +6,19 @@ mychart/ Chart.yaml values.yaml - values.schema.json # Optional, for type generation - templates/ # Go templates (optional) - ts/ # TypeScript source + values.schema.json # Optional, for type generation + templates/ # Go templates (optional) + ts/ # TypeScript source package.json tsconfig.json - node_modules/ # .gitignore + node_modules/ # .gitignore src/ - index.ts # Entry point - values.types.ts # Generated from schema - deployment.ts # Helper modules - service.ts + generated/ + values.types.ts # From values.schema.json + *.types.ts # From CRDs + index.ts # Entry point (render + optional data) vendor/ - bundle.js # Bundled for distribution + bundle.js # ES5 bundle for distribution ``` ## Development Workflow @@ -48,7 +48,7 @@ Created ts/src/index.ts Next steps: cd ts npm install - npm run generate-types # if values.schema.json exists + npm run generate:values # if values.schema.json exists ``` ### 2. Install Dependencies @@ -58,21 +58,22 @@ cd ts npm install ``` -### 3. Generate Types (if schema exists) +### 3. Generate Types ```bash -npm run generate-types -``` +# Values types from schema +npm run generate:values -Creates `src/values.types.ts` from `../values.schema.json`. +# CRD types (if needed) +npm run generate:crd +``` ### 4. Develop -Edit `src/index.ts` and other modules. IDE provides full TypeScript support. - ```typescript -import { HelmContext, Manifest, when } from '@nelm/sdk' -import { Values } from './values.types' +import { HelmContext, Manifest } from '@nelm/types' +import { Deployment, Service } from '@nelm/types/apps/v1' +import { Values } from './generated/values.types' export default function render(ctx: HelmContext): Manifest[] { return [ @@ -81,25 +82,17 @@ export default function render(ctx: HelmContext): Manifest[] { } ``` -### 5. Test Locally with Node.js +### 5. Type Check ```bash -npm run dev -# Executes with Node.js, outputs manifests to stdout +npm run typecheck ``` -### 6. Test with QuickJS (Nelm) +### 6. Test with Nelm ```bash +cd .. nelm chart render . -# Uses embedded QuickJS runtime -``` - -### 7. Type Check - -```bash -npm run typecheck -# tsc --noEmit ``` ## Publishing Workflow @@ -110,16 +103,19 @@ npm run typecheck nelm chart publish . ``` -Nelm internally runs: +Nelm runs embedded esbuild: ```bash -esbuild ts/src/index.ts --bundle --outfile=ts/vendor/bundle.js --format=esm +# Internally: +esbuild ts/src/index.ts --bundle --target=es5 --format=iife --outfile=ts/vendor/bundle.js ``` +**Note:** esbuild is embedded in Nelm CLI. No need to install separately. + ### 2. Upload to Registry -Chart is uploaded with `ts/vendor/bundle.js` included. +Chart uploaded with `ts/vendor/bundle.js`. -`node_modules/` is NOT included (in .gitignore). +`node_modules/` NOT included. ## Deployment Workflow @@ -131,14 +127,15 @@ nelm release install myrepo/mychart ### 2. Nelm Renders -Under the hood: -1. Nelm loads `ts/vendor/bundle.js` -2. Passes context (Values, Release, etc.) to QuickJS -3. Executes `render(ctx)` -4. Receives manifests array -5. Serializes to YAML -6. Combines with Go templates output (if any) -7. Deploys to cluster +1. Load `ts/vendor/bundle.js` +2. If `data` export exists: + - Execute `data(ctx)` in goja + - Fetch external data (Go) + - Populate `ctx.Data` +3. Execute `render(ctx)` in goja +4. Serialize Manifest[] to YAML +5. Combine with Go templates (if any) +6. Deploy **No Node.js required on deployment machine.** @@ -150,22 +147,23 @@ Under the hood: { "name": "mychart-ts", "private": true, - "type": "module", "scripts": { - "generate-types": "json2ts ../values.schema.json -o src/values.types.ts", + "generate:values": "json2ts ../values.schema.json -o src/generated/values.types.ts", + "generate:crd": "crd-to-ts --crd servicemonitors.monitoring.coreos.com -o src/generated/", "typecheck": "tsc --noEmit", - "dev": "tsx src/index.ts", - "build": "esbuild src/index.ts --bundle --outfile=vendor/bundle.js --format=esm" + "build": "esbuild src/index.ts --bundle --target=es5 --format=iife --outfile=vendor/bundle.js" }, "devDependencies": { - "@nelm/sdk": "^1.0.0", + "@nelm/types": "^1.0.0", + "@nelm/crd-to-ts": "^1.0.0", "typescript": "^5.0.0", - "json-schema-to-typescript": "^15.0.0", - "tsx": "^4.0.0" + "json-schema-to-typescript": "^15.0.0" } } ``` +**Note:** esbuild is embedded in Nelm CLI, not needed in devDependencies. + ### tsconfig.json ```json @@ -188,10 +186,10 @@ Under the hood: ### src/index.ts (template) ```typescript -import { HelmContext, Manifest, when } from '@nelm/sdk' -// import { Values } from './values.types' // Uncomment after generate-types +import { HelmContext, Manifest } from '@nelm/types' +// import { Values } from './generated/values.types' -type Values = Record // Remove after generate-types +type Values = Record // Remove after generate:values export default function render(ctx: HelmContext): Manifest[] { return [ @@ -210,10 +208,17 @@ export default function render(ctx: HelmContext): Manifest[] { } ``` -## .gitignore Additions +## .gitignore ```gitignore ts/node_modules/ ``` -Note: `ts/vendor/bundle.js` IS committed for published charts. +Note: `ts/vendor/bundle.js` IS committed. + +## Build Constraints + +- **Target:** ES5 (goja compatibility) +- **Format:** IIFE +- **No async/await** +- **No network/fs in JS**