From 2af445b4569041a8f93056552736c119ab16e021 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 07:19:59 +0000 Subject: [PATCH 01/39] feat(k8s): add client factory with kubectl-compatible kubeconfig discovery Implements the foundation & client setup for kubectl query removal. - Add pkg/k8s/client.go with NewClient(), GetCurrentNamespace(), WrapError() - kubectl-compatible kubeconfig discovery (KUBECONFIG env -> ~/.kube/config -> in-cluster) - Namespace resolution matching kubectl behavior - Error wrapping with apierrors.IsNotFound() and IsForbidden() - Add client-go v0.35.0 and apimachinery v0.35.0 dependencies - 15 unit tests Assisted-by: AI Signed-off-by: Pradipta Banerjee --- go.mod | 39 ++++- go.sum | 125 +++++++++++++- pkg/k8s/client.go | 216 ++++++++++++++++++++++++ pkg/k8s/client_test.go | 364 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 742 insertions(+), 2 deletions(-) create mode 100644 pkg/k8s/client.go create mode 100644 pkg/k8s/client_test.go diff --git a/go.mod b/go.mod index 1446ad5..c124806 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,51 @@ module github.com/confidential-devhub/cococtl -go 1.24.4 +go 1.25.0 require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/cobra v1.10.1 gopkg.in/yaml.v3 v3.0.1 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // 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.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.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 + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.35.0 // 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 index 2a5a356..bbdb618 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,137 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/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/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.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +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/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +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/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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.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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/k8s/client.go b/pkg/k8s/client.go new file mode 100644 index 0000000..bee9d6e --- /dev/null +++ b/pkg/k8s/client.go @@ -0,0 +1,216 @@ +// Package k8s provides a shared Kubernetes client factory with kubectl-compatible +// kubeconfig discovery and namespace resolution. +package k8s + +import ( + "fmt" + "os" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + // inClusterNamespacePath is the path to the namespace file in a Kubernetes pod. + inClusterNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" +) + +// Client wraps a Kubernetes clientset with resolved configuration. +type Client struct { + // Clientset is the Kubernetes client interface. Using Interface (not *Clientset) + // allows for easy testing with fake.NewSimpleClientset(). + Clientset kubernetes.Interface + + // Namespace is the resolved default namespace for operations. + Namespace string + + // Config is the underlying REST config for advanced use cases. + Config *rest.Config +} + +// ClientOptions configures client creation. +type ClientOptions struct { + // Kubeconfig is an explicit kubeconfig path. If empty, standard discovery is used: + // KUBECONFIG env -> ~/.kube/config -> in-cluster config. + Kubeconfig string + + // Context is an explicit context name. If empty, current-context is used. + Context string + + // Namespace is an explicit namespace. If empty, namespace is resolved from: + // kubeconfig context -> in-cluster namespace file -> "default". + Namespace string + + // Timeout is the default timeout for API operations. If zero, no timeout is set. + Timeout time.Duration +} + +// NewClient creates a Kubernetes client with kubectl-compatible kubeconfig discovery. +// +// Kubeconfig discovery order (same as kubectl): +// 1. opts.Kubeconfig (if provided) +// 2. KUBECONFIG environment variable +// 3. ~/.kube/config +// 4. In-cluster config (when running inside a pod) +// +// Namespace resolution order: +// 1. opts.Namespace (if provided) +// 2. Namespace from kubeconfig current context +// 3. In-cluster namespace file (/var/run/secrets/kubernetes.io/serviceaccount/namespace) +// 4. "default" +func NewClient(opts ClientOptions) (*Client, error) { + config, namespace, err := loadConfig(opts) + if err != nil { + return nil, fmt.Errorf("failed to load kubernetes config: %w", err) + } + + // Apply timeout if specified + if opts.Timeout > 0 { + config.Timeout = opts.Timeout + } + + // Create clientset + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes client: %w", err) + } + + // Override namespace if explicitly provided + if opts.Namespace != "" { + namespace = opts.Namespace + } + + // Ensure we always have a namespace + if namespace == "" { + namespace = getInClusterNamespace("") + } + + return &Client{ + Clientset: clientset, + Namespace: namespace, + Config: config, + }, nil +} + +// GetCurrentNamespace returns the namespace from the current kubeconfig context. +// This is a standalone function for cases where you only need the namespace +// without creating a full client. +// +// Resolution order: +// 1. Namespace from kubeconfig current context +// 2. In-cluster namespace file +// 3. "default" +func GetCurrentNamespace() (string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + configOverrides, + ) + + namespace, _, err := kubeConfig.Namespace() + if err != nil { + // If kubeconfig doesn't exist or is invalid, try in-cluster + return getInClusterNamespace(""), nil + } + + if namespace == "" { + namespace = getInClusterNamespace("") + } + + return namespace, nil +} + +// WrapError wraps a Kubernetes API error with operation context. +// It provides user-friendly messages for common error types. +func WrapError(err error, operation, resource, namespace string) error { + if err == nil { + return nil + } + + if apierrors.IsNotFound(err) { + if namespace != "" { + return fmt.Errorf("%s not found in namespace %s", resource, namespace) + } + return fmt.Errorf("%s not found", resource) + } + + if apierrors.IsForbidden(err) { + if namespace != "" { + return fmt.Errorf("permission denied: cannot %s %s in namespace %s", operation, resource, namespace) + } + return fmt.Errorf("permission denied: cannot %s %s", operation, resource) + } + + if namespace != "" { + return fmt.Errorf("failed to %s %s in namespace %s: %w", operation, resource, namespace, err) + } + return fmt.Errorf("failed to %s %s: %w", operation, resource, err) +} + +// loadConfig loads the Kubernetes config using kubectl-compatible discovery. +func loadConfig(opts ClientOptions) (*rest.Config, string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + + // Use explicit kubeconfig path if provided + if opts.Kubeconfig != "" { + loadingRules.ExplicitPath = opts.Kubeconfig + } + + configOverrides := &clientcmd.ConfigOverrides{} + + // Use explicit context if provided + if opts.Context != "" { + configOverrides.CurrentContext = opts.Context + } + + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + configOverrides, + ) + + // Get namespace from context + namespace, _, err := kubeConfig.Namespace() + if err != nil { + // Namespace resolution failed, but we might still get a valid config + namespace = "" + } + + // Try to get client config from kubeconfig + config, err := kubeConfig.ClientConfig() + if err != nil { + // Kubeconfig failed, try in-cluster config + config, err = rest.InClusterConfig() + if err != nil { + return nil, "", fmt.Errorf("unable to load kubeconfig (tried KUBECONFIG, ~/.kube/config, in-cluster): %w", err) + } + // For in-cluster, namespace comes from the namespace file + namespace = getInClusterNamespace(namespace) + } + + return config, namespace, nil +} + +// getInClusterNamespace returns the namespace from the in-cluster namespace file, +// or the override if provided, or "default" as a fallback. +func getInClusterNamespace(override string) string { + if override != "" { + return override + } + + data, err := os.ReadFile(inClusterNamespacePath) + if err != nil { + return "default" + } + + ns := string(data) + if ns == "" { + return "default" + } + + return ns +} diff --git a/pkg/k8s/client_test.go b/pkg/k8s/client_test.go new file mode 100644 index 0000000..fe82342 --- /dev/null +++ b/pkg/k8s/client_test.go @@ -0,0 +1,364 @@ +package k8s + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" +) + +// createTestKubeconfig creates a temporary kubeconfig file for testing. +// Returns the path to the created file. +func createTestKubeconfig(t *testing.T, namespace string) string { + t.Helper() + + template := `apiVersion: v1 +kind: Config +current-context: test-context +contexts: +- name: test-context + context: + cluster: test-cluster +` + + // Add namespace if provided + if namespace != "" { + template += " namespace: " + namespace + "\n" + } + + template += `clusters: +- name: test-cluster + cluster: + server: https://localhost:6443 +users: +- name: test-user + user: + token: fake-token +` + + dir := t.TempDir() + path := filepath.Join(dir, "config") + + if err := os.WriteFile(path, []byte(template), 0600); err != nil { + t.Fatalf("failed to write test kubeconfig: %v", err) + } + + return path +} + +func TestNewClient_WithMockKubeconfig(t *testing.T) { + // Create temporary kubeconfig with namespace "test-namespace" + kubeconfigPath := createTestKubeconfig(t, "test-namespace") + + client, err := NewClient(ClientOptions{ + Kubeconfig: kubeconfigPath, + }) + + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + if client == nil { + t.Fatal("NewClient returned nil client") + } + + if client.Namespace != "test-namespace" { + t.Errorf("expected namespace 'test-namespace', got '%s'", client.Namespace) + } + + if client.Clientset == nil { + t.Error("Clientset is nil") + } + + if client.Config == nil { + t.Error("Config is nil") + } +} + +func TestNewClient_WithExplicitNamespace(t *testing.T) { + // Create kubeconfig with context namespace "from-context" + kubeconfigPath := createTestKubeconfig(t, "from-context") + + client, err := NewClient(ClientOptions{ + Kubeconfig: kubeconfigPath, + Namespace: "explicit-ns", + }) + + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + // Explicit namespace should override context namespace + if client.Namespace != "explicit-ns" { + t.Errorf("expected namespace 'explicit-ns', got '%s'", client.Namespace) + } +} + +func TestNewClient_DefaultNamespace(t *testing.T) { + // Create kubeconfig with no namespace in context + kubeconfigPath := createTestKubeconfig(t, "") + + client, err := NewClient(ClientOptions{ + Kubeconfig: kubeconfigPath, + }) + + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + // Should fall back to "default" + if client.Namespace != "default" { + t.Errorf("expected namespace 'default', got '%s'", client.Namespace) + } +} + +func TestNewClient_WithTimeout(t *testing.T) { + kubeconfigPath := createTestKubeconfig(t, "test-ns") + + client, err := NewClient(ClientOptions{ + Kubeconfig: kubeconfigPath, + Timeout: 30 * time.Second, + }) + + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + if client.Config.Timeout != 30*time.Second { + t.Errorf("expected timeout 30s, got %v", client.Config.Timeout) + } +} + +func TestGetCurrentNamespace_FromContext(t *testing.T) { + // Create kubeconfig with namespace + kubeconfigPath := createTestKubeconfig(t, "test-ns") + + // Save and restore original KUBECONFIG + origKubeconfig := os.Getenv("KUBECONFIG") + t.Setenv("KUBECONFIG", kubeconfigPath) + defer os.Setenv("KUBECONFIG", origKubeconfig) + + namespace, err := GetCurrentNamespace() + if err != nil { + t.Fatalf("GetCurrentNamespace failed: %v", err) + } + + if namespace != "test-ns" { + t.Errorf("expected namespace 'test-ns', got '%s'", namespace) + } +} + +func TestGetCurrentNamespace_Default(t *testing.T) { + // Create kubeconfig without namespace + kubeconfigPath := createTestKubeconfig(t, "") + + // Save and restore original KUBECONFIG + origKubeconfig := os.Getenv("KUBECONFIG") + t.Setenv("KUBECONFIG", kubeconfigPath) + defer os.Setenv("KUBECONFIG", origKubeconfig) + + namespace, err := GetCurrentNamespace() + if err != nil { + t.Fatalf("GetCurrentNamespace failed: %v", err) + } + + if namespace != "default" { + t.Errorf("expected namespace 'default', got '%s'", namespace) + } +} + +func TestWrapError_NotFound(t *testing.T) { + // Create a real NotFound error + notFoundErr := apierrors.NewNotFound( + schema.GroupResource{Group: "", Resource: "pods"}, + "test-pod", + ) + + wrapped := WrapError(notFoundErr, "get", "pod/test-pod", "test-ns") + + if wrapped == nil { + t.Fatal("WrapError returned nil for NotFound error") + } + + expectedMsg := "pod/test-pod not found in namespace test-ns" + if wrapped.Error() != expectedMsg { + t.Errorf("expected '%s', got '%s'", expectedMsg, wrapped.Error()) + } +} + +func TestWrapError_NotFoundNoNamespace(t *testing.T) { + notFoundErr := apierrors.NewNotFound( + schema.GroupResource{Group: "", Resource: "pods"}, + "test-pod", + ) + + wrapped := WrapError(notFoundErr, "get", "pod/test-pod", "") + + expectedMsg := "pod/test-pod not found" + if wrapped.Error() != expectedMsg { + t.Errorf("expected '%s', got '%s'", expectedMsg, wrapped.Error()) + } +} + +func TestWrapError_Forbidden(t *testing.T) { + forbiddenErr := apierrors.NewForbidden( + schema.GroupResource{Group: "", Resource: "secrets"}, + "my-secret", + nil, + ) + + wrapped := WrapError(forbiddenErr, "get", "secret/my-secret", "test-ns") + + if wrapped == nil { + t.Fatal("WrapError returned nil for Forbidden error") + } + + expectedMsg := "permission denied: cannot get secret/my-secret in namespace test-ns" + if wrapped.Error() != expectedMsg { + t.Errorf("expected '%s', got '%s'", expectedMsg, wrapped.Error()) + } +} + +func TestWrapError_GenericError(t *testing.T) { + // Create a generic timeout error + genericErr := apierrors.NewTimeoutError("request timeout", 30) + + wrapped := WrapError(genericErr, "list", "pods", "default") + + if wrapped == nil { + t.Fatal("WrapError returned nil for generic error") + } + + // Should contain the operation context + msg := wrapped.Error() + if len(msg) == 0 { + t.Error("wrapped error message is empty") + } + + // Generic errors should be wrapped with context + expected := "failed to list pods in namespace default:" + if len(msg) < len(expected) { + t.Errorf("expected message to start with '%s', got '%s'", expected, msg) + } +} + +func TestWrapError_Nil(t *testing.T) { + wrapped := WrapError(nil, "get", "pod", "default") + + if wrapped != nil { + t.Error("WrapError should return nil for nil error") + } +} + +func TestClient_WithFakeClientset(t *testing.T) { + // Create fake clientset with pre-populated objects + fakeClientset := fake.NewSimpleClientset( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + }, + ) + + // Create a Client struct with the fake clientset + // This proves that kubernetes.Interface typing is correct + client := &Client{ + Clientset: fakeClientset, + Namespace: "test-ns", + } + + if client.Clientset == nil { + t.Fatal("Clientset is nil") + } + + // Verify we can use the fake clientset + pods, err := client.Clientset.CoreV1().Pods("test-ns").List( + context.Background(), + metav1.ListOptions{}, + ) + + if err != nil { + t.Fatalf("failed to list pods: %v", err) + } + + if len(pods.Items) != 1 { + t.Errorf("expected 1 pod, got %d", len(pods.Items)) + } + + if pods.Items[0].Name != "test-pod" { + t.Errorf("expected pod name 'test-pod', got '%s'", pods.Items[0].Name) + } +} + +func TestClient_FakeClientset_NotFound(t *testing.T) { + fakeClientset := fake.NewSimpleClientset() + + client := &Client{ + Clientset: fakeClientset, + Namespace: "test-ns", + } + + // Try to get a non-existent pod + _, err := client.Clientset.CoreV1().Pods("test-ns").Get( + context.Background(), + "nonexistent", + metav1.GetOptions{}, + ) + + if err == nil { + t.Fatal("expected error for non-existent pod") + } + + // Verify it's a NotFound error + if !apierrors.IsNotFound(err) { + t.Errorf("expected NotFound error, got %T: %v", err, err) + } + + // Test WrapError with the real NotFound error + wrapped := WrapError(err, "get", "pod/nonexistent", "test-ns") + expectedMsg := "pod/nonexistent not found in namespace test-ns" + if wrapped.Error() != expectedMsg { + t.Errorf("expected '%s', got '%s'", expectedMsg, wrapped.Error()) + } +} + +func TestNewClient_InvalidKubeconfig(t *testing.T) { + // Create an invalid kubeconfig + dir := t.TempDir() + invalidPath := filepath.Join(dir, "invalid-config") + if err := os.WriteFile(invalidPath, []byte("not valid yaml: ["), 0600); err != nil { + t.Fatalf("failed to write invalid kubeconfig: %v", err) + } + + _, err := NewClient(ClientOptions{ + Kubeconfig: invalidPath, + }) + + if err == nil { + t.Error("expected error for invalid kubeconfig, got nil") + } +} + +func TestNewClient_NonExistentKubeconfig(t *testing.T) { + _, err := NewClient(ClientOptions{ + Kubeconfig: "/nonexistent/path/to/kubeconfig", + }) + + if err == nil { + t.Error("expected error for non-existent kubeconfig, got nil") + } +} From 50c79358f327f9b9fc3064bc553fca5d245dd1f5 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 07:44:18 +0000 Subject: [PATCH 02/39] migrate GetNodeIPs to client-go API - Replace kubectl exec with clientset.CoreV1().Nodes().List() - Add context.Context and kubernetes.Interface parameters - Use corev1.NodeExternalIP/NodeInternalIP constants - Add extractAddresses helper for typed field access - Remove getNodeIPsByType kubectl-based helper Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/cluster/nodes.go | 62 +++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/pkg/cluster/nodes.go b/pkg/cluster/nodes.go index bfa4bf8..488f576 100644 --- a/pkg/cluster/nodes.go +++ b/pkg/cluster/nodes.go @@ -2,59 +2,49 @@ package cluster import ( - "bytes" + "context" "fmt" - "os/exec" - "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" ) // GetNodeIPs retrieves IP addresses of all nodes in the cluster. // It attempts to get ExternalIP first, falling back to InternalIP if unavailable. // Returns a deduplicated list of node IP addresses. -func GetNodeIPs() ([]string, error) { - // Try external IPs first - externalIPs, err := getNodeIPsByType("ExternalIP") - if err == nil && len(externalIPs) > 0 { - return externalIPs, nil +func GetNodeIPs(ctx context.Context, clientset kubernetes.Interface) ([]string, error) { + nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list nodes: %w", err) } - // Fall back to internal IPs - internalIPs, err := getNodeIPsByType("InternalIP") - if err != nil { - return nil, fmt.Errorf("failed to get node IPs: %w", err) + // Try external IPs first + externalIPs := extractAddresses(nodes.Items, corev1.NodeExternalIP) + if len(externalIPs) > 0 { + return deduplicateStrings(externalIPs), nil } + // Fall back to internal IPs + internalIPs := extractAddresses(nodes.Items, corev1.NodeInternalIP) if len(internalIPs) == 0 { return nil, fmt.Errorf("no node IPs found in cluster") } - return internalIPs, nil + return deduplicateStrings(internalIPs), nil } -// getNodeIPsByType retrieves node IPs of a specific address type. -func getNodeIPsByType(addressType string) ([]string, error) { - jsonPath := fmt.Sprintf("{.items[*].status.addresses[?(@.type==\"%s\")].address}", addressType) - - // #nosec G204 -- addressType is controlled, only called with "ExternalIP" or "InternalIP" - cmd := exec.Command("kubectl", "get", "nodes", - "-o", fmt.Sprintf("jsonpath=%s", jsonPath)) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("kubectl get nodes failed: %w: %s", err, stderr.String()) - } - - output := strings.TrimSpace(stdout.String()) - if output == "" { - return nil, nil +// extractAddresses extracts addresses of a specific type from all nodes. +func extractAddresses(nodes []corev1.Node, addrType corev1.NodeAddressType) []string { + var addresses []string + for _, node := range nodes { + for _, addr := range node.Status.Addresses { + if addr.Type == addrType { + addresses = append(addresses, addr.Address) + } + } } - - // Split by spaces and deduplicate - ips := strings.Fields(output) - return deduplicateStrings(ips), nil + return addresses } // deduplicateStrings removes duplicate entries from a string slice. From 8107500645515bca00ab765b78f5a247eaf36cdf Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 07:45:06 +0000 Subject: [PATCH 03/39] add unit tests for GetNodeIPs - TestGetNodeIPs_ExternalIP: prefer external IPs when available - TestGetNodeIPs_FallbackToInternal: fallback to internal IPs - TestGetNodeIPs_NoNodes: error on empty cluster - TestGetNodeIPs_NoAddresses: error when node has no addresses - TestGetNodeIPs_Deduplication: deduplicate same IPs across nodes - TestGetNodeIPs_MixedAddressTypes: only extract IP types, not Hostname/DNS - TestGetNodeIPs_OnlyHostname: error when only Hostname available Uses fake.NewSimpleClientset() for unit testing without cluster Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/cluster/nodes_test.go | 263 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 pkg/cluster/nodes_test.go diff --git a/pkg/cluster/nodes_test.go b/pkg/cluster/nodes_test.go new file mode 100644 index 0000000..a5335e4 --- /dev/null +++ b/pkg/cluster/nodes_test.go @@ -0,0 +1,263 @@ +package cluster + +import ( + "context" + "sort" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetNodeIPs_ExternalIP(t *testing.T) { + // Setup fake clientset with 2 nodes, each having both ExternalIP and InternalIP + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "5.6.7.8"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.2"}, + }, + }, + }, + ) + + ctx := context.Background() + ips, err := GetNodeIPs(ctx, fakeClient) + if err != nil { + t.Fatalf("GetNodeIPs() error = %v", err) + } + + // Should prefer external IPs + if len(ips) != 2 { + t.Errorf("GetNodeIPs() returned %d IPs, want 2", len(ips)) + } + + // Check external IPs are returned (order may vary) + sort.Strings(ips) + expected := []string{"1.2.3.4", "5.6.7.8"} + sort.Strings(expected) + + if len(ips) != len(expected) { + t.Errorf("GetNodeIPs() = %v, want %v", ips, expected) + return + } + for i := range ips { + if ips[i] != expected[i] { + t.Errorf("GetNodeIPs() = %v, want %v", ips, expected) + return + } + } +} + +func TestGetNodeIPs_FallbackToInternal(t *testing.T) { + // Setup fake clientset with 2 nodes, only InternalIP (no ExternalIP) + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.2"}, + }, + }, + }, + ) + + ctx := context.Background() + ips, err := GetNodeIPs(ctx, fakeClient) + if err != nil { + t.Fatalf("GetNodeIPs() error = %v", err) + } + + // Should fall back to internal IPs + if len(ips) != 2 { + t.Errorf("GetNodeIPs() returned %d IPs, want 2", len(ips)) + } + + sort.Strings(ips) + expected := []string{"10.0.0.1", "10.0.0.2"} + sort.Strings(expected) + + for i := range ips { + if ips[i] != expected[i] { + t.Errorf("GetNodeIPs() = %v, want %v", ips, expected) + return + } + } +} + +func TestGetNodeIPs_NoNodes(t *testing.T) { + // Setup empty fake clientset (no Nodes) + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + _, err := GetNodeIPs(ctx, fakeClient) + if err == nil { + t.Fatal("GetNodeIPs() expected error for empty cluster, got nil") + } + + if !strings.Contains(err.Error(), "no node IPs found") { + t.Errorf("GetNodeIPs() error = %q, want error containing 'no node IPs found'", err.Error()) + } +} + +func TestGetNodeIPs_NoAddresses(t *testing.T) { + // Setup fake clientset with node that has no addresses + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{}, + }, + }, + ) + + ctx := context.Background() + _, err := GetNodeIPs(ctx, fakeClient) + if err == nil { + t.Fatal("GetNodeIPs() expected error for node with no addresses, got nil") + } + + if !strings.Contains(err.Error(), "no node IPs found") { + t.Errorf("GetNodeIPs() error = %q, want error containing 'no node IPs found'", err.Error()) + } +} + +func TestGetNodeIPs_Deduplication(t *testing.T) { + // Setup fake clientset with 2 nodes having same ExternalIP (rare but possible) + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + ) + + ctx := context.Background() + ips, err := GetNodeIPs(ctx, fakeClient) + if err != nil { + t.Fatalf("GetNodeIPs() error = %v", err) + } + + // Should return deduplicated list (single IP) + if len(ips) != 1 { + t.Errorf("GetNodeIPs() returned %d IPs, want 1 (deduplicated)", len(ips)) + } + + if ips[0] != "1.2.3.4" { + t.Errorf("GetNodeIPs() = %v, want [1.2.3.4]", ips) + } +} + +func TestGetNodeIPs_MixedAddressTypes(t *testing.T) { + // Setup fake clientset with nodes having various address types (Hostname, InternalDNS, etc.) + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeHostName, Address: "node-1.example.com"}, + {Type: corev1.NodeInternalDNS, Address: "node-1.cluster.local"}, + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeHostName, Address: "node-2.example.com"}, + {Type: corev1.NodeExternalDNS, Address: "node-2.public.example.com"}, + {Type: corev1.NodeExternalIP, Address: "5.6.7.8"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.2"}, + }, + }, + }, + ) + + ctx := context.Background() + ips, err := GetNodeIPs(ctx, fakeClient) + if err != nil { + t.Fatalf("GetNodeIPs() error = %v", err) + } + + // Should only return ExternalIP, not Hostname or DNS + if len(ips) != 2 { + t.Errorf("GetNodeIPs() returned %d IPs, want 2 (ExternalIPs only)", len(ips)) + } + + // Ensure only ExternalIPs are returned + sort.Strings(ips) + expected := []string{"1.2.3.4", "5.6.7.8"} + sort.Strings(expected) + + for i := range ips { + if ips[i] != expected[i] { + t.Errorf("GetNodeIPs() = %v, want %v (ExternalIPs only)", ips, expected) + return + } + } + + // Verify hostnames and DNS names are NOT included + for _, ip := range ips { + if strings.Contains(ip, "example.com") || strings.Contains(ip, "cluster.local") { + t.Errorf("GetNodeIPs() returned hostname/DNS %q, should only return IPs", ip) + } + } +} + +func TestGetNodeIPs_OnlyHostname(t *testing.T) { + // Edge case: node only has Hostname, no ExternalIP or InternalIP + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeHostName, Address: "node-1.example.com"}, + }, + }, + }, + ) + + ctx := context.Background() + _, err := GetNodeIPs(ctx, fakeClient) + if err == nil { + t.Fatal("GetNodeIPs() expected error when node only has Hostname, got nil") + } + + if !strings.Contains(err.Error(), "no node IPs found") { + t.Errorf("GetNodeIPs() error = %q, want error containing 'no node IPs found'", err.Error()) + } +} From a34b9f5e8468c00d7db6389c8395a2b54210d343 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 07:45:32 +0000 Subject: [PATCH 04/39] migrate DetectRuntimeClass to client-go API - Replace kubectl exec with clientset.NodeV1().RuntimeClasses().List() - Update function signature: add context.Context and kubernetes.Interface params - Remove custom runtimeClassList struct, use official nodev1.RuntimeClass - Update cmd/init.go and cmd/apply.go callers to use new signature - Maintain graceful degradation: return default on error Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 16 +++++++++---- cmd/init.go | 12 +++++++++- pkg/cluster/runtimeclass.go | 48 ++++++++++--------------------------- 3 files changed, 35 insertions(+), 41 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 06cacf5..33f6c3c 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -11,6 +11,7 @@ import ( "github.com/confidential-devhub/cococtl/pkg/cluster" "github.com/confidential-devhub/cococtl/pkg/config" "github.com/confidential-devhub/cococtl/pkg/initdata" + "github.com/confidential-devhub/cococtl/pkg/k8s" "github.com/confidential-devhub/cococtl/pkg/manifest" "github.com/confidential-devhub/cococtl/pkg/secrets" "github.com/confidential-devhub/cococtl/pkg/sidecar" @@ -769,11 +770,18 @@ func handleSidecarServerCert(appName, namespace, trusteeNamespace string) error // Auto-detect SANs unless skipped if !sidecarSkipAutoSANs { // Auto-detect node IPs - nodeIPs, err := cluster.GetNodeIPs() - if err != nil { - fmt.Printf("Warning: failed to auto-detect node IPs: %v\n", err) + // Create Kubernetes client for node IP detection + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + if clientErr != nil { + fmt.Printf("Warning: failed to create Kubernetes client for node IP detection: %v\n", clientErr) } else { - sans.IPAddresses = append(sans.IPAddresses, nodeIPs...) + ctx := context.Background() + nodeIPs, err := cluster.GetNodeIPs(ctx, client.Clientset) + if err != nil { + fmt.Printf("Warning: failed to auto-detect node IPs: %v\n", err) + } else { + sans.IPAddresses = append(sans.IPAddresses, nodeIPs...) + } } // Add service DNS names (format: ..svc.cluster.local) diff --git a/cmd/init.go b/cmd/init.go index 9f1ccc8..caf30bd 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -9,6 +9,7 @@ import ( "github.com/confidential-devhub/cococtl/pkg/cluster" "github.com/confidential-devhub/cococtl/pkg/config" + "github.com/confidential-devhub/cococtl/pkg/k8s" "github.com/confidential-devhub/cococtl/pkg/sidecar/certs" "github.com/confidential-devhub/cococtl/pkg/trustee" "github.com/spf13/cobra" @@ -116,7 +117,16 @@ func runInit(cmd *cobra.Command, _ []string) error { cfg.RuntimeClass = runtimeClass } else { // Auto-detect RuntimeClass with SNP or TDX support - cfg.RuntimeClass = cluster.DetectRuntimeClass(config.DefaultRuntimeClass) + // Create Kubernetes client for runtime class detection + client, err := k8s.NewClient(k8s.ClientOptions{}) + if err != nil { + // Log warning but don't fail - use default runtime class + fmt.Printf("Warning: unable to create Kubernetes client: %v\n", err) + cfg.RuntimeClass = config.DefaultRuntimeClass + } else { + ctx := cmd.Context() + cfg.RuntimeClass = cluster.DetectRuntimeClass(ctx, client.Clientset, config.DefaultRuntimeClass) + } } // In non-interactive mode, show the RuntimeClass being used diff --git a/pkg/cluster/runtimeclass.go b/pkg/cluster/runtimeclass.go index ad8156e..e72307a 100644 --- a/pkg/cluster/runtimeclass.go +++ b/pkg/cluster/runtimeclass.go @@ -2,57 +2,33 @@ package cluster import ( - "bytes" - "encoding/json" + "context" "fmt" - "os/exec" "strings" -) -// runtimeClassList represents the JSON response from kubectl get runtimeclasses -type runtimeClassList struct { - Items []struct { - Metadata struct { - Name string `json:"name"` - } `json:"metadata"` - Handler string `json:"handler"` - } `json:"items"` -} + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) // DetectRuntimeClass attempts to auto-detect a RuntimeClass with SNP or TDX support. // It retrieves all RuntimeClasses from the cluster and selects the first one whose // handler contains "snp" or "tdx" (case-insensitive). // Returns the default RuntimeClass if: -// - There's an error retrieving RuntimeClasses (permissions, kubectl not available, etc.) +// - There's an error retrieving RuntimeClasses (permissions, cluster unreachable, etc.) // - No RuntimeClasses have handlers containing "snp" or "tdx" -func DetectRuntimeClass(defaultRuntimeClass string) string { - // #nosec G204 -- static command with no user-controlled input - cmd := exec.Command("kubectl", "get", "runtimeclasses", "-o", "json") - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - // Error retrieving RuntimeClasses (permissions, kubectl not available, etc.) - // Return default - fmt.Printf("Unable to detect RuntimeClasses: %v (stderr: %s) (using default: %s)\n", err, strings.TrimSpace(stderr.String()), defaultRuntimeClass) - return defaultRuntimeClass - } - - var rcList runtimeClassList - if err := json.Unmarshal(stdout.Bytes(), &rcList); err != nil { - // Error parsing JSON, return default - fmt.Printf("Unable to parse RuntimeClasses: %v (using default: %s)\n", err, defaultRuntimeClass) +func DetectRuntimeClass(ctx context.Context, clientset kubernetes.Interface, defaultRuntimeClass string) string { + rcs, err := clientset.NodeV1().RuntimeClasses().List(ctx, metav1.ListOptions{}) + if err != nil { + fmt.Printf("Unable to detect RuntimeClasses: %v (using default: %s)\n", err, defaultRuntimeClass) return defaultRuntimeClass } // Look for RuntimeClasses with handlers containing "snp" or "tdx" - for _, rc := range rcList.Items { + for _, rc := range rcs.Items { handler := strings.ToLower(rc.Handler) if strings.Contains(handler, "snp") || strings.Contains(handler, "tdx") { - fmt.Printf("Detected RuntimeClass: %s\n", rc.Metadata.Name) - return rc.Metadata.Name + fmt.Printf("Detected RuntimeClass: %s\n", rc.Name) + return rc.Name } } From 029e991745f0268b11b2b7555639a53514f92086 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 07:46:21 +0000 Subject: [PATCH 05/39] add unit tests for DetectRuntimeClass - Add 8 test cases using fake.NewSimpleClientset() - Test SNP handler detection - Test TDX handler detection (when no SNP present) - Test no match returns default - Test empty cluster returns default - Test case-insensitive handler matching - Test handler contains "snp" substring - Test handler contains "tdx" substring Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/cluster/runtimeclass_test.go | 167 +++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 pkg/cluster/runtimeclass_test.go diff --git a/pkg/cluster/runtimeclass_test.go b/pkg/cluster/runtimeclass_test.go new file mode 100644 index 0000000..0325672 --- /dev/null +++ b/pkg/cluster/runtimeclass_test.go @@ -0,0 +1,167 @@ +package cluster + +import ( + "context" + "testing" + + nodev1 "k8s.io/api/node/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestDetectRuntimeClass_SNPHandler(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "kata-cc-snp"}, + Handler: "kata-snp", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "kata-cc-tdx"}, + Handler: "kata-tdx", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "runc"}, + Handler: "runc", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "kata-cc") + + // Should return first SNP/TDX match (SNP preferred if both present) + // Note: fake clientset may return items in any order, so accept either SNP or TDX + if result != "kata-cc-snp" && result != "kata-cc-tdx" { + t.Errorf("DetectRuntimeClass() = %q, want %q or %q", result, "kata-cc-snp", "kata-cc-tdx") + } +} + +func TestDetectRuntimeClass_TDXHandler(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "kata-cc-tdx"}, + Handler: "kata-tdx", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "runc"}, + Handler: "runc", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "kata-cc") + + // Should return TDX match when no SNP present + if result != "kata-cc-tdx" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "kata-cc-tdx") + } +} + +func TestDetectRuntimeClass_NoMatch(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "runc"}, + Handler: "runc", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "gvisor"}, + Handler: "gvisor", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "kata-cc") + + // Should return default when no SNP/TDX match + if result != "kata-cc" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "kata-cc") + } +} + +func TestDetectRuntimeClass_EmptyCluster(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "kata-cc") + + // Should return default when no RuntimeClasses exist + if result != "kata-cc" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "kata-cc") + } +} + +func TestDetectRuntimeClass_CaseInsensitive(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "kata-uppercase"}, + Handler: "KATA-SNP", // uppercase handler + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "default-rc") + + // Should return the matching RuntimeClass name (case-insensitive handler matching) + if result != "kata-uppercase" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "kata-uppercase") + } +} + +func TestDetectRuntimeClass_PrefersSNPOverTDX(t *testing.T) { + // When both SNP and TDX are available, the function should return + // whichever comes first in the list. This test ensures the function + // properly handles both handler types. + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "runc"}, + Handler: "runc", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "only-snp"}, + Handler: "kata-snp", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "default-rc") + + // Should return the SNP runtime class + if result != "only-snp" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "only-snp") + } +} + +func TestDetectRuntimeClass_HandlerContainsSNP(t *testing.T) { + // Test that handler just needs to CONTAIN "snp", not equal it exactly + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "my-custom-rc"}, + Handler: "my-kata-snp-handler", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "default-rc") + + // Should match because handler contains "snp" + if result != "my-custom-rc" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "my-custom-rc") + } +} + +func TestDetectRuntimeClass_HandlerContainsTDX(t *testing.T) { + // Test that handler just needs to CONTAIN "tdx", not equal it exactly + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "my-tdx-rc"}, + Handler: "secure-tdx-runtime", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "default-rc") + + // Should match because handler contains "tdx" + if result != "my-tdx-rc" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "my-tdx-rc") + } +} From 8592b9f3382fc5e90db982305c247e08780a81c6 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 08:21:14 +0000 Subject: [PATCH 06/39] update cmd/apply.go to use cmd.Context() for GetNodeIPs - Change runApply to use cmd parameter for context access - Thread context through transformManifest and handleSidecarServerCert - Replace context.Background() with passed context for proper signal handling - Enables graceful cancellation of Kubernetes API calls on SIGINT/SIGTERM Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 33f6c3c..b5010a4 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -84,7 +84,7 @@ func init() { applyCmd.Flags().IntVar(&sidecarPortForward, "sidecar-port-forward", 0, "Port to forward from primary container (requires --sidecar)") } -func runApply(_ *cobra.Command, _ []string) error { +func runApply(cmd *cobra.Command, _ []string) error { // Validate required flags (manual validation to keep all flags visible in shell completion) if manifestFile == "" { return fmt.Errorf("required flag(s) \"filename\" not set") @@ -180,7 +180,7 @@ func runApply(_ *cobra.Command, _ []string) error { } // Transform manifest - if err := transformManifest(m, cfg, rc, skipApply); err != nil { + if err := transformManifest(cmd.Context(), m, cfg, rc, skipApply); err != nil { return fmt.Errorf("failed to transform manifest: %w", err) } @@ -250,7 +250,7 @@ func runApply(_ *cobra.Command, _ []string) error { return nil } -func transformManifest(m *manifest.Manifest, cfg *config.CocoConfig, rc string, skipApply bool) error { +func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, rc string, skipApply bool) error { // 1. Set RuntimeClass fmt.Printf(" - Setting runtimeClassName: %s\n", rc) if err := m.SetRuntimeClass(rc); err != nil { @@ -326,7 +326,7 @@ func transformManifest(m *manifest.Manifest, cfg *config.CocoConfig, rc string, // Generate and upload server certificate fmt.Println(" - Setting up sidecar server certificate") - if err := handleSidecarServerCert(appName, namespace, trusteeNamespace); err != nil { + if err := handleSidecarServerCert(ctx, appName, namespace, trusteeNamespace); err != nil { return fmt.Errorf("failed to setup sidecar server certificate: %w", err) } @@ -723,10 +723,11 @@ func addImagePullSecretToTrustee(trusteeNamespace, secretName, secretNamespace s // It loads the Client CA, auto-detects or uses provided SANs, generates the server cert, // and uploads it to Trustee KBS at per-app paths. // Parameters: +// - ctx: context for Kubernetes API calls (for proper signal handling) // - appName: name of the application (from manifest metadata.name) // - namespace: namespace for certificate KBS path (from manifest metadata.namespace) // - trusteeNamespace: namespace where Trustee KBS is deployed -func handleSidecarServerCert(appName, namespace, trusteeNamespace string) error { +func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNamespace string) error { // Load Client CA from ~/.kube/coco-sidecar/ homeDir, err := os.UserHomeDir() if err != nil { @@ -775,7 +776,6 @@ func handleSidecarServerCert(appName, namespace, trusteeNamespace string) error if clientErr != nil { fmt.Printf("Warning: failed to create Kubernetes client for node IP detection: %v\n", clientErr) } else { - ctx := context.Background() nodeIPs, err := cluster.GetNodeIPs(ctx, client.Clientset) if err != nil { fmt.Printf("Warning: failed to auto-detect node IPs: %v\n", err) From 7ff00166524ca7c1bf73cc309dad304bdb0f53b5 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 13:00:43 +0000 Subject: [PATCH 07/39] add context to TestInitCommand_WithoutRuntimeClassFlag The test was calling runInit without setting a context on the cobra command. When auto-detecting RuntimeClass, cmd.Context() returned nil, causing a panic in the Kubernetes client. Add cmd.SetContext(context.Background()) before runInit to provide a valid context for Kubernetes API operations. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/init_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/init_test.go b/cmd/init_test.go index c4d41aa..74a66f4 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "os" "path/filepath" "testing" @@ -66,6 +67,9 @@ func TestInitCommand_WithoutRuntimeClassFlag(t *testing.T) { t.Fatalf("Failed to set runtime-class flag: %v", err) } + // Set context for Kubernetes client operations (required when auto-detecting RuntimeClass) + cmd.SetContext(context.Background()) + err := runInit(cmd, []string{}) if err != nil { t.Fatalf("runInit failed: %v", err) From 152efb725e6d024c295cfc12b451b6665319e7b1 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 16:55:12 +0000 Subject: [PATCH 08/39] migrate InspectSecret and InspectSecrets to client-go - Replace kubectl subprocess calls with clientset.CoreV1().Secrets().Get() - InspectSecret now returns *corev1.Secret (typed API) instead of *SecretKeys - InspectSecrets returns map[string]*corev1.Secret for batch operations - Empty namespace resolves via k8s.GetCurrentNamespace() before API call - Add SecretToSecretKeys/SecretsToSecretKeys adapters for backward compatibility - Remove GetCurrentNamespace function (replaced with k8s.GetCurrentNamespace) - Update cmd/apply.go to create k8s.Client and use new API signatures - Update pkg/secrets/secrets.go to use k8s.GetCurrentNamespace() Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 40 ++++++--- pkg/secrets/kubernetes.go | 174 +++++++++++++++----------------------- pkg/secrets/secrets.go | 5 +- 3 files changed, 101 insertions(+), 118 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index b5010a4..725ccdd 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -259,7 +259,7 @@ func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.Co // 2. Convert secrets if enabled if convertSecrets { - if err := handleSecrets(m, cfg, skipApply); err != nil { + if err := handleSecrets(ctx, m, cfg, skipApply); err != nil { return fmt.Errorf("failed to convert secrets: %w", err) } } else { @@ -276,7 +276,7 @@ func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.Co var imagePullSecretsInfo []initdata.ImagePullSecretInfo if convertSecrets { var err error - imagePullSecretsInfo, err = handleImagePullSecrets(m, cfg, skipApply) + imagePullSecretsInfo, err = handleImagePullSecrets(ctx, m, cfg, skipApply) if err != nil { return fmt.Errorf("failed to handle imagePullSecrets: %w", err) } @@ -401,7 +401,7 @@ func handleInitContainer(m *manifest.Manifest, cfg *config.CocoConfig) error { return nil } -func handleSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) error { +func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) error { // 1. Detect all secret references allSecretRefs, err := secrets.DetectSecrets(m.GetData()) if err != nil { @@ -430,13 +430,22 @@ func handleSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) fmt.Printf(" - Found %d K8s secret(s) to convert\n", len(secretRefs)) - // 2. Inspect K8s secrets - inspectedKeys, err := secrets.InspectSecrets(secretRefs) + // 2. Create Kubernetes client for secret inspection + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + if clientErr != nil { + return fmt.Errorf("failed to create Kubernetes client: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the secrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", clientErr) + } + + // 3. Inspect K8s secrets + inspectedSecrets, err := secrets.InspectSecrets(ctx, client.Clientset, secretRefs) if err != nil { - return fmt.Errorf("failed to inspect secrets via kubectl: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the secrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) + return fmt.Errorf("failed to inspect secrets: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the secrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) } - // 3. Convert to sealed secrets + // Convert to SecretKeys format for converter + inspectedKeys := secrets.SecretsToSecretKeys(inspectedSecrets) + + // 4. Convert to sealed secrets sealedSecrets, err := secrets.ConvertSecrets(secretRefs, inspectedKeys) if err != nil { return err @@ -444,7 +453,7 @@ func handleSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) fmt.Printf(" - Generated %d sealed secret(s)\n", len(sealedSecrets)) - // 4. Create or save sealed secrets based on skipApply flag + // 5. Create or save sealed secrets based on skipApply flag var sealedSecretNames map[string]string if skipApply { // Generate YAML and save to file instead of creating in cluster @@ -617,7 +626,7 @@ func addK8sSecretToTrustee(trusteeNamespace, secretName, secretNamespace string) // handleImagePullSecrets processes imagePullSecrets from the manifest // It detects, uploads to KBS, and prepares them for initdata // Falls back to default service account if no imagePullSecrets in manifest -func handleImagePullSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) ([]initdata.ImagePullSecretInfo, error) { +func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) ([]initdata.ImagePullSecretInfo, error) { // Detect imagePullSecrets in manifest, with fallback to default service account imagePullSecretRefs, err := secrets.DetectImagePullSecretsWithServiceAccount(m.GetData()) if err != nil { @@ -638,12 +647,21 @@ func handleImagePullSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipAp imagePullSecretRefs = imagePullSecretRefs[:1] } + // Create Kubernetes client for secret inspection + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + if clientErr != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", clientErr) + } + // Inspect K8s secrets to get keys - inspectedKeys, err := secrets.InspectSecrets(imagePullSecretRefs) + inspectedSecrets, err := secrets.InspectSecrets(ctx, client.Clientset, imagePullSecretRefs) if err != nil { - return nil, fmt.Errorf("failed to inspect imagePullSecrets via kubectl: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) + return nil, fmt.Errorf("failed to inspect imagePullSecrets: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) } + // Convert to SecretKeys format + inspectedKeys := secrets.SecretsToSecretKeys(inspectedSecrets) + // Build ImagePullSecretInfo for initdata var imagePullSecretsInfo []initdata.ImagePullSecretInfo for _, ref := range imagePullSecretRefs { diff --git a/pkg/secrets/kubernetes.go b/pkg/secrets/kubernetes.go index 358bdff..77d959f 100644 --- a/pkg/secrets/kubernetes.go +++ b/pkg/secrets/kubernetes.go @@ -7,124 +7,108 @@ import ( "fmt" "os/exec" "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/confidential-devhub/cococtl/pkg/k8s" ) // SecretKeys holds the keys found in a K8s secret +// Kept for backward compatibility with converter and cmd layer type SecretKeys struct { Name string Namespace string Keys []string } -// K8sSecret represents the structure of a K8s secret from kubectl output -type K8sSecret struct { - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - } `json:"metadata"` - Data map[string]string `json:"data"` // Keys are base64 encoded values -} - -// GetCurrentNamespace returns the current namespace from kubectl config. -// If no namespace is set in the current context or if there is no current context, -// returns "default". -func GetCurrentNamespace() (string, error) { - ctx := context.Background() - - // First check if a current context exists - checkCmd := exec.CommandContext(ctx, "kubectl", "config", "current-context") - if err := checkCmd.Run(); err != nil { - // No current context is not an error; we intentionally return "default" - return "default", nil //nolint:nilerr - } - - // Get namespace from the current context - cmd := exec.CommandContext(ctx, "kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to get current namespace: %w", err) +// SecretToSecretKeys converts a corev1.Secret to SecretKeys format +func SecretToSecretKeys(secret *corev1.Secret) *SecretKeys { + keys := make([]string, 0, len(secret.Data)) + for key := range secret.Data { + keys = append(keys, key) } - namespace := strings.TrimSpace(string(output)) - if namespace == "" { - namespace = "default" + return &SecretKeys{ + Name: secret.Name, + Namespace: secret.Namespace, + Keys: keys, } - - return namespace, nil } -// InspectSecret queries K8s to get all keys in a secret -// If namespace is empty, uses current context namespace (no -n flag) -// Returns error if kubectl fails or secret doesn't exist -func InspectSecret(secretName, namespace string) (*SecretKeys, error) { - ctx := context.Background() - // Build kubectl command - var cmd *exec.Cmd - if namespace != "" { - // Explicit namespace specified - cmd = exec.CommandContext(ctx, "kubectl", "get", "secret", secretName, "-n", namespace, "-o", "json") - } else { - // No namespace specified - use current context namespace - cmd = exec.CommandContext(ctx, "kubectl", "get", "secret", secretName, "-o", "json") +// SecretsToSecretKeys converts a map of corev1.Secret to SecretKeys format +func SecretsToSecretKeys(secrets map[string]*corev1.Secret) map[string]*SecretKeys { + result := make(map[string]*SecretKeys, len(secrets)) + for name, secret := range secrets { + result[name] = SecretToSecretKeys(secret) } + return result +} - // Execute command - output, err := cmd.Output() - if err != nil { - // Check if it's an exit error with stderr - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return nil, fmt.Errorf("kubectl get secret failed: %s", string(exitErr.Stderr)) +// InspectSecret queries K8s to get a secret using client-go +// If namespace is empty, uses current context namespace +// Returns typed *corev1.Secret with decoded Data field +func InspectSecret(ctx context.Context, clientset kubernetes.Interface, secretName, namespace string) (*corev1.Secret, error) { + // Resolve empty namespace to current context namespace + ns := namespace + if ns == "" { + var err error + ns, err = k8s.GetCurrentNamespace() + if err != nil { + return nil, fmt.Errorf("failed to resolve namespace: %w", err) } - return nil, fmt.Errorf("kubectl get secret failed: %w", err) } - // Parse JSON output - var k8sSecret K8sSecret - if err := json.Unmarshal(output, &k8sSecret); err != nil { - return nil, fmt.Errorf("failed to parse kubectl output: %w", err) - } - - // Extract keys - keys := make([]string, 0, len(k8sSecret.Data)) - for key := range k8sSecret.Data { - keys = append(keys, key) + // Get secret using client-go + secret, err := clientset.CoreV1().Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + return nil, k8s.WrapError(err, "get", fmt.Sprintf("secret/%s", secretName), ns) } - // Use the actual namespace from kubectl response (not the input parameter) - actualNamespace := k8sSecret.Metadata.Namespace - - return &SecretKeys{ - Name: secretName, - Namespace: actualNamespace, - Keys: keys, - }, nil + return secret, nil } -// InspectSecrets queries multiple secrets in batch -// Returns a map of secretName -> SecretKeys (includes namespace and keys) +// InspectSecrets queries multiple secrets in batch using client-go +// Returns a map of secretName -> *corev1.Secret // Fails immediately on first error -func InspectSecrets(refs []SecretReference) (map[string]*SecretKeys, error) { - result := make(map[string]*SecretKeys) +func InspectSecrets(ctx context.Context, clientset kubernetes.Interface, refs []SecretReference) (map[string]*corev1.Secret, error) { + result := make(map[string]*corev1.Secret) for _, ref := range refs { // Skip if lookup not needed (all keys already known) if !ref.NeedsLookup { if len(ref.Keys) > 0 { - // For secrets that don't need lookup, we still need namespace - // Use the namespace from ref (could be empty or explicit) - // If empty, it will be resolved during conversion - result[ref.Name] = &SecretKeys{ - Name: ref.Name, - Namespace: ref.Namespace, - Keys: ref.Keys, + // For secrets that don't need lookup, create minimal corev1.Secret + // with known keys populated + data := make(map[string][]byte) + for _, key := range ref.Keys { + data[key] = []byte{} // Empty data - actual values unknown + } + + // Resolve namespace if empty + ns := ref.Namespace + if ns == "" { + var err error + ns, err = k8s.GetCurrentNamespace() + if err != nil { + return nil, fmt.Errorf("failed to resolve namespace for secret %s: %w", ref.Name, err) + } + } + + result[ref.Name] = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: ref.Name, + Namespace: ns, + }, + Data: data, } } continue } - // Inspect the secret - secretKeys, err := InspectSecret(ref.Name, ref.Namespace) + // Inspect the secret using client-go + secret, err := InspectSecret(ctx, clientset, ref.Name, ref.Namespace) if err != nil { // Fail immediately nsInfo := "current context namespace" @@ -134,27 +118,7 @@ func InspectSecrets(refs []SecretReference) (map[string]*SecretKeys, error) { return nil, fmt.Errorf("failed to inspect secret %s in %s: %w", ref.Name, nsInfo, err) } - // Merge with known keys - allKeys := make(map[string]bool) - for _, key := range ref.Keys { - allKeys[key] = true - } - for _, key := range secretKeys.Keys { - allKeys[key] = true - } - - // Convert to slice - keys := make([]string, 0, len(allKeys)) - for key := range allKeys { - keys = append(keys, key) - } - - // Store with actual namespace from kubectl - result[ref.Name] = &SecretKeys{ - Name: ref.Name, - Namespace: secretKeys.Namespace, - Keys: keys, - } + result[ref.Name] = secret } return result, nil diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index bedc004..e286ab0 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -3,6 +3,7 @@ package secrets import ( "fmt" + "github.com/confidential-devhub/cococtl/pkg/k8s" "github.com/confidential-devhub/cococtl/pkg/manifest" ) @@ -37,7 +38,7 @@ func DetectSecrets(manifestData map[string]interface{}) ([]SecretReference, erro // If manifest doesn't specify namespace, get current kubectl context namespace if namespace == "" { var err error - namespace, err = GetCurrentNamespace() + namespace, err = k8s.GetCurrentNamespace() if err != nil { return nil, fmt.Errorf("manifest has no namespace and failed to get current namespace: %w", err) } @@ -306,7 +307,7 @@ func DetectImagePullSecretsWithServiceAccount(manifestData map[string]interface{ // If manifest doesn't specify namespace, get current kubectl context namespace if namespace == "" { var err error - namespace, err = GetCurrentNamespace() + namespace, err = k8s.GetCurrentNamespace() if err != nil { return nil, fmt.Errorf("manifest has no namespace and failed to get current namespace: %w", err) } From 32c67ea4b4afcf7c158823081eee2eaedbb8ef37 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Wed, 4 Feb 2026 16:56:11 +0000 Subject: [PATCH 09/39] add unit tests for secret operations - TestInspectSecret_Found - Secret exists in fake clientset - TestInspectSecret_NotFound - Secret doesn't exist - TestInspectSecret_WithExplicitNamespace - Explicit namespace provided - TestInspectSecret_EmptyNamespaceResolution - Empty namespace resolves to context namespace - TestInspectSecret_DataFieldDecoding - Data values are auto-decoded - TestInspectSecrets_MultipleSecrets - Batch query multiple secrets - TestInspectSecrets_NoLookupNeeded - Ref with NeedsLookup=false - TestInspectSecrets_FailFast - First error stops batch processing All tests use fake.NewSimpleClientset for testability Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/secrets/kubernetes_test.go | 316 +++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 pkg/secrets/kubernetes_test.go diff --git a/pkg/secrets/kubernetes_test.go b/pkg/secrets/kubernetes_test.go new file mode 100644 index 0000000..264c4c2 --- /dev/null +++ b/pkg/secrets/kubernetes_test.go @@ -0,0 +1,316 @@ +package secrets + +import ( + "context" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestInspectSecret_Found(t *testing.T) { + // Setup fake clientset with a secret + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("secret123"), + }, + Type: corev1.SecretTypeOpaque, + }, + ) + + ctx := context.Background() + secret, err := InspectSecret(ctx, fakeClient, "my-secret", "default") + if err != nil { + t.Fatalf("InspectSecret() error = %v", err) + } + + // Verify secret metadata + if secret.Name != "my-secret" { + t.Errorf("InspectSecret() Name = %q, want %q", secret.Name, "my-secret") + } + if secret.Namespace != "default" { + t.Errorf("InspectSecret() Namespace = %q, want %q", secret.Namespace, "default") + } + + // Verify data keys exist + if len(secret.Data) != 2 { + t.Errorf("InspectSecret() Data has %d keys, want 2", len(secret.Data)) + } + if _, ok := secret.Data["username"]; !ok { + t.Error("InspectSecret() Data missing 'username' key") + } + if _, ok := secret.Data["password"]; !ok { + t.Error("InspectSecret() Data missing 'password' key") + } + + // Verify data values are decoded (not base64) + if string(secret.Data["username"]) != "admin" { + t.Errorf("InspectSecret() Data['username'] = %q, want %q", string(secret.Data["username"]), "admin") + } +} + +func TestInspectSecret_NotFound(t *testing.T) { + // Setup empty fake clientset + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + _, err := InspectSecret(ctx, fakeClient, "missing-secret", "default") + if err == nil { + t.Fatal("InspectSecret() expected error for missing secret, got nil") + } + + // Error should mention the secret name + if !strings.Contains(err.Error(), "missing-secret") { + t.Errorf("InspectSecret() error = %q, want error mentioning 'missing-secret'", err.Error()) + } +} + +func TestInspectSecret_WithExplicitNamespace(t *testing.T) { + // Setup fake clientset with secret in custom namespace + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "custom-ns", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + ) + + ctx := context.Background() + secret, err := InspectSecret(ctx, fakeClient, "my-secret", "custom-ns") + if err != nil { + t.Fatalf("InspectSecret() error = %v", err) + } + + if secret.Namespace != "custom-ns" { + t.Errorf("InspectSecret() Namespace = %q, want %q", secret.Namespace, "custom-ns") + } +} + +func TestInspectSecret_EmptyNamespaceResolution(t *testing.T) { + // Setup fake clientset with secret in default namespace + // Note: fake clientset doesn't validate empty namespace (known limitation) + // Real client would error: "an empty namespace may not be set when a resource name is provided" + // This test verifies we resolve namespace before calling API + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + ) + + ctx := context.Background() + // Empty namespace should resolve to current context namespace (likely "default") + secret, err := InspectSecret(ctx, fakeClient, "my-secret", "") + if err != nil { + t.Fatalf("InspectSecret() error = %v", err) + } + + // Verify namespace was resolved (not empty) + if secret.Namespace == "" { + t.Error("InspectSecret() returned secret with empty namespace, expected resolved namespace") + } +} + +func TestInspectSecret_DataFieldDecoding(t *testing.T) { + // Setup fake clientset with secret containing various data types + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "data-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "text": []byte("plain text"), + "number": []byte("12345"), + "json": []byte(`{"key":"value"}`), + }, + Type: corev1.SecretTypeOpaque, + }, + ) + + ctx := context.Background() + secret, err := InspectSecret(ctx, fakeClient, "data-secret", "default") + if err != nil { + t.Fatalf("InspectSecret() error = %v", err) + } + + // Verify all data fields are accessible as []byte (auto-decoded from base64 in etcd) + if string(secret.Data["text"]) != "plain text" { + t.Errorf("InspectSecret() Data['text'] = %q, want %q", string(secret.Data["text"]), "plain text") + } + if string(secret.Data["number"]) != "12345" { + t.Errorf("InspectSecret() Data['number'] = %q, want %q", string(secret.Data["number"]), "12345") + } + if string(secret.Data["json"]) != `{"key":"value"}` { + t.Errorf("InspectSecret() Data['json'] = %q, want %q", string(secret.Data["json"]), `{"key":"value"}`) + } + + // Verify Data is map[string][]byte (not base64 strings) + for key, val := range secret.Data { + if val == nil { + t.Errorf("InspectSecret() Data[%q] is nil, expected []byte", key) + } + } +} + +func TestInspectSecrets_MultipleSecrets(t *testing.T) { + // Setup fake clientset with multiple secrets + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Namespace: "default", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret2", + Namespace: "default", + }, + Data: map[string][]byte{ + "key2": []byte("value2"), + }, + }, + ) + + ctx := context.Background() + refs := []SecretReference{ + {Name: "secret1", Namespace: "default", NeedsLookup: true}, + {Name: "secret2", Namespace: "default", NeedsLookup: true}, + } + + secrets, err := InspectSecrets(ctx, fakeClient, refs) + if err != nil { + t.Fatalf("InspectSecrets() error = %v", err) + } + + // Verify both secrets returned + if len(secrets) != 2 { + t.Errorf("InspectSecrets() returned %d secrets, want 2", len(secrets)) + } + + // Verify secret1 + if secret1, ok := secrets["secret1"]; !ok { + t.Error("InspectSecrets() missing 'secret1' in results") + } else { + if secret1.Name != "secret1" { + t.Errorf("InspectSecrets() secret1.Name = %q, want %q", secret1.Name, "secret1") + } + if _, ok := secret1.Data["key1"]; !ok { + t.Error("InspectSecrets() secret1 missing 'key1' in Data") + } + } + + // Verify secret2 + if secret2, ok := secrets["secret2"]; !ok { + t.Error("InspectSecrets() missing 'secret2' in results") + } else { + if secret2.Name != "secret2" { + t.Errorf("InspectSecrets() secret2.Name = %q, want %q", secret2.Name, "secret2") + } + if _, ok := secret2.Data["key2"]; !ok { + t.Error("InspectSecrets() secret2 missing 'key2' in Data") + } + } +} + +func TestInspectSecrets_NoLookupNeeded(t *testing.T) { + // Setup empty fake clientset (secret doesn't need to exist) + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + refs := []SecretReference{ + { + Name: "secret1", + Namespace: "default", + Keys: []string{"key1", "key2"}, + NeedsLookup: false, // Keys already known, no lookup needed + }, + } + + secrets, err := InspectSecrets(ctx, fakeClient, refs) + if err != nil { + t.Fatalf("InspectSecrets() error = %v", err) + } + + // Verify secret returned with known keys + if len(secrets) != 1 { + t.Errorf("InspectSecrets() returned %d secrets, want 1", len(secrets)) + } + + secret, ok := secrets["secret1"] + if !ok { + t.Fatal("InspectSecrets() missing 'secret1' in results") + } + + // Verify metadata populated + if secret.Name != "secret1" { + t.Errorf("InspectSecrets() secret.Name = %q, want %q", secret.Name, "secret1") + } + if secret.Namespace != "default" { + t.Errorf("InspectSecrets() secret.Namespace = %q, want %q", secret.Namespace, "default") + } + + // Verify keys are present (values will be empty) + if len(secret.Data) != 2 { + t.Errorf("InspectSecrets() secret.Data has %d keys, want 2", len(secret.Data)) + } + if _, ok := secret.Data["key1"]; !ok { + t.Error("InspectSecrets() secret missing 'key1' in Data") + } + if _, ok := secret.Data["key2"]; !ok { + t.Error("InspectSecrets() secret missing 'key2' in Data") + } +} + +func TestInspectSecrets_FailFast(t *testing.T) { + // Setup fake clientset with only one secret + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Namespace: "default", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + ) + + ctx := context.Background() + refs := []SecretReference{ + {Name: "secret1", Namespace: "default", NeedsLookup: true}, + {Name: "missing-secret", Namespace: "default", NeedsLookup: true}, + {Name: "secret3", Namespace: "default", NeedsLookup: true}, + } + + _, err := InspectSecrets(ctx, fakeClient, refs) + if err == nil { + t.Fatal("InspectSecrets() expected error for missing secret, got nil") + } + + // Error should mention the missing secret (fail-fast on first error) + if !strings.Contains(err.Error(), "missing-secret") { + t.Errorf("InspectSecrets() error = %q, want error mentioning 'missing-secret'", err.Error()) + } +} From 3dd0750e483dfa7047ddd6daca0950689b4d1d93 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Thu, 5 Feb 2026 17:53:33 +0000 Subject: [PATCH 10/39] migrate IsDeployed to client-go deployment query - Replace kubectl subprocess with clientset.AppsV1().Deployments().List() - Add ctx and clientset parameters to IsDeployed and Deploy functions - Handle not found errors as false instead of error - Add 3 unit tests with fake clientset (found/not found/wrong label) - Update Deploy function signature to thread ctx and clientset to ensureNamespace Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/trustee/trustee.go | 54 ++++++++++++-------------- pkg/trustee/trustee_test.go | 76 +++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 30 deletions(-) diff --git a/pkg/trustee/trustee.go b/pkg/trustee/trustee.go index 1a82405..c5b225d 100644 --- a/pkg/trustee/trustee.go +++ b/pkg/trustee/trustee.go @@ -2,6 +2,7 @@ package trustee import ( + "context" "crypto/ed25519" "crypto/x509" "encoding/base64" @@ -12,6 +13,10 @@ import ( "os/exec" "path/filepath" "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" ) const ( @@ -51,32 +56,26 @@ type SecretResource struct { } // IsDeployed checks if Trustee is already running in the namespace -func IsDeployed(namespace string) (bool, error) { - cmd := exec.Command("kubectl", "get", "deployment", "-n", namespace, "-l", trusteeLabel, "-o", "json") - output, err := cmd.CombinedOutput() +func IsDeployed(ctx context.Context, clientset kubernetes.Interface, namespace string) (bool, error) { + // List deployments with the trustee label + deployments, err := clientset.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: trusteeLabel, + }) if err != nil { - if strings.Contains(string(output), "NotFound") || strings.Contains(string(output), "No resources found") { + // If namespace doesn't exist or no permission, treat as not deployed + if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) { return false, nil } - return false, fmt.Errorf("failed to check Trustee deployment: %w\n%s", err, output) - } - - var result map[string]interface{} - if err := json.Unmarshal(output, &result); err != nil { - return false, fmt.Errorf("failed to parse kubectl output: %w", err) - } - - items, ok := result["items"].([]interface{}) - if !ok || len(items) == 0 { - return false, nil + return false, fmt.Errorf("failed to check Trustee deployment: %w", err) } - return true, nil + // Check if any deployments were found + return len(deployments.Items) > 0, nil } // Deploy deploys Trustee all-in-one KBS to the specified namespace -func Deploy(cfg *Config) error { - if err := ensureNamespace(cfg.Namespace); err != nil { +func Deploy(ctx context.Context, clientset kubernetes.Interface, cfg *Config) error { + if err := ensureNamespace(ctx, clientset, cfg.Namespace); err != nil { return fmt.Errorf("failed to create namespace: %w", err) } @@ -118,26 +117,21 @@ func GetServiceURL(namespace, serviceName string) string { return fmt.Sprintf("http://%s.%s.svc.cluster.local:8080", serviceName, namespace) } -func ensureNamespace(namespace string) error { +func ensureNamespace(ctx context.Context, clientset kubernetes.Interface, namespace string) error { // Check if namespace exists by trying to access it (namespace-level permission) // This is more reliable than 'kubectl get namespace' which requires cluster-level permissions - cmd := exec.Command("kubectl", "get", "serviceaccounts", "-n", namespace, "--limit=1") - output, err := cmd.CombinedOutput() + _, err := clientset.CoreV1().ServiceAccounts(namespace).List(ctx, metav1.ListOptions{Limit: 1}) if err == nil { // Successfully accessed resources in namespace, so it exists return nil } // Check if the error indicates namespace doesn't exist - outputStr := string(output) - namespaceNotFound := strings.Contains(outputStr, "NotFound") || - strings.Contains(outputStr, "not found") || - strings.Contains(outputStr, fmt.Sprintf("namespace \"%s\" not found", namespace)) - - if namespaceNotFound { - // Namespace doesn't exist, try to create it - cmd = exec.Command("kubectl", "create", "namespace", namespace) - output, err = cmd.CombinedOutput() + if apierrors.IsNotFound(err) { + // Namespace doesn't exist, try to create it with kubectl + // (namespace creation via client-go is out of scope for this phase) + cmd := exec.Command("kubectl", "create", "namespace", namespace) + output, err := cmd.CombinedOutput() if err != nil && !strings.Contains(string(output), "AlreadyExists") { return fmt.Errorf("failed to create namespace: %w\n%s", err, output) } diff --git a/pkg/trustee/trustee_test.go b/pkg/trustee/trustee_test.go index 67559d7..b3809d2 100644 --- a/pkg/trustee/trustee_test.go +++ b/pkg/trustee/trustee_test.go @@ -1,12 +1,88 @@ package trustee import ( + "context" "strings" "testing" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + "gopkg.in/yaml.v3" ) +// TestIsDeployed_Found tests that IsDeployed returns true when a deployment with the trustee label exists +func TestIsDeployed_Found(t *testing.T) { + // Create a fake clientset with a deployment that has the trustee label + fakeClient := fake.NewSimpleClientset( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "trustee-deployment", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + }, + ) + + ctx := context.Background() + deployed, err := IsDeployed(ctx, fakeClient, "coco-tenant") + + if err != nil { + t.Fatalf("IsDeployed() error = %v, want nil", err) + } + + if !deployed { + t.Errorf("IsDeployed() = false, want true") + } +} + +// TestIsDeployed_NotFound tests that IsDeployed returns false when no deployment exists +func TestIsDeployed_NotFound(t *testing.T) { + // Create an empty fake clientset + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + deployed, err := IsDeployed(ctx, fakeClient, "coco-tenant") + + if err != nil { + t.Fatalf("IsDeployed() error = %v, want nil", err) + } + + if deployed { + t.Errorf("IsDeployed() = true, want false") + } +} + +// TestIsDeployed_WrongLabel tests that IsDeployed returns false when deployment exists but has wrong label +func TestIsDeployed_WrongLabel(t *testing.T) { + // Create a fake clientset with a deployment that has a different label + fakeClient := fake.NewSimpleClientset( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-deployment", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "other-app", + }, + }, + }, + ) + + ctx := context.Background() + deployed, err := IsDeployed(ctx, fakeClient, "coco-tenant") + + if err != nil { + t.Fatalf("IsDeployed() error = %v, want nil", err) + } + + if deployed { + t.Errorf("IsDeployed() = true, want false when deployment has wrong label") + } +} + // TestDeployKBS_ResourceLimits tests that the KBS deployment includes CPU resource requests and limits func TestDeployKBS_ResourceLimits(t *testing.T) { cfg := &Config{ From 327f851f7f89cc4e6d10f890a8d1cb92f95fc9da Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Thu, 5 Feb 2026 17:53:40 +0000 Subject: [PATCH 11/39] migrate GetKBSPodName to client-go pod query - Replace kubectl subprocess call with clientset.CoreV1().Pods().List() - Use LabelSelector "app=kbs" to filter pods - Return first matching pod name or error if none found - Update WaitForKBSReady to accept ctx + clientset parameters - Update populateSecrets to create k8s client for pod queries - Add comprehensive unit tests with fake clientset - TestGetKBSPodName_Found: pod with label exists - TestGetKBSPodName_NotFound: no pods found - TestGetKBSPodName_MultiplePods: returns first pod Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/trustee/kbs.go | 36 ++++++++++------ pkg/trustee/kbs_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 pkg/trustee/kbs_test.go diff --git a/pkg/trustee/kbs.go b/pkg/trustee/kbs.go index fcb2f5f..c103f54 100644 --- a/pkg/trustee/kbs.go +++ b/pkg/trustee/kbs.go @@ -1,12 +1,18 @@ package trustee import ( + "context" "crypto/rand" "fmt" "os" "os/exec" "path/filepath" "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/confidential-devhub/cococtl/pkg/k8s" ) const kbsRepositoryPath = "/opt/confidential-containers/kbs/repository" @@ -45,25 +51,24 @@ func UploadResources(namespace string, resources map[string][]byte) error { } // GetKBSPodName retrieves the name of the KBS pod in the specified namespace. -func GetKBSPodName(namespace string) (string, error) { - cmd := exec.Command("kubectl", "get", "pod", "-n", namespace, - "-l", "app=kbs", "-o", "jsonpath={.items[0].metadata.name}") - output, err := cmd.CombinedOutput() +func GetKBSPodName(ctx context.Context, clientset kubernetes.Interface, namespace string) (string, error) { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app=kbs", + }) if err != nil { - return "", fmt.Errorf("failed to get KBS pod: %w\n%s", err, output) + return "", fmt.Errorf("failed to list pods: %w", err) } - podName := strings.TrimSpace(string(output)) - if podName == "" { + if len(pods.Items) == 0 { return "", fmt.Errorf("no KBS pod found in namespace %s", namespace) } - return podName, nil + return pods.Items[0].Name, nil } // WaitForKBSReady waits for the KBS pod to be ready. -func WaitForKBSReady(namespace string) error { - podName, err := GetKBSPodName(namespace) +func WaitForKBSReady(ctx context.Context, clientset kubernetes.Interface, namespace string) error { + podName, err := GetKBSPodName(ctx, clientset, namespace) if err != nil { return err } @@ -84,12 +89,19 @@ func populateSecrets(namespace string, secrets []SecretResource) error { return nil } - podName, err := GetKBSPodName(namespace) + // For now, create context here until UploadResource/UploadResources are migrated + ctx := context.Background() + client, err := k8s.NewClient(k8s.ClientOptions{}) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + + podName, err := GetKBSPodName(ctx, client.Clientset, namespace) if err != nil { return err } - if err := WaitForKBSReady(namespace); err != nil { + if err := WaitForKBSReady(ctx, client.Clientset, namespace); err != nil { return err } diff --git a/pkg/trustee/kbs_test.go b/pkg/trustee/kbs_test.go new file mode 100644 index 0000000..ab8aa57 --- /dev/null +++ b/pkg/trustee/kbs_test.go @@ -0,0 +1,93 @@ +package trustee + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetKBSPodName_Found(t *testing.T) { + // Setup fake clientset with KBS pod + fakeClient := fake.NewSimpleClientset( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-pod-1", + Namespace: "trustee-ns", + Labels: map[string]string{"app": "kbs"}, + }, + }, + ) + + ctx := context.Background() + podName, err := GetKBSPodName(ctx, fakeClient, "trustee-ns") + if err != nil { + t.Fatalf("GetKBSPodName() error = %v, want nil", err) + } + + expectedName := "kbs-pod-1" + if podName != expectedName { + t.Errorf("GetKBSPodName() = %v, want %v", podName, expectedName) + } +} + +func TestGetKBSPodName_NotFound(t *testing.T) { + // Empty fake clientset - no pods + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + podName, err := GetKBSPodName(ctx, fakeClient, "trustee-ns") + if err == nil { + t.Fatalf("GetKBSPodName() error = nil, want error") + } + + if podName != "" { + t.Errorf("GetKBSPodName() = %v, want empty string on error", podName) + } + + expectedErr := "no KBS pod found in namespace trustee-ns" + if err.Error() != expectedErr { + t.Errorf("GetKBSPodName() error = %v, want %v", err.Error(), expectedErr) + } +} + +func TestGetKBSPodName_MultiplePods(t *testing.T) { + // Setup fake clientset with multiple KBS pods + fakeClient := fake.NewSimpleClientset( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-pod-1", + Namespace: "trustee-ns", + Labels: map[string]string{"app": "kbs"}, + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-pod-2", + Namespace: "trustee-ns", + Labels: map[string]string{"app": "kbs"}, + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-pod", + Namespace: "trustee-ns", + Labels: map[string]string{"app": "other"}, + }, + }, + ) + + ctx := context.Background() + podName, err := GetKBSPodName(ctx, fakeClient, "trustee-ns") + if err != nil { + t.Fatalf("GetKBSPodName() error = %v, want nil", err) + } + + // Should return first matching pod + expectedName := "kbs-pod-1" + if podName != expectedName { + t.Errorf("GetKBSPodName() = %v, want %v (first pod)", podName, expectedName) + } +} From 4d1aa508eb5c632e4c2919ee5e0d67147449353c Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Thu, 5 Feb 2026 17:54:30 +0000 Subject: [PATCH 12/39] migrate ensureNamespace from kubectl to client-go Replace the kubectl-based namespace creation with a direct client-go Namespaces().Create() call and add comprehensive unit tests. Co-Authored-By: Claude Opus 4.6 --- pkg/trustee/trustee.go | 36 +++++++++++++------------------ pkg/trustee/trustee_test.go | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/pkg/trustee/trustee.go b/pkg/trustee/trustee.go index c5b225d..474831a 100644 --- a/pkg/trustee/trustee.go +++ b/pkg/trustee/trustee.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strings" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -118,29 +119,22 @@ func GetServiceURL(namespace, serviceName string) string { } func ensureNamespace(ctx context.Context, clientset kubernetes.Interface, namespace string) error { - // Check if namespace exists by trying to access it (namespace-level permission) - // This is more reliable than 'kubectl get namespace' which requires cluster-level permissions - _, err := clientset.CoreV1().ServiceAccounts(namespace).List(ctx, metav1.ListOptions{Limit: 1}) - if err == nil { - // Successfully accessed resources in namespace, so it exists - return nil - } - - // Check if the error indicates namespace doesn't exist - if apierrors.IsNotFound(err) { - // Namespace doesn't exist, try to create it with kubectl - // (namespace creation via client-go is out of scope for this phase) - cmd := exec.Command("kubectl", "create", "namespace", namespace) - output, err := cmd.CombinedOutput() - if err != nil && !strings.Contains(string(output), "AlreadyExists") { - return fmt.Errorf("failed to create namespace: %w\n%s", err, output) + // Try to create the namespace directly. This is simpler and more reliable + // than checking existence first: + // - If namespace doesn't exist: creates it + // - If namespace exists: AlreadyExists error is ignored + // - If user lacks create permission but namespace exists: subsequent + // operations will work (or fail with clear permission errors) + _, err := clientset.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespace}, + }, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + // Ignore Forbidden — namespace likely exists but user lacks create permission. + // Subsequent operations will fail appropriately if it truly doesn't exist. + if !apierrors.IsForbidden(err) { + return fmt.Errorf("failed to create namespace %s: %w", namespace, err) } - return nil } - - // For any other error (e.g., Forbidden when user lacks namespace creation permissions - // but namespace exists), assume namespace exists and proceed. - // Subsequent operations will fail appropriately if the namespace truly doesn't exist. return nil } diff --git a/pkg/trustee/trustee_test.go b/pkg/trustee/trustee_test.go index b3809d2..7e61a0d 100644 --- a/pkg/trustee/trustee_test.go +++ b/pkg/trustee/trustee_test.go @@ -6,6 +6,7 @@ import ( "testing" appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" @@ -83,6 +84,47 @@ func TestIsDeployed_WrongLabel(t *testing.T) { } } +// TestEnsureNamespace_Exists tests that ensureNamespace returns nil when namespace already exists +func TestEnsureNamespace_Exists(t *testing.T) { + // Create a fake clientset with the namespace already existing + fakeClient := fake.NewSimpleClientset( + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "coco-tenant", + }, + }, + ) + + ctx := context.Background() + err := ensureNamespace(ctx, fakeClient, "coco-tenant") + + if err != nil { + t.Errorf("ensureNamespace() error = %v, want nil when namespace exists", err) + } +} + +// TestEnsureNamespace_Creates tests that ensureNamespace creates a new namespace +func TestEnsureNamespace_Creates(t *testing.T) { + // Create an empty fake clientset (namespace doesn't exist yet) + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + err := ensureNamespace(ctx, fakeClient, "new-ns") + + if err != nil { + t.Errorf("ensureNamespace() error = %v, want nil when creating namespace", err) + } + + // Verify namespace was actually created + ns, getErr := fakeClient.CoreV1().Namespaces().Get(ctx, "new-ns", metav1.GetOptions{}) + if getErr != nil { + t.Fatalf("expected namespace to be created, got error: %v", getErr) + } + if ns.Name != "new-ns" { + t.Errorf("namespace name = %q, want %q", ns.Name, "new-ns") + } +} + // TestDeployKBS_ResourceLimits tests that the KBS deployment includes CPU resource requests and limits func TestDeployKBS_ResourceLimits(t *testing.T) { cfg := &Config{ From 60f23537c6cc6e5675c1acee85f2d2f41fb1e8bc Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Thu, 5 Feb 2026 17:55:37 +0000 Subject: [PATCH 13/39] migrate GetServiceAccountImagePullSecrets to client-go - Replace kubectl subprocess call with clientset.CoreV1().ServiceAccounts().Get() - Access ImagePullSecrets via typed field sa.ImagePullSecrets[0].Name - Resolve empty namespace to current context namespace before API call - Remove custom ServiceAccount struct (lines 277-286) - Remove unused encoding/json import - Update DetectImagePullSecretsWithServiceAccount to create k8s client - Add comprehensive unit tests with fake clientset - TestGetServiceAccountImagePullSecrets_Found: returns first secret - TestGetServiceAccountImagePullSecrets_NotFound: serviceaccount missing - TestGetServiceAccountImagePullSecrets_NoSecrets: empty imagePullSecrets - TestGetServiceAccountImagePullSecrets_EmptyNamespace: namespace resolution Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/secrets/kubernetes.go | 45 +++++----------- pkg/secrets/kubernetes_test.go | 99 ++++++++++++++++++++++++++++++++++ pkg/secrets/secrets.go | 22 +++++--- 3 files changed, 127 insertions(+), 39 deletions(-) diff --git a/pkg/secrets/kubernetes.go b/pkg/secrets/kubernetes.go index 77d959f..b41918d 100644 --- a/pkg/secrets/kubernetes.go +++ b/pkg/secrets/kubernetes.go @@ -2,7 +2,6 @@ package secrets import ( "context" - "encoding/json" "errors" "fmt" "os/exec" @@ -264,47 +263,31 @@ func CreateSealedSecrets(sealedSecrets []*SealedSecretData) (map[string]string, return result, nil } -// ServiceAccount represents the structure of a K8s service account from kubectl output -type ServiceAccount struct { - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - } `json:"metadata"` - ImagePullSecrets []struct { - Name string `json:"name"` - } `json:"imagePullSecrets"` -} - // GetServiceAccountImagePullSecrets queries a service account for imagePullSecrets // If namespace is empty, uses current context namespace // Returns the first imagePullSecret name or empty string if none found -func GetServiceAccountImagePullSecrets(serviceAccountName, namespace string) (string, error) { - ctx := context.Background() - - var cmd *exec.Cmd - if namespace != "" { - cmd = exec.CommandContext(ctx, "kubectl", "get", "sa", serviceAccountName, "-n", namespace, "-o", "json") - } else { - cmd = exec.CommandContext(ctx, "kubectl", "get", "sa", serviceAccountName, "-o", "json") - } - - output, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", fmt.Errorf("kubectl get sa failed: %s", string(exitErr.Stderr)) +func GetServiceAccountImagePullSecrets(ctx context.Context, clientset kubernetes.Interface, serviceAccountName, namespace string) (string, error) { + // Resolve empty namespace to current context namespace + ns := namespace + if ns == "" { + var err error + ns, err = k8s.GetCurrentNamespace() + if err != nil { + return "", fmt.Errorf("failed to resolve namespace: %w", err) } - return "", fmt.Errorf("kubectl get sa failed: %w", err) } - var sa ServiceAccount - if err := json.Unmarshal(output, &sa); err != nil { - return "", fmt.Errorf("failed to parse kubectl output: %w", err) + // Get ServiceAccount using client-go + sa, err := clientset.CoreV1().ServiceAccounts(ns).Get(ctx, serviceAccountName, metav1.GetOptions{}) + if err != nil { + return "", k8s.WrapError(err, "get", fmt.Sprintf("serviceaccount/%s", serviceAccountName), ns) } + // Typed field access - ImagePullSecrets is []corev1.LocalObjectReference if len(sa.ImagePullSecrets) == 0 { return "", nil } + // Return first imagePullSecret name return sa.ImagePullSecrets[0].Name, nil } diff --git a/pkg/secrets/kubernetes_test.go b/pkg/secrets/kubernetes_test.go index 264c4c2..a8dd7d5 100644 --- a/pkg/secrets/kubernetes_test.go +++ b/pkg/secrets/kubernetes_test.go @@ -314,3 +314,102 @@ func TestInspectSecrets_FailFast(t *testing.T) { t.Errorf("InspectSecrets() error = %q, want error mentioning 'missing-secret'", err.Error()) } } + +func TestGetServiceAccountImagePullSecrets_Found(t *testing.T) { + // Setup fake clientset with serviceaccount that has imagePullSecrets + fakeClient := fake.NewSimpleClientset( + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: "default", + }, + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: "regcred"}, + {Name: "regcred2"}, + }, + }, + ) + + ctx := context.Background() + secretName, err := GetServiceAccountImagePullSecrets(ctx, fakeClient, "test-sa", "default") + if err != nil { + t.Fatalf("GetServiceAccountImagePullSecrets() error = %v, want nil", err) + } + + // Should return first imagePullSecret name + expectedName := "regcred" + if secretName != expectedName { + t.Errorf("GetServiceAccountImagePullSecrets() = %q, want %q", secretName, expectedName) + } +} + +func TestGetServiceAccountImagePullSecrets_NotFound(t *testing.T) { + // Empty fake clientset - serviceaccount doesn't exist + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + _, err := GetServiceAccountImagePullSecrets(ctx, fakeClient, "missing-sa", "default") + if err == nil { + t.Fatal("GetServiceAccountImagePullSecrets() expected error for missing serviceaccount, got nil") + } + + // Error should mention the serviceaccount name + if !strings.Contains(err.Error(), "missing-sa") && !strings.Contains(err.Error(), "not found") { + t.Errorf("GetServiceAccountImagePullSecrets() error = %q, want error mentioning 'missing-sa' or 'not found'", err.Error()) + } +} + +func TestGetServiceAccountImagePullSecrets_NoSecrets(t *testing.T) { + // Setup fake clientset with serviceaccount but no imagePullSecrets + fakeClient := fake.NewSimpleClientset( + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: "default", + }, + ImagePullSecrets: []corev1.LocalObjectReference{}, + }, + ) + + ctx := context.Background() + secretName, err := GetServiceAccountImagePullSecrets(ctx, fakeClient, "test-sa", "default") + if err != nil { + t.Fatalf("GetServiceAccountImagePullSecrets() error = %v, want nil", err) + } + + // Should return empty string when no imagePullSecrets configured + if secretName != "" { + t.Errorf("GetServiceAccountImagePullSecrets() = %q, want empty string", secretName) + } +} + +func TestGetServiceAccountImagePullSecrets_EmptyNamespace(t *testing.T) { + // Setup fake clientset with serviceaccount in default namespace + // Note: fake clientset doesn't validate empty namespace (known limitation) + // Real client would error: "an empty namespace may not be set when a resource name is provided" + // This test verifies we resolve namespace before calling API + fakeClient := fake.NewSimpleClientset( + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: "default", + }, + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: "regcred"}, + }, + }, + ) + + ctx := context.Background() + // Empty namespace should resolve to current context namespace + secretName, err := GetServiceAccountImagePullSecrets(ctx, fakeClient, "test-sa", "") + if err != nil { + t.Fatalf("GetServiceAccountImagePullSecrets() error = %v, want nil", err) + } + + // Should still return the secret name + expectedName := "regcred" + if secretName != expectedName { + t.Errorf("GetServiceAccountImagePullSecrets() = %q, want %q", secretName, expectedName) + } +} diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index e286ab0..a72c483 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -1,6 +1,7 @@ package secrets import ( + "context" "fmt" "github.com/confidential-devhub/cococtl/pkg/k8s" @@ -326,14 +327,19 @@ func DetectImagePullSecretsWithServiceAccount(manifestData map[string]interface{ // If no imagePullSecrets found in manifest, check default service account if len(secretsMap) == 0 { - secretName, err := GetServiceAccountImagePullSecrets("default", namespace) - if err == nil && secretName != "" { - // Found imagePullSecret in default service account - ref := getOrCreateSecretRef(secretsMap, secretName, namespace) - ref.NeedsLookup = true - ref.Usages = append(ref.Usages, SecretUsage{ - Type: "imagePullSecrets", - }) + // Create context and clientset for serviceaccount query + ctx := context.Background() + client, err := k8s.NewClient(k8s.ClientOptions{}) + if err == nil { + secretName, err := GetServiceAccountImagePullSecrets(ctx, client.Clientset, "default", namespace) + if err == nil && secretName != "" { + // Found imagePullSecret in default service account + ref := getOrCreateSecretRef(secretsMap, secretName, namespace) + ref.NeedsLookup = true + ref.Usages = append(ref.Usages, SecretUsage{ + Type: "imagePullSecrets", + }) + } } } From a6180fee17748c5ea9fccd7c942058480ab0d298 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Thu, 5 Feb 2026 18:37:18 +0000 Subject: [PATCH 14/39] update handleTrusteeSetup to use new trustee API signatures - Pass cmd parameter to handleTrusteeSetup for context access - Create k8s client and pass ctx+clientset to IsDeployed and Deploy - Fixes build failure from Phase 4 signature changes Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/init.go | 17 ++++++++++++----- cmd/root.go | 22 ---------------------- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index caf30bd..6b07373 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -89,7 +89,7 @@ func runInit(cmd *cobra.Command, _ []string) error { cfg := config.DefaultConfig() // Handle Trustee setup - trusteeDeployed, actualNamespace, err := handleTrusteeSetup(cfg, interactive, skipTrusteeDeploy, trusteeNamespace, trusteeURL) + trusteeDeployed, actualNamespace, err := handleTrusteeSetup(cmd, cfg, interactive, skipTrusteeDeploy, trusteeNamespace, trusteeURL) if err != nil { return err } @@ -200,7 +200,7 @@ func promptString(prompt, defaultValue string, required bool) string { return input } -func handleTrusteeSetup(cfg *config.CocoConfig, interactive, skipDeploy bool, namespace, url string) (bool, string, error) { +func handleTrusteeSetup(cmd *cobra.Command, cfg *config.CocoConfig, interactive, skipDeploy bool, namespace, url string) (bool, string, error) { // If URL provided via flag, use it and skip deployment if url != "" { cfg.TrusteeServer = url @@ -241,14 +241,21 @@ func handleTrusteeSetup(cfg *config.CocoConfig, interactive, skipDeploy bool, na // Get current namespace if not specified if namespace == "" { var err error - namespace, err = getCurrentNamespace() + namespace, err = k8s.GetCurrentNamespace() if err != nil { return false, "", err } } + // Create Kubernetes client for trustee operations + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + if clientErr != nil { + return false, "", fmt.Errorf("failed to create Kubernetes client: %w", clientErr) + } + ctx := cmd.Context() + // Check if Trustee is already deployed - deployed, err := trustee.IsDeployed(namespace) + deployed, err := trustee.IsDeployed(ctx, client.Clientset, namespace) if err != nil { return false, "", fmt.Errorf("failed to check Trustee deployment: %w", err) } @@ -274,7 +281,7 @@ func handleTrusteeSetup(cfg *config.CocoConfig, interactive, skipDeploy bool, na PCCSURL: cfg.PCCSURL, } - if err := trustee.Deploy(trusteeCfg); err != nil { + if err := trustee.Deploy(ctx, client.Clientset, trusteeCfg); err != nil { return false, "", fmt.Errorf("failed to deploy Trustee: %w", err) } diff --git a/cmd/root.go b/cmd/root.go index 65aed62..f765d73 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,11 +2,6 @@ package cmd import ( - "context" - "fmt" - "os/exec" - "strings" - "github.com/spf13/cobra" ) @@ -34,20 +29,3 @@ func Execute() error { func init() { cobra.OnInitialize() } - -// getCurrentNamespace gets the current namespace from kubectl config -func getCurrentNamespace() (string, error) { - ctx := context.Background() - cmd := exec.CommandContext(ctx, "kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to get current namespace: %w", err) - } - - namespace := strings.TrimSpace(string(output)) - if namespace == "" { - namespace = "default" - } - - return namespace, nil -} From 43e9e72beb9cbc27809373432aa91ec330017f94 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Fri, 6 Feb 2026 04:03:53 +0000 Subject: [PATCH 15/39] add kubectl detection with context caching - Add contextKey type and kubectlAvailableKey constant - Add detectKubectl function using exec.LookPath - Add isKubectlAvailable helper to retrieve cached value - Add requireKubectl guard with installation instructions Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/root.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index f765d73..1ec1da2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,10 @@ package cmd import ( + "context" + "fmt" + "os/exec" + "github.com/spf13/cobra" ) @@ -29,3 +33,34 @@ func Execute() error { func init() { cobra.OnInitialize() } + +// contextKey is the type for context keys used in cococtl +type contextKey int + +const kubectlAvailableKey contextKey = iota + +// detectKubectl checks if kubectl is available in PATH and caches the result in context +func detectKubectl(ctx context.Context) context.Context { + _, err := exec.LookPath("kubectl") + return context.WithValue(ctx, kubectlAvailableKey, err == nil) +} + +// isKubectlAvailable retrieves the cached kubectl availability from context +func isKubectlAvailable(ctx context.Context) bool { + if v := ctx.Value(kubectlAvailableKey); v != nil { + return v.(bool) + } + return false +} + +// requireKubectl returns an error if kubectl is not available, providing installation guidance +func requireKubectl(ctx context.Context, operation string) error { + if !isKubectlAvailable(ctx) { + return fmt.Errorf("kubectl is required for %s operations\n\n"+ + "To fix:\n"+ + " 1. Install kubectl: https://kubernetes.io/docs/tasks/tools/\n"+ + " 2. Ensure kubectl is in your PATH\n"+ + " 3. Verify with: kubectl version --client", operation) + } + return nil +} From 1bb42dd05d9c8a81706b664b4d874d1b745194c6 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Fri, 6 Feb 2026 04:04:50 +0000 Subject: [PATCH 16/39] add kubectl requirement checks to write operations - cmd/init.go: detectKubectl in handleTrusteeSetup, requireKubectl before trustee.Deploy - cmd/apply.go: detectKubectl at start of runApply, requireKubectl check before any operations - Thread enhanced context through transformManifest and downstream calls Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 11 ++++++++++- cmd/init.go | 9 ++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 725ccdd..56f4ed0 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -85,6 +85,15 @@ func init() { } func runApply(cmd *cobra.Command, _ []string) error { + // Detect kubectl availability immediately - needed for apply and secret upload operations + ctx := detectKubectl(cmd.Context()) + + // Fail fast if kubectl not available - apply command requires kubectl for both + // addK8sSecretToTrustee (kubectl cp/exec) and final kubectl apply + if err := requireKubectl(ctx, "apply"); err != nil { + return err + } + // Validate required flags (manual validation to keep all flags visible in shell completion) if manifestFile == "" { return fmt.Errorf("required flag(s) \"filename\" not set") @@ -180,7 +189,7 @@ func runApply(cmd *cobra.Command, _ []string) error { } // Transform manifest - if err := transformManifest(cmd.Context(), m, cfg, rc, skipApply); err != nil { + if err := transformManifest(ctx, m, cfg, rc, skipApply); err != nil { return fmt.Errorf("failed to transform manifest: %w", err) } diff --git a/cmd/init.go b/cmd/init.go index 6b07373..ed92d06 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -252,7 +252,9 @@ func handleTrusteeSetup(cmd *cobra.Command, cfg *config.CocoConfig, interactive, if clientErr != nil { return false, "", fmt.Errorf("failed to create Kubernetes client: %w", clientErr) } - ctx := cmd.Context() + + // Detect kubectl availability and enhance context + ctx := detectKubectl(cmd.Context()) // Check if Trustee is already deployed deployed, err := trustee.IsDeployed(ctx, client.Clientset, namespace) @@ -269,6 +271,11 @@ func handleTrusteeSetup(cmd *cobra.Command, cfg *config.CocoConfig, interactive, // Deploy Trustee fmt.Printf("Deploying Trustee to namespace '%s'...\n", namespace) + // Check kubectl availability before deployment (kubectl is required for trustee.Deploy) + if err := requireKubectl(ctx, "init"); err != nil { + return false, "", err + } + kbsImage := cfg.KBSImage if kbsImage == "" { kbsImage = config.DefaultKBSImage From e0e646d9d4192db71a4702b9e360df0a0f76b9d8 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Fri, 6 Feb 2026 04:07:15 +0000 Subject: [PATCH 17/39] add integration tests for query workflows without kubectl - Add TestWorkflow_InitDetection: validates RuntimeClass detection, node IP extraction, and Trustee deployment checks - Add TestWorkflow_SecretInspection: validates single and batch secret queries - Add TestWorkflow_TrusteeQueries: validates Trustee deployment and KBS pod queries - All tests use fake.NewSimpleClientset pattern - Tests validate query operations work via client-go when kubectl not available Assisted-by: AI Signed-off-by: Pradipta Banerjee --- integration_test/workflow_test.go | 384 ++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) diff --git a/integration_test/workflow_test.go b/integration_test/workflow_test.go index f117695..7bb2c05 100644 --- a/integration_test/workflow_test.go +++ b/integration_test/workflow_test.go @@ -3,6 +3,7 @@ package integration_test import ( "bytes" "compress/gzip" + "context" "encoding/base64" "io" "os" @@ -10,11 +11,20 @@ import ( "strings" "testing" + "github.com/confidential-devhub/cococtl/pkg/cluster" "github.com/confidential-devhub/cococtl/pkg/config" "github.com/confidential-devhub/cococtl/pkg/initdata" "github.com/confidential-devhub/cococtl/pkg/manifest" "github.com/confidential-devhub/cococtl/pkg/sealed" + "github.com/confidential-devhub/cococtl/pkg/secrets" + "github.com/confidential-devhub/cococtl/pkg/trustee" "github.com/pelletier/go-toml/v2" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + nodev1 "k8s.io/api/node/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" ) // TestWorkflow_BasicTransformation tests the basic transformation workflow: @@ -526,3 +536,377 @@ func TestWorkflow_PreserveExisting(t *testing.T) { t.Errorf("First initContainer = %v, want new-init", firstInit["name"]) } } + +// TestWorkflow_InitDetection tests that init detection queries work without kubectl +// using fake clientset. Validates RuntimeClass detection, node IP extraction, and +// Trustee deployment checks work via client-go when kubectl is not available. +func TestWorkflow_InitDetection(t *testing.T) { + tests := []struct { + name string + setupFunc func(*testing.T) kubernetes.Interface + testFunc func(*testing.T, kubernetes.Interface) + }{ + { + name: "detect RuntimeClass without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Create RuntimeClass with kata-qemu handler + rc := &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kata-qemu", + }, + Handler: "kata-qemu", + } + return fake.NewSimpleClientset(rc) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + runtimeClass := cluster.DetectRuntimeClass(ctx, client, "kata-remote") + if runtimeClass == "" { + t.Error("DetectRuntimeClass returned empty string") + } + }, + }, + { + name: "extract node IPs without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Create nodes with external and internal IPs + node1 := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-1", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "203.0.113.10"}, + {Type: corev1.NodeInternalIP, Address: "10.0.1.10"}, + }, + }, + } + node2 := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-2", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "203.0.113.20"}, + {Type: corev1.NodeInternalIP, Address: "10.0.1.20"}, + }, + }, + } + return fake.NewSimpleClientset(node1, node2) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + ips, err := cluster.GetNodeIPs(ctx, client) + if err != nil { + t.Fatalf("GetNodeIPs failed: %v", err) + } + if len(ips) == 0 { + t.Error("GetNodeIPs returned empty list") + } + // Verify we got external IPs + expectedIPs := map[string]bool{ + "203.0.113.10": true, + "203.0.113.20": true, + } + for _, ip := range ips { + if !expectedIPs[ip] { + t.Errorf("Unexpected IP: %s", ip) + } + } + }, + }, + { + name: "check Trustee deployment without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Create Trustee deployment with correct label (app=kbs) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "kbs", + }, + }, + }, + } + return fake.NewSimpleClientset(deployment) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + deployed, err := trustee.IsDeployed(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("IsDeployed failed: %v", err) + } + if !deployed { + t.Error("IsDeployed returned false, expected true") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := tt.setupFunc(t) + tt.testFunc(t, client) + }) + } +} + +// TestWorkflow_SecretInspection tests that secret queries work without kubectl +// using fake clientset. Validates InspectSecret and InspectSecrets work via +// client-go when kubectl is not available. +func TestWorkflow_SecretInspection(t *testing.T) { + tests := []struct { + name string + setupFunc func(*testing.T) kubernetes.Interface + testFunc func(*testing.T, kubernetes.Interface) + }{ + { + name: "inspect single secret without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "db-credentials", + Namespace: "default", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("secret123"), + }, + Type: corev1.SecretTypeOpaque, + } + return fake.NewSimpleClientset(secret) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + secret, err := secrets.InspectSecret(ctx, client, "db-credentials", "default") + if err != nil { + t.Fatalf("InspectSecret failed: %v", err) + } + if secret == nil { + t.Fatal("InspectSecret returned nil") + } + if secret.Name != "db-credentials" { + t.Errorf("Secret name = %q, want %q", secret.Name, "db-credentials") + } + if len(secret.Data) != 2 { + t.Errorf("Secret data length = %d, want 2", len(secret.Data)) + } + }, + }, + { + name: "inspect multiple secrets without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + secret1 := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-key", + Namespace: "default", + }, + Data: map[string][]byte{ + "key": []byte("abc123"), + }, + Type: corev1.SecretTypeOpaque, + } + secret2 := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tls-cert", + Namespace: "default", + }, + Data: map[string][]byte{ + "tls.crt": []byte("cert-data"), + "tls.key": []byte("key-data"), + }, + Type: corev1.SecretTypeTLS, + } + return fake.NewSimpleClientset(secret1, secret2) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + refs := []secrets.SecretReference{ + {Name: "api-key", Namespace: "default", NeedsLookup: true}, + {Name: "tls-cert", Namespace: "default", NeedsLookup: true}, + } + secretMap, err := secrets.InspectSecrets(ctx, client, refs) + if err != nil { + t.Fatalf("InspectSecrets failed: %v", err) + } + if len(secretMap) != 2 { + t.Errorf("InspectSecrets returned %d secrets, want 2", len(secretMap)) + } + if secretMap["api-key"] == nil { + t.Error("api-key secret not found in result") + } + if secretMap["tls-cert"] == nil { + t.Error("tls-cert secret not found in result") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := tt.setupFunc(t) + tt.testFunc(t, client) + }) + } +} + +// TestWorkflow_TrusteeQueries tests that Trustee pod and deployment queries work +// without kubectl using fake clientset. Validates IsDeployed and GetKBSPodName +// work via client-go when kubectl is not available. +func TestWorkflow_TrusteeQueries(t *testing.T) { + tests := []struct { + name string + setupFunc func(*testing.T) kubernetes.Interface + testFunc func(*testing.T, kubernetes.Interface) + }{ + { + name: "check Trustee not deployed", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Empty clientset - no Trustee deployed + return fake.NewSimpleClientset() + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + deployed, err := trustee.IsDeployed(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("IsDeployed failed: %v", err) + } + if deployed { + t.Error("IsDeployed returned true, expected false for empty cluster") + } + }, + }, + { + name: "get KBS pod name without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-6f8d9c7b5-xk9wz", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + return fake.NewSimpleClientset(pod) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + podName, err := trustee.GetKBSPodName(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("GetKBSPodName failed: %v", err) + } + if podName != "kbs-6f8d9c7b5-xk9wz" { + t.Errorf("GetKBSPodName = %q, want %q", podName, "kbs-6f8d9c7b5-xk9wz") + } + }, + }, + { + name: "Trustee deployed with multiple components", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Create KBS deployment + kbsDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "kbs", + }, + }, + }, + } + // Create Trustee operator deployment + operatorDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "trustee-operator", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "trustee-operator", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "trustee-operator", + }, + }, + }, + } + // Create KBS pod + kbsPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-7d9f8c6b4-mnp2q", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + return fake.NewSimpleClientset(kbsDeployment, operatorDeployment, kbsPod) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + + // Verify Trustee is deployed + deployed, err := trustee.IsDeployed(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("IsDeployed failed: %v", err) + } + if !deployed { + t.Error("IsDeployed returned false, expected true") + } + + // Verify we can get KBS pod name + podName, err := trustee.GetKBSPodName(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("GetKBSPodName failed: %v", err) + } + if podName != "kbs-7d9f8c6b4-mnp2q" { + t.Errorf("GetKBSPodName = %q, want %q", podName, "kbs-7d9f8c6b4-mnp2q") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := tt.setupFunc(t) + tt.testFunc(t, client) + }) + } +} From d46b41a09fa51160d0eacc2717d1bf1308cae230 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 09:24:28 +0000 Subject: [PATCH 18/39] add --namespace flag and offline namespace resolution - Add --namespace/-n flag to apply command for explicit namespace override - Implement resolveNamespace() with kubectl precedence: flag > manifest > kubeconfig > default - Return error when --namespace flag conflicts with manifest namespace (kubectl behavior) - Replace all getCurrentNamespace() calls in apply.go with resolveNamespace() or k8s.GetCurrentNamespace() - Use k8s.GetCurrentNamespace() for offline kubeconfig reading (no kubectl binary needed) Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 71 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 56f4ed0..d1ea669 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -63,6 +63,7 @@ var ( sidecarSANDNS string sidecarSkipAutoSANs bool sidecarPortForward int + namespaceFlag string ) func init() { @@ -82,6 +83,7 @@ func init() { applyCmd.Flags().StringVar(&sidecarSANDNS, "sidecar-san-dns", "", "Comma-separated list of DNS names for sidecar server certificate SANs") applyCmd.Flags().BoolVar(&sidecarSkipAutoSANs, "sidecar-skip-auto-sans", false, "Skip auto-detection of SANs (node IPs and service DNS)") applyCmd.Flags().IntVar(&sidecarPortForward, "sidecar-port-forward", 0, "Port to forward from primary container (requires --sidecar)") + applyCmd.Flags().StringVarP(&namespaceFlag, "namespace", "n", "", "Namespace for operations (overrides manifest and kubeconfig)") } func runApply(cmd *cobra.Command, _ []string) error { @@ -189,7 +191,13 @@ func runApply(cmd *cobra.Command, _ []string) error { } // Transform manifest - if err := transformManifest(ctx, m, cfg, rc, skipApply); err != nil { + // Resolve namespace before transformation + resolvedNamespace, err := resolveNamespace(namespaceFlag, m.GetNamespace()) + if err != nil { + return err + } + + if err := transformManifest(ctx, m, cfg, rc, skipApply, resolvedNamespace); err != nil { return fmt.Errorf("failed to transform manifest: %w", err) } @@ -204,13 +212,9 @@ func runApply(cmd *cobra.Command, _ []string) error { var servicePath string if enableSidecar || cfg.Sidecar.Enabled { appName := m.GetName() - namespace := m.GetNamespace() - if namespace == "" { - var err error - namespace, err = getCurrentNamespace() - if err != nil { - return fmt.Errorf("failed to get current namespace: %w", err) - } + namespace, err := resolveNamespace(namespaceFlag, m.GetNamespace()) + if err != nil { + return fmt.Errorf("failed to resolve namespace: %w", err) } fmt.Println("Generating Service manifest for sidecar...") @@ -259,7 +263,7 @@ func runApply(cmd *cobra.Command, _ []string) error { return nil } -func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, rc string, skipApply bool) error { +func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, rc string, skipApply bool, resolvedNamespace string) error { // 1. Set RuntimeClass fmt.Printf(" - Setting runtimeClassName: %s\n", rc) if err := m.SetRuntimeClass(rc); err != nil { @@ -320,15 +324,7 @@ func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.Co if appName == "" { return fmt.Errorf("manifest must have metadata.name for sidecar injection") } - namespace := m.GetNamespace() - if namespace == "" { - // Use current kubectl namespace instead of hardcoding "default" - var err error - namespace, err = getCurrentNamespace() - if err != nil { - return fmt.Errorf("failed to get current namespace: %w", err) - } - } + namespace := resolvedNamespace // Get Trustee namespace from config (where KBS is deployed) trusteeNamespace := cfg.GetTrusteeNamespace() @@ -611,7 +607,7 @@ func addSecretsToTrustee(secretRefs []secrets.SecretReference, trusteeNamespace secretNamespace := ref.Namespace if secretNamespace == "" { var err error - secretNamespace, err = getCurrentNamespace() + secretNamespace, err = k8s.GetCurrentNamespace() if err != nil { return fmt.Errorf("failed to get current namespace for secret %s: %w", ref.Name, err) } @@ -725,7 +721,7 @@ func addImagePullSecretsToTrustee(secretRefs []secrets.SecretReference, trusteeN secretNamespace := ref.Namespace if secretNamespace == "" { var err error - secretNamespace, err = getCurrentNamespace() + secretNamespace, err = k8s.GetCurrentNamespace() if err != nil { return fmt.Errorf("failed to get current namespace for imagePullSecret %s: %w", ref.Name, err) } @@ -851,3 +847,38 @@ func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNam fmt.Printf(" - Server certificate uploaded to kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) return nil } + +// resolveNamespace determines the namespace using kubectl precedence order: +// 1. --namespace flag (highest priority) +// 2. manifest metadata.namespace field +// 3. kubeconfig context namespace (offline via k8s.GetCurrentNamespace) +// 4. "default" (final fallback) +// +// Returns an error if --namespace flag and manifest namespace both exist but differ, +// matching kubectl behavior. +func resolveNamespace(flagNamespace, manifestNamespace string) (string, error) { + // Validation: flag and manifest namespace must not conflict (kubectl behavior) + if flagNamespace != "" && manifestNamespace != "" && flagNamespace != manifestNamespace { + return "", fmt.Errorf("the namespace from the provided object %q does not match the namespace %q. You must pass '--namespace=%s' to perform this operation", + manifestNamespace, flagNamespace, manifestNamespace) + } + + // 1. --namespace flag (highest priority) + if flagNamespace != "" { + return flagNamespace, nil + } + + // 2. manifest metadata.namespace + if manifestNamespace != "" { + return manifestNamespace, nil + } + + // 3. kubeconfig context namespace (offline read) + kubeconfigNs, err := k8s.GetCurrentNamespace() + if err == nil && kubeconfigNs != "" { + return kubeconfigNs, nil + } + + // 4. "default" (final fallback) + return "default", nil +} From aec9ab0e7ebfd5ef34bb81cae5734713f638abdc Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 09:25:03 +0000 Subject: [PATCH 19/39] refactor kubectl gate to be conditional on skipApply - Check skipApply flag before kubectl detection in runApply() - Print informational message when skipApply=true and kubectl not found - Preserve existing requireKubectl() error for non-skip-apply mode - Skip-apply mode uses cmd.Context() directly (no kubectl detection needed) Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index d1ea669..1f98bd2 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -87,13 +87,21 @@ func init() { } func runApply(cmd *cobra.Command, _ []string) error { - // Detect kubectl availability immediately - needed for apply and secret upload operations - ctx := detectKubectl(cmd.Context()) + var ctx context.Context - // Fail fast if kubectl not available - apply command requires kubectl for both - // addK8sSecretToTrustee (kubectl cp/exec) and final kubectl apply - if err := requireKubectl(ctx, "apply"); err != nil { - return err + if skipApply { + // Skip-apply mode: kubectl is optional, only needed for informational message + _, err := exec.LookPath("kubectl") + if err != nil { + fmt.Println(" \u2139 kubectl not found (--skip-apply mode: cluster write operations unavailable)") + } + ctx = cmd.Context() + } else { + // Normal mode: kubectl required, fail fast + ctx = detectKubectl(cmd.Context()) + if err := requireKubectl(ctx, "apply"); err != nil { + return err + } } // Validate required flags (manual validation to keep all flags visible in shell completion) From 9ca31da4aae0e3ce41180b41ef160172d229b241 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 09:29:53 +0000 Subject: [PATCH 20/39] add skipApply guard to handleSidecarServerCert and cert file saving - Add skipApply and manifestPath parameters to handleSidecarServerCert() - Guard trustee.UploadResources() call with if !skipApply condition - Implement saveSidecarCertsToYAML() to save kubernetes.io/tls Secret - Cert file uses {basename}-sidecar-certs.yaml naming convention - File permissions set to 0600 matching existing secret file pattern - Update call site in transformManifest() to pass new parameters Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 79 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 1f98bd2..75442b5 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/base64" "fmt" "os" "os/exec" @@ -337,9 +338,9 @@ func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.Co // Get Trustee namespace from config (where KBS is deployed) trusteeNamespace := cfg.GetTrusteeNamespace() - // Generate and upload server certificate + // Generate and upload server certificate (or save to file in skip-apply mode) fmt.Println(" - Setting up sidecar server certificate") - if err := handleSidecarServerCert(ctx, appName, namespace, trusteeNamespace); err != nil { + if err := handleSidecarServerCert(ctx, appName, namespace, trusteeNamespace, skipApply, manifestFile); err != nil { return fmt.Errorf("failed to setup sidecar server certificate: %w", err) } @@ -752,13 +753,15 @@ func addImagePullSecretToTrustee(trusteeNamespace, secretName, secretNamespace s // handleSidecarServerCert generates and uploads a server certificate for the sidecar. // It loads the Client CA, auto-detects or uses provided SANs, generates the server cert, -// and uploads it to Trustee KBS at per-app paths. +// and either uploads it to Trustee KBS or saves it to a file (when skipApply is true). // Parameters: // - ctx: context for Kubernetes API calls (for proper signal handling) // - appName: name of the application (from manifest metadata.name) // - namespace: namespace for certificate KBS path (from manifest metadata.namespace) // - trusteeNamespace: namespace where Trustee KBS is deployed -func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNamespace string) error { +// - skipApply: when true, save certs to file instead of uploading to Trustee +// - manifestPath: path to the original manifest file (for cert file naming) +func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNamespace string, skipApply bool, manifestPath string) error { // Load Client CA from ~/.kube/coco-sidecar/ homeDir, err := os.UserHomeDir() if err != nil { @@ -838,22 +841,72 @@ func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNam return fmt.Errorf("failed to generate server certificate: %w", err) } - // Upload to Trustee KBS (in the namespace where Trustee is deployed) - fmt.Printf(" - Uploading server certificate to Trustee KBS (namespace: %s)...\n", trusteeNamespace) + // Build KBS resource paths for certificate storage serverCertPath := namespace + "/sidecar-tls-" + appName + "/server-cert" serverKeyPath := namespace + "/sidecar-tls-" + appName + "/server-key" - resources := map[string][]byte{ - serverCertPath: serverCert.CertPEM, - serverKeyPath: serverCert.KeyPEM, + if !skipApply { + // Normal mode: upload to Trustee KBS + fmt.Printf(" - Uploading server certificate to Trustee KBS (namespace: %s)...\n", trusteeNamespace) + resources := map[string][]byte{ + serverCertPath: serverCert.CertPEM, + serverKeyPath: serverCert.KeyPEM, + } + if err := trustee.UploadResources(trusteeNamespace, resources); err != nil { + return fmt.Errorf("failed to upload server certificate to KBS: %w", err) + } + fmt.Printf(" - Server certificate uploaded to kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) + } else { + // Skip-apply mode: save certs to file instead of uploading + certFilePath, err := saveSidecarCertsToYAML(manifestPath, serverCert, appName, namespace) + if err != nil { + return err + } + fmt.Printf(" - Sidecar certificate saved to: %s (Trustee upload skipped)\n", certFilePath) + fmt.Printf(" - KBS resource paths: kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) } - if err := trustee.UploadResources(trusteeNamespace, resources); err != nil { - return fmt.Errorf("failed to upload server certificate to KBS: %w", err) + return nil +} + +// saveSidecarCertsToYAML saves sidecar server certificate and key to a YAML file +// as a Kubernetes TLS Secret. The file is saved alongside the manifest with the +// naming convention {basename}-sidecar-certs.yaml, matching the existing pattern +// used by other generated files (e.g., {basename}-sealed-secrets.yaml). +func saveSidecarCertsToYAML(manifestPath string, serverCert *certs.CertificateSet, appName, namespace string) (string, error) { + // Build output path following existing naming convention + ext := filepath.Ext(manifestPath) + if ext == "" { + ext = ".yaml" + } + baseName := strings.TrimSuffix(manifestPath, ext) + certFilePath := baseName + "-sidecar-certs.yaml" + + // Build Kubernetes Secret structure (kubernetes.io/tls) + secretData := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "sidecar-tls-" + appName, + "namespace": namespace, + }, + "type": "kubernetes.io/tls", + "data": map[string]string{ + "tls.crt": base64.StdEncoding.EncodeToString(serverCert.CertPEM), + "tls.key": base64.StdEncoding.EncodeToString(serverCert.KeyPEM), + }, + } + + yamlData, err := yaml.Marshal(secretData) + if err != nil { + return "", fmt.Errorf("failed to marshal sidecar certificate Secret: %w", err) } - fmt.Printf(" - Server certificate uploaded to kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) - return nil + if err := os.WriteFile(certFilePath, yamlData, 0600); err != nil { + return "", fmt.Errorf("failed to write sidecar certificate file: %w", err) + } + + return certFilePath, nil } // resolveNamespace determines the namespace using kubectl precedence order: From af87e05fccadac20f45f6a555fa0e64fb839ddf7 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 09:34:35 +0000 Subject: [PATCH 21/39] add skip-apply offline path unit tests - Test namespace resolution with --namespace flag (priority, matching, conflict) - Test namespace resolution from manifest metadata.namespace - Test namespace resolution fallback to kubeconfig context namespace - Test namespace resolution fallback to "default" when no source available - Test sidecar cert file saving (YAML structure, TLS Secret format, permissions) Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply_skip_test.go | 278 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 cmd/apply_skip_test.go diff --git a/cmd/apply_skip_test.go b/cmd/apply_skip_test.go new file mode 100644 index 0000000..9c8aebb --- /dev/null +++ b/cmd/apply_skip_test.go @@ -0,0 +1,278 @@ +package cmd + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/confidential-devhub/cococtl/pkg/k8s" + "github.com/confidential-devhub/cococtl/pkg/sidecar/certs" + "gopkg.in/yaml.v3" +) + +// TestSkipApply_NamespaceResolution_Flag tests that the --namespace flag takes +// highest priority in resolveNamespace() and validates conflict detection. +func TestSkipApply_NamespaceResolution_Flag(t *testing.T) { + t.Run("flag value returned when only flag is set", func(t *testing.T) { + ns, err := resolveNamespace("my-namespace", "") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "my-namespace" { + t.Errorf("resolveNamespace() = %q, want %q", ns, "my-namespace") + } + }) + + t.Run("flag value returned when flag matches manifest", func(t *testing.T) { + ns, err := resolveNamespace("production", "production") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "production" { + t.Errorf("resolveNamespace() = %q, want %q", ns, "production") + } + }) + + t.Run("error when flag and manifest namespace conflict", func(t *testing.T) { + _, err := resolveNamespace("flag-ns", "manifest-ns") + if err == nil { + t.Fatal("resolveNamespace should return error when flag and manifest namespace conflict") + } + if !strings.Contains(err.Error(), "does not match") { + t.Errorf("error message should contain 'does not match', got: %v", err) + } + }) + + t.Run("error message includes both namespaces", func(t *testing.T) { + _, err := resolveNamespace("alpha", "beta") + if err == nil { + t.Fatal("resolveNamespace should return error for conflicting namespaces") + } + errMsg := err.Error() + if !strings.Contains(errMsg, "beta") || !strings.Contains(errMsg, "alpha") { + t.Errorf("error message should contain both namespace values, got: %v", err) + } + }) +} + +// TestSkipApply_NamespaceResolution_ManifestOnly tests that the manifest +// metadata.namespace is used when no flag is provided. +func TestSkipApply_NamespaceResolution_ManifestOnly(t *testing.T) { + ns, err := resolveNamespace("", "manifest-namespace") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "manifest-namespace" { + t.Errorf("resolveNamespace() = %q, want %q", ns, "manifest-namespace") + } +} + +// TestSkipApply_NamespaceResolution_KubeconfigFallback tests that the namespace +// from kubeconfig context is used when no flag and no manifest namespace exist. +func TestSkipApply_NamespaceResolution_KubeconfigFallback(t *testing.T) { + tmpDir := t.TempDir() + + // Create a minimal valid kubeconfig with a namespace set in the context + kubeconfigContent := `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://localhost:6443 + name: test-cluster +contexts: +- context: + cluster: test-cluster + namespace: test-from-kubeconfig + name: test-context +current-context: test-context +users: +- name: test-user +` + kubeconfigPath := filepath.Join(tmpDir, "kubeconfig") + if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil { + t.Fatalf("Failed to write test kubeconfig: %v", err) + } + + // Save and restore KUBECONFIG env + origKubeconfig := os.Getenv("KUBECONFIG") + t.Cleanup(func() { + if origKubeconfig == "" { + os.Unsetenv("KUBECONFIG") + } else { + os.Setenv("KUBECONFIG", origKubeconfig) + } + }) + + os.Setenv("KUBECONFIG", kubeconfigPath) + + // Verify k8s.GetCurrentNamespace() returns the kubeconfig namespace + kubeconfigNs, err := k8s.GetCurrentNamespace() + if err != nil { + t.Fatalf("k8s.GetCurrentNamespace() returned error: %v", err) + } + if kubeconfigNs != "test-from-kubeconfig" { + t.Errorf("k8s.GetCurrentNamespace() = %q, want %q", kubeconfigNs, "test-from-kubeconfig") + } + + // Verify resolveNamespace falls back to kubeconfig when flag and manifest are empty + ns, err := resolveNamespace("", "") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "test-from-kubeconfig" { + t.Errorf("resolveNamespace() = %q, want %q (expected kubeconfig fallback)", ns, "test-from-kubeconfig") + } +} + +// TestSkipApply_NamespaceResolution_DefaultFallback tests that "default" is +// returned when no flag, no manifest namespace, and no kubeconfig namespace exist. +func TestSkipApply_NamespaceResolution_DefaultFallback(t *testing.T) { + // Save and restore KUBECONFIG env + origKubeconfig := os.Getenv("KUBECONFIG") + t.Cleanup(func() { + if origKubeconfig == "" { + os.Unsetenv("KUBECONFIG") + } else { + os.Setenv("KUBECONFIG", origKubeconfig) + } + }) + + // Point KUBECONFIG to a non-existent path so kubeconfig reading fails + os.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "nonexistent-kubeconfig")) + + ns, err := resolveNamespace("", "") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "default" { + t.Errorf("resolveNamespace() = %q, want %q (expected default fallback)", ns, "default") + } +} + +// TestSkipApply_SidecarCertFileSaving tests that saveSidecarCertsToYAML creates +// a properly formatted Kubernetes TLS Secret YAML file with correct permissions. +func TestSkipApply_SidecarCertFileSaving(t *testing.T) { + // Generate a CA for signing the server cert + ca, err := certs.GenerateCA("test-ca") + if err != nil { + t.Fatalf("Failed to generate CA: %v", err) + } + + // Generate a server certificate + sans := certs.SANs{ + DNSNames: []string{"test-app.test-ns.svc.cluster.local"}, + IPAddresses: []string{"10.0.0.1"}, + } + serverCert, err := certs.GenerateServerCert(ca.CertPEM, ca.KeyPEM, "test-app", sans) + if err != nil { + t.Fatalf("Failed to generate server cert: %v", err) + } + + // Create a temp manifest path + tmpDir := t.TempDir() + manifestPath := filepath.Join(tmpDir, "app.yaml") + // Create a placeholder manifest file (saveSidecarCertsToYAML uses the path for naming) + if err := os.WriteFile(manifestPath, []byte("placeholder"), 0600); err != nil { + t.Fatalf("Failed to create placeholder manifest: %v", err) + } + + // Call saveSidecarCertsToYAML + certFilePath, err := saveSidecarCertsToYAML(manifestPath, serverCert, "test-app", "test-ns") + if err != nil { + t.Fatalf("saveSidecarCertsToYAML returned error: %v", err) + } + + // Verify the cert file path follows naming convention + expectedPath := filepath.Join(tmpDir, "app-sidecar-certs.yaml") + if certFilePath != expectedPath { + t.Errorf("cert file path = %q, want %q", certFilePath, expectedPath) + } + + // Verify file exists + fileInfo, err := os.Stat(certFilePath) + if err != nil { + t.Fatalf("cert file does not exist: %v", err) + } + + // Verify file permissions are 0600 + perm := fileInfo.Mode().Perm() + if perm != 0600 { + t.Errorf("cert file permissions = %o, want %o", perm, 0600) + } + + // Read and parse the YAML file + data, err := os.ReadFile(certFilePath) + if err != nil { + t.Fatalf("Failed to read cert file: %v", err) + } + + var secret map[string]interface{} + if err := yaml.Unmarshal(data, &secret); err != nil { + t.Fatalf("Failed to parse cert file YAML: %v", err) + } + + // Verify apiVersion + if apiVersion, ok := secret["apiVersion"].(string); !ok || apiVersion != "v1" { + t.Errorf("apiVersion = %v, want %q", secret["apiVersion"], "v1") + } + + // Verify kind + if kind, ok := secret["kind"].(string); !ok || kind != "Secret" { + t.Errorf("kind = %v, want %q", secret["kind"], "Secret") + } + + // Verify type + if secretType, ok := secret["type"].(string); !ok || secretType != "kubernetes.io/tls" { + t.Errorf("type = %v, want %q", secret["type"], "kubernetes.io/tls") + } + + // Verify metadata + metadata, ok := secret["metadata"].(map[string]interface{}) + if !ok { + t.Fatalf("metadata is not a map: %T", secret["metadata"]) + } + if name, ok := metadata["name"].(string); !ok || name != "sidecar-tls-test-app" { + t.Errorf("metadata.name = %v, want %q", metadata["name"], "sidecar-tls-test-app") + } + if namespace, ok := metadata["namespace"].(string); !ok || namespace != "test-ns" { + t.Errorf("metadata.namespace = %v, want %q", metadata["namespace"], "test-ns") + } + + // Verify data fields contain base64-encoded content + secretData, ok := secret["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data is not a map: %T", secret["data"]) + } + + tlsCrt, ok := secretData["tls.crt"].(string) + if !ok || tlsCrt == "" { + t.Error("data[tls.crt] is missing or empty") + } else { + // Verify tls.crt is valid base64 + decoded, err := base64.StdEncoding.DecodeString(tlsCrt) + if err != nil { + t.Errorf("data[tls.crt] is not valid base64: %v", err) + } + // Verify decoded content matches the original cert PEM + if string(decoded) != string(serverCert.CertPEM) { + t.Error("data[tls.crt] decoded content does not match original cert PEM") + } + } + + tlsKey, ok := secretData["tls.key"].(string) + if !ok || tlsKey == "" { + t.Error("data[tls.key] is missing or empty") + } else { + // Verify tls.key is valid base64 + decoded, err := base64.StdEncoding.DecodeString(tlsKey) + if err != nil { + t.Errorf("data[tls.key] is not valid base64: %v", err) + } + // Verify decoded content matches the original key PEM + if string(decoded) != string(serverCert.KeyPEM) { + t.Error("data[tls.key] decoded content does not match original key PEM") + } + } +} From b983976d8b8fdcdc83985e8d7724924239fd17cf Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 10:13:49 +0000 Subject: [PATCH 22/39] replace GenerateSealedSecretYAML with native Go YAML and add nil clientset guard - Replace exec.Command("kubectl") with yaml.Marshal() in GenerateSealedSecretYAML - Use stringData field (functionally equivalent to data for kubectl apply) - Add nil clientset guard in InspectSecrets for NeedsLookup=true refs - Add describeUsageTypes helper for descriptive error messages - Eliminates kubectl dependency for offline skip-apply sealed secret generation Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/secrets/kubernetes.go | 61 ++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/pkg/secrets/kubernetes.go b/pkg/secrets/kubernetes.go index b41918d..959094e 100644 --- a/pkg/secrets/kubernetes.go +++ b/pkg/secrets/kubernetes.go @@ -2,11 +2,11 @@ package secrets import ( "context" - "errors" "fmt" "os/exec" "strings" + "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -106,6 +106,11 @@ func InspectSecrets(ctx context.Context, clientset kubernetes.Interface, refs [] continue } + // clientset required for cluster lookup + if clientset == nil { + return nil, fmt.Errorf("cluster connection required to inspect secret %q (needs key enumeration for %s usage)", ref.Name, describeUsageTypes(ref.Usages)) + } + // Inspect the secret using client-go secret, err := InspectSecret(ctx, clientset, ref.Name, ref.Namespace) if err != nil { @@ -129,36 +134,31 @@ func InspectSecrets(ctx context.Context, clientset kubernetes.Interface, refs [] func GenerateSealedSecretYAML(secretName, namespace string, sealedData map[string]string) (string, string, error) { sealedSecretName := secretName + "-sealed" - // Build kubectl command to create secret - args := []string{"create", "secret", "generic", sealedSecretName} - - // Add namespace if specified - if namespace != "" { - args = append(args, "-n", namespace) + // Build Kubernetes Secret structure using stringData for readability + // stringData is functionally equivalent to data (Kubernetes auto-encodes on apply) + secret := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": sealedSecretName, + "namespace": namespace, + }, + "type": "Opaque", + "stringData": sealedData, } - // Add each sealed secret as a literal - for key, sealedValue := range sealedData { - args = append(args, fmt.Sprintf("--from-literal=%s=%s", key, sealedValue)) + // Omit namespace from metadata if empty (matches kubectl behavior) + if namespace == "" { + metadata := secret["metadata"].(map[string]interface{}) + delete(metadata, "namespace") } - // Add --dry-run=client and -o yaml to generate YAML without applying - args = append(args, "--dry-run=client", "-o", "yaml") - - // Execute command to generate YAML - // #nosec G204 - args are constructed from application-controlled inputs (secret name, namespace, sealed values) - // No arbitrary user input is passed to kubectl - cmd := exec.Command("kubectl", args...) - output, err := cmd.Output() + yamlData, err := yaml.Marshal(secret) if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", "", fmt.Errorf("kubectl create secret failed: %s", string(exitErr.Stderr)) - } - return "", "", fmt.Errorf("kubectl create secret failed: %w", err) + return "", "", fmt.Errorf("failed to marshal sealed secret YAML: %w", err) } - return sealedSecretName, string(output), nil + return sealedSecretName, string(yamlData), nil } // CreateSealedSecret creates a K8s secret with sealed secret values @@ -291,3 +291,16 @@ func GetServiceAccountImagePullSecrets(ctx context.Context, clientset kubernetes // Return first imagePullSecret name return sa.ImagePullSecrets[0].Name, nil } + +// describeUsageTypes returns a comma-separated list of usage types for error messages +func describeUsageTypes(usages []SecretUsage) string { + types := make([]string, 0, len(usages)) + seen := make(map[string]bool) + for _, u := range usages { + if !seen[u.Type] { + types = append(types, u.Type) + seen[u.Type] = true + } + } + return strings.Join(types, ", ") +} From 993e4bfc1f0d5df7f153a3f4dc1e3851442c5a52 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 10:15:02 +0000 Subject: [PATCH 23/39] add unit tests for native YAML generation and nil clientset guard - TestGenerateSealedSecretYAML_NativeYAML: verify correct Secret structure - TestGenerateSealedSecretYAML_EmptyNamespace: verify namespace omission - TestGenerateSealedSecretYAML_SpecialCharacters: verify JWS-format handling - TestInspectSecrets_NilClientset_OfflineRefsSucceed: offline path works - TestInspectSecrets_NilClientset_ClusterRefsError: cluster refs fail with nil - TestInspectSecrets_NilClientset_MixedRefs: mixed refs fail-fast behavior Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/secrets/kubernetes_test.go | 243 +++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/pkg/secrets/kubernetes_test.go b/pkg/secrets/kubernetes_test.go index a8dd7d5..a0b8710 100644 --- a/pkg/secrets/kubernetes_test.go +++ b/pkg/secrets/kubernetes_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" @@ -413,3 +414,245 @@ func TestGetServiceAccountImagePullSecrets_EmptyNamespace(t *testing.T) { t.Errorf("GetServiceAccountImagePullSecrets() = %q, want %q", secretName, expectedName) } } + +func TestGenerateSealedSecretYAML_NativeYAML(t *testing.T) { + // Test native YAML generation produces correct Kubernetes Secret structure + secretName := "db-creds" + namespace := "production" + sealedData := map[string]string{ + "password": "sealed-value", + "username": "sealed-user", + } + + sealedName, yamlContent, err := GenerateSealedSecretYAML(secretName, namespace, sealedData) + if err != nil { + t.Fatalf("GenerateSealedSecretYAML() error = %v", err) + } + + // Verify returned name + expectedName := "db-creds-sealed" + if sealedName != expectedName { + t.Errorf("GenerateSealedSecretYAML() name = %q, want %q", sealedName, expectedName) + } + + // Parse the YAML + var secret map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &secret); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + // Verify apiVersion + if secret["apiVersion"] != "v1" { + t.Errorf("apiVersion = %q, want %q", secret["apiVersion"], "v1") + } + + // Verify kind + if secret["kind"] != "Secret" { + t.Errorf("kind = %q, want %q", secret["kind"], "Secret") + } + + // Verify type + if secret["type"] != "Opaque" { + t.Errorf("type = %q, want %q", secret["type"], "Opaque") + } + + // Verify metadata + metadata, ok := secret["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("metadata is not a map") + } + if metadata["name"] != expectedName { + t.Errorf("metadata.name = %q, want %q", metadata["name"], expectedName) + } + if metadata["namespace"] != namespace { + t.Errorf("metadata.namespace = %q, want %q", metadata["namespace"], namespace) + } + + // Verify stringData contains correct keys and values + stringData, ok := secret["stringData"].(map[string]interface{}) + if !ok { + t.Fatal("stringData is not a map") + } + if stringData["password"] != "sealed-value" { + t.Errorf("stringData.password = %q, want %q", stringData["password"], "sealed-value") + } + if stringData["username"] != "sealed-user" { + t.Errorf("stringData.username = %q, want %q", stringData["username"], "sealed-user") + } +} + +func TestGenerateSealedSecretYAML_EmptyNamespace(t *testing.T) { + // Test that empty namespace is omitted from metadata + secretName := "test-secret" + namespace := "" + sealedData := map[string]string{ + "key": "value", + } + + _, yamlContent, err := GenerateSealedSecretYAML(secretName, namespace, sealedData) + if err != nil { + t.Fatalf("GenerateSealedSecretYAML() error = %v", err) + } + + // Parse the YAML + var secret map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &secret); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + // Verify metadata does NOT contain namespace key + metadata, ok := secret["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("metadata is not a map") + } + if _, hasNamespace := metadata["namespace"]; hasNamespace { + t.Error("metadata contains namespace key when it should be omitted for empty namespace") + } +} + +func TestGenerateSealedSecretYAML_SpecialCharacters(t *testing.T) { + // Test that JWS-format values are correctly included + secretName := "jws-secret" + namespace := "default" + sealedData := map[string]string{ + "key": "sealed.fakejwsheader.eyJ0ZXN0IjoiZGF0YSJ9.fakesignature", + } + + _, yamlContent, err := GenerateSealedSecretYAML(secretName, namespace, sealedData) + if err != nil { + t.Fatalf("GenerateSealedSecretYAML() error = %v", err) + } + + // Verify the YAML contains the value (no escaping issues) + if !strings.Contains(yamlContent, "sealed.fakejwsheader.eyJ0ZXN0IjoiZGF0YSJ9.fakesignature") { + t.Errorf("YAML does not contain expected JWS value, got:\n%s", yamlContent) + } + + // Parse to verify it's valid YAML + var secret map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &secret); err != nil { + t.Fatalf("Failed to parse YAML with special characters: %v", err) + } + + // Verify stringData preserves the value + stringData, ok := secret["stringData"].(map[string]interface{}) + if !ok { + t.Fatal("stringData is not a map") + } + if stringData["key"] != "sealed.fakejwsheader.eyJ0ZXN0IjoiZGF0YSJ9.fakesignature" { + t.Errorf("stringData.key = %q, want JWS value", stringData["key"]) + } +} + +func TestInspectSecrets_NilClientset_OfflineRefsSucceed(t *testing.T) { + // Test that offline refs (NeedsLookup=false) succeed with nil clientset + ctx := context.Background() + refs := []SecretReference{ + { + Name: "offline-secret", + Namespace: "default", + Keys: []string{"key1", "key2"}, + NeedsLookup: false, + }, + } + + secrets, err := InspectSecrets(ctx, nil, refs) + if err != nil { + t.Fatalf("InspectSecrets() with nil clientset and offline refs error = %v, want nil", err) + } + + // Verify secret returned + if len(secrets) != 1 { + t.Errorf("InspectSecrets() returned %d secrets, want 1", len(secrets)) + } + + secret, ok := secrets["offline-secret"] + if !ok { + t.Fatal("InspectSecrets() missing 'offline-secret' in results") + } + + // Verify metadata + if secret.Name != "offline-secret" { + t.Errorf("secret.Name = %q, want %q", secret.Name, "offline-secret") + } + if secret.Namespace != "default" { + t.Errorf("secret.Namespace = %q, want %q", secret.Namespace, "default") + } + + // Verify keys are present + if len(secret.Data) != 2 { + t.Errorf("secret.Data has %d keys, want 2", len(secret.Data)) + } + if _, ok := secret.Data["key1"]; !ok { + t.Error("secret missing 'key1' in Data") + } + if _, ok := secret.Data["key2"]; !ok { + t.Error("secret missing 'key2' in Data") + } +} + +func TestInspectSecrets_NilClientset_ClusterRefsError(t *testing.T) { + // Test that cluster refs (NeedsLookup=true) fail with nil clientset + ctx := context.Background() + refs := []SecretReference{ + { + Name: "cluster-secret", + Namespace: "default", + NeedsLookup: true, + Usages: []SecretUsage{ + {Type: "volume"}, + }, + }, + } + + _, err := InspectSecrets(ctx, nil, refs) + if err == nil { + t.Fatal("InspectSecrets() with nil clientset and cluster refs expected error, got nil") + } + + // Verify error mentions cluster connection required + if !strings.Contains(err.Error(), "cluster connection required") { + t.Errorf("error = %q, want error mentioning 'cluster connection required'", err.Error()) + } + + // Verify error mentions the secret name + if !strings.Contains(err.Error(), "cluster-secret") { + t.Errorf("error = %q, want error mentioning 'cluster-secret'", err.Error()) + } +} + +func TestInspectSecrets_NilClientset_MixedRefs(t *testing.T) { + // Test that mixed refs (offline + cluster) fail on the cluster ref + ctx := context.Background() + refs := []SecretReference{ + { + Name: "offline-secret", + Namespace: "default", + Keys: []string{"key1"}, + NeedsLookup: false, + }, + { + Name: "cluster-secret", + Namespace: "default", + NeedsLookup: true, + Usages: []SecretUsage{ + {Type: "env"}, + }, + }, + } + + _, err := InspectSecrets(ctx, nil, refs) + if err == nil { + t.Fatal("InspectSecrets() with nil clientset and mixed refs expected error, got nil") + } + + // Verify error mentions cluster connection (fails on cluster ref) + if !strings.Contains(err.Error(), "cluster connection required") { + t.Errorf("error = %q, want error mentioning 'cluster connection required'", err.Error()) + } + + // Verify error mentions the cluster secret name + if !strings.Contains(err.Error(), "cluster-secret") { + t.Errorf("error = %q, want error mentioning 'cluster-secret'", err.Error()) + } +} From 3e3e65308e5525e93973437b517383219a2af0f7 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 10:20:40 +0000 Subject: [PATCH 24/39] bifurcate handleSecrets by NeedsLookup with offline-first resolution - Split secret refs into offlineRefs (NeedsLookup=false) and clusterRefs (NeedsLookup=true) - Process offline refs with nil clientset (no cluster connection required) - Process cluster refs with real client, providing actionable errors on failure - Add secretsClusterUnreachableError helper for descriptive offline mode errors - Add secretsClusterQueryError helper for cluster query failures - Offline refs skip cluster connection entirely - Cluster refs provide clear guidance when unreachable Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 108 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 19 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 75442b5..92eeefc 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -444,28 +444,53 @@ func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoCo fmt.Printf(" - Found %d K8s secret(s) to convert\n", len(secretRefs)) - // 2. Create Kubernetes client for secret inspection - client, clientErr := k8s.NewClient(k8s.ClientOptions{}) - if clientErr != nil { - return fmt.Errorf("failed to create Kubernetes client: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the secrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", clientErr) + // 2. Split refs by lookup requirement + var offlineRefs, clusterRefs []secrets.SecretReference + for _, ref := range secretRefs { + if ref.NeedsLookup { + clusterRefs = append(clusterRefs, ref) + } else { + offlineRefs = append(offlineRefs, ref) + } } - // 3. Inspect K8s secrets - inspectedSecrets, err := secrets.InspectSecrets(ctx, client.Clientset, secretRefs) - if err != nil { - return fmt.Errorf("failed to inspect secrets: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the secrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) + // 3. Process offline refs (always works - uses only manifest metadata, no cluster needed) + var allSealedSecrets []*secrets.SealedSecretData + if len(offlineRefs) > 0 { + fmt.Printf(" - Resolving %d secret(s) offline (explicit keys in manifest)\n", len(offlineRefs)) + offlineSecrets, err := secrets.InspectSecrets(ctx, nil, offlineRefs) + if err != nil { + return fmt.Errorf("failed to resolve offline secrets: %w", err) + } + offlineKeys := secrets.SecretsToSecretKeys(offlineSecrets) + offlineSealed, err := secrets.ConvertSecrets(offlineRefs, offlineKeys) + if err != nil { + return err + } + allSealedSecrets = append(allSealedSecrets, offlineSealed...) } - // Convert to SecretKeys format for converter - inspectedKeys := secrets.SecretsToSecretKeys(inspectedSecrets) + // 4. Process cluster refs (needs cluster connection for key enumeration) + if len(clusterRefs) > 0 { + fmt.Printf(" - %d secret(s) require cluster access for key enumeration\n", len(clusterRefs)) + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + if clientErr != nil { + return secretsClusterUnreachableError(clusterRefs, clientErr) + } - // 4. Convert to sealed secrets - sealedSecrets, err := secrets.ConvertSecrets(secretRefs, inspectedKeys) - if err != nil { - return err + clusterSecrets, err := secrets.InspectSecrets(ctx, client.Clientset, clusterRefs) + if err != nil { + return secretsClusterQueryError(clusterRefs, err) + } + clusterKeys := secrets.SecretsToSecretKeys(clusterSecrets) + clusterSealed, err := secrets.ConvertSecrets(clusterRefs, clusterKeys) + if err != nil { + return err + } + allSealedSecrets = append(allSealedSecrets, clusterSealed...) } - fmt.Printf(" - Generated %d sealed secret(s)\n", len(sealedSecrets)) + fmt.Printf(" - Generated %d sealed secret(s)\n", len(allSealedSecrets)) // 5. Create or save sealed secrets based on skipApply flag var sealedSecretNames map[string]string @@ -473,7 +498,7 @@ func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoCo // Generate YAML and save to file instead of creating in cluster fmt.Println(" - Generating sealed secret manifests") var yamlContent string - sealedSecretNames, yamlContent, err = secrets.GenerateSealedSecretsYAML(sealedSecrets) + sealedSecretNames, yamlContent, err = secrets.GenerateSealedSecretsYAML(allSealedSecrets) if err != nil { return fmt.Errorf("failed to generate sealed secret YAML: %w", err) } @@ -494,7 +519,7 @@ func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoCo } else { // Create sealed secrets in cluster fmt.Println(" - Creating K8s sealed secrets in cluster") - sealedSecretNames, err = secrets.CreateSealedSecrets(sealedSecrets) + sealedSecretNames, err = secrets.CreateSealedSecrets(allSealedSecrets) if err != nil { return fmt.Errorf("failed to create sealed secrets: %w", err) } @@ -536,12 +561,12 @@ func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoCo baseName := strings.TrimSuffix(manifestFile, ext) trusteeConfigPath := baseName + "-trustee-secrets.yaml" - if err := secrets.GenerateTrusteeConfig(sealedSecrets, trusteeConfigPath); err != nil { + if err := secrets.GenerateTrusteeConfig(allSealedSecrets, trusteeConfigPath); err != nil { return fmt.Errorf("failed to generate Trustee config: %w", err) } // 8. Print instructions - secrets.PrintTrusteeInstructions(sealedSecrets, trusteeConfigPath, autoUploadSuccess) + secrets.PrintTrusteeInstructions(allSealedSecrets, trusteeConfigPath, autoUploadSuccess) return nil } @@ -943,3 +968,48 @@ func resolveNamespace(flagNamespace, manifestNamespace string) (string, error) { // 4. "default" (final fallback) return "default", nil } + +// secretsClusterUnreachableError creates an actionable error when the cluster +// can't be reached and some secrets require key enumeration. +func secretsClusterUnreachableError(clusterRefs []secrets.SecretReference, clientErr error) error { + names := make([]string, len(clusterRefs)) + for i, ref := range clusterRefs { + usageTypes := make([]string, 0) + seen := make(map[string]bool) + for _, u := range ref.Usages { + if !seen[u.Type] { + usageTypes = append(usageTypes, u.Type) + seen[u.Type] = true + } + } + names[i] = fmt.Sprintf(" - %s (used in %s)", ref.Name, strings.Join(usageTypes, ", ")) + } + + return fmt.Errorf("cannot determine keys for %d secret(s) in offline mode:\n%s\n\n"+ + "These secrets use envFrom, volume mounts without explicit items, or other patterns\n"+ + "that require querying the cluster to enumerate their keys.\n\n"+ + "To fix:\n"+ + " 1. Use explicit key references in your manifest instead of envFrom or whole-secret mounts\n"+ + " Example: env.valueFrom.secretKeyRef with explicit 'key' field\n"+ + " 2. Ensure cluster is reachable (check kubeconfig and network connectivity)\n"+ + " 3. Or disable secret conversion with --convert-secrets=false\n\n"+ + "Underlying error: %v", + len(clusterRefs), strings.Join(names, "\n"), clientErr) +} + +// secretsClusterQueryError creates an actionable error when the cluster is reachable +// but the secret query fails (e.g., secret doesn't exist, permission denied). +func secretsClusterQueryError(clusterRefs []secrets.SecretReference, queryErr error) error { + names := make([]string, len(clusterRefs)) + for i, ref := range clusterRefs { + names[i] = ref.Name + } + + return fmt.Errorf("failed to inspect secrets requiring cluster lookup (%s): %w\n\n"+ + "To fix:\n"+ + " 1. Ensure the secrets exist in the cluster\n"+ + " 2. Verify your kubeconfig has read access to secrets\n"+ + " 3. Or use explicit key references to avoid cluster lookups\n"+ + " 4. Or disable secret conversion with --convert-secrets=false", + strings.Join(names, ", "), queryErr) +} From d6883e07598443a05976eecec2ec3c0022f3fbe7 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 10:22:02 +0000 Subject: [PATCH 25/39] handle imagePullSecrets gracefully in skip-apply offline mode - handleImagePullSecrets returns nil early when cluster unreachable in skip-apply mode - Print informative message about skipped imagePullSecret inspection - Preserve cluster requirement for non-skip-apply mode (no behavior change) - Allow offline manifest generation even when imagePullSecrets can't be inspected - Users see clear guidance to ensure cluster is reachable if imagePullSecrets needed Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cmd/apply.go b/cmd/apply.go index 92eeefc..ed9b839 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -689,12 +689,22 @@ func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *conf // Create Kubernetes client for secret inspection client, clientErr := k8s.NewClient(k8s.ClientOptions{}) if clientErr != nil { + if skipApply { + // In skip-apply mode, imagePullSecrets are optional since we're not applying + fmt.Printf(" - Skipping imagePullSecret inspection (cluster not reachable in offline mode)\n") + fmt.Printf(" To include imagePullSecrets, ensure cluster is reachable or specify them manually\n") + return nil, nil + } return nil, fmt.Errorf("failed to create Kubernetes client: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", clientErr) } // Inspect K8s secrets to get keys inspectedSecrets, err := secrets.InspectSecrets(ctx, client.Clientset, imagePullSecretRefs) if err != nil { + if skipApply { + fmt.Printf(" - Skipping imagePullSecret inspection (cluster query failed in offline mode)\n") + return nil, nil + } return nil, fmt.Errorf("failed to inspect imagePullSecrets: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) } From 3c0fa9f6b54d41a7fcb82b5582624603eb8126f0 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 10:27:10 +0000 Subject: [PATCH 26/39] add end-to-end offline secret resolution tests - TestOfflineSecretResolution_EndToEnd: Full pipeline (InspectSecrets -> ConvertSecrets -> URIs) - TestOfflineSecretResolution_ConsistentWithCluster: Proves offline and cluster URIs match - Ensures URI format consistency across resolution paths Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/secrets/kubernetes_test.go | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/pkg/secrets/kubernetes_test.go b/pkg/secrets/kubernetes_test.go index a0b8710..d122f29 100644 --- a/pkg/secrets/kubernetes_test.go +++ b/pkg/secrets/kubernetes_test.go @@ -656,3 +656,110 @@ func TestInspectSecrets_NilClientset_MixedRefs(t *testing.T) { t.Errorf("error = %q, want error mentioning 'cluster-secret'", err.Error()) } } + +func TestOfflineSecretResolution_EndToEnd(t *testing.T) { + ctx := context.Background() + + // Simulate secrets with explicit keys from manifest + refs := []SecretReference{ + { + Name: "db-creds", + Namespace: "production", + Keys: []string{"password", "username"}, + NeedsLookup: false, + Usages: []SecretUsage{ + {Type: "env", Key: "password"}, + {Type: "env", Key: "username"}, + }, + }, + { + Name: "api-config", + Namespace: "production", + Keys: []string{"api-key"}, + NeedsLookup: false, + Usages: []SecretUsage{ + {Type: "volume", VolumeName: "config-vol"}, + }, + }, + } + + // InspectSecrets with nil clientset (offline mode) + inspected, err := InspectSecrets(ctx, nil, refs) + if err != nil { + t.Fatalf("InspectSecrets(nil) failed for offline refs: %v", err) + } + + // Verify synthetic secrets created + if len(inspected) != 2 { + t.Fatalf("Expected 2 inspected secrets, got %d", len(inspected)) + } + + // Convert to SecretKeys and then to sealed secrets + keys := SecretsToSecretKeys(inspected) + sealed, err := ConvertSecrets(refs, keys) + if err != nil { + t.Fatalf("ConvertSecrets failed: %v", err) + } + + // Verify correct number of sealed secrets (3 total: 2 from db-creds + 1 from api-config) + if len(sealed) != 3 { + t.Fatalf("Expected 3 sealed secrets, got %d", len(sealed)) + } + + // Verify URIs follow consistent format + for _, s := range sealed { + expectedPrefix := "kbs:///production/" + if !strings.HasPrefix(s.ResourceURI, expectedPrefix) { + t.Errorf("ResourceURI %q doesn't start with %q", s.ResourceURI, expectedPrefix) + } + + // Verify sealed secret format + if !strings.HasPrefix(s.SealedSecret, "sealed.fakejwsheader.") { + t.Errorf("SealedSecret doesn't have correct format: %s", s.SealedSecret) + } + } +} + +func TestOfflineSecretResolution_ConsistentWithCluster(t *testing.T) { + ctx := context.Background() + + // Same secret resolved offline + offlineRef := SecretReference{ + Name: "test-secret", Namespace: "myns", + Keys: []string{"key1"}, NeedsLookup: false, + } + offlineResult, err := InspectSecrets(ctx, nil, []SecretReference{offlineRef}) + if err != nil { + t.Fatalf("Offline InspectSecrets failed: %v", err) + } + offlineKeys := SecretsToSecretKeys(offlineResult) + offlineSealed, err := ConvertSecrets([]SecretReference{offlineRef}, offlineKeys) + if err != nil { + t.Fatalf("Offline ConvertSecrets failed: %v", err) + } + + // Same secret resolved via cluster (fake clientset) + fakeClient := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "myns"}, + Data: map[string][]byte{"key1": []byte("value1")}, + }) + clusterRef := SecretReference{ + Name: "test-secret", Namespace: "myns", + Keys: []string{"key1"}, NeedsLookup: true, + } + clusterResult, err := InspectSecrets(ctx, fakeClient, []SecretReference{clusterRef}) + if err != nil { + t.Fatalf("Cluster InspectSecrets failed: %v", err) + } + clusterKeys := SecretsToSecretKeys(clusterResult) + clusterSealed, err := ConvertSecrets([]SecretReference{clusterRef}, clusterKeys) + if err != nil { + t.Fatalf("Cluster ConvertSecrets failed: %v", err) + } + + // URIs must match regardless of resolution path + if offlineSealed[0].ResourceURI != clusterSealed[0].ResourceURI { + t.Errorf("URI mismatch: offline=%q cluster=%q", + offlineSealed[0].ResourceURI, clusterSealed[0].ResourceURI) + } +} From db00843a3076773851ea9e6bd4d212ab32d9cebc Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 10:28:42 +0000 Subject: [PATCH 27/39] add cmd-level tests for error handling and bifurcation - TestSkipApply_SecretsClusterUnreachableError_Format: Validates actionable error content - TestSkipApply_SecretsClusterQueryError_Format: Validates query error format - TestSkipApply_SecretRefSplitting: Validates NeedsLookup-based partitioning - Error messages include secret names, usage types, and fix suggestions Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply_skip_test.go | 106 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/cmd/apply_skip_test.go b/cmd/apply_skip_test.go index 9c8aebb..1dad67d 100644 --- a/cmd/apply_skip_test.go +++ b/cmd/apply_skip_test.go @@ -2,12 +2,14 @@ package cmd import ( "encoding/base64" + "fmt" "os" "path/filepath" "strings" "testing" "github.com/confidential-devhub/cococtl/pkg/k8s" + "github.com/confidential-devhub/cococtl/pkg/secrets" "github.com/confidential-devhub/cococtl/pkg/sidecar/certs" "gopkg.in/yaml.v3" ) @@ -276,3 +278,107 @@ func TestSkipApply_SidecarCertFileSaving(t *testing.T) { } } } + +func TestSkipApply_SecretsClusterUnreachableError_Format(t *testing.T) { + refs := []secrets.SecretReference{ + { + Name: "app-config", + Usages: []secrets.SecretUsage{ + {Type: "envFrom", ContainerName: "app"}, + }, + }, + { + Name: "volume-data", + Usages: []secrets.SecretUsage{ + {Type: "volume", VolumeName: "data-vol"}, + }, + }, + } + + err := secretsClusterUnreachableError(refs, fmt.Errorf("connection refused")) + + errMsg := err.Error() + + // Verify error mentions secret names + if !strings.Contains(errMsg, "app-config") { + t.Errorf("Error should mention 'app-config', got: %s", errMsg) + } + if !strings.Contains(errMsg, "volume-data") { + t.Errorf("Error should mention 'volume-data', got: %s", errMsg) + } + + // Verify error mentions usage types + if !strings.Contains(errMsg, "envFrom") { + t.Errorf("Error should mention 'envFrom' usage type, got: %s", errMsg) + } + if !strings.Contains(errMsg, "volume") { + t.Errorf("Error should mention 'volume' usage type, got: %s", errMsg) + } + + // Verify actionable guidance + if !strings.Contains(errMsg, "explicit key references") { + t.Errorf("Error should suggest explicit key references, got: %s", errMsg) + } + if !strings.Contains(errMsg, "convert-secrets=false") { + t.Errorf("Error should suggest --convert-secrets=false, got: %s", errMsg) + } + + // Verify underlying error included + if !strings.Contains(errMsg, "connection refused") { + t.Errorf("Error should include underlying error, got: %s", errMsg) + } +} + +func TestSkipApply_SecretsClusterQueryError_Format(t *testing.T) { + refs := []secrets.SecretReference{ + {Name: "missing-secret"}, + } + + err := secretsClusterQueryError(refs, fmt.Errorf("secret not found")) + + errMsg := err.Error() + + if !strings.Contains(errMsg, "missing-secret") { + t.Errorf("Error should mention secret name, got: %s", errMsg) + } + if !strings.Contains(errMsg, "secret not found") { + t.Errorf("Error should include underlying error, got: %s", errMsg) + } + if !strings.Contains(errMsg, "explicit key references") { + t.Errorf("Error should suggest explicit key references, got: %s", errMsg) + } +} + +func TestSkipApply_SecretRefSplitting(t *testing.T) { + // Simulate mixed refs + allRefs := []secrets.SecretReference{ + {Name: "explicit-secret", NeedsLookup: false, Keys: []string{"key1"}}, + {Name: "envfrom-secret", NeedsLookup: true}, + {Name: "volume-explicit", NeedsLookup: false, Keys: []string{"cert", "key"}}, + {Name: "volume-all", NeedsLookup: true}, + } + + var offlineRefs, clusterRefs []secrets.SecretReference + for _, ref := range allRefs { + if ref.NeedsLookup { + clusterRefs = append(clusterRefs, ref) + } else { + offlineRefs = append(offlineRefs, ref) + } + } + + if len(offlineRefs) != 2 { + t.Errorf("Expected 2 offline refs, got %d", len(offlineRefs)) + } + if len(clusterRefs) != 2 { + t.Errorf("Expected 2 cluster refs, got %d", len(clusterRefs)) + } + + // Verify correct assignment + if offlineRefs[0].Name != "explicit-secret" { + t.Errorf("First offline ref should be 'explicit-secret', got %q", offlineRefs[0].Name) + } + if clusterRefs[0].Name != "envfrom-secret" { + t.Errorf("First cluster ref should be 'envfrom-secret', got %q", clusterRefs[0].Name) + } +} From 63c484986518862e3140a8a815acc77d713d5515 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 13:45:54 +0000 Subject: [PATCH 28/39] use yaml.Unmarshal in output_test.go to match GenerateTrusteeConfig output GenerateTrusteeConfig marshals to YAML via yaml.Marshal, but tests were using json.Unmarshal to parse the output, causing 3 test failures with "invalid character 's' looking for beginning of value". Changed tests to use yaml.Unmarshal to match the actual output format. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/secrets/output_test.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/secrets/output_test.go b/pkg/secrets/output_test.go index fea6fb0..afd500f 100644 --- a/pkg/secrets/output_test.go +++ b/pkg/secrets/output_test.go @@ -1,10 +1,11 @@ package secrets import ( - "encoding/json" "os" "path/filepath" "testing" + + "gopkg.in/yaml.v3" ) func TestGenerateTrusteeConfig(t *testing.T) { @@ -38,8 +39,8 @@ func TestGenerateTrusteeConfig(t *testing.T) { } var config TrusteeConfig - if err := json.Unmarshal(data, &config); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) } // Verify content @@ -107,8 +108,8 @@ func TestGenerateTrusteeConfig_MultipleSecrets(t *testing.T) { } var config TrusteeConfig - if err := json.Unmarshal(data, &config); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) } if len(config.Secrets) != 2 { @@ -134,8 +135,8 @@ func TestGenerateTrusteeConfig_EmptySecrets(t *testing.T) { } var config TrusteeConfig - if err := json.Unmarshal(data, &config); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) } if len(config.Secrets) != 0 { From f1f521452b16205e645588c490a11259ced36319 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 13:46:20 +0000 Subject: [PATCH 29/39] resolve nilerr lint by ignoring intentional Namespace() error GetCurrentNamespace intentionally falls back to in-cluster namespace or "default" when kubeconfig is missing or invalid. The previous code checked err != nil but returned nil error, which triggered the nilerr linter. Simplified by ignoring the error with _ since the empty-namespace check that follows handles the fallback identically. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/k8s/client.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index bee9d6e..14a0c9a 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -112,11 +112,10 @@ func GetCurrentNamespace() (string, error) { configOverrides, ) - namespace, _, err := kubeConfig.Namespace() - if err != nil { - // If kubeconfig doesn't exist or is invalid, try in-cluster - return getInClusterNamespace(""), nil - } + // Namespace() may fail if kubeconfig is missing or invalid; + // treat that the same as an empty namespace and fall through + // to in-cluster / "default" resolution below. + namespace, _, _ := kubeConfig.Namespace() if namespace == "" { namespace = getInClusterNamespace("") From 437a04ac330095c39fc7383f606a6f2e66ad6133 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 13:47:12 +0000 Subject: [PATCH 30/39] use t.Setenv instead of unchecked os.Setenv/os.Unsetenv Replace manual os.Setenv/os.Unsetenv calls with t.Setenv which automatically restores the original value on test cleanup. This fixes errcheck lint violations Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply_skip_test.go | 26 ++++---------------------- pkg/k8s/client_test.go | 8 ++------ 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/cmd/apply_skip_test.go b/cmd/apply_skip_test.go index 1dad67d..40d2d4a 100644 --- a/cmd/apply_skip_test.go +++ b/cmd/apply_skip_test.go @@ -97,17 +97,8 @@ users: t.Fatalf("Failed to write test kubeconfig: %v", err) } - // Save and restore KUBECONFIG env - origKubeconfig := os.Getenv("KUBECONFIG") - t.Cleanup(func() { - if origKubeconfig == "" { - os.Unsetenv("KUBECONFIG") - } else { - os.Setenv("KUBECONFIG", origKubeconfig) - } - }) - - os.Setenv("KUBECONFIG", kubeconfigPath) + // Set KUBECONFIG env (t.Setenv restores original value on cleanup) + t.Setenv("KUBECONFIG", kubeconfigPath) // Verify k8s.GetCurrentNamespace() returns the kubeconfig namespace kubeconfigNs, err := k8s.GetCurrentNamespace() @@ -131,18 +122,9 @@ users: // TestSkipApply_NamespaceResolution_DefaultFallback tests that "default" is // returned when no flag, no manifest namespace, and no kubeconfig namespace exist. func TestSkipApply_NamespaceResolution_DefaultFallback(t *testing.T) { - // Save and restore KUBECONFIG env - origKubeconfig := os.Getenv("KUBECONFIG") - t.Cleanup(func() { - if origKubeconfig == "" { - os.Unsetenv("KUBECONFIG") - } else { - os.Setenv("KUBECONFIG", origKubeconfig) - } - }) - // Point KUBECONFIG to a non-existent path so kubeconfig reading fails - os.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "nonexistent-kubeconfig")) + // (t.Setenv restores original value on cleanup) + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "nonexistent-kubeconfig")) ns, err := resolveNamespace("", "") if err != nil { diff --git a/pkg/k8s/client_test.go b/pkg/k8s/client_test.go index fe82342..cfb21e1 100644 --- a/pkg/k8s/client_test.go +++ b/pkg/k8s/client_test.go @@ -140,10 +140,8 @@ func TestGetCurrentNamespace_FromContext(t *testing.T) { // Create kubeconfig with namespace kubeconfigPath := createTestKubeconfig(t, "test-ns") - // Save and restore original KUBECONFIG - origKubeconfig := os.Getenv("KUBECONFIG") + // Set KUBECONFIG env (t.Setenv restores original value on cleanup) t.Setenv("KUBECONFIG", kubeconfigPath) - defer os.Setenv("KUBECONFIG", origKubeconfig) namespace, err := GetCurrentNamespace() if err != nil { @@ -159,10 +157,8 @@ func TestGetCurrentNamespace_Default(t *testing.T) { // Create kubeconfig without namespace kubeconfigPath := createTestKubeconfig(t, "") - // Save and restore original KUBECONFIG - origKubeconfig := os.Getenv("KUBECONFIG") + // Set KUBECONFIG env (t.Setenv restores original value on cleanup) t.Setenv("KUBECONFIG", kubeconfigPath) - defer os.Setenv("KUBECONFIG", origKubeconfig) namespace, err := GetCurrentNamespace() if err != nil { From d7bc60fe5959cd8f167dfb1e17b983578028074c Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 13:48:41 +0000 Subject: [PATCH 31/39] rename SecretsToSecretKeys to ToSecretKeys and use t in test Rename SecretsToSecretKeys to ToSecretKeys to avoid stuttering when called as secrets.ToSecretKeys (revive lint). Update all callers in cmd/apply.go and kubernetes_test.go. Also fix unused parameter 't' in TestEnsureNamespace_NotFound by replacing bare _ = err with t.Logf to log the result. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 6 +++--- pkg/secrets/kubernetes.go | 4 ++-- pkg/secrets/kubernetes_test.go | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index ed9b839..8941cfc 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -462,7 +462,7 @@ func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoCo if err != nil { return fmt.Errorf("failed to resolve offline secrets: %w", err) } - offlineKeys := secrets.SecretsToSecretKeys(offlineSecrets) + offlineKeys := secrets.ToSecretKeys(offlineSecrets) offlineSealed, err := secrets.ConvertSecrets(offlineRefs, offlineKeys) if err != nil { return err @@ -482,7 +482,7 @@ func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoCo if err != nil { return secretsClusterQueryError(clusterRefs, err) } - clusterKeys := secrets.SecretsToSecretKeys(clusterSecrets) + clusterKeys := secrets.ToSecretKeys(clusterSecrets) clusterSealed, err := secrets.ConvertSecrets(clusterRefs, clusterKeys) if err != nil { return err @@ -709,7 +709,7 @@ func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *conf } // Convert to SecretKeys format - inspectedKeys := secrets.SecretsToSecretKeys(inspectedSecrets) + inspectedKeys := secrets.ToSecretKeys(inspectedSecrets) // Build ImagePullSecretInfo for initdata var imagePullSecretsInfo []initdata.ImagePullSecretInfo diff --git a/pkg/secrets/kubernetes.go b/pkg/secrets/kubernetes.go index 959094e..15c3968 100644 --- a/pkg/secrets/kubernetes.go +++ b/pkg/secrets/kubernetes.go @@ -36,8 +36,8 @@ func SecretToSecretKeys(secret *corev1.Secret) *SecretKeys { } } -// SecretsToSecretKeys converts a map of corev1.Secret to SecretKeys format -func SecretsToSecretKeys(secrets map[string]*corev1.Secret) map[string]*SecretKeys { +// ToSecretKeys converts a map of corev1.Secret to SecretKeys format +func ToSecretKeys(secrets map[string]*corev1.Secret) map[string]*SecretKeys { result := make(map[string]*SecretKeys, len(secrets)) for name, secret := range secrets { result[name] = SecretToSecretKeys(secret) diff --git a/pkg/secrets/kubernetes_test.go b/pkg/secrets/kubernetes_test.go index d122f29..6e168f5 100644 --- a/pkg/secrets/kubernetes_test.go +++ b/pkg/secrets/kubernetes_test.go @@ -695,7 +695,7 @@ func TestOfflineSecretResolution_EndToEnd(t *testing.T) { } // Convert to SecretKeys and then to sealed secrets - keys := SecretsToSecretKeys(inspected) + keys := ToSecretKeys(inspected) sealed, err := ConvertSecrets(refs, keys) if err != nil { t.Fatalf("ConvertSecrets failed: %v", err) @@ -732,7 +732,7 @@ func TestOfflineSecretResolution_ConsistentWithCluster(t *testing.T) { if err != nil { t.Fatalf("Offline InspectSecrets failed: %v", err) } - offlineKeys := SecretsToSecretKeys(offlineResult) + offlineKeys := ToSecretKeys(offlineResult) offlineSealed, err := ConvertSecrets([]SecretReference{offlineRef}, offlineKeys) if err != nil { t.Fatalf("Offline ConvertSecrets failed: %v", err) @@ -751,7 +751,7 @@ func TestOfflineSecretResolution_ConsistentWithCluster(t *testing.T) { if err != nil { t.Fatalf("Cluster InspectSecrets failed: %v", err) } - clusterKeys := SecretsToSecretKeys(clusterResult) + clusterKeys := ToSecretKeys(clusterResult) clusterSealed, err := ConvertSecrets([]SecretReference{clusterRef}, clusterKeys) if err != nil { t.Fatalf("Cluster ConvertSecrets failed: %v", err) From fa17631ed68d0b27b9b0aa391994f6388f07972d Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sat, 7 Feb 2026 13:50:44 +0000 Subject: [PATCH 32/39] thread context and kubernetes.Interface through populateSecrets and callers populateSecrets created context.Background() and its own k8s.NewClient internally. Thread both context.Context and kubernetes.Interface as parameters so callers pass their existing context and client. Updated UploadResource, UploadResources, and all callers in cmd/apply.go and cmd/init.go. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 12 ++++++++---- cmd/init.go | 13 ++++++++++--- pkg/secrets/secrets.go | 4 +--- pkg/trustee/kbs.go | 23 +++++++---------------- pkg/trustee/trustee.go | 2 +- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 8941cfc..bf83ecd 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -667,7 +667,7 @@ func addK8sSecretToTrustee(trusteeNamespace, secretName, secretNamespace string) // Falls back to default service account if no imagePullSecrets in manifest func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) ([]initdata.ImagePullSecretInfo, error) { // Detect imagePullSecrets in manifest, with fallback to default service account - imagePullSecretRefs, err := secrets.DetectImagePullSecretsWithServiceAccount(m.GetData()) + imagePullSecretRefs, err := secrets.DetectImagePullSecretsWithServiceAccount(ctx, m.GetData()) if err != nil { return nil, err } @@ -837,11 +837,12 @@ func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNam } } + // Create Kubernetes client (used for auto-SAN detection and KBS upload) + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + // Auto-detect SANs unless skipped if !sidecarSkipAutoSANs { // Auto-detect node IPs - // Create Kubernetes client for node IP detection - client, clientErr := k8s.NewClient(k8s.ClientOptions{}) if clientErr != nil { fmt.Printf("Warning: failed to create Kubernetes client for node IP detection: %v\n", clientErr) } else { @@ -882,12 +883,15 @@ func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNam if !skipApply { // Normal mode: upload to Trustee KBS + if clientErr != nil { + return fmt.Errorf("failed to create Kubernetes client for certificate upload: %w", clientErr) + } fmt.Printf(" - Uploading server certificate to Trustee KBS (namespace: %s)...\n", trusteeNamespace) resources := map[string][]byte{ serverCertPath: serverCert.CertPEM, serverKeyPath: serverCert.KeyPEM, } - if err := trustee.UploadResources(trusteeNamespace, resources); err != nil { + if err := trustee.UploadResources(ctx, client.Clientset, trusteeNamespace, resources); err != nil { return fmt.Errorf("failed to upload server certificate to KBS: %w", err) } fmt.Printf(" - Server certificate uploaded to kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) diff --git a/cmd/init.go b/cmd/init.go index ed92d06..23769d5 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,11 +2,14 @@ package cmd import ( "bufio" + "context" "fmt" "os" "path/filepath" "strings" + "k8s.io/client-go/kubernetes" + "github.com/confidential-devhub/cococtl/pkg/cluster" "github.com/confidential-devhub/cococtl/pkg/config" "github.com/confidential-devhub/cococtl/pkg/k8s" @@ -107,7 +110,11 @@ func runInit(cmd *cobra.Command, _ []string) error { // Handle sidecar certificate setup if enabled if enableSidecar { - if err := handleSidecarCertSetup(sidecarNamespace); err != nil { + sidecarClient, err := k8s.NewClient(k8s.ClientOptions{}) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client for sidecar cert setup: %w", err) + } + if err := handleSidecarCertSetup(cmd.Context(), sidecarClient.Clientset, sidecarNamespace); err != nil { return err } } @@ -304,7 +311,7 @@ func handleTrusteeSetup(cmd *cobra.Command, cfg *config.CocoConfig, interactive, // uploads the Client CA to Trustee KBS, and saves both the CA and client certificate locally. // The CA is needed during 'apply' to sign per-app server certificates. // The trusteeNamespace parameter specifies where the Trustee KBS pod is deployed. -func handleSidecarCertSetup(trusteeNamespace string) error { +func handleSidecarCertSetup(ctx context.Context, clientset kubernetes.Interface, trusteeNamespace string) error { fmt.Println("\nSetting up sidecar certificates...") // Generate Client CA @@ -328,7 +335,7 @@ func handleSidecarCertSetup(trusteeNamespace string) error { const kbsResourceNamespace = "default" fmt.Printf(" - Uploading Client CA to Trustee KBS (Trustee namespace: %s, resource path: default)...\n", trusteeNamespace) clientCAPath := kbsResourceNamespace + "/sidecar-tls/client-ca" - if err := trustee.UploadResource(trusteeNamespace, clientCAPath, clientCA.CertPEM); err != nil { + if err := trustee.UploadResource(ctx, clientset, trusteeNamespace, clientCAPath, clientCA.CertPEM); err != nil { return fmt.Errorf("failed to upload client CA to KBS: %w", err) } diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index a72c483..9ec90c1 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -299,7 +299,7 @@ func detectImagePullSecrets(spec map[string]interface{}, namespace string, secre // DetectImagePullSecretsWithServiceAccount detects imagePullSecrets from manifest // and falls back to default service account if none are found in the spec -func DetectImagePullSecretsWithServiceAccount(manifestData map[string]interface{}) ([]SecretReference, error) { +func DetectImagePullSecretsWithServiceAccount(ctx context.Context, manifestData map[string]interface{}) ([]SecretReference, error) { // Create manifest wrapper to reuse existing manifest methods m := manifest.GetFromData(manifestData) @@ -327,8 +327,6 @@ func DetectImagePullSecretsWithServiceAccount(manifestData map[string]interface{ // If no imagePullSecrets found in manifest, check default service account if len(secretsMap) == 0 { - // Create context and clientset for serviceaccount query - ctx := context.Background() client, err := k8s.NewClient(k8s.ClientOptions{}) if err == nil { secretName, err := GetServiceAccountImagePullSecrets(ctx, client.Clientset, "default", namespace) diff --git a/pkg/trustee/kbs.go b/pkg/trustee/kbs.go index c103f54..e52f478 100644 --- a/pkg/trustee/kbs.go +++ b/pkg/trustee/kbs.go @@ -11,8 +11,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" - - "github.com/confidential-devhub/cococtl/pkg/k8s" ) const kbsRepositoryPath = "/opt/confidential-containers/kbs/repository" @@ -20,7 +18,7 @@ const kbsRepositoryPath = "/opt/confidential-containers/kbs/repository" // UploadResource uploads a single resource to Trustee KBS. // The resourcePath should be relative (e.g., "default/sidecar-tls/server-cert"). // The data is the raw bytes to upload. -func UploadResource(namespace, resourcePath string, data []byte) error { +func UploadResource(ctx context.Context, clientset kubernetes.Interface, namespace, resourcePath string, data []byte) error { resources := []SecretResource{ { URI: "kbs:///" + resourcePath, @@ -28,13 +26,13 @@ func UploadResource(namespace, resourcePath string, data []byte) error { }, } - return populateSecrets(namespace, resources) + return populateSecrets(ctx, clientset, namespace, resources) } // UploadResources uploads multiple resources to Trustee KBS in a single operation. // Each resource is specified as a map entry where key is the resource path // (e.g., "default/sidecar-tls/server-cert") and value is the data bytes. -func UploadResources(namespace string, resources map[string][]byte) error { +func UploadResources(ctx context.Context, clientset kubernetes.Interface, namespace string, resources map[string][]byte) error { if len(resources) == 0 { return nil } @@ -47,7 +45,7 @@ func UploadResources(namespace string, resources map[string][]byte) error { }) } - return populateSecrets(namespace, secretResources) + return populateSecrets(ctx, clientset, namespace, secretResources) } // GetKBSPodName retrieves the name of the KBS pod in the specified namespace. @@ -84,24 +82,17 @@ func WaitForKBSReady(ctx context.Context, clientset kubernetes.Interface, namesp } // It uploads multiple secrets to KBS via kubectl cp. -func populateSecrets(namespace string, secrets []SecretResource) error { +func populateSecrets(ctx context.Context, clientset kubernetes.Interface, namespace string, secrets []SecretResource) error { if len(secrets) == 0 { return nil } - // For now, create context here until UploadResource/UploadResources are migrated - ctx := context.Background() - client, err := k8s.NewClient(k8s.ClientOptions{}) - if err != nil { - return fmt.Errorf("failed to create kubernetes client: %w", err) - } - - podName, err := GetKBSPodName(ctx, client.Clientset, namespace) + podName, err := GetKBSPodName(ctx, clientset, namespace) if err != nil { return err } - if err := WaitForKBSReady(ctx, client.Clientset, namespace); err != nil { + if err := WaitForKBSReady(ctx, clientset, namespace); err != nil { return err } diff --git a/pkg/trustee/trustee.go b/pkg/trustee/trustee.go index 474831a..7029555 100644 --- a/pkg/trustee/trustee.go +++ b/pkg/trustee/trustee.go @@ -105,7 +105,7 @@ func Deploy(ctx context.Context, clientset kubernetes.Interface, cfg *Config) er } if len(cfg.Secrets) > 0 { - if err := populateSecrets(cfg.Namespace, cfg.Secrets); err != nil { + if err := populateSecrets(ctx, clientset, cfg.Namespace, cfg.Secrets); err != nil { return fmt.Errorf("failed to populate secrets: %w", err) } } From e475f77686de1473316cf08ed6430875403d346d Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sun, 8 Feb 2026 07:36:42 +0000 Subject: [PATCH 33/39] eliminate duplicate resolveNamespace call in runApply resolveNamespace(namespaceFlag, m.GetNamespace()) was called at line 204 (stored in resolvedNamespace) and again at line 224 inside the sidecar block with identical arguments. The function is pure, so the second call always returns the same value. Use the existing variable. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index bf83ecd..3635bf7 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -221,13 +221,9 @@ func runApply(cmd *cobra.Command, _ []string) error { var servicePath string if enableSidecar || cfg.Sidecar.Enabled { appName := m.GetName() - namespace, err := resolveNamespace(namespaceFlag, m.GetNamespace()) - if err != nil { - return fmt.Errorf("failed to resolve namespace: %w", err) - } fmt.Println("Generating Service manifest for sidecar...") - serviceManifest, err := sidecar.GenerateService(m, cfg, appName, namespace) + serviceManifest, err := sidecar.GenerateService(m, cfg, appName, resolvedNamespace) if err != nil { return fmt.Errorf("failed to generate sidecar Service: %w", err) } From 8a372f84b8250099ac71b56ef66dff22215bd915 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sun, 8 Feb 2026 07:37:06 +0000 Subject: [PATCH 34/39] thread context through applyWithKubectl for signal handling applyWithKubectl created context.Background() internally, discarding the command context from cmd.Context(). This meant SIGINT during kubectl apply would not terminate the subprocess. Accept ctx as a parameter so the caller's context propagates to exec.CommandContext. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 3635bf7..ff7550c 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -248,14 +248,14 @@ func runApply(cmd *cobra.Command, _ []string) error { // Apply manifests if not skipped if !skipApply { fmt.Println("Applying manifest with kubectl...") - if err := applyWithKubectl(backupPath); err != nil { + if err := applyWithKubectl(ctx, backupPath); err != nil { return fmt.Errorf("failed to apply manifest: %w", err) } // Apply Service manifest if generated if servicePath != "" { fmt.Println("Applying sidecar Service manifest with kubectl...") - if err := applyWithKubectl(servicePath); err != nil { + if err := applyWithKubectl(ctx, servicePath); err != nil { return fmt.Errorf("failed to apply sidecar Service: %w", err) } } @@ -580,8 +580,7 @@ func updateManifestSecretNames(m *manifest.Manifest, sealedSecretNames map[strin return nil } -func applyWithKubectl(manifestPath string) error { - ctx := context.Background() +func applyWithKubectl(ctx context.Context, manifestPath string) error { cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestPath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr From 7cf00936fe096932d61cef092975aa4f1de38483 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sun, 8 Feb 2026 07:37:33 +0000 Subject: [PATCH 35/39] fix error messages referencing kubectl instead of kubeconfig Client-go uses kubeconfig for cluster access, not kubectl. Update error messages to say "Ensure kubeconfig is properly configured" instead of "Ensure kubectl is configured". Also fix CA cert error to reference 'cococtl init' instead of 'kubectl coco init'. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index ff7550c..ed7e48f 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -690,7 +690,7 @@ func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *conf fmt.Printf(" To include imagePullSecrets, ensure cluster is reachable or specify them manually\n") return nil, nil } - return nil, fmt.Errorf("failed to create Kubernetes client: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", clientErr) + return nil, fmt.Errorf("failed to create Kubernetes client: %w\n\nTo fix:\n 1. Ensure kubeconfig is properly configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", clientErr) } // Inspect K8s secrets to get keys @@ -700,7 +700,7 @@ func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *conf fmt.Printf(" - Skipping imagePullSecret inspection (cluster query failed in offline mode)\n") return nil, nil } - return nil, fmt.Errorf("failed to inspect imagePullSecrets: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) + return nil, fmt.Errorf("failed to inspect imagePullSecrets: %w\n\nTo fix:\n 1. Ensure kubeconfig is properly configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) } // Convert to SecretKeys format From c00ed22994128bf2aa207669e40d20092590ed30 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sun, 8 Feb 2026 07:38:29 +0000 Subject: [PATCH 36/39] thread context through applyManifest and trustee deploy helpers applyManifest and its callers (createAuthSecretFromKeys, deployConfigMaps, deployPCCSConfigMap, deployKBS) did not accept context, so kubectl subprocesses could not be cancelled via SIGINT. Thread ctx from Deploy() through to exec.CommandContext in applyManifest. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- pkg/trustee/trustee.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/trustee/trustee.go b/pkg/trustee/trustee.go index 7029555..2a6c18e 100644 --- a/pkg/trustee/trustee.go +++ b/pkg/trustee/trustee.go @@ -80,22 +80,22 @@ func Deploy(ctx context.Context, clientset kubernetes.Interface, cfg *Config) er return fmt.Errorf("failed to create namespace: %w", err) } - if err := createAuthSecretFromKeys(cfg.Namespace); err != nil { + if err := createAuthSecretFromKeys(ctx, cfg.Namespace); err != nil { return fmt.Errorf("failed to create auth secret: %w", err) } - if err := deployConfigMaps(cfg.Namespace); err != nil { + if err := deployConfigMaps(ctx, cfg.Namespace); err != nil { return fmt.Errorf("failed to deploy ConfigMaps: %w", err) } // Deploy PCCS ConfigMap if PCCSURL is configured if cfg.PCCSURL != "" { - if err := deployPCCSConfigMap(cfg.Namespace, cfg.PCCSURL); err != nil { + if err := deployPCCSConfigMap(ctx, cfg.Namespace, cfg.PCCSURL); err != nil { return fmt.Errorf("failed to deploy PCCS ConfigMap: %w", err) } } - if err := deployKBS(cfg); err != nil { + if err := deployKBS(ctx, cfg); err != nil { return fmt.Errorf("failed to deploy KBS: %w", err) } @@ -138,8 +138,8 @@ func ensureNamespace(ctx context.Context, clientset kubernetes.Interface, namesp return nil } -func applyManifest(yaml string) error { - cmd := exec.Command("kubectl", "apply", "-f", "-") +func applyManifest(ctx context.Context, yaml string) error { + cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", "-") cmd.Stdin = strings.NewReader(yaml) output, err := cmd.CombinedOutput() if err != nil { @@ -148,7 +148,7 @@ func applyManifest(yaml string) error { return nil } -func createAuthSecretFromKeys(namespace string) error { +func createAuthSecretFromKeys(ctx context.Context, namespace string) error { // Generate ED25519 key pair locally publicKey, privateKey, err := ed25519.GenerateKey(nil) if err != nil { @@ -211,10 +211,10 @@ func createAuthSecretFromKeys(namespace string) error { } // Apply the secret - return applyManifest(string(secretYAML)) + return applyManifest(ctx, string(secretYAML)) } -func deployConfigMaps(namespace string) error { +func deployConfigMaps(ctx context.Context, namespace string) error { manifest := fmt.Sprintf(` apiVersion: v1 kind: ConfigMap @@ -275,10 +275,10 @@ data: {} `, namespace, namespace, namespace) - return applyManifest(manifest) + return applyManifest(ctx, manifest) } -func deployPCCSConfigMap(namespace, pccsURL string) error { +func deployPCCSConfigMap(ctx context.Context, namespace, pccsURL string) error { qcnlConfig := fmt.Sprintf(`{"collateral_service":"%s"}`, pccsURL) manifest := fmt.Sprintf(` @@ -291,10 +291,10 @@ data: sgx_default_qcnl.conf: '%s' `, namespace, qcnlConfig) - return applyManifest(manifest) + return applyManifest(ctx, manifest) } -func deployKBS(cfg *Config) error { +func deployKBS(ctx context.Context, cfg *Config) error { // Build volumeMounts - base mounts volumeMounts := ` - name: confidential-containers mountPath: /opt/confidential-containers @@ -405,7 +405,7 @@ spec: protocol: TCP `, cfg.Namespace, cfg.KBSImage, volumeMounts, volumes, cfg.ServiceName, cfg.Namespace) - return applyManifest(manifest) + return applyManifest(ctx, manifest) } // ParseSecretSpec parses a secret specification and reads the file From f911e9d7564bd7f03c0d670799e8d10f90c9d3f8 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sun, 8 Feb 2026 07:42:24 +0000 Subject: [PATCH 37/39] thread clientset through DetectImagePullSecretsWithServiceAccount The function created its own k8s.NewClient internally for the service account fallback lookup. Its caller handleImagePullSecrets then created a second client for InspectSecrets. Accept kubernetes.Interface as a parameter so the caller passes one client for both operations. When clientset is nil, the SA fallback is skipped gracefully. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 17 +++++++++++++---- pkg/secrets/secrets.go | 29 +++++++++++++++-------------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index ed7e48f..83d8bf5 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" + "k8s.io/client-go/kubernetes" + "github.com/confidential-devhub/cococtl/pkg/cluster" "github.com/confidential-devhub/cococtl/pkg/config" "github.com/confidential-devhub/cococtl/pkg/initdata" @@ -661,8 +663,16 @@ func addK8sSecretToTrustee(trusteeNamespace, secretName, secretNamespace string) // It detects, uploads to KBS, and prepares them for initdata // Falls back to default service account if no imagePullSecrets in manifest func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) ([]initdata.ImagePullSecretInfo, error) { + // Create Kubernetes client for SA fallback detection and secret inspection + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + var clientset kubernetes.Interface + if clientErr == nil { + clientset = client.Clientset + } + // Detect imagePullSecrets in manifest, with fallback to default service account - imagePullSecretRefs, err := secrets.DetectImagePullSecretsWithServiceAccount(ctx, m.GetData()) + // Pass clientset for SA fallback (nil if client creation failed — fallback is skipped) + imagePullSecretRefs, err := secrets.DetectImagePullSecretsWithServiceAccount(ctx, clientset, m.GetData()) if err != nil { return nil, err } @@ -681,8 +691,7 @@ func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *conf imagePullSecretRefs = imagePullSecretRefs[:1] } - // Create Kubernetes client for secret inspection - client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + // Ensure client is available for secret inspection if clientErr != nil { if skipApply { // In skip-apply mode, imagePullSecrets are optional since we're not applying @@ -694,7 +703,7 @@ func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *conf } // Inspect K8s secrets to get keys - inspectedSecrets, err := secrets.InspectSecrets(ctx, client.Clientset, imagePullSecretRefs) + inspectedSecrets, err := secrets.InspectSecrets(ctx, clientset, imagePullSecretRefs) if err != nil { if skipApply { fmt.Printf(" - Skipping imagePullSecret inspection (cluster query failed in offline mode)\n") diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index 9ec90c1..5a7e7bf 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "k8s.io/client-go/kubernetes" + "github.com/confidential-devhub/cococtl/pkg/k8s" "github.com/confidential-devhub/cococtl/pkg/manifest" ) @@ -298,8 +300,10 @@ func detectImagePullSecrets(spec map[string]interface{}, namespace string, secre } // DetectImagePullSecretsWithServiceAccount detects imagePullSecrets from manifest -// and falls back to default service account if none are found in the spec -func DetectImagePullSecretsWithServiceAccount(ctx context.Context, manifestData map[string]interface{}) ([]SecretReference, error) { +// and falls back to default service account if none are found in the spec. +// The clientset parameter is used for the service account fallback lookup; +// pass nil to skip the fallback. +func DetectImagePullSecretsWithServiceAccount(ctx context.Context, clientset kubernetes.Interface, manifestData map[string]interface{}) ([]SecretReference, error) { // Create manifest wrapper to reuse existing manifest methods m := manifest.GetFromData(manifestData) @@ -326,18 +330,15 @@ func DetectImagePullSecretsWithServiceAccount(ctx context.Context, manifestData detectImagePullSecrets(podSpec, namespace, secretsMap) // If no imagePullSecrets found in manifest, check default service account - if len(secretsMap) == 0 { - client, err := k8s.NewClient(k8s.ClientOptions{}) - if err == nil { - secretName, err := GetServiceAccountImagePullSecrets(ctx, client.Clientset, "default", namespace) - if err == nil && secretName != "" { - // Found imagePullSecret in default service account - ref := getOrCreateSecretRef(secretsMap, secretName, namespace) - ref.NeedsLookup = true - ref.Usages = append(ref.Usages, SecretUsage{ - Type: "imagePullSecrets", - }) - } + if len(secretsMap) == 0 && clientset != nil { + secretName, err := GetServiceAccountImagePullSecrets(ctx, clientset, "default", namespace) + if err == nil && secretName != "" { + // Found imagePullSecret in default service account + ref := getOrCreateSecretRef(secretsMap, secretName, namespace) + ref.NeedsLookup = true + ref.Usages = append(ref.Usages, SecretUsage{ + Type: "imagePullSecrets", + }) } } From 82dde93477be404a80d52cd1305ca73e938a96aa Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Sun, 8 Feb 2026 07:44:25 +0000 Subject: [PATCH 38/39] consolidate k8s.NewClient calls in transformManifest handleSecrets, handleImagePullSecrets, and handleSidecarServerCert each created their own k8s.NewClient, resulting in three redundant client initializations per apply invocation. Create the client once in transformManifest and pass kubernetes.Interface + clientErr to all three handlers. Assisted-by: AI Signed-off-by: Pradipta Banerjee --- cmd/apply.go | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 83d8bf5..cef68b8 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -271,6 +271,14 @@ func runApply(cmd *cobra.Command, _ []string) error { } func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, rc string, skipApply bool, resolvedNamespace string) error { + // Create Kubernetes client once for all operations that need cluster access. + // Client creation is deferred-error: handlers that need it check clientErr. + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + var clientset kubernetes.Interface + if clientErr == nil { + clientset = client.Clientset + } + // 1. Set RuntimeClass fmt.Printf(" - Setting runtimeClassName: %s\n", rc) if err := m.SetRuntimeClass(rc); err != nil { @@ -279,7 +287,7 @@ func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.Co // 2. Convert secrets if enabled if convertSecrets { - if err := handleSecrets(ctx, m, cfg, skipApply); err != nil { + if err := handleSecrets(ctx, m, cfg, skipApply, clientset, clientErr); err != nil { return fmt.Errorf("failed to convert secrets: %w", err) } } else { @@ -296,7 +304,7 @@ func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.Co var imagePullSecretsInfo []initdata.ImagePullSecretInfo if convertSecrets { var err error - imagePullSecretsInfo, err = handleImagePullSecrets(ctx, m, cfg, skipApply) + imagePullSecretsInfo, err = handleImagePullSecrets(ctx, m, cfg, skipApply, clientset, clientErr) if err != nil { return fmt.Errorf("failed to handle imagePullSecrets: %w", err) } @@ -338,7 +346,7 @@ func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.Co // Generate and upload server certificate (or save to file in skip-apply mode) fmt.Println(" - Setting up sidecar server certificate") - if err := handleSidecarServerCert(ctx, appName, namespace, trusteeNamespace, skipApply, manifestFile); err != nil { + if err := handleSidecarServerCert(ctx, appName, namespace, trusteeNamespace, skipApply, manifestFile, clientset, clientErr); err != nil { return fmt.Errorf("failed to setup sidecar server certificate: %w", err) } @@ -413,7 +421,7 @@ func handleInitContainer(m *manifest.Manifest, cfg *config.CocoConfig) error { return nil } -func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) error { +func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool, clientset kubernetes.Interface, clientErr error) error { // 1. Detect all secret references allSecretRefs, err := secrets.DetectSecrets(m.GetData()) if err != nil { @@ -471,12 +479,11 @@ func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoCo // 4. Process cluster refs (needs cluster connection for key enumeration) if len(clusterRefs) > 0 { fmt.Printf(" - %d secret(s) require cluster access for key enumeration\n", len(clusterRefs)) - client, clientErr := k8s.NewClient(k8s.ClientOptions{}) if clientErr != nil { return secretsClusterUnreachableError(clusterRefs, clientErr) } - clusterSecrets, err := secrets.InspectSecrets(ctx, client.Clientset, clusterRefs) + clusterSecrets, err := secrets.InspectSecrets(ctx, clientset, clusterRefs) if err != nil { return secretsClusterQueryError(clusterRefs, err) } @@ -662,14 +669,7 @@ func addK8sSecretToTrustee(trusteeNamespace, secretName, secretNamespace string) // handleImagePullSecrets processes imagePullSecrets from the manifest // It detects, uploads to KBS, and prepares them for initdata // Falls back to default service account if no imagePullSecrets in manifest -func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) ([]initdata.ImagePullSecretInfo, error) { - // Create Kubernetes client for SA fallback detection and secret inspection - client, clientErr := k8s.NewClient(k8s.ClientOptions{}) - var clientset kubernetes.Interface - if clientErr == nil { - clientset = client.Clientset - } - +func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool, clientset kubernetes.Interface, clientErr error) ([]initdata.ImagePullSecretInfo, error) { // Detect imagePullSecrets in manifest, with fallback to default service account // Pass clientset for SA fallback (nil if client creation failed — fallback is skipped) imagePullSecretRefs, err := secrets.DetectImagePullSecretsWithServiceAccount(ctx, clientset, m.GetData()) @@ -800,7 +800,7 @@ func addImagePullSecretToTrustee(trusteeNamespace, secretName, secretNamespace s // - trusteeNamespace: namespace where Trustee KBS is deployed // - skipApply: when true, save certs to file instead of uploading to Trustee // - manifestPath: path to the original manifest file (for cert file naming) -func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNamespace string, skipApply bool, manifestPath string) error { +func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNamespace string, skipApply bool, manifestPath string, clientset kubernetes.Interface, clientErr error) error { // Load Client CA from ~/.kube/coco-sidecar/ homeDir, err := os.UserHomeDir() if err != nil { @@ -841,16 +841,13 @@ func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNam } } - // Create Kubernetes client (used for auto-SAN detection and KBS upload) - client, clientErr := k8s.NewClient(k8s.ClientOptions{}) - // Auto-detect SANs unless skipped if !sidecarSkipAutoSANs { // Auto-detect node IPs if clientErr != nil { fmt.Printf("Warning: failed to create Kubernetes client for node IP detection: %v\n", clientErr) } else { - nodeIPs, err := cluster.GetNodeIPs(ctx, client.Clientset) + nodeIPs, err := cluster.GetNodeIPs(ctx, clientset) if err != nil { fmt.Printf("Warning: failed to auto-detect node IPs: %v\n", err) } else { @@ -895,7 +892,7 @@ func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNam serverCertPath: serverCert.CertPEM, serverKeyPath: serverCert.KeyPEM, } - if err := trustee.UploadResources(ctx, client.Clientset, trusteeNamespace, resources); err != nil { + if err := trustee.UploadResources(ctx, clientset, trusteeNamespace, resources); err != nil { return fmt.Errorf("failed to upload server certificate to KBS: %w", err) } fmt.Printf(" - Server certificate uploaded to kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) From 37f730a1e913ab6599d6bfc8f6516351a19fb611 Mon Sep 17 00:00:00 2001 From: Pradipta Banerjee Date: Tue, 10 Feb 2026 15:38:17 +0000 Subject: [PATCH 39/39] use CommandContext for kubectl calls in trustee kbs helpers Allows the kubectl call to be cancelled Signed-off-by: Pradipta Banerjee --- pkg/trustee/kbs.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/trustee/kbs.go b/pkg/trustee/kbs.go index e52f478..09563a1 100644 --- a/pkg/trustee/kbs.go +++ b/pkg/trustee/kbs.go @@ -72,7 +72,7 @@ func WaitForKBSReady(ctx context.Context, clientset kubernetes.Interface, namesp } // #nosec G204 - namespace is from function parameter, podName is from kubectl get output - cmd := exec.Command("kubectl", "wait", "--for=condition=ready", "--timeout=120s", + cmd := exec.CommandContext(ctx, "kubectl", "wait", "--for=condition=ready", "--timeout=120s", "-n", namespace, fmt.Sprintf("pod/%s", podName)) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("pod not ready: %w\n%s", err, output) @@ -126,7 +126,7 @@ func populateSecrets(ctx context.Context, clientset kubernetes.Interface, namesp // #nosec G204 - namespace is from function parameter, tmpDir is from os.MkdirTemp, podName is from kubectl get // Use --no-preserve to avoid tar ownership errors when local files have different uid/gid than container - cmd := exec.Command("kubectl", "cp", "--no-preserve=true", "-n", namespace, + cmd := exec.CommandContext(ctx, "kubectl", "cp", "--no-preserve=true", "-n", namespace, tmpDir+"/.", podName+":"+kbsRepositoryPath+"/") output, err := cmd.CombinedOutput() if err != nil {