diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..42869db --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,150 @@ +run: + timeout: 15m + allow-parallel-runners: true + +linters: + disable-all: true + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - copyloopvar + - containedctx + - dogsled + - dupword + - durationcheck + - errcheck + - errchkjson + - gci + - goconst + - gocritic + - godot + - gofmt + - goimports + - goprintffuncname + - gosec + - gosimple + - govet + - importas + - ineffassign + - misspell + - nakedret + - nilerr + - noctx + - nolintlint + - nosprintfhostport + - prealloc + - predeclared + - revive + - rowserrcheck + - staticcheck + - stylecheck + - thelper + - typecheck + - unconvert + - unparam + - unused + - usestdlibvars + - whitespace + +linters-settings: + gci: + sections: + - "standard" + - "default" + - "prefix(github.com/balabanovds)" + custom-order: true + importas: + no-unaliased: true + goconst: + min-len: 5 + min-occurrences: 99 + lll: + line-length: 140 + revive: + rules: + # The following rules are recommended https://github.com/mgechev/revive#recommended-configuration + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + # - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + # - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + - name: unreachable-code + - name: redefines-builtin-id + # + # Rules in addition to the recommended configuration above. + # + - name: bool-literal-in-expr + - name: constant-logical-expr + gosec: + excludes: + - G307 # Deferring unsafe method "Close" on type "\*os.File" + - G108 # Profiling endpoint is automatically exposed on /debug/pprof + - G115 # integer overflow conversion int -> uint32 + gocritic: + enabled-tags: + - diagnostic + - experimental + - performance + disabled-checks: + - appendAssign + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - evalOrder + - ifElseChain + - octalLiteral + - regexpSimplify + - singleCaseSwitch + - sloppyReassign + - truncateCmp + - typeDefFirst + - unnamedResult + - unnecessaryDefer + - whyNoLint + - wrapperFunc + - rangeValCopy + - hugeParam + +issues: + exclude-rules: + - text: "at least one file in a package should have a package comment" + linters: + - stylecheck + - text: "should have a package comment, unless it's in another file for this package" + linters: + - golint + - text: "should have comment or be unexported" + linters: + - golint + - text: "don't use an underscore in package name" + linters: + - revive + - text: "should not use underscores in package names" + linters: + - stylecheck + - path: _test\.go + linters: + - gosec + - dupl + exclude-use-default: false + exclude-files: + - \.pb\.go$ + - \.pb\.validate\.go$ + - \.pb\.gw\.go$ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3ef1c3f --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +OUT_DIR=bin +PROG_NAME_PGPF=pgpf + +build-mac-arm64-pgpf: + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o ./$(OUT_DIR)/$(PROG_NAME_PGPF)_macos_arm64 main.go + +build-mac-amd64-pgpf: + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o ./$(OUT_DIR)/$(PROG_NAME_PGPF)_macos_amd64 main.go + +build-linux-amd64-pgpf: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./$(OUT_DIR)/$(PROG_NAME_PGPF)_linux_amd64 main.go + +build-windows-amd64-pgpf: + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o ./$(OUT_DIR)/$(PROG_NAME_PGPF)_windows_amd64.exe main.go + +build-all: build-mac-arm64-pgpf build-mac-amd64-pgpf build-linux-amd64-pgpf build-windows-amd64-pgpf + +lint: + golangci-lint run --max-issues-per-linter 0 --max-same-issues 0 -v ./... diff --git a/cmd/pgpf/README.md b/cmd/pgpf/README.md new file mode 100644 index 0000000..893aa4c --- /dev/null +++ b/cmd/pgpf/README.md @@ -0,0 +1,74 @@ +# pgpf + +## Назначение + +Программа предназначена для проброса портов (port-forwarding) для пода на котором развернут инстанс БД Postgres. +Опционально: запуск терминальных клиентов `psql` или `pgcli`. + +Расшифровывается pgpf как PostGres Port Forward + +## Аргументы + +- `--config` - файл с конфигурацией в формате yaml (поля конфига описаны ниже. По дефолту `$HOME/.pgpf/config.yaml`) +- `--kubeconfig` - файл с кубконфигом (по дефолту `$HOME/.kube/config`) +- `--ns` - неймспейс пода - обязательное поле, может использоваться часть + +### Конфиг приложения + +```yaml +app: + port-forward-only: + enabled: false # использовать только проброс портов + local-host: IP.AD.DR.ES # IPv4 адрес куда пробрасывать трафик (если не указано, то по умолчанию 127.0.0.1) + local-port: 0 # локальный TCP порт куда пробрасывать трафик (если не указано, то выбирается любой свободный) + use-app: + pgcli: false # использовать терминальное приложение pgcli + psql: true # использовать терминальное приложение psql +kube: + db: + pod-substring: db-cluster-pooler # подстрока в имени пода с БД для фильтрации + secret: + pod-substring: db-cluster-app # подстрока в имени секрета для фильтрации + data-fields: + password: password # имя поля, в котором лежит пароль + username: user # имя поля, в которм лежит имя пользователя + dbname: dbname # имя поля, в котором лежит название базы данных + port: port # имя поля, в котором лежит порт на которм слешает инстанс БД +``` + +Примечания: +1. Если используется только проброс портов `app.port-forward-only.enabled = true`, то использование терминальных приложений игнорируется. В выводе будет указан хост и порт которые можно будет использовать для подключения. + + При этом в файл `$HOME/.pgpass` добавляется временная запись с кредами подключения. При завершении работы эта запись удаляется. + +2. При использовании терминальных приложений, всегда используется локальный хост и любой свободный порт. Креды нигде не сохраняются, а передаются в терминальное приложение напрямую. Соответственно, терминальное приложение должно находиться в $PATH. + +3. Приложение ищет под в неймспейсе или если это часть неймспейса, то делается запрос на получение подов из всех неймспейсов, Из общего списка отфильтровывается список содержащий часть неймспейса. И в последнем уже ищется под с именем, содержащим в названии значение `kube.db.pod-substring` + Если найдено более одного пода, то приложение завершается с ошибкой (в будущем добавлю интерактив) + +4. Данные для подключения берутся из секрета. Секрет берется из неймспейса найденного пода и отфильровывается по имени, содержащем `kube.secret.pod-substring`. Если секрет не найден, приложение завершается с ошибкой. + +### Пример использования + +Терминальное приложение pgcli + +```bash +$ pgpf --kubeconfig /Users/dsbalabanov/.kube/kubeconfig_dev2_sso.config --ns backbone +Forwarding from 127.0.0.1:50159 -> 5432 +Forwarding from [::1]:50159 -> 5432 +Handling connection for 50159 +Handling connection for 50159 +Server: PostgreSQL 16.3 (Debian 16.3-1.pgdg110+1) +Version: 4.2.0 +Home: http://pgcli.com +backbone-api> +``` + +Только проброс портов + +```bash +$ pgpf --kubeconfig /Users/dsbalabanov/.kube/kubeconfig_dev2_sso.config --ns backbone +Forwarding from 127.0.0.1:50222 -> 5432 +Forwarding from [::1]:50222 -> 5432 +Connection to database backbone-api is ready, temporary data added to .pgpass. Use 127.0.0.1:50222 for connection... +``` diff --git a/cmd/pgpf/main.go b/cmd/pgpf/main.go new file mode 100644 index 0000000..1ead224 --- /dev/null +++ b/cmd/pgpf/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + + "github.com/spf13/pflag" + "golang.org/x/sync/errgroup" + + "github.com/balabanovds/pgcli/internal/config" + "github.com/balabanovds/pgcli/internal/core" + "github.com/balabanovds/pgcli/internal/kube" + "github.com/balabanovds/pgcli/internal/pg" +) + +const ( + appName = "pgpf" +) + +func main() { + ctx := context.Background() + + if err := run(ctx); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func run(ctx context.Context) error { + var defaultAppConfig string + if home := core.Homedir(); home != "" { + defaultAppConfig = filepath.Join(home, "."+appName, "config.yaml") + } + + var ( + appconfig string + kubeconfig string + namespace string + ) + + pflag.StringVar(&appconfig, "config", defaultAppConfig, "Application configuration file path") + pflag.StringVar(&kubeconfig, "kubeconfig", "", "Absolute path to the kubeconfig file") + pflag.StringVar(&namespace, "ns", "", "Namespace (required)") + pflag.Parse() + + if namespace == "" { + pflag.Usage() + os.Exit(1) + } + + appCfg, err := config.InitConfig(appconfig) + if err != nil { + return fmt.Errorf("failed to initiate application config %s: %w", appconfig, err) + } + + cl, err := kube.NewClient(kubeconfig, &appCfg.Kube) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + pods, err := cl.PodNames(ctx, namespace) + if err != nil { + return fmt.Errorf("failed to get Pods: %w", err) + } + + if len(pods) == 0 { + return fmt.Errorf("pods not found") + } + + if len(pods) != 1 { + return fmt.Errorf("expected only one pod") + } + + pod := pods[0] + secret, err := cl.GetSecret(ctx, pod.Namespace) + if err != nil { + return fmt.Errorf("failed to get secret for namespace %s: %w", pod.Namespace, err) + } + + // stopCh control the port forwarding lifecycle. When it gets closed the + // port forward will terminate + stopCh := make(chan struct{}, 1) + // readyCh communicate when the port forward is ready to get traffic + readyCh := make(chan struct{}) + + // managing termination signal from the terminal. As you can see the stopCh + // gets closed to gracefully handle its termination. + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigs + fmt.Println("Bye...") + close(stopCh) + }() + + var localport int + if appCfg.App.PortForwardOnly.Enabled && appCfg.App.PortForwardOnly.LocalPort != 0 { + localport = appCfg.App.PortForwardOnly.LocalPort + } else { + localport, err = freeTCPPort() + if err != nil { + return fmt.Errorf("failed to get free local TCP port: %w", err) + } + } + + localhost := "127.0.0.1" + if appCfg.App.PortForwardOnly.Enabled && appCfg.App.PortForwardOnly.LocalHost != "" { + localhost = appCfg.App.PortForwardOnly.LocalHost + } + + var wgerr errgroup.Group + wgerr.Go(func() error { + return cl.ForwardPort(&kube.ForwardPodRequest{ + Pod: pod, + LocalPort: localport, + PodPort: secret.Port, + Stream: core.Stream{ + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + }, + StopCh: stopCh, + ReadyCh: readyCh, + }) + }) + + <-readyCh + + if appCfg.App.PortForwardOnly.Enabled { + pgpass, err := pg.NewPgPassHandler() + if err != nil { + return fmt.Errorf("failed to create pgpass handler: %w", err) + } + defer func() { + if err = pgpass.Close(); err != nil { + fmt.Println("error occurred during closing pgpass handler", err) + } + }() + + err = pgpass.Append(secret, localhost, localport) + if err != nil { + return fmt.Errorf("failed to add new data to pgpass file: %w", err) + } + + fmt.Printf("Connection to database %s is ready, temporary data added to .pgpass. Use %s:%d for connection...", + secret.DBName, localhost, localport) + + return wgerr.Wait() + } + + wgerr.Go(func() error { + defer close(stopCh) + dsn := dsn(secret, localport) + var cmd *exec.Cmd + switch { + case appCfg.App.UseApp.PgCLI: + cmd = exec.CommandContext(ctx, "pgcli", dsn) //nolint:gosec + case appCfg.App.UseApp.Psql: + cmd = exec.CommandContext(ctx, "psql", dsn) //nolint:gosec + default: + panic("can not run unknown command") + } + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + return cmd.Run() + }) + + return wgerr.Wait() +} + +func freeTCPPort() (int, error) { + addr, err := net.ResolveTCPAddr("tcp", "localhost:0") + if err != nil { + return 0, err + } + + l, err := net.ListenTCP("tcp", addr) + if err != nil { + return 0, err + } + defer func() { + if cerr := l.Close(); cerr != nil { + fmt.Printf("failed to close socket: %s", cerr) + } + }() + + return l.Addr().(*net.TCPAddr).Port, nil +} + +func dsn(secret *kube.Secret, localport int) string { + return fmt.Sprintf("postgresql://%s:%s@localhost:%d/%s", secret.User, secret.Password, localport, secret.DBName) +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..44588fc --- /dev/null +++ b/config.yaml @@ -0,0 +1,21 @@ +app: + port-forward-only: + enabled: false + local-host: 127.0.0.1 + local-port: 0 # zero - will be selected any free TCP port + use-app: + pgcli: false + psql: true +kube: + db: + pod-substring: db-cluster-pooler + secret: + pod-substring: db-cluster-app + data-fields: + password: password + username: user + dbname: dbname + port: port + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..639e515 --- /dev/null +++ b/go.mod @@ -0,0 +1,69 @@ +module github.com/balabanovds/pgcli + +go 1.24.1 + +require ( + github.com/spf13/pflag v1.0.5 + golang.org/x/sync v0.12.0 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.33.0 + k8s.io/apimachinery v0.33.0 + k8s.io/cli-runtime v0.33.0 + k8s.io/client-go v0.33.0 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-logr/logr v1.4.2 // 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/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // 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/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/cobra v1.8.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/kustomize/api v0.19.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/yaml v1.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fd9f959 --- /dev/null +++ b/go.sum @@ -0,0 +1,205 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +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.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +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/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/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.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU= +k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM= +k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ= +k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/cli-runtime v0.33.0 h1:Lbl/pq/1o8BaIuyn+aVLdEPHVN665tBAXUePs8wjX7c= +k8s.io/cli-runtime v0.33.0/go.mod h1:QcA+r43HeUM9jXFJx7A+yiTPfCooau/iCcP1wQh4NFw= +k8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98= +k8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg= +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-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ= +sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o= +sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA= +sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +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.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6ab0ce9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,62 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Kube KubeConfig `yaml:"kube"` + App AppConfig `yaml:"app"` +} + +type KubeConfig struct { + PodDB KubeSecretDBConfig `yaml:"db"` + Secret KubeSecretDBConfig `yaml:"secret"` +} + +type KubePodDBConfig struct { + Substring string `yaml:"pod-substring"` +} + +type KubeSecretDBConfig struct { + Substring string `yaml:"pod-substring"` + DataFields struct { + Password string `yaml:"password"` + Username string `yaml:"username"` + DBName string `yaml:"dbname"` + Port string `yaml:"port"` + } `yaml:"data-fields"` +} + +type AppConfig struct { + PortForwardOnly struct { + Enabled bool `yaml:"enabled"` + LocalHost string `yaml:"local-host"` + LocalPort int `yaml:"local-port"` + } `yaml:"port-forward-only"` + UseApp struct { + PgCLI bool `yaml:"pgcli"` + Psql bool `yaml:"psql"` + } `yaml:"use-app"` +} + +func InitConfig(config string) (*Config, error) { + f, err := os.ReadFile(filepath.Clean(config)) + if err != nil { + return nil, err + } + + buf := bytes.NewReader(f) + + var cfg Config + err = yaml.NewDecoder(buf).Decode(&cfg) + if err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/internal/core/os.go b/internal/core/os.go new file mode 100644 index 0000000..209a594 --- /dev/null +++ b/internal/core/os.go @@ -0,0 +1,10 @@ +package core + +import "os" + +func Homedir() string { + if h := os.Getenv("HOME"); h != "" { + return h + } + return os.Getenv("USERPROFILE") // windows +} diff --git a/internal/core/stream.go b/internal/core/stream.go new file mode 100644 index 0000000..8356337 --- /dev/null +++ b/internal/core/stream.go @@ -0,0 +1,9 @@ +package core + +import "io" + +type Stream struct { + In io.Reader + Out io.Writer + Err io.Writer +} diff --git a/internal/kube/client.go b/internal/kube/client.go new file mode 100644 index 0000000..dcff899 --- /dev/null +++ b/internal/kube/client.go @@ -0,0 +1,176 @@ +package kube + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + + "github.com/balabanovds/pgcli/internal/config" + "github.com/balabanovds/pgcli/internal/core" +) + +type Client struct { + config *rest.Config + appConfig *config.KubeConfig + client *kubernetes.Clientset +} + +func NewClient(kubeconfig string, appConfig *config.KubeConfig) (*Client, error) { + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + config, err = defaultConfig() + if err != nil { + return nil, fmt.Errorf("failed to read Kubernetes config: %w", err) + } + } + + client, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + return &Client{ + config: config, + appConfig: appConfig, + client: client, + }, nil +} + +type NamespacedName struct { + Namespace string + Name string +} + +func (c *Client) PodNames(ctx context.Context, namespace string) ([]NamespacedName, error) { + podsList, err := c.client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get Pods for namespace %s: %w", namespace, err) + } + + pods := podsList.Items + if len(pods) == 0 { + allpods, err := c.client.CoreV1().Pods("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get Pods for namespace %s: %w", namespace, err) + } + for _, p := range allpods.Items { + if strings.Contains(p.Namespace, namespace) { + pods = append(pods, p) + } + } + } + + res := make([]NamespacedName, 0, 1) + for _, p := range pods { + if strings.Contains(p.Name, c.appConfig.PodDB.Substring) { + res = append(res, NamespacedName{ + Namespace: p.Namespace, + Name: p.Name, + }) + } + } + + return res, nil +} + +type Secret struct { + User string + Password string + DBName string + Port int +} + +func (c *Client) GetSecret(ctx context.Context, namespace string) (*Secret, error) { + secrets, err := c.client.CoreV1().Secrets(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get Pods for namespace %s: %w", namespace, err) + } + + var scr *v1.Secret + for _, s := range secrets.Items { + if strings.Contains(s.Name, c.appConfig.Secret.Substring) { + scr = &s + break + } + } + + if scr == nil { + return nil, fmt.Errorf("secret for namespace %q containing substring %q not found", namespace, c.appConfig.Secret.Substring) + } + + port, err := strconv.Atoi(string(scr.Data[c.appConfig.Secret.DataFields.Port])) + if err != nil { + port = 5432 + } + + return &Secret{ + User: string(scr.Data[c.appConfig.Secret.DataFields.Username]), + Password: string(scr.Data[c.appConfig.Secret.DataFields.Password]), + DBName: string(scr.Data[c.appConfig.Secret.DataFields.DBName]), + Port: port, + }, nil +} + +type ForwardPodRequest struct { + // Pod is the selected pod for this port forwarding + Pod NamespacedName + // LocalPort is the local port that will be selected to expose the PodPort + LocalPort int + // PodPort is the target port for the pod + PodPort int + // Steam configures where to write or read input from + Stream core.Stream + // StopCh is the channel used to manage the port forward lifecycle + StopCh <-chan struct{} + // ReadyCh communicates when the tunnel is ready to receive traffic + ReadyCh chan struct{} +} + +func (c *Client) ForwardPort(req *ForwardPodRequest) error { + // stream is used to tell the port forwarder where to place its output or + // where to expect input if needed. For the port forwarding we just need + // the output eventually + iostream := genericclioptions.IOStreams{ + In: req.Stream.In, + Out: req.Stream.Out, + ErrOut: req.Stream.Err, + } + + path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/portforward", + req.Pod.Namespace, req.Pod.Name) + hostIP := strings.TrimLeft(c.config.Host, "htps:/") + + transport, upgrader, err := spdy.RoundTripperFor(c.config) + if err != nil { + return err + } + + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, &url.URL{Scheme: "https", Path: path, Host: hostIP}) + fw, err := portforward.New(dialer, []string{fmt.Sprintf("%d:%d", req.LocalPort, req.PodPort)}, req.StopCh, req.ReadyCh, iostream.Out, iostream.ErrOut) + if err != nil { + return err + } + return fw.ForwardPorts() +} + +func defaultConfig() (*rest.Config, error) { + pathOptions := clientcmd.NewDefaultPathOptions() + config, err := clientcmd.BuildConfigFromKubeconfigGetter("", pathOptions.GetStartingConfig) + if err != nil { + return nil, fmt.Errorf("error loading REST configuration using default path options: %w", err) + } + + return config, nil +} diff --git a/internal/pg/pgpass.go b/internal/pg/pgpass.go new file mode 100644 index 0000000..2e0ed2c --- /dev/null +++ b/internal/pg/pgpass.go @@ -0,0 +1,120 @@ +package pg + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/balabanovds/pgcli/internal/core" + "github.com/balabanovds/pgcli/internal/kube" +) + +type PgPassHandler struct { + orig string + bckp string + deleteOriginal bool +} + +func NewPgPassHandler() (*PgPassHandler, error) { + if core.Homedir() == "" { + return nil, fmt.Errorf("unable to figure out your home dir") + } + + h := &PgPassHandler{} + + h.orig = filepath.Join(core.Homedir(), ".pgpass") + h.bckp = fmt.Sprintf("%s-%d", h.orig, time.Now().UnixMilli()) + if !h.fileExists(h.orig) { + _, err := os.Create(h.orig) + if err != nil { + return nil, fmt.Errorf("failed to create .pgpass file for portforward: %w", err) + } + h.deleteOriginal = true + } else { + err := h.copyFile(h.orig, h.bckp) + if err != nil { + return nil, fmt.Errorf("failed to copy original file %s to backup %s: %w", h.orig, h.bckp, err) + } + } + + return h, nil +} + +func (h *PgPassHandler) Close() error { + if err := h.deleteFile(h.orig); err != nil { + return fmt.Errorf("failed to delete file %s: %w", h.orig, err) + } + + if err := h.copyFile(h.bckp, h.orig); err != nil { + return fmt.Errorf("failed to copy file from %s to %s: %w", h.bckp, h.orig, err) + } + + if h.deleteOriginal { + if err := h.deleteFile(h.orig); err != nil { + return fmt.Errorf("failed to delete file %s: %w", h.orig, err) + } + } + + if err := h.deleteFile(h.bckp); err != nil { + return fmt.Errorf("failed to delete file %s: %w", h.bckp, err) + } + return nil +} + +func (h *PgPassHandler) Append(sec *kube.Secret, localhost string, localport int) error { + f, err := os.OpenFile(h.orig, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("failed to append data to file %s: %w", h.orig, err) + } + defer func() { + if cerr := f.Close(); cerr != nil { + fmt.Printf("failed to close file %s during appendL %s", h.orig, cerr) + } + }() + + _, err = fmt.Fprintf(f, "# added by programm pgpf\n%s:%d:%s:%s:%s\n", localhost, localport, sec.DBName, sec.User, sec.Password) + + return err +} + +func (h *PgPassHandler) copyFile(src, dst string) error { + src = filepath.Clean(src) + fsrc, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", src, err) + } + defer func() { + if cerr := fsrc.Close(); cerr != nil { + fmt.Printf("failed to close source file %s during copy: %s\n", src, cerr) + } + }() + + dst = filepath.Clean(dst) + fdst, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", dst, err) + } + defer func() { + if cerr := fdst.Close(); cerr != nil { + fmt.Printf("faled to close destination file %s during copy: %s\n", dst, cerr) + } + }() + + _, err = io.Copy(fdst, fsrc) + if err != nil { + return fmt.Errorf("failed to copy file from %s to %s: %w", src, dst, err) + } + return nil +} + +func (h *PgPassHandler) fileExists(src string) bool { + _, err := os.Stat(src) + return err == nil || errors.Is(err, os.ErrNotExist) +} + +func (h *PgPassHandler) deleteFile(src string) error { + return os.Remove(src) +}