diff --git a/go.mod b/go.mod index 3a5f475d..249e05c9 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module github.com/deckhouse/deckhouse-cli -go 1.25.0 +go 1.25.6 require ( github.com/Masterminds/semver/v3 v3.3.1 + github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260519102525-fcde26a798e9 github.com/deckhouse/deckhouse/pkg/log v0.2.0 github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260414112803-53a5662881d9 github.com/deckhouse/virtualization/src/cli v1.5.1 @@ -25,7 +26,7 @@ require ( github.com/samber/lo v1.51.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.7 + github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 github.com/vbauerster/mpb/v8 v8.7.5 github.com/werf/3p-helm v0.0.0-20260211143448-0b619e3cc3bf @@ -46,9 +47,9 @@ require ( k8s.io/client-go v0.33.8 k8s.io/component-base v0.33.8 k8s.io/kubectl v0.33.8 - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 sigs.k8s.io/controller-runtime v0.21.0 - sigs.k8s.io/yaml v1.4.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -204,7 +205,7 @@ require ( github.com/dominikbraun/graph v0.23.0 // indirect github.com/duosecurity/duo_api_golang v0.0.0-20190308151101-6c680f768e74 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emicklei/go-restful/v3 v3.11.2 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/evanphx/json-patch v5.8.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect @@ -215,7 +216,7 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsouza/go-dockerclient v1.11.1 // indirect github.com/fvbommel/sortorder v1.1.0 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gammazero/deque v0.2.1 // indirect github.com/gammazero/workerpool v1.1.3 // indirect github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect @@ -443,7 +444,7 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/montanaflynn/stats v0.7.0 // indirect github.com/morikuni/aec v1.0.0 // indirect @@ -596,6 +597,7 @@ require ( go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.52.0 // indirect @@ -611,7 +613,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/evanphx/json-patch.v5 v5.8.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect @@ -631,12 +633,12 @@ require ( mvdan.cc/xurls v1.1.0 // indirect nhooyr.io/websocket v1.8.7 // indirect oras.land/oras-go v1.2.5 // indirect - sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.19.0 // indirect sigs.k8s.io/kustomize/kustomize/v5 v5.6.0 // indirect sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect tags.cncf.io/container-device-interface v0.8.1 // indirect tags.cncf.io/container-device-interface/specs-go v0.8.0 // indirect ) diff --git a/go.sum b/go.sum index 751cb563..f11da35a 100644 --- a/go.sum +++ b/go.sum @@ -417,6 +417,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260519102525-fcde26a798e9 h1:MffQ8Hk099o2BIED5pZD9ju5JQmlg36ghwSahw4DIQg= +github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260519102525-fcde26a798e9/go.mod h1:sSsmzJpe+mL3xEtlzae0kywYBjMS/tnw3TyaQ2X77hQ= github.com/deckhouse/deckhouse/pkg/log v0.2.0 h1:6tmZQLwNb1o/hP1gzJQBjcwfA/bubbgObovXzxq+Exo= github.com/deckhouse/deckhouse/pkg/log v0.2.0/go.mod h1:pbAxTSDcPmwyl3wwKDcEB3qdxHnRxqTV+J0K+sha8bw= github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260414112803-53a5662881d9 h1:Il2d6wB6SdgjmD5ojC48qT9eQyITuANzIFjSd0DCdUI= @@ -501,8 +503,8 @@ github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVo github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful v2.15.0+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/emicklei/go-restful/v3 v3.11.2 h1:1onLa9DcsMYO9P+CXaL0dStDqQ2EHHXLiz+BtnqkLAU= -github.com/emicklei/go-restful/v3 v3.11.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +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/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -549,8 +551,8 @@ github.com/fsouza/go-dockerclient v1.11.1 h1:i5Vk9riDxW2uP9pVS5FYkpquMTFT5lsx2pt github.com/fsouza/go-dockerclient v1.11.1/go.mod h1:UfjOOaspAq+RGh7GX1aZ0HeWWGHQWWsh+H5BgEWB3Pk= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +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/gammazero/deque v0.2.1 h1:qSdsbG6pgp6nL7A0+K/B7s12mcCY/5l5SIUpMOl+dC0= github.com/gammazero/deque v0.2.1/go.mod h1:LFroj8x4cMYCukHJDbxFCkT+r9AndaJnFMuZDV34tuU= github.com/gammazero/workerpool v1.1.3 h1:WixN4xzukFoN0XSeXF6puqEqFTl2mECI9S6W44HWy9Q= @@ -1341,8 +1343,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 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/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/mongodb-forks/digest v1.0.5 h1:EJu3wtLZcA0HCvsZpX5yuD193/sW9tHiNvrEM5apXMk= github.com/mongodb-forks/digest v1.0.5/go.mod h1:rb+EX8zotClD5Dj4NdgxnJXG9nwrlx3NWKJ8xttz1Dg= @@ -1699,8 +1702,8 @@ github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= @@ -1976,6 +1979,8 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +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/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -2483,8 +2488,8 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/evanphx/json-patch.v5 v5.8.0 h1:A8QNKkaxzza4Ubx7N23Yav3OstJhP8KYRZbk98mZsFo= gopkg.in/evanphx/json-patch.v5 v5.8.0/go.mod h1:/kvTRh1TVm5wuM6OkHxqXtE/1nUZZpihg29RtuIyfvk= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= @@ -2570,8 +2575,8 @@ k8s.io/metrics v0.33.8 h1:nZXHD9vL0Nx23bK3m+SbAUeLfx/lX/mtjJvaDwYLYLA= k8s.io/metrics v0.33.8/go.mod h1:xsza6RcjxahAtSw2S/CB9v3nhetlW2EAMrzMmamAZQQ= k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= kubevirt.io/api v1.6.2 h1:aoqZ4KsbOyDjLnuDw7H9wEgE/YTd/q5BBmYeQjJNizc= kubevirt.io/api v1.6.2/go.mod h1:p66fEy/g79x7VpgUwrkUgOoG2lYs5LQq37WM6JXMwj4= kubevirt.io/containerized-data-importer-api v1.60.3-0.20241105012228-50fbed985de9 h1:KTb8wO1Lxj220DX7d2Rdo9xovvlyWWNo3AVm2ua+1nY= @@ -2596,8 +2601,8 @@ sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytI sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/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/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ= sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o= sigs.k8s.io/kustomize/kustomize/v5 v5.6.0 h1:MWtRRDWCwQEeW2rnJTqJMuV6Agy56P53SkbVoJpN7wA= @@ -2609,12 +2614,13 @@ 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/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= tags.cncf.io/container-device-interface v0.8.1 h1:c0jN4Mt6781jD67NdPajmZlD1qrqQyov/Xfoab37lj0= tags.cncf.io/container-device-interface v0.8.1/go.mod h1:Apb7N4VdILW0EVdEMRYXIDVRZfNJZ+kmEUss2kRRQ6Y= tags.cncf.io/container-device-interface/specs-go v0.8.0 h1:QYGFzGxvYK/ZLMrjhvY0RjpUavIn4KcmRmVP/JjdBTA= diff --git a/internal/tools/pki/certs/certs.go b/internal/tools/pki/certs/certs.go new file mode 100644 index 00000000..8e3c19ee --- /dev/null +++ b/internal/tools/pki/certs/certs.go @@ -0,0 +1,258 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package certs + +import ( + "errors" + "fmt" + "io" + "io/fs" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/deckhouse/deckhouse/go_lib/controlplane/kubeconfig" + "github.com/deckhouse/deckhouse/go_lib/controlplane/pki" +) + +// CertEntry represents a non-CA certificate in the report. +type CertEntry struct { + Name string + Expires time.Time + Authority string +} + +// CAEntry represents a CA certificate in the report. +type CAEntry struct { + Name string + Expires time.Time +} + +// Report holds the result of a certificate expiration check. +type Report struct { + Certs []CertEntry + CAs []CAEntry +} + +type multiUnwrapper interface { + Unwrap() []error +} + +// BuildFullScanReport enumerates all known control-plane certificates and kubeconfig +// client certificates, returning a report split into CAs and leaf certs. +// certsDir is the PKI directory (e.g. /etc/kubernetes/pki). +// kubeconfigDir is the directory containing kubeconfig files (e.g. /etc/kubernetes). +// Callers that want the standard layout can pass filepath.Dir(certsDir). +func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) { + pkiExpirations, pkiErr := pki.ListCertificateExpirations( + pki.WithCertificatesDir(certsDir), + pki.WithIgnoreReadErrors(), + ) + kcExpirations, kcErr := kubeconfig.ListClientCertificateExpirations( + kubeconfig.WithKubeconfigDir(kubeconfigDir), + kubeconfig.WithIgnoreReadErrors(), + ) + + hasExpirations := len(pkiExpirations) > 0 || len(kcExpirations) > 0 + + err := errors.Join( + parseFullScanError("PKI certificates", certsDir, pkiErr), + parseFullScanError("kubeconfig client certificates", kubeconfigDir, kcErr), + ) + if err != nil && !hasExpirations { + return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found: %w", err) + } + if err != nil { + return nil, err + } + if !hasExpirations { + return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found in %q and %q", certsDir, kubeconfigDir) + } + + report := reportFromExpirations(pkiExpirations, kcExpirations) + + sort.Slice(report.Certs, func(i, j int) bool { + return report.Certs[i].Name < report.Certs[j].Name + }) + sort.Slice(report.CAs, func(i, j int) bool { + return report.CAs[i].Name < report.CAs[j].Name + }) + + return report, nil +} + +func reportFromExpirations(pkiExpirations []pki.CertificateExpiration, kcExpirations []kubeconfig.ClientCertificateExpiration) *Report { + report := &Report{} + + for _, exp := range pkiExpirations { + if exp.IsCA { + report.CAs = append(report.CAs, CAEntry{ + Name: pkiDisplayName(exp.Name), + Expires: exp.NotAfter, + }) + } else { + report.Certs = append(report.Certs, CertEntry{ + Name: pkiDisplayName(exp.Name), + Expires: exp.NotAfter, + Authority: pkiDisplayName(string(exp.Authority)), + }) + } + } + + for _, exp := range kcExpirations { + report.Certs = append(report.Certs, CertEntry{ + Name: kubeconfigDisplayName(exp.File), + Expires: exp.NotAfter, + Authority: string(pki.CACertName), + }) + } + + return report +} + +func parseFullScanError(subject, dir string, err error) error { + if err == nil || onlyNotExistErrors(err) { + return nil + } + + return fmt.Errorf("listing %s in %q: %w", subject, dir, err) +} + +func onlyNotExistErrors(err error) bool { + if err == nil { + return false + } + + var multiErr multiUnwrapper + if errors.As(err, &multiErr) { + for _, nestedErr := range multiErr.Unwrap() { + if !onlyNotExistErrors(nestedErr) { + return false + } + } + return true + } + + return errors.Is(err, fs.ErrNotExist) +} + +// BuildSingleFileReport inspects a single file at path. +// It tries kubeconfig parsing first; if that fails it falls back to PEM certificate +// parsing. If both parsers fail, the combined error is returned. +func BuildSingleFileReport(path string) (*Report, error) { + kcExp, kcErr := kubeconfig.GetClientCertificateExpiration(path) + if kcErr == nil { + return &Report{ + Certs: []CertEntry{{ + Name: kubeconfigDisplayName(kcExp.File), + Expires: kcExp.NotAfter, + Authority: string(pki.CACertName), + }}, + }, nil + } + + certExp, certErr := pki.GetCertificateExpiration(path) + if certErr == nil { + report := &Report{} + if certExp.IsCA { + report.CAs = []CAEntry{{Name: pkiDisplayName(certExp.Name), Expires: certExp.NotAfter}} + } else { + report.Certs = []CertEntry{{ + Name: pkiDisplayName(certExp.Name), + Expires: certExp.NotAfter, + Authority: pkiDisplayName(string(certExp.Authority)), + }} + } + return report, nil + } + + return nil, errors.Join( + fmt.Errorf("kubeconfig: %w", kcErr), + fmt.Errorf("certificate: %w", certErr), + ) +} + +// RenderReport writes the certificate expiration report to w in two sections: +// leaf certificates followed by certificate authorities. +func RenderReport(w io.Writer, report *Report) { + now := time.Now().UTC() + + if len(report.Certs) > 0 { + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + fmt.Fprintln(tw, "CERTIFICATE\tEXPIRES\tRESIDUAL TIME\tCERTIFICATE AUTHORITY") + for _, c := range report.Certs { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + c.Name, + c.Expires.UTC().Format("Jan 02, 2006 15:04 MST"), + residualTime(c.Expires, now), + c.Authority, + ) + } + tw.Flush() + } + + if len(report.CAs) > 0 { + if len(report.Certs) > 0 { + fmt.Fprintln(w) + } + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + fmt.Fprintln(tw, "CERTIFICATE AUTHORITY\tEXPIRES\tRESIDUAL TIME") + for _, ca := range report.CAs { + fmt.Fprintf(tw, "%s\t%s\t%s\n", + ca.Name, + ca.Expires.UTC().Format("Jan 02, 2006 15:04 MST"), + residualTime(ca.Expires, now), + ) + } + tw.Flush() + } +} + +// residualTime formats the duration between notAfter and now in a compact human-readable form. +// Years are computed as totalDays/365 (integer division), matching the kubeadm +// "certs check-expiration" approximation. A leap year therefore still reads as +// "1y" at 366 days, and 730 days reads as "2y" regardless of calendar years. +func residualTime(notAfter, now time.Time) string { + if !notAfter.After(now) { + return "expired" + } + d := notAfter.Sub(now) + totalDays := int(d.Hours() / 24) + if totalDays < 1 { + return "< 1 day" + } + if totalDays < 365 { + return fmt.Sprintf("%dd", totalDays) + } + years := totalDays / 365 + return fmt.Sprintf("%dy", years) +} + +// pkiDisplayName returns a display-friendly certificate name for CLI output. +// PKI inventory uses slash-separated names for nested etcd paths; the CLI renders +// them with dashes to match kubeadm-style output better. +func pkiDisplayName(name string) string { + return strings.ReplaceAll(name, "/", "-") +} + +// kubeconfigDisplayName returns a display-friendly name for a kubeconfig file: +// it strips the directory component and preserves the .conf suffix. +func kubeconfigDisplayName(file kubeconfig.File) string { + return filepath.Base(string(file)) +} diff --git a/internal/tools/pki/certs/certs_test.go b/internal/tools/pki/certs/certs_test.go new file mode 100644 index 00000000..4b0ca852 --- /dev/null +++ b/internal/tools/pki/certs/certs_test.go @@ -0,0 +1,278 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package certs + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/deckhouse/deckhouse/go_lib/controlplane/kubeconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResidualTime(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + notAfter time.Time + want string + }{ + { + name: "expired", + notAfter: now.Add(-time.Hour), + want: "expired", + }, + { + name: "less than a day", + notAfter: now.Add(23 * time.Hour), + want: "< 1 day", + }, + { + name: "one day", + notAfter: now.Add(24 * time.Hour), + want: "1d", + }, + { + name: "30 days", + notAfter: now.Add(30 * 24 * time.Hour), + want: "30d", + }, + { + name: "364 days", + notAfter: now.Add(364 * 24 * time.Hour), + want: "364d", + }, + { + name: "365 days (1 year)", + notAfter: now.Add(365 * 24 * time.Hour), + want: "1y", + }, + { + name: "9 years", + notAfter: now.Add(9 * 365 * 24 * time.Hour), + want: "9y", + }, + // The year boundary uses integer division by 365 (kubeadm-style approximation). + // A leap year (366 days) therefore still reports as "1y", and 730 days + // reports as "2y" regardless of whether those days span actual calendar years. + { + name: "leap year boundary: 366 days rounds as 1y", + notAfter: now.Add(366 * 24 * time.Hour), + want: "1y", + }, + { + name: "730 days reports as 2y (kubeadm approximation)", + notAfter: now.Add(730 * 24 * time.Hour), + want: "2y", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := residualTime(tt.notAfter, now) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRenderReport_CertsSection(t *testing.T) { + expires := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) + + report := &Report{ + Certs: []CertEntry{ + {Name: "apiserver", Expires: expires, Authority: "ca"}, + }, + } + + var buf bytes.Buffer + RenderReport(&buf, report) + out := buf.String() + + require.Contains(t, out, "CERTIFICATE") + require.Contains(t, out, "EXPIRES") + require.Contains(t, out, "RESIDUAL TIME") + require.Contains(t, out, "CERTIFICATE AUTHORITY") + require.Contains(t, out, "apiserver") + require.Contains(t, out, "ca") +} + +func TestRenderReport_CAsSection(t *testing.T) { + expires := time.Date(2035, 1, 1, 0, 0, 0, 0, time.UTC) + + report := &Report{ + CAs: []CAEntry{ + {Name: "ca", Expires: expires}, + }, + } + + var buf bytes.Buffer + RenderReport(&buf, report) + out := buf.String() + + require.Contains(t, out, "CERTIFICATE AUTHORITY") + require.Contains(t, out, "EXPIRES") + require.Contains(t, out, "ca") +} + +func TestRenderReport_BothSections(t *testing.T) { + expires := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) + + report := &Report{ + Certs: []CertEntry{ + {Name: "apiserver", Expires: expires, Authority: "ca"}, + }, + CAs: []CAEntry{ + {Name: "ca", Expires: expires}, + }, + } + + var buf bytes.Buffer + RenderReport(&buf, report) + out := buf.String() + + require.Contains(t, out, "apiserver") + require.Contains(t, out, "CERTIFICATE AUTHORITY") + require.Contains(t, out, "ca") + + // Should have a blank separator line between sections + require.Contains(t, out, "\n\n") +} + +func TestKubeconfigDisplayName(t *testing.T) { + tests := []struct { + input kubeconfig.File + want string + }{ + {kubeconfig.Admin, "admin.conf"}, + {kubeconfig.ControllerManager, "controller-manager.conf"}, + {kubeconfig.Scheduler, "scheduler.conf"}, + {kubeconfig.SuperAdmin, "super-admin.conf"}, + {kubeconfig.Kubelet, "kubelet.conf"}, + {"/etc/kubernetes/admin.conf", "admin.conf"}, + } + for _, tt := range tests { + t.Run(string(tt.input), func(t *testing.T) { + got := kubeconfigDisplayName(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestPkiDisplayName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"apiserver", "apiserver"}, + {"front-proxy-ca", "front-proxy-ca"}, + {"etcd/server", "etcd-server"}, + {"etcd/healthcheck-client", "etcd-healthcheck-client"}, + {"etcd/ca", "etcd-ca"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, pkiDisplayName(tt.input)) + }) + } +} + +func TestBuildFullScanReport_ReturnsPartialReportWhenOnlySomeArtifactsExist(t *testing.T) { + t.Parallel() + + certsDir := t.TempDir() + kubeconfigDir := t.TempDir() + + require.NoError(t, writeTestCertificate(filepath.Join(certsDir, "ca.crt"), true)) + + report, err := BuildFullScanReport(certsDir, kubeconfigDir) + require.NoError(t, err) + require.NotNil(t, report) + require.Empty(t, report.Certs) + require.Len(t, report.CAs, 1) + assert.Equal(t, "ca", report.CAs[0].Name) +} + +func TestBuildFullScanReport_ReturnsHelpfulErrorWhenNothingFound(t *testing.T) { + t.Parallel() + + certsDir := t.TempDir() + kubeconfigDir := t.TempDir() + + report, err := BuildFullScanReport(certsDir, kubeconfigDir) + require.Nil(t, report) + require.Error(t, err) + assert.ErrorContains(t, err, "no control-plane certificates or kubeconfig client certificates found") +} + +func TestBuildFullScanReport_FailsOnInvalidExistingCertificate(t *testing.T) { + t.Parallel() + + certsDir := t.TempDir() + kubeconfigDir := t.TempDir() + + require.NoError(t, writeTestCertificate(filepath.Join(certsDir, "front-proxy-ca.crt"), true)) + require.NoError(t, os.WriteFile(filepath.Join(certsDir, "ca.crt"), []byte("not a certificate"), 0o600)) + + report, err := BuildFullScanReport(certsDir, kubeconfigDir) + require.Nil(t, report) + require.Error(t, err) + assert.ErrorContains(t, err, `listing PKI certificates in "`) + assert.ErrorContains(t, err, "ca.crt") +} + +func writeTestCertificate(path string, isCA bool) error { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: filepath.Base(path), + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + IsCA: isCA, + } + + if isCA { + template.KeyUsage |= x509.KeyUsageCertSign + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + return err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + return os.WriteFile(path, certPEM, 0o600) +} diff --git a/internal/tools/pki/certs/cmd/certs.go b/internal/tools/pki/certs/cmd/certs.go new file mode 100644 index 00000000..550749b3 --- /dev/null +++ b/internal/tools/pki/certs/cmd/certs.go @@ -0,0 +1,40 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" +) + +var certsLong = templates.LongDesc(` +Manage and inspect control-plane TLS certificates. + +© Flant JSC 2026`) + +// NewCommand returns the "pki certs" group command. +func NewCommand() *cobra.Command { + certsCmd := &cobra.Command{ + Use: "certs", + Short: "Manage and inspect control-plane TLS certificates", + Long: certsLong, + } + + certsCmd.AddCommand(NewCheckCommand()) + + return certsCmd +} diff --git a/internal/tools/pki/certs/cmd/check.go b/internal/tools/pki/certs/cmd/check.go new file mode 100644 index 00000000..f9e1964f --- /dev/null +++ b/internal/tools/pki/certs/cmd/check.go @@ -0,0 +1,93 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/tools/pki/certs" +) + +const defaultCertificatesDir = "/etc/kubernetes/pki" + +var checkLong = templates.LongDesc(` +Check expiration of control-plane certificates and kubeconfig client certificates. + +Without arguments, all known control-plane certificates under --path and the +kubeconfig files under --kubeconfig-dir are checked. Output is split into two +sections – leaf certificates and certificate authorities – similar to +"kubeadm certs check-expiration". + +Missing known files are skipped in full-scan mode, so on worker or arbiter +nodes the command prints the artifacts that are actually present locally. + +--kubeconfig-dir defaults to the parent directory of --path, which matches the +standard Kubernetes layout (/etc/kubernetes/pki → /etc/kubernetes). Set it +explicitly when using a non-default directory layout. + +With a single PATH argument, only that file is inspected. The command +auto-detects whether the file is a kubeconfig or a PEM certificate. + +© Flant JSC 2026`) + +// NewCheckCommand returns the "certs check" leaf command. +func NewCheckCommand() *cobra.Command { + var certsDir string + var kubeconfigDir string + + checkCmd := &cobra.Command{ + Use: "check [PATH]", + Short: "Check expiration of control-plane certificates", + Long: checkLong, + Args: cobra.MaximumNArgs(1), + Example: " d8 tools pki certs check\n d8 tools pki certs check /etc/kubernetes/pki/apiserver.crt\n d8 tools pki certs check /etc/kubernetes/admin.conf\n d8 tools pki certs check --path /opt/k8s/pki --kubeconfig-dir /opt/k8s", + RunE: func(cmd *cobra.Command, args []string) error { + var report *certs.Report + var err error + + if len(args) == 1 { + report, err = certs.BuildSingleFileReport(args[0]) + if err != nil { + return fmt.Errorf("checking certificate %q: %w", args[0], err) + } + } else { + effectiveKubeconfigDir := kubeconfigDir + if effectiveKubeconfigDir == "" { + effectiveKubeconfigDir = filepath.Dir(certsDir) + } + report, err = certs.BuildFullScanReport(certsDir, effectiveKubeconfigDir) + if err != nil { + return fmt.Errorf("checking certificates in %q: %w", certsDir, err) + } + } + + certs.RenderReport(cmd.OutOrStdout(), report) + return nil + }, + } + + checkCmd.Flags().StringVar(&certsDir, "path", defaultCertificatesDir, + "Directory containing the PKI certificates (used in full-scan mode only)") + checkCmd.Flags().StringVar(&kubeconfigDir, "kubeconfig-dir", "", + "Directory containing kubeconfig files (full-scan mode only; defaults to the parent of --path)") + + return checkCmd +} diff --git a/internal/tools/pki/cmd/pki.go b/internal/tools/pki/cmd/pki.go new file mode 100644 index 00000000..ce331316 --- /dev/null +++ b/internal/tools/pki/cmd/pki.go @@ -0,0 +1,42 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + certscmd "github.com/deckhouse/deckhouse-cli/internal/tools/pki/certs/cmd" +) + +var pkiLong = templates.LongDesc(` +Tools for working with control-plane PKI. + +© Flant JSC 2026`) + +// NewCommand returns the "tools pki" group command. +func NewCommand() *cobra.Command { + pkiCmd := &cobra.Command{ + Use: "pki", + Short: "Tools for working with control-plane PKI", + Long: pkiLong, + } + + pkiCmd.AddCommand(certscmd.NewCommand()) + + return pkiCmd +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 0b6a39a7..d1614a8c 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -23,6 +23,7 @@ import ( farconverter "github.com/deckhouse/deckhouse-cli/internal/tools/farconverter/cmd" gostsum "github.com/deckhouse/deckhouse-cli/internal/tools/gostsum/cmd" imagedigest "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd" + pki "github.com/deckhouse/deckhouse-cli/internal/tools/pki/cmd" sigmigrate "github.com/deckhouse/deckhouse-cli/internal/tools/sigmigrate/cmd" ) @@ -44,6 +45,7 @@ func NewCommand() *cobra.Command { gostsum.NewCommand(), imagedigest.NewCommand(), sigmigrate.NewCommand(), + pki.NewCommand(), ) return toolsCmd