diff --git a/docs/proposals/go-template-alternative/README.md b/docs/proposals/go-template-alternative/README.md new file mode 100644 index 00000000..c6945c06 --- /dev/null +++ b/docs/proposals/go-template-alternative/README.md @@ -0,0 +1,64 @@ +# 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) — 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/types' +import { Deployment } from '@nelm/types/apps/v1' +import { Values } from './generated/values.types' + +export default function render(ctx: HelmContext): Manifest[] { + 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. **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 new file mode 100644 index 00000000..b12ca7ae --- /dev/null +++ b/docs/proposals/go-template-alternative/api.md @@ -0,0 +1,223 @@ +# HelmContext API + +## Main Interface + +```typescript +interface HelmContext { + // Data only, no functions + Values: V + Release: Release + Chart: Chart + Capabilities: Capabilities + Files: Files + Data: D // Results from data() phase +} +``` + +**Note:** No helper functions in ctx. Define your own as needed. + +## 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" +} + +interface APIVersions { + list: string[] +} + +interface HelmVersion { + Version: string + GitCommit: string + GoVersion: string +} +``` + +## Files + +```typescript +interface Files { + get(path: string): string + getBytes(path: string): Uint8Array + glob(pattern: string): Record // path -> content + lines(path: string): string[] +} +``` + +## 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 +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/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[] { + var labels = { + 'app.kubernetes.io/name': ctx.Chart.Name, + 'app.kubernetes.io/instance': ctx.Release.Name, + 'app.kubernetes.io/version': ctx.Chart.AppVersion, + } + + 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, + }], + }, + }, + }, + } + + 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 based on values + ...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 based on data mechanism + ...when(ctx.Data.serviceMonitorCRDExists === true, [{ + 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..9fea3b8a --- /dev/null +++ b/docs/proposals/go-template-alternative/cli.md @@ -0,0 +1,102 @@ +# 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:values # 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 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: + +```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:values + +# 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/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 new file mode 100644 index 00000000..5200aed5 --- /dev/null +++ b/docs/proposals/go-template-alternative/decisions.md @@ -0,0 +1,165 @@ +# Design Decisions + +## 1. JS Runtime + +**Decision:** Use goja (pure Go) instead of quickjs-go or WASM. + +**Rationale:** +- Pure Go, no CGO +- Simpler cross-compilation +- No external dependencies +- No need for Wazero + +## 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 +- Deterministic renders +- External data via data mechanism (separate phase) + +## 4. Render API + +**Decision:** Return-based, not emit-based. + +```typescript +export default function render(ctx: HelmContext): Manifest[] { + return [manifest1, manifest2, ...] +} +``` + +**Rationale:** +- Clear, explicit output +- Easy to see what's being created +- Better readability +- Predictable + +## 5. Context Design + +**Decision:** ctx contains only data, no helper functions. + +```typescript +// ctx contains only data +ctx.Values +ctx.Release +ctx.Chart +ctx.Capabilities +ctx.Files +ctx.Data // from data mechanism +``` + +**Rationale:** +- Minimal API surface +- User defines own helpers +- Flexibility +- Smaller bundle + +## 6. No lookup in render + +**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. + +**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 + +## 9. Types via npm Packages + +**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:** +- npm ecosystem — familiar for TS developers +- Package versioning +- K8s types generated from OpenAPI spec +- Single package for all types + +## 10. Values Type Generation + +**Decision:** Via npm script using json-schema-to-typescript. + +```json +{ + "scripts": { + "generate:values": "json2ts ../values.schema.json -o src/generated/values.types.ts" + } +} +``` + +**Rationale:** +- Node.js already required for development +- Proven library +- Developer controls when to regenerate + +## 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:** Generate K8s types from OpenAPI spec in CI. + +**Rationale:** +- 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 new file mode 100644 index 00000000..4f428194 --- /dev/null +++ b/docs/proposals/go-template-alternative/sdk.md @@ -0,0 +1,180 @@ +# Types & Packages + +## npm Packages + +| 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 | + +## @nelm/types + +Single package with all types. No helper functions. + +### Nelm Types + +```typescript +import { HelmContext, Manifest, DataContext, DataRequest } from '@nelm/types' +``` + +### Kubernetes Types (Generated from OpenAPI) + +```typescript +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' +``` + +### Package Structure + +``` +@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 +``` + +### Generation + +K8s types generated from OpenAPI spec in CI. Version managed in CI pipeline. + +## @nelm/crd-to-ts + +CLI for generating TypeScript types from Kubernetes CRD. + +```bash +# From cluster +npx @nelm/crd-to-ts --crd prometheuses.monitoring.coreos.com -o src/generated/ + +# From file +npx @nelm/crd-to-ts --file crds/my-crd.yaml -o src/generated/ + +# From URL +npx @nelm/crd-to-ts --url https://raw.githubusercontent.com/.../crd.yaml -o src/generated/ +``` + +Generates: +```typescript +// src/generated/prometheus.types.ts + +export interface Prometheus { + apiVersion: 'monitoring.coreos.com/v1' + kind: 'Prometheus' + metadata: ObjectMeta + spec: PrometheusSpec + status?: PrometheusStatus +} + +export interface PrometheusSpec { + replicas?: number + serviceAccountName?: string + serviceMonitorSelector?: LabelSelector + // ... from OpenAPI schema in CRD +} +``` + +## Project Structure + +``` +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": "mychart-ts", + "private": true, + "scripts": { + "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": { + "@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 +} +``` + +## Usage Example + +```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' + +function when(condition: boolean, items: T[]): T[] { + return condition ? items : [] +} + +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 new file mode 100644 index 00000000..8aa2668f --- /dev/null +++ b/docs/proposals/go-template-alternative/workflow.md @@ -0,0 +1,224 @@ +# 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/ + generated/ + values.types.ts # From values.schema.json + *.types.ts # From CRDs + index.ts # Entry point (render + optional data) + vendor/ + bundle.js # ES5 bundle 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:values # if values.schema.json exists +``` + +### 2. Install Dependencies + +```bash +cd ts +npm install +``` + +### 3. Generate Types + +```bash +# Values types from schema +npm run generate:values + +# CRD types (if needed) +npm run generate:crd +``` + +### 4. Develop + +```typescript +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 [ + // ... + ] +} +``` + +### 5. Type Check + +```bash +npm run typecheck +``` + +### 6. Test with Nelm + +```bash +cd .. +nelm chart render . +``` + +## Publishing Workflow + +### 1. Bundle for Distribution + +```bash +nelm chart publish . +``` + +Nelm runs embedded esbuild: +```bash +# 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 uploaded with `ts/vendor/bundle.js`. + +`node_modules/` NOT included. + +## Deployment Workflow + +### 1. Install Chart + +```bash +nelm release install myrepo/mychart +``` + +### 2. Nelm Renders + +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.** + +## Generated Files + +### package.json + +```json +{ + "name": "mychart-ts", + "private": true, + "scripts": { + "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": { + "@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 is embedded in Nelm CLI, not needed in devDependencies. + +### 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 } from '@nelm/types' +// import { Values } from './generated/values.types' + +type Values = Record // Remove after generate:values + +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 + +```gitignore +ts/node_modules/ +``` + +Note: `ts/vendor/bundle.js` IS committed. + +## Build Constraints + +- **Target:** ES5 (goja compatibility) +- **Format:** IIFE +- **No async/await** +- **No network/fs in JS** 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