From c3e8ca49a0d627ea6182aba42d81daac1b922205 Mon Sep 17 00:00:00 2001 From: Jeremy Harrington Date: Tue, 21 Jul 2026 06:41:48 -0700 Subject: [PATCH] =?UTF-8?q?feat(k8s):=20Kustomize=20deployment=20kind=20?= =?UTF-8?q?=E2=80=94=20methods=20as=20providers=20(K4b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Kustomize kind to plugins/k8s, proving the K4(b) intent that deployment *methods* are just providers/kinds (alongside HelmRelease, Manifest, Argo) rather than bespoke platform code. - spec.files is an in-memory kustomization tree (path -> content); renderKustomization builds it with sigs.k8s.io/kustomize/api (krusty over an in-memory filesystem — no temp dirs, no network) into object YAML. spec.path picks the build dir (default "."). - The rendered objects flow through the SHARED Manifest object-apply core: factored applyObjectSet / getObjectSet from applyManifest / getManifest (both are "a set of applied cluster objects"), so a Kustomize is a Manifest whose content is computed. Delete/state are identical. manifestObserved gains a kind param. - #Kustomize CUE schema; registered in Handshake + Apply/Get/Delete dispatch. examples/kustomize-app.yaml demonstrates it. kustomize/api was already an indirect dep (v0.18 -> v0.21.1 upgrade), so the footprint change is modest. Flux and other methods follow the same pattern as future kinds — no framework change. Bounded per K5 (homelab PaaS via methods-as-providers). Tests: renderKustomization (transforms applied, overlay build-dir, invalid-kustomization errors), spec.files parsing; existing Manifest tests unchanged (shared core intact). --- ROADMAP.md | 22 +++-- examples/kustomize-app.yaml | 60 ++++++++++++ plugins/k8s/go.mod | 18 ++-- plugins/k8s/go.sum | 32 ++++--- plugins/k8s/internal/provider/kustomize.go | 94 +++++++++++++++++++ .../k8s/internal/provider/kustomize_test.go | 87 +++++++++++++++++ plugins/k8s/internal/provider/provider.go | 45 ++++++--- plugins/k8s/internal/provider/schema.go | 29 ++++++ 8 files changed, 347 insertions(+), 40 deletions(-) create mode 100644 examples/kustomize-app.yaml create mode 100644 plugins/k8s/internal/provider/kustomize.go create mode 100644 plugins/k8s/internal/provider/kustomize_test.go diff --git a/ROADMAP.md b/ROADMAP.md index 844d3ed..3841d1c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -300,9 +300,9 @@ 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 - for `Platform` is NOT a curated grab-bag of preferred charts but **generic - support**, Terraform-provider style. +- [x] **K4 — Generic Platform + deployment methods as providers — COMPLETE.** + The intent for `Platform` is NOT a curated grab-bag of preferred charts but + **generic 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?}}` @@ -312,10 +312,18 @@ these are the corners where drift would start if it starts anywhere. 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. + - [x] **(b) Deployment methods as providers — SHIPPED.** Added a + **Kustomize** kind to plugins/k8s (alongside HelmRelease/Manifest/Argo), + proving deployment *methods* are just kinds. `spec.files` is an in-memory + kustomization tree rendered via `kustomize build` + (`sigs.k8s.io/kustomize/api` krusty over an in-memory filesystem — no temp + dirs, no network) into object YAML, then applied through the **shared** + Manifest object-apply/get/delete/prune core (`applyObjectSet`/ + `getObjectSet` — a Kustomize is a Manifest whose content is computed). + `#Kustomize` schema. Tests: render (transforms, overlay build-dir, + invalid errors), `spec.files` parsing. Flux/others follow the same + pattern as future kinds, no framework change. Bounded per K5 (homelab + PaaS via methods-as-providers). - [ ] **K5 — Re-baseline the scope wedge (`direction.md`).** The doc originally vetoed workloads/PaaS ("stop at cluster-Ready"); the deployment model + diff --git a/examples/kustomize-app.yaml b/examples/kustomize-app.yaml new file mode 100644 index 0000000..487c85b --- /dev/null +++ b/examples/kustomize-app.yaml @@ -0,0 +1,60 @@ +# Kustomize deployment kind (K4b): a deployment *method* as a provider. The +# spec.files map is an in-memory kustomization tree; openctl renders it with +# `kustomize build` and applies the result onto the cluster the kubeconfigPath +# $ref points at — the same object-apply path as the Manifest kind. +apiVersion: k8s.openctl.io/v1 +kind: Kustomize +metadata: + name: guestbook +spec: + kubeconfigPath: + $ref: + apiVersion: k3s.openctl.io/v1 + kind: Cluster + name: home + field: status.outputs.kubeconfigPath + # path defaults to "." (kustomization.yaml at the tree root). + files: + kustomization.yaml: | + namespace: guestbook + namePrefix: gb- + commonLabels: + app.kubernetes.io/part-of: guestbook + resources: + - namespace.yaml + - deployment.yaml + - service.yaml + namespace.yaml: | + apiVersion: v1 + kind: Namespace + metadata: + name: guestbook + deployment.yaml: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: web + spec: + replicas: 2 + selector: + matchLabels: { app: web } + template: + metadata: + labels: { app: web } + spec: + containers: + - name: web + image: registry.k8s.io/e2e-test-images/agnhost:2.53 + args: ["netexec", "--http-port=8080"] + ports: + - containerPort: 8080 + service.yaml: | + apiVersion: v1 + kind: Service + metadata: + name: web + spec: + selector: { app: web } + ports: + - port: 80 + targetPort: 8080 diff --git a/plugins/k8s/go.mod b/plugins/k8s/go.mod index 1ebf1d0..3a43cfe 100644 --- a/plugins/k8s/go.mod +++ b/plugins/k8s/go.mod @@ -7,6 +7,11 @@ require ( k8s.io/cli-runtime v0.32.2 ) +require ( + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect +) + require ( dario.cat/mergo v1.0.1 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect @@ -52,10 +57,9 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.0.1 // indirect - github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect @@ -124,7 +128,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect helm.sh/helm/v3 v3.17.3 @@ -135,15 +139,15 @@ require ( k8s.io/client-go v0.32.2 k8s.io/component-base v0.32.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect k8s.io/kubectl v0.32.2 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect oras.land/oras-go v1.2.5 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/kustomize/api v0.18.0 // indirect - sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect + sigs.k8s.io/kustomize/api v0.21.1 + sigs.k8s.io/kustomize/kyaml v0.21.1 sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/yaml v1.5.0 // indirect ) replace github.com/openctl/openctl => ../.. diff --git a/plugins/k8s/go.sum b/plugins/k8s/go.sum index bd6afc9..f5c2a08 100644 --- a/plugins/k8s/go.sum +++ b/plugins/k8s/go.sum @@ -149,8 +149,8 @@ github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -160,8 +160,6 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= @@ -303,8 +301,8 @@ github.com/rubenv/sql-migrate v1.7.1 h1:f/o0WgfO/GqNuVg+6801K/KW3WdDSupzSjDYODmi github.com/rubenv/sql-migrate v1.7.1/go.mod h1:Ob2Psprc0/3ggbM6wCzyYVFFuc6FyZrb2AS+ezLDFb4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -365,6 +363,9 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -433,8 +434,8 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -463,8 +464,8 @@ k8s.io/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU= k8s.io/component-base v0.32.2/go.mod h1:PXJ61Vx9Lg+P5mS8TLd7bCIr+eMJRQTyXe8KvkrvJq0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= k8s.io/kubectl v0.32.2 h1:TAkag6+XfSBgkqK9I7ZvwtF0WVtUAvK8ZqTt+5zi1Us= k8s.io/kubectl v0.32.2/go.mod h1:+h/NQFSPxiDZYX/WZaWw9fwYezGLISP0ud8nQKg+3g8= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= @@ -473,11 +474,12 @@ oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo= -sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U= -sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E= -sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= +sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/plugins/k8s/internal/provider/kustomize.go b/plugins/k8s/internal/provider/kustomize.go new file mode 100644 index 0000000..fa09703 --- /dev/null +++ b/plugins/k8s/internal/provider/kustomize.go @@ -0,0 +1,94 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "sigs.k8s.io/kustomize/api/krusty" + "sigs.k8s.io/kustomize/kyaml/filesys" + + "github.com/openctl/openctl/pkg/pluginproto" + "github.com/openctl/openctl/pkg/protocol" +) + +// Kustomize is a deployment method as a provider (K4b): a first-class kind that +// renders a kustomization and applies the resulting objects, exactly like the +// Manifest kind but with the object YAML produced by `kustomize build` instead +// of a literal spec.manifest. State/Get/Delete are shared with Manifest — both +// are "a set of applied cluster objects" — so a Kustomize is a Manifest whose +// content is computed. +const kindKustomize = "Kustomize" + +// renderKustomization builds the kustomization rooted at buildDir out of an +// in-memory file tree (path -> content), returning the rendered multi-document +// YAML. Pure and offline (kustomize's in-memory filesystem — no temp dirs, no +// network), so it is unit-testable independent of a cluster. +func renderKustomization(files map[string]string, buildDir string) (string, error) { + fSys := filesys.MakeFsInMemory() + // Deterministic write order (in-memory fs is a map, but be explicit). + names := make([]string, 0, len(files)) + for n := range files { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + if err := fSys.WriteFile(n, []byte(files[n])); err != nil { + return "", fmt.Errorf("stage %s: %w", n, err) + } + } + k := krusty.MakeKustomizer(krusty.MakeDefaultOptions()) + resMap, err := k.Run(fSys, buildDir) + if err != nil { + return "", err + } + out, err := resMap.AsYaml() + if err != nil { + return "", fmt.Errorf("encode rendered objects: %w", err) + } + return string(out), nil +} + +// kustomizeFiles extracts the spec.files map (path -> content). Every value must +// be a string; a non-string is a schema-level error surfaced clearly here. +func kustomizeFiles(spec map[string]any) (map[string]string, error) { + raw, ok := spec["files"].(map[string]any) + if !ok || len(raw) == 0 { + return nil, fmt.Errorf("spec.files is required (map of path -> file content, including a kustomization.yaml)") + } + out := make(map[string]string, len(raw)) + for name, v := range raw { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("spec.files[%q] must be a string, got %T", name, v) + } + out[name] = s + } + return out, nil +} + +func (p *provider) applyKustomize(ctx context.Context, m *protocol.Resource, prior json.RawMessage) (*pluginproto.ApplyResult, error) { + content, path, err := kubeconfigFromSpec(m.Spec, m.Metadata.Name) + if err != nil { + return nil, err + } + files, err := kustomizeFiles(m.Spec) + if err != nil { + return nil, fmt.Errorf("kustomize %q: %w", m.Metadata.Name, err) + } + buildDir := specString(m.Spec, "path") + if buildDir == "" { + buildDir = "." + } + rendered, err := renderKustomization(files, buildDir) + if err != nil { + return nil, fmt.Errorf("kustomize build %q: %w", m.Metadata.Name, err) + } + // From here it is identical to a Manifest apply — parse, apply, prune, save. + return p.applyObjectSet(ctx, m, content, path, rendered, kindKustomize, prior) +} + +func (p *provider) getKustomize(ctx context.Context, req pluginproto.GetParams) (*pluginproto.GetResult, error) { + return p.getObjectSet(ctx, req, kindKustomize) +} diff --git a/plugins/k8s/internal/provider/kustomize_test.go b/plugins/k8s/internal/provider/kustomize_test.go new file mode 100644 index 0000000..10649be --- /dev/null +++ b/plugins/k8s/internal/provider/kustomize_test.go @@ -0,0 +1,87 @@ +package provider + +import ( + "strings" + "testing" +) + +func TestRenderKustomization_TransformsAndBuilds(t *testing.T) { + files := map[string]string{ + "kustomization.yaml": "" + + "namespace: demo\n" + + "namePrefix: prod-\n" + + "commonLabels:\n team: platform\n" + + "resources:\n - cm.yaml\n", + "cm.yaml": "" + + "apiVersion: v1\n" + + "kind: ConfigMap\n" + + "metadata:\n name: settings\n" + + "data:\n key: value\n", + } + out, err := renderKustomization(files, ".") + if err != nil { + t.Fatalf("renderKustomization: %v", err) + } + // The kustomize transforms must have been applied. + for _, want := range []string{"kind: ConfigMap", "name: prod-settings", "namespace: demo", "team: platform"} { + if !strings.Contains(out, want) { + t.Errorf("rendered output missing %q:\n%s", want, out) + } + } + // The rendered YAML must parse back into objects (feeds the Manifest apply core). + objs, err := parseObjects(out) + if err != nil { + t.Fatalf("parse rendered objects: %v", err) + } + if len(objs) != 1 || objs[0].GetName() != "prod-settings" { + t.Fatalf("expected one ConfigMap named prod-settings, got %d: %v", len(objs), objs) + } +} + +func TestRenderKustomization_BuildDirSubfolder(t *testing.T) { + // kustomization.yaml lives under overlays/prod; build that dir. + files := map[string]string{ + "base/cm.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: base\n", + "base/kustomization.yaml": "resources:\n - cm.yaml\n", + "overlays/prod/kustomization.yaml": "namePrefix: prod-\nresources:\n - ../../base\n", + } + out, err := renderKustomization(files, "overlays/prod") + if err != nil { + t.Fatalf("renderKustomization: %v", err) + } + if !strings.Contains(out, "name: prod-base") { + t.Errorf("overlay build did not apply namePrefix:\n%s", out) + } +} + +func TestRenderKustomization_InvalidErrors(t *testing.T) { + // A kustomization referencing a missing resource must error, not silently + // produce nothing. + _, err := renderKustomization(map[string]string{ + "kustomization.yaml": "resources:\n - nope.yaml\n", + }, ".") + if err == nil { + t.Fatal("expected an error building a kustomization with a missing resource") + } +} + +func TestKustomizeFiles(t *testing.T) { + // Missing files → error. + if _, err := kustomizeFiles(map[string]any{}); err == nil { + t.Error("want error when spec.files is absent") + } + // Non-string value → error. + if _, err := kustomizeFiles(map[string]any{"files": map[string]any{"a": 5}}); err == nil { + t.Error("want error when a file value is not a string") + } + // Happy path. + got, err := kustomizeFiles(map[string]any{"files": map[string]any{ + "kustomization.yaml": "resources: []\n", + }}) + if err != nil { + t.Fatalf("kustomizeFiles: %v", err) + } + if got["kustomization.yaml"] != "resources: []\n" { + t.Errorf("files not extracted: %v", got) + } +} diff --git a/plugins/k8s/internal/provider/provider.go b/plugins/k8s/internal/provider/provider.go index 2da603d..6a7b579 100644 --- a/plugins/k8s/internal/provider/provider.go +++ b/plugins/k8s/internal/provider/provider.go @@ -40,6 +40,7 @@ func (p *provider) Handshake(context.Context) (*pluginproto.HandshakeResult, err Kinds: []pluginproto.KindInfo{ {Kind: kindHelmRelease, Schema: helmReleaseSchema}, {Kind: kindManifest, Schema: manifestSchema}, + {Kind: kindKustomize, Schema: kustomizeSchema}, {Kind: kindPlatform, Schema: platformSchema}, {Kind: kindArgoApplications, Schema: argoApplicationsSchema}, }, @@ -119,12 +120,14 @@ func (p *provider) Apply(ctx context.Context, req pluginproto.ApplyParams) (*plu return p.applyHelmRelease(m) case kindManifest: return p.applyManifest(ctx, m, req.State) + case kindKustomize: + return p.applyKustomize(ctx, m, req.State) case kindPlatform: return p.applyPlatform(ctx, m, req.State) case kindArgoApplications: return p.applyArgoApplications(ctx, m) default: - return nil, pluginproto.Unsupported("k8s provider handles HelmRelease, Manifest and Platform, not " + m.Kind) + return nil, pluginproto.Unsupported("k8s provider handles HelmRelease, Manifest, Kustomize and Platform, not " + m.Kind) } } @@ -134,12 +137,14 @@ func (p *provider) Get(ctx context.Context, req pluginproto.GetParams) (*pluginp return p.getHelmRelease(req) case kindManifest: return p.getManifest(ctx, req) + case kindKustomize: + return p.getKustomize(ctx, req) case kindPlatform: return p.getPlatform(ctx, req) case kindArgoApplications: return p.getArgoApplications(ctx, req) default: - return nil, pluginproto.Unsupported("k8s provider handles HelmRelease, Manifest and Platform, not " + req.Kind) + return nil, pluginproto.Unsupported("k8s provider handles HelmRelease, Manifest, Kustomize and Platform, not " + req.Kind) } } @@ -147,7 +152,8 @@ func (p *provider) Delete(ctx context.Context, req pluginproto.DeleteParams) err switch req.Kind { case kindHelmRelease: return p.deleteHelmRelease(req) - case kindManifest: + case kindManifest, kindKustomize: + // Identical: both delete the recorded object set. return p.deleteManifest(ctx, req) case kindPlatform: return p.deletePlatform(ctx, req) @@ -250,9 +256,19 @@ func (p *provider) applyManifest(ctx context.Context, m *protocol.Resource, prio if manifestYAML == "" { return nil, fmt.Errorf("spec.manifest is required for Manifest %q", m.Metadata.Name) } + return p.applyObjectSet(ctx, m, content, path, manifestYAML, kindManifest, prior) +} + +// applyObjectSet applies a rendered multi-document manifest to the cluster, +// prunes objects that left the set since the prior apply, and records the +// applied object refs as state. Shared by the Manifest kind (YAML from +// spec.manifest) and the Kustomize kind (YAML from `kustomize build`) — both +// are "a set of applied cluster objects", so their apply/get/delete/state are +// identical apart from the rendered content and the kind label. +func (p *provider) applyObjectSet(ctx context.Context, m *protocol.Resource, content []byte, path, manifestYAML, kind string, prior json.RawMessage) (*pluginproto.ApplyResult, error) { objs, err := parseObjects(manifestYAML) if err != nil { - return nil, fmt.Errorf("parse Manifest %q: %w", m.Metadata.Name, err) + return nil, fmt.Errorf("parse %s %q: %w", kind, m.Metadata.Name, err) } kc, err := newKubeClient(content) if err != nil { @@ -268,7 +284,7 @@ func (p *provider) applyManifest(ctx context.Context, m *protocol.Resource, prio applied = append(applied, ref) } - // Prune objects that were in prior state but left the manifest. + // Prune objects that were in prior state but left the rendered set. var priorState manifestState if len(prior) > 0 { _ = json.Unmarshal(prior, &priorState) @@ -280,16 +296,23 @@ func (p *provider) applyManifest(ctx context.Context, m *protocol.Resource, prio } st, _ := json.Marshal(manifestState{kubeconfigState: kubeconfigStateOf(content, path), Objects: applied}) - return &pluginproto.ApplyResult{Resource: manifestObserved(m, applied), State: st}, nil + return &pluginproto.ApplyResult{Resource: manifestObserved(m, applied, kind), State: st}, nil } func (p *provider) getManifest(ctx context.Context, req pluginproto.GetParams) (*pluginproto.GetResult, error) { + return p.getObjectSet(ctx, req, kindManifest) +} + +// getObjectSet reports the health of a previously-applied object set by probing +// each recorded object in the cluster. Shared by Manifest and Kustomize (their +// state is the same object-ref list); kind labels the returned resource. +func (p *provider) getObjectSet(ctx context.Context, req pluginproto.GetParams, kind string) (*pluginproto.GetResult, error) { var st manifestState if len(req.State) > 0 { _ = json.Unmarshal(req.State, &st) } if len(st.Objects) == 0 || !st.present() { - return nil, pluginproto.NotFound(fmt.Sprintf("Manifest %q has no prior state", req.Name)) + return nil, pluginproto.NotFound(fmt.Sprintf("%s %q has no prior state", kind, req.Name)) } kc, err := st.loadKubeconfig() if err != nil { @@ -309,7 +332,7 @@ func (p *provider) getManifest(ctx context.Context, req pluginproto.GetParams) ( } } if present == 0 { - return nil, pluginproto.NotFound(fmt.Sprintf("Manifest %q objects not found in cluster", req.Name)) + return nil, pluginproto.NotFound(fmt.Sprintf("%s %q objects not found in cluster", kind, req.Name)) } status := map[string]any{ "objects": names, @@ -319,7 +342,7 @@ func (p *provider) getManifest(ctx context.Context, req pluginproto.GetParams) ( if present < len(st.Objects) { status["phase"] = "Degraded" } - r := &protocol.Resource{APIVersion: apiVersion, Kind: kindManifest, Status: status} + r := &protocol.Resource{APIVersion: apiVersion, Kind: kind, Status: status} r.Metadata.Name = req.Name return &pluginproto.GetResult{Resource: r, State: req.State}, nil } @@ -453,7 +476,7 @@ func observedFromRelease(name string, rel *release.Release) *protocol.Resource { // manifestObserved builds the applied resource for a Manifest: spec echoes the // desired manifest (minus credentials), status lists the applied objects. -func manifestObserved(m *protocol.Resource, refs []objectRef) *protocol.Resource { +func manifestObserved(m *protocol.Resource, refs []objectRef, kind string) *protocol.Resource { names := make([]string, 0, len(refs)) for _, r := range refs { names = append(names, r.String()) @@ -467,7 +490,7 @@ func manifestObserved(m *protocol.Resource, refs []objectRef) *protocol.Resource } r := &protocol.Resource{ APIVersion: apiVersion, - Kind: kindManifest, + Kind: kind, Spec: spec, Status: map[string]any{"objects": names, "applied": len(refs), "phase": "Ready"}, } diff --git a/plugins/k8s/internal/provider/schema.go b/plugins/k8s/internal/provider/schema.go index b80d1a1..cd42bae 100644 --- a/plugins/k8s/internal/provider/schema.go +++ b/plugins/k8s/internal/provider/schema.go @@ -76,6 +76,35 @@ const manifestSchema = ` } ` +// kustomizeSchema is the CUE schema for the Kustomize kind — a deployment +// method as a provider (K4b). It renders a kustomization from an in-memory file +// tree and applies the result like a Manifest. +const kustomizeSchema = ` +#Kustomize: { + apiVersion: "k8s.openctl.io/v1" + kind: "Kustomize" + metadata: { + name: string + ... + } + spec: { + // See HelmRelease: supply exactly one of kubeconfigPath ($ref to a + // Cluster's status.outputs.kubeconfigPath) or kubeconfig (inline, $secret). + kubeconfigPath?: string + kubeconfig?: string + // files is the kustomization tree: a map of relative path -> file content. + // It must include a kustomization.yaml at the build directory (see path), + // and may include the resource YAMLs, patches, and generator inputs it + // references. Rendered in-memory via kustomize build. + files: {[string]: string} + // path is the build directory within files (the dir holding + // kustomization.yaml). Defaults to "." (kustomization.yaml at the root). + path?: string + } + ... +} +` + // platformSchema is the CUE schema for the opt-in Platform composite: a curated, // infra-coupled platform layer. Nothing is enabled by default. Each component // installs a Helm release; disabling a previously-enabled one uninstalls it.