diff --git a/ROADMAP.md b/ROADMAP.md index 9bf1cb9..a7459a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -264,17 +264,22 @@ these are the corners where drift would start if it starts anywhere. column (`TestPrunerGuards`), child guard independent of source. **Unblocks K2** (gitops mode needs reliable `source=git`). -- [ ] **K4 — Generic Platform + deployment methods as providers.** The intent +- [~] **K4 — Generic Platform + deployment methods as providers.** The intent for `Platform` is NOT a curated grab-bag of preferred charts but **generic - support**, Terraform-provider style: (a) make Platform components - data-declared (any chart via `{name, chart, namespace, values}`), with - traefik/cloudflared/nvidia/nfs/… as optional named **presets** or examples - — not baked-in opinion; (b) treat the deployment **method** itself as a - provider — Helm/Manifest/Argo are already kinds; add new methods - (Kustomize, Flux, …) as new kinds/providers rather than bespoke code. - openctl already has the provider bones (it's TF-like for infra); extend - the same shape to workload deployment so breadth comes from providers, not - special cases. + support**, Terraform-provider style. + - [x] **(a) Data-declared components — SHIPPED.** Platform now installs + ANY chart with no code change via a generic `components: {: + {chart: {repo, name, version?}, namespace?, values?, wait?, enabled?}}` + map (`enabled` defaults true; a generic name overrides a same-named + preset). `traefik/cloudflared/argocd/nvidia/nfs/longhorn` are now + explicitly **presets** — default chart coords layered on the generic + path, not baked-in opinion. `enabledComponents` merges presets + + generic deterministically; `#Platform.components?` schema. Tests: generic + install, enabled:false + missing-chart skip, preset override, ordering. + - [ ] **(b) Deployment methods as providers.** Treat the deployment + **method** itself as a provider — Helm/Manifest/Argo are already kinds; + add new methods (Kustomize, Flux, …) as new kinds/providers rather than + bespoke code, so breadth comes from providers, not special cases. - [ ] **K5 — Re-baseline the scope wedge (`direction.md`).** The doc originally vetoed workloads/PaaS ("stop at cluster-Ready"); the deployment model + diff --git a/plugins/k8s/internal/provider/platform.go b/plugins/k8s/internal/provider/platform.go index 0f7d45f..7db03d0 100644 --- a/plugins/k8s/internal/provider/platform.go +++ b/plugins/k8s/internal/provider/platform.go @@ -36,9 +36,14 @@ type component struct { defaultNS string } -// platformComponents is the curated, opinionated set. Traefik is the ingress -// (ingress-nginx is in maintenance; no Cilium-CNI dependency); cloudflared wires -// a Cloudflare Tunnel to in-cluster services. +// platformComponents is a set of named PRESETS — convenience defaults for +// common charts, NOT an exclusive list. A preset only fills in default chart +// coordinates so you enable it with `: {enabled: true}`; the generic +// `components` map (see enabledComponents) installs ANY chart without a code +// change, which is the primary, Terraform-provider-style path. Presets are +// examples/shortcuts layered on top of that generic support, not baked-in +// opinion. Traefik is the ingress (ingress-nginx is in maintenance; no +// Cilium-CNI dependency); cloudflared wires a Cloudflare Tunnel to services. var platformComponents = []component{ {"traefik", "https://traefik.github.io/charts", "traefik", "traefik"}, {"cloudflared", "https://cloudflare.github.io/helm-charts", "cloudflare-tunnel-remote", "cloudflared"}, @@ -80,43 +85,105 @@ type componentRelease struct { values map[string]any } +// buildComponentRelease resolves one component from its raw spec block, layering +// per-component overrides (namespace/chart/values/wait) over the supplied +// defaults. For a named preset, def carries the default chart coords; for a +// generic component, def is zero and the chart must be fully declared. +func buildComponentRelease(name string, raw map[string]any, def component) componentRelease { + ns := specString(raw, "namespace") + if ns == "" { + ns = def.defaultNS + } + if ns == "" { + ns = name // generic component with no namespace → its own name + } + chart := chartSpec{Repo: def.defaultRepo, Name: def.defaultChart} + if cr, ok := raw["chart"].(map[string]any); ok { + if v := specString(cr, "repo"); v != "" { + chart.Repo = v + } + if v := specString(cr, "name"); v != "" { + chart.Name = v + } + chart.Version = specString(cr, "version") + } + values, _ := raw["values"].(map[string]any) + return componentRelease{ + comp: component{name: name, defaultRepo: chart.Repo, defaultChart: chart.Name, defaultNS: ns}, + chart: chart, + opts: releaseOpts{ + releaseName: name, + namespace: ns, + createNamespace: true, + values: values, + wait: specBool(raw, "wait"), + timeout: 5 * time.Minute, + }, + values: values, + } +} + // enabledComponents parses the Platform spec into the set of enabled component -// releases, applying per-component overrides over the curated defaults. +// releases. Two sources feed it, both opt-in: +// +// - Named PRESETS (platformComponents): enable with `: {enabled: true}`; +// the preset supplies default chart coordinates, which the block may still +// override (chart/namespace/values/wait). +// - The generic `components` map: `components: {: {chart: {repo, name, +// version?}, namespace?, values?, wait?, enabled?}}` installs ANY chart with +// no code change (the Terraform-provider-style path). `enabled` defaults +// true for a listed generic component (set false to disable without +// removing). A generic entry sharing a preset's name overrides it. +// +// Order is deterministic: presets in declaration order, then generic components +// sorted by name; a generic override keeps the earlier slot. func enabledComponents(spec map[string]any) []componentRelease { - var out []componentRelease + byName := map[string]componentRelease{} + var order []string + put := func(name string, cr componentRelease) { + if _, seen := byName[name]; !seen { + order = append(order, name) + } + byName[name] = cr + } + for _, c := range platformComponents { raw, _ := spec[c.name].(map[string]any) if raw == nil || !specBool(raw, "enabled") { continue } - ns := specString(raw, "namespace") - if ns == "" { - ns = c.defaultNS + put(c.name, buildComponentRelease(c.name, raw, c)) + } + + if generic, ok := spec["components"].(map[string]any); ok { + names := make([]string, 0, len(generic)) + for name := range generic { + names = append(names, name) } - chart := chartSpec{Repo: c.defaultRepo, Name: c.defaultChart} - if cr, ok := raw["chart"].(map[string]any); ok { - if v := specString(cr, "repo"); v != "" { - chart.Repo = v + sort.Strings(names) + for _, name := range names { + raw, _ := generic[name].(map[string]any) + if raw == nil { + continue } - if v := specString(cr, "name"); v != "" { - chart.Name = v + // enabled defaults true for a listed generic component; only an + // explicit false disables it. + if v, ok := raw["enabled"].(bool); ok && !v { + continue } - chart.Version = specString(cr, "version") + cr := buildComponentRelease(name, raw, component{}) + // Can't install without chart coordinates; skip (the CUE schema + // requires chart.repo + chart.name, so this is defensive). + if cr.chart.Repo == "" || cr.chart.Name == "" { + continue + } + put(name, cr) } - values, _ := raw["values"].(map[string]any) - out = append(out, componentRelease{ - comp: c, - chart: chart, - opts: releaseOpts{ - releaseName: c.name, - namespace: ns, - createNamespace: true, - values: values, - wait: specBool(raw, "wait"), - timeout: 5 * time.Minute, - }, - values: values, - }) + } + + out := make([]componentRelease, 0, len(order)) + for _, n := range order { + out = append(out, byName[n]) } return out } diff --git a/plugins/k8s/internal/provider/platform_test.go b/plugins/k8s/internal/provider/platform_test.go index c7622c1..f821d32 100644 --- a/plugins/k8s/internal/provider/platform_test.go +++ b/plugins/k8s/internal/provider/platform_test.go @@ -117,6 +117,90 @@ func TestEnabledComponents_NvidiaDevicePlugin(t *testing.T) { } } +// TestEnabledComponents_Generic covers the K4(a) generic path: an arbitrary +// chart declared under `components` installs with no preset, defaults enabled +// to true, defaults its namespace to its name, and threads chart+values. +func TestEnabledComponents_Generic(t *testing.T) { + comps := enabledComponents(map[string]any{ + "components": map[string]any{ + "prometheus": map[string]any{ + "chart": map[string]any{"repo": "https://prometheus-community.github.io/helm-charts", "name": "kube-prometheus-stack", "version": "65.0.0"}, + "values": map[string]any{"grafana": map[string]any{"enabled": false}}, + }, + }, + }) + if len(comps) != 1 { + t.Fatalf("enabled = %d, want 1 (generic prometheus)", len(comps)) + } + c := comps[0] + if c.comp.name != "prometheus" || c.opts.releaseName != "prometheus" { + t.Errorf("name/release = %s/%s", c.comp.name, c.opts.releaseName) + } + if c.chart.Repo != "https://prometheus-community.github.io/helm-charts" || c.chart.Name != "kube-prometheus-stack" || c.chart.Version != "65.0.0" { + t.Errorf("chart wrong: %+v", c.chart) + } + if c.opts.namespace != "prometheus" { // defaults to the release name + t.Errorf("namespace = %q, want prometheus (defaulted to name)", c.opts.namespace) + } + g, _ := c.values["grafana"].(map[string]any) + if g["enabled"] != false { + t.Errorf("values not threaded: %+v", c.values) + } +} + +// TestEnabledComponents_GenericDisabledAndInvalid: an explicit enabled:false +// generic component is skipped, and one without chart coordinates is skipped +// (defensive — the schema requires chart.repo+name). +func TestEnabledComponents_GenericDisabledAndInvalid(t *testing.T) { + comps := enabledComponents(map[string]any{ + "components": map[string]any{ + "off": map[string]any{"enabled": false, "chart": map[string]any{"repo": "r", "name": "n"}}, + "nochart": map[string]any{"namespace": "x"}, + "malformed": map[string]any{"chart": map[string]any{"repo": "r"}}, // no name + }, + }) + if len(comps) != 0 { + t.Fatalf("enabled = %d, want 0 (all skipped)", len(comps)) + } +} + +// TestEnabledComponents_GenericOverridesPreset: a generic component sharing a +// preset's name overrides that preset's chart, keeping a single release. +func TestEnabledComponents_GenericOverridesPreset(t *testing.T) { + comps := enabledComponents(map[string]any{ + "traefik": map[string]any{"enabled": true}, + "components": map[string]any{ + "traefik": map[string]any{"chart": map[string]any{"repo": "https://example.test/charts", "name": "my-traefik"}}, + }, + }) + if len(comps) != 1 { + t.Fatalf("enabled = %d, want 1 (deduped)", len(comps)) + } + if comps[0].chart.Name != "my-traefik" || comps[0].chart.Repo != "https://example.test/charts" { + t.Errorf("generic should override preset chart, got %+v", comps[0].chart) + } +} + +// TestEnabledComponents_PresetAndGenericOrder: presets come first in +// declaration order, then generic components sorted by name. +func TestEnabledComponents_PresetAndGenericOrder(t *testing.T) { + comps := enabledComponents(map[string]any{ + "traefik": map[string]any{"enabled": true}, + "components": map[string]any{ + "zzz": map[string]any{"chart": map[string]any{"repo": "r", "name": "z"}}, + "aaa": map[string]any{"chart": map[string]any{"repo": "r", "name": "a"}}, + }, + }) + got := []string{} + for _, c := range comps { + got = append(got, c.comp.name) + } + want := []string{"traefik", "aaa", "zzz"} + if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { + t.Errorf("order = %v, want %v", got, want) + } +} + func TestPlanPlatform(t *testing.T) { p := New() m := platformResource(map[string]any{ diff --git a/plugins/k8s/internal/provider/schema.go b/plugins/k8s/internal/provider/schema.go index f31739b..b80d1a1 100644 --- a/plugins/k8s/internal/provider/schema.go +++ b/plugins/k8s/internal/provider/schema.go @@ -117,6 +117,23 @@ const platformSchema = ` // a k3s worker pool's nodePrep.packages, or Longhorn's iscsi-installer // DaemonSet as a fallback. longhorn?: {...} + // components installs ANY Helm chart, no code change required — the + // generic, Terraform-provider-style path (the named fields above are + // just presets layered on this). Key is the release name. enabled + // defaults true (listing a component opts in); set false to disable it + // without removing the block. A generic component sharing a preset's + // name overrides that preset. + components?: {[string]: { + enabled?: bool | *true + chart: { + repo: string + name: string + version?: string + } + namespace?: string + wait?: bool + values?: {...} + }} } ... }