From 1136da69fe8e962994550ccae836141318e96ad1 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 20 Jan 2026 09:15:51 -0500 Subject: [PATCH 1/4] feat: add initial kubernetes extension Signed-off-by: Calum Murray --- cmd/main.go | 22 ++++++ go.mod | 40 ++++++++++ go.sum | 124 ++++++++++++++++++++++++++++++ pkg/extension/client.go | 49 ++++++++++++ pkg/extension/create.go | 63 +++++++++++++++ pkg/extension/create_test.go | 82 ++++++++++++++++++++ pkg/extension/delete.go | 69 +++++++++++++++++ pkg/extension/delete_test.go | 82 ++++++++++++++++++++ pkg/extension/extension.go | 84 ++++++++++++++++++++ pkg/extension/mock_client_test.go | 36 +++++++++ pkg/extension/operations.go | 108 ++++++++++++++++++++++++++ pkg/extension/resource.go | 64 +++++++++++++++ pkg/extension/resource_test.go | 108 ++++++++++++++++++++++++++ pkg/extension/wait.go | 115 +++++++++++++++++++++++++++ pkg/extension/wait_test.go | 97 +++++++++++++++++++++++ 15 files changed, 1143 insertions(+) create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/extension/client.go create mode 100644 pkg/extension/create.go create mode 100644 pkg/extension/create_test.go create mode 100644 pkg/extension/delete.go create mode 100644 pkg/extension/delete_test.go create mode 100644 pkg/extension/extension.go create mode 100644 pkg/extension/mock_client_test.go create mode 100644 pkg/extension/operations.go create mode 100644 pkg/extension/resource.go create mode 100644 pkg/extension/resource_test.go create mode 100644 pkg/extension/wait.go create mode 100644 pkg/extension/wait_test.go diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..5cc037b --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "github.com/genmcp/gevals-kubernetes-extension/pkg/extension" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + ext := extension.New() + + if err := ext.Run(ctx); err != nil { + log.Fatalf("extension error: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0282510 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/genmcp/gevals-kubernetes-extension + +go 1.25.5 + +require ( + github.com/genmcp/gevals v0.0.2-rc.1 + github.com/google/jsonschema-go v0.4.2 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/exp/event v0.0.0-20251125195548-87e1e737ad39 // indirect + golang.org/x/exp/jsonrpc2 v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bb2237f --- /dev/null +++ b/go.sum @@ -0,0 +1,124 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/genmcp/gevals v0.0.2-rc.1 h1:8JcVwiJNVRcBcYWJpN5kOOxbkm1AaoKBtGWIGNk+SeY= +github.com/genmcp/gevals v0.0.2-rc.1/go.mod h1:YuGxHNuRkq47+0DAEH0EQY1IJ4TUH/29ysnWA2JOLeA= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +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/exp/event v0.0.0-20251125195548-87e1e737ad39 h1:f5EV5KaFNN+jMNyN1tqh/XVz+blqdHTmm4QVrPChISw= +golang.org/x/exp/event v0.0.0-20251125195548-87e1e737ad39/go.mod h1:Sq4N4Q6DTQBFxWLvGHTLn9kAanCxrb/6EL3jADLxrIU= +golang.org/x/exp/jsonrpc2 v0.0.0-20251219203646-944ab1f22d93 h1:ZabgC+unQj2M9lE6PttK7ggcnal9oiamCCEaC0z5gSc= +golang.org/x/exp/jsonrpc2 v0.0.0-20251219203646-944ab1f22d93/go.mod h1:WwgJrh4tB4NUHMIiKK4ZXXQvqpz1wCl9jTFiiQtk9DM= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +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-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/extension/client.go b/pkg/extension/client.go new file mode 100644 index 0000000..b6f482d --- /dev/null +++ b/pkg/extension/client.go @@ -0,0 +1,49 @@ +package extension + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +// ResourceClient abstracts Kubernetes resource operations for testability. +// Implementations can use the real dynamic client or a mock for testing. +type ResourceClient interface { + // Create creates a Kubernetes resource and returns the created object. + Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) + + // Get retrieves a Kubernetes resource by name. + Get(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) + + // Delete removes a Kubernetes resource. + Delete(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error +} + +// dynamicClientAdapter adapts the Kubernetes dynamic client to the ResourceClient interface. +type dynamicClientAdapter struct { + client dynamic.Interface +} + +func (a *dynamicClientAdapter) Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { + if namespace != "" { + return a.client.Resource(gvr).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{}) + } + return a.client.Resource(gvr).Create(ctx, obj, metav1.CreateOptions{}) +} + +func (a *dynamicClientAdapter) Get(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) { + if namespace != "" { + return a.client.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + } + return a.client.Resource(gvr).Get(ctx, name, metav1.GetOptions{}) +} + +func (a *dynamicClientAdapter) Delete(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error { + if namespace != "" { + return a.client.Resource(gvr).Namespace(namespace).Delete(ctx, name, opts) + } + return a.client.Resource(gvr).Delete(ctx, name, opts) +} diff --git a/pkg/extension/create.go b/pkg/extension/create.go new file mode 100644 index 0000000..c53e3b9 --- /dev/null +++ b/pkg/extension/create.go @@ -0,0 +1,63 @@ +package extension + +import ( + "context" + "fmt" + + "github.com/genmcp/gevals/pkg/extension/sdk" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func (e *Extension) handleCreate(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + // Args is the resource spec as a map + resourceSpec, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be a resource spec object")), nil + } + + obj := &unstructured.Unstructured{Object: resourceSpec} + + gvk := obj.GroupVersionKind() + if gvk.Kind == "" { + return sdk.Failure(fmt.Errorf("kind is required")), nil + } + + gvr := gvkToGVR(gvk) + namespace := obj.GetNamespace() + + e.LogInfo(ctx, "Creating resource", map[string]any{ + "kind": gvk.Kind, + "name": obj.GetName(), + "namespace": namespace, + }) + + result, err := e.client.Create(ctx, gvr, obj, namespace) + if err != nil { + e.LogError(ctx, "Failed to create resource", map[string]any{ + "kind": gvk.Kind, + "name": obj.GetName(), + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("failed to create resource: %w", err)), nil + } + + e.LogInfo(ctx, "Resource created successfully", map[string]any{ + "kind": gvk.Kind, + "name": result.GetName(), + "uid": string(result.GetUID()), + }) + + return sdk.SuccessWithOutputs( + fmt.Sprintf("Created %s/%s", gvk.Kind, result.GetName()), + map[string]string{ + "name": result.GetName(), + "namespace": result.GetNamespace(), + "uid": string(result.GetUID()), + "resourceVersion": result.GetResourceVersion(), + }, + ), nil +} diff --git a/pkg/extension/create_test.go b/pkg/extension/create_test.go new file mode 100644 index 0000000..71f43da --- /dev/null +++ b/pkg/extension/create_test.go @@ -0,0 +1,82 @@ +package extension + +import ( + "context" + "errors" + "testing" + + "github.com/genmcp/gevals/pkg/extension/sdk" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestHandleCreate(t *testing.T) { + tests := []struct { + name string + args any + client *mockClient + wantSuccess bool + }{ + { + name: "successful create", + args: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-cm", + "namespace": "default", + }, + }, + client: &mockClient{ + createFn: func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { + result := obj.DeepCopy() + result.SetUID("test-uid") + result.SetResourceVersion("1") + return result, nil + }, + }, + wantSuccess: true, + }, + { + name: "invalid args type", + args: "not a map", + client: &mockClient{}, + wantSuccess: false, + }, + { + name: "client error", + args: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-cm", + }, + }, + client: &mockClient{ + createFn: func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { + return nil, errors.New("connection refused") + }, + }, + wantSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + client: tt.client, + } + + req := &sdk.OperationRequest{Args: tt.args} + result, err := ext.handleCreate(context.Background(), req) + + if err != nil { + t.Fatalf("handleCreate() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleCreate() success = %v, want %v", result.Success, tt.wantSuccess) + } + }) + } +} diff --git a/pkg/extension/delete.go b/pkg/extension/delete.go new file mode 100644 index 0000000..e448ccd --- /dev/null +++ b/pkg/extension/delete.go @@ -0,0 +1,69 @@ +package extension + +import ( + "context" + "fmt" + + "github.com/genmcp/gevals/pkg/extension/sdk" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (e *Extension) handleDelete(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + args, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be an object")), nil + } + + ref, err := parseResourceRef(args) + if err != nil { + return sdk.Failure(err), nil + } + + ignoreNotFound, _ := args["ignoreNotFound"].(bool) + + gvr, err := ref.gvr() + if err != nil { + return sdk.Failure(err), nil + } + + e.LogInfo(ctx, "Deleting resource", map[string]any{ + "kind": ref.kind, + "name": ref.name, + "namespace": ref.namespace, + "ignoreNotFound": ignoreNotFound, + }) + + propagation := metav1.DeletePropagationForeground + deleteOpts := metav1.DeleteOptions{ + PropagationPolicy: &propagation, + } + + err = e.client.Delete(ctx, gvr, ref.name, ref.namespace, deleteOpts) + if err != nil { + if ignoreNotFound && apierrors.IsNotFound(err) { + e.LogInfo(ctx, "Resource not found (ignored)", map[string]any{ + "kind": ref.kind, + "name": ref.name, + }) + return sdk.Success(fmt.Sprintf("%s/%s not found (ignored)", ref.kind, ref.name)), nil + } + e.LogError(ctx, "Failed to delete resource", map[string]any{ + "kind": ref.kind, + "name": ref.name, + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("failed to delete resource: %w", err)), nil + } + + e.LogInfo(ctx, "Resource deleted successfully", map[string]any{ + "kind": ref.kind, + "name": ref.name, + }) + + return sdk.Success(fmt.Sprintf("Deleted %s/%s", ref.kind, ref.name)), nil +} diff --git a/pkg/extension/delete_test.go b/pkg/extension/delete_test.go new file mode 100644 index 0000000..fa1ba1d --- /dev/null +++ b/pkg/extension/delete_test.go @@ -0,0 +1,82 @@ +package extension + +import ( + "context" + "testing" + + "github.com/genmcp/gevals/pkg/extension/sdk" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestHandleDelete(t *testing.T) { + tests := []struct { + name string + args any + client *mockClient + wantSuccess bool + }{ + { + name: "successful delete", + args: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-cm", + "namespace": "default", + }, + }, + client: &mockClient{}, + wantSuccess: true, + }, + { + name: "not found with ignoreNotFound", + args: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": "missing"}, + "ignoreNotFound": true, + }, + client: &mockClient{ + deleteFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error { + return apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "missing") + }, + }, + wantSuccess: true, + }, + { + name: "not found without ignoreNotFound", + args: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": "missing"}, + }, + client: &mockClient{ + deleteFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error { + return apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "missing") + }, + }, + wantSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + client: tt.client, + } + + req := &sdk.OperationRequest{Args: tt.args} + result, err := ext.handleDelete(context.Background(), req) + + if err != nil { + t.Fatalf("handleDelete() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleDelete() success = %v, want %v", result.Success, tt.wantSuccess) + } + }) + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go new file mode 100644 index 0000000..caadff3 --- /dev/null +++ b/pkg/extension/extension.go @@ -0,0 +1,84 @@ +package extension + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/genmcp/gevals/pkg/extension/sdk" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/tools/clientcmd" +) + +// Extension wraps the SDK extension with Kubernetes client +type Extension struct { + *sdk.Extension + client ResourceClient +} + +// New creates a new Kubernetes extension +func New() *Extension { + ext := &Extension{} + ext.Extension = sdk.NewExtension( + sdk.ExtensionInfo{ + Name: "kubernetes", + Version: "0.1.0", + Description: "Kubernetes resource operations using client-go", + }, + sdk.WithInitializeHandler(ext.handleInitialize), + ) + + ext.registerOperations() + return ext +} + +func (e *Extension) handleInitialize(config map[string]any) error { + kubeconfigPath := "" + + if path, ok := config["kubeconfig"].(string); ok { + kubeconfigPath = path + } + + // Expand ~ to home directory + if strings.HasPrefix(kubeconfigPath, "~") { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + kubeconfigPath = filepath.Join(home, kubeconfigPath[1:]) + } + + // If no kubeconfig specified, use default + if kubeconfigPath == "" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + kubeconfigPath = filepath.Join(home, ".kube", "config") + } + + // Validate kubeconfig file exists + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return fmt.Errorf("kubeconfig not found: %s", kubeconfigPath) + } + + kubeconfig, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath) + if err != nil { + return fmt.Errorf("failed to build kubeconfig from %s: %w", kubeconfigPath, err) + } + + client, err := dynamic.NewForConfig(kubeconfig) + if err != nil { + return fmt.Errorf("failed to create dynamic client: %w", err) + } + + e.client = &dynamicClientAdapter{client: client} + return nil +} + +// Run starts the extension, listening for JSON-RPC messages on stdin/stdout +func (e *Extension) Run(ctx context.Context) error { + return e.Extension.Run(ctx) +} diff --git a/pkg/extension/mock_client_test.go b/pkg/extension/mock_client_test.go new file mode 100644 index 0000000..d302469 --- /dev/null +++ b/pkg/extension/mock_client_test.go @@ -0,0 +1,36 @@ +package extension + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type mockClient struct { + createFn func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) + getFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) + deleteFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error +} + +func (m *mockClient) Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { + if m.createFn != nil { + return m.createFn(ctx, gvr, obj, namespace) + } + return obj, nil +} + +func (m *mockClient) Get(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) { + if m.getFn != nil { + return m.getFn(ctx, gvr, name, namespace) + } + return nil, nil +} + +func (m *mockClient) Delete(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error { + if m.deleteFn != nil { + return m.deleteFn(ctx, gvr, name, namespace, opts) + } + return nil +} diff --git a/pkg/extension/operations.go b/pkg/extension/operations.go new file mode 100644 index 0000000..291e30e --- /dev/null +++ b/pkg/extension/operations.go @@ -0,0 +1,108 @@ +package extension + +import ( + "github.com/genmcp/gevals/pkg/extension/sdk" + "github.com/google/jsonschema-go/jsonschema" +) + +// registerOperations adds all available Kubernetes operations to the extension. +// Each operation is defined with a JSON schema for input validation and a handler function. +func (e *Extension) registerOperations() { + e.AddOperation( + sdk.NewOperation("create", + sdk.WithDescription("Create a Kubernetes resource"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Kubernetes resource spec (apiVersion, kind, metadata, spec, etc.)", + Properties: map[string]*jsonschema.Schema{ + "apiVersion": { + Type: "string", + Description: "API version (e.g., v1, apps/v1)", + }, + "kind": { + Type: "string", + Description: "Resource kind (e.g., Pod, Namespace, Deployment)", + }, + "metadata": { + Type: "object", + Description: "Resource metadata (name, namespace, labels, annotations)", + }, + "spec": { + Type: "object", + Description: "Resource spec (optional, depends on resource type)", + }, + }, + Required: []string{"apiVersion", "kind", "metadata"}, + }), + ), + e.handleCreate, + ) + + e.AddOperation( + sdk.NewOperation("wait", + sdk.WithDescription("Wait for a condition on a Kubernetes resource"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Resource reference with condition to wait for", + Properties: map[string]*jsonschema.Schema{ + "apiVersion": { + Type: "string", + Description: "API version (e.g., v1, apps/v1)", + }, + "kind": { + Type: "string", + Description: "Resource kind (e.g., Pod, Deployment)", + }, + "metadata": { + Type: "object", + Description: "Resource metadata (name, namespace)", + }, + "condition": { + Type: "string", + Description: "Condition type to wait for (e.g., Ready, Available)", + }, + "status": { + Type: "string", + Description: "Expected condition status (default: True)", + }, + "timeout": { + Type: "string", + Description: "Timeout duration (e.g., 60s, 5m, default: 60s)", + }, + }, + Required: []string{"apiVersion", "kind", "metadata", "condition"}, + }), + ), + e.handleWait, + ) + + e.AddOperation( + sdk.NewOperation("delete", + sdk.WithDescription("Delete a Kubernetes resource"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Resource reference to delete", + Properties: map[string]*jsonschema.Schema{ + "apiVersion": { + Type: "string", + Description: "API version (e.g., v1, apps/v1)", + }, + "kind": { + Type: "string", + Description: "Resource kind (e.g., Pod, Namespace)", + }, + "metadata": { + Type: "object", + Description: "Resource metadata (name, namespace)", + }, + "ignoreNotFound": { + Type: "boolean", + Description: "If true, do not fail when the resource does not exist", + }, + }, + Required: []string{"apiVersion", "kind", "metadata"}, + }), + ), + e.handleDelete, + ) +} diff --git a/pkg/extension/resource.go b/pkg/extension/resource.go new file mode 100644 index 0000000..47ddf7e --- /dev/null +++ b/pkg/extension/resource.go @@ -0,0 +1,64 @@ +package extension + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// resourceRef holds parsed resource reference information from operation arguments. +type resourceRef struct { + apiVersion string + kind string + name string + namespace string +} + +// parseResourceRef extracts resource reference info from operation arguments. +// It validates that required fields (apiVersion, kind, metadata.name) are present. +func parseResourceRef(args map[string]any) (*resourceRef, error) { + apiVersion, _ := args["apiVersion"].(string) + kind, _ := args["kind"].(string) + + if apiVersion == "" { + return nil, fmt.Errorf("apiVersion is required") + } + if kind == "" { + return nil, fmt.Errorf("kind is required") + } + + ref := &resourceRef{ + apiVersion: apiVersion, + kind: kind, + } + + if metadata, ok := args["metadata"].(map[string]any); ok { + ref.name, _ = metadata["name"].(string) + ref.namespace, _ = metadata["namespace"].(string) + } + + if ref.name == "" { + return nil, fmt.Errorf("metadata.name is required") + } + + return ref, nil +} + +// gvr converts the resource reference to a GroupVersionResource. +func (r *resourceRef) gvr() (schema.GroupVersionResource, error) { + gv, err := schema.ParseGroupVersion(r.apiVersion) + if err != nil { + return schema.GroupVersionResource{}, fmt.Errorf("invalid apiVersion: %w", err) + } + gvk := gv.WithKind(r.kind) + return gvkToGVR(gvk), nil +} + +// gvkToGVR converts a GroupVersionKind to GroupVersionResource using Kubernetes +// built-in pluralization logic which correctly handles compound CamelCase kinds +// and common irregular plurals (e.g., Ingress → ingresses, NetworkPolicy → networkpolicies). +func gvkToGVR(gvk schema.GroupVersionKind) schema.GroupVersionResource { + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return gvr +} diff --git a/pkg/extension/resource_test.go b/pkg/extension/resource_test.go new file mode 100644 index 0000000..a40afb1 --- /dev/null +++ b/pkg/extension/resource_test.go @@ -0,0 +1,108 @@ +package extension + +import ( + "testing" +) + +func TestParseResourceRef(t *testing.T) { + tests := []struct { + name string + args map[string]any + want *resourceRef + wantErr bool + }{ + { + name: "valid namespaced resource", + args: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "default", + }, + }, + want: &resourceRef{ + apiVersion: "apps/v1", + kind: "Deployment", + name: "nginx", + namespace: "default", + }, + }, + { + name: "missing apiVersion", + args: map[string]any{ + "kind": "Pod", + "metadata": map[string]any{ + "name": "test", + }, + }, + wantErr: true, + }, + { + name: "missing metadata.name", + args: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseResourceRef(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("parseResourceRef() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.want != nil { + if got.apiVersion != tt.want.apiVersion || + got.kind != tt.want.kind || + got.name != tt.want.name || + got.namespace != tt.want.namespace { + t.Errorf("parseResourceRef() = %+v, want %+v", got, tt.want) + } + } + }) + } +} + +func TestResourceRefGVR(t *testing.T) { + tests := []struct { + name string + ref *resourceRef + wantGroup string + wantVersion string + wantResource string + wantErr bool + }{ + { + name: "core v1 pod", + ref: &resourceRef{apiVersion: "v1", kind: "Pod"}, + wantGroup: "", + wantVersion: "v1", + wantResource: "pods", + }, + { + name: "apps/v1 deployment", + ref: &resourceRef{apiVersion: "apps/v1", kind: "Deployment"}, + wantGroup: "apps", + wantVersion: "v1", + wantResource: "deployments", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gvr, err := tt.ref.gvr() + if (err != nil) != tt.wantErr { + t.Errorf("gvr() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gvr.Group != tt.wantGroup || gvr.Version != tt.wantVersion || gvr.Resource != tt.wantResource { + t.Errorf("gvr() = %v, want %s/%s/%s", gvr, tt.wantGroup, tt.wantVersion, tt.wantResource) + } + }) + } +} diff --git a/pkg/extension/wait.go b/pkg/extension/wait.go new file mode 100644 index 0000000..d1cd9dc --- /dev/null +++ b/pkg/extension/wait.go @@ -0,0 +1,115 @@ +package extension + +import ( + "context" + "fmt" + "time" + + "github.com/genmcp/gevals/pkg/extension/sdk" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/wait" +) + +func (e *Extension) handleWait(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + args, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be an object")), nil + } + + ref, err := parseResourceRef(args) + if err != nil { + return sdk.Failure(err), nil + } + + condition, _ := args["condition"].(string) + if condition == "" { + return sdk.Failure(fmt.Errorf("condition is required")), nil + } + + status, _ := args["status"].(string) + if status == "" { + status = "True" + } + + timeoutStr, _ := args["timeout"].(string) + if timeoutStr == "" { + timeoutStr = "60s" + } + + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + return sdk.Failure(fmt.Errorf("invalid timeout format: %w", err)), nil + } + + gvr, err := ref.gvr() + if err != nil { + return sdk.Failure(err), nil + } + + e.LogInfo(ctx, "Waiting for condition", map[string]any{ + "kind": ref.kind, + "name": ref.name, + "namespace": ref.namespace, + "condition": condition, + "status": status, + "timeout": timeoutStr, + }) + + var lastStatus string + err = wait.PollUntilContextTimeout(ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + obj, getErr := e.client.Get(ctx, gvr, ref.name, ref.namespace) + if getErr != nil { + return false, nil // Keep polling on transient errors + } + + conditions, found, err := unstructured.NestedSlice(obj.Object, "status", "conditions") + if err != nil || !found { + lastStatus = "NoConditions" + return false, nil + } + + for _, c := range conditions { + cond, ok := c.(map[string]any) + if !ok { + continue + } + + condType, _, _ := unstructured.NestedString(cond, "type") + condStatus, _, _ := unstructured.NestedString(cond, "status") + + if condType == condition { + lastStatus = condStatus + return condStatus == status, nil + } + } + + lastStatus = "ConditionNotFound" + return false, nil + }) + + if err != nil { + e.LogError(ctx, "Condition wait timed out", map[string]any{ + "kind": ref.kind, + "name": ref.name, + "condition": condition, + "lastStatus": lastStatus, + }) + return sdk.FailureWithMessage( + fmt.Sprintf("Condition %s=%s not met", condition, status), + fmt.Errorf("timed out waiting for %s/%s: last status was %s", ref.kind, ref.name, lastStatus), + ), nil + } + + e.LogInfo(ctx, "Condition met", map[string]any{ + "kind": ref.kind, + "name": ref.name, + "condition": condition, + "status": status, + }) + + return sdk.Success(fmt.Sprintf("%s/%s condition %s=%s", ref.kind, ref.name, condition, status)), nil +} diff --git a/pkg/extension/wait_test.go b/pkg/extension/wait_test.go new file mode 100644 index 0000000..71f0bcd --- /dev/null +++ b/pkg/extension/wait_test.go @@ -0,0 +1,97 @@ +package extension + +import ( + "context" + "testing" + + "github.com/genmcp/gevals/pkg/extension/sdk" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestHandleWait(t *testing.T) { + tests := []struct { + name string + args any + client *mockClient + wantSuccess bool + }{ + { + name: "condition already met", + args: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{"name": "nginx", "namespace": "default"}, + "condition": "Available", + "timeout": "1s", + }, + client: &mockClient{ + getFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{ + Object: map[string]any{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{"type": "Available", "status": "True"}, + }, + }, + }, + }, nil + }, + }, + wantSuccess: true, + }, + { + name: "condition not met within timeout", + args: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{"name": "nginx", "namespace": "default"}, + "condition": "Available", + "timeout": "1s", + }, + client: &mockClient{ + getFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{ + Object: map[string]any{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{"type": "Available", "status": "False"}, + }, + }, + }, + }, nil + }, + }, + wantSuccess: false, + }, + { + name: "missing condition field", + args: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{"name": "test"}, + }, + client: &mockClient{}, + wantSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + client: tt.client, + } + + req := &sdk.OperationRequest{Args: tt.args} + result, err := ext.handleWait(context.Background(), req) + + if err != nil { + t.Fatalf("handleWait() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleWait() success = %v, want %v", result.Success, tt.wantSuccess) + } + }) + } +} From 9a8e68b60156565bd90b6e929289086ae696325b Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 20 Jan 2026 09:16:09 -0500 Subject: [PATCH 2/4] chore: add Makefile, github actions Signed-off-by: Calum Murray --- .github/dependabot.yaml | 11 ++ .github/workflows/nightly.yaml | 159 ++++++++++++++++++++++++++++ .github/workflows/prerelease.yaml | 130 +++++++++++++++++++++++ .github/workflows/release.yaml | 139 +++++++++++++++++++++++++ .github/workflows/test.yaml | 20 ++++ Makefile | 166 ++++++++++++++++++++++++++++++ 6 files changed, 625 insertions(+) create mode 100644 .github/dependabot.yaml create mode 100644 .github/workflows/nightly.yaml create mode 100644 .github/workflows/prerelease.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 Makefile diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..d163711 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml new file mode 100644 index 0000000..e7653ca --- /dev/null +++ b/.github/workflows/nightly.yaml @@ -0,0 +1,159 @@ +name: Nightly Release + +on: + schedule: + # Run at 02:00 UTC every day + - cron: '0 2 * * *' + workflow_dispatch: + +concurrency: + group: nightly-release + cancel-in-progress: true + +jobs: + check-and-create-nightly: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + nightly_version: ${{ env.NIGHTLY_VERSION }} + skip_nightly: ${{ env.SKIP_NIGHTLY }} + changelog_body: ${{ env.CHANGELOG_BODY }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for unreleased commits + id: check_commits + run: | + # Find the latest release tag (any x.y.z release) + LATEST_RELEASE=$(git tag -l 'v*.*.*' --sort=-version:refname | grep -v 'prerelease' | grep -v '^nightly' | head -n1) + + if [ -z "$LATEST_RELEASE" ]; then + echo "No existing releases found, creating first nightly" + COMMITS_SINCE_RELEASE=$(git rev-list HEAD --count) + LATEST_RELEASE_COMMIT="" + else + echo "Latest release: $LATEST_RELEASE" + COMMITS_SINCE_RELEASE=$(git rev-list ${LATEST_RELEASE}..HEAD --count) + LATEST_RELEASE_COMMIT="$LATEST_RELEASE" + fi + + echo "Commits since latest release: $COMMITS_SINCE_RELEASE" + + if [ "$COMMITS_SINCE_RELEASE" -eq "0" ]; then + echo "No new commits since latest release, skipping nightly" + echo "SKIP_NIGHTLY=true" >> "$GITHUB_ENV" + exit 0 + fi + + CURRENT_COMMIT=$(git rev-parse HEAD) + + # Check for existing nightly with same commit + EXISTING_NIGHTLY_COMMIT="" + if git rev-parse --verify nightly >/dev/null 2>&1; then + EXISTING_NIGHTLY_COMMIT=$(git rev-list -n 1 nightly 2>/dev/null || echo "") + fi + + if [ "$EXISTING_NIGHTLY_COMMIT" = "$CURRENT_COMMIT" ]; then + echo "Nightly release already exists for current commit" + echo "SKIP_NIGHTLY=true" >> "$GITHUB_ENV" + exit 0 + fi + + # Use fixed nightly tag name + NIGHTLY_VERSION="nightly" + echo "NIGHTLY_VERSION=$NIGHTLY_VERSION" >> "$GITHUB_ENV" + echo "Creating nightly release: $NIGHTLY_VERSION" + + - name: Extract changelog for nightly + id: changelog + if: env.SKIP_NIGHTLY != 'true' + run: | + # Extract the Unreleased section from CHANGELOG.md using make target + CHANGELOG_CONTENT=$(make extract-changelog-unreleased) + + # Save to environment variable, handling multiline content + { + echo 'CHANGELOG_BODY<> "$GITHUB_ENV" + + - name: Delete existing nightly release and tag + if: env.SKIP_NIGHTLY != 'true' + run: | + # Delete existing nightly release if it exists + if gh release view nightly >/dev/null 2>&1; then + echo "Deleting existing nightly release" + gh release delete nightly --yes + fi + + # Delete existing nightly tag if it exists + if git rev-parse --verify nightly >/dev/null 2>&1; then + echo "Deleting existing nightly tag" + git tag -d nightly + if ! git push origin :refs/tags/nightly; then + echo "Warning: Failed to delete remote nightly tag" >&2 + exit 1 + fi + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create nightly release + if: env.SKIP_NIGHTLY != 'true' + run: | + VERSION="${{ env.NIGHTLY_VERSION }}" + CURRENT_DATE=$(date -u +%Y-%m-%d) + SHORT_COMMIT=$(git rev-parse --short HEAD) + + echo "Creating nightly release: $VERSION" + gh release create "$VERSION" \ + --title "Nightly Release ($CURRENT_DATE - $SHORT_COMMIT)" \ + --notes "${{ env.CHANGELOG_BODY }}" \ + --prerelease + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-and-upload: + needs: check-and-create-nightly + if: needs.check-and-create-nightly.outputs.skip_nightly != 'true' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # Permission for keyless signing + strategy: + matrix: + goos: [linux, windows, darwin] + goarch: [amd64, arm64] + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Install cosign + uses: sigstore/cosign-installer@v4.0.0 + + - name: Build release artifacts + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: make build-release package-release + + - name: Sign release artifacts + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: make sign-release + + - name: Upload assets to nightly release + run: make upload-release-assets VERSION="${{ needs.check-and-create-nightly.outputs.nightly_version }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prerelease.yaml b/.github/workflows/prerelease.yaml new file mode 100644 index 0000000..4dfe1c9 --- /dev/null +++ b/.github/workflows/prerelease.yaml @@ -0,0 +1,130 @@ +name: Create Pre-Release + +on: + push: + tags: + - 'v*.*.*-rc.*' + workflow_dispatch: + inputs: + tag: + description: 'Pre-release tag (e.g., v1.0.0-rc.1)' + required: true + type: string + +jobs: + determine-version: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + prerelease_version: ${{ env.PRERELEASE_VERSION }} + changelog_body: ${{ env.CHANGELOG_BODY }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: get_version + run: | + # Get version from tag (either from push event or manual input) + if [ "${{ github.event_name }}" = "push" ]; then + VERSION="${{ github.ref_name }}" + else + VERSION="${{ inputs.tag }}" + fi + + # Validate version format using make target + make validate-prerelease-tag VERSION="$VERSION" + + echo "PRERELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Pre-release version: $VERSION" + + - name: Extract changelog + id: changelog + run: | + VERSION="${{ env.PRERELEASE_VERSION }}" + # Extract the base release version (without -rc.N suffix) + RELEASE_VERSION=$(echo "$VERSION" | sed 's/-rc\.[0-9]*$//') + + # Extract changelog using make target (tries version-specific, then unreleased) + CHANGELOG_CONTENT=$(make extract-changelog-version VERSION="$RELEASE_VERSION" 2>/dev/null || echo "Pre-release build for ${RELEASE_VERSION}. See CHANGELOG.md for details.") + + # Save to environment variable, handling multiline content + { + echo 'CHANGELOG_BODY<> "$GITHUB_ENV" + + - name: Create or update pre-release + run: | + VERSION="${{ env.PRERELEASE_VERSION }}" + + # Check if release already exists + if gh release view "$VERSION" >/dev/null 2>&1; then + echo "Pre-release $VERSION already exists, checking if changelog needs to be updated..." + + # Get current release notes + CURRENT_NOTES=$(gh release view "$VERSION" --json body -q .body) + + # Check if changelog content is already in the notes + if echo "$CURRENT_NOTES" | grep -qF "${{ env.CHANGELOG_BODY }}"; then + echo "Changelog already present in release notes, skipping update..." + else + echo "Appending changelog to existing release notes..." + NEW_NOTES="${CURRENT_NOTES}\n\n---\n\n${{ env.CHANGELOG_BODY }}" + gh release edit "$VERSION" --notes "$NEW_NOTES" + fi + else + echo "Creating pre-release $VERSION..." + gh release create "$VERSION" \ + --title "$VERSION" \ + --notes "${{ env.CHANGELOG_BODY }}" \ + --prerelease + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-and-upload: + needs: determine-version + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # Permission for keyless signing + strategy: + matrix: + goos: [linux, windows, darwin] + goarch: [amd64, arm64] + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Install cosign + uses: sigstore/cosign-installer@v4.0.0 + + - name: Build release artifacts + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + VERSION: ${{ needs.determine-version.outputs.prerelease_version }} + run: make build-release package-release + + - name: Sign release artifacts + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: make sign-release + + - name: Upload assets to release + run: make upload-release-assets VERSION="${{ needs.determine-version.outputs.prerelease_version }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..3234dc7 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,139 @@ +name: Publish Release + +on: + push: + tags: + - 'v*.*.*' + - '!v*.*.*-*' # Exclude pre-releases + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g., v1.0.0)' + required: true + type: string + +jobs: + determine-version: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ env.VERSION }} + changelog_body: ${{ env.CHANGELOG_BODY }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: get_version + run: | + # Get version from tag (either from push event or manual input) + if [ "${{ github.event_name }}" = "push" ]; then + VERSION="${{ github.ref_name }}" + else + VERSION="${{ inputs.tag }}" + fi + + # Validate version format using make target + make validate-release-tag VERSION="$VERSION" + + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Release version: $VERSION" + + - name: Validate CHANGELOG has version section + run: make validate-changelog-has-version VERSION="${{ env.VERSION }}" + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Extract changelog for release + id: changelog + run: | + VERSION="${{ env.VERSION }}" + # Extract the specific version section from CHANGELOG.md using make target + CHANGELOG_CONTENT=$(make extract-changelog-version VERSION="$VERSION") + + # Save to environment variable, handling multiline content + { + echo 'CHANGELOG_BODY<> "$GITHUB_ENV" + + - name: Run tests + run: make test + + - name: Create or update release + run: | + VERSION="${{ env.VERSION }}" + + # Check if release already exists + if gh release view "$VERSION" >/dev/null 2>&1; then + echo "Release $VERSION already exists, checking if changelog needs to be updated..." + + # Get current release notes + CURRENT_NOTES=$(gh release view "$VERSION" --json body -q .body) + + # Check if changelog content is already in the notes + if echo "$CURRENT_NOTES" | grep -qF "${{ env.CHANGELOG_BODY }}"; then + echo "Changelog already present in release notes, skipping update..." + else + echo "Appending changelog to existing release notes..." + NEW_NOTES="${CURRENT_NOTES}\n\n---\n\n${{ env.CHANGELOG_BODY }}" + gh release edit "$VERSION" --notes "$NEW_NOTES" + fi + else + echo "Creating release $VERSION..." + gh release create "$VERSION" \ + --title "$VERSION" \ + --notes "${{ env.CHANGELOG_BODY }}" \ + --latest + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-and-upload: + needs: determine-version + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # Permission for keyless signing + strategy: + matrix: + goos: [linux, windows, darwin] + goarch: [amd64, arm64] + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Install cosign + uses: sigstore/cosign-installer@v4.0.0 + + - name: Build release artifacts + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + VERSION: ${{ needs.determine-version.outputs.version }} + run: make build-release package-release + + - name: Sign release artifacts + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: make sign-release + + - name: Upload assets to release + run: make upload-release-assets VERSION="${{ needs.determine-version.outputs.version }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..c6da8c5 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,20 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run tests + run: make test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c404477 --- /dev/null +++ b/Makefile @@ -0,0 +1,166 @@ +BINARY_NAME := gevals-kubernetes-extension +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) + +.PHONY: build +build: + go build -o bin/$(BINARY_NAME) ./cmd + +.PHONY: test +test: + go test -v -race ./... + +.PHONY: clean +clean: + rm -rf bin dist + +# Release targets for CI/CD +.PHONY: build-release +build-release: + @echo "Building release binary for $(GOOS)/$(GOARCH)..." + @mkdir -p dist + @if [ "$(GOOS)" = "windows" ]; then \ + GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags="-s -w" -o "dist/$(BINARY_NAME)-$(GOOS)-$(GOARCH).exe" ./cmd; \ + else \ + GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags="-s -w" -o "dist/$(BINARY_NAME)-$(GOOS)-$(GOARCH)" ./cmd; \ + fi + @echo "Build complete!" + +.PHONY: package-release +package-release: + @echo "Packaging release artifacts for $(GOOS)/$(GOARCH)..." + @cd dist && \ + if [ "$(GOOS)" = "windows" ]; then \ + zip "$(BINARY_NAME)-$(GOOS)-$(GOARCH).zip" "$(BINARY_NAME)-$(GOOS)-$(GOARCH).exe"; \ + else \ + zip "$(BINARY_NAME)-$(GOOS)-$(GOARCH).zip" "$(BINARY_NAME)-$(GOOS)-$(GOARCH)"; \ + fi + @echo "Packaging complete!" + +.PHONY: sign-release +sign-release: + @echo "Signing release artifacts for $(GOOS)/$(GOARCH)..." + @cd dist && \ + cosign sign-blob --yes "$(BINARY_NAME)-$(GOOS)-$(GOARCH).zip" \ + --bundle "$(BINARY_NAME)-$(GOOS)-$(GOARCH).zip.bundle" + @echo "Signing complete!" + +.PHONY: release +release: build-release package-release sign-release + @echo "Release build complete for $(GOOS)/$(GOARCH)!" + +# Changelog parsing pipeline: removes section boundaries, formats sections with their items +define CHANGELOG_PIPELINE +sed '$$d' | tail -n +2 | sed -e '$$G' | awk '/^### /{section=$$0; items=""; next} /^( *)?- /{items=items $$0 "\n"; next} /^$$/ && items{print section "\n" items; items=""}' | sed '/^$$/d' +endef + +# Changelog extraction targets +.PHONY: extract-changelog-unreleased +extract-changelog-unreleased: + @echo "Extracting unreleased changelog section..." >&2 + @CHANGELOG_CONTENT=$$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | $(CHANGELOG_PIPELINE)); \ + if [ -z "$$CHANGELOG_CONTENT" ]; then \ + CHANGELOG_CONTENT="See CHANGELOG.md for details."; \ + fi; \ + printf '%s\n' "$$CHANGELOG_CONTENT" + +.PHONY: extract-changelog-version +extract-changelog-version: + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION is required. Usage: make extract-changelog-version VERSION=v1.0.0"; \ + exit 1; \ + fi + @echo "Extracting changelog for version $(VERSION)..." >&2 + @VERSION_NO_V=$$(echo "$(VERSION)" | sed 's/^v//'); \ + CHANGELOG_CONTENT=$$(sed -n "/## \[$${VERSION_NO_V}\]/,/## \[/p" CHANGELOG.md | $(CHANGELOG_PIPELINE)); \ + if [ -z "$$CHANGELOG_CONTENT" ]; then \ + CHANGELOG_CONTENT=$$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | $(CHANGELOG_PIPELINE)); \ + fi; \ + if [ -z "$$CHANGELOG_CONTENT" ]; then \ + CHANGELOG_CONTENT="See CHANGELOG.md for details."; \ + fi; \ + printf '%s\n' "$$CHANGELOG_CONTENT" + +# Version validation targets +.PHONY: validate-version-tag +validate-version-tag: + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION is required. Usage: make validate-version-tag VERSION=v1.0.0"; \ + exit 1; \ + fi + @echo "Validating version tag format: $(VERSION)" + @if echo "$(VERSION)" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$$'; then \ + echo "✓ Version tag $(VERSION) is valid"; \ + else \ + echo "✗ Error: Version tag must match format 'vX.Y.Z' or 'vX.Y.Z-rc.N'"; \ + echo " Got: $(VERSION)"; \ + exit 1; \ + fi + +.PHONY: validate-release-tag +validate-release-tag: + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION is required. Usage: make validate-release-tag VERSION=v1.0.0"; \ + exit 1; \ + fi + @echo "Validating release tag format: $(VERSION)" + @if echo "$(VERSION)" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$$'; then \ + echo "✓ Release tag $(VERSION) is valid"; \ + else \ + echo "✗ Error: Release tag must match format 'vX.Y.Z' (no suffixes)"; \ + echo " Got: $(VERSION)"; \ + exit 1; \ + fi + +.PHONY: validate-prerelease-tag +validate-prerelease-tag: + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION is required. Usage: make validate-prerelease-tag VERSION=v1.0.0-rc.1"; \ + exit 1; \ + fi + @echo "Validating prerelease tag format: $(VERSION)" + @if echo "$(VERSION)" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$$'; then \ + echo "✓ Prerelease tag $(VERSION) is valid"; \ + else \ + echo "✗ Error: Prerelease tag must match format 'vX.Y.Z-rc.N'"; \ + echo " Got: $(VERSION)"; \ + exit 1; \ + fi + +.PHONY: validate-changelog-has-version +validate-changelog-has-version: + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION is required. Usage: make validate-changelog-has-version VERSION=v1.0.0"; \ + exit 1; \ + fi + @VERSION_NO_V=$$(echo "$(VERSION)" | sed 's/^v//'); \ + echo "Checking if CHANGELOG.md contains section for version $${VERSION_NO_V}..."; \ + if grep -q "## \[$${VERSION_NO_V}\]" CHANGELOG.md; then \ + echo "✓ CHANGELOG.md contains section for version $${VERSION_NO_V}"; \ + else \ + echo "✗ Error: CHANGELOG.md must contain a section for version $${VERSION_NO_V}"; \ + echo " Expected format: ## [$${VERSION_NO_V}]"; \ + echo " Current CHANGELOG sections:"; \ + grep "^## \[" CHANGELOG.md || echo " No version sections found"; \ + exit 1; \ + fi + +# Release management targets +.PHONY: upload-release-assets +upload-release-assets: + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION is required. Usage: make upload-release-assets VERSION=v1.0.0"; \ + exit 1; \ + fi + @if [ -z "$(GITHUB_TOKEN)" ]; then \ + echo "Error: GITHUB_TOKEN environment variable is required"; \ + exit 1; \ + fi + @echo "Uploading release assets for $(VERSION)..." + @for file in dist/*.zip dist/*.bundle; do \ + if [ -f "$$file" ]; then \ + echo "Uploading $$file..."; \ + gh release upload "$(VERSION)" "$$file" --clobber; \ + fi; \ + done + @echo "✓ All assets uploaded successfully" From ac61e41200f32f1ab525af3c0fc587cffbfa0acd Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 20 Jan 2026 09:16:21 -0500 Subject: [PATCH 3/4] docs: create CHANGELOG Signed-off-by: Calum Murray --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7dd6fd2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.1] - 2025-01-20 + +### Added + +- Initial release of gevals-kubernetes-extension +- MCP server implementation for Kubernetes resource management +- Support for creating, deleting, and listing Kubernetes resources +- Wait functionality for resource readiness +- Integration with gevals evaluation framework From 9b926cde2521210d92e8452350b4ce6a42602280 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 20 Jan 2026 09:16:31 -0500 Subject: [PATCH 4/4] docs: add CONTRIBUTING.md Signed-off-by: Calum Murray --- CONTRIBUTING.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..46cc128 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing + +Want to contribute to the Kubernetes extension? We recommend opening an issue first to discuss your proposed changes. Once aligned, refer to the guide below for development workflow. + +## Project Structure + +``` +cmd/main.go # Entry point +pkg/extension/ + extension.go # Extension struct, New(), Run() + client.go # ResourceClient interface and adapter + resource.go # Resource reference parsing helpers + operations.go # Operation registration + create.go # Create handler + wait.go # Wait handler + delete.go # Delete handler + *_test.go # Unit tests +``` + +## Adding a New Operation + +1. Create `pkg/extension/.go` with your handler: + +```go +func (e *Extension) handleMyOp(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + args, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be an object")), nil + } + + // Your logic here + + e.LogInfo(ctx, "Operation completed", map[string]any{"key": "value"}) + return sdk.Success("Done"), nil +} +``` + +2. Register the operation in `operations.go`: + +```go +e.AddOperation( + sdk.NewOperation("myop", + sdk.WithDescription("Description of your operation"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "field": {Type: "string", Description: "Field description"}, + }, + Required: []string{"field"}, + }), + ), + e.handleMyOp, +) +``` + +3. Add tests in `pkg/extension/_test.go` using table-driven tests. + +## Testing + +Run all tests: +```bash +go test ./... +``` + +Run with verbose output: +```bash +go test ./... -v +``` + +## Code Style + +- Use `e.LogInfo()` and `e.LogError()` for logging +- Return `sdk.Failure(err)` for errors, not Go errors +- Use `parseResourceRef()` for standard resource arguments +- Keep handlers focused on a single responsibility