From 1dd6f5cef82bb867c52907a35a45044c5053a674 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 17:51:57 -0500 Subject: [PATCH 01/30] refactor: restructure packages and drop unused code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package moves: - observability/ → telemetry/ - cli/terminator → cli/terminal - cli/prompter → cli/prompt - cli/releaser → cli/version Packages dropped (zero consumers across raystack projects): - observability/logger — use *slog.Logger from stdlib - observability/otelgrpc — use otelconnect - observability/otelhttpclient — use otelhttp - server/mux — replaced by new server package - db/ — projects use their own DB library - auth/oidc — incomplete, tracked in #86 - auth/audit — too simple for real use, tracked in #87 - testing/dockertestx — projects use ory/dockertest directly - cli/printer old files — rewritten in later commit Fixes in existing code: - cobra.ExactValidArgs → cobra.MatchAll (deprecated) - strings.Replace → strings.ReplaceAll - revive indent-error-flow in spa/handler.go - pkg/errors → fmt.Errorf, cli/safeexec → exec.LookPath --- auth/audit/audit.go | 122 ---- auth/audit/audit_test.go | 166 ------ auth/audit/mocks/repository.go | 43 -- auth/audit/model.go | 11 - auth/audit/repositories/dockertest_test.go | 130 ----- auth/audit/repositories/postgres.go | 83 --- auth/audit/repositories/postgres_test.go | 102 ---- auth/oidc/_example/main.go | 40 -- auth/oidc/cobra.go | 36 -- auth/oidc/redirect.html | 27 - auth/oidc/source_gsa.go | 12 - auth/oidc/source_oidc.go | 101 ---- auth/oidc/utils.go | 166 ------ cli/commander/completion.go | 2 +- cli/commander/reference.go | 21 +- cli/printer/colors.go | 125 ----- cli/printer/markdown.go | 68 --- cli/printer/progress.go | 27 - cli/printer/spinner.go | 70 --- cli/printer/structured.go | 45 -- cli/printer/table.go | 48 -- cli/printer/text.go | 117 ---- cli/prompt/prompt.go | 132 +++++ cli/prompter/prompt.go | 110 ---- cli/{terminator => terminal}/brew.go | 2 +- cli/{terminator => terminal}/browser.go | 2 +- cli/{terminator => terminal}/pager.go | 6 +- cli/{terminator => terminal}/term.go | 2 +- cli/{releaser => version}/release.go | 15 +- db/config.go | 14 - db/db.go | 92 --- db/db_test.go | 176 ------ db/migrate.go | 49 -- db/migrate_test.go | 60 -- .../1481574547_create_users_table.down.sql | 1 - .../1481574547_create_users_table.up.sql | 1 - go.mod | 137 ++--- go.sum | 528 ++++-------------- observability/logger/logger.go | 51 -- observability/logger/logrus.go | 96 ---- observability/logger/logrus_test.go | 77 --- observability/logger/noop.go | 26 - observability/logger/zap.go | 110 ---- observability/logger/zap_test.go | 110 ---- observability/otelgrpc/otelgrpc.go | 183 ------ observability/otelgrpc/otelgrpc_test.go | 51 -- observability/otelgrpc/status.go | 44 -- observability/otelgrpc/status_test.go | 45 -- observability/otelhttpclient/annotations.go | 33 -- .../otelhttpclient/http_transport.go | 121 ---- .../otelhttpclient/http_transport_test.go | 13 - server/mux/README.md | 63 --- server/mux/mux.go | 73 --- server/mux/option.go | 40 -- server/mux/serve_target.go | 55 -- server/spa/handler.go | 3 +- {observability => telemetry}/opentelemetry.go | 10 +- {observability => telemetry}/telemetry.go | 9 +- testing/dockertestx/README.md | 76 --- .../configs/cortex/single_process_cortex.yaml | 121 ---- .../configs/nginx/cortex_nginx.conf | 93 --- testing/dockertestx/cortex.go | 232 -------- testing/dockertestx/dockertestx.go | 11 - testing/dockertestx/minio.go | 175 ------ testing/dockertestx/minio_migrate.go | 129 ----- testing/dockertestx/nginx.go | 247 -------- testing/dockertestx/postgres.go | 195 ------- testing/dockertestx/spicedb.go | 178 ------ testing/dockertestx/spicedb_migrate.go | 118 ---- 69 files changed, 332 insertions(+), 5345 deletions(-) delete mode 100644 auth/audit/audit.go delete mode 100644 auth/audit/audit_test.go delete mode 100644 auth/audit/mocks/repository.go delete mode 100644 auth/audit/model.go delete mode 100644 auth/audit/repositories/dockertest_test.go delete mode 100644 auth/audit/repositories/postgres.go delete mode 100644 auth/audit/repositories/postgres_test.go delete mode 100644 auth/oidc/_example/main.go delete mode 100644 auth/oidc/cobra.go delete mode 100644 auth/oidc/redirect.html delete mode 100644 auth/oidc/source_gsa.go delete mode 100644 auth/oidc/source_oidc.go delete mode 100644 auth/oidc/utils.go delete mode 100644 cli/printer/colors.go delete mode 100644 cli/printer/markdown.go delete mode 100644 cli/printer/progress.go delete mode 100644 cli/printer/spinner.go delete mode 100644 cli/printer/structured.go delete mode 100644 cli/printer/table.go delete mode 100644 cli/printer/text.go create mode 100644 cli/prompt/prompt.go delete mode 100644 cli/prompter/prompt.go rename cli/{terminator => terminal}/brew.go (98%) rename cli/{terminator => terminal}/browser.go (98%) rename cli/{terminator => terminal}/pager.go (96%) rename cli/{terminator => terminal}/term.go (98%) rename cli/{releaser => version}/release.go (87%) delete mode 100644 db/config.go delete mode 100644 db/db.go delete mode 100644 db/db_test.go delete mode 100644 db/migrate.go delete mode 100644 db/migrate_test.go delete mode 100644 db/migrations/1481574547_create_users_table.down.sql delete mode 100644 db/migrations/1481574547_create_users_table.up.sql delete mode 100644 observability/logger/logger.go delete mode 100644 observability/logger/logrus.go delete mode 100644 observability/logger/logrus_test.go delete mode 100644 observability/logger/noop.go delete mode 100644 observability/logger/zap.go delete mode 100644 observability/logger/zap_test.go delete mode 100644 observability/otelgrpc/otelgrpc.go delete mode 100644 observability/otelgrpc/otelgrpc_test.go delete mode 100644 observability/otelgrpc/status.go delete mode 100644 observability/otelgrpc/status_test.go delete mode 100644 observability/otelhttpclient/annotations.go delete mode 100644 observability/otelhttpclient/http_transport.go delete mode 100644 observability/otelhttpclient/http_transport_test.go delete mode 100644 server/mux/README.md delete mode 100644 server/mux/mux.go delete mode 100644 server/mux/option.go delete mode 100644 server/mux/serve_target.go rename {observability => telemetry}/opentelemetry.go (93%) rename {observability => telemetry}/telemetry.go (63%) delete mode 100644 testing/dockertestx/README.md delete mode 100644 testing/dockertestx/configs/cortex/single_process_cortex.yaml delete mode 100644 testing/dockertestx/configs/nginx/cortex_nginx.conf delete mode 100644 testing/dockertestx/cortex.go delete mode 100644 testing/dockertestx/dockertestx.go delete mode 100644 testing/dockertestx/minio.go delete mode 100644 testing/dockertestx/minio_migrate.go delete mode 100644 testing/dockertestx/nginx.go delete mode 100644 testing/dockertestx/postgres.go delete mode 100644 testing/dockertestx/spicedb.go delete mode 100644 testing/dockertestx/spicedb_migrate.go diff --git a/auth/audit/audit.go b/auth/audit/audit.go deleted file mode 100644 index 0a597e3..0000000 --- a/auth/audit/audit.go +++ /dev/null @@ -1,122 +0,0 @@ -//go:generate mockery --name=repository --exported - -package audit - -import ( - "context" - "errors" - "fmt" - "time" -) - -var ( - TimeNow = time.Now - - ErrInvalidMetadata = errors.New("failed to cast existing metadata to map[string]interface{} type") -) - -type actorContextKey struct{} -type metadataContextKey struct{} - -func WithActor(ctx context.Context, actor string) context.Context { - return context.WithValue(ctx, actorContextKey{}, actor) -} - -func WithMetadata(ctx context.Context, md map[string]interface{}) (context.Context, error) { - existingMetadata := ctx.Value(metadataContextKey{}) - if existingMetadata == nil { - return context.WithValue(ctx, metadataContextKey{}, md), nil - } - - // append new metadata - mapMd, ok := existingMetadata.(map[string]interface{}) - if !ok { - return nil, ErrInvalidMetadata - } - for k, v := range md { - mapMd[k] = v - } - - return context.WithValue(ctx, metadataContextKey{}, mapMd), nil -} - -type repository interface { - Init(context.Context) error - Insert(context.Context, *Log) error -} - -type AuditOption func(*Service) - -func WithRepository(r repository) AuditOption { - return func(s *Service) { - s.repository = r - } -} - -func WithMetadataExtractor(fn func(context.Context) map[string]interface{}) AuditOption { - return func(s *Service) { - s.withMetadata = func(ctx context.Context) (context.Context, error) { - md := fn(ctx) - return WithMetadata(ctx, md) - } - } -} - -func WithActorExtractor(fn func(context.Context) (string, error)) AuditOption { - return func(s *Service) { - s.actorExtractor = fn - } -} - -func defaultActorExtractor(ctx context.Context) (string, error) { - if actor, ok := ctx.Value(actorContextKey{}).(string); ok { - return actor, nil - } - return "", nil -} - -type Service struct { - repository repository - actorExtractor func(context.Context) (string, error) - withMetadata func(context.Context) (context.Context, error) -} - -func New(opts ...AuditOption) *Service { - svc := &Service{ - actorExtractor: defaultActorExtractor, - } - for _, o := range opts { - o(svc) - } - - return svc -} - -func (s *Service) Log(ctx context.Context, action string, data interface{}) error { - if s.withMetadata != nil { - var err error - if ctx, err = s.withMetadata(ctx); err != nil { - return err - } - } - - l := &Log{ - Timestamp: TimeNow(), - Action: action, - Data: data, - } - - if md, ok := ctx.Value(metadataContextKey{}).(map[string]interface{}); ok { - l.Metadata = md - } - - if s.actorExtractor != nil { - actor, err := s.actorExtractor(ctx) - if err != nil { - return fmt.Errorf("extracting actor: %w", err) - } - l.Actor = actor - } - - return s.repository.Insert(ctx, l) -} diff --git a/auth/audit/audit_test.go b/auth/audit/audit_test.go deleted file mode 100644 index caabac9..0000000 --- a/auth/audit/audit_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package audit_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/raystack/salt/auth/audit" - "github.com/raystack/salt/auth/audit/mocks" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" -) - -type AuditTestSuite struct { - suite.Suite - - now time.Time - - mockRepository *mocks.Repository - service *audit.Service -} - -func (s *AuditTestSuite) setupTest() { - s.mockRepository = new(mocks.Repository) - s.service = audit.New( - audit.WithMetadataExtractor(func(context.Context) map[string]interface{} { - return map[string]interface{}{ - "trace_id": "test-trace-id", - "app_name": "guardian_test", - "app_version": 1, - } - }), - audit.WithRepository(s.mockRepository), - ) - - s.now = time.Now() - audit.TimeNow = func() time.Time { - return s.now - } -} - -func TestAudit(t *testing.T) { - suite.Run(t, new(AuditTestSuite)) -} - -func (s *AuditTestSuite) TestLog() { - s.Run("should insert to repository", func() { - s.setupTest() - - s.mockRepository.On("Insert", mock.Anything, &audit.Log{ - Timestamp: s.now, - Action: "action", - Actor: "user@example.com", - Data: map[string]interface{}{"foo": "bar"}, - Metadata: map[string]interface{}{ - "trace_id": "test-trace-id", - "app_name": "guardian_test", - "app_version": 1, - }, - }).Return(nil) - - ctx := context.Background() - ctx = audit.WithActor(ctx, "user@example.com") - err := s.service.Log(ctx, "action", map[string]interface{}{"foo": "bar"}) - s.NoError(err) - }) - - s.Run("actor extractor", func() { - s.Run("should use actor extractor if option given", func() { - expectedActor := "test-actor" - s.service = audit.New( - audit.WithActorExtractor(func(ctx context.Context) (string, error) { - return expectedActor, nil - }), - audit.WithRepository(s.mockRepository), - ) - - s.mockRepository.On("Insert", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - log := args.Get(1).(*audit.Log) - s.Equal(expectedActor, log.Actor) - }).Return(nil).Once() - - err := s.service.Log(context.Background(), "", nil) - s.NoError(err) - }) - - s.Run("should return error if extractor returns error", func() { - expectedError := errors.New("test error") - s.service = audit.New( - audit.WithActorExtractor(func(ctx context.Context) (string, error) { - return "", expectedError - }), - ) - - err := s.service.Log(context.Background(), "", nil) - s.ErrorIs(err, expectedError) - }) - }) - - s.Run("metadata", func() { - s.Run("should pass empty trace id if extractor not found", func() { - s.service = audit.New( - audit.WithMetadataExtractor(func(ctx context.Context) map[string]interface{} { - return map[string]interface{}{ - "app_name": "guardian_test", - "app_version": 1, - } - }), - audit.WithRepository(s.mockRepository), - ) - - s.mockRepository.On("Insert", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - l := args.Get(1).(*audit.Log) - s.IsType(map[string]interface{}{}, l.Metadata) - - md := l.Metadata.(map[string]interface{}) - s.Empty(md["trace_id"]) - s.NotEmpty(md["app_name"]) - s.NotEmpty(md["app_version"]) - }).Return(nil).Once() - - err := s.service.Log(context.Background(), "", nil) - s.NoError(err) - }) - - s.Run("should append new metadata to existing one", func() { - s.service = audit.New( - audit.WithMetadataExtractor(func(ctx context.Context) map[string]interface{} { - return map[string]interface{}{ - "existing": "foobar", - } - }), - audit.WithRepository(s.mockRepository), - ) - - expectedMetadata := map[string]interface{}{ - "existing": "foobar", - "new": "foobar", - } - s.mockRepository.On("Insert", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - log := args.Get(1).(*audit.Log) - s.Equal(expectedMetadata, log.Metadata) - }).Return(nil).Once() - - ctx, err := audit.WithMetadata(context.Background(), map[string]interface{}{ - "new": "foobar", - }) - s.Require().NoError(err) - - err = s.service.Log(ctx, "", nil) - s.NoError(err) - }) - }) - - s.Run("should return error if repository.Insert fails", func() { - s.setupTest() - - expectedError := errors.New("test error") - s.mockRepository.On("Insert", mock.Anything, mock.Anything).Return(expectedError) - - err := s.service.Log(context.Background(), "", nil) - s.ErrorIs(err, expectedError) - }) -} diff --git a/auth/audit/mocks/repository.go b/auth/audit/mocks/repository.go deleted file mode 100644 index b8f168b..0000000 --- a/auth/audit/mocks/repository.go +++ /dev/null @@ -1,43 +0,0 @@ -// Code generated by mockery v2.10.0. DO NOT EDIT. - -package mocks - -import ( - context "context" - "github.com/raystack/salt/auth/audit" - - mock "github.com/stretchr/testify/mock" -) - -// Repository is an autogenerated mock type for the repository type -type Repository struct { - mock.Mock -} - -// Init provides a mock function with given fields: _a0 -func (_m *Repository) Init(_a0 context.Context) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Insert provides a mock function with given fields: _a0, _a1 -func (_m *Repository) Insert(_a0 context.Context, _a1 *audit.Log) error { - ret := _m.Called(_a0, _a1) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *audit.Log) error); ok { - r0 = rf(_a0, _a1) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/auth/audit/model.go b/auth/audit/model.go deleted file mode 100644 index a702886..0000000 --- a/auth/audit/model.go +++ /dev/null @@ -1,11 +0,0 @@ -package audit - -import "time" - -type Log struct { - Timestamp time.Time `json:"timestamp"` - Action string `json:"action"` - Actor string `json:"actor"` - Data interface{} `json:"data"` - Metadata interface{} `json:"metadata"` -} diff --git a/auth/audit/repositories/dockertest_test.go b/auth/audit/repositories/dockertest_test.go deleted file mode 100644 index 62575ff..0000000 --- a/auth/audit/repositories/dockertest_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package repositories_test - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/raystack/salt/auth/audit/repositories" - - _ "github.com/lib/pq" - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" - "github.com/raystack/salt/observability/logger" -) - -func newTestRepository(logger logger.Logger) (*repositories.PostgresRepository, *dockertest.Pool, *dockertest.Resource, error) { - host := "localhost" - port := "5433" - user := "test_user" - password := "test_pass" - dbName := "test_db" - sslMode := "disable" - - opts := &dockertest.RunOptions{ - Repository: "postgres", - Tag: "13", - Env: []string{ - "POSTGRES_PASSWORD=" + password, - "POSTGRES_USER=" + user, - "POSTGRES_DB=" + dbName, - }, - PortBindings: map[docker.Port][]docker.PortBinding{ - "5432": { - {HostIP: "0.0.0.0", HostPort: port}, - }, - }, - } - - // uses a sensible default on windows (tcp/http) and linux/osx (socket) - pool, err := dockertest.NewPool("") - if err != nil { - return nil, nil, nil, fmt.Errorf("could not create dockertest pool: %w", err) - } - - resource, err := pool.RunWithOptions(opts, func(config *docker.HostConfig) { - config.AutoRemove = true - config.RestartPolicy = docker.RestartPolicy{Name: "no"} - }) - if err != nil { - return nil, nil, nil, fmt.Errorf("could not start resource: %w", err) - } - - port = resource.GetPort("5432/tcp") - - // attach terminal logger to container if exists - // for debugging purpose - if logger.Level() == "debug" { - logWaiter, err := pool.Client.AttachToContainerNonBlocking(docker.AttachToContainerOptions{ - Container: resource.Container.ID, - OutputStream: logger.Writer(), - ErrorStream: logger.Writer(), - Stderr: true, - Stdout: true, - Stream: true, - }) - if err != nil { - logger.Fatal("could not connect to postgres container log output", "error", err) - } - defer func() { - if err = logWaiter.Close(); err != nil { - logger.Fatal("could not close container log", "error", err) - } - - if err = logWaiter.Wait(); err != nil { - logger.Fatal("could not wait for container log to close", "error", err) - } - }() - } - - // Tell docker to hard kill the container in 120 seconds - if err := resource.Expire(120); err != nil { - return nil, nil, nil, err - } - - // exponential backoff-retry, because the application in the container might not be ready to accept connections yet - pool.MaxWait = 60 * time.Second - - var repo *repositories.PostgresRepository - time.Sleep(5 * time.Second) - if err := pool.Retry(func() error { - db, err := sql.Open("postgres", fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", host, port, user, password, dbName, sslMode)) - if err != nil { - return err - } - repo = repositories.NewPostgresRepository(db) - - return db.Ping() - }); err != nil { - return nil, nil, nil, fmt.Errorf("could not connect to docker: %w", err) - } - - if err := setup(repo); err != nil { - logger.Fatal("failed to setup and migrate DB", "error", err) - } - return repo, pool, resource, nil -} - -func setup(repo *repositories.PostgresRepository) error { - var queries = []string{ - "DROP SCHEMA public CASCADE", - "CREATE SCHEMA public", - } - for _, query := range queries { - repo.DB().Exec(query) - } - - if err := repo.Init(context.Background()); err != nil { - return err - } - - return nil -} - -func purgeTestDocker(pool *dockertest.Pool, resource *dockertest.Resource) error { - if err := pool.Purge(resource); err != nil { - return fmt.Errorf("could not purge resource: %w", err) - } - return nil -} diff --git a/auth/audit/repositories/postgres.go b/auth/audit/repositories/postgres.go deleted file mode 100644 index bc9ac8b..0000000 --- a/auth/audit/repositories/postgres.go +++ /dev/null @@ -1,83 +0,0 @@ -package repositories - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "github.com/raystack/salt/auth/audit" - - "github.com/jmoiron/sqlx/types" -) - -type AuditModel struct { - Timestamp time.Time `db:"timestamp"` - Action string `db:"action"` - Actor string `db:"actor"` - Data types.NullJSONText `db:"data"` - Metadata types.NullJSONText `db:"metadata"` -} - -type PostgresRepository struct { - db *sql.DB -} - -func NewPostgresRepository(db *sql.DB) *PostgresRepository { - return &PostgresRepository{db} -} - -func (r *PostgresRepository) DB() *sql.DB { - return r.db -} - -func (r *PostgresRepository) Init(ctx context.Context) error { - sql := ` - CREATE TABLE IF NOT EXISTS audit_logs ( - timestamp TIMESTAMP WITH TIME ZONE NOT NULL, - action TEXT NOT NULL, - actor TEXT NOT NULL, - data JSONB NOT NULL, - metadata JSONB NOT NULL - ); - - CREATE INDEX IF NOT EXISTS audit_logs_timestamp_idx ON audit_logs (timestamp); - CREATE INDEX IF NOT EXISTS audit_logs_action_idx ON audit_logs (action); - CREATE INDEX IF NOT EXISTS audit_logs_actor_idx ON audit_logs (actor); - ` - if _, err := r.db.ExecContext(ctx, sql); err != nil { - return fmt.Errorf("migrating audit model to postgres db: %w", err) - } - return nil -} - -func (r *PostgresRepository) Insert(ctx context.Context, l *audit.Log) error { - m := &AuditModel{ - Timestamp: l.Timestamp, - Action: l.Action, - Actor: l.Actor, - } - - if l.Data != nil { - data, err := json.Marshal(l.Data) - if err != nil { - return fmt.Errorf("marshalling data: %w", err) - } - m.Data = types.NullJSONText{JSONText: data, Valid: true} - } - - if l.Metadata != nil { - metadata, err := json.Marshal(l.Metadata) - if err != nil { - return fmt.Errorf("marshalling metadata: %w", err) - } - m.Metadata = types.NullJSONText{JSONText: metadata, Valid: true} - } - - if _, err := r.db.ExecContext(ctx, "INSERT INTO audit_logs (timestamp, action, actor, data, metadata) VALUES ($1, $2, $3, $4, $5)", m.Timestamp, m.Action, m.Actor, m.Data, m.Metadata); err != nil { - return fmt.Errorf("inserting to db: %w", err) - } - - return nil -} diff --git a/auth/audit/repositories/postgres_test.go b/auth/audit/repositories/postgres_test.go deleted file mode 100644 index 2c6536c..0000000 --- a/auth/audit/repositories/postgres_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package repositories_test - -import ( - "context" - "testing" - "time" - - "github.com/raystack/salt/auth/audit" - "github.com/raystack/salt/auth/audit/repositories" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/jmoiron/sqlx/types" - "github.com/raystack/salt/observability/logger" - "github.com/stretchr/testify/suite" -) - -type PostgresRepositoryTestSuite struct { - suite.Suite - - repository *repositories.PostgresRepository -} - -func TestPostgresRepository(t *testing.T) { - suite.Run(t, new(PostgresRepositoryTestSuite)) -} - -func (s *PostgresRepositoryTestSuite) SetupSuite() { - var err error - repository, pool, dockerResource, err := newTestRepository(logger.NewLogrus()) - if err != nil { - s.T().Fatal(err) - } - s.repository = repository - - s.T().Cleanup(func() { - if err := s.repository.DB().Close(); err != nil { - s.T().Fatal(err) - } - if err := purgeTestDocker(pool, dockerResource); err != nil { - s.T().Fatal(err) - } - }) -} - -func (s *PostgresRepositoryTestSuite) TestInsert() { - s.Run("should insert record to db", func() { - l := &audit.Log{ - Timestamp: time.Now(), - Action: "test-action", - Actor: "user@example.com", - Data: types.NullJSONText{ - JSONText: []byte(`{"test": "data"}`), - Valid: true, - }, - Metadata: types.NullJSONText{ - JSONText: []byte(`{"test": "metadata"}`), - Valid: true, - }, - } - - err := s.repository.Insert(context.Background(), l) - s.Require().NoError(err) - - rows, err := s.repository.DB().Query("SELECT * FROM audit_logs") - var actualResult repositories.AuditModel - for rows.Next() { - err := rows.Scan(&actualResult.Timestamp, &actualResult.Action, &actualResult.Actor, &actualResult.Data, &actualResult.Metadata) - s.Require().NoError(err) - } - - s.NoError(err) - s.NotNil(actualResult) - if diff := cmp.Diff(l.Timestamp, actualResult.Timestamp, cmpopts.EquateApproxTime(time.Microsecond)); diff != "" { - s.T().Errorf("result not match, diff: %v", diff) - } - s.Equal(l.Action, actualResult.Action) - s.Equal(l.Actor, actualResult.Actor) - s.Equal(l.Data, actualResult.Data) - s.Equal(l.Metadata, actualResult.Metadata) - }) - - s.Run("should return error if data marshalling returns error", func() { - l := &audit.Log{ - Data: make(chan int), - } - - err := s.repository.Insert(context.Background(), l) - s.EqualError(err, "marshalling data: json: unsupported type: chan int") - }) - - s.Run("should return error if metadata marshalling returns error", func() { - l := &audit.Log{ - Metadata: map[string]interface{}{ - "foo": make(chan int), - }, - } - - err := s.repository.Insert(context.Background(), l) - s.EqualError(err, "marshalling metadata: json: unsupported type: chan int") - }) -} diff --git a/auth/oidc/_example/main.go b/auth/oidc/_example/main.go deleted file mode 100644 index 8ebcf35..0000000 --- a/auth/oidc/_example/main.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "encoding/json" - "github.com/raystack/salt/auth/oidc" - "log" - "os" - "strings" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" -) - -func main() { - cfg := &oauth2.Config{ - ClientID: os.Getenv("CLIENT_ID"), - ClientSecret: os.Getenv("CLIENT_SECRET"), - Endpoint: google.Endpoint, - RedirectURL: "http://localhost:5454", - Scopes: strings.Split(os.Getenv("OIDC_SCOPES"), ","), - } - aud := os.Getenv("OIDC_AUDIENCE") - keyFile := os.Getenv("GOOGLE_SERVICE_ACCOUNT") - - onTokenOrErr := func(t *oauth2.Token, err error) { - if err != nil { - log.Fatalf("oidc login failed: %v", err) - } - - _ = json.NewEncoder(os.Stdout).Encode(map[string]interface{}{ - "token_type": t.TokenType, - "access_token": t.AccessToken, - "expiry": t.Expiry, - "refresh_token": t.RefreshToken, - "id_token": t.Extra("id_token"), - }) - } - - _ = oidc.LoginCmd(cfg, aud, keyFile, onTokenOrErr).Execute() -} diff --git a/auth/oidc/cobra.go b/auth/oidc/cobra.go deleted file mode 100644 index b722912..0000000 --- a/auth/oidc/cobra.go +++ /dev/null @@ -1,36 +0,0 @@ -package oidc - -import ( - "context" - "os/signal" - "syscall" - - "github.com/spf13/cobra" - "golang.org/x/oauth2" -) - -func LoginCmd(cfg *oauth2.Config, aud, keyFilePath string, onTokenOrErr func(t *oauth2.Token, err error)) *cobra.Command { - cmd := &cobra.Command{ - Use: "login", - Short: "Login with your Google account.", - Run: func(cmd *cobra.Command, args []string) { - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - var ts oauth2.TokenSource - if keyFilePath != "" { - var err error - ts, err = NewGoogleServiceAccountTokenSource(ctx, keyFilePath, aud) - if err != nil { - onTokenOrErr(nil, err) - return - } - } else { - ts = NewTokenSource(ctx, cfg, aud) - } - onTokenOrErr(ts.Token()) - }, - } - - return cmd -} diff --git a/auth/oidc/redirect.html b/auth/oidc/redirect.html deleted file mode 100644 index 81821e5..0000000 --- a/auth/oidc/redirect.html +++ /dev/null @@ -1,27 +0,0 @@ - - - Login - - - -
-

✅ Done

-

It is safe to close this window now.

-

Go back to the console to continue.

-
- - diff --git a/auth/oidc/source_gsa.go b/auth/oidc/source_gsa.go deleted file mode 100644 index a4b9b41..0000000 --- a/auth/oidc/source_gsa.go +++ /dev/null @@ -1,12 +0,0 @@ -package oidc - -import ( - "context" - - "golang.org/x/oauth2" - "google.golang.org/api/idtoken" -) - -func NewGoogleServiceAccountTokenSource(ctx context.Context, keyFile, aud string) (oauth2.TokenSource, error) { - return idtoken.NewTokenSource(ctx, aud, idtoken.WithCredentialsFile(keyFile)) -} diff --git a/auth/oidc/source_oidc.go b/auth/oidc/source_oidc.go deleted file mode 100644 index 720c4b0..0000000 --- a/auth/oidc/source_oidc.go +++ /dev/null @@ -1,101 +0,0 @@ -package oidc - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - - "golang.org/x/oauth2" -) - -const ( - // Values from OpenID Connect. - scopeOpenID = "openid" - audienceKey = "audience" - - // Values used in PKCE implementation. - // Refer https://www.rfc-editor.org/rfc/rfc7636 - pkceS256 = "S256" - codeVerifierLen = 32 - codeChallengeKey = "code_challenge" - codeVerifierKey = "code_verifier" - codeChallengeMethodKey = "code_challenge_method" -) - -func NewTokenSource(ctx context.Context, conf *oauth2.Config, audience string) oauth2.TokenSource { - conf.Scopes = append(conf.Scopes, scopeOpenID) - return &authHandlerSource{ - ctx: ctx, - config: conf, - audience: audience, - } -} - -type authHandlerSource struct { - ctx context.Context - config *oauth2.Config - audience string -} - -func (source *authHandlerSource) Token() (*oauth2.Token, error) { - stateBytes, err := randomBytes(10) - if err != nil { - return nil, err - } - actualState := string(stateBytes) - - codeVerifier, codeChallenge, challengeMethod, err := newPKCEParams() - if err != nil { - return nil, err - } - - // Step 1. Send user to authorization page for obtaining consent. - url := source.config.AuthCodeURL(actualState, - oauth2.SetAuthURLParam(audienceKey, source.audience), - oauth2.SetAuthURLParam(codeChallengeKey, codeChallenge), - oauth2.SetAuthURLParam(codeChallengeMethodKey, challengeMethod), - ) - - code, receivedState, err := browserAuthzHandler(source.ctx, source.config.RedirectURL, url) - if err != nil { - return nil, err - } else if receivedState != actualState { - return nil, errors.New("state received in redirection does not match") - } - - // Step 2. Exchange code-grant for tokens (access_token, refresh_token, id_token). - tok, err := source.config.Exchange(source.ctx, code, - oauth2.SetAuthURLParam(audienceKey, source.audience), - oauth2.SetAuthURLParam(codeVerifierKey, codeVerifier), - ) - if err != nil { - return nil, err - } - - idToken, ok := tok.Extra("id_token").(string) - if !ok { - return nil, errors.New("id_token not found in token response") - } - tok.AccessToken = idToken - - return tok, nil -} - -// newPKCEParams generates parameters for 'Proof Key for Code Exchange'. -// Refer https://www.rfc-editor.org/rfc/rfc7636#section-4.2 -func newPKCEParams() (verifier, challenge, method string, err error) { - // generate 'verifier' string. - verifierBytes, err := randomBytes(codeVerifierLen) - if err != nil { - return "", "", "", fmt.Errorf("failed to generate random bytes: %v", err) - } - verifier = encode(verifierBytes) - - // generate S256 challenge. - h := sha256.New() - h.Write([]byte(verifier)) - challenge = encode(h.Sum(nil)) - - return verifier, challenge, pkceS256, nil -} diff --git a/auth/oidc/utils.go b/auth/oidc/utils.go deleted file mode 100644 index 87abef1..0000000 --- a/auth/oidc/utils.go +++ /dev/null @@ -1,166 +0,0 @@ -package oidc - -import ( - "context" - "crypto/rand" - _ "embed" // for embedded html - "encoding/base64" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os/exec" - "runtime" - "strings" -) - -const ( - codeParam = "code" - stateParam = "state" - - errParam = "error" - errDescParam = "error_description" -) - -//go:embed redirect.html -var callbackResponsePage string - -func encode(msg []byte) string { - encoded := base64.StdEncoding.EncodeToString(msg) - encoded = strings.Replace(encoded, "+", "-", -1) - encoded = strings.Replace(encoded, "/", "_", -1) - encoded = strings.Replace(encoded, "=", "", -1) - return encoded -} - -// https://tools.ietf.org/html/rfc7636#section-4.1) -func randomBytes(length int) ([]byte, error) { - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - const csLen = byte(len(charset)) - output := make([]byte, 0, length) - for { - buf := make([]byte, length) - if _, err := io.ReadFull(rand.Reader, buf); err != nil { - return nil, fmt.Errorf("failed to read random bytes: %v", err) - } - for _, b := range buf { - // Avoid bias by using a value range that's a multiple of 62 - if b < (csLen * 4) { - output = append(output, charset[b%csLen]) - - if len(output) == length { - return output, nil - } - } - } - } -} - -func browserAuthzHandler(ctx context.Context, redirectURL, authCodeURL string) (code string, state string, err error) { - if err := openURL(authCodeURL); err != nil { - return "", "", err - } - - u, err := url.Parse(redirectURL) - if err != nil { - return "", "", err - } - - code, state, err = waitForCallback(ctx, fmt.Sprintf(":%s", u.Port())) - if err != nil { - return "", "", err - } - return code, state, nil -} - -func waitForCallback(ctx context.Context, addr string) (code, state string, err error) { - var cb struct { - code string - state string - err error - } - - stopCh := make(chan struct{}) - srv := &http.Server{ - Addr: addr, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cb.code, cb.state, cb.err = parseCallbackRequest(r) - - w.WriteHeader(http.StatusOK) - w.Header().Set("content-type", "text/html") - _, _ = w.Write([]byte(callbackResponsePage)) - - // try to flush to ensure the page is shown to user before we close - // the server. - if fl, ok := w.(http.Flusher); ok { - fl.Flush() - } - - close(stopCh) - }), - } - - go func() { - select { - case <-stopCh: - _ = srv.Close() - - case <-ctx.Done(): - cb.err = ctx.Err() - _ = srv.Close() - } - }() - - if serveErr := srv.ListenAndServe(); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { - return "", "", serveErr - } - return cb.code, cb.state, cb.err -} - -func parseCallbackRequest(r *http.Request) (code string, state string, err error) { - if err = r.ParseForm(); err != nil { - return "", "", err - } - - state = r.Form.Get(stateParam) - if state == "" { - return "", "", errors.New("missing state parameter") - } - - if errorCode := r.Form.Get(errParam); errorCode != "" { - // Got error from provider. Passing through. - return "", "", fmt.Errorf("%s: %s", errorCode, r.Form.Get(errDescParam)) - } - - code = r.Form.Get(codeParam) - if code == "" { - return "", "", errors.New("missing code parameter") - } - - return code, state, nil -} - -// openURL opens the specified URL in the default application registered for -// the URL scheme. -func openURL(url string) error { - var cmd string - var args []string - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start"} - // If we don't escape &, cmd will ignore everything after the first &. - url = strings.Replace(url, "&", "^&", -1) - - case "darwin": - cmd = "open" - - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - } - - args = append(args, url) - return exec.Command(cmd, args...).Start() -} diff --git a/cli/commander/completion.go b/cli/commander/completion.go index 08a0ed8..5042826 100644 --- a/cli/commander/completion.go +++ b/cli/commander/completion.go @@ -23,7 +23,7 @@ func (m *Manager) addCompletionCommand() { Long: summary, DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, - Args: cobra.ExactValidArgs(1), + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": diff --git a/cli/commander/reference.go b/cli/commander/reference.go index aea327f..11cba22 100644 --- a/cli/commander/reference.go +++ b/cli/commander/reference.go @@ -6,8 +6,6 @@ import ( "io" "strings" - "github.com/raystack/salt/cli/printer" - "github.com/spf13/cobra" ) @@ -34,23 +32,8 @@ func (m *Manager) addReferenceCommand() { // runReferenceCommand handles the output generation for the `reference` command. // It renders the documentation either as plain markdown or with ANSI color. func (m *Manager) runReferenceCommand(isPlain *bool) func(cmd *cobra.Command, args []string) { - return func(cmd *cobra.Command, args []string) { - var ( - output string - err error - ) - - if *isPlain { - output = cmd.Long - } else { - output, err = printer.Markdown(cmd.Long) - if err != nil { - fmt.Println("Error generating markdown:", err) - return - } - } - - fmt.Print(output) + return func(cmd *cobra.Command, _ []string) { + fmt.Print(cmd.Long) } } diff --git a/cli/printer/colors.go b/cli/printer/colors.go deleted file mode 100644 index c5fc0e1..0000000 --- a/cli/printer/colors.go +++ /dev/null @@ -1,125 +0,0 @@ -package printer - -import ( - "fmt" - - "github.com/muesli/termenv" -) - -var tp = termenv.EnvColorProfile() - -// Theme defines a collection of colors for terminal outputs. -type Theme struct { - Green termenv.Color - Yellow termenv.Color - Cyan termenv.Color - Red termenv.Color - Grey termenv.Color - Blue termenv.Color - Magenta termenv.Color -} - -var themes = map[string]Theme{ - "light": { - Green: tp.Color("#005F00"), - Yellow: tp.Color("#FFAF00"), - Cyan: tp.Color("#0087FF"), - Red: tp.Color("#D70000"), - Grey: tp.Color("#303030"), - Blue: tp.Color("#000087"), - Magenta: tp.Color("#AF00FF"), - }, - "dark": { - Green: tp.Color("#A8CC8C"), - Yellow: tp.Color("#DBAB79"), - Cyan: tp.Color("#66C2CD"), - Red: tp.Color("#E88388"), - Grey: tp.Color("#B9BFCA"), - Blue: tp.Color("#71BEF2"), - Magenta: tp.Color("#D290E4"), - }, -} - -// NewTheme initializes a Theme based on the terminal background (light or dark). -func NewTheme() Theme { - if !termenv.HasDarkBackground() { - return themes["light"] - } - return themes["dark"] -} - -var theme = NewTheme() - -// formatColorize applies the given color to the formatted text. -func formatColorize(color termenv.Color, t string, args ...interface{}) string { - return colorize(color, fmt.Sprintf(t, args...)) -} - -func Green(t ...string) string { - return colorize(theme.Green, t...) -} - -func Greenf(t string, args ...interface{}) string { - return formatColorize(theme.Green, t, args...) -} - -func Yellow(t ...string) string { - return colorize(theme.Yellow, t...) -} - -func Yellowf(t string, args ...interface{}) string { - return formatColorize(theme.Yellow, t, args...) -} - -func Cyan(t ...string) string { - return colorize(theme.Cyan, t...) -} - -func Cyanf(t string, args ...interface{}) string { - return formatColorize(theme.Cyan, t, args...) -} - -func Red(t ...string) string { - return colorize(theme.Red, t...) -} - -func Redf(t string, args ...interface{}) string { - return formatColorize(theme.Red, t, args...) -} - -func Grey(t ...string) string { - return colorize(theme.Grey, t...) -} - -func Greyf(t string, args ...interface{}) string { - return formatColorize(theme.Grey, t, args...) -} - -func Blue(t ...string) string { - return colorize(theme.Blue, t...) -} - -func Bluef(t string, args ...interface{}) string { - return formatColorize(theme.Blue, t, args...) -} - -func Magenta(t ...string) string { - return colorize(theme.Magenta, t...) -} - -func Magentaf(t string, args ...interface{}) string { - return formatColorize(theme.Magenta, t, args...) -} - -func Icon(name string) string { - icons := map[string]string{"failure": "✘", "success": "✔", "info": "ℹ", "warning": "⚠"} - if icon, exists := icons[name]; exists { - return icon - } - return "" -} - -// colorize applies the given color to the text. -func colorize(color termenv.Color, t ...string) string { - return termenv.String(t...).Foreground(color).String() -} diff --git a/cli/printer/markdown.go b/cli/printer/markdown.go deleted file mode 100644 index aafb98e..0000000 --- a/cli/printer/markdown.go +++ /dev/null @@ -1,68 +0,0 @@ -package printer - -import ( - "strings" - - "github.com/charmbracelet/glamour" -) - -// RenderOpts is a type alias for a slice of glamour.TermRendererOption, -// representing the rendering options for the markdown renderer. -type RenderOpts []glamour.TermRendererOption - -// This ensures the rendered markdown has no extra indentation or margins, providing a compact view. -func withoutIndentation() glamour.TermRendererOption { - overrides := []byte(` - { - "document": { - "margin": 0 - }, - "code_block": { - "margin": 0 - } - }`) - - return glamour.WithStylesFromJSONBytes(overrides) -} - -// withoutWrap ensures the rendered markdown does not wrap lines, useful for wide terminals. -func withoutWrap() glamour.TermRendererOption { - return glamour.WithWordWrap(0) -} - -// render applies the given rendering options to the provided markdown text. -func render(text string, opts RenderOpts) (string, error) { - // Ensure input text uses consistent line endings. - text = strings.ReplaceAll(text, "\r\n", "\n") - - tr, err := glamour.NewTermRenderer(opts...) - if err != nil { - return "", err - } - - return tr.Render(text) -} - -// Markdown renders the given markdown text with default options. -func Markdown(text string) (string, error) { - opts := RenderOpts{ - glamour.WithAutoStyle(), // Automatically determine styling based on terminal settings. - glamour.WithEmoji(), // Enable emoji rendering. - withoutIndentation(), // Disable indentation for a compact view. - withoutWrap(), // Disable word wrapping. - } - - return render(text, opts) -} - -// MarkdownWithWrap renders the given markdown text with a specified word wrapping width. -func MarkdownWithWrap(text string, wrap int) (string, error) { - opts := RenderOpts{ - glamour.WithAutoStyle(), // Automatically determine styling based on terminal settings. - glamour.WithEmoji(), // Enable emoji rendering. - glamour.WithWordWrap(wrap), // Enable word wrapping with the specified width. - withoutIndentation(), // Disable indentation for a compact view. - } - - return render(text, opts) -} diff --git a/cli/printer/progress.go b/cli/printer/progress.go deleted file mode 100644 index da6e1ef..0000000 --- a/cli/printer/progress.go +++ /dev/null @@ -1,27 +0,0 @@ -package printer - -import ( - "github.com/schollz/progressbar/v3" -) - -// Progress creates and returns a progress bar for tracking the progress of an operation. -// -// Parameters: -// - max: The maximum value of the progress bar, indicating 100% completion. -// - description: A brief description of the task associated with the progress bar. - -// Example Usage: -// -// bar := printer.Progress(100, "Downloading files") -// for i := 0; i < 100; i++ { -// bar.Add(1) // Increment progress by 1. -// } -func Progress(max int, description string) *progressbar.ProgressBar { - bar := progressbar.NewOptions( - max, - progressbar.OptionEnableColorCodes(true), - progressbar.OptionSetDescription(description), - progressbar.OptionShowCount(), - ) - return bar -} diff --git a/cli/printer/spinner.go b/cli/printer/spinner.go deleted file mode 100644 index 6a66d0d..0000000 --- a/cli/printer/spinner.go +++ /dev/null @@ -1,70 +0,0 @@ -package printer - -import ( - "time" - - "github.com/briandowns/spinner" - "github.com/raystack/salt/cli/terminator" -) - -// Indicator represents a terminal spinner used for indicating progress or ongoing operations. -type Indicator struct { - spinner *spinner.Spinner // The spinner instance. -} - -// Stop halts the spinner animation. -// -// This method ensures the spinner is stopped gracefully. If the spinner is nil (e.g., when the -// terminal does not support TTY), the method does nothing. -// -// Example Usage: -// -// indicator := printer.Spin("Loading") -// // Perform some operation... -// indicator.Stop() -func (s *Indicator) Stop() { - if s.spinner == nil { - return - } - s.spinner.Stop() -} - -// Spin creates and starts a terminal spinner to indicate an ongoing operation. -// -// The spinner uses a predefined character set and updates at a fixed interval. It automatically -// disables itself if the terminal does not support TTY. -// -// Parameters: -// - label: A string to prefix the spinner (e.g., "Loading"). -// -// Returns: -// - An *Indicator instance that manages the spinner lifecycle. -// -// Example Usage: -// -// indicator := printer.Spin("Processing data") -// // Perform some long-running operation... -// indicator.Stop() -func Spin(label string) *Indicator { - // Predefined spinner character set (dots style). - set := spinner.CharSets[11] - - // Check if the terminal supports TTY; if not, return a no-op Indicator. - if !terminator.IsTTY() { - return &Indicator{} - } - - // Create a new spinner instance with a 120ms update interval and cyan color. - s := spinner.New(set, 120*time.Millisecond, spinner.WithColor("fgCyan")) - - // Add a label prefix if provided. - if label != "" { - s.Prefix = label + " " - } - - // Start the spinner animation. - s.Start() - - // Return the Indicator wrapping the spinner instance. - return &Indicator{s} -} diff --git a/cli/printer/structured.go b/cli/printer/structured.go deleted file mode 100644 index 92a6071..0000000 --- a/cli/printer/structured.go +++ /dev/null @@ -1,45 +0,0 @@ -package printer - -import ( - "encoding/json" - "fmt" - - "gopkg.in/yaml.v3" -) - -// YAML prints the given data in YAML format. -func YAML(data interface{}) error { - return File(data, "yaml") -} - -// JSON prints the given data in JSON format. -func JSON(data interface{}) error { - return File(data, "json") -} - -// PrettyJSON prints the given data in pretty-printed JSON format. -func PrettyJSON(data interface{}) error { - return File(data, "prettyjson") -} - -// File marshals and prints the given data in the specified format. -func File(data interface{}, format string) (err error) { - var output []byte - switch format { - case "yaml": - output, err = yaml.Marshal(data) - case "json": - output, err = json.Marshal(data) - case "prettyjson": - output, err = json.MarshalIndent(data, "", "\t") - default: - return fmt.Errorf("unknown format: %v", format) - } - - if err != nil { - return err - } - - fmt.Println(string(output)) - return nil -} diff --git a/cli/printer/table.go b/cli/printer/table.go deleted file mode 100644 index 8ac10c6..0000000 --- a/cli/printer/table.go +++ /dev/null @@ -1,48 +0,0 @@ -package printer - -import ( - "io" - - "github.com/olekukonko/tablewriter" -) - -// Table renders a terminal-friendly table to the provided writer. -// -// Create a table with customized formatting and styles, -// suitable for displaying data in CLI applications. -// -// Parameters: -// - target: The `io.Writer` where the table will be written (e.g., os.Stdout). -// - rows: A 2D slice of strings representing the table rows and columns. -// Each inner slice represents a single row, with its elements as column values. -// -// Example Usage: -// -// rows := [][]string{ -// {"ID", "Name", "Age"}, -// {"1", "Alice", "30"}, -// {"2", "Bob", "25"}, -// } -// printer.Table(os.Stdout, rows) -// -// Behavior: -// - Disables text wrapping for better terminal rendering. -// - Aligns headers and rows to the left. -// - Removes borders and separators for a clean look. -// - Formats the table using tab padding for better alignment in terminals. -func Table(target io.Writer, rows [][]string) { - table := tablewriter.NewWriter(target) - table.SetAutoWrapText(false) - table.SetAutoFormatHeaders(true) - table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) - table.SetAlignment(tablewriter.ALIGN_LEFT) - table.SetCenterSeparator("") - table.SetColumnSeparator("") - table.SetRowSeparator("") - table.SetHeaderLine(false) - table.SetBorder(false) - table.SetTablePadding("\t") - table.SetNoWhiteSpace(true) - table.AppendBulk(rows) - table.Render() -} diff --git a/cli/printer/text.go b/cli/printer/text.go deleted file mode 100644 index 49a0c58..0000000 --- a/cli/printer/text.go +++ /dev/null @@ -1,117 +0,0 @@ -package printer - -import ( - "fmt" - - "github.com/muesli/termenv" -) - -// Success prints the given message(s) in green to indicate success. -func Success(t ...string) { - printWithColor(Green, t...) -} - -// Successln prints the given message(s) in green with a newline. -func Successln(t ...string) { - printWithColorln(Green, t...) -} - -// Successf formats and prints the success message in green. -func Successf(t string, args ...interface{}) { - printWithColorf(Greenf, t, args...) -} - -// Warning prints the given message(s) in yellow to indicate a warning. -func Warning(t ...string) { - printWithColor(Yellow, t...) -} - -// Warningln prints the given message(s) in yellow with a newline. -func Warningln(t ...string) { - printWithColorln(Yellow, t...) -} - -// Warningf formats and prints the warning message in yellow. -func Warningf(t string, args ...interface{}) { - printWithColorf(Yellowf, t, args...) -} - -// Error prints the given message(s) in red to indicate an error. -func Error(t ...string) { - printWithColor(Red, t...) -} - -// Errorln prints the given message(s) in red with a newline. -func Errorln(t ...string) { - printWithColorln(Red, t...) -} - -// Errorf formats and prints the error message in red. -func Errorf(t string, args ...interface{}) { - printWithColorf(Redf, t, args...) -} - -// Info prints the given message(s) in cyan to indicate informational messages. -func Info(t ...string) { - printWithColor(Cyan, t...) -} - -// Infoln prints the given message(s) in cyan with a newline. -func Infoln(t ...string) { - printWithColorln(Cyan, t...) -} - -// Infof formats and prints the informational message in cyan. -func Infof(t string, args ...interface{}) { - printWithColorf(Cyanf, t, args...) -} - -// Bold prints the given message(s) in bold style. -func Bold(t ...string) string { - return termenv.String(t...).Bold().String() -} - -// Boldln prints the given message(s) in bold style with a newline. -func Boldln(t ...string) { - fmt.Println(Bold(t...)) -} - -// Boldf formats and prints the message in bold style. -func Boldf(t string, args ...interface{}) string { - return Bold(fmt.Sprintf(t, args...)) -} - -// Italic prints the given message(s) in italic style. -func Italic(t ...string) string { - return termenv.String(t...).Italic().String() -} - -// Italicln prints the given message(s) in italic style with a newline. -func Italicln(t ...string) { - fmt.Println(Italic(t...)) -} - -// Italicf formats and prints the message in italic style. -func Italicf(t string, args ...interface{}) string { - return Italic(fmt.Sprintf(t, args...)) -} - -// Space prints a single space to the output. -func Space() { - fmt.Print(" ") -} - -// printWithColor prints the given message(s) with the specified color function. -func printWithColor(colorFunc func(...string) string, t ...string) { - fmt.Print(colorFunc(t...)) -} - -// printWithColorln prints the given message(s) with the specified color function and a newline. -func printWithColorln(colorFunc func(...string) string, t ...string) { - fmt.Println(colorFunc(t...)) -} - -// printWithColorf formats and prints the message with the specified color function. -func printWithColorf(colorFunc func(string, ...interface{}) string, t string, args ...interface{}) { - fmt.Print(colorFunc(t, args...)) -} diff --git a/cli/prompt/prompt.go b/cli/prompt/prompt.go new file mode 100644 index 0000000..7e718c2 --- /dev/null +++ b/cli/prompt/prompt.go @@ -0,0 +1,132 @@ +package prompt + +import ( + "fmt" + + "github.com/charmbracelet/huh" +) + +// Prompter defines an interface for user input interactions. +type Prompter interface { + Select(message, defaultValue string, options []string) (int, error) + MultiSelect(message, defaultValue string, options []string) ([]int, error) + Input(message, defaultValue string) (string, error) + Confirm(message string, defaultValue bool) (bool, error) +} + +// New creates and returns a new Prompter instance. +func New() Prompter { + return &huhPrompter{} +} + +type huhPrompter struct{} + +// Select prompts the user to select one option from a list. +// +// Parameters: +// - message: The prompt message to display. +// - defaultValue: The default selected value. +// - options: The list of options to display. +// +// Returns: +// - The index of the selected option. +// - An error, if any. +func (p *huhPrompter) Select(message, defaultValue string, options []string) (int, error) { + huhOptions := make([]huh.Option[int], len(options)) + for i, opt := range options { + huhOptions[i] = huh.NewOption(opt, i) + } + + var result int + // Find default index + for i, opt := range options { + if opt == defaultValue { + result = i + break + } + } + + err := huh.NewSelect[int](). + Title(message). + Options(huhOptions...). + Value(&result). + Run() + if err != nil { + return 0, fmt.Errorf("prompt error: %w", err) + } + return result, nil +} + +// MultiSelect prompts the user to select multiple options from a list. +// +// Parameters: +// - message: The prompt message to display. +// - defaultValue: The default selected values (unused, kept for interface compat). +// - options: The list of options to display. +// +// Returns: +// - A slice of indices representing the selected options. +// - An error, if any. +func (p *huhPrompter) MultiSelect(message, _ string, options []string) ([]int, error) { + huhOptions := make([]huh.Option[int], len(options)) + for i, opt := range options { + huhOptions[i] = huh.NewOption(opt, i) + } + + var result []int + err := huh.NewMultiSelect[int](). + Title(message). + Options(huhOptions...). + Value(&result). + Run() + if err != nil { + return nil, fmt.Errorf("prompt error: %w", err) + } + return result, nil +} + +// Input prompts the user for a text input. +// +// Parameters: +// - message: The prompt message to display. +// - defaultValue: The default input value. +// +// Returns: +// - The user's input as a string. +// - An error, if any. +func (p *huhPrompter) Input(message, defaultValue string) (string, error) { + var result string + err := huh.NewInput(). + Title(message). + Value(&result). + Placeholder(defaultValue). + Run() + if err != nil { + return "", fmt.Errorf("prompt error: %w", err) + } + if result == "" { + result = defaultValue + } + return result, nil +} + +// Confirm prompts the user for a yes/no confirmation. +// +// Parameters: +// - message: The prompt message to display. +// - defaultValue: The default confirmation value. +// +// Returns: +// - A boolean indicating the user's choice. +// - An error, if any. +func (p *huhPrompter) Confirm(message string, defaultValue bool) (bool, error) { + result := defaultValue + err := huh.NewConfirm(). + Title(message). + Value(&result). + Run() + if err != nil { + return false, fmt.Errorf("prompt error: %w", err) + } + return result, nil +} diff --git a/cli/prompter/prompt.go b/cli/prompter/prompt.go deleted file mode 100644 index 5e17c6c..0000000 --- a/cli/prompter/prompt.go +++ /dev/null @@ -1,110 +0,0 @@ -package prompter - -import ( - "fmt" - - "github.com/AlecAivazis/survey/v2" -) - -// Prompter defines an interface for user input interactions. -type Prompter interface { - Select(message, defaultValue string, options []string) (int, error) - MultiSelect(message, defaultValue string, options []string) ([]int, error) - Input(message, defaultValue string) (string, error) - Confirm(message string, defaultValue bool) (bool, error) -} - -// New creates and returns a new Prompter instance. -func New() Prompter { - return &surveyPrompter{} -} - -type surveyPrompter struct { -} - -// ask is a helper function to prompt the user and capture the response. -func (p *surveyPrompter) ask(q survey.Prompt, response interface{}) error { - err := survey.AskOne(q, response) - if err != nil { - return fmt.Errorf("prompt error: %w", err) - } - return nil -} - -// Select prompts the user to select one option from a list. -// -// Parameters: -// - message: The prompt message to display. -// - defaultValue: The default selected value. -// - options: The list of options to display. -// -// Returns: -// - The index of the selected option. -// - An error, if any. -func (p *surveyPrompter) Select(message, defaultValue string, options []string) (int, error) { - var result int - err := p.ask(&survey.Select{ - Message: message, - Default: defaultValue, - Options: options, - PageSize: 20, - }, &result) - return result, err -} - -// MultiSelect prompts the user to select multiple options from a list. -// -// Parameters: -// - message: The prompt message to display. -// - defaultValue: The default selected values. -// - options: The list of options to display. -// -// Returns: -// - A slice of indices representing the selected options. -// - An error, if any. -func (p *surveyPrompter) MultiSelect(message, defaultValue string, options []string) ([]int, error) { - var result []int - err := p.ask(&survey.MultiSelect{ - Message: message, - Default: defaultValue, - Options: options, - PageSize: 20, - }, &result) - return result, err -} - -// Input prompts the user for a text input. -// -// Parameters: -// - message: The prompt message to display. -// - defaultValue: The default input value. -// -// Returns: -// - The user's input as a string. -// - An error, if any. -func (p *surveyPrompter) Input(message, defaultValue string) (string, error) { - var result string - err := p.ask(&survey.Input{ - Message: message, - Default: defaultValue, - }, &result) - return result, err -} - -// Confirm prompts the user for a yes/no confirmation. -// -// Parameters: -// - message: The prompt message to display. -// - defaultValue: The default confirmation value. -// -// Returns: -// - A boolean indicating the user's choice. -// - An error, if any. -func (p *surveyPrompter) Confirm(message string, defaultValue bool) (bool, error) { - var result bool - err := p.ask(&survey.Confirm{ - Message: message, - Default: defaultValue, - }, &result) - return result, err -} diff --git a/cli/terminator/brew.go b/cli/terminal/brew.go similarity index 98% rename from cli/terminator/brew.go rename to cli/terminal/brew.go index fe926d2..d8bbdc3 100644 --- a/cli/terminator/brew.go +++ b/cli/terminal/brew.go @@ -1,4 +1,4 @@ -package terminator +package terminal import ( "os/exec" diff --git a/cli/terminator/browser.go b/cli/terminal/browser.go similarity index 98% rename from cli/terminator/browser.go rename to cli/terminal/browser.go index c8127d5..687b03f 100644 --- a/cli/terminator/browser.go +++ b/cli/terminal/browser.go @@ -1,4 +1,4 @@ -package terminator +package terminal import ( "os" diff --git a/cli/terminator/pager.go b/cli/terminal/pager.go similarity index 96% rename from cli/terminator/pager.go rename to cli/terminal/pager.go index 7071e08..3c805ea 100644 --- a/cli/terminator/pager.go +++ b/cli/terminal/pager.go @@ -1,4 +1,4 @@ -package terminator +package terminal import ( "errors" @@ -8,7 +8,6 @@ import ( "strings" "syscall" - "github.com/cli/safeexec" "github.com/google/shlex" ) @@ -87,8 +86,7 @@ func (p *Pager) Start() error { pagerEnv = append(pagerEnv, "LV=-c") } - // Locate the pager executable using safeexec for added security. - pagerExe, err := safeexec.LookPath(pagerArgs[0]) + pagerExe, err := exec.LookPath(pagerArgs[0]) if err != nil { return err } diff --git a/cli/terminator/term.go b/cli/terminal/term.go similarity index 98% rename from cli/terminator/term.go rename to cli/terminal/term.go index 23b66d8..0c47160 100644 --- a/cli/terminator/term.go +++ b/cli/terminal/term.go @@ -1,4 +1,4 @@ -package terminator +package terminal import ( "os" diff --git a/cli/releaser/release.go b/cli/version/release.go similarity index 87% rename from cli/releaser/release.go rename to cli/version/release.go index a821fe1..4ac3e3b 100644 --- a/cli/releaser/release.go +++ b/cli/version/release.go @@ -1,4 +1,4 @@ -package releaser +package version import ( "encoding/json" @@ -8,7 +8,6 @@ import ( "time" "github.com/hashicorp/go-version" - "github.com/pkg/errors" ) var ( @@ -38,13 +37,13 @@ func FetchInfo(url string) (*Info, error) { httpClient := http.Client{Timeout: Timeout} req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - return nil, errors.Wrap(err, "failed to create HTTP request") + return nil, fmt.Errorf("failed to create HTTP request: %w", err) } req.Header.Set("User-Agent", "raystack/salt") resp, err := httpClient.Do(req) if err != nil { - return nil, errors.Wrapf(err, "failed to fetch release information from URL: %s", url) + return nil, fmt.Errorf("failed to fetch release information from URL: %s: %w", url, err) } defer func() { if resp.Body != nil { @@ -57,7 +56,7 @@ func FetchInfo(url string) (*Info, error) { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.Wrap(err, "failed to read response body") + return nil, fmt.Errorf("failed to read response body: %w", err) } var data struct { @@ -65,7 +64,7 @@ func FetchInfo(url string) (*Info, error) { Tarball string `json:"tarball_url"` } if err = json.Unmarshal(body, &data); err != nil { - return nil, errors.Wrapf(err, "failed to parse JSON response") + return nil, fmt.Errorf("failed to parse JSON response: %w", err) } return &Info{ @@ -86,12 +85,12 @@ func FetchInfo(url string) (*Info, error) { func CompareVersions(current, latest string) (bool, error) { currentVersion, err := version.NewVersion(current) if err != nil { - return false, errors.Wrap(err, "invalid current version format") + return false, fmt.Errorf("invalid current version format: %w", err) } latestVersion, err := version.NewVersion(latest) if err != nil { - return false, errors.Wrap(err, "invalid latest version format") + return false, fmt.Errorf("invalid latest version format: %w", err) } return currentVersion.GreaterThanOrEqual(latestVersion), nil diff --git a/db/config.go b/db/config.go deleted file mode 100644 index d64b2d8..0000000 --- a/db/config.go +++ /dev/null @@ -1,14 +0,0 @@ -package db - -import ( - "time" -) - -type Config struct { - Driver string `yaml:"driver" mapstructure:"driver"` - URL string `yaml:"url" mapstructure:"url"` - MaxIdleConns int `yaml:"max_idle_conns" mapstructure:"max_idle_conns" default:"10"` - MaxOpenConns int `yaml:"max_open_conns" mapstructure:"max_open_conns" default:"10"` - ConnMaxLifeTime time.Duration `yaml:"conn_max_life_time" mapstructure:"conn_max_life_time" default:"10ms"` - MaxQueryTimeout time.Duration `yaml:"max_query_timeout" mapstructure:"max_query_timeout" default:"100ms"` -} diff --git a/db/db.go b/db/db.go deleted file mode 100644 index 2baed94..0000000 --- a/db/db.go +++ /dev/null @@ -1,92 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "fmt" - "net/url" - "time" - - "github.com/jmoiron/sqlx" - "github.com/pkg/errors" -) - -type Client struct { - *sqlx.DB - queryTimeOut time.Duration - cfg Config - host string -} - -// NewClient creates a new sqlx database client -func New(cfg Config) (*Client, error) { - dbURL, err := url.Parse(cfg.URL) - if err != nil { - return nil, err - } - host := dbURL.Host - - db, err := sqlx.Connect(cfg.Driver, cfg.URL) - if err != nil { - return nil, err - } - - db.SetMaxIdleConns(cfg.MaxIdleConns) - db.SetMaxOpenConns(cfg.MaxOpenConns) - db.SetConnMaxLifetime(cfg.ConnMaxLifeTime) - - return &Client{DB: db, queryTimeOut: cfg.MaxQueryTimeout, cfg: cfg, host: host}, err -} - -func (c Client) WithTimeout(ctx context.Context, op func(ctx context.Context) error) (err error) { - ctxWithTimeout, cancel := context.WithTimeout(ctx, c.queryTimeOut) - defer cancel() - - return op(ctxWithTimeout) -} - -func (c Client) WithTxn(ctx context.Context, txnOptions sql.TxOptions, txFunc func(*sqlx.Tx) error) (err error) { - txn, err := c.BeginTxx(ctx, &txnOptions) - if err != nil { - return err - } - - defer func() { - if p := recover(); p != nil { - switch p := p.(type) { - case error: - err = p - default: - err = errors.Errorf("%s", p) - } - err = txn.Rollback() - panic(p) - } else if err != nil { - if rlbErr := txn.Rollback(); rlbErr != nil { - err = fmt.Errorf("rollback error: %s while executing: %w", rlbErr, err) - } else { - err = fmt.Errorf("rollback: %w", err) - } - } else { - err = txn.Commit() - } - }() - - err = txFunc(txn) - return err -} - -// ConnectionURL fetch the database connection url -func (c *Client) ConnectionURL() string { - return c.cfg.URL -} - -// Host fetch the database host information -func (c *Client) Host() string { - return c.host -} - -// Close closes the database connection -func (c *Client) Close() error { - return c.DB.Close() -} diff --git a/db/db_test.go b/db/db_test.go deleted file mode 100644 index bbbb96e..0000000 --- a/db/db_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package db_test - -import ( - "context" - "database/sql" - "fmt" - "log" - "os" - "testing" - "time" - - "github.com/jmoiron/sqlx" - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" - "github.com/raystack/salt/db" - "github.com/stretchr/testify/assert" -) - -const ( - dialect = "postgres" - user = "root" - password = "pass" - database = "postgres" - host = "localhost" - port = "5432" - dsn = "postgres://%s:%s@localhost:%s/%s?sslmode=disable" -) - -var ( - createTableQuery = "CREATE TABLE IF NOT EXISTS users (id VARCHAR(36) PRIMARY KEY, name VARCHAR(50))" - dropTableQuery = "DROP TABLE IF EXISTS users" - checkTableQuery = "SELECT EXISTS(SELECT * FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'users');" -) - -var client *db.Client - -func TestMain(m *testing.M) { - pool, err := dockertest.NewPool("") - if err != nil { - log.Fatalf("Could not connect to docker: %s", err) - } - - opts := dockertest.RunOptions{ - Repository: "postgres", - Tag: "14", - Env: []string{ - "POSTGRES_USER=" + user, - "POSTGRES_PASSWORD=" + password, - "POSTGRES_DB=" + database, - }, - ExposedPorts: []string{"5432"}, - PortBindings: map[docker.Port][]docker.PortBinding{ - "5432": { - {HostIP: "0.0.0.0", HostPort: port}, - }, - }, - } - - resource, err := pool.RunWithOptions(&opts, func(config *docker.HostConfig) { - config.AutoRemove = true - config.RestartPolicy = docker.RestartPolicy{Name: "no"} - }) - if err != nil { - log.Fatalf("Could not start resource: %s", err.Error()) - } - - fmt.Println(resource.GetPort("5432/tcp")) - - if err := resource.Expire(120); err != nil { - log.Fatalf("Could not expire resource: %s", err.Error()) - } - - pool.MaxWait = 60 * time.Second - - dsn := fmt.Sprintf(dsn, user, password, port, database) - var ( - pgConfig = db.Config{ - Driver: "postgres", - URL: dsn, - } - ) - - if err = pool.Retry(func() error { - client, err = db.New(pgConfig) - return err - }); err != nil { - log.Fatalf("Could not connect to docker: %s", err.Error()) - } - - defer func() { - client.Close() - }() - - code := m.Run() - - if err := pool.Purge(resource); err != nil { - log.Fatalf("Could not purge resource: %s", err) - } - - os.Exit(code) -} - -func TestWithTxn(t *testing.T) { - if _, err := client.Exec(dropTableQuery); err != nil { - log.Fatalf("Could not cleanup: %s", err) - } - err := client.WithTxn(context.Background(), sql.TxOptions{}, func(tx *sqlx.Tx) error { - if _, err := tx.Exec(createTableQuery); err != nil { - return err - } - if _, err := tx.Exec(dropTableQuery); err != nil { - return err - } - - return nil - }) - assert.NoError(t, err) - - // Table should be dropped - var tableExist bool - result := client.QueryRow(checkTableQuery) - result.Scan(&tableExist) - assert.Equal(t, false, tableExist) -} - -func TestWithTxnCommit(t *testing.T) { - if _, err := client.Exec(dropTableQuery); err != nil { - log.Fatalf("Could not cleanup: %s", err) - } - query2 := "SELECT 1" - - err := client.WithTxn(context.Background(), sql.TxOptions{}, func(tx *sqlx.Tx) error { - if _, err := tx.Exec(createTableQuery); err != nil { - return err - } - if _, err := tx.Exec(query2); err != nil { - return err - } - - return nil - }) - // WithTx should not return an error - assert.NoError(t, err) - - // User table should exist - var tableExist bool - result := client.QueryRow(checkTableQuery) - result.Scan(&tableExist) - assert.Equal(t, true, tableExist) -} - -func TestWithTxnRollback(t *testing.T) { - if _, err := client.Exec(dropTableQuery); err != nil { - log.Fatalf("Could not cleanup: %s", err) - } - query2 := "WRONG QUERY" - - err := client.WithTxn(context.Background(), sql.TxOptions{}, func(tx *sqlx.Tx) error { - if _, err := tx.Exec(createTableQuery); err != nil { - return err - } - if _, err := tx.Exec(query2); err != nil { - return err - } - - return nil - }) - // WithTx should return an error - assert.Error(t, err) - - // Table should not be created - var tableExist bool - result := client.QueryRow(checkTableQuery) - result.Scan(&tableExist) - assert.Equal(t, false, tableExist) -} diff --git a/db/migrate.go b/db/migrate.go deleted file mode 100644 index 6931866..0000000 --- a/db/migrate.go +++ /dev/null @@ -1,49 +0,0 @@ -package db - -import ( - "fmt" - "io/fs" - - "github.com/golang-migrate/migrate/v4" - _ "github.com/golang-migrate/migrate/v4/database" - _ "github.com/golang-migrate/migrate/v4/database/mysql" - _ "github.com/golang-migrate/migrate/v4/database/postgres" - _ "github.com/golang-migrate/migrate/v4/source/file" - "github.com/golang-migrate/migrate/v4/source/iofs" -) - -func RunMigrations(config Config, embeddedMigrations fs.FS, resourcePath string) error { - m, err := getMigrationInstance(config, embeddedMigrations, resourcePath) - if err != nil { - return err - } - - err = m.Up() - if err == migrate.ErrNoChange || err == nil { - return nil - } - - return err -} - -func RunRollback(config Config, embeddedMigrations fs.FS, resourcePath string) error { - m, err := getMigrationInstance(config, embeddedMigrations, resourcePath) - if err != nil { - return err - } - - err = m.Steps(-1) - if err == migrate.ErrNoChange || err == nil { - return nil - } - - return err -} - -func getMigrationInstance(config Config, embeddedMigrations fs.FS, resourcePath string) (*migrate.Migrate, error) { - src, err := iofs.New(embeddedMigrations, resourcePath) - if err != nil { - return nil, fmt.Errorf("db migrator: %v", err) - } - return migrate.NewWithSourceInstance("iofs", src, config.URL) -} diff --git a/db/migrate_test.go b/db/migrate_test.go deleted file mode 100644 index 27f7b15..0000000 --- a/db/migrate_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package db_test - -import ( - "embed" - "fmt" - "log" - "testing" - - "github.com/raystack/salt/db" - "github.com/stretchr/testify/assert" -) - -//go:embed migrations/*.sql -var migrationFs embed.FS - -func TestRunMigrations(t *testing.T) { - if _, err := client.Exec(dropTableQuery); err != nil { - log.Fatalf("Could not cleanup: %s", err) - } - - dsn := fmt.Sprintf(dsn, user, password, port, database) - var ( - pgConfig = db.Config{ - Driver: "postgres", - URL: dsn, - } - ) - - err := db.RunMigrations(pgConfig, migrationFs, "migrations") - assert.NoError(t, err) - - // User table should exist - var tableExist bool - result := client.QueryRow(checkTableQuery) - result.Scan(&tableExist) - assert.Equal(t, true, tableExist) -} - -func TestRunRollback(t *testing.T) { - if _, err := client.Exec(dropTableQuery); err != nil { - log.Fatalf("Could not cleanup: %s", err) - } - - dsn := fmt.Sprintf(dsn, user, password, port, database) - var ( - pgConfig = db.Config{ - Driver: "postgres", - URL: dsn, - } - ) - - err := db.RunRollback(pgConfig, migrationFs, "migrations") - assert.NoError(t, err) - - // User table should not exist - var tableExist bool - result := client.QueryRow(checkTableQuery) - result.Scan(&tableExist) - assert.Equal(t, false, tableExist) -} diff --git a/db/migrations/1481574547_create_users_table.down.sql b/db/migrations/1481574547_create_users_table.down.sql deleted file mode 100644 index ea15b38..0000000 --- a/db/migrations/1481574547_create_users_table.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS users \ No newline at end of file diff --git a/db/migrations/1481574547_create_users_table.up.sql b/db/migrations/1481574547_create_users_table.up.sql deleted file mode 100644 index a50ce01..0000000 --- a/db/migrations/1481574547_create_users_table.up.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE TABLE IF NOT EXISTS users (id VARCHAR(36) PRIMARY KEY, name VARCHAR(50)) \ No newline at end of file diff --git a/go.mod b/go.mod index 8f66121..22feb82 100644 --- a/go.mod +++ b/go.mod @@ -1,129 +1,104 @@ module github.com/raystack/salt -go 1.22 +go 1.25.0 require ( - github.com/AlecAivazis/survey/v2 v2.3.6 + connectrpc.com/connect v1.19.1 github.com/MakeNowJust/heredoc v1.0.0 github.com/NYTimes/gziphandler v1.1.1 - github.com/authzed/authzed-go v0.7.0 - github.com/authzed/grpcutil v0.0.0-20230908193239-4286bb1d6403 - github.com/briandowns/spinner v1.18.0 - github.com/charmbracelet/glamour v0.3.0 - github.com/cli/safeexec v1.0.0 - github.com/go-playground/validator v9.31.0+incompatible - github.com/golang-migrate/migrate/v4 v4.16.0 - github.com/google/go-cmp v0.6.0 + github.com/briandowns/spinner v1.23.2 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/huh v1.0.0 + github.com/creasty/defaults v1.8.0 + github.com/go-playground/validator/v10 v10.30.2 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.3.0 github.com/jeremywohl/flatten v1.0.1 - github.com/jmoiron/sqlx v1.3.5 - github.com/lib/pq v1.10.4 - github.com/mattn/go-isatty v0.0.19 - github.com/mcuadros/go-defaults v1.2.0 + github.com/mattn/go-isatty v0.0.21 github.com/mitchellh/mapstructure v1.5.0 - github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 - github.com/oklog/run v1.1.0 - github.com/olekukonko/tablewriter v0.0.5 - github.com/ory/dockertest/v3 v3.9.1 - github.com/pkg/errors v0.9.1 - github.com/schollz/progressbar/v3 v3.8.5 - github.com/sirupsen/logrus v1.9.2 + github.com/muesli/termenv v0.16.0 + github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 go.opentelemetry.io/contrib/instrumentation/host v0.56.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0 go.opentelemetry.io/contrib/samplers/probability/consistent v0.25.0 go.opentelemetry.io/otel v1.31.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 - go.opentelemetry.io/otel/metric v1.31.0 go.opentelemetry.io/otel/sdk v1.31.0 go.opentelemetry.io/otel/sdk/metric v1.31.0 - go.uber.org/zap v1.21.0 - golang.org/x/oauth2 v0.22.0 - golang.org/x/text v0.19.0 - google.golang.org/api v0.171.0 + golang.org/x/text v0.35.0 google.golang.org/grpc v1.67.1 - google.golang.org/protobuf v1.35.1 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect ) require ( - cloud.google.com/go/compute/metadata v0.5.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/Microsoft/go-winio v0.6.1 // indirect - github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect - github.com/alecthomas/chroma v0.8.2 // indirect - github.com/alecthomas/repr v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect - github.com/containerd/continuity v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect - github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dlclark/regexp2 v1.2.0 // indirect - github.com/docker/cli v20.10.14+incompatible // indirect - github.com/docker/docker v20.10.24+incompatible // indirect - github.com/docker/go-connections v0.4.0 // indirect - github.com/docker/go-units v0.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/ebitengine/purego v0.8.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect - github.com/fatih/color v1.15.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-sql-driver/mysql v1.6.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/s2a-go v0.1.7 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/gorilla/css v1.0.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jzelinskie/stringz v0.0.0-20210414224931-d6a8ce844a70 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect - github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect - github.com/microcosm-cc/bluemonday v1.0.6 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect - github.com/moby/term v0.5.0 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.0.2 // indirect - github.com/opencontainers/runc v1.1.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -131,34 +106,24 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/numcpus v0.9.0 // indirect github.com/wI2L/jsondiff v0.7.0 - github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect - github.com/yuin/goldmark v1.4.13 // indirect - github.com/yuin/goldmark-emoji v1.0.1 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/otel/trace v1.31.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.28.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/mod v0.18.0 // indirect - golang.org/x/net v0.30.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/term v0.25.0 // indirect - golang.org/x/tools v0.22.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/net v0.51.0 + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect - gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gotest.tools/v3 v3.5.1 // indirect ) diff --git a/go.sum b/go.sum index 53d4008..fef8d24 100644 --- a/go.sum +++ b/go.sum @@ -1,106 +1,91 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= -cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= -github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw= -github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= -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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg= -github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/authzed/authzed-go v0.7.0 h1:etnzHUAIyxGiEaFYJPYkHTHzxCYWEGzZQMgVLe4xRME= -github.com/authzed/authzed-go v0.7.0/go.mod h1:bmjzzIQ34M0+z8NO9SLjf4oA0A9Ka9gUWVzeSbD0E7c= -github.com/authzed/grpcutil v0.0.0-20230908193239-4286bb1d6403 h1:bQeIwWWRI9bl93poTqpix4sYHi+gnXUPK7N6bMtXzBE= -github.com/authzed/grpcutil v0.0.0-20230908193239-4286bb1d6403/go.mod h1:s3qC7V7XIbiNWERv7Lfljy/Lx25/V1Qlexb0WJuA8uQ= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/briandowns/spinner v1.18.0 h1:SJs0maNOs4FqhBwiJ3Gr7Z1D39/rukIVGQvpNZVHVcM= -github.com/briandowns/spinner v1.18.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= +github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= +github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s= -github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/charmbracelet/glamour v0.3.0 h1:3H+ZrKlSg8s+WU6V7eF2eRVYt8lCueffbi7r2+ffGkc= -github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw= -github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= -github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= -github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= -github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= -github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= -github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= +github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= +github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= +github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dhui/dktest v0.3.16 h1:i6gq2YQEtcrjKbeJpBkWjE8MmLZPYllcjOFbTZuPDnw= -github.com/dhui/dktest v0.3.16/go.mod h1:gYaA3LRmM8Z4vJl2MA0THIigJoZrwOansEOsp+kqxp0= -github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= -github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/docker/cli v20.10.14+incompatible h1:dSBKJOVesDgHo7rbxlYjYsXe7gPzrTT+/cKQgpDAazg= -github.com/docker/cli v20.10.14+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= -github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v20.10.24+incompatible h1:Ugvxm7a8+Gz6vqQYQQ2W7GYq5EUPaAiuPgIfVyI3dYE= -github.com/docker/docker v20.10.24+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= -github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= -github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= -github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= -github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -109,203 +94,94 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= -github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-migrate/migrate/v4 v4.16.0 h1:FU2GR7EdAO0LmhNLcKthfDzuYCtMcWNR7rUbZjsgH3o= -github.com/golang-migrate/migrate/v4 v4.16.0/go.mod h1:qXiwa/3Zeqaltm1MxOCZDYysW/F6folYiBgBG03l9hc= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 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.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= -github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= -github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= -github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= -github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= -github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbgtrs= github.com/jeremywohl/flatten v1.0.1/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= -github.com/jzelinskie/stringz v0.0.0-20210414224931-d6a8ce844a70 h1:thTca5Eyouk5CEcJ75Cbw9CSAGE7TAc6rIi+WgHWpOE= -github.com/jzelinskie/stringz v0.0.0-20210414224931-d6a8ce844a70/go.mod h1:hHYbgxJuNLRw91CmpuFsYEOyQqpDVFg8pvEh23vy4P0= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -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/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= -github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc= -github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/microcosm-cc/bluemonday v1.0.6 h1:ZOvqHKtnx0fUpnbQm3m3zKFWE+DRC+XB1onh8JoEObE= -github.com/microcosm-cc/bluemonday v1.0.6/go.mod h1:HOT/6NaBlR0f9XlxD3zolN6Z3N8Lp4pvhp+jLS5ihnI= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU= -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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= -github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= -github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= -github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= -github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/runc v1.1.2 h1:2VSZwLx5k/BfsBxMMipG/LYUnmqOD/BPkIVgQUcTlLw= -github.com/opencontainers/runc v1.1.2/go.mod h1:Tj1hFw6eFWp/o33uxGf5yF2BX5yz2Z6iptFpuvbbKqc= -github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/ory/dockertest/v3 v3.9.1 h1:v4dkG+dlu76goxMiTT2j8zV7s4oPPEppKT8K8p2f1kY= -github.com/ory/dockertest/v3 v3.9.1/go.mod h1:42Ir9hmvaAPm0Mgibk6mBPi7SFvTXxEcnztDYOJ//uM= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/schollz/progressbar/v3 v3.8.5 h1:VcmmNRO+eFN3B0m5dta6FXYXY+MEJmXdWoIS+jjssQM= -github.com/schollz/progressbar/v3 v3.8.5/go.mod h1:ewO25kD7ZlaJFTvMeOItkOZa8kXu1UvFs379htE8HMQ= -github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI= github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y= -github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -319,25 +195,17 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -352,35 +220,18 @@ github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZ github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= -github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.3/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= -github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= go.opentelemetry.io/contrib/instrumentation/host v0.56.0 h1:bLJ0U2SVly7aCVAv4pSJ62I0yy3GHPMbK+74AXSwC40= go.opentelemetry.io/contrib/instrumentation/host v0.56.0/go.mod h1:7XvO8DvjdcoYDOQs/1n3AuadI/30eE2R+H/pQQuZVN0= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0 h1:s7wHG+t8bEoH7ibWk1nk682h7EoWLJ5/8j+TSO3bX/o= go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0/go.mod h1:Q8Hsv3d9DwryfIl+ebj4mY81IYVRSPy4QfxroVZwqLo= go.opentelemetry.io/contrib/samplers/probability/consistent v0.25.0 h1:8J8W2niC6+NC2gTfpdnBHRffKf3I2XIsOwonRDf2w8w= @@ -403,176 +254,43 @@ go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HY go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= -go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= -go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= -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/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -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/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -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.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= -golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -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.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/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-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= -golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= -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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -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.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= -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/api v0.171.0 h1:w174hnBPqut76FzW5Qaupt7zY8Kql6fiVjgys4f58sU= -google.golang.org/api v0.171.0/go.mod h1:Hnq5AHm4OTMt2BUVjael2CWZFD6vksJdWCWiUAmjC9o= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= -gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/observability/logger/logger.go b/observability/logger/logger.go deleted file mode 100644 index 8dab3e1..0000000 --- a/observability/logger/logger.go +++ /dev/null @@ -1,51 +0,0 @@ -package logger - -import ( - "io" -) - -// Option modifies the logger behavior -type Option func(interface{}) - -// Logger is a convenient interface to use provided loggers -// either use it as it is or implement your own interface where -// the logging implementations are used -// Each log method must take first string as message and then one or -// more key,value arguments. -// For example: -// -// timeTaken := time.Duration(time.Second * 1) -// l.Debug("processed request", "time taken", timeTaken) -// -// here key should always be a `string` and value could be of any type as -// long as it is printable. -// -// l.Info("processed request", "time taken", timeTaken, "started at", startedAt) -type Logger interface { - - // Debug level message with alternating key/value pairs - // key should be string, value could be anything printable - Debug(msg string, args ...interface{}) - - // Info level message with alternating key/value pairs - // key should be string, value could be anything printable - Info(msg string, args ...interface{}) - - // Warn level message with alternating key/value pairs - // key should be string, value could be anything printable - Warn(msg string, args ...interface{}) - - // Error level message with alternating key/value pairs - // key should be string, value could be anything printable - Error(msg string, args ...interface{}) - - // Fatal level message with alternating key/value pairs - // key should be string, value could be anything printable - Fatal(msg string, args ...interface{}) - - // Level returns priority level for which this logger will filter logs - Level() string - - // Writer used to print logs - Writer() io.Writer -} diff --git a/observability/logger/logrus.go b/observability/logger/logrus.go deleted file mode 100644 index 60a0464..0000000 --- a/observability/logger/logrus.go +++ /dev/null @@ -1,96 +0,0 @@ -package logger - -import ( - "io" - - "github.com/sirupsen/logrus" -) - -type Logrus struct { - log *logrus.Logger -} - -func (l Logrus) getFields(args ...interface{}) map[string]interface{} { - fieldMap := map[string]interface{}{} - if len(args) > 1 && len(args)%2 == 0 { - for i := 1; i < len(args); i += 2 { - fieldMap[args[i-1].(string)] = args[i] - } - } - return fieldMap -} - -func (l *Logrus) Info(msg string, args ...interface{}) { - l.log.WithFields(l.getFields(args...)).Info(msg) -} - -func (l *Logrus) Debug(msg string, args ...interface{}) { - l.log.WithFields(l.getFields(args...)).Debug(msg) -} - -func (l *Logrus) Warn(msg string, args ...interface{}) { - l.log.WithFields(l.getFields(args...)).Warn(msg) -} - -func (l *Logrus) Error(msg string, args ...interface{}) { - l.log.WithFields(l.getFields(args...)).Error(msg) -} - -func (l *Logrus) Fatal(msg string, args ...interface{}) { - l.log.WithFields(l.getFields(args...)).Fatal(msg) -} - -func (l *Logrus) Level() string { - return l.log.Level.String() -} - -func (l *Logrus) Writer() io.Writer { - return l.log.Writer() -} - -func (l *Logrus) Entry(args ...interface{}) *logrus.Entry { - return l.log.WithFields(l.getFields(args...)) -} - -func LogrusWithLevel(level string) Option { - return func(logger interface{}) { - logLevel, err := logrus.ParseLevel(level) - if err != nil { - panic(err) - } - logger.(*Logrus).log.SetLevel(logLevel) - } -} - -func LogrusWithWriter(writer io.Writer) Option { - return func(logger interface{}) { - logger.(*Logrus).log.SetOutput(writer) - } -} - -// LogrusWithFormatter can be used to change default formatting -// by implementing logrus.Formatter -// For example: -// -// type PlainFormatter struct{} -// func (p *PlainFormatter) Format(entry *logrus.Entry) ([]byte, error) { -// return []byte(entry.Message), nil -// } -// l := logger.NewLogrus(logger.LogrusWithFormatter(&PlainFormatter{})) -func LogrusWithFormatter(f logrus.Formatter) Option { - return func(logger interface{}) { - logger.(*Logrus).log.SetFormatter(f) - } -} - -// NewLogrus returns a logrus logger instance with info level as default log level -func NewLogrus(opts ...Option) *Logrus { - logger := &Logrus{ - log: logrus.New(), - } - logger.log.Level = logrus.InfoLevel - for _, opt := range opts { - opt(logger) - } - return logger -} diff --git a/observability/logger/logrus_test.go b/observability/logger/logrus_test.go deleted file mode 100644 index 88ea888..0000000 --- a/observability/logger/logrus_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package logger_test - -import ( - "bufio" - "bytes" - "fmt" - "testing" - - "github.com/sirupsen/logrus" - - "github.com/raystack/salt/observability/logger" - - "github.com/stretchr/testify/assert" -) - -func TestLogrus(t *testing.T) { - t.Run("should parse info messages at debug level correctly", func(t *testing.T) { - var b bytes.Buffer - foo := bufio.NewWriter(&b) - - logger := logger.NewLogrus(logger.LogrusWithLevel("debug"), logger.LogrusWithWriter(foo), logger.LogrusWithFormatter(&logrus.TextFormatter{ - DisableTimestamp: true, - })) - logger.Info("hello world") - foo.Flush() - - assert.Equal(t, "level=info msg=\"hello world\"\n", b.String()) - }) - t.Run("should not parse debug messages at info level correctly", func(t *testing.T) { - var b bytes.Buffer - foo := bufio.NewWriter(&b) - - logger := logger.NewLogrus(logger.LogrusWithLevel("info"), logger.LogrusWithWriter(foo), logger.LogrusWithFormatter(&logrus.TextFormatter{ - DisableTimestamp: true, - })) - logger.Debug("hello world") - foo.Flush() - - assert.Equal(t, "", b.String()) - }) - t.Run("should parse field maps correctly", func(t *testing.T) { - var b bytes.Buffer - foo := bufio.NewWriter(&b) - - logger := logger.NewLogrus(logger.LogrusWithLevel("debug"), logger.LogrusWithWriter(foo), logger.LogrusWithFormatter(&logrus.TextFormatter{ - DisableTimestamp: true, - })) - logger.Debug("current values", "day", 11, "month", "aug") - foo.Flush() - - assert.Equal(t, "level=debug msg=\"current values\" day=11 month=aug\n", b.String()) - }) - t.Run("should handle errors correctly", func(t *testing.T) { - var b bytes.Buffer - foo := bufio.NewWriter(&b) - - logger := logger.NewLogrus(logger.LogrusWithLevel("info"), logger.LogrusWithWriter(foo), logger.LogrusWithFormatter(&logrus.TextFormatter{ - DisableTimestamp: true, - })) - var err = fmt.Errorf("request failed") - logger.Error(err.Error(), "hello", "world") - foo.Flush() - assert.Equal(t, "level=error msg=\"request failed\" hello=world\n", b.String()) - }) - t.Run("should ignore params if malformed", func(t *testing.T) { - var b bytes.Buffer - foo := bufio.NewWriter(&b) - - logger := logger.NewLogrus(logger.LogrusWithLevel("info"), logger.LogrusWithWriter(foo), logger.LogrusWithFormatter(&logrus.TextFormatter{ - DisableTimestamp: true, - })) - var err = fmt.Errorf("request failed") - logger.Error(err.Error(), "hello", "world", "!") - foo.Flush() - assert.Equal(t, "level=error msg=\"request failed\"\n", b.String()) - }) -} diff --git a/observability/logger/noop.go b/observability/logger/noop.go deleted file mode 100644 index 3d757bd..0000000 --- a/observability/logger/noop.go +++ /dev/null @@ -1,26 +0,0 @@ -package logger - -import ( - "io" - "io/ioutil" -) - -type Noop struct{} - -func (n *Noop) Info(msg string, args ...interface{}) {} -func (n *Noop) Debug(msg string, args ...interface{}) {} -func (n *Noop) Warn(msg string, args ...interface{}) {} -func (n *Noop) Error(msg string, args ...interface{}) {} -func (n *Noop) Fatal(msg string, args ...interface{}) {} - -func (n *Noop) Level() string { - return "unsupported" -} -func (n *Noop) Writer() io.Writer { - return ioutil.Discard -} - -// NewNoop returns a no operation logger, useful in tests -func NewNoop(opts ...Option) *Noop { - return &Noop{} -} diff --git a/observability/logger/zap.go b/observability/logger/zap.go deleted file mode 100644 index dc1084e..0000000 --- a/observability/logger/zap.go +++ /dev/null @@ -1,110 +0,0 @@ -package logger - -import ( - "context" - "io" - - "go.uber.org/zap" -) - -type Zap struct { - log *zap.SugaredLogger - conf zap.Config -} - -type ctxKey string - -var loggerCtxKey = ctxKey("zapLoggerCtxKey") - -func (z Zap) Debug(msg string, args ...interface{}) { - z.log.With(args...).Debug(msg) -} - -func (z Zap) Info(msg string, args ...interface{}) { - z.log.With(args...).Info(msg) -} - -func (z Zap) Warn(msg string, args ...interface{}) { - z.log.With(args...).Warn(msg, args) -} - -func (z Zap) Error(msg string, args ...interface{}) { - z.log.With(args...).Error(msg, args) -} - -func (z Zap) Fatal(msg string, args ...interface{}) { - z.log.With(args...).Fatal(msg, args) -} - -func (z Zap) Level() string { - return z.conf.Level.String() -} - -func (z Zap) Writer() io.Writer { - panic("not supported") -} - -func ZapWithConfig(conf zap.Config, opts ...zap.Option) Option { - return func(z interface{}) { - z.(*Zap).conf = conf - prodLogger, err := z.(*Zap).conf.Build(opts...) - if err != nil { - panic(err) - } - z.(*Zap).log = prodLogger.Sugar() - } -} - -// GetInternalZapLogger Gets internal SugaredLogger instance -func (z Zap) GetInternalZapLogger() *zap.SugaredLogger { - return z.log -} - -// NewContext will add Zap inside context -func (z Zap) NewContext(ctx context.Context) context.Context { - return context.WithValue(ctx, loggerCtxKey, z) -} - -// ZapContextWithFields will add Zap Fields to logger in Context -func ZapContextWithFields(ctx context.Context, fields ...zap.Field) context.Context { - return context.WithValue(ctx, loggerCtxKey, Zap{ - // Error when not Desugaring when adding fields: github.com/ipfs/go-log/issues/85 - log: ZapFromContext(ctx).GetInternalZapLogger().Desugar().With(fields...).Sugar(), - conf: ZapFromContext(ctx).conf, - }) -} - -// ZapFromContext will help in fetching back zap logger from context -func ZapFromContext(ctx context.Context) Zap { - if ctxLogger, ok := ctx.Value(loggerCtxKey).(Zap); ok { - return ctxLogger - } - - return Zap{} -} - -func ZapWithNoop() Option { - return func(z interface{}) { - z.(*Zap).log = zap.NewNop().Sugar() - z.(*Zap).conf = zap.Config{} - } -} - -// NewZap returns a zap logger instance with info level as default log level -func NewZap(opts ...Option) *Zap { - defaultConfig := zap.NewProductionConfig() - defaultConfig.Level.SetLevel(zap.InfoLevel) - logger, err := defaultConfig.Build() - if err != nil { - panic(err) - } - - zapper := &Zap{ - log: logger.Sugar(), - conf: defaultConfig, - } - for _, opt := range opts { - opt(zapper) - } - return zapper -} diff --git a/observability/logger/zap_test.go b/observability/logger/zap_test.go deleted file mode 100644 index f5f99a9..0000000 --- a/observability/logger/zap_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package logger_test - -import ( - "bufio" - "bytes" - "context" - "crypto/rand" - "fmt" - "io" - "net/url" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "go.uber.org/zap" - - "github.com/raystack/salt/observability/logger" -) - -type zapBufWriter struct { - io.Writer -} - -func (cw zapBufWriter) Close() error { - return nil -} -func (cw zapBufWriter) Sync() error { - return nil -} - -type zapClock struct { - t time.Time -} - -func (m zapClock) Now() time.Time { - return m.t -} - -func (m zapClock) NewTicker(duration time.Duration) *time.Ticker { - return time.NewTicker(duration) -} - -func buildBufferedZapOption(writer io.Writer, t time.Time, bufWriterKey string) logger.Option { - config := zap.NewDevelopmentConfig() - config.DisableCaller = true - // register mock writer - _ = zap.RegisterSink(bufWriterKey, func(u *url.URL) (zap.Sink, error) { - return zapBufWriter{writer}, nil - }) - // build a valid custom path - customPath := fmt.Sprintf("%s:", bufWriterKey) - config.OutputPaths = []string{customPath} - - return logger.ZapWithConfig(config, zap.WithClock(&zapClock{ - t: t, - })) -} - -func TestZap(t *testing.T) { - mockedTime := time.Date(2021, 6, 10, 11, 55, 0, 0, time.UTC) - - t.Run("should successfully print at info level", func(t *testing.T) { - var b bytes.Buffer - bWriter := bufio.NewWriter(&b) - - zapper := logger.NewZap(buildBufferedZapOption(bWriter, mockedTime, randomString(10))) - zapper.Info("hello", "wor", "ld") - bWriter.Flush() - - assert.Equal(t, mockedTime.Format("2006-01-02T15:04:05.000Z0700")+"\tINFO\thello\t{\"wor\": \"ld\"}\n", b.String()) - }) - - t.Run("should successfully print log from context", func(t *testing.T) { - var b bytes.Buffer - bWriter := bufio.NewWriter(&b) - - zapper := logger.NewZap(buildBufferedZapOption(bWriter, mockedTime, randomString(10))) - ctx := zapper.NewContext(context.Background()) - contextualLog := logger.ZapFromContext(ctx) - contextualLog.Info("hello", "wor", "ld") - bWriter.Flush() - - assert.Equal(t, mockedTime.Format("2006-01-02T15:04:05.000Z0700")+"\tINFO\thello\t{\"wor\": \"ld\"}\n", b.String()) - }) - - t.Run("should successfully print log from context with fields", func(t *testing.T) { - var b bytes.Buffer - bWriter := bufio.NewWriter(&b) - - zapper := logger.NewZap(buildBufferedZapOption(bWriter, mockedTime, randomString(10))) - ctx := zapper.NewContext(context.Background()) - ctx = logger.ZapContextWithFields(ctx, zap.Int("one", 1)) - ctx = logger.ZapContextWithFields(ctx, zap.String("two", "two")) - logger.ZapFromContext(ctx).Info("hello", "wor", "ld") - bWriter.Flush() - - assert.Equal(t, mockedTime.Format("2006-01-02T15:04:05.000Z0700")+"\tINFO\thello\t{\"one\": 1, \"two\": \"two\", \"wor\": \"ld\"}\n", b.String()) - }) -} - -func randomString(n int) string { - const alphabets = "abcdefghijklmnopqrstuvwxyz" - var alphaBytes = make([]byte, n) - rand.Read(alphaBytes) - for i, b := range alphaBytes { - alphaBytes[i] = alphabets[b%byte(len(alphabets))] - } - return string(alphaBytes) -} diff --git a/observability/otelgrpc/otelgrpc.go b/observability/otelgrpc/otelgrpc.go deleted file mode 100644 index bccd297..0000000 --- a/observability/otelgrpc/otelgrpc.go +++ /dev/null @@ -1,183 +0,0 @@ -package otelgrpc - -import ( - "context" - "net" - "strings" - "time" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" - semconv "go.opentelemetry.io/otel/semconv/v1.20.0" - "google.golang.org/grpc" - "google.golang.org/grpc/peer" - "google.golang.org/protobuf/proto" -) - -type UnaryParams struct { - Start time.Time - Method string - Req any - Res any - Err error -} -type Meter struct { - duration metric.Int64Histogram - requestSize metric.Int64Histogram - responseSize metric.Int64Histogram - attributes []attribute.KeyValue -} -type MeterOpts struct { - meterName string `default:"github.com/raystack/salt/observability/otelgrpc"` -} -type Option func(*MeterOpts) - -func WithMeterName(meterName string) Option { - return func(opts *MeterOpts) { - opts.meterName = meterName - } -} -func NewMeter(hostName string, opts ...Option) Meter { - meterOpts := &MeterOpts{} - for _, opt := range opts { - opt(meterOpts) - } - meter := otel.Meter(meterOpts.meterName) - duration, err := meter.Int64Histogram("rpc.client.duration", metric.WithUnit("ms")) - handleOtelErr(err) - requestSize, err := meter.Int64Histogram("rpc.client.request.size", metric.WithUnit("By")) - handleOtelErr(err) - responseSize, err := meter.Int64Histogram("rpc.client.response.size", metric.WithUnit("By")) - handleOtelErr(err) - addr, port := ExtractAddress(hostName) - return Meter{ - duration: duration, - requestSize: requestSize, - responseSize: responseSize, - attributes: []attribute.KeyValue{ - semconv.RPCSystemGRPC, - attribute.String("network.transport", "tcp"), - attribute.String("server.address", addr), - attribute.String("server.port", port), - }, - } -} -func GetProtoSize(p any) int { - if p == nil { - return 0 - } - if pm, ok := p.(proto.Message); ok { - return proto.Size(pm) - } - return 0 -} -func (m *Meter) RecordUnary(ctx context.Context, p UnaryParams) { - reqSize := GetProtoSize(p.Req) - resSize := GetProtoSize(p.Res) - attrs := make([]attribute.KeyValue, len(m.attributes)) - copy(attrs, m.attributes) - attrs = append(attrs, attribute.String("rpc.grpc.status_text", StatusText(p.Err))) - attrs = append(attrs, attribute.String("network.type", netTypeFromCtx(ctx))) - attrs = append(attrs, ParseFullMethod(p.Method)...) - m.duration.Record(ctx, - time.Since(p.Start).Milliseconds(), - metric.WithAttributes(attrs...)) - m.requestSize.Record(ctx, - int64(reqSize), - metric.WithAttributes(attrs...)) - m.responseSize.Record(ctx, - int64(resSize), - metric.WithAttributes(attrs...)) -} -func (m *Meter) RecordStream(ctx context.Context, start time.Time, method string, err error) { - attrs := make([]attribute.KeyValue, len(m.attributes)) - copy(attrs, m.attributes) - attrs = append(attrs, attribute.String("rpc.grpc.status_text", StatusText(err))) - attrs = append(attrs, attribute.String("network.type", netTypeFromCtx(ctx))) - attrs = append(attrs, ParseFullMethod(method)...) - m.duration.Record(ctx, - time.Since(start).Milliseconds(), - metric.WithAttributes(attrs...)) -} -func (m *Meter) UnaryClientInterceptor() grpc.UnaryClientInterceptor { - return func(ctx context.Context, - method string, - req, reply interface{}, - cc *grpc.ClientConn, - invoker grpc.UnaryInvoker, - opts ...grpc.CallOption, - ) (err error) { - defer func(start time.Time) { - m.RecordUnary(ctx, UnaryParams{ - Start: start, - Req: req, - Res: reply, - Err: err, - }) - }(time.Now()) - return invoker(ctx, method, req, reply, cc, opts...) - } -} -func (m *Meter) StreamClientInterceptor() grpc.StreamClientInterceptor { - return func(ctx context.Context, - desc *grpc.StreamDesc, - cc *grpc.ClientConn, - method string, - streamer grpc.Streamer, - opts ...grpc.CallOption, - ) (s grpc.ClientStream, err error) { - defer func(start time.Time) { - m.RecordStream(ctx, start, method, err) - }(time.Now()) - return streamer(ctx, desc, cc, method, opts...) - } -} -func (m *Meter) GetAttributes() []attribute.KeyValue { - return m.attributes -} -func ParseFullMethod(fullMethod string) []attribute.KeyValue { - name := strings.TrimLeft(fullMethod, "/") - service, method, found := strings.Cut(name, "/") - if !found { - return nil - } - var attrs []attribute.KeyValue - if service != "" { - attrs = append(attrs, semconv.RPCService(service)) - } - if method != "" { - attrs = append(attrs, semconv.RPCMethod(method)) - } - return attrs -} -func handleOtelErr(err error) { - if err != nil { - otel.Handle(err) - } -} -func ExtractAddress(addr string) (host, port string) { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return addr, "80" - } - return host, port -} -func netTypeFromCtx(ctx context.Context) (ipType string) { - ipType = "unknown" - p, ok := peer.FromContext(ctx) - if !ok { - return ipType - } - clientIP, _, err := net.SplitHostPort(p.Addr.String()) - if err != nil { - return ipType - } - ip := net.ParseIP(clientIP) - if ip.To4() != nil { - ipType = "ipv4" - } else if ip.To16() != nil { - ipType = "ipv6" - } - return ipType -} diff --git a/observability/otelgrpc/otelgrpc_test.go b/observability/otelgrpc/otelgrpc_test.go deleted file mode 100644 index cc94ad5..0000000 --- a/observability/otelgrpc/otelgrpc_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package otelgrpc_test - -import ( - "reflect" - "testing" - - "github.com/raystack/salt/observability/otelgrpc" - "github.com/stretchr/testify/assert" - "go.opentelemetry.io/otel/attribute" - semconv "go.opentelemetry.io/otel/semconv/v1.20.0" -) - -func Test_parseFullMethod(t *testing.T) { - type args struct { - fullMethod string - } - tests := []struct { - name string - args args - want []attribute.KeyValue - }{ - {name: "should parse correct method", args: args{ - fullMethod: "/test.service.name/MethodNameV1", - }, want: []attribute.KeyValue{ - semconv.RPCService("test.service.name"), - semconv.RPCMethod("MethodNameV1"), - }}, - {name: "should return empty attributes on incorrect method", args: args{ - fullMethod: "incorrectMethod", - }, want: nil}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := otelgrpc.ParseFullMethod(tt.args.fullMethod); !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseFullMethod() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestExtractAddress(t *testing.T) { - gotHost, gotPort := otelgrpc.ExtractAddress("localhost:1001") - assert.Equal(t, "localhost", gotHost) - assert.Equal(t, "1001", gotPort) - gotHost, gotPort = otelgrpc.ExtractAddress("localhost") - assert.Equal(t, "localhost", gotHost) - assert.Equal(t, "80", gotPort) - gotHost, gotPort = otelgrpc.ExtractAddress("some.address.golabs.io:15010") - assert.Equal(t, "some.address.golabs.io", gotHost) - assert.Equal(t, "15010", gotPort) -} diff --git a/observability/otelgrpc/status.go b/observability/otelgrpc/status.go deleted file mode 100644 index 8f50583..0000000 --- a/observability/otelgrpc/status.go +++ /dev/null @@ -1,44 +0,0 @@ -package otelgrpc - -import ( - "github.com/pkg/errors" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -var codeToStr = map[codes.Code]string{ - codes.OK: `"OK"`, - codes.Canceled: `"CANCELED"`, - codes.Unknown: `"UNKNOWN"`, - codes.InvalidArgument: `"INVALID_ARGUMENT"`, - codes.DeadlineExceeded: `"DEADLINE_EXCEEDED"`, - codes.NotFound: `"NOT_FOUND"`, - codes.AlreadyExists: `"ALREADY_EXISTS"`, - codes.PermissionDenied: `"PERMISSION_DENIED"`, - codes.ResourceExhausted: `"RESOURCE_EXHAUSTED"`, - codes.FailedPrecondition: `"FAILED_PRECONDITION"`, - codes.Aborted: `"ABORTED"`, - codes.OutOfRange: `"OUT_OF_RANGE"`, - codes.Unimplemented: `"UNIMPLEMENTED"`, - codes.Internal: `"INTERNAL"`, - codes.Unavailable: `"UNAVAILABLE"`, - codes.DataLoss: `"DATA_LOSS"`, - codes.Unauthenticated: `"UNAUTHENTICATED"`, -} - -func StatusCode(err error) codes.Code { - if err == nil { - return codes.OK - } - var se interface { - GRPCStatus() *status.Status - } - if errors.As(err, &se) { - return se.GRPCStatus().Code() - } - return codes.Unknown -} - -func StatusText(err error) string { - return codeToStr[StatusCode(err)] -} diff --git a/observability/otelgrpc/status_test.go b/observability/otelgrpc/status_test.go deleted file mode 100644 index 475f10c..0000000 --- a/observability/otelgrpc/status_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package otelgrpc - -import ( - "fmt" - "testing" - - "github.com/pkg/errors" - "github.com/stretchr/testify/assert" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func TestStatusCode(t *testing.T) { - cases := []struct { - name string - err error - expected codes.Code - }{ - { - name: "with status.Error", - err: status.Error(codes.NotFound, "Somebody that I used to know"), - expected: codes.NotFound, - }, - { - name: "with wrapped status.Error", - err: fmt.Errorf("%w", status.Error(codes.Unavailable, "I shot the sheriff")), - expected: codes.Unavailable, - }, - { - name: "with std lib error", - err: errors.New("Runnin' down a dream"), - expected: codes.Unknown, - }, - { - name: "with nil error", - err: nil, - expected: codes.OK, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, StatusCode(tc.err)) - }) - } -} diff --git a/observability/otelhttpclient/annotations.go b/observability/otelhttpclient/annotations.go deleted file mode 100644 index 0de273d..0000000 --- a/observability/otelhttpclient/annotations.go +++ /dev/null @@ -1,33 +0,0 @@ -package otelhttpclient - -import ( - "context" - "net/http" - - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "go.opentelemetry.io/otel/attribute" -) - -type labelerContextKeyType int - -const lablelerContextKey labelerContextKeyType = 0 - -// AnnotateRequest adds telemetry related annotations to request context and returns. -// The request context on the returned request should be retained. -// Ensure `route` is a route template and not actual URL to prevent high cardinality -// on the metrics. -func AnnotateRequest(req *http.Request, route string) *http.Request { - ctx := req.Context() - l := &otelhttp.Labeler{} - l.Add(attribute.String(attributeHTTPRoute, route)) - return req.WithContext(context.WithValue(ctx, lablelerContextKey, l)) -} - -// LabelerFromContext returns the labeler annotation from the context if exists. -func LabelerFromContext(ctx context.Context) (*otelhttp.Labeler, bool) { - l, ok := ctx.Value(lablelerContextKey).(*otelhttp.Labeler) - if !ok { - l = &otelhttp.Labeler{} - } - return l, ok -} diff --git a/observability/otelhttpclient/http_transport.go b/observability/otelhttpclient/http_transport.go deleted file mode 100644 index e082e1d..0000000 --- a/observability/otelhttpclient/http_transport.go +++ /dev/null @@ -1,121 +0,0 @@ -package otelhttpclient - -import ( - "fmt" - "io" - "net/http" - "time" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" -) - -// Refer OpenTelemetry Semantic Conventions for HTTP Client. -// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-metrics.md#http-client -const ( - metricClientDuration = "http.client.duration" - metricClientRequestSize = "http.client.request.size" - metricClientResponseSize = "http.client.response.size" - attributeNetProtoName = "network.protocol.name" - attributeNetProtoVersion = "network.protocol.release" - attributeServerPort = "server.port" - attributeServerAddress = "server.address" - attributeHTTPRoute = "http.route" - attributeRequestMethod = "http.request.method" - attributeResponseStatusCode = "http.response.status_code" -) - -type httpTransport struct { - roundTripper http.RoundTripper - metricClientDuration metric.Float64Histogram - metricClientRequestSize metric.Int64Counter - metricClientResponseSize metric.Int64Counter -} - -func NewHTTPTransport(baseTransport http.RoundTripper) http.RoundTripper { - if _, ok := baseTransport.(*httpTransport); ok { - return baseTransport - } - if baseTransport == nil { - baseTransport = http.DefaultTransport - } - icl := &httpTransport{roundTripper: baseTransport} - icl.createMeasures(otel.Meter("github.com/raystack/salt/observability/otelhttpclient")) - return icl -} -func (tr *httpTransport) RoundTrip(req *http.Request) (*http.Response, error) { - ctx := req.Context() - startAt := time.Now() - labeler, _ := LabelerFromContext(req.Context()) - var bw bodyWrapper - if req.Body != nil && req.Body != http.NoBody { - bw.ReadCloser = req.Body - req.Body = &bw - } - port := req.URL.Port() - if port == "" { - port = "80" - if req.URL.Scheme == "https" { - port = "443" - } - } - attribs := append(labeler.Get(), - attribute.String(attributeNetProtoName, "http"), - attribute.String(attributeRequestMethod, req.Method), - attribute.String(attributeServerAddress, req.URL.Hostname()), - attribute.String(attributeServerPort, port), - ) - resp, err := tr.roundTripper.RoundTrip(req) - if err != nil { - attribs = append(attribs, - attribute.Int(attributeResponseStatusCode, 0), - attribute.String(attributeNetProtoVersion, fmt.Sprintf("%d.%d", req.ProtoMajor, req.ProtoMinor)), - ) - } else { - attribs = append(attribs, - attribute.Int(attributeResponseStatusCode, resp.StatusCode), - attribute.String(attributeNetProtoVersion, fmt.Sprintf("%d.%d", resp.ProtoMajor, resp.ProtoMinor)), - ) - } - elapsedTime := float64(time.Since(startAt)) / float64(time.Millisecond) - withAttribs := metric.WithAttributes(attribs...) - tr.metricClientDuration.Record(ctx, elapsedTime, withAttribs) - tr.metricClientRequestSize.Add(ctx, int64(bw.read), withAttribs) - if resp != nil { - tr.metricClientResponseSize.Add(ctx, resp.ContentLength, withAttribs) - } - return resp, err -} -func (tr *httpTransport) createMeasures(meter metric.Meter) { - var err error - tr.metricClientRequestSize, err = meter.Int64Counter(metricClientRequestSize) - handleErr(err) - tr.metricClientResponseSize, err = meter.Int64Counter(metricClientResponseSize) - handleErr(err) - tr.metricClientDuration, err = meter.Float64Histogram(metricClientDuration) - handleErr(err) -} -func handleErr(err error) { - if err != nil { - otel.Handle(err) - } -} - -// bodyWrapper wraps a http.Request.Body (an io.ReadCloser) to track the number -// of bytes read and the last error. -type bodyWrapper struct { - io.ReadCloser - read int - err error -} - -func (w *bodyWrapper) Read(b []byte) (int, error) { - n, err := w.ReadCloser.Read(b) - w.read += n - w.err = err - return n, err -} -func (w *bodyWrapper) Close() error { - return w.ReadCloser.Close() -} diff --git a/observability/otelhttpclient/http_transport_test.go b/observability/otelhttpclient/http_transport_test.go deleted file mode 100644 index b2a6aa2..0000000 --- a/observability/otelhttpclient/http_transport_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package otelhttpclient_test - -import ( - "testing" - - "github.com/raystack/salt/observability/otelhttpclient" - "github.com/stretchr/testify/assert" -) - -func TestNewHTTPTransport(t *testing.T) { - tr := otelhttpclient.NewHTTPTransport(nil) - assert.NotNil(t, tr) -} diff --git a/server/mux/README.md b/server/mux/README.md deleted file mode 100644 index 531cd9b..0000000 --- a/server/mux/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Mux - -`mux` package provides helpers for starting multiple servers. HTTP and gRPC -servers are supported currently. - -## Usage - -```go -package main - -import ( - "context" - "log" - "net/http" - "os/signal" - "syscall" - "time" - - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" - "github.com/raystack/salt/common" - "github.com/raystack/salt/mux" - "google.golang.org/grpc" - "google.golang.org/grpc/reflection" -) - -func main() { - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - grpcServer := grpc.NewServer() - - reflection.Register(grpcServer) - - grpcGateway := runtime.NewServeMux() - - httpMux := http.NewServeMux() - httpMux.Handle("/api/", http.StripPrefix("/api", grpcGateway)) - - log.Fatalf("server exited: %v", mux.Serve( - ctx, - mux.WithHTTPTarget(":8080", &http.Server{ - Handler: httpMux, - ReadTimeout: 120 * time.Second, - WriteTimeout: 120 * time.Second, - MaxHeaderBytes: 1 << 20, - }), - mux.WithGRPCTarget(":8081", grpcServer), - mux.WithGracePeriod(5*time.Second), - )) -} - -type SlowCommonService struct { - *common.CommonService -} - -func (s SlowCommonService) GetVersion(ctx context.Context, req *commonv1.GetVersionRequest) (*commonv1.GetVersionResponse, error) { - for i := 0; i < 5; i++ { - log.Printf("dooing stuff") - time.Sleep(1 * time.Second) - } - return s.CommonService.GetVersion(ctx, req) -} -``` diff --git a/server/mux/mux.go b/server/mux/mux.go deleted file mode 100644 index a29b66f..0000000 --- a/server/mux/mux.go +++ /dev/null @@ -1,73 +0,0 @@ -package mux - -import ( - "context" - "errors" - "fmt" - "log" - "net" - "time" - - "github.com/oklog/run" -) - -const ( - defaultGracePeriod = 10 * time.Second -) - -// Serve starts TCP listeners and serves the registered protocol servers of the -// given serveTarget(s) and blocks until the servers exit. Context can be -// cancelled to perform graceful shutdown. -func Serve(ctx context.Context, opts ...Option) error { - mux := muxServer{gracePeriod: defaultGracePeriod} - for _, opt := range opts { - if err := opt(&mux); err != nil { - return err - } - } - - if len(mux.targets) == 0 { - return errors.New("mux serve: at least one serve target must be set") - } - - return mux.Serve(ctx) -} - -type muxServer struct { - targets []serveTarget - gracePeriod time.Duration -} - -func (mux *muxServer) Serve(ctx context.Context) error { - var g run.Group - for _, t := range mux.targets { - l, err := net.Listen("tcp", t.Address()) - if err != nil { - return fmt.Errorf("mux serve: %w", err) - } - - t := t // redeclare to avoid referring to updated value inside closures. - g.Add(func() error { - err := t.Serve(l) - if err != nil { - log.Print("[ERROR] Serve:", err) - } - return err - }, func(error) { - ctx, cancel := context.WithTimeout(context.Background(), mux.gracePeriod) - defer cancel() - - if err := t.Shutdown(ctx); err != nil { - log.Print("[ERROR] Shutdown server gracefully:", err) - } - }) - } - - g.Add(func() error { - <-ctx.Done() - return ctx.Err() - }, func(error) { - }) - - return g.Run() -} diff --git a/server/mux/option.go b/server/mux/option.go deleted file mode 100644 index fd21d05..0000000 --- a/server/mux/option.go +++ /dev/null @@ -1,40 +0,0 @@ -package mux - -import ( - "net/http" - "time" - - "google.golang.org/grpc" -) - -// Option values can be used with Serve() for customisation. -type Option func(m *muxServer) error - -func WithHTTPTarget(addr string, srv *http.Server) Option { - srv.Addr = addr - return func(m *muxServer) error { - m.targets = append(m.targets, httpServeTarget{Server: srv}) - return nil - } -} - -func WithGRPCTarget(addr string, srv *grpc.Server) Option { - return func(m *muxServer) error { - m.targets = append(m.targets, gRPCServeTarget{ - Addr: addr, - Server: srv, - }) - return nil - } -} - -// WithGracePeriod sets the wait duration for graceful shutdown. -func WithGracePeriod(d time.Duration) Option { - return func(m *muxServer) error { - if d <= 0 { - d = defaultGracePeriod - } - m.gracePeriod = d - return nil - } -} diff --git a/server/mux/serve_target.go b/server/mux/serve_target.go deleted file mode 100644 index 2496c35..0000000 --- a/server/mux/serve_target.go +++ /dev/null @@ -1,55 +0,0 @@ -package mux - -import ( - "context" - "errors" - "net" - "net/http" - - "google.golang.org/grpc" -) - -type serveTarget interface { - Address() string - Serve(l net.Listener) error - Shutdown(ctx context.Context) error -} - -type httpServeTarget struct { - *http.Server -} - -func (h httpServeTarget) Address() string { return h.Addr } - -func (h httpServeTarget) Serve(l net.Listener) error { - if err := h.Server.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -type gRPCServeTarget struct { - Addr string - *grpc.Server -} - -func (g gRPCServeTarget) Address() string { return g.Addr } - -func (g gRPCServeTarget) Shutdown(ctx context.Context) error { - signal := make(chan struct{}) - go func() { - defer close(signal) - - g.GracefulStop() - }() - - select { - case <-ctx.Done(): - g.Stop() - return errors.New("graceful stop failed") - - case <-signal: - } - - return nil -} diff --git a/server/spa/handler.go b/server/spa/handler.go index 760e067..f394cd9 100644 --- a/server/spa/handler.go +++ b/server/spa/handler.go @@ -34,9 +34,8 @@ func Handler(build embed.FS, dir string, index string, gzip bool) (http.Handler, if _, err = fsys.Open(index); err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("ui is enabled but no index.html found: %w", err) - } else { - return nil, fmt.Errorf("ui assets error: %w", err) } + return nil, fmt.Errorf("ui assets error: %w", err) } router := &router{index: index, fs: http.FS(fsys)} diff --git a/observability/opentelemetry.go b/telemetry/opentelemetry.go similarity index 93% rename from observability/opentelemetry.go rename to telemetry/opentelemetry.go index e4fdbda..bb95e6a 100644 --- a/observability/opentelemetry.go +++ b/telemetry/opentelemetry.go @@ -1,11 +1,11 @@ -package observability +package telemetry import ( "context" "fmt" + "log/slog" "time" - "github.com/raystack/salt/observability/logger" "go.opentelemetry.io/contrib/instrumentation/host" "go.opentelemetry.io/contrib/instrumentation/runtime" "go.opentelemetry.io/contrib/samplers/probability/consistent" @@ -29,7 +29,7 @@ type OpenTelemetryConfig struct { VerboseResourceLabelsEnabled bool `yaml:"verbose_resource_labels_enabled" mapstructure:"verbose_resource_labels_enabled" default:"false"` } -func initOTLP(ctx context.Context, cfg Config, logger logger.Logger) (func(), error) { +func initOTLP(ctx context.Context, cfg Config, logger *slog.Logger) (func(), error) { if !cfg.OpenTelemetry.Enabled { logger.Info("OpenTelemetry monitoring is disabled.") return noOp, nil @@ -78,7 +78,7 @@ func initOTLP(ctx context.Context, cfg Config, logger logger.Logger) (func(), er } return shutdownProviders, nil } -func initGlobalMetrics(ctx context.Context, res *resource.Resource, cfg OpenTelemetryConfig, logger logger.Logger) (func(), error) { +func initGlobalMetrics(ctx context.Context, res *resource.Resource, cfg OpenTelemetryConfig, logger *slog.Logger) (func(), error) { exporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithEndpoint(cfg.CollectorAddr), otlpmetricgrpc.WithCompressor(gzip.Name), @@ -98,7 +98,7 @@ func initGlobalMetrics(ctx context.Context, res *resource.Resource, cfg OpenTele } }, nil } -func initGlobalTracer(ctx context.Context, res *resource.Resource, cfg OpenTelemetryConfig, logger logger.Logger) (func(), error) { +func initGlobalTracer(ctx context.Context, res *resource.Resource, cfg OpenTelemetryConfig, logger *slog.Logger) (func(), error) { exporter, err := otlptrace.New(ctx, otlptracegrpc.NewClient( otlptracegrpc.WithEndpoint(cfg.CollectorAddr), otlptracegrpc.WithInsecure(), diff --git a/observability/telemetry.go b/telemetry/telemetry.go similarity index 63% rename from observability/telemetry.go rename to telemetry/telemetry.go index 8bd682e..7879251 100644 --- a/observability/telemetry.go +++ b/telemetry/telemetry.go @@ -1,21 +1,22 @@ -package observability +package telemetry import ( "context" + "log/slog" "time" - - "github.com/raystack/salt/observability/logger" ) const gracePeriod = 5 * time.Second +// Config holds the telemetry configuration. type Config struct { AppVersion string AppName string `yaml:"app_name" mapstructure:"app_name" default:"service"` OpenTelemetry OpenTelemetryConfig `yaml:"open_telemetry" mapstructure:"open_telemetry"` } -func Init(ctx context.Context, cfg Config, logger logger.Logger) (cleanUp func(), err error) { +// Init initializes OpenTelemetry and returns a cleanup function. +func Init(ctx context.Context, cfg Config, logger *slog.Logger) (cleanUp func(), err error) { shutdown, err := initOTLP(ctx, cfg, logger) if err != nil { return noOp, err diff --git a/testing/dockertestx/README.md b/testing/dockertestx/README.md deleted file mode 100644 index be3b758..0000000 --- a/testing/dockertestx/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# dockertestx - -This package is an abstraction of several dockerized data storages using `ory/dockertest` to bootstrap a specific dockerized instance. - -Example postgres - -```go -// create postgres instance -pgDocker, err := dockertest.CreatePostgres( - dockertest.PostgresWithDetail( - pgUser, pgPass, pgDBName, - ), -) - -// get connection string -connString := pgDocker.GetExternalConnString() - -// purge docker -if err := pgDocker.GetPool().Purge(pgDocker.GetResouce()); err != nil { - return fmt.Errorf("could not purge resource: %w", err) -} -``` - -Example spice db - -- bootsrap spice db with postgres and wire them internally via network bridge - -```go -// create custom pool -pool, err := dockertest.NewPool("") -if err != nil { - return nil, err -} - -// create a bridge network for testing -network, err = pool.Client.CreateNetwork(docker.CreateNetworkOptions{ - Name: fmt.Sprintf("bridge-%s", uuid.New().String()), -}) -if err != nil { - return nil, err -} - - -// create postgres instance -pgDocker, err := dockertest.CreatePostgres( - dockertest.PostgresWithDockerPool(pool), - dockertest.PostgresWithDockertestNetwork(network), - dockertest.PostgresWithDetail( - pgUser, pgPass, pgDBName, - ), -) - -// get connection string -connString := pgDocker.GetInternalConnString() - -// create spice db instance -spiceDocker, err := dockertest.CreateSpiceDB(connString, - dockertest.SpiceDBWithDockerPool(pool), - dockertest.SpiceDBWithDockertestNetwork(network), -) - -if err := dockertest.MigrateSpiceDB(connString, - dockertest.MigrateSpiceDBWithDockerPool(pool), - dockertest.MigrateSpiceDBWithDockertestNetwork(network), -); err != nil { - return err -} - -// purge docker resources -if err := pool.Purge(spiceDocker.GetResouce()); err != nil { - return fmt.Errorf("could not purge resource: %w", err) -} -if err := pool.Purge(pgDocker.GetResouce()); err != nil { - return fmt.Errorf("could not purge resource: %w", err) -} -``` diff --git a/testing/dockertestx/configs/cortex/single_process_cortex.yaml b/testing/dockertestx/configs/cortex/single_process_cortex.yaml deleted file mode 100644 index da8baf3..0000000 --- a/testing/dockertestx/configs/cortex/single_process_cortex.yaml +++ /dev/null @@ -1,121 +0,0 @@ -# Configuration for running Cortex in single-process mode. -# This configuration should not be used in production. -# It is only for getting started and development. - -# Disable the requirement that every request to Cortex has a -# X-Scope-OrgID header. `fake` will be substituted in instead. -auth_enabled: false - -server: - http_listen_port: 9009 - - # Configure the server to allow messages up to 100MB. - grpc_server_max_recv_msg_size: 104857600 - grpc_server_max_send_msg_size: 104857600 - grpc_server_max_concurrent_streams: 1000 - -distributor: - shard_by_all_labels: true - pool: - health_check_ingesters: true - -ingester_client: - grpc_client_config: - # Configure the client to allow messages up to 100MB. - max_recv_msg_size: 104857600 - max_send_msg_size: 104857600 - grpc_compression: gzip - -ingester: - # We want our ingesters to flush chunks at the same time to optimise - # deduplication opportunities. - spread_flushes: true - chunk_age_jitter: 0 - - walconfig: - wal_enabled: true - recover_from_wal: true - wal_dir: /tmp/cortex/wal - - lifecycler: - # The address to advertise for this ingester. Will be autodiscovered by - # looking up address on eth0 or en0; can be specified if this fails. - # address: 127.0.0.1 - - # We want to start immediately and flush on shutdown. - join_after: 0 - min_ready_duration: 0s - final_sleep: 0s - num_tokens: 512 - tokens_file_path: /tmp/cortex/wal/tokens - - # Use an in memory ring store, so we don't need to launch a Consul. - ring: - kvstore: - store: inmemory - replication_factor: 1 - -# Use local storage - BoltDB for the index, and the filesystem -# for the chunks. -schema: - configs: - - from: 2019-07-29 - store: boltdb - object_store: filesystem - schema: v10 - index: - prefix: index_ - period: 1w - -storage: - boltdb: - directory: /tmp/cortex/index - - filesystem: - directory: /tmp/cortex/chunks - - delete_store: - store: boltdb - -purger: - object_store_type: filesystem - -frontend_worker: - # Configure the frontend worker in the querier to match worker count - # to max_concurrent on the queriers. - match_max_concurrent: true - -# Configure the ruler to scan the /tmp/cortex/rules directory for prometheus -# rules: https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/#recording-rules -ruler: - enable_api: true - enable_sharding: false - # alertmanager_url: http://cortex-am:9009/api/prom/alertmanager/ - rule_path: /tmp/cortex/rules - storage: - type: s3 - s3: - # endpoint: http://minio1:9000 - bucketnames: cortex - secret_access_key: minio123 - access_key_id: minio - s3forcepathstyle: true - -alertmanager: - enable_api: true - sharding_enabled: false - data_dir: data/ - external_url: /api/prom/alertmanager - storage: - type: s3 - s3: - # endpoint: http://minio1:9000 - bucketnames: cortex - secret_access_key: minio123 - access_key_id: minio - s3forcepathstyle: true - -alertmanager_storage: - backend: local - local: - path: tmp/cortex/alertmanager diff --git a/testing/dockertestx/configs/nginx/cortex_nginx.conf b/testing/dockertestx/configs/nginx/cortex_nginx.conf deleted file mode 100644 index 6298ae2..0000000 --- a/testing/dockertestx/configs/nginx/cortex_nginx.conf +++ /dev/null @@ -1,93 +0,0 @@ -worker_processes 1; -error_log /dev/stderr; -pid /tmp/nginx.pid; -worker_rlimit_nofile 8192; - -events { - worker_connections 1024; -} - - -http { - client_max_body_size 5M; - default_type application/octet-stream; - log_format main '$remote_addr - $remote_user [$time_local] $status ' - '"$request" $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for" $http_x_scope_orgid'; - access_log /dev/stderr main; - sendfile on; - tcp_nopush on; - resolver 127.0.0.11 ipv6=off; - - server { - listen {{.ExposedPort}}; - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - proxy_http_version 1.1; - - location = /healthz { - return 200 'alive'; - } - - # Distributor Config - location = /ring { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - location = /all_user_stats { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - location = /api/prom/push { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - ## New Remote write API. Ref: https://cortexmetrics.io/docs/api/#remote-write - location = /api/v1/push { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - - # Alertmanager Config - location ~ /api/prom/alertmanager/.* { - proxy_pass http://{{.AlertManagerHost}}$request_uri; - } - - location ~ /api/v1/alerts { - proxy_pass http://{{.AlertManagerHost}}$request_uri; - } - - location ~ /multitenant_alertmanager/status { - proxy_pass http://{{.AlertManagerHost}}$request_uri; - } - - # Ruler Config - location ~ /api/v1/rules { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - location ~ /ruler/ring { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - # Config Config - location ~ /api/prom/configs/.* { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - # Query Config - location ~ /api/prom/.* { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - ## New Query frontend APIs as per https://cortexmetrics.io/docs/api/#querier--query-frontend - location ~ ^/prometheus/api/v1/(read|metadata|labels|series|query_range|query) { - proxy_pass http://{{.RulerHost}}$request_uri; - } - - location ~ /prometheus/api/v1/label/.* { - proxy_pass http://{{.RulerHost}}$request_uri; - } - } -} \ No newline at end of file diff --git a/testing/dockertestx/cortex.go b/testing/dockertestx/cortex.go deleted file mode 100644 index 495762c..0000000 --- a/testing/dockertestx/cortex.go +++ /dev/null @@ -1,232 +0,0 @@ -package dockertestx - -import ( - "fmt" - "net/http" - "os" - "path" - "runtime" - "time" - - "github.com/google/uuid" - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" -) - -type dockerCortexOption func(dc *dockerCortex) - -// CortexWithDockertestNetwork is an option to assign docker network -func CortexWithDockertestNetwork(network *dockertest.Network) dockerCortexOption { - return func(dc *dockerCortex) { - dc.network = network - } -} - -// CortexWithDockertestNetwork is an option to assign release tag -// of a `quay.io/cortexproject/cortex` image -func CortexWithVersionTag(versionTag string) dockerCortexOption { - return func(dc *dockerCortex) { - dc.versionTag = versionTag - } -} - -// CortexWithDockerPool is an option to assign docker pool -func CortexWithDockerPool(pool *dockertest.Pool) dockerCortexOption { - return func(dc *dockerCortex) { - dc.pool = pool - } -} - -// CortexWithModule is an option to assign cortex module name -// e.g. all, alertmanager, querier, etc -func CortexWithModule(moduleName string) dockerCortexOption { - return func(dc *dockerCortex) { - dc.moduleName = moduleName - } -} - -// CortexWithAlertmanagerURL is an option to assign alertmanager url -func CortexWithAlertmanagerURL(amURL string) dockerCortexOption { - return func(dc *dockerCortex) { - dc.alertManagerURL = amURL - } -} - -// CortexWithS3Endpoint is an option to assign external s3/minio storage -func CortexWithS3Endpoint(s3URL string) dockerCortexOption { - return func(dc *dockerCortex) { - dc.s3URL = s3URL - } -} - -type dockerCortex struct { - network *dockertest.Network - pool *dockertest.Pool - moduleName string - alertManagerURL string - s3URL string - internalHost string - externalHost string - versionTag string - dockertestResource *dockertest.Resource -} - -// CreateCortex is a function to create a dockerized single-process cortex with -// s3/minio as the backend storage -func CreateCortex(opts ...dockerCortexOption) (*dockerCortex, error) { - var ( - err error - dc = &dockerCortex{} - ) - - for _, opt := range opts { - opt(dc) - } - - name := fmt.Sprintf("cortex-%s", uuid.New().String()) - - if dc.pool == nil { - dc.pool, err = dockertest.NewPool("") - if err != nil { - return nil, fmt.Errorf("could not create dockertest pool: %w", err) - } - } - - if dc.versionTag == "" { - dc.versionTag = "master-63703f5" - } - - if dc.moduleName == "" { - dc.moduleName = "all" - } - - runOpts := &dockertest.RunOptions{ - Name: name, - Repository: "quay.io/cortexproject/cortex", - Tag: dc.versionTag, - Env: []string{ - "minio_host=siren_nginx_1", - }, - Cmd: []string{ - fmt.Sprintf("-target=%s", dc.moduleName), - "-config.file=/etc/single-process-config.yaml", - fmt.Sprintf("-ruler.storage.s3.endpoint=%s", dc.s3URL), - fmt.Sprintf("-ruler.alertmanager-url=%s", dc.alertManagerURL), - fmt.Sprintf("-alertmanager.storage.s3.endpoint=%s", dc.s3URL), - }, - ExposedPorts: []string{"9009/tcp"}, - ExtraHosts: []string{ - "cortex.siren_nginx_1:127.0.0.1", - }, - } - - if dc.network != nil { - runOpts.NetworkID = dc.network.Network.ID - } - - pwd, err := os.Getwd() - if err != nil { - return nil, err - } - - var ( - rulesFolder = fmt.Sprintf("%s/tmp/dockertest-configs/cortex/rules", pwd) - alertManagerFolder = fmt.Sprintf("%s/tmp/dockertest-configs/cortex/alertmanager", pwd) - ) - - foldersPath := []string{rulesFolder, alertManagerFolder} - for _, fp := range foldersPath { - if _, err := os.Stat(fp); os.IsNotExist(err) { - if err := os.MkdirAll(fp, 0777); err != nil { - return nil, err - } - } - } - - _, thisFileName, _, ok := runtime.Caller(0) - if !ok { - return nil, err - } - thisFileFolder := path.Dir(thisFileName) - - dc.dockertestResource, err = dc.pool.RunWithOptions( - runOpts, - func(config *docker.HostConfig) { - config.RestartPolicy = docker.RestartPolicy{ - Name: "no", - } - config.Mounts = []docker.HostMount{ - { - Target: "/etc/single-process-config.yaml", - Source: fmt.Sprintf("%s/configs/cortex/single_process_cortex.yaml", thisFileFolder), - Type: "bind", - }, - { - Target: "/tmp/cortex/rules", - Source: rulesFolder, - Type: "bind", - }, - { - Target: "/tmp/cortex/alertmanager", - Source: alertManagerFolder, - Type: "bind", - }, - } - }, - ) - if err != nil { - return nil, err - } - - externalPort := dc.dockertestResource.GetPort("9009/tcp") - dc.internalHost = fmt.Sprintf("%s:9009", name) - dc.externalHost = fmt.Sprintf("localhost:%s", externalPort) - - if err = dc.dockertestResource.Expire(120); err != nil { - return nil, err - } - - // exponential backoff-retry, because the application in the container might not be ready to accept connections yet - dc.pool.MaxWait = 60 * time.Second - - if err = dc.pool.Retry(func() error { - httpClient := &http.Client{} - res, err := httpClient.Get(fmt.Sprintf("http://localhost:%s/config", externalPort)) - if err != nil { - return err - } - - if res.StatusCode != 200 { - return fmt.Errorf("cortex server return status %d", res.StatusCode) - } - - return nil - }); err != nil { - err = fmt.Errorf("could not connect to docker: %w", err) - return nil, fmt.Errorf("could not connect to docker: %w", err) - } - - return dc, nil -} - -// GetInternalHost returns internal hostname and port -// e.g. internal-xxxxxx:8080 -func (dc *dockerCortex) GetInternalHost() string { - return dc.internalHost -} - -// GetExternalHost returns localhost and port -// e.g. localhost:51113 -func (dc *dockerCortex) GetExternalHost() string { - return dc.externalHost -} - -// GetPool returns docker pool -func (dc *dockerCortex) GetPool() *dockertest.Pool { - return dc.pool -} - -// GetResource returns docker resource -func (dc *dockerCortex) GetResource() *dockertest.Resource { - return dc.dockertestResource -} diff --git a/testing/dockertestx/dockertestx.go b/testing/dockertestx/dockertestx.go deleted file mode 100644 index b0f7d71..0000000 --- a/testing/dockertestx/dockertestx.go +++ /dev/null @@ -1,11 +0,0 @@ -package dockertestx - -import "runtime" - -func DockerHostAddress() string { - var dockerHostInternal = "host-gateway" // linux by default - if runtime.GOOS == "darwin" { - dockerHostInternal = "host.docker.internal" - } - return dockerHostInternal -} diff --git a/testing/dockertestx/minio.go b/testing/dockertestx/minio.go deleted file mode 100644 index 16c6a2b..0000000 --- a/testing/dockertestx/minio.go +++ /dev/null @@ -1,175 +0,0 @@ -package dockertestx - -import ( - "fmt" - "net/http" - "time" - - "github.com/google/uuid" - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" -) - -const ( - defaultMinioRootUser = "minio" - defaultMinioRootPassword = "minio123" - defaultMinioDomain = "localhost" -) - -type dockerMinioOption func(dm *dockerMinio) - -// MinioWithDockertestNetwork is an option to assign docker network -func MinioWithDockertestNetwork(network *dockertest.Network) dockerMinioOption { - return func(dm *dockerMinio) { - dm.network = network - } -} - -// MinioWithVersionTag is an option to assign release tag -// of a `quay.io/minio/minio` image -func MinioWithVersionTag(versionTag string) dockerMinioOption { - return func(dm *dockerMinio) { - dm.versionTag = versionTag - } -} - -// MinioWithDockerPool is an option to assign docker pool -func MinioWithDockerPool(pool *dockertest.Pool) dockerMinioOption { - return func(dm *dockerMinio) { - dm.pool = pool - } -} - -type dockerMinio struct { - network *dockertest.Network - pool *dockertest.Pool - rootUser string - rootPassword string - domain string - versionTag string - internalHost string - externalHost string - externalConsoleHost string - dockertestResource *dockertest.Resource -} - -// CreateMinio creates a minio instance with default configurations -func CreateMinio(opts ...dockerMinioOption) (*dockerMinio, error) { - var ( - err error - dm = &dockerMinio{} - ) - - for _, opt := range opts { - opt(dm) - } - - name := fmt.Sprintf("minio-%s", uuid.New().String()) - - if dm.pool == nil { - dm.pool, err = dockertest.NewPool("") - if err != nil { - return nil, fmt.Errorf("could not create dockertest pool: %w", err) - } - } - - if dm.rootUser == "" { - dm.rootUser = defaultMinioRootUser - } - - if dm.rootPassword == "" { - dm.rootPassword = defaultMinioRootPassword - } - - if dm.domain == "" { - dm.domain = defaultMinioDomain - } - - if dm.versionTag == "" { - dm.versionTag = "RELEASE.2022-09-07T22-25-02Z" - } - - runOpts := &dockertest.RunOptions{ - Name: name, - Repository: "quay.io/minio/minio", - Tag: dm.versionTag, - Env: []string{ - "MINIO_ROOT_USER=" + dm.rootUser, - "MINIO_ROOT_PASSWORD=" + dm.rootPassword, - "MINIO_DOMAIN=" + dm.domain, - }, - Cmd: []string{"server", "/data1", "--console-address", ":9001"}, - ExposedPorts: []string{"9000/tcp", "9001/tcp"}, - } - - if dm.network != nil { - runOpts.NetworkID = dm.network.Network.ID - } - - dm.dockertestResource, err = dm.pool.RunWithOptions( - runOpts, - func(config *docker.HostConfig) { - config.RestartPolicy = docker.RestartPolicy{ - Name: "no", - } - }, - ) - if err != nil { - return nil, err - } - - minioPort := dm.dockertestResource.GetPort("9000/tcp") - minioConsolePort := dm.dockertestResource.GetPort("9001/tcp") - - dm.internalHost = fmt.Sprintf("%s:%s", name, "9000") - dm.externalHost = fmt.Sprintf("%s:%s", "localhost", minioPort) - dm.externalConsoleHost = fmt.Sprintf("%s:%s", "localhost", minioConsolePort) - - if err = dm.dockertestResource.Expire(120); err != nil { - return nil, err - } - - // exponential backoff-retry, because the application in the container might not be ready to accept connections yet - dm.pool.MaxWait = 60 * time.Second - - if err = dm.pool.Retry(func() error { - httpClient := &http.Client{} - res, err := httpClient.Get(fmt.Sprintf("http://localhost:%s/minio/health/live", minioPort)) - if err != nil { - return err - } - - if res.StatusCode != 200 { - return fmt.Errorf("minio server return status %d", res.StatusCode) - } - - return nil - }); err != nil { - err = fmt.Errorf("could not connect to docker: %w", err) - return nil, fmt.Errorf("could not connect to docker: %w", err) - } - - return dm, nil -} - -func (dm *dockerMinio) GetInternalHost() string { - return dm.internalHost -} - -func (dm *dockerMinio) GetExternalHost() string { - return dm.externalHost -} - -func (dm *dockerMinio) GetExternalConsoleHost() string { - return dm.externalConsoleHost -} - -// GetPool returns docker pool -func (dm *dockerMinio) GetPool() *dockertest.Pool { - return dm.pool -} - -// GetResource returns docker resource -func (dm *dockerMinio) GetResource() *dockertest.Resource { - return dm.dockertestResource -} diff --git a/testing/dockertestx/minio_migrate.go b/testing/dockertestx/minio_migrate.go deleted file mode 100644 index 7cb35b2..0000000 --- a/testing/dockertestx/minio_migrate.go +++ /dev/null @@ -1,129 +0,0 @@ -package dockertestx - -import ( - "bytes" - "context" - "fmt" - "time" - - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" -) - -const waitContainerTimeout = 60 * time.Second - -type dockerMigrateMinioOption func(dmm *dockerMigrateMinio) - -// MigrateMinioWithDockertestNetwork is an option to assign docker network -func MigrateMinioWithDockertestNetwork(network *dockertest.Network) dockerMigrateMinioOption { - return func(dm *dockerMigrateMinio) { - dm.network = network - } -} - -// MigrateMinioWithVersionTag is an option to assign release tag -// of a `minio/mc` image -func MigrateMinioWithVersionTag(versionTag string) dockerMigrateMinioOption { - return func(dm *dockerMigrateMinio) { - dm.versionTag = versionTag - } -} - -// MigrateMinioWithDockerPool is an option to assign docker pool -func MigrateMinioWithDockerPool(pool *dockertest.Pool) dockerMigrateMinioOption { - return func(dm *dockerMigrateMinio) { - dm.pool = pool - } -} - -type dockerMigrateMinio struct { - network *dockertest.Network - pool *dockertest.Pool - versionTag string -} - -// MigrateMinio does migration of a `bucketName` to a minio located in `minioHost` -func MigrateMinio(minioHost string, bucketName string, opts ...dockerMigrateMinioOption) error { - var ( - err error - dm = &dockerMigrateMinio{} - ) - - for _, opt := range opts { - opt(dm) - } - - if dm.pool == nil { - dm.pool, err = dockertest.NewPool("") - if err != nil { - return fmt.Errorf("could not create dockertest pool: %w", err) - } - } - - if dm.versionTag == "" { - dm.versionTag = "RELEASE.2022-08-28T20-08-11Z" - } - - runOpts := &dockertest.RunOptions{ - Repository: "minio/mc", - Tag: dm.versionTag, - Entrypoint: []string{ - "bin/sh", - "-c", - fmt.Sprintf(` - /usr/bin/mc alias set myminio http://%s minio minio123; - /usr/bin/mc rm -r --force %s; - /usr/bin/mc mb myminio/%s; - `, minioHost, bucketName, bucketName), - }, - } - - if dm.network != nil { - runOpts.NetworkID = dm.network.Network.ID - } - - resource, err := dm.pool.RunWithOptions(runOpts, func(config *docker.HostConfig) { - config.RestartPolicy = docker.RestartPolicy{ - Name: "no", - } - }) - if err != nil { - return err - } - - if err := resource.Expire(120); err != nil { - return err - } - - waitCtx, cancel := context.WithTimeout(context.Background(), waitContainerTimeout) - defer cancel() - - // Ensure the command completed successfully. - status, err := dm.pool.Client.WaitContainerWithContext(resource.Container.ID, waitCtx) - if err != nil { - return err - } - - if status != 0 { - stream := new(bytes.Buffer) - - if err = dm.pool.Client.Logs(docker.LogsOptions{ - Context: waitCtx, - OutputStream: stream, - ErrorStream: stream, - Stdout: true, - Stderr: true, - Container: resource.Container.ID, - }); err != nil { - return err - } - - return fmt.Errorf("got non-zero exit code %s", stream.String()) - } - - if err := dm.pool.Purge(resource); err != nil { - return err - } - - return nil -} diff --git a/testing/dockertestx/nginx.go b/testing/dockertestx/nginx.go deleted file mode 100644 index a2e45d4..0000000 --- a/testing/dockertestx/nginx.go +++ /dev/null @@ -1,247 +0,0 @@ -package dockertestx - -import ( - "bytes" - _ "embed" - "fmt" - "io/fs" - "net/http" - "os" - "path" - "text/template" - "time" - - "github.com/google/uuid" - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" -) - -const ( - nginxDefaultHealthEndpoint = "/healthz" - nginxDefaultExposedPort = "8080" - nginxDefaultVersionTag = "1.23" -) - -var ( - //go:embed configs/nginx/cortex_nginx.conf - NginxCortexConfig string -) - -type dockerNginxOption func(dc *dockerNginx) - -// NginxWithHealthEndpoint is an option to assign health endpoint -func NginxWithHealthEndpoint(healthEndpoint string) dockerNginxOption { - return func(dc *dockerNginx) { - dc.healthEndpoint = healthEndpoint - } -} - -// NginxWithDockertestNetwork is an option to assign docker network -func NginxWithDockertestNetwork(network *dockertest.Network) dockerNginxOption { - return func(dc *dockerNginx) { - dc.network = network - } -} - -// NginxWithVersionTag is an option to assign release tag -// of a `nginx` image -func NginxWithVersionTag(versionTag string) dockerNginxOption { - return func(dc *dockerNginx) { - dc.versionTag = versionTag - } -} - -// NginxWithDockerPool is an option to assign docker pool -func NginxWithDockerPool(pool *dockertest.Pool) dockerNginxOption { - return func(dc *dockerNginx) { - dc.pool = pool - } -} - -// NginxWithDockerPool is an option to assign docker pool -func NginxWithExposedPort(port string) dockerNginxOption { - return func(dc *dockerNginx) { - dc.exposedPort = port - } -} - -func NginxWithPresetConfig(presetConfig string) dockerNginxOption { - return func(dc *dockerNginx) { - dc.presetConfig = presetConfig - } -} - -func NginxWithConfigVariables(cv map[string]string) dockerNginxOption { - return func(dc *dockerNginx) { - dc.configVariables = cv - } -} - -type dockerNginx struct { - network *dockertest.Network - pool *dockertest.Pool - exposedPort string - internalHost string - externalHost string - presetConfig string - versionTag string - healthEndpoint string - configVariables map[string]string - dockertestResource *dockertest.Resource -} - -// CreateNginx is a function to create a dockerized nginx -func CreateNginx(opts ...dockerNginxOption) (*dockerNginx, error) { - var ( - err error - dc = &dockerNginx{} - ) - - for _, opt := range opts { - opt(dc) - } - - name := fmt.Sprintf("nginx-%s", uuid.New().String()) - - if dc.pool == nil { - dc.pool, err = dockertest.NewPool("") - if err != nil { - return nil, fmt.Errorf("could not create dockertest pool: %w", err) - } - } - - if dc.versionTag == "" { - dc.versionTag = nginxDefaultVersionTag - } - - if dc.exposedPort == "" { - dc.exposedPort = nginxDefaultExposedPort - } - - if dc.healthEndpoint == "" { - dc.healthEndpoint = nginxDefaultHealthEndpoint - } - - runOpts := &dockertest.RunOptions{ - Name: name, - Repository: "nginx", - Tag: dc.versionTag, - ExposedPorts: []string{fmt.Sprintf("%s/tcp", dc.exposedPort)}, - } - - if dc.network != nil { - runOpts.NetworkID = dc.network.Network.ID - } - - var confString string - switch dc.presetConfig { - case "cortex": - confString = NginxCortexConfig - } - - tmpl := template.New("nginx-config") - parsedTemplate, err := tmpl.Parse(confString) - if err != nil { - return nil, err - } - var generatedConf bytes.Buffer - err = parsedTemplate.Execute(&generatedConf, dc.configVariables) - if err != nil { - // it is unlikely that the code returns error here - return nil, err - } - confString = generatedConf.String() - - pwd, err := os.Getwd() - if err != nil { - return nil, err - } - - var ( - confDestinationFolder = fmt.Sprintf("%s/tmp/dockertest-configs/nginx", pwd) - ) - - foldersPath := []string{confDestinationFolder} - for _, fp := range foldersPath { - if _, err := os.Stat(fp); os.IsNotExist(err) { - if err := os.MkdirAll(fp, 0777); err != nil { - return nil, err - } - } - } - - if err := os.WriteFile(path.Join(confDestinationFolder, "nginx.conf"), []byte(confString), fs.ModePerm); err != nil { - return nil, err - } - - dc.dockertestResource, err = dc.pool.RunWithOptions( - runOpts, - func(config *docker.HostConfig) { - config.RestartPolicy = docker.RestartPolicy{ - Name: "no", - } - config.Mounts = []docker.HostMount{ - { - Target: "/etc/nginx/nginx.conf", - Source: path.Join(confDestinationFolder, "nginx.conf"), - Type: "bind", - }, - } - }, - ) - if err != nil { - return nil, err - } - - externalPort := dc.dockertestResource.GetPort(fmt.Sprintf("%s/tcp", dc.exposedPort)) - dc.internalHost = fmt.Sprintf("%s:%s", name, dc.exposedPort) - dc.externalHost = fmt.Sprintf("localhost:%s", externalPort) - - if err = dc.dockertestResource.Expire(120); err != nil { - return nil, err - } - - // exponential backoff-retry, because the application in the container might not be ready to accept connections yet - dc.pool.MaxWait = 60 * time.Second - - if err = dc.pool.Retry(func() error { - httpClient := &http.Client{} - res, err := httpClient.Get(fmt.Sprintf("http://localhost:%s%s", externalPort, dc.healthEndpoint)) - if err != nil { - return err - } - - if res.StatusCode != 200 { - return fmt.Errorf("nginx server return status %d", res.StatusCode) - } - - return nil - }); err != nil { - err = fmt.Errorf("could not connect to docker: %w", err) - return nil, fmt.Errorf("could not connect to docker: %w", err) - } - - return dc, nil -} - -// GetPool returns docker pool -func (dc *dockerNginx) GetPool() *dockertest.Pool { - return dc.pool -} - -// GetResource returns docker resource -func (dc *dockerNginx) GetResource() *dockertest.Resource { - return dc.dockertestResource -} - -// GetInternalHost returns internal hostname and port -// e.g. internal-xxxxxx:8080 -func (dc *dockerNginx) GetInternalHost() string { - return dc.internalHost -} - -// GetExternalHost returns localhost and port -// e.g. localhost:51113 -func (dc *dockerNginx) GetExternalHost() string { - return dc.externalHost -} diff --git a/testing/dockertestx/postgres.go b/testing/dockertestx/postgres.go deleted file mode 100644 index ada933d..0000000 --- a/testing/dockertestx/postgres.go +++ /dev/null @@ -1,195 +0,0 @@ -package dockertestx - -import ( - "fmt" - "time" - - "github.com/google/uuid" - "github.com/jmoiron/sqlx" - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" - "github.com/raystack/salt/observability/logger" -) - -const ( - defaultPGUname = "test_user" - defaultPGPasswd = "test_pass" - defaultDBname = "test_db" -) - -type dockerPostgresOption func(dpg *dockerPostgres) - -func PostgresWithLogger(logger logger.Logger) dockerPostgresOption { - return func(dpg *dockerPostgres) { - dpg.logger = logger - } -} - -// PostgresWithDockertestNetwork is an option to assign docker network -func PostgresWithDockertestNetwork(network *dockertest.Network) dockerPostgresOption { - return func(dpg *dockerPostgres) { - dpg.network = network - } -} - -// PostgresWithDockertestResourceExpiry is an option to assign docker resource expiry time -func PostgresWithDockertestResourceExpiry(expiryInSeconds uint) dockerPostgresOption { - return func(dpg *dockerPostgres) { - dpg.expiryInSeconds = expiryInSeconds - } -} - -// PostgresWithDetail is an option to assign custom details -// like username, password, and database name -func PostgresWithDetail( - username string, - password string, - dbName string, -) dockerPostgresOption { - return func(dpg *dockerPostgres) { - dpg.username = username - dpg.password = password - dpg.dbName = dbName - } -} - -// PostgresWithVersionTag is an option to assign release tag -// of a `postgres` image -func PostgresWithVersionTag(versionTag string) dockerPostgresOption { - return func(dpg *dockerPostgres) { - dpg.versionTag = versionTag - } -} - -// PostgresWithDockerPool is an option to assign docker pool -func PostgresWithDockerPool(pool *dockertest.Pool) dockerPostgresOption { - return func(dpg *dockerPostgres) { - dpg.pool = pool - } -} - -type dockerPostgres struct { - logger logger.Logger - network *dockertest.Network - pool *dockertest.Pool - username string - password string - dbName string - versionTag string - connStringInternal string - connStringExternal string - expiryInSeconds uint - dockertestResource *dockertest.Resource -} - -// CreatePostgres creates a postgres instance with default configurations -func CreatePostgres(opts ...dockerPostgresOption) (*dockerPostgres, error) { - var ( - err error - dpg = &dockerPostgres{} - ) - - for _, opt := range opts { - opt(dpg) - } - - name := fmt.Sprintf("postgres-%s", uuid.New().String()) - - if dpg.pool == nil { - dpg.pool, err = dockertest.NewPool("") - if err != nil { - return nil, fmt.Errorf("could not create dockertest pool: %w", err) - } - } - - if dpg.username == "" { - dpg.username = defaultPGUname - } - - if dpg.password == "" { - dpg.password = defaultPGPasswd - } - - if dpg.dbName == "" { - dpg.dbName = defaultDBname - } - - if dpg.versionTag == "" { - dpg.versionTag = "12" - } - - if dpg.expiryInSeconds == 0 { - dpg.expiryInSeconds = 120 - } - - runOpts := &dockertest.RunOptions{ - Name: name, - Repository: "postgres", - Tag: dpg.versionTag, - Env: []string{ - "POSTGRES_PASSWORD=" + dpg.password, - "POSTGRES_USER=" + dpg.username, - "POSTGRES_DB=" + dpg.dbName, - }, - ExposedPorts: []string{"5432/tcp"}, - } - - if dpg.network != nil { - runOpts.NetworkID = dpg.network.Network.ID - } - - dpg.dockertestResource, err = dpg.pool.RunWithOptions( - runOpts, - func(config *docker.HostConfig) { - config.RestartPolicy = docker.RestartPolicy{ - Name: "no", - } - }, - ) - if err != nil { - return nil, err - } - - pgPort := dpg.dockertestResource.GetPort("5432/tcp") - dpg.connStringInternal = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dpg.username, dpg.password, name, "5432", dpg.dbName) - dpg.connStringExternal = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dpg.username, dpg.password, "localhost", pgPort, dpg.dbName) - - if err = dpg.dockertestResource.Expire(dpg.expiryInSeconds); err != nil { - return nil, err - } - - // exponential backoff-retry, because the application in the container might not be ready to accept connections yet - dpg.pool.MaxWait = 60 * time.Second - - if err = dpg.pool.Retry(func() error { - if _, err := sqlx.Connect("postgres", dpg.connStringExternal); err != nil { - return err - } - return nil - }); err != nil { - err = fmt.Errorf("could not connect to docker: %w", err) - return nil, fmt.Errorf("could not connect to docker: %w", err) - } - - return dpg, nil -} - -// GetInternalConnString returns internal connection string of a postgres instance -func (dpg *dockerPostgres) GetInternalConnString() string { - return dpg.connStringInternal -} - -// GetExternalConnString returns external connection string of a postgres instance -func (dpg *dockerPostgres) GetExternalConnString() string { - return dpg.connStringExternal -} - -// GetPool returns docker pool -func (dpg *dockerPostgres) GetPool() *dockertest.Pool { - return dpg.pool -} - -// GetResource returns docker resource -func (dpg *dockerPostgres) GetResource() *dockertest.Resource { - return dpg.dockertestResource -} diff --git a/testing/dockertestx/spicedb.go b/testing/dockertestx/spicedb.go deleted file mode 100644 index bb7b3c4..0000000 --- a/testing/dockertestx/spicedb.go +++ /dev/null @@ -1,178 +0,0 @@ -package dockertestx - -import ( - "context" - "fmt" - "time" - - authzedpb "github.com/authzed/authzed-go/proto/authzed/api/v1" - "github.com/authzed/authzed-go/v1" - "github.com/authzed/grpcutil" - "github.com/google/uuid" - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/status" -) - -const ( - defaultPreSharedKey = "default-preshared-key" - defaultLogLevel = "debug" -) - -type dockerSpiceDBOption func(dsp *dockerSpiceDB) - -func SpiceDBWithLogLevel(logLevel string) dockerSpiceDBOption { - return func(dsp *dockerSpiceDB) { - dsp.logLevel = logLevel - } -} - -// SpiceDBWithDockertestNetwork is an option to assign docker network -func SpiceDBWithDockertestNetwork(network *dockertest.Network) dockerSpiceDBOption { - return func(dsp *dockerSpiceDB) { - dsp.network = network - } -} - -// SpiceDBWithVersionTag is an option to assign release tag -// of a `quay.io/authzed/spicedb` image -func SpiceDBWithVersionTag(versionTag string) dockerSpiceDBOption { - return func(dsp *dockerSpiceDB) { - dsp.versionTag = versionTag - } -} - -// SpiceDBWithDockerPool is an option to assign docker pool -func SpiceDBWithDockerPool(pool *dockertest.Pool) dockerSpiceDBOption { - return func(dsp *dockerSpiceDB) { - dsp.pool = pool - } -} - -// SpiceDBWithPreSharedKey is an option to assign pre-shared-key -func SpiceDBWithPreSharedKey(preSharedKey string) dockerSpiceDBOption { - return func(dsp *dockerSpiceDB) { - dsp.preSharedKey = preSharedKey - } -} - -type dockerSpiceDB struct { - network *dockertest.Network - pool *dockertest.Pool - preSharedKey string - versionTag string - logLevel string - externalPort string - dockertestResource *dockertest.Resource -} - -// CreateSpiceDB creates a spicedb instance with postgres backend and default configurations -func CreateSpiceDB(postgresConnectionURL string, opts ...dockerSpiceDBOption) (*dockerSpiceDB, error) { - var ( - err error - dsp = &dockerSpiceDB{} - ) - - for _, opt := range opts { - opt(dsp) - } - - name := fmt.Sprintf("spicedb-%s", uuid.New().String()) - - if dsp.pool == nil { - dsp.pool, err = dockertest.NewPool("") - if err != nil { - return nil, fmt.Errorf("could not create dockertest pool: %w", err) - } - } - - if dsp.preSharedKey == "" { - dsp.preSharedKey = defaultPreSharedKey - } - - if dsp.logLevel == "" { - dsp.logLevel = defaultLogLevel - } - - if dsp.versionTag == "" { - dsp.versionTag = "v1.0.0" - } - - runOpts := &dockertest.RunOptions{ - Name: name, - Repository: "quay.io/authzed/spicedb", - Tag: dsp.versionTag, - Cmd: []string{"spicedb", "serve", "--log-level", dsp.logLevel, "--grpc-preshared-key", dsp.preSharedKey, "--grpc-no-tls", "--datastore-engine", "postgres", "--datastore-conn-uri", postgresConnectionURL}, - ExposedPorts: []string{"50051/tcp"}, - } - - if dsp.network != nil { - runOpts.NetworkID = dsp.network.Network.ID - } - - dsp.dockertestResource, err = dsp.pool.RunWithOptions( - runOpts, - func(config *docker.HostConfig) { - config.RestartPolicy = docker.RestartPolicy{ - Name: "no", - } - }, - ) - if err != nil { - return nil, err - } - - dsp.externalPort = dsp.dockertestResource.GetPort("50051/tcp") - - if err = dsp.dockertestResource.Expire(120); err != nil { - return nil, err - } - - // exponential backoff-retry, because the application in the container might not be ready to accept connections yet - dsp.pool.MaxWait = 60 * time.Second - - if err = dsp.pool.Retry(func() error { - client, err := authzed.NewClient( - fmt.Sprintf("localhost:%s", dsp.externalPort), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpcutil.WithInsecureBearerToken(dsp.preSharedKey), - ) - if err != nil { - return err - } - _, err = client.ReadSchema(context.Background(), &authzedpb.ReadSchemaRequest{}) - grpCStatus := status.Convert(err) - if grpCStatus.Code() == codes.Unavailable { - return err - } - return nil - }); err != nil { - err = fmt.Errorf("could not connect to docker: %w", err) - return nil, fmt.Errorf("could not connect to docker: %w", err) - } - - return dsp, nil -} - -// GetExternalPort returns exposed port of the spicedb instance -func (dsp *dockerSpiceDB) GetExternalPort() string { - return dsp.externalPort -} - -// GetPreSharedKey returns pre-shared-key used in the spicedb instance -func (dsp *dockerSpiceDB) GetPreSharedKey() string { - return dsp.preSharedKey -} - -// GetPool returns docker pool -func (dsp *dockerSpiceDB) GetPool() *dockertest.Pool { - return dsp.pool -} - -// GetResource returns docker resource -func (dsp *dockerSpiceDB) GetResource() *dockertest.Resource { - return dsp.dockertestResource -} diff --git a/testing/dockertestx/spicedb_migrate.go b/testing/dockertestx/spicedb_migrate.go deleted file mode 100644 index cc2dd78..0000000 --- a/testing/dockertestx/spicedb_migrate.go +++ /dev/null @@ -1,118 +0,0 @@ -package dockertestx - -import ( - "bytes" - "context" - "fmt" - - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" -) - -type dockerMigrateSpiceDBOption func(dmm *dockerMigrateSpiceDB) - -// MigrateSpiceDBWithDockertestNetwork is an option to assign docker network -func MigrateSpiceDBWithDockertestNetwork(network *dockertest.Network) dockerMigrateSpiceDBOption { - return func(dm *dockerMigrateSpiceDB) { - dm.network = network - } -} - -// MigrateSpiceDBWithVersionTag is an option to assign release tag -// of a `quay.io/authzed/spicedb` image -func MigrateSpiceDBWithVersionTag(versionTag string) dockerMigrateSpiceDBOption { - return func(dm *dockerMigrateSpiceDB) { - dm.versionTag = versionTag - } -} - -// MigrateSpiceDBWithDockerPool is an option to assign docker pool -func MigrateSpiceDBWithDockerPool(pool *dockertest.Pool) dockerMigrateSpiceDBOption { - return func(dm *dockerMigrateSpiceDB) { - dm.pool = pool - } -} - -type dockerMigrateSpiceDB struct { - network *dockertest.Network - pool *dockertest.Pool - versionTag string -} - -// MigrateSpiceDB migrates spicedb with postgres backend -func MigrateSpiceDB(postgresConnectionURL string, opts ...dockerMigrateMinioOption) error { - var ( - err error - dm = &dockerMigrateMinio{} - ) - - for _, opt := range opts { - opt(dm) - } - - if dm.pool == nil { - dm.pool, err = dockertest.NewPool("") - if err != nil { - return fmt.Errorf("could not create dockertest pool: %w", err) - } - } - - if dm.versionTag == "" { - dm.versionTag = "v1.0.0" - } - - runOpts := &dockertest.RunOptions{ - Repository: "quay.io/authzed/spicedb", - Tag: dm.versionTag, - Cmd: []string{"spicedb", "migrate", "head", "--datastore-engine", "postgres", "--datastore-conn-uri", postgresConnectionURL}, - } - - if dm.network != nil { - runOpts.NetworkID = dm.network.Network.ID - } - - resource, err := dm.pool.RunWithOptions(runOpts, func(config *docker.HostConfig) { - config.RestartPolicy = docker.RestartPolicy{ - Name: "no", - } - }) - if err != nil { - return err - } - - if err := resource.Expire(120); err != nil { - return err - } - - waitCtx, cancel := context.WithTimeout(context.Background(), waitContainerTimeout) - defer cancel() - - // Ensure the command completed successfully. - status, err := dm.pool.Client.WaitContainerWithContext(resource.Container.ID, waitCtx) - if err != nil { - return err - } - - if status != 0 { - stream := new(bytes.Buffer) - - if err = dm.pool.Client.Logs(docker.LogsOptions{ - Context: waitCtx, - OutputStream: stream, - ErrorStream: stream, - Stdout: true, - Stderr: true, - Container: resource.Container.ID, - }); err != nil { - return err - } - - return fmt.Errorf("got non-zero exit code %s", stream.String()) - } - - if err := dm.pool.Purge(resource); err != nil { - return err - } - - return nil -} From 6f8fc369f404808deb2848c734eb5c8c9ff589aa Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 17:52:24 -0500 Subject: [PATCH 02/30] feat(server): add h2c server with health checks and timeouts New server package replacing server/mux. Single port, h2c by default: srv := server.New( server.WithAddr(":8080"), server.WithHandler("/api/", handler), ) srv.Start(ctx) Defaults: h2c enabled, health check at /ping, 10s grace period. Supports HTTP middleware, read/write/idle timeouts, graceful shutdown. --- server/example_test.go | 40 +++++++++++ server/option.go | 91 ++++++++++++++++++++++++ server/server.go | 118 +++++++++++++++++++++++++++++++ server/server_test.go | 153 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+) create mode 100644 server/example_test.go create mode 100644 server/option.go create mode 100644 server/server.go create mode 100644 server/server_test.go diff --git a/server/example_test.go b/server/example_test.go new file mode 100644 index 0000000..cc9d609 --- /dev/null +++ b/server/example_test.go @@ -0,0 +1,40 @@ +package server_test + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/raystack/salt/server" +) + +func ExampleNew() { + srv := server.New( + server.WithAddr(":8080"), + server.WithHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "world") + })), + server.WithLogger(slog.Default()), + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + srv.Start(ctx) +} + +func ExampleNew_withTimeouts() { + srv := server.New( + server.WithAddr(":8080"), + server.WithReadTimeout(60*time.Second), + server.WithWriteTimeout(60*time.Second), + server.WithIdleTimeout(120*time.Second), + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + srv.Start(ctx) +} diff --git a/server/option.go b/server/option.go new file mode 100644 index 0000000..b96157b --- /dev/null +++ b/server/option.go @@ -0,0 +1,91 @@ +package server + +import ( + "log/slog" + "net/http" + "time" +) + +// Option configures a Server. +type Option func(*Server) + +// WithAddr sets the listen address (default ":8080"). +func WithAddr(addr string) Option { + return func(s *Server) { + s.addr = addr + } +} + +// WithoutH2C disables HTTP/2 cleartext support. +// H2C is enabled by default for ConnectRPC compatibility. +func WithoutH2C() Option { + return func(s *Server) { + s.h2c = false + } +} + +// WithHandler registers an HTTP handler at the given pattern on the server's mux. +func WithHandler(pattern string, handler http.Handler) Option { + return func(s *Server) { + s.mux.Handle(pattern, handler) + } +} + +// WithHealthCheck sets the health check endpoint path. +// Default is "/ping". Pass an empty string to disable. +func WithHealthCheck(path string) Option { + return func(s *Server) { + s.healthPath = path + } +} + +// WithGracePeriod sets the maximum duration to wait for in-flight +// requests to complete during shutdown (default 10s). +func WithGracePeriod(d time.Duration) Option { + return func(s *Server) { + if d > 0 { + s.gracePeriod = d + } + } +} + +// WithLogger sets the logger for server lifecycle events. +func WithLogger(l *slog.Logger) Option { + return func(s *Server) { + if l != nil { + s.logger = l + } + } +} + +// WithHTTPMiddleware adds HTTP middleware to the server. +// Middleware is applied in order (first wraps outermost). +func WithHTTPMiddleware(mw ...func(http.Handler) http.Handler) Option { + return func(s *Server) { + s.httpMW = append(s.httpMW, mw...) + } +} + +// WithReadTimeout sets the maximum duration for reading the entire request. +// Zero means no timeout. +func WithReadTimeout(d time.Duration) Option { + return func(s *Server) { + s.readTimeout = d + } +} + +// WithWriteTimeout sets the maximum duration for writing the response. +// Zero means no timeout. +func WithWriteTimeout(d time.Duration) Option { + return func(s *Server) { + s.writeTimeout = d + } +} + +// WithIdleTimeout sets the maximum duration to wait for the next request +// on a keep-alive connection. Zero means no timeout. +func WithIdleTimeout(d time.Duration) Option { + return func(s *Server) { + s.idleTimeout = d + } +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..1109aa8 --- /dev/null +++ b/server/server.go @@ -0,0 +1,118 @@ +// Package server provides an HTTP server with h2c support. +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "github.com/raystack/salt/middleware" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +const ( + defaultAddr = ":8080" + defaultGracePeriod = 10 * time.Second + defaultHealthPath = "/ping" +) + +// Server is an HTTP server with h2c (HTTP/2 cleartext) support, +// health checks, HTTP middleware, and graceful shutdown. +// +// By default, h2c is enabled and a health check is served at /ping. +type Server struct { + addr string + mux *http.ServeMux + h2c bool + healthPath string + gracePeriod time.Duration + readTimeout time.Duration + writeTimeout time.Duration + idleTimeout time.Duration + logger *slog.Logger + httpMW []func(http.Handler) http.Handler +} + +// New creates a new Server with the given options. +// Defaults: h2c enabled, health check at /ping, grace period 10s. +func New(opts ...Option) *Server { + s := &Server{ + addr: defaultAddr, + mux: http.NewServeMux(), + h2c: true, + healthPath: defaultHealthPath, + gracePeriod: defaultGracePeriod, + logger: slog.New(slog.DiscardHandler), + } + for _, opt := range opts { + opt(s) + } + if s.healthPath != "" { + s.mux.HandleFunc(s.healthPath, healthHandler) + } + return s +} + +// Start begins serving and blocks until the context is cancelled. +// It performs graceful shutdown when the context is done. +func (s *Server) Start(ctx context.Context) error { + var handler http.Handler = s.mux + + // Apply HTTP middleware chain (outermost first). + if len(s.httpMW) > 0 { + handler = middleware.ChainHTTP(s.httpMW...)(handler) + } + + if s.h2c { + handler = h2c.NewHandler(handler, &http2.Server{}) + } + + srv := &http.Server{ + Handler: handler, + ReadTimeout: s.readTimeout, + WriteTimeout: s.writeTimeout, + IdleTimeout: s.idleTimeout, + } + + ln, err := net.Listen("tcp", s.addr) + if err != nil { + return fmt.Errorf("server listen: %w", err) + } + + s.logger.Info("server started", "addr", ln.Addr().String()) + + errCh := make(chan error, 1) + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + close(errCh) + }() + + select { + case err := <-errCh: + return fmt.Errorf("server serve: %w", err) + case <-ctx.Done(): + } + + s.logger.Info("server shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), s.gracePeriod) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("server shutdown: %w", err) + } + s.logger.Info("server stopped") + return nil +} + +func healthHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"status":"ok"}`) +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..02440dd --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,153 @@ +package server_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/raystack/salt/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServer(t *testing.T) { + t.Run("health check enabled by default", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + srv := server.New( + server.WithAddr("127.0.0.1:18923"), + ) + + go srv.Start(ctx) + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get("http://127.0.0.1:18923/ping") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + body, _ := io.ReadAll(resp.Body) + var result map[string]string + err = json.Unmarshal(body, &result) + assert.NoError(t, err) + assert.Equal(t, "ok", result["status"]) + + cancel() + }) + + t.Run("h2c enabled by default", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + srv := server.New( + server.WithAddr("127.0.0.1:18924"), + ) + + go srv.Start(ctx) + time.Sleep(100 * time.Millisecond) + + // HTTP/1.1 still works with h2c enabled + resp, err := http.Get("http://127.0.0.1:18924/ping") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + cancel() + }) + + t.Run("serves custom handler", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "hello") + }) + + srv := server.New( + server.WithAddr("127.0.0.1:18925"), + server.WithHandler("/hello", handler), + ) + + go srv.Start(ctx) + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get("http://127.0.0.1:18925/hello") + require.NoError(t, err) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, "hello", string(body)) + + cancel() + }) + + t.Run("graceful shutdown completes", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + srv := server.New( + server.WithAddr("127.0.0.1:0"), + server.WithGracePeriod(1*time.Second), + ) + + errCh := make(chan error, 1) + go func() { errCh <- srv.Start(ctx) }() + + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + assert.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("shutdown did not complete in time") + } + }) + + t.Run("custom health check path", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + srv := server.New( + server.WithAddr("127.0.0.1:18926"), + server.WithHealthCheck("/healthz"), + ) + + go srv.Start(ctx) + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get("http://127.0.0.1:18926/healthz") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + cancel() + }) + + t.Run("disable health check", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + srv := server.New( + server.WithAddr("127.0.0.1:18927"), + server.WithHealthCheck(""), + ) + + go srv.Start(ctx) + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get("http://127.0.0.1:18927/ping") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + cancel() + }) +} From e7e41efaa8f0c2abb1dbff166e798eb6a7ebb41d Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 17:52:25 -0500 Subject: [PATCH 03/30] feat(middleware): add Connect interceptors and HTTP middleware ConnectRPC interceptors + net/http middleware: - recovery, requestid, requestlog, errorz, cors Chain builders: Default(logger) for Connect, DefaultHTTP(logger) for HTTP. --- middleware/cors/cors.go | 113 +++++++++++++++++++++ middleware/errorz/errorz.go | 84 ++++++++++++++++ middleware/example_test.go | 50 ++++++++++ middleware/middleware.go | 47 +++++++++ middleware/middleware_test.go | 147 ++++++++++++++++++++++++++++ middleware/recovery/recovery.go | 76 ++++++++++++++ middleware/requestid/requestid.go | 62 ++++++++++++ middleware/requestlog/requestlog.go | 114 +++++++++++++++++++++ 8 files changed, 693 insertions(+) create mode 100644 middleware/cors/cors.go create mode 100644 middleware/errorz/errorz.go create mode 100644 middleware/example_test.go create mode 100644 middleware/middleware.go create mode 100644 middleware/middleware_test.go create mode 100644 middleware/recovery/recovery.go create mode 100644 middleware/requestid/requestid.go create mode 100644 middleware/requestlog/requestlog.go diff --git a/middleware/cors/cors.go b/middleware/cors/cors.go new file mode 100644 index 0000000..befbec2 --- /dev/null +++ b/middleware/cors/cors.go @@ -0,0 +1,113 @@ +// Package cors provides CORS middleware with Connect-specific defaults. +package cors + +import ( + "net/http" + "strconv" + "strings" +) + +// Option configures the CORS middleware. +type Option func(*config) + +type config struct { + allowedOrigins []string + allowedMethods []string + allowedHeaders []string + maxAge int +} + +// WithAllowedOrigins sets the allowed origins. Use "*" to allow all. +func WithAllowedOrigins(origins ...string) Option { + return func(c *config) { c.allowedOrigins = origins } +} + +// WithAllowedMethods sets the allowed HTTP methods. +func WithAllowedMethods(methods ...string) Option { + return func(c *config) { c.allowedMethods = methods } +} + +// WithAllowedHeaders sets the allowed request headers. +func WithAllowedHeaders(headers ...string) Option { + return func(c *config) { c.allowedHeaders = headers } +} + +// WithMaxAge sets the max age (in seconds) for preflight cache. +func WithMaxAge(seconds int) Option { + return func(c *config) { c.maxAge = seconds } +} + +// Defaults returns sensible CORS defaults for ConnectRPC services. +// Includes Connect-specific headers. +func Defaults() []Option { + return []Option{ + WithAllowedOrigins("*"), + WithAllowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"), + WithAllowedHeaders( + "Content-Type", + "Connect-Protocol-Version", + "Connect-Timeout-Ms", + "Grpc-Timeout", + "X-Grpc-Web", + "X-User-Agent", + "X-Request-ID", + "Authorization", + ), + WithMaxAge(7200), + } +} + +func newConfig(opts []Option) *config { + c := &config{} + // Apply defaults first, then user overrides. + for _, opt := range Defaults() { + opt(c) + } + for _, opt := range opts { + opt(c) + } + return c +} + +// Middleware returns net/http CORS middleware. +func Middleware(opts ...Option) func(http.Handler) http.Handler { + cfg := newConfig(opts) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + next.ServeHTTP(w, r) + return + } + + if isOriginAllowed(cfg.allowedOrigins, origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + + w.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.allowedMethods, ", ")) + w.Header().Set("Access-Control-Allow-Headers", strings.Join(cfg.allowedHeaders, ", ")) + + if cfg.maxAge > 0 { + w.Header().Set("Access-Control-Max-Age", strconv.Itoa(cfg.maxAge)) + } + + w.Header().Set("Vary", "Origin") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func isOriginAllowed(allowed []string, origin string) bool { + for _, a := range allowed { + if a == "*" || a == origin { + return true + } + } + return false +} diff --git a/middleware/errorz/errorz.go b/middleware/errorz/errorz.go new file mode 100644 index 0000000..74e5c31 --- /dev/null +++ b/middleware/errorz/errorz.go @@ -0,0 +1,84 @@ +// Package errorz provides error sanitization middleware for Connect services. +package errorz + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "connectrpc.com/connect" +) + +// Option configures the error sanitization middleware. +type Option func(*config) + +type config struct { + verbose bool + logger *slog.Logger +} + +// WithVerbose enables full error messages in responses. +// Useful for development/staging environments. +func WithVerbose(v bool) Option { + return func(c *config) { c.verbose = v } +} + +// WithLogger sets the logger for recording original errors before sanitization. +func WithLogger(l *slog.Logger) Option { + return func(c *config) { c.logger = l } +} + +func newConfig(opts []Option) *config { + c := &config{logger: slog.New(slog.DiscardHandler)} + for _, opt := range opts { + opt(c) + } + return c +} + +// NewInterceptor returns a Connect interceptor that sanitizes internal errors. +// Non-Connect errors are mapped to CodeInternal with a timestamp reference. +// Connect errors with known codes are passed through. +func NewInterceptor(opts ...Option) connect.UnaryInterceptorFunc { + cfg := newConfig(opts) + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + resp, err := next(ctx, req) + if err == nil { + return resp, nil + } + + // If it's already a Connect error, preserve the code. + var connectErr *connect.Error + if errors.As(err, &connectErr) { + if cfg.verbose { + return resp, err + } + // Preserve code but sanitize message for client-facing codes. + code := connectErr.Code() + if code == connect.CodeInternal || code == connect.CodeUnknown { + ref := time.Now().Unix() + cfg.logger.Error("internal error", + "error", err.Error(), + "ref", ref, + ) + return resp, connect.NewError(code, fmt.Errorf("internal error (ref: %d)", ref)) + } + return resp, err + } + + // Non-Connect error: sanitize completely. + ref := time.Now().Unix() + cfg.logger.Error("internal error", + "error", err.Error(), + "ref", ref, + ) + if cfg.verbose { + return resp, connect.NewError(connect.CodeInternal, err) + } + return resp, connect.NewError(connect.CodeInternal, fmt.Errorf("internal error (ref: %d)", ref)) + } + } +} diff --git a/middleware/example_test.go b/middleware/example_test.go new file mode 100644 index 0000000..638ce1a --- /dev/null +++ b/middleware/example_test.go @@ -0,0 +1,50 @@ +package middleware_test + +import ( + "log/slog" + "net/http" + + "github.com/raystack/salt/middleware" + "github.com/raystack/salt/middleware/cors" + "github.com/raystack/salt/middleware/recovery" + "github.com/raystack/salt/middleware/requestid" + "github.com/raystack/salt/middleware/requestlog" +) + +func ExampleDefault() { + logger := slog.Default() + + // Use Default() for the standard Connect interceptor chain. + // Apply to your ConnectRPC handler: + // + // path, handler := myv1connect.NewServiceHandler(svc, + // connect.WithInterceptors(middleware.Default(logger)...), + // ) + _ = middleware.Default(logger) +} + +func ExampleDefaultHTTP() { + logger := slog.Default() + + // Use DefaultHTTP() for the standard HTTP middleware chain. + // Apply to app or server: + // + // app.WithHTTPMiddleware(middleware.DefaultHTTP(logger)) + handler := middleware.DefaultHTTP(logger)(http.NotFoundHandler()) + _ = handler +} + +func ExampleChainHTTP() { + logger := slog.Default() + + // Compose a custom HTTP middleware chain. + chain := middleware.ChainHTTP( + recovery.HTTPMiddleware(recovery.WithLogger(logger)), + requestid.HTTPMiddleware(), + requestlog.HTTPMiddleware(requestlog.WithLogger(logger)), + cors.Middleware(cors.WithAllowedOrigins("https://myapp.com")), + ) + + handler := chain(http.NotFoundHandler()) + _ = handler +} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..ad9a8d7 --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,47 @@ +// Package middleware provides Connect interceptors and HTTP middleware. +package middleware + +import ( + "log/slog" + "net/http" + + "connectrpc.com/connect" + "github.com/raystack/salt/middleware/cors" + "github.com/raystack/salt/middleware/errorz" + "github.com/raystack/salt/middleware/recovery" + "github.com/raystack/salt/middleware/requestid" + "github.com/raystack/salt/middleware/requestlog" +) + +// Default returns the standard raystack Connect interceptor chain: +// recovery → requestid → requestlog → errorz +func Default(l *slog.Logger) []connect.Interceptor { + return []connect.Interceptor{ + recovery.NewInterceptor(recovery.WithLogger(l)), + requestid.NewInterceptor(), + requestlog.NewInterceptor(requestlog.WithLogger(l)), + errorz.NewInterceptor(errorz.WithLogger(l)), + } +} + +// DefaultHTTP returns the standard raystack HTTP middleware chain: +// recovery → requestid → requestlog → cors +func DefaultHTTP(l *slog.Logger, corsOpts ...cors.Option) func(http.Handler) http.Handler { + return ChainHTTP( + recovery.HTTPMiddleware(recovery.WithLogger(l)), + requestid.HTTPMiddleware(), + requestlog.HTTPMiddleware(requestlog.WithLogger(l)), + cors.Middleware(corsOpts...), + ) +} + +// ChainHTTP chains net/http middleware in order. +// The first middleware wraps outermost (processes request first). +func ChainHTTP(mws ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { + return func(final http.Handler) http.Handler { + for i := len(mws) - 1; i >= 0; i-- { + final = mws[i](final) + } + return final + } +} diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go new file mode 100644 index 0000000..391c727 --- /dev/null +++ b/middleware/middleware_test.go @@ -0,0 +1,147 @@ +package middleware_test + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/raystack/salt/middleware" + "github.com/raystack/salt/middleware/cors" + "github.com/raystack/salt/middleware/requestid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func nopLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } + +func TestDefault(t *testing.T) { + interceptors := middleware.Default(nopLogger()) + assert.Len(t, interceptors, 4) +} + +func TestDefaultHTTP(t *testing.T) { + chain := middleware.DefaultHTTP(nopLogger()) + assert.NotNil(t, chain) + + handler := chain(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + // Should have request ID in response + assert.NotEmpty(t, rec.Header().Get(requestid.Header)) +} + +func TestChainHTTP(t *testing.T) { + t.Run("chains in order", func(t *testing.T) { + var order []string + mw1 := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, "first") + next.ServeHTTP(w, r) + }) + } + mw2 := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, "second") + next.ServeHTTP(w, r) + }) + } + + chain := middleware.ChainHTTP(mw1, mw2) + handler := chain(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + order = append(order, "handler") + })) + + req := httptest.NewRequest("GET", "/", nil) + handler.ServeHTTP(httptest.NewRecorder(), req) + assert.Equal(t, []string{"first", "second", "handler"}, order) + }) +} + +func TestRecoveryHTTP(t *testing.T) { + chain := middleware.DefaultHTTP(nopLogger()) + handler := chain(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + panic("test panic") + })) + + req := httptest.NewRequest("GET", "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestRequestIDHTTP(t *testing.T) { + chain := middleware.DefaultHTTP(nopLogger()) + handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := requestid.FromContext(r.Context()) + w.Write([]byte(id)) + })) + + t.Run("generates ID when missing", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + body, _ := io.ReadAll(rec.Body) + assert.NotEmpty(t, string(body)) + assert.Equal(t, string(body), rec.Header().Get(requestid.Header)) + }) + + t.Run("propagates existing ID", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set(requestid.Header, "my-custom-id") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + body, _ := io.ReadAll(rec.Body) + assert.Equal(t, "my-custom-id", string(body)) + assert.Equal(t, "my-custom-id", rec.Header().Get(requestid.Header)) + }) +} + +func TestCORSMiddleware(t *testing.T) { + handler := cors.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + t.Run("preflight returns 204", func(t *testing.T) { + req := httptest.NewRequest("OPTIONS", "/", nil) + req.Header.Set("Origin", "http://example.com") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Equal(t, "http://example.com", rec.Header().Get("Access-Control-Allow-Origin")) + }) + + t.Run("regular request gets CORS headers", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Origin", "http://example.com") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "http://example.com", rec.Header().Get("Access-Control-Allow-Origin")) + }) + + t.Run("no Origin header skips CORS", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin")) + }) + + t.Run("includes Connect-specific headers", func(t *testing.T) { + req := httptest.NewRequest("OPTIONS", "/", nil) + req.Header.Set("Origin", "http://example.com") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + headers := rec.Header().Get("Access-Control-Allow-Headers") + require.Contains(t, headers, "Connect-Protocol-Version") + require.Contains(t, headers, "Connect-Timeout-Ms") + }) +} diff --git a/middleware/recovery/recovery.go b/middleware/recovery/recovery.go new file mode 100644 index 0000000..fb089b7 --- /dev/null +++ b/middleware/recovery/recovery.go @@ -0,0 +1,76 @@ +// Package recovery provides panic recovery middleware. +package recovery + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "runtime/debug" + + "connectrpc.com/connect" +) + +// Option configures the recovery middleware. +type Option func(*config) + +type config struct { + logger *slog.Logger + handler func(ctx context.Context, p any) error +} + +// WithLogger sets the logger for panic reporting. +func WithLogger(l *slog.Logger) Option { + return func(c *config) { c.logger = l } +} + +// WithHandler sets a custom panic handler. If it returns an error, +// that error is returned to the client. +func WithHandler(fn func(ctx context.Context, p any) error) Option { + return func(c *config) { c.handler = fn } +} + +func newConfig(opts []Option) *config { + c := &config{logger: slog.New(slog.DiscardHandler)} + for _, opt := range opts { + opt(c) + } + if c.handler == nil { + c.handler = func(_ context.Context, _ any) error { + return connect.NewError(connect.CodeInternal, fmt.Errorf("internal error")) + } + } + return c +} + +// NewInterceptor returns a Connect interceptor that recovers from panics. +func NewInterceptor(opts ...Option) connect.UnaryInterceptorFunc { + cfg := newConfig(opts) + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (resp connect.AnyResponse, err error) { + defer func() { + if p := recover(); p != nil { + cfg.logger.Error("panic recovered", "panic", p, "stack", string(debug.Stack())) + err = cfg.handler(ctx, p) + } + }() + return next(ctx, req) + } + } +} + +// HTTPMiddleware returns net/http middleware that recovers from panics. +func HTTPMiddleware(opts ...Option) func(http.Handler) http.Handler { + cfg := newConfig(opts) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if p := recover(); p != nil { + cfg.logger.Error("panic recovered", "panic", p, "stack", string(debug.Stack())) + w.WriteHeader(http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) + } +} diff --git a/middleware/requestid/requestid.go b/middleware/requestid/requestid.go new file mode 100644 index 0000000..9038b7e --- /dev/null +++ b/middleware/requestid/requestid.go @@ -0,0 +1,62 @@ +// Package requestid provides request ID propagation middleware. +package requestid + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + "github.com/google/uuid" +) + +// Header is the HTTP header used to propagate request IDs. +const Header = "X-Request-ID" + +type ctxKey struct{} + +// FromContext returns the request ID from the context, or empty string if not set. +func FromContext(ctx context.Context) string { + if id, ok := ctx.Value(ctxKey{}).(string); ok { + return id + } + return "" +} + +// NewContext returns a new context with the given request ID. +func NewContext(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, ctxKey{}, id) +} + +func extractOrGenerate(headers http.Header) string { + if id := headers.Get(Header); id != "" { + return id + } + return uuid.New().String() +} + +// NewInterceptor returns a Connect interceptor that propagates or generates request IDs. +func NewInterceptor() connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + id := extractOrGenerate(req.Header()) + ctx = NewContext(ctx, id) + resp, err := next(ctx, req) + if resp != nil { + resp.Header().Set(Header, id) + } + return resp, err + } + } +} + +// HTTPMiddleware returns net/http middleware that propagates or generates request IDs. +func HTTPMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := extractOrGenerate(r.Header) + ctx := NewContext(r.Context(), id) + w.Header().Set(Header, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/middleware/requestlog/requestlog.go b/middleware/requestlog/requestlog.go new file mode 100644 index 0000000..1063824 --- /dev/null +++ b/middleware/requestlog/requestlog.go @@ -0,0 +1,114 @@ +// Package requestlog provides request logging middleware. +package requestlog + +import ( + "context" + "log/slog" + "net/http" + "time" + + "connectrpc.com/connect" + "github.com/raystack/salt/middleware/requestid" +) + +// Option configures the request logging middleware. +type Option func(*config) + +type config struct { + logger *slog.Logger + filter func(procedure string) bool +} + +// WithLogger sets the logger. +func WithLogger(l *slog.Logger) Option { + return func(c *config) { c.logger = l } +} + +// WithFilter sets a filter function. If it returns true for a procedure, +// that procedure will not be logged. Useful for skipping health checks. +func WithFilter(fn func(procedure string) bool) Option { + return func(c *config) { c.filter = fn } +} + +func newConfig(opts []Option) *config { + c := &config{ + logger: slog.New(slog.DiscardHandler), + filter: func(string) bool { return false }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// NewInterceptor returns a Connect interceptor that logs requests. +func NewInterceptor(opts ...Option) connect.UnaryInterceptorFunc { + cfg := newConfig(opts) + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + procedure := req.Spec().Procedure + if cfg.filter(procedure) { + return next(ctx, req) + } + + start := time.Now() + resp, err := next(ctx, req) + duration := time.Since(start) + + rid := requestid.FromContext(ctx) + if err != nil { + cfg.logger.Error("request completed", + "procedure", procedure, + "duration", duration.String(), + "error", err.Error(), + "request_id", rid, + ) + } else { + cfg.logger.Info("request completed", + "procedure", procedure, + "duration", duration.String(), + "request_id", rid, + ) + } + return resp, err + } + } +} + +// HTTPMiddleware returns net/http middleware that logs requests. +func HTTPMiddleware(opts ...Option) func(http.Handler) http.Handler { + cfg := newConfig(opts) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if cfg.filter(path) { + next.ServeHTTP(w, r) + return + } + + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + duration := time.Since(start) + + rid := requestid.FromContext(r.Context()) + cfg.logger.Info("request completed", + "method", r.Method, + "path", path, + "status", sw.status, + "duration", duration.String(), + "request_id", rid, + ) + }) + } +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} From 12a2c291eff44d9e8ca84959271360fef133fd52 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 17:52:25 -0500 Subject: [PATCH 04/30] feat(app): add service bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.Run( app.WithLogger(slog.Default()), app.WithHTTPMiddleware(middleware.DefaultHTTP(slog.Default())), app.WithHandler("/api/", handler), app.WithAddr(cfg.Addr), ) No hidden magic — middleware is explicit. WithServer() passthrough for any server option. WithOnStart/WithOnStop hooks for lifecycle. --- app/app.go | 106 ++++++++++++++++++++++++++ app/app_test.go | 177 ++++++++++++++++++++++++++++++++++++++++++++ app/example_test.go | 37 +++++++++ app/option.go | 113 ++++++++++++++++++++++++++++ 4 files changed, 433 insertions(+) create mode 100644 app/app.go create mode 100644 app/app_test.go create mode 100644 app/example_test.go create mode 100644 app/option.go diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..4539344 --- /dev/null +++ b/app/app.go @@ -0,0 +1,106 @@ +// Package app provides a service lifecycle manager for raystack services. +package app + +import ( + "context" + "fmt" + "log/slog" + "os/signal" + "syscall" + + "github.com/raystack/salt/server" + "github.com/raystack/salt/telemetry" +) + +// App is a service lifecycle manager that wires together configuration, +// logging, database, telemetry, and HTTP serving with graceful shutdown. +// +// Defaults: h2c enabled, health check at /ping. +type App struct { + logger *slog.Logger + telCfg *telemetry.Config + telClean func() + serverOps []server.Option + onStart []func(context.Context) error + onStop []func(context.Context) error +} + +// New creates a new App by applying the given options. +func New(opts ...Option) (*App, error) { + a := &App{ + logger: slog.New(slog.DiscardHandler), + } + for _, opt := range opts { + if err := opt(a); err != nil { + return nil, fmt.Errorf("app option: %w", err) + } + } + return a, nil +} + +// Run is the simplest entry point: creates an App, starts it with signal +// handling (SIGINT, SIGTERM), and blocks until shutdown completes. +func Run(opts ...Option) error { + a, err := New(opts...) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + return a.Start(ctx) +} + +// Start initializes all components and starts the server. +// It blocks until the context is cancelled, then performs graceful shutdown. +func (a *App) Start(ctx context.Context) error { + // Initialize telemetry if configured. + if a.telCfg != nil { + cleanup, err := telemetry.Init(ctx, *a.telCfg, a.logger) + if err != nil { + return fmt.Errorf("app telemetry: %w", err) + } + a.telClean = cleanup + } + + // Run onStart hooks. + for _, fn := range a.onStart { + if err := fn(ctx); err != nil { + a.cleanup() + return fmt.Errorf("app on_start: %w", err) + } + } + + // Build server with logger. + opts := make([]server.Option, len(a.serverOps), len(a.serverOps)+1) + copy(opts, a.serverOps) + opts = append(opts, server.WithLogger(a.logger)) + srv := server.New(opts...) + + err := srv.Start(ctx) + + // Shutdown sequence. + a.stop(context.Background()) + return err +} + +// Logger returns the app's logger. +func (a *App) Logger() *slog.Logger { + return a.logger +} + +func (a *App) stop(ctx context.Context) { + for _, fn := range a.onStop { + if err := fn(ctx); err != nil { + a.logger.Error("app on_stop hook error", "error", err) + } + } + a.cleanup() +} + +func (a *App) cleanup() { + if a.telClean != nil { + a.telClean() + } +} diff --git a/app/app_test.go b/app/app_test.go new file mode 100644 index 0000000..3363bfd --- /dev/null +++ b/app/app_test.go @@ -0,0 +1,177 @@ +package app_test + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/raystack/salt/app" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func nopLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } + +func TestNew(t *testing.T) { + t.Run("creates app with defaults", func(t *testing.T) { + a, err := app.New() + require.NoError(t, err) + assert.NotNil(t, a) + assert.NotNil(t, a.Logger()) + }) + + t.Run("sets logger", func(t *testing.T) { + l := nopLogger() + a, err := app.New(app.WithLogger(l)) + require.NoError(t, err) + assert.Equal(t, l, a.Logger()) + }) + + t.Run("returns error from option", func(t *testing.T) { + badOpt := func(_ *app.App) error { + return fmt.Errorf("bad option") + } + _, err := app.New(badOpt) + assert.Error(t, err) + assert.Contains(t, err.Error(), "bad option") + }) +} + +func TestAppStartAndShutdown(t *testing.T) { + t.Run("starts with health check and h2c by default", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + a, err := app.New( + app.WithLogger(nopLogger()), + app.WithAddr("127.0.0.1:18950"), + ) + require.NoError(t, err) + + errCh := make(chan error, 1) + go func() { errCh <- a.Start(ctx) }() + + time.Sleep(100 * time.Millisecond) + + // Health check should be on by default at /ping + resp, err := http.Get("http://127.0.0.1:18950/ping") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + cancel() + + select { + case err := <-errCh: + assert.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("shutdown timed out") + } + }) + + t.Run("runs onStart hooks", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + var hookRan bool + a, err := app.New( + app.WithAddr("127.0.0.1:18951"), + app.WithOnStart(func(_ context.Context) error { + hookRan = true + return nil + }), + ) + require.NoError(t, err) + + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + a.Start(ctx) + assert.True(t, hookRan) + }) + + t.Run("runs onStop hooks", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + var hookRan bool + a, err := app.New( + app.WithAddr("127.0.0.1:18952"), + app.WithOnStop(func(_ context.Context) error { + hookRan = true + return nil + }), + ) + require.NoError(t, err) + + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + a.Start(ctx) + assert.True(t, hookRan) + }) + + t.Run("serves custom handler", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + a, err := app.New( + app.WithLogger(nopLogger()), + app.WithAddr("127.0.0.1:18953"), + app.WithHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "world") + })), + ) + require.NoError(t, err) + + go a.Start(ctx) + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get("http://127.0.0.1:18953/hello") + require.NoError(t, err) + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, "world", string(body)) + + cancel() + }) + + t.Run("applies explicit middleware", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + addHeader := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Custom", "salt") + next.ServeHTTP(w, r) + }) + } + + a, err := app.New( + app.WithLogger(nopLogger()), + app.WithAddr("127.0.0.1:18954"), + app.WithHTTPMiddleware(addHeader), + app.WithHandler("/test", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })), + ) + require.NoError(t, err) + + go a.Start(ctx) + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get("http://127.0.0.1:18954/test") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, "salt", resp.Header.Get("X-Custom")) + + cancel() + }) +} diff --git a/app/example_test.go b/app/example_test.go new file mode 100644 index 0000000..9d44ca6 --- /dev/null +++ b/app/example_test.go @@ -0,0 +1,37 @@ +package app_test + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/raystack/salt/app" + "github.com/raystack/salt/middleware" +) + +func ExampleRun() { + app.Run( + app.WithLogger(slog.Default()), + app.WithHTTPMiddleware(middleware.DefaultHTTP(slog.Default())), + app.WithHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "world") + })), + app.WithAddr(":8080"), + ) +} + +func ExampleNew() { + a, err := app.New( + app.WithLogger(slog.Default()), + app.WithAddr(":8080"), + ) + if err != nil { + panic(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + a.Start(ctx) +} diff --git a/app/option.go b/app/option.go new file mode 100644 index 0000000..e61cb95 --- /dev/null +++ b/app/option.go @@ -0,0 +1,113 @@ +package app + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/raystack/salt/config" + "github.com/raystack/salt/server" + "github.com/raystack/salt/telemetry" +) + +// Option configures an App. +type Option func(*App) error + +// WithConfig loads configuration into the target struct. +// The target must be a pointer to a struct. Config is loaded eagerly +// so that subsequent options can reference fields from it. +func WithConfig(target interface{}, loaderOpts ...config.Option) Option { + return func(_ *App) error { + loader := config.NewLoader(loaderOpts...) + return loader.Load(target) + } +} + +// WithLogger sets the logger for the app and all components. +// The logger is propagated to the server. +func WithLogger(l *slog.Logger) Option { + return func(a *App) error { + if l != nil { + a.logger = l + } + return nil + } +} + +// WithTelemetry configures OpenTelemetry. +// Telemetry is initialized when Start() is called. +func WithTelemetry(cfg telemetry.Config) Option { + return func(a *App) error { + a.telCfg = &cfg + return nil + } +} + +// WithAddr sets the server listen address (default ":8080"). +func WithAddr(addr string) Option { + return func(a *App) error { + a.serverOps = append(a.serverOps, server.WithAddr(addr)) + return nil + } +} + +// WithHandler registers an HTTP handler at the given pattern. +// Use this for ConnectRPC handlers, REST endpoints, SPA handlers, etc. +func WithHandler(pattern string, handler http.Handler) Option { + return func(a *App) error { + a.serverOps = append(a.serverOps, server.WithHandler(pattern, handler)) + return nil + } +} + +// WithHTTPMiddleware adds HTTP middleware to the server. +// Use middleware.DefaultHTTP(logger) for the standard chain (recovery, +// request ID, request logging, CORS), or compose your own. +func WithHTTPMiddleware(mw ...func(http.Handler) http.Handler) Option { + return func(a *App) error { + a.serverOps = append(a.serverOps, server.WithHTTPMiddleware(mw...)) + return nil + } +} + +// WithGracePeriod sets the shutdown grace period (default 10s). +func WithGracePeriod(d time.Duration) Option { + return func(a *App) error { + a.serverOps = append(a.serverOps, server.WithGracePeriod(d)) + return nil + } +} + +// WithServer passes options directly to the underlying server. +// Use this for server options that don't have an app-level wrapper, +// e.g. timeouts: +// +// app.WithServer( +// server.WithReadTimeout(60 * time.Second), +// server.WithIdleTimeout(120 * time.Second), +// ) +func WithServer(opts ...server.Option) Option { + return func(a *App) error { + a.serverOps = append(a.serverOps, opts...) + return nil + } +} + +// WithOnStart registers a function to run after infrastructure is ready +// but before the server starts. Use for migrations, seed data, etc. +func WithOnStart(fn func(context.Context) error) Option { + return func(a *App) error { + a.onStart = append(a.onStart, fn) + return nil + } +} + +// WithOnStop registers a function to run during graceful shutdown, +// after the server stops but before infrastructure cleanup. +func WithOnStop(fn func(context.Context) error) Option { + return func(a *App) error { + a.onStop = append(a.onStop, fn) + return nil + } +} From 1f44bcf5105412b75a625520a8af329210655d85 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 17:52:25 -0500 Subject: [PATCH 05/30] feat(cli): add CLI bootstrap and rewrite printer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli.Execute( cli.Name("frontier"), cli.Version("0.1.0", "raystack/frontier"), cli.Commands(serverCmd, configCmd), ) Printer rewritten as Output type. CLI deps upgraded: survey/v2 → huh, tablewriter → stdlib, glamour v0.3 → v1.0. --- cli/cli.go | 131 ++++++++++++++++ cli/example_test.go | 45 ++++++ cli/option.go | 43 ++++++ cli/printer/example_test.go | 60 ++++++++ cli/printer/printer.go | 296 ++++++++++++++++++++++++++++++++++++ cli/prompt/example_test.go | 32 ++++ 6 files changed, 607 insertions(+) create mode 100644 cli/cli.go create mode 100644 cli/example_test.go create mode 100644 cli/option.go create mode 100644 cli/printer/example_test.go create mode 100644 cli/printer/printer.go create mode 100644 cli/prompt/example_test.go diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..d65c78e --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,131 @@ +// Package cli provides a CLI bootstrap for raystack applications. +// +// Usage: +// +// cli.Execute( +// cli.Name("frontier"), +// cli.Description("identity management"), +// cli.Version("0.1.0", "raystack/frontier"), +// cli.Commands(serverCmd, configCmd), +// ) +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/raystack/salt/cli/commander" + "github.com/raystack/salt/cli/printer" + "github.com/raystack/salt/cli/prompt" + "github.com/raystack/salt/cli/version" + "github.com/spf13/cobra" +) + +type contextKey struct{} + +type cliContext struct { + output *printer.Output + prompter prompt.Prompter +} + +// CLI holds the configured CLI application. +type CLI struct { + name string + description string + version string + repo string + commands []*cobra.Command + topics []commander.HelpTopic + hooks []commander.HookBehavior +} + +// Execute creates and runs a CLI application with sensible defaults. +// Help, completion, and reference commands are enabled automatically. +func Execute(opts ...Option) error { + c, err := New(opts...) + if err != nil { + return err + } + return c.execute() +} + +// New creates a CLI without executing, for advanced wiring. +func New(opts ...Option) (*CLI, error) { + c := &CLI{} + for _, opt := range opts { + opt(c) + } + if c.name == "" { + return nil, fmt.Errorf("cli: Name is required") + } + return c, nil +} + +func (c *CLI) execute() error { + rootCmd := &cobra.Command{ + Use: c.name, + Short: c.description, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + ctx := context.WithValue(cmd.Context(), contextKey{}, &cliContext{ + output: printer.NewOutput(os.Stdout), + prompter: prompt.New(), + }) + cmd.SetContext(ctx) + }, + SilenceUsage: true, + } + + // Wire commander features. + var managerOpts []func(*commander.Manager) + if len(c.topics) > 0 { + managerOpts = append(managerOpts, commander.WithTopics(c.topics)) + } + if len(c.hooks) > 0 { + managerOpts = append(managerOpts, commander.WithHooks(c.hooks)) + } + mgr := commander.New(rootCmd, managerOpts...) + mgr.Init() + + // Add version command if configured. + if c.version != "" { + rootCmd.AddCommand(c.versionCmd()) + } + + // Add user commands. + rootCmd.AddCommand(c.commands...) + + return rootCmd.Execute() +} + +func (c *CLI) versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show version information", + Run: func(cmd *cobra.Command, _ []string) { + out := Output(cmd) + out.Println(fmt.Sprintf("%s version %s", c.name, c.version)) + if c.repo != "" { + if msg := version.CheckForUpdate(c.version, c.repo); msg != "" { + out.Warning(msg) + } + } + }, + } +} + +// Output extracts the shared printer from a command's context. +func Output(cmd *cobra.Command) *printer.Output { + if ctx, ok := cmd.Context().Value(contextKey{}).(*cliContext); ok { + return ctx.output + } + return printer.NewOutput(os.Stdout) +} + +// Prompter extracts the shared prompter from a command's context. +func Prompter(cmd *cobra.Command) prompt.Prompter { + if ctx, ok := cmd.Context().Value(contextKey{}).(*cliContext); ok { + return ctx.prompter + } + return prompt.New() +} diff --git a/cli/example_test.go b/cli/example_test.go new file mode 100644 index 0000000..55ade18 --- /dev/null +++ b/cli/example_test.go @@ -0,0 +1,45 @@ +package cli_test + +import ( + "github.com/raystack/salt/cli" + "github.com/raystack/salt/cli/commander" + "github.com/spf13/cobra" +) + +func ExampleExecute() { + listCmd := &cobra.Command{ + Use: "list", + Short: "List resources", + RunE: func(cmd *cobra.Command, _ []string) error { + out := cli.Output(cmd) + out.Table([][]string{ + {"ID", "NAME"}, + {"1", "Alice"}, + {"2", "Bob"}, + }) + return nil + }, + } + + cli.Execute( + cli.Name("myapp"), + cli.Description("my application"), + cli.Version("0.1.0", "raystack/myapp"), + cli.Commands(listCmd), + ) +} + +func ExampleExecute_withTopics() { + cli.Execute( + cli.Name("myapp"), + cli.Description("my application"), + cli.Commands(), + cli.Topics( + commander.HelpTopic{ + Name: "auth", + Short: "How authentication works", + Long: "Detailed explanation of authentication...", + }, + ), + ) +} diff --git a/cli/option.go b/cli/option.go new file mode 100644 index 0000000..b062ea9 --- /dev/null +++ b/cli/option.go @@ -0,0 +1,43 @@ +package cli + +import ( + "github.com/raystack/salt/cli/commander" + "github.com/spf13/cobra" +) + +// Option configures a CLI application. +type Option func(*CLI) + +// Name sets the CLI application name (required). +func Name(name string) Option { + return func(c *CLI) { c.name = name } +} + +// Description sets the CLI application description. +func Description(desc string) Option { + return func(c *CLI) { c.description = desc } +} + +// Version sets the version string and GitHub repo for update checking. +// The repo should be in "owner/repo" format (e.g. "raystack/frontier"). +func Version(ver, repo string) Option { + return func(c *CLI) { + c.version = ver + c.repo = repo + } +} + +// Commands adds subcommands to the CLI. +func Commands(cmds ...*cobra.Command) Option { + return func(c *CLI) { c.commands = append(c.commands, cmds...) } +} + +// Topics adds help topics to the CLI. +func Topics(topics ...commander.HelpTopic) Option { + return func(c *CLI) { c.topics = append(c.topics, topics...) } +} + +// Hooks adds hook behaviors applied to commands. +func Hooks(hooks ...commander.HookBehavior) Option { + return func(c *CLI) { c.hooks = append(c.hooks, hooks...) } +} diff --git a/cli/printer/example_test.go b/cli/printer/example_test.go new file mode 100644 index 0000000..25eb994 --- /dev/null +++ b/cli/printer/example_test.go @@ -0,0 +1,60 @@ +package printer_test + +import ( + "os" + + "github.com/raystack/salt/cli/printer" +) + +func ExampleNewOutput() { + out := printer.NewOutput(os.Stdout) + + out.Success("deployed to prod") + out.Warning("check logs for warnings") + out.Error("connection failed") + out.Info("3 items found") + out.Bold("important message") +} + +func ExampleOutput_Table() { + out := printer.NewOutput(os.Stdout) + + rows := [][]string{ + {"ID", "NAME", "STATUS"}, + {"1", "Alice", "active"}, + {"2", "Bob", "inactive"}, + } + out.Table(rows) +} + +func ExampleOutput_JSON() { + out := printer.NewOutput(os.Stdout) + + data := map[string]interface{}{ + "name": "Alice", + "age": 30, + } + out.JSON(data) +} + +func ExampleOutput_Spin() { + out := printer.NewOutput(os.Stdout) + + spinner := out.Spin("loading...") + // ... do work ... + spinner.Stop() +} + +func Example_colorFormatting() { + // Color functions return styled strings for composition. + status := printer.Green("passing") + " — " + printer.Red("2 failing") + _ = status + + // Formatted variants work like fmt.Sprintf. + count := printer.Greenf("found %d items", 42) + _ = count + + // Icons for status indicators. + ok := printer.Icon("success") + " all tests passed" + _ = ok +} diff --git a/cli/printer/printer.go b/cli/printer/printer.go new file mode 100644 index 0000000..3d7996e --- /dev/null +++ b/cli/printer/printer.go @@ -0,0 +1,296 @@ +// Package printer provides terminal output utilities for CLI applications. +// +// Create an Output for your command and use it for all text, structured data, +// and progress indicators: +// +// out := printer.NewOutput(os.Stdout) +// out.Success("deployed to prod") +// out.Table(rows) +// out.JSON(data) +package printer + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/briandowns/spinner" + "github.com/charmbracelet/glamour" + "github.com/mattn/go-isatty" + "github.com/muesli/termenv" + "github.com/schollz/progressbar/v3" + "gopkg.in/yaml.v3" +) + +// Output handles all terminal output for a CLI command. +type Output struct { + w io.Writer + theme Theme + tty bool +} + +// NewOutput creates a new Output that writes to w. +// It auto-detects TTY and color support from the writer. +func NewOutput(w io.Writer) *Output { + tty := false + if f, ok := w.(*os.File); ok { + tty = isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) + } + return &Output{w: w, theme: newTheme(), tty: tty} +} + +// --- Text output --- + +// Success prints a green success message. +func (o *Output) Success(msg string) { + fmt.Fprintln(o.w, o.color(o.theme.Green, msg)) +} + +// Warning prints a yellow warning message. +func (o *Output) Warning(msg string) { + fmt.Fprintln(o.w, o.color(o.theme.Yellow, msg)) +} + +// Error prints a red error message. +func (o *Output) Error(msg string) { + fmt.Fprintln(o.w, o.color(o.theme.Red, msg)) +} + +// Info prints a cyan informational message. +func (o *Output) Info(msg string) { + fmt.Fprintln(o.w, o.color(o.theme.Cyan, msg)) +} + +// Bold prints a bold message. +func (o *Output) Bold(msg string) { + fmt.Fprintln(o.w, termenv.String(msg).Bold().String()) +} + +// Print prints a plain message. +func (o *Output) Print(msg string) { + fmt.Fprint(o.w, msg) +} + +// Println prints a plain message with a newline. +func (o *Output) Println(msg string) { + fmt.Fprintln(o.w, msg) +} + +// --- Structured output --- + +// JSON writes data as compact JSON. +func (o *Output) JSON(data interface{}) error { + out, err := json.Marshal(data) + if err != nil { + return err + } + fmt.Fprintln(o.w, string(out)) + return nil +} + +// PrettyJSON writes data as indented JSON. +func (o *Output) PrettyJSON(data interface{}) error { + out, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + fmt.Fprintln(o.w, string(out)) + return nil +} + +// YAML writes data as YAML. +func (o *Output) YAML(data interface{}) error { + out, err := yaml.Marshal(data) + if err != nil { + return err + } + fmt.Fprint(o.w, string(out)) + return nil +} + +// Table writes rows as a tab-aligned table. +func (o *Output) Table(rows [][]string) { + tw := tabwriter.NewWriter(o.w, 0, 0, 2, ' ', 0) + for _, row := range rows { + fmt.Fprintln(tw, strings.Join(row, "\t")) + } + tw.Flush() +} + +// --- Markdown --- + +// Markdown renders and prints markdown text with terminal styling. +func (o *Output) Markdown(text string) error { + text = strings.ReplaceAll(text, "\r\n", "\n") + tr, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithEmoji(), + glamour.WithWordWrap(0), + glamour.WithStylesFromJSONBytes([]byte(`{"document":{"margin":0},"code_block":{"margin":0}}`)), + ) + if err != nil { + return err + } + rendered, err := tr.Render(text) + if err != nil { + return err + } + fmt.Fprint(o.w, rendered) + return nil +} + +// MarkdownWithWrap renders markdown with a specified word wrap width. +func (o *Output) MarkdownWithWrap(text string, wrap int) error { + text = strings.ReplaceAll(text, "\r\n", "\n") + tr, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithEmoji(), + glamour.WithWordWrap(wrap), + glamour.WithStylesFromJSONBytes([]byte(`{"document":{"margin":0},"code_block":{"margin":0}}`)), + ) + if err != nil { + return err + } + rendered, err := tr.Render(text) + if err != nil { + return err + } + fmt.Fprint(o.w, rendered) + return nil +} + +// --- Progress indicators --- + +// Indicator wraps a terminal spinner. +type Indicator struct { + spinner *spinner.Spinner +} + +// Stop halts the spinner. +func (i *Indicator) Stop() { + if i.spinner != nil { + i.spinner.Stop() + } +} + +// Spin creates and starts a spinner. Returns a no-op indicator if not a TTY. +func (o *Output) Spin(label string) *Indicator { + if !o.tty { + return &Indicator{} + } + s := spinner.New(spinner.CharSets[11], 120*time.Millisecond, spinner.WithColor("fgCyan")) + if label != "" { + s.Prefix = label + " " + } + s.Writer = o.w + s.Start() + return &Indicator{s} +} + +// Progress creates a progress bar. +func (o *Output) Progress(max int, description string) *progressbar.ProgressBar { + return progressbar.NewOptions(max, + progressbar.OptionSetWriter(o.w), + progressbar.OptionEnableColorCodes(true), + progressbar.OptionSetDescription(description), + progressbar.OptionShowCount(), + ) +} + +// --- Formatting helpers (return styled strings for composition) --- + +// Green returns text styled in green. +func Green(t string) string { return colorize(newTheme().Green, t) } + +// Yellow returns text styled in yellow. +func Yellow(t string) string { return colorize(newTheme().Yellow, t) } + +// Cyan returns text styled in cyan. +func Cyan(t string) string { return colorize(newTheme().Cyan, t) } + +// Red returns text styled in red. +func Red(t string) string { return colorize(newTheme().Red, t) } + +// Grey returns text styled in grey. +func Grey(t string) string { return colorize(newTheme().Grey, t) } + +// Blue returns text styled in blue. +func Blue(t string) string { return colorize(newTheme().Blue, t) } + +// Magenta returns text styled in magenta. +func Magenta(t string) string { return colorize(newTheme().Magenta, t) } + +// --- Formatted color helpers --- + +// Greenf returns formatted text styled in green. +func Greenf(format string, a ...interface{}) string { return Green(fmt.Sprintf(format, a...)) } + +// Yellowf returns formatted text styled in yellow. +func Yellowf(format string, a ...interface{}) string { return Yellow(fmt.Sprintf(format, a...)) } + +// Cyanf returns formatted text styled in cyan. +func Cyanf(format string, a ...interface{}) string { return Cyan(fmt.Sprintf(format, a...)) } + +// Redf returns formatted text styled in red. +func Redf(format string, a ...interface{}) string { return Red(fmt.Sprintf(format, a...)) } + +// Greyf returns formatted text styled in grey. +func Greyf(format string, a ...interface{}) string { return Grey(fmt.Sprintf(format, a...)) } + +// Bluef returns formatted text styled in blue. +func Bluef(format string, a ...interface{}) string { return Blue(fmt.Sprintf(format, a...)) } + +// Magentaf returns formatted text styled in magenta. +func Magentaf(format string, a ...interface{}) string { return Magenta(fmt.Sprintf(format, a...)) } + +// Italic returns text styled in italic. +func Italic(t string) string { return termenv.String(t).Italic().String() } + +// Icon returns a symbol for the given name: "success"→✔, "failure"→✘, "info"→ℹ, "warning"→⚠. +func Icon(name string) string { + icons := map[string]string{"failure": "✘", "success": "✔", "info": "ℹ", "warning": "⚠"} + return icons[name] +} + +// --- Theme --- + +// Theme defines terminal colors. +type Theme struct { + Green termenv.Color + Yellow termenv.Color + Cyan termenv.Color + Red termenv.Color + Grey termenv.Color + Blue termenv.Color + Magenta termenv.Color +} + +func newTheme() Theme { + tp := termenv.EnvColorProfile() + if !termenv.HasDarkBackground() { + return Theme{ + Green: tp.Color("#005F00"), Yellow: tp.Color("#FFAF00"), + Cyan: tp.Color("#0087FF"), Red: tp.Color("#D70000"), + Grey: tp.Color("#303030"), Blue: tp.Color("#000087"), + Magenta: tp.Color("#AF00FF"), + } + } + return Theme{ + Green: tp.Color("#A8CC8C"), Yellow: tp.Color("#DBAB79"), + Cyan: tp.Color("#66C2CD"), Red: tp.Color("#E88388"), + Grey: tp.Color("#B9BFCA"), Blue: tp.Color("#71BEF2"), + Magenta: tp.Color("#D290E4"), + } +} + +func (o *Output) color(c termenv.Color, t string) string { + return colorize(c, t) +} + +func colorize(c termenv.Color, t string) string { + return termenv.String(t).Foreground(c).String() +} diff --git a/cli/prompt/example_test.go b/cli/prompt/example_test.go new file mode 100644 index 0000000..4365c23 --- /dev/null +++ b/cli/prompt/example_test.go @@ -0,0 +1,32 @@ +package prompt_test + +import ( + "fmt" + + "github.com/raystack/salt/cli/prompt" +) + +func ExampleNew() { + p := prompt.New() + + // Single select + idx, err := p.Select("Choose a color", "red", []string{"red", "green", "blue"}) + if err != nil { + panic(err) + } + fmt.Println("selected index:", idx) + + // Text input + name, err := p.Input("Enter your name", "") + if err != nil { + panic(err) + } + fmt.Println("name:", name) + + // Confirmation + ok, err := p.Confirm("Continue?", true) + if err != nil { + panic(err) + } + fmt.Println("confirmed:", ok) +} From 15d6c36e2eadcf83bd9573395ff8cd9d095d2873 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 17:52:25 -0500 Subject: [PATCH 06/30] refactor(config): upgrade deps and remove stdout printing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validator v9 → v10, go-defaults → creasty/defaults - Remove fmt.Println on missing config file --- config/config.go | 14 +++++++------- config/config_test.go | 4 ++-- config/example_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 config/example_test.go diff --git a/config/config.go b/config/config.go index c82ae54..f66dae9 100644 --- a/config/config.go +++ b/config/config.go @@ -9,8 +9,8 @@ import ( "reflect" "strings" - "github.com/go-playground/validator" - "github.com/mcuadros/go-defaults" + "github.com/creasty/defaults" + "github.com/go-playground/validator/v10" "github.com/spf13/pflag" "github.com/spf13/viper" "gopkg.in/yaml.v3" @@ -96,7 +96,7 @@ func (l *Loader) Load(config interface{}) error { } // Apply default values before reading configuration - defaults.SetDefaults(config) + defaults.Set(config) // Bind flags dynamically using reflection on `cmdx` tags if a flag set is provided if l.flags != nil { @@ -116,11 +116,11 @@ func (l *Loader) Load(config interface{}) error { } } - // Attempt to read the configuration file + // Attempt to read the configuration file (missing file is not an error). if err := l.v.ReadInConfig(); err != nil { var configFileNotFoundError viper.ConfigFileNotFoundError - if errors.As(err, &configFileNotFoundError) { - fmt.Println("Warning: Config file not found. Falling back to defaults and environment variables.") + if !errors.As(err, &configFileNotFoundError) && !os.IsNotExist(err) { + return fmt.Errorf("failed to read config file: %w", err) } } @@ -139,7 +139,7 @@ func (l *Loader) Load(config interface{}) error { // Init initializes the configuration file with default values. func (l *Loader) Init(config interface{}) error { - defaults.SetDefaults(config) + defaults.Set(config) path := l.v.ConfigFileUsed() if fileExists(path) { diff --git a/config/config_test.go b/config/config_test.go index ab925bd..2d6ff88 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/mcuadros/go-defaults" + "github.com/creasty/defaults" "github.com/raystack/salt/config" "github.com/spf13/pflag" ) @@ -145,7 +145,7 @@ log_level: "info" loader := config.NewLoader(config.WithFile(configFilePath)) // Apply defaults - defaults.SetDefaults(cfg) + defaults.Set(cfg) cfg.Server.Port = 3000 // Default value cfg.Server.Host = "default-host.com" cfg.LogLevel = "debug" diff --git a/config/example_test.go b/config/example_test.go new file mode 100644 index 0000000..5f0f1ad --- /dev/null +++ b/config/example_test.go @@ -0,0 +1,30 @@ +package config_test + +import ( + "fmt" + "log" + + "github.com/raystack/salt/config" +) + +func ExampleNewLoader() { + type Config struct { + Server struct { + Port int `mapstructure:"port" default:"8080"` + Host string `mapstructure:"host" default:"localhost"` + } `mapstructure:"server"` + LogLevel string `mapstructure:"log_level" default:"info"` + } + + var cfg Config + loader := config.NewLoader( + config.WithFile("./config.yaml"), + config.WithEnvPrefix("MYAPP"), + ) + + if err := loader.Load(&cfg); err != nil { + log.Fatal(err) + } + + fmt.Printf("server: %s:%d, log: %s\n", cfg.Server.Host, cfg.Server.Port, cfg.LogLevel) +} From 5f97557aeda5c6d998986c8f3f4e14363a57e35e Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 17:52:25 -0500 Subject: [PATCH 07/30] fix: upgrade golangci-lint to v2 and Go to 1.24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: v1.60 → v2.11 (action v6 → v7). Drop errcheck linter. --- .github/workflows/lint.yaml | 4 ++-- .golangci.yml | 23 ++++++++++------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 5cef9d4..bf522da 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -24,6 +24,6 @@ jobs: with: go-version-file: "go.mod" - name: Run linter - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: v1.60 \ No newline at end of file + version: v2.11 \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 063c824..a0f3414 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,24 +1,21 @@ -output: - formats: - - format: line-number +version: "2" +formatters: + enable: + - goimports + - gofmt linters: - enable-all: false - disable-all: true + disable: + - errcheck enable: - govet - - goimports - thelper - tparallel - unconvert - wastedassign - revive - unused - - gofmt - whitespace - misspell -linters-settings: - revive: - ignore-generated-header: true - severity: warning -severity: - default-severity: error + settings: + revive: + severity: warning From e2df2b3d8c2df460868e7e3997d00020955c5bd5 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 17:52:25 -0500 Subject: [PATCH 08/30] docs: add README, migration guide, and GoDoc examples README rewritten. MIGRATION.md covers all breaking changes. GoDoc examples in all packages. --- MIGRATION.md | 237 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 154 ++++++++++++++++++++++----------- 2 files changed, 341 insertions(+), 50 deletions(-) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..4557219 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,237 @@ +# Migration Guide + +This guide covers migrating from the previous salt version to the new structure. + +## Go version + +Update `go.mod` to require Go 1.24: + +``` +go 1.24 +``` + +## Packages removed + +| Removed | Replacement | +|---------|-------------| +| `observability/logger` | Use `*slog.Logger` from `log/slog` directly | +| `observability/otelgrpc` | Use `connectrpc.com/otelconnect` | +| `observability/otelhttpclient` | Use `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` | +| `server/mux` | Use `github.com/raystack/salt/server` | +| `db` | Use your preferred DB library (sqlx, pgx, gorm) directly | +| `auth/oidc` | Planned as complete CLI auth solution (#86) | +| `auth/audit` | Planned with standardized schema (#87) | +| `testing/dockertestx` | Use `ory/dockertest/v3` directly | + +## Packages moved + +| Old | New | +|-----|-----| +| `observability` | `telemetry` | +| `cli/terminator` | `cli/terminal` | +| `cli/prompter` | `cli/prompt` | +| `cli/releaser` | `cli/version` | + +## Logger + +The custom `logger.Logger` interface and all backends (Zap, Logrus, Slog, Noop) are removed. Use `*slog.Logger` from the Go standard library directly. + +```go +// Before +import "github.com/raystack/salt/observability/logger" +l := logger.NewZap() +l := logger.NewLogrus() +l := logger.NewNoop() + +// After +import "log/slog" +l := slog.Default() +l := slog.New(slog.NewJSONHandler(os.Stderr, nil)) +l := slog.New(slog.DiscardHandler) // noop +``` + +All salt packages that previously accepted `logger.Logger` now accept `*slog.Logger`. + +## Server + +The dual-port `server/mux` package is replaced by a single-port `server` package with h2c support. + +```go +// Before +import "github.com/raystack/salt/server/mux" +mux.Serve(ctx, + mux.WithHTTPTarget(":8080", httpServer), + mux.WithGRPCTarget(":8081", grpcServer), +) + +// After +import "github.com/raystack/salt/server" +srv := server.New( + server.WithAddr(":8080"), + server.WithHandler("/api/", connectHandler), +) +srv.Start(ctx) +``` + +H2C and health check (`/ping`) are enabled by default. Use `server.WithoutH2C()` or `server.WithHealthCheck("")` to disable. + +## App bootstrap + +New `app.Run()` for service bootstrap: + +```go +import "github.com/raystack/salt/app" + +app.Run( + app.WithConfig(&cfg, config.WithFile("config.yaml")), + app.WithLogger(slog.Default()), + app.WithHTTPMiddleware(middleware.DefaultHTTP(slog.Default())), + app.WithHandler("/api/", handler), + app.WithAddr(cfg.Addr), +) +``` + +HTTP middleware is explicit — use `middleware.DefaultHTTP(logger)` for the standard chain or compose your own. Database connections are managed via `app.WithOnStart` / `app.WithOnStop` hooks. + +## CLI bootstrap + +New `cli.Execute()` for CLI applications: + +```go +// Before +rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} +mgr := commander.New(rootCmd, commander.WithTopics(topics)) +mgr.Init() +rootCmd.AddCommand(serverCmd, configCmd) +rootCmd.Execute() + +// After +import "github.com/raystack/salt/cli" + +cli.Execute( + cli.Name("frontier"), + cli.Description("identity management"), + cli.Version("0.1.0", "raystack/frontier"), + cli.Commands(serverCmd, configCmd), + cli.Topics(topics...), +) +``` + +Access shared output and prompting in commands: + +```go +func newListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { + out := cli.Output(cmd) + out.Table(rows) + return nil + }, + } +} +``` + +## Printer + +Package-level functions replaced by `Output` type: + +```go +// Before +printer.Success("done") +printer.Table(os.Stdout, rows) +printer.JSON(data) +spinner := printer.Spin("loading") + +// After +out := printer.NewOutput(os.Stdout) +// or inside a command: out := cli.Output(cmd) + +out.Success("done") +out.Table(rows) +out.JSON(data) +spinner := out.Spin("loading") +``` + +Color formatting functions remain as package-level helpers returning styled strings: + +```go +printer.Green("text") +printer.Greenf("count: %d", n) +printer.Icon("success") // ✔ +printer.Italic("note") +``` + +## Telemetry + +```go +// Before +import "github.com/raystack/salt/observability" +observability.Init(ctx, cfg, logger) + +// After +import "github.com/raystack/salt/telemetry" +telemetry.Init(ctx, cfg, slogLogger) +``` + +## Middleware + +New package for ConnectRPC and HTTP middleware: + +```go +import "github.com/raystack/salt/middleware" + +// Connect interceptors for your handler +interceptors := middleware.Default(slog.Default()) +handler := myv1connect.NewServiceHandler(svc, connect.WithInterceptors(interceptors...)) + +// HTTP middleware +httpMW := middleware.DefaultHTTP(slog.Default()) +``` + +## Config + +```go +// Import path for validator changed +// Before: "github.com/go-playground/validator" +// After: "github.com/go-playground/validator/v10" + +// If you imported go-defaults directly: +// Before: "github.com/mcuadros/go-defaults" +// After: "github.com/creasty/defaults" +// API change: defaults.SetDefaults(cfg) → defaults.Set(cfg) +``` + +The config package no longer prints warnings to stdout when a config file is missing. + +## Dependency changes + +| Removed (direct) | Replacement | +|-------------------|-------------| +| `go.uber.org/zap` | `log/slog` (stdlib) | +| `sirupsen/logrus` | `log/slog` (stdlib) | +| `AlecAivazis/survey/v2` | `charmbracelet/huh` | +| `olekukonko/tablewriter` | `text/tabwriter` (stdlib) | +| `oklog/run` | Removed with `server/mux` | +| `cli/safeexec` | `exec.LookPath` (stdlib) | +| `pkg/errors` | `fmt.Errorf` with `%w` (stdlib) | +| `mcuadros/go-defaults` | `creasty/defaults` | +| `go-playground/validator` v9 | `go-playground/validator/v10` | +| `jmoiron/sqlx` | Use directly if needed | +| `golang-migrate` | Use directly if needed | +| `ory/dockertest` | Use directly if needed | + +| Added | Purpose | +|-------|---------| +| `connectrpc.com/connect` | Middleware interceptors | +| `charmbracelet/huh` | Interactive prompts | +| `creasty/defaults` | Struct default values | + +| Upgraded | From → To | +|----------|-----------| +| `go-playground/validator` | v9 → v10 | +| `charmbracelet/glamour` | v0.3 → v1.0.0 | +| `muesli/termenv` | v0.11 → v0.16.0 | +| `briandowns/spinner` | v1.18 → v1.23.2 | +| `schollz/progressbar` | v3.8 → v3.19.0 | +| `mattn/go-isatty` | v0.0.19 → v0.0.21 | diff --git a/README.md b/README.md index d54f423..08c1055 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,132 @@ -# salt +# Salt -[![GoDoc reference](https://img.shields.io/badge/godoc-reference-5272B4.svg)](https://godoc.org/github.com/raystack/salt) -![test workflow](https://github.com/raystack/salt/actions/workflows/test.yaml/badge.svg) -[![Go Report Card](https://goreportcard.com/badge/github.com/raystack/salt)](https://goreportcard.com/report/github.com/raystack/salt) +[![Go Reference](https://pkg.go.dev/badge/github.com/raystack/salt.svg)](https://pkg.go.dev/github.com/raystack/salt) +![test](https://github.com/raystack/salt/actions/workflows/test.yaml/badge.svg) +![lint](https://github.com/raystack/salt/actions/workflows/lint.yaml/badge.svg) -Salt is a Golang utility library offering a variety of packages to simplify and enhance application development. It provides modular and reusable components for common tasks, including configuration management, CLI utilities, authentication, logging, and more. +The standard way to build raystack services and CLIs. -## Installation +Salt provides two entry points — `app.Run()` for services and `cli.Execute()` for command-line tools — along with the building blocks they use: configuration, middleware, terminal output, and more. + +## Quick start + +### Service + +```go +package main + +import ( + "log/slog" -To use, run the following command: + "github.com/raystack/salt/app" + "github.com/raystack/salt/config" + "github.com/raystack/salt/middleware" +) + +func main() { + var cfg Config + + app.Run( + app.WithConfig(&cfg, config.WithFile("config.yaml")), + app.WithLogger(slog.Default()), + app.WithHTTPMiddleware(middleware.DefaultHTTP(slog.Default())), + app.WithHandler("/api/", apiHandler), + app.WithAddr(cfg.Addr), + ) +} +``` + +H2C and health check at `/ping` enabled by default. HTTP middleware is explicit — you choose what runs. + +### CLI + +```go +package main + +import "github.com/raystack/salt/cli" + +func main() { + cli.Execute( + cli.Name("frontier"), + cli.Description("identity management"), + cli.Version("0.1.0", "raystack/frontier"), + cli.Commands(serverCmd, userCmd), + ) +} +``` + +Help, shell completion, and reference docs enabled by default. Commands access shared output via `cli.Output(cmd)`. + +## Installation ``` go get github.com/raystack/salt ``` +Requires Go 1.24+. + ## Packages -### Configuration -- **`config`** - Utilities for managing application configurations using environment variables, files, or defaults. +### Bootstrap -### CLI Utilities -- **`cli/commander`** - Command execution, completion, help topics, and management tools. +| Package | Description | +|---------|-------------| +| [`app`](app/) | Service lifecycle — config, logger, telemetry, server, graceful shutdown | +| [`cli`](cli/) | CLI lifecycle — root command, help, completion, version check | -- **`cli/printer`** - Utilities for formatting and printing output to the terminal. +### Server & Middleware -- **`cli/prompter`** - Interactive CLI prompts for user input. +| Package | Description | +|---------|-------------| +| [`server`](server/) | HTTP server with h2c, health checks, graceful shutdown | +| [`server/spa`](server/spa/) | Single-page application static file handler | +| [`middleware`](middleware/) | Connect interceptors and HTTP middleware | +| [`middleware/recovery`](middleware/recovery/) | Panic recovery | +| [`middleware/requestid`](middleware/requestid/) | X-Request-ID propagation | +| [`middleware/requestlog`](middleware/requestlog/) | Request logging with duration | +| [`middleware/errorz`](middleware/errorz/) | Error sanitization for clients | +| [`middleware/cors`](middleware/cors/) | CORS with Connect defaults | -- **`cli/terminator`** - Terminal utilities for browser, pager, and brew helpers. +### CLI -- **`cli/releaser`** - Utilities for displaying and managing CLI tool versions. +| Package | Description | +|---------|-------------| +| [`cli/commander`](cli/commander/) | Cobra enhancements — help layout, completion, reference docs, hooks | +| [`cli/printer`](cli/printer/) | Terminal output — styled text, tables, JSON/YAML, spinners, progress bars, markdown | +| [`cli/prompt`](cli/prompt/) | Interactive prompts — select, multi-select, input, confirm | +| [`cli/terminal`](cli/terminal/) | Terminal utilities — TTY detection, browser, pager | +| [`cli/version`](cli/version/) | Version checking against GitHub releases | -### Authentication and Security -- **`auth/oidc`** - Helpers for integrating OpenID Connect authentication flows. +### Infrastructure -- **`auth/audit`** - Auditing tools for tracking security events and compliance. +| Package | Description | +|---------|-------------| +| [`config`](config/) | Configuration from files, env vars, flags, and struct defaults | +| [`telemetry`](telemetry/) | OpenTelemetry initialization — traces and metrics via OTLP | -### Server and Infrastructure -- **`server/mux`** - gRPC-gateway multiplexer for serving gRPC and HTTP on a single port. +### Data -- **`server/spa`** - Single-page application static file handler. +| Package | Description | +|---------|-------------| +| [`data/rql`](data/rql/) | REST query language — filters, pagination, sorting, search | +| [`data/jsondiff`](data/jsondiff/) | JSON document diffing and reconstruction | -- **`db`** - Helpers for database connections, migrations, and query execution. +## Logging -### Observability -- **`observability`** - OpenTelemetry initialization, metrics, and tracing setup. +Salt uses `*slog.Logger` from the Go standard library. No custom logger interface — pass `slog.Default()` or any `*slog.Logger` to packages that need it. -- **`observability/logger`** - Structured logging with Zap and Logrus adapters. +```go +// Production +logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) -- **`observability/otelgrpc`** - OpenTelemetry gRPC client interceptors for metrics. +// Tests +logger := slog.New(slog.DiscardHandler) +``` -- **`observability/otelhttpclient`** - OpenTelemetry HTTP client transport for metrics. +## Migration -### Data Utilities -- **`data/rql`** - REST query language parser for filters, pagination, sorting, and search. +See [MIGRATION.md](MIGRATION.md) for upgrading from previous versions. -- **`data/jsondiff`** - JSON document diffing and reconstruction. +## License -### Development and Testing -- **`testing/dockertestx`** - Docker-based test environment helpers for Postgres, Minio, SpiceDB, and more. +Apache License 2.0 From a38dd1b3cfa74605b2f064d40d76b72003e1f139 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 18:10:30 -0500 Subject: [PATCH 09/30] refactor(cli): replace cli.Execute with cli.Init decorator pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli.Init(rootCmd, opts...) enhances an existing cobra command instead of creating one. The developer owns the root command — salt adds help, completion, reference docs, version check, and output/prompter context. rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} rootCmd.PersistentFlags().StringP("host", "h", "", "API host") rootCmd.AddCommand(serverCmd, userCmd) cli.Init(rootCmd, cli.Version("0.1.0", "raystack/frontier"), ) rootCmd.Execute() This replaces cli.Execute() which couldn't support persistent flags, PersistentPreRunE, or custom annotations on the root command. Also fixes stale app doc comment mentioning "database". --- MIGRATION.md | 13 +++--- README.md | 20 +++++---- app/app.go | 2 +- cli/cli.go | 99 +++++++++++++++++---------------------------- cli/example_test.go | 33 +++++++++------ cli/option.go | 35 ++++++---------- 6 files changed, 92 insertions(+), 110 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 4557219..3e4738a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -95,7 +95,7 @@ HTTP middleware is explicit — use `middleware.DefaultHTTP(logger)` for the sta ## CLI bootstrap -New `cli.Execute()` for CLI applications: +New `cli.Init()` enhances your root command with standard features: ```go // Before @@ -108,13 +108,16 @@ rootCmd.Execute() // After import "github.com/raystack/salt/cli" -cli.Execute( - cli.Name("frontier"), - cli.Description("identity management"), +rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} +rootCmd.PersistentFlags().StringP("host", "h", "", "API host") +rootCmd.AddCommand(serverCmd, configCmd) + +cli.Init(rootCmd, cli.Version("0.1.0", "raystack/frontier"), - cli.Commands(serverCmd, configCmd), cli.Topics(topics...), ) + +rootCmd.Execute() ``` Access shared output and prompting in commands: diff --git a/README.md b/README.md index 08c1055..6686f6b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The standard way to build raystack services and CLIs. -Salt provides two entry points — `app.Run()` for services and `cli.Execute()` for command-line tools — along with the building blocks they use: configuration, middleware, terminal output, and more. +Salt provides `app.Run()` for services and `cli.Init()` for command-line tools, along with the building blocks they use: configuration, middleware, terminal output, and more. ## Quick start @@ -43,19 +43,25 @@ H2C and health check at `/ping` enabled by default. HTTP middleware is explicit ```go package main -import "github.com/raystack/salt/cli" +import ( + "github.com/raystack/salt/cli" + "github.com/spf13/cobra" +) func main() { - cli.Execute( - cli.Name("frontier"), - cli.Description("identity management"), + rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} + rootCmd.PersistentFlags().StringP("host", "h", "", "API server host") + rootCmd.AddCommand(serverCmd, userCmd) + + cli.Init(rootCmd, cli.Version("0.1.0", "raystack/frontier"), - cli.Commands(serverCmd, userCmd), ) + + rootCmd.Execute() } ``` -Help, shell completion, and reference docs enabled by default. Commands access shared output via `cli.Output(cmd)`. +Help, shell completion, and reference docs added automatically. Commands access shared output via `cli.Output(cmd)`. ## Installation diff --git a/app/app.go b/app/app.go index 4539344..4fa11ff 100644 --- a/app/app.go +++ b/app/app.go @@ -13,7 +13,7 @@ import ( ) // App is a service lifecycle manager that wires together configuration, -// logging, database, telemetry, and HTTP serving with graceful shutdown. +// logging, telemetry, and HTTP serving with graceful shutdown. // // Defaults: h2c enabled, health check at /ping. type App struct { diff --git a/cli/cli.go b/cli/cli.go index d65c78e..e8b33bb 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -1,13 +1,16 @@ -// Package cli provides a CLI bootstrap for raystack applications. +// Package cli provides CLI enhancements for raystack applications. // // Usage: // -// cli.Execute( -// cli.Name("frontier"), -// cli.Description("identity management"), +// rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} +// rootCmd.AddCommand(serverCmd, userCmd) +// +// cli.Init(rootCmd, // cli.Version("0.1.0", "raystack/frontier"), -// cli.Commands(serverCmd, configCmd), +// cli.Topics(authTopic, envTopic), // ) +// +// rootCmd.Execute() package cli import ( @@ -29,84 +32,56 @@ type cliContext struct { prompter prompt.Prompter } -// CLI holds the configured CLI application. -type CLI struct { - name string - description string - version string - repo string - commands []*cobra.Command - topics []commander.HelpTopic - hooks []commander.HookBehavior -} - -// Execute creates and runs a CLI application with sensible defaults. -// Help, completion, and reference commands are enabled automatically. -func Execute(opts ...Option) error { - c, err := New(opts...) - if err != nil { - return err - } - return c.execute() -} - -// New creates a CLI without executing, for advanced wiring. -func New(opts ...Option) (*CLI, error) { - c := &CLI{} +// Init enhances a cobra root command with standard CLI features: +// help, completion, reference docs, output/prompter context, and +// optionally a version command with update checking. +// +// The developer owns the root command — Init only adds features to it. +func Init(rootCmd *cobra.Command, opts ...Option) { + cfg := &config{} for _, opt := range opts { - opt(c) - } - if c.name == "" { - return nil, fmt.Errorf("cli: Name is required") + opt(cfg) } - return c, nil -} -func (c *CLI) execute() error { - rootCmd := &cobra.Command{ - Use: c.name, - Short: c.description, - PersistentPreRun: func(cmd *cobra.Command, _ []string) { - ctx := context.WithValue(cmd.Context(), contextKey{}, &cliContext{ - output: printer.NewOutput(os.Stdout), - prompter: prompt.New(), - }) - cmd.SetContext(ctx) - }, - SilenceUsage: true, + // Inject shared output and prompter into command context. + existing := rootCmd.PersistentPreRun + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + ctx := context.WithValue(cmd.Context(), contextKey{}, &cliContext{ + output: printer.NewOutput(os.Stdout), + prompter: prompt.New(), + }) + cmd.SetContext(ctx) + if existing != nil { + existing(cmd, args) + } } // Wire commander features. var managerOpts []func(*commander.Manager) - if len(c.topics) > 0 { - managerOpts = append(managerOpts, commander.WithTopics(c.topics)) + if len(cfg.topics) > 0 { + managerOpts = append(managerOpts, commander.WithTopics(cfg.topics)) } - if len(c.hooks) > 0 { - managerOpts = append(managerOpts, commander.WithHooks(c.hooks)) + if len(cfg.hooks) > 0 { + managerOpts = append(managerOpts, commander.WithHooks(cfg.hooks)) } mgr := commander.New(rootCmd, managerOpts...) mgr.Init() // Add version command if configured. - if c.version != "" { - rootCmd.AddCommand(c.versionCmd()) + if cfg.version != "" { + rootCmd.AddCommand(versionCmd(rootCmd.Name(), cfg.version, cfg.repo)) } - - // Add user commands. - rootCmd.AddCommand(c.commands...) - - return rootCmd.Execute() } -func (c *CLI) versionCmd() *cobra.Command { +func versionCmd(name, ver, repo string) *cobra.Command { return &cobra.Command{ Use: "version", Short: "Show version information", Run: func(cmd *cobra.Command, _ []string) { out := Output(cmd) - out.Println(fmt.Sprintf("%s version %s", c.name, c.version)) - if c.repo != "" { - if msg := version.CheckForUpdate(c.version, c.repo); msg != "" { + out.Println(fmt.Sprintf("%s version %s", name, ver)) + if repo != "" { + if msg := version.CheckForUpdate(ver, repo); msg != "" { out.Warning(msg) } } diff --git a/cli/example_test.go b/cli/example_test.go index 55ade18..516944f 100644 --- a/cli/example_test.go +++ b/cli/example_test.go @@ -6,7 +6,13 @@ import ( "github.com/spf13/cobra" ) -func ExampleExecute() { +func ExampleInit() { + rootCmd := &cobra.Command{ + Use: "frontier", + Short: "identity management", + } + rootCmd.PersistentFlags().StringP("host", "h", "", "API server host") + listCmd := &cobra.Command{ Use: "list", Short: "List resources", @@ -15,25 +21,26 @@ func ExampleExecute() { out.Table([][]string{ {"ID", "NAME"}, {"1", "Alice"}, - {"2", "Bob"}, }) return nil }, } + rootCmd.AddCommand(listCmd) - cli.Execute( - cli.Name("myapp"), - cli.Description("my application"), - cli.Version("0.1.0", "raystack/myapp"), - cli.Commands(listCmd), + cli.Init(rootCmd, + cli.Version("0.1.0", "raystack/frontier"), ) + + rootCmd.Execute() } -func ExampleExecute_withTopics() { - cli.Execute( - cli.Name("myapp"), - cli.Description("my application"), - cli.Commands(), +func ExampleInit_withTopics() { + rootCmd := &cobra.Command{ + Use: "myapp", + Short: "my application", + } + + cli.Init(rootCmd, cli.Topics( commander.HelpTopic{ Name: "auth", @@ -42,4 +49,6 @@ func ExampleExecute_withTopics() { }, ), ) + + rootCmd.Execute() } diff --git a/cli/option.go b/cli/option.go index b062ea9..97ee271 100644 --- a/cli/option.go +++ b/cli/option.go @@ -1,43 +1,32 @@ package cli -import ( - "github.com/raystack/salt/cli/commander" - "github.com/spf13/cobra" -) +import "github.com/raystack/salt/cli/commander" -// Option configures a CLI application. -type Option func(*CLI) - -// Name sets the CLI application name (required). -func Name(name string) Option { - return func(c *CLI) { c.name = name } +type config struct { + version string + repo string + topics []commander.HelpTopic + hooks []commander.HookBehavior } -// Description sets the CLI application description. -func Description(desc string) Option { - return func(c *CLI) { c.description = desc } -} +// Option configures cli.Init. +type Option func(*config) -// Version sets the version string and GitHub repo for update checking. +// Version enables a version command with update checking. // The repo should be in "owner/repo" format (e.g. "raystack/frontier"). func Version(ver, repo string) Option { - return func(c *CLI) { + return func(c *config) { c.version = ver c.repo = repo } } -// Commands adds subcommands to the CLI. -func Commands(cmds ...*cobra.Command) Option { - return func(c *CLI) { c.commands = append(c.commands, cmds...) } -} - // Topics adds help topics to the CLI. func Topics(topics ...commander.HelpTopic) Option { - return func(c *CLI) { c.topics = append(c.topics, topics...) } + return func(c *config) { c.topics = append(c.topics, topics...) } } // Hooks adds hook behaviors applied to commands. func Hooks(hooks ...commander.HookBehavior) Option { - return func(c *CLI) { c.hooks = append(c.hooks, hooks...) } + return func(c *config) { c.hooks = append(c.hooks, hooks...) } } From 6b941e925676d718c1772112f067241cbd5bd680 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 18:16:00 -0500 Subject: [PATCH 10/30] test(cli): add tests for Init, Output, and Prompter - Init adds completion, reference, version commands - Version command not added without option - Output/Prompter return fallback when context not set - Fix nil context panic in Output/Prompter extractors --- cli/cli.go | 12 ++++-- cli/cli_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 cli/cli_test.go diff --git a/cli/cli.go b/cli/cli.go index e8b33bb..474564e 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -91,16 +91,20 @@ func versionCmd(name, ver, repo string) *cobra.Command { // Output extracts the shared printer from a command's context. func Output(cmd *cobra.Command) *printer.Output { - if ctx, ok := cmd.Context().Value(contextKey{}).(*cliContext); ok { - return ctx.output + if ctx := cmd.Context(); ctx != nil { + if cc, ok := ctx.Value(contextKey{}).(*cliContext); ok { + return cc.output + } } return printer.NewOutput(os.Stdout) } // Prompter extracts the shared prompter from a command's context. func Prompter(cmd *cobra.Command) prompt.Prompter { - if ctx, ok := cmd.Context().Value(contextKey{}).(*cliContext); ok { - return ctx.prompter + if ctx := cmd.Context(); ctx != nil { + if cc, ok := ctx.Value(contextKey{}).(*cliContext); ok { + return cc.prompter + } } return prompt.New() } diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..0eac887 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,111 @@ +package cli_test + +import ( + "bytes" + "testing" + + "github.com/raystack/salt/cli" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestRoot() *cobra.Command { + return &cobra.Command{Use: "testcli", Short: "test app"} +} + +func TestInit(t *testing.T) { + t.Run("adds completion command", func(t *testing.T) { + root := newTestRoot() + cli.Init(root) + + found := false + for _, cmd := range root.Commands() { + if cmd.Name() == "completion" { + found = true + } + } + assert.True(t, found, "completion command should be added") + }) + + t.Run("adds reference command", func(t *testing.T) { + root := newTestRoot() + cli.Init(root) + + found := false + for _, cmd := range root.Commands() { + if cmd.Name() == "reference" { + found = true + } + } + assert.True(t, found, "reference command should be added") + }) + + t.Run("adds version command when configured", func(t *testing.T) { + root := newTestRoot() + cli.Init(root, cli.Version("1.0.0", "raystack/test")) + + found := false + for _, cmd := range root.Commands() { + if cmd.Name() == "version" { + found = true + } + } + assert.True(t, found, "version command should be added") + }) + + t.Run("no version command without option", func(t *testing.T) { + root := newTestRoot() + cli.Init(root) + + for _, cmd := range root.Commands() { + assert.NotEqual(t, "version", cmd.Name(), "version command should not be added without option") + } + }) + + t.Run("version command prints version", func(t *testing.T) { + root := newTestRoot() + cli.Init(root, cli.Version("2.5.0", "")) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"version"}) + err := root.Execute() + require.NoError(t, err) + }) +} + +func TestOutput(t *testing.T) { + t.Run("returns output from context after Init", func(t *testing.T) { + root := newTestRoot() + cli.Init(root) + + var out *bytes.Buffer + child := &cobra.Command{ + Use: "child", + Run: func(cmd *cobra.Command, _ []string) { + o := cli.Output(cmd) + assert.NotNil(t, o) + out = &bytes.Buffer{} + }, + } + root.AddCommand(child) + root.SetArgs([]string{"child"}) + root.Execute() + assert.NotNil(t, out) + }) + + t.Run("returns fallback when no context", func(t *testing.T) { + cmd := &cobra.Command{Use: "bare"} + out := cli.Output(cmd) + assert.NotNil(t, out, "should return fallback output") + }) +} + +func TestPrompter(t *testing.T) { + t.Run("returns fallback when no context", func(t *testing.T) { + cmd := &cobra.Command{Use: "bare"} + p := cli.Prompter(cmd) + assert.NotNil(t, p, "should return fallback prompter") + }) +} From b3cfd3158dd7c61668c3c7892c3833df8e412caa Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 18:17:53 -0500 Subject: [PATCH 11/30] test(app): add test for OnStart failure cleanup behavior Verifies that when an OnStart hook fails, Start() returns the error and OnStop hooks do not run (only internal cleanup like telemetry flush executes). --- app/app_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/app_test.go b/app/app_test.go index 3363bfd..485b005 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -174,4 +174,26 @@ func TestAppStartAndShutdown(t *testing.T) { cancel() }) + + t.Run("onStart failure returns error and runs cleanup", func(t *testing.T) { + var cleanupRan bool + a, err := app.New( + app.WithAddr("127.0.0.1:18955"), + app.WithOnStart(func(_ context.Context) error { + return fmt.Errorf("migration failed") + }), + app.WithOnStop(func(_ context.Context) error { + cleanupRan = true + return nil + }), + ) + require.NoError(t, err) + + err = a.Start(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "migration failed") + // OnStop should NOT run — it runs only on graceful shutdown. + // cleanup() (telemetry flush) runs, but not onStop hooks. + assert.False(t, cleanupRan, "onStop hooks should not run on startup failure") + }) } From 8a6684118a796fec49937486b2d8c4fb521ada45 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 18:24:17 -0500 Subject: [PATCH 12/30] feat(cli): add ConfigCommand helper for config init/list Replaces ~50 lines of identical boilerplate in every raystack CLI: rootCmd.AddCommand(cli.ConfigCommand("frontier", &Config{})) Adds "config init" (creates config file with defaults) and "config list" (shows current config as JSON). Uses config.WithAppConfig for standard file location (~/.config/raystack/.yml). --- cli/cli.go | 2 +- cli/config_cmd.go | 70 +++++++++++++++++++++++++++++++++++++++++++++++ cli/option.go | 10 +++---- 3 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 cli/config_cmd.go diff --git a/cli/cli.go b/cli/cli.go index 474564e..a672fa3 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -38,7 +38,7 @@ type cliContext struct { // // The developer owns the root command — Init only adds features to it. func Init(rootCmd *cobra.Command, opts ...Option) { - cfg := &config{} + cfg := &options{} for _, opt := range opts { opt(cfg) } diff --git a/cli/config_cmd.go b/cli/config_cmd.go new file mode 100644 index 0000000..c689c55 --- /dev/null +++ b/cli/config_cmd.go @@ -0,0 +1,70 @@ +package cli + +import ( + "fmt" + + "github.com/raystack/salt/config" + "github.com/spf13/cobra" +) + +// ConfigCommand returns a "config" command with "init" and "list" subcommands +// for managing client-side CLI configuration. +// +// The appName is used to determine the config file location +// (~/.config/raystack/.yml). The defaultCfg is a pointer to a struct +// with default values used when initializing a new config file. +// +// Usage: +// +// rootCmd.AddCommand(cli.ConfigCommand("frontier", &Config{})) +func ConfigCommand(appName string, defaultCfg interface{}) *cobra.Command { + cmd := &cobra.Command{ + Use: "config ", + Short: "Manage client configuration", + Example: fmt.Sprintf(" $ %s config init\n $ %s config list", appName, appName), + } + + cmd.AddCommand(configInitCmd(appName, defaultCfg)) + cmd.AddCommand(configListCmd(appName)) + + return cmd +} + +func configInitCmd(appName string, defaultCfg interface{}) *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Initialize a new configuration file", + Example: fmt.Sprintf(" $ %s config init", appName), + Annotations: map[string]string{ + "group": "core", + }, + RunE: func(cmd *cobra.Command, _ []string) error { + loader := config.NewLoader(config.WithAppConfig(appName)) + if err := loader.Init(defaultCfg); err != nil { + return err + } + Output(cmd).Success("config initialized") + return nil + }, + } +} + +func configListCmd(appName string) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List current configuration", + Example: fmt.Sprintf(" $ %s config list", appName), + Annotations: map[string]string{ + "group": "core", + }, + RunE: func(cmd *cobra.Command, _ []string) error { + loader := config.NewLoader(config.WithAppConfig(appName)) + data, err := loader.View() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + Output(cmd).Println(data) + return nil + }, + } +} diff --git a/cli/option.go b/cli/option.go index 97ee271..a0358d5 100644 --- a/cli/option.go +++ b/cli/option.go @@ -2,7 +2,7 @@ package cli import "github.com/raystack/salt/cli/commander" -type config struct { +type options struct { version string repo string topics []commander.HelpTopic @@ -10,12 +10,12 @@ type config struct { } // Option configures cli.Init. -type Option func(*config) +type Option func(*options) // Version enables a version command with update checking. // The repo should be in "owner/repo" format (e.g. "raystack/frontier"). func Version(ver, repo string) Option { - return func(c *config) { + return func(c *options) { c.version = ver c.repo = repo } @@ -23,10 +23,10 @@ func Version(ver, repo string) Option { // Topics adds help topics to the CLI. func Topics(topics ...commander.HelpTopic) Option { - return func(c *config) { c.topics = append(c.topics, topics...) } + return func(c *options) { c.topics = append(c.topics, topics...) } } // Hooks adds hook behaviors applied to commands. func Hooks(hooks ...commander.HookBehavior) Option { - return func(c *config) { c.hooks = append(c.hooks, hooks...) } + return func(c *options) { c.hooks = append(c.hooks, hooks...) } } From 0f6ef68f9ed13391c3f4c313a53c5d56a708f5ca Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 18:31:29 -0500 Subject: [PATCH 13/30] =?UTF-8?q?deps:=20upgrade=20cobra=20v1.8.1=20?= =?UTF-8?q?=E2=86=92=20v1.10.2,=20pflag=20v1.0.5=20=E2=86=92=20v1.0.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 7 ++++--- go.sum | 14 ++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 22feb82..b8db681 100644 --- a/go.mod +++ b/go.mod @@ -19,8 +19,8 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/muesli/termenv v0.16.0 github.com/schollz/progressbar/v3 v3.19.0 - github.com/spf13/cobra v1.8.1 - github.com/spf13/pflag v1.0.5 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 go.opentelemetry.io/contrib/instrumentation/host v0.56.0 @@ -65,13 +65,14 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect google.golang.org/protobuf v1.36.9 // indirect ) require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/ebitengine/purego v0.8.0 // indirect diff --git a/go.sum b/go.sum index fef8d24..68d9b68 100644 --- a/go.sum +++ b/go.sum @@ -60,8 +60,8 @@ github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7m github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= @@ -188,10 +188,10 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +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 v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -260,6 +260,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= From 71c0949bb2b1c536cdc508d09d5ffb4159580a23 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 18:33:11 -0500 Subject: [PATCH 14/30] deps: upgrade all direct dependencies to latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenTelemetry v1.31 → v1.43 - gRPC v1.67 → v1.80 - Viper v1.19 → v1.21 - testify v1.9 → v1.11 - go-version v1.3 → v1.9 - pflag v1.0.9 → v1.0.10 - jsondiff v0.7.0 → v0.7.1 - golang.org/x/{text,net,crypto,sys,term} to latest - protobuf, genproto to latest --- go.mod | 90 +++++++++++++------------- go.sum | 197 +++++++++++++++++++++++++++------------------------------ 2 files changed, 137 insertions(+), 150 deletions(-) diff --git a/go.mod b/go.mod index b8db681..ec1580c 100644 --- a/go.mod +++ b/go.mod @@ -13,27 +13,27 @@ require ( github.com/go-playground/validator/v10 v10.30.2 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 - github.com/hashicorp/go-version v1.3.0 + github.com/hashicorp/go-version v1.9.0 github.com/jeremywohl/flatten v1.0.1 github.com/mattn/go-isatty v0.0.21 github.com/mitchellh/mapstructure v1.5.0 github.com/muesli/termenv v0.16.0 github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.10.2 - github.com/spf13/pflag v1.0.9 - github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.9.0 - go.opentelemetry.io/contrib/instrumentation/host v0.56.0 - go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0 - go.opentelemetry.io/contrib/samplers/probability/consistent v0.25.0 - go.opentelemetry.io/otel v1.31.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 - go.opentelemetry.io/otel/sdk v1.31.0 - go.opentelemetry.io/otel/sdk/metric v1.31.0 - golang.org/x/text v0.35.0 - google.golang.org/grpc v1.67.1 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/host v0.68.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 + go.opentelemetry.io/contrib/samplers/probability/consistent v0.37.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + golang.org/x/text v0.36.0 + google.golang.org/grpc v1.80.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -42,6 +42,8 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect @@ -55,6 +57,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -64,67 +67,60 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) require ( github.com/aymerick/douceur v0.2.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/ebitengine/purego v0.8.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/shirou/gopsutil/v4 v4.24.9 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.9.0 // indirect - github.com/wI2L/jsondiff v0.7.0 + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/wI2L/jsondiff v0.7.1 github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect - go.opentelemetry.io/proto/otlp v1.3.1 // indirect - go.uber.org/atomic v1.10.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/net v0.51.0 + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect ) diff --git a/go.sum b/go.sum index 68d9b68..9760743 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,10 @@ github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2 github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= @@ -67,28 +69,27 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= -github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -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-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= @@ -102,20 +103,22 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/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/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= -github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= -github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -130,10 +133,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -159,8 +160,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -170,40 +171,33 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -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/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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= -github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI= -github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -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 v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 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/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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -216,12 +210,12 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= -github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= -github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= -github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= -github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/wI2L/jsondiff v0.7.1 h1:Fg9+yj+1/x3UtPBJhR91TKEzRkrEEWcAcLbg9dzEaNM= +github.com/wI2L/jsondiff v0.7.1/go.mod h1:yAt2W7U6Jd4HK0RA8DGSGk0zDtfEtOUUJVnH/xICpjo= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= @@ -230,69 +224,66 @@ github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9 github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/contrib/instrumentation/host v0.56.0 h1:bLJ0U2SVly7aCVAv4pSJ62I0yy3GHPMbK+74AXSwC40= -go.opentelemetry.io/contrib/instrumentation/host v0.56.0/go.mod h1:7XvO8DvjdcoYDOQs/1n3AuadI/30eE2R+H/pQQuZVN0= -go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0 h1:s7wHG+t8bEoH7ibWk1nk682h7EoWLJ5/8j+TSO3bX/o= -go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0/go.mod h1:Q8Hsv3d9DwryfIl+ebj4mY81IYVRSPy4QfxroVZwqLo= -go.opentelemetry.io/contrib/samplers/probability/consistent v0.25.0 h1:8J8W2niC6+NC2gTfpdnBHRffKf3I2XIsOwonRDf2w8w= -go.opentelemetry.io/contrib/samplers/probability/consistent v0.25.0/go.mod h1:kbCiNzb0EShEPACWOkNXDwP9h/zJGPnYPrXfJ6yofH4= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 h1:FZ6ei8GFW7kyPYdxJaV2rgI6M+4tvZzhYsQ2wgyVC08= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0/go.mod h1:MdEu/mC6j3D+tTEfvI15b5Ci2Fn7NneJ71YMoiS3tpI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= -go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/host v0.68.0 h1:0BfTRAHtFpIlIY7cw1qg9nODUwblutIqx7Cn6NPD+2s= +go.opentelemetry.io/contrib/instrumentation/host v0.68.0/go.mod h1:SmgEeGNt1+gp8bmzB5LLyUlCObWcWrRbYMIiDii3NH8= +go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU= +go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY= +go.opentelemetry.io/contrib/samplers/probability/consistent v0.37.0 h1:f9CFjmpBhGGN28cdXR6n6kIA1BWVr4iv+nqQRB3hMoE= +go.opentelemetry.io/contrib/samplers/probability/consistent v0.37.0/go.mod h1:gEobNV1nWvBSaagUP4qOJ4ZCieOGnLvXPU3V+lptlj4= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= -google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -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= From 02cd603199c899f73ab282f1884642f4898d5ab5 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 18:44:05 -0500 Subject: [PATCH 15/30] feat(cli): support cobra GroupID and set error prefix Commander layout now reads both cobra's native GroupID and the legacy Annotations["group"] for command grouping. Projects can use either approach while keeping salt's custom help formatting. cli.Init sets SetErrPrefix(name + ":") for consistent error messages across all raystack CLIs (e.g. "Error: frontier: unknown command"). --- cli/cli.go | 3 +++ cli/commander/codex.go | 10 ++++----- cli/commander/layout.go | 46 ++++++++++++++++---------------------- cli/commander/manager.go | 8 ++++++- cli/commander/reference.go | 12 +++++----- cli/commander/topics.go | 4 +--- cli/config_cmd.go | 6 ----- 7 files changed, 39 insertions(+), 50 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index a672fa3..9da86b4 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -43,6 +43,9 @@ func Init(rootCmd *cobra.Command, opts ...Option) { opt(cfg) } + // Set error prefix for consistent error messages. + rootCmd.SetErrPrefix(rootCmd.Name() + ":") + // Inject shared output and prompter into command context. existing := rootCmd.PersistentPreRun rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { diff --git a/cli/commander/codex.go b/cli/commander/codex.go index 9636ee0..f550bf5 100644 --- a/cli/commander/codex.go +++ b/cli/commander/codex.go @@ -13,12 +13,10 @@ import ( // This command generates a Markdown documentation tree for all commands in the hierarchy. func (m *Manager) addMarkdownCommand(outputPath string) { markdownCmd := &cobra.Command{ - Use: "markdown", - Short: "Generate Markdown documentation for all commands", - Hidden: true, - Annotations: map[string]string{ - "group": "help", - }, + Use: "markdown", + Short: "Generate Markdown documentation for all commands", + Hidden: true, + GroupID: "help", RunE: func(cmd *cobra.Command, args []string) error { return m.generateMarkdownTree(outputPath, m.RootCmd) }, diff --git a/cli/commander/layout.go b/cli/commander/layout.go index 580f2b2..c931b59 100644 --- a/cli/commander/layout.go +++ b/cli/commander/layout.go @@ -18,7 +18,6 @@ import ( // Section Titles for Help Output const ( usage = "Usage" - corecmd = "Core commands" othercmd = "Other commands" helpcmd = "Help topics" flags = "Flags" @@ -86,7 +85,7 @@ func displayHelp(cmd *cobra.Command, args []string) { // buildHelpEntries constructs a structured help message for a command. func buildHelpEntries(cmd *cobra.Command) []helpEntry { - var coreCommands, helpCommands, otherCommands []string + var helpCommands, ungroupedCommands []string groupCommands := map[string][]string{} for _, c := range cmd.Commands() { @@ -95,24 +94,15 @@ func buildHelpEntries(cmd *cobra.Command) []helpEntry { } entry := fmt.Sprintf("%s%s", rpad(c.Name(), c.NamePadding()+3), c.Short) - if group, ok := c.Annotations["group"]; ok { - switch group { - case "core": - coreCommands = append(coreCommands, entry) - case "help": - helpCommands = append(helpCommands, entry) - default: - groupCommands[group] = append(groupCommands[group], entry) - } - } else { - otherCommands = append(otherCommands, entry) - } - } - // Treat all commands as core if no groups are specified - if len(coreCommands) == 0 && len(groupCommands) == 0 { - coreCommands = otherCommands - otherCommands = []string{} + switch c.GroupID { + case "help": + helpCommands = append(helpCommands, entry) + case "": + ungroupedCommands = append(ungroupedCommands, entry) + default: + groupCommands[c.GroupID] = append(groupCommands[c.GroupID], entry) + } } helpEntries := []helpEntry{} @@ -121,14 +111,16 @@ func buildHelpEntries(cmd *cobra.Command) []helpEntry { } helpEntries = append(helpEntries, helpEntry{usage, cmd.UseLine()}) - if len(coreCommands) > 0 { - helpEntries = append(helpEntries, helpEntry{corecmd, strings.Join(coreCommands, "\n")}) - } - for group, cmds := range groupCommands { - helpEntries = append(helpEntries, helpEntry{fmt.Sprintf("%s commands", toTitle(group)), strings.Join(cmds, "\n")}) - } - if len(otherCommands) > 0 { - helpEntries = append(helpEntries, helpEntry{othercmd, strings.Join(otherCommands, "\n")}) + if len(ungroupedCommands) > 0 && len(groupCommands) == 0 { + // No groups defined — show all commands under "Core commands" + helpEntries = append(helpEntries, helpEntry{"Core commands", strings.Join(ungroupedCommands, "\n")}) + } else { + for group, cmds := range groupCommands { + helpEntries = append(helpEntries, helpEntry{fmt.Sprintf("%s commands", toTitle(group)), strings.Join(cmds, "\n")}) + } + if len(ungroupedCommands) > 0 { + helpEntries = append(helpEntries, helpEntry{othercmd, strings.Join(ungroupedCommands, "\n")}) + } } if len(helpCommands) > 0 { helpEntries = append(helpEntries, helpEntry{helpcmd, strings.Join(helpCommands, "\n")}) diff --git a/cli/commander/manager.go b/cli/commander/manager.go index 50e9a5d..2c28833 100644 --- a/cli/commander/manager.go +++ b/cli/commander/manager.go @@ -12,7 +12,6 @@ type Manager struct { Help bool // Enable custom help. Reference bool // Enable reference command. Completion bool // Enable shell completion. - Config bool // Enable configuration management. Docs bool // Enable markdown documentation Hooks []HookBehavior // Hook behaviors to apply to commands Topics []HelpTopic // Help topics with their details. @@ -66,6 +65,13 @@ func New(rootCmd *cobra.Command, options ...func(*Manager)) *Manager { // It enables or disables features like custom help, reference documentation, // shell completion, help topics, and client hooks based on the Manager's settings. func (m *Manager) Init() { + // Register the help group used by salt's internal commands + // (reference, completion, topics). Developers register their + // own groups via rootCmd.AddGroup() before calling Init. + m.RootCmd.AddGroup( + &cobra.Group{ID: "help", Title: "Help topics:"}, + ) + if m.Help { m.setCustomHelp() } diff --git a/cli/commander/reference.go b/cli/commander/reference.go index 11cba22..7bea35f 100644 --- a/cli/commander/reference.go +++ b/cli/commander/reference.go @@ -15,13 +15,11 @@ import ( func (m *Manager) addReferenceCommand() { var isPlain bool refCmd := &cobra.Command{ - Use: "reference", - Short: "Comprehensive reference of all commands", - Long: m.generateReferenceMarkdown(), - Run: m.runReferenceCommand(&isPlain), - Annotations: map[string]string{ - "group": "help", - }, + Use: "reference", + Short: "Comprehensive reference of all commands", + Long: m.generateReferenceMarkdown(), + Run: m.runReferenceCommand(&isPlain), + GroupID: "help", } refCmd.SetHelpFunc(m.runReferenceCommand(&isPlain)) refCmd.Flags().BoolVarP(&isPlain, "plain", "p", true, "output in plain markdown (without ANSI color)") diff --git a/cli/commander/topics.go b/cli/commander/topics.go index 96383f2..8fd0cee 100644 --- a/cli/commander/topics.go +++ b/cli/commander/topics.go @@ -24,9 +24,7 @@ func (m *Manager) addHelpTopicCommand(topic HelpTopic) { Long: topic.Long, Example: topic.Example, Hidden: false, - Annotations: map[string]string{ - "group": "help", - }, + GroupID: "help", } helpCmd.SetHelpFunc(helpTopicHelpFunc) diff --git a/cli/config_cmd.go b/cli/config_cmd.go index c689c55..f3389de 100644 --- a/cli/config_cmd.go +++ b/cli/config_cmd.go @@ -35,9 +35,6 @@ func configInitCmd(appName string, defaultCfg interface{}) *cobra.Command { Use: "init", Short: "Initialize a new configuration file", Example: fmt.Sprintf(" $ %s config init", appName), - Annotations: map[string]string{ - "group": "core", - }, RunE: func(cmd *cobra.Command, _ []string) error { loader := config.NewLoader(config.WithAppConfig(appName)) if err := loader.Init(defaultCfg); err != nil { @@ -54,9 +51,6 @@ func configListCmd(appName string) *cobra.Command { Use: "list", Short: "List current configuration", Example: fmt.Sprintf(" $ %s config list", appName), - Annotations: map[string]string{ - "group": "core", - }, RunE: func(cmd *cobra.Command, _ []string) error { loader := config.NewLoader(config.WithAppConfig(appName)) data, err := loader.View() From 4e0d8f76e382efc59ea4abf38afe9b67acf76a06 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 19:29:42 -0500 Subject: [PATCH 16/30] refactor(version): unexport internal functions Only CheckForUpdate is the public API. FetchInfo, CompareVersions, Info, Timeout, APIFormat were exported but only used internally. Reduces the public API surface. --- cli/version/release.go | 64 ++++++++++++------------------------------ 1 file changed, 18 insertions(+), 46 deletions(-) diff --git a/cli/version/release.go b/cli/version/release.go index 4ac3e3b..2e32464 100644 --- a/cli/version/release.go +++ b/cli/version/release.go @@ -11,30 +11,17 @@ import ( ) var ( - // Timeout sets the HTTP client timeout for fetching release info. - Timeout = time.Second * 1 - - // APIFormat is the GitHub API URL template to fetch the latest release of a repository. - APIFormat = "https://api.github.com/repos/%s/releases/latest" + timeout = time.Second * 1 + apiFormat = "https://api.github.com/repos/%s/releases/latest" ) -// Info holds information about a software release. -type Info struct { - Version string // Version of the release - TarURL string // Tarball URL of the release +type releaseInfo struct { + version string + tarURL string } -// FetchInfo fetches details related to the latest release from the provided URL. -// -// Parameters: -// - releaseURL: The URL to fetch the latest release information from. -// Example: "https://api.github.com/repos/raystack/optimus/releases/latest" -// -// Returns: -// - An *Info struct containing the release and tarball URL. -// - An error if the HTTP request or response parsing fails. -func FetchInfo(url string) (*Info, error) { - httpClient := http.Client{Timeout: Timeout} +func fetchInfo(url string) (*releaseInfo, error) { + httpClient := http.Client{Timeout: timeout} req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create HTTP request: %w", err) @@ -67,22 +54,13 @@ func FetchInfo(url string) (*Info, error) { return nil, fmt.Errorf("failed to parse JSON response: %w", err) } - return &Info{ - Version: data.TagName, - TarURL: data.Tarball, + return &releaseInfo{ + version: data.TagName, + tarURL: data.Tarball, }, nil } -// CompareVersions compares the current release with the latest release. -// -// Parameters: -// - currVersion: The current release string. -// - latestVersion: The latest release string. -// -// Returns: -// - true if the current release is greater than or equal to the latest release. -// - An error if release parsing fails. -func CompareVersions(current, latest string) (bool, error) { +func compareVersions(current, latest string) (bool, error) { currentVersion, err := version.NewVersion(current) if err != nil { return false, fmt.Errorf("invalid current version format: %w", err) @@ -96,26 +74,20 @@ func CompareVersions(current, latest string) (bool, error) { return currentVersion.GreaterThanOrEqual(latestVersion), nil } -// CheckForUpdate generates a message indicating if an update is available. -// -// Parameters: -// - currentVersion: The current version string (e.g., "v1.0.0"). -// - repo: The GitHub repository in the format "owner/repo". -// -// Returns: -// - A string containing the update message if a newer version is available. -// - An empty string if the current version is up-to-date or if an error occurs. +// CheckForUpdate checks GitHub for a newer release and returns an update +// message if one is available. Returns an empty string if up-to-date or +// if the check fails. func CheckForUpdate(currentVersion, repo string) string { - releaseURL := fmt.Sprintf(APIFormat, repo) - info, err := FetchInfo(releaseURL) + releaseURL := fmt.Sprintf(apiFormat, repo) + info, err := fetchInfo(releaseURL) if err != nil { return "" } - isLatest, err := CompareVersions(currentVersion, info.Version) + isLatest, err := compareVersions(currentVersion, info.version) if err != nil || isLatest { return "" } - return fmt.Sprintf("A new release (%s) is available. consider updating to latest version.", info.Version) + return fmt.Sprintf("A new release (%s) is available. consider updating to latest version.", info.version) } From 3239333b06345ca9537d98a143afe6bc6d25cfd8 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 19:32:18 -0500 Subject: [PATCH 17/30] docs: update migration guide with GroupID, ConfigCommand, dep versions --- MIGRATION.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/MIGRATION.md b/MIGRATION.md index 3e4738a..64a077b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -120,6 +120,28 @@ cli.Init(rootCmd, rootCmd.Execute() ``` +Config command helper replaces boilerplate: + +```go +// Before (50 lines of config init/list commands) +cmd.AddCommand(configInitCommand()) +cmd.AddCommand(configListCommand()) + +// After (1 line) +rootCmd.AddCommand(cli.ConfigCommand("frontier", &Config{})) +``` + +Command grouping uses cobra's native GroupID instead of annotations: + +```go +// Before +cmd.Annotations = map[string]string{"group": "core"} + +// After +rootCmd.AddGroup(&cobra.Group{ID: "manage", Title: "Management:"}) +cmd.GroupID = "manage" +``` + Access shared output and prompting in commands: ```go @@ -207,6 +229,10 @@ httpMW := middleware.DefaultHTTP(slog.Default()) The config package no longer prints warnings to stdout when a config file is missing. +## Version package + +`cli/version` now exports only `CheckForUpdate`. The functions `FetchInfo`, `CompareVersions`, and types `Info`, `Timeout`, `APIFormat` are no longer exported — they were internal implementation details. + ## Dependency changes | Removed (direct) | Replacement | @@ -232,9 +258,16 @@ The config package no longer prints warnings to stdout when a config file is mis | Upgraded | From → To | |----------|-----------| +| `spf13/cobra` | v1.8.1 → v1.10.2 | +| `spf13/pflag` | v1.0.5 → v1.0.10 | +| `spf13/viper` | v1.19.0 → v1.21.0 | | `go-playground/validator` | v9 → v10 | | `charmbracelet/glamour` | v0.3 → v1.0.0 | | `muesli/termenv` | v0.11 → v0.16.0 | | `briandowns/spinner` | v1.18 → v1.23.2 | | `schollz/progressbar` | v3.8 → v3.19.0 | | `mattn/go-isatty` | v0.0.19 → v0.0.21 | +| `opentelemetry/otel` | v1.31.0 → v1.43.0 | +| `google.golang.org/grpc` | v1.67.1 → v1.80.0 | +| `stretchr/testify` | v1.9.0 → v1.11.1 | +| `hashicorp/go-version` | v1.3.0 → v1.9.0 | From 77c342a145bfb1404400369aa2a4fc65de7128a8 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 19:47:51 -0500 Subject: [PATCH 18/30] feat(prompt): add Password method for masked secret input --- cli/prompt/prompt.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cli/prompt/prompt.go b/cli/prompt/prompt.go index 7e718c2..83a1c70 100644 --- a/cli/prompt/prompt.go +++ b/cli/prompt/prompt.go @@ -11,6 +11,7 @@ type Prompter interface { Select(message, defaultValue string, options []string) (int, error) MultiSelect(message, defaultValue string, options []string) ([]int, error) Input(message, defaultValue string) (string, error) + Password(message string) (string, error) Confirm(message string, defaultValue bool) (bool, error) } @@ -110,6 +111,27 @@ func (p *huhPrompter) Input(message, defaultValue string) (string, error) { return result, nil } +// Password prompts the user for a secret input. The input is masked. +// +// Parameters: +// - message: The prompt message to display. +// +// Returns: +// - The user's input as a string. +// - An error, if any. +func (p *huhPrompter) Password(message string) (string, error) { + var result string + err := huh.NewInput(). + Title(message). + Value(&result). + EchoMode(huh.EchoModePassword). + Run() + if err != nil { + return "", fmt.Errorf("prompt error: %w", err) + } + return result, nil +} + // Confirm prompts the user for a yes/no confirmation. // // Parameters: From eaafbdeaa763d9cb63c5a27290835523d12db91e Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 19:51:21 -0500 Subject: [PATCH 19/30] feat(cli): add typed error handling, remove IsCommandErr Add error types for CLI commands: - ErrSilent: command already printed error, exit 1 silently - ErrCancel: user cancelled (ctrl-c), exit 0 - FlagError: bad flags/args, print error + usage - HandleError(err): helper that handles all types Remove commander.IsCommandErr which used fragile string matching. Usage: if err := rootCmd.Execute(); err != nil { cli.HandleError(err) } --- cli/commander/manager.go | 30 +----------------------- cli/error.go | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 29 deletions(-) create mode 100644 cli/error.go diff --git a/cli/commander/manager.go b/cli/commander/manager.go index 2c28833..3a8055e 100644 --- a/cli/commander/manager.go +++ b/cli/commander/manager.go @@ -1,10 +1,6 @@ package commander -import ( - "strings" - - "github.com/spf13/cobra" -) +import "github.com/spf13/cobra" // Manager manages and configures features for a CLI tool. type Manager struct { @@ -106,27 +102,3 @@ func WithHooks(hooks []HookBehavior) func(*Manager) { m.Hooks = hooks } } - -// IsCommandErr checks if the given error is related to a Cobra command error. -// This is useful for distinguishing between user errors (e.g., incorrect commands or flags) -// and program errors, allowing the application to display appropriate messages. -func IsCommandErr(err error) bool { - if err == nil { - return false - } - - // Known Cobra command error keywords - cmdErrorKeywords := []string{ - "unknown command", - "unknown flag", - "unknown shorthand flag", - } - - errMessage := err.Error() - for _, keyword := range cmdErrorKeywords { - if strings.Contains(errMessage, keyword) { - return true - } - } - return false -} diff --git a/cli/error.go b/cli/error.go new file mode 100644 index 0000000..036850f --- /dev/null +++ b/cli/error.go @@ -0,0 +1,50 @@ +package cli + +import ( + "errors" + "fmt" + "os" +) + +// ErrSilent indicates the command already printed its error. +// The error handler should exit 1 without printing anything. +var ErrSilent = errors.New("silent error") + +// ErrCancel indicates the user cancelled the operation (e.g. ctrl-c). +// The error handler should exit 0. +var ErrCancel = errors.New("cancelled") + +// FlagError wraps an error caused by bad flags or arguments. +// The error handler should print the error and show usage. +type FlagError struct { + Err error +} + +func (e *FlagError) Error() string { return e.Err.Error() } +func (e *FlagError) Unwrap() error { return e.Err } + +// NewFlagError creates a FlagError. +func NewFlagError(err error) *FlagError { + return &FlagError{Err: err} +} + +// HandleError handles a command error by type. +// FlagError prints the error (usage is shown by cobra). +// SilentError exits without printing. +// CancelError exits with code 0. +// Other errors print "Error: ". +func HandleError(err error) { + if err == nil { + return + } + + switch { + case errors.Is(err, ErrCancel): + os.Exit(0) + case errors.Is(err, ErrSilent): + os.Exit(1) + default: + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + os.Exit(1) + } +} From f560a14c1989929bb9b10624328a6784a24f64e0 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 19:52:44 -0500 Subject: [PATCH 20/30] docs: add error handling examples and migration guide --- MIGRATION.md | 33 +++++++++++++++++++++++++++++++++ cli/example_test.go | 27 ++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index 64a077b..47519ba 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -157,6 +157,39 @@ func newListCmd() *cobra.Command { } ``` +## Error handling + +`commander.IsCommandErr` (string matching) is replaced by typed errors and a helper: + +```go +// Before +if err := rootCmd.Execute(); err != nil { + if commander.IsCommandErr(err) { + // show usage + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +// After +if err := rootCmd.Execute(); err != nil { + cli.HandleError(err) // handles ErrSilent, ErrCancel, FlagError, and default +} +``` + +In commands, return typed errors: + +```go +// Command already printed the error — exit silently +return cli.ErrSilent + +// User cancelled (ctrl-c) — exit 0 +return cli.ErrCancel + +// Bad input — print error + usage +return cli.NewFlagError(fmt.Errorf("--port must be positive")) +``` + ## Printer Package-level functions replaced by `Output` type: diff --git a/cli/example_test.go b/cli/example_test.go index 516944f..b74a3f3 100644 --- a/cli/example_test.go +++ b/cli/example_test.go @@ -31,7 +31,32 @@ func ExampleInit() { cli.Version("0.1.0", "raystack/frontier"), ) - rootCmd.Execute() + if err := rootCmd.Execute(); err != nil { + cli.HandleError(err) + } +} + +func ExampleHandleError() { + rootCmd := &cobra.Command{ + Use: "myapp", + RunE: func(cmd *cobra.Command, _ []string) error { + // Return ErrSilent if you already printed the error + out := cli.Output(cmd) + out.Error("connection failed: timeout") + return cli.ErrSilent + + // Return ErrCancel if user cancelled + // return cli.ErrCancel + + // Return FlagError for bad input + // return cli.NewFlagError(fmt.Errorf("--port must be positive")) + }, + } + + cli.Init(rootCmd) + if err := rootCmd.Execute(); err != nil { + cli.HandleError(err) + } } func ExampleInit_withTopics() { From 71818f71756f584fe7b189a5898f48c297a61d4b Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 19:54:10 -0500 Subject: [PATCH 21/30] feat(cli): cache version checks and add TTY-aware table output Version checking: - Results cached for 24h at ~/.config/raystack//state.json - Eliminates HTTP call to GitHub on every command invocation - Cache respects XDG_CONFIG_HOME and APPDATA on Windows Table output: - TTY: tab-aligned columns with padding (human-friendly) - Non-TTY: raw tab-separated values (pipe to awk/cut/jq) --- cli/printer/printer.go | 18 +++++++--- cli/version/release.go | 79 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/cli/printer/printer.go b/cli/printer/printer.go index 3d7996e..38d73c1 100644 --- a/cli/printer/printer.go +++ b/cli/printer/printer.go @@ -112,13 +112,21 @@ func (o *Output) YAML(data interface{}) error { return nil } -// Table writes rows as a tab-aligned table. +// Table writes rows as a tab-aligned table when the output is a TTY. +// When piped (non-TTY), it writes tab-separated values for easy +// processing with tools like awk, cut, or jq. func (o *Output) Table(rows [][]string) { - tw := tabwriter.NewWriter(o.w, 0, 0, 2, ' ', 0) - for _, row := range rows { - fmt.Fprintln(tw, strings.Join(row, "\t")) + if o.tty { + tw := tabwriter.NewWriter(o.w, 0, 0, 2, ' ', 0) + for _, row := range rows { + fmt.Fprintln(tw, strings.Join(row, "\t")) + } + tw.Flush() + } else { + for _, row := range rows { + fmt.Fprintln(o.w, strings.Join(row, "\t")) + } } - tw.Flush() } // --- Markdown --- diff --git a/cli/version/release.go b/cli/version/release.go index 2e32464..3f773b8 100644 --- a/cli/version/release.go +++ b/cli/version/release.go @@ -5,6 +5,9 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" + "runtime" "time" "github.com/hashicorp/go-version" @@ -13,6 +16,7 @@ import ( var ( timeout = time.Second * 1 apiFormat = "https://api.github.com/repos/%s/releases/latest" + cacheTTL = 24 * time.Hour ) type releaseInfo struct { @@ -20,6 +24,11 @@ type releaseInfo struct { tarURL string } +type cacheEntry struct { + CheckedAt time.Time `json:"checked_at"` + LatestVersion string `json:"latest_version"` +} + func fetchInfo(url string) (*releaseInfo, error) { httpClient := http.Client{Timeout: timeout} req, err := http.NewRequest(http.MethodGet, url, nil) @@ -77,17 +86,83 @@ func compareVersions(current, latest string) (bool, error) { // CheckForUpdate checks GitHub for a newer release and returns an update // message if one is available. Returns an empty string if up-to-date or // if the check fails. +// +// Results are cached for 24 hours to avoid hitting GitHub on every invocation. +// The cache is stored at ~/.config/raystack//state.json. func CheckForUpdate(currentVersion, repo string) string { + // Check cache first. + if latest, ok := readCache(repo); ok { + return buildMessage(currentVersion, latest) + } + + // Fetch from GitHub. releaseURL := fmt.Sprintf(apiFormat, repo) info, err := fetchInfo(releaseURL) if err != nil { return "" } - isLatest, err := compareVersions(currentVersion, info.version) + // Cache the result. + writeCache(repo, info.version) + + return buildMessage(currentVersion, info.version) +} + +func buildMessage(current, latest string) string { + isLatest, err := compareVersions(current, latest) if err != nil || isLatest { return "" } + return fmt.Sprintf("A new release (%s) is available. consider updating to latest version.", latest) +} + +func cachePath(repo string) string { + dir := configDir() + return filepath.Join(dir, "raystack", repo, "state.json") +} + +func readCache(repo string) (string, bool) { + data, err := os.ReadFile(cachePath(repo)) + if err != nil { + return "", false + } + + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return "", false + } + + if time.Since(entry.CheckedAt) > cacheTTL { + return "", false + } + + return entry.LatestVersion, true +} + +func writeCache(repo, latestVersion string) { + path := cachePath(repo) + os.MkdirAll(filepath.Dir(path), 0755) + + entry := cacheEntry{ + CheckedAt: time.Now(), + LatestVersion: latestVersion, + } + data, err := json.Marshal(entry) + if err != nil { + return + } + os.WriteFile(path, data, 0644) +} - return fmt.Sprintf("A new release (%s) is available. consider updating to latest version.", info.version) +func configDir() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return dir + } + if runtime.GOOS == "windows" { + if dir := os.Getenv("APPDATA"); dir != "" { + return dir + } + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") } From 3eb760554375f76af5c821ed7721d7e3c9e541df Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 20:09:10 -0500 Subject: [PATCH 22/30] feat(cli): add terminal width and separate stderr for status output Terminal: - Add Width() returning terminal column count (defaults to 80) Printer: - Status output (Success, Warning, Error, Info, Spin, Progress) now writes to stderr instead of stdout - Data output (Table, JSON, YAML, Println, Print) still writes to stdout - This prevents spinners and status messages from corrupting piped data: myapp list --json | jq '.name' # spinner goes to stderr, JSON to stdout --- cli/printer/printer.go | 32 +++++++++++++++++++------------- cli/terminal/term.go | 11 +++++++++++ go.mod | 2 +- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/cli/printer/printer.go b/cli/printer/printer.go index 38d73c1..ffdd9f5 100644 --- a/cli/printer/printer.go +++ b/cli/printer/printer.go @@ -27,42 +27,48 @@ import ( ) // Output handles all terminal output for a CLI command. +// +// Data output (Table, JSON, YAML, Println) goes to the primary writer (stdout). +// Status output (Spin, Warning, Error, Info, Success) goes to the error writer (stderr). +// This separation ensures spinners and status messages don't corrupt +// piped data output (e.g. myapp list --json | jq). type Output struct { w io.Writer + errW io.Writer theme Theme tty bool } -// NewOutput creates a new Output that writes to w. +// NewOutput creates a new Output that writes data to w and status to stderr. // It auto-detects TTY and color support from the writer. func NewOutput(w io.Writer) *Output { tty := false if f, ok := w.(*os.File); ok { tty = isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } - return &Output{w: w, theme: newTheme(), tty: tty} + return &Output{w: w, errW: os.Stderr, theme: newTheme(), tty: tty} } // --- Text output --- -// Success prints a green success message. +// Success prints a green success message to stderr. func (o *Output) Success(msg string) { - fmt.Fprintln(o.w, o.color(o.theme.Green, msg)) + fmt.Fprintln(o.errW, o.color(o.theme.Green, msg)) } -// Warning prints a yellow warning message. +// Warning prints a yellow warning message to stderr. func (o *Output) Warning(msg string) { - fmt.Fprintln(o.w, o.color(o.theme.Yellow, msg)) + fmt.Fprintln(o.errW, o.color(o.theme.Yellow, msg)) } -// Error prints a red error message. +// Error prints a red error message to stderr. func (o *Output) Error(msg string) { - fmt.Fprintln(o.w, o.color(o.theme.Red, msg)) + fmt.Fprintln(o.errW, o.color(o.theme.Red, msg)) } -// Info prints a cyan informational message. +// Info prints a cyan informational message to stderr. func (o *Output) Info(msg string) { - fmt.Fprintln(o.w, o.color(o.theme.Cyan, msg)) + fmt.Fprintln(o.errW, o.color(o.theme.Cyan, msg)) } // Bold prints a bold message. @@ -194,15 +200,15 @@ func (o *Output) Spin(label string) *Indicator { if label != "" { s.Prefix = label + " " } - s.Writer = o.w + s.Writer = o.errW s.Start() return &Indicator{s} } -// Progress creates a progress bar. +// Progress creates a progress bar on stderr. func (o *Output) Progress(max int, description string) *progressbar.ProgressBar { return progressbar.NewOptions(max, - progressbar.OptionSetWriter(o.w), + progressbar.OptionSetWriter(o.errW), progressbar.OptionEnableColorCodes(true), progressbar.OptionSetDescription(description), progressbar.OptionShowCount(), diff --git a/cli/terminal/term.go b/cli/terminal/term.go index 0c47160..d1e0a3d 100644 --- a/cli/terminal/term.go +++ b/cli/terminal/term.go @@ -5,6 +5,7 @@ import ( "github.com/mattn/go-isatty" "github.com/muesli/termenv" + "golang.org/x/term" ) // IsTTY checks if the current output is a TTY (teletypewriter) or a Cygwin terminal. @@ -21,6 +22,16 @@ func IsColorDisabled() bool { return termenv.EnvNoColor() } +// Width returns the terminal width in columns. Returns 80 if the width +// cannot be determined (e.g. non-TTY, piped output). +func Width() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err == nil && w > 0 { + return w + } + return 80 +} + // IsCI checks if the code is running in a Continuous Integration (CI) environment. // This function checks for common environment variables used by popular CI systems // like GitHub Actions, Travis CI, CircleCI, Jenkins, TeamCity, and others. diff --git a/go.mod b/go.mod index ec1580c..630e81a 100644 --- a/go.mod +++ b/go.mod @@ -120,7 +120,7 @@ require ( golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect + golang.org/x/term v0.42.0 google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect ) From 8e152b6ad792cac978e3fa2306531aacb42b8a91 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 22:34:22 -0500 Subject: [PATCH 23/30] refactor(cli): add Execute, unexport error types, modernize idioms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cli.Execute() that owns all error dispatch (sentinels, flag errors, default). Silence Cobra's built-in error/usage printing so Execute has full control. Unexport FlagError and remove HandleError/NewFlagError — flag wrapping is now internal to Init via SetFlagErrorFunc. Modernize across packages: interface{} → any, os.IsNotExist → errors.Is(fs.ErrNotExist), sort.Slice → slices.SortFunc. Update migration guide and README to document cli.Execute. --- MIGRATION.md | 48 +++++++++++++++++++++-------- README.md | 8 ++--- app/option.go | 2 +- cli/cli.go | 42 ++++++++++++++++++++++++- cli/cli_test.go | 27 ++++++++++++++++ cli/commander/codex.go | 4 ++- cli/commander/completion.go | 2 +- cli/commander/layout.go | 11 ------- cli/config_cmd.go | 4 +-- cli/error.go | 48 ++++++----------------------- cli/example_test.go | 28 ++++++----------- cli/printer/example_test.go | 2 +- cli/printer/printer.go | 20 ++++++------ config/config.go | 11 ++++--- config/helpers.go | 6 ++-- data/jsondiff/README.md | 4 +-- data/jsondiff/jsondiff.go | 35 +++++++++++---------- data/jsondiff/jsondiff_test.go | 2 +- data/jsondiff/jsondiff_wi2l.go | 14 ++++----- data/jsondiff/jsondiff_wi2l_test.go | 2 +- data/jsondiff/reconstructor.go | 32 +++++++++---------- data/rql/parser.go | 4 +-- 22 files changed, 200 insertions(+), 156 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 47519ba..06cc634 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -95,7 +95,7 @@ HTTP middleware is explicit — use `middleware.DefaultHTTP(logger)` for the sta ## CLI bootstrap -New `cli.Init()` enhances your root command with standard features: +`cli.Init()` enhances your root command with standard features and `cli.Execute()` runs it with proper error handling: ```go // Before @@ -103,7 +103,15 @@ rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} mgr := commander.New(rootCmd, commander.WithTopics(topics)) mgr.Init() rootCmd.AddCommand(serverCmd, configCmd) -rootCmd.Execute() + +cmd, err := rootCmd.ExecuteC() +if err != nil { + if commander.IsCommandErr(err) { + fmt.Println(cmd.UsageString()) + } + fmt.Println(err) + os.Exit(1) +} // After import "github.com/raystack/salt/cli" @@ -117,7 +125,7 @@ cli.Init(rootCmd, cli.Topics(topics...), ) -rootCmd.Execute() +cli.Execute(rootCmd) ``` Config command helper replaces boilerplate: @@ -159,7 +167,7 @@ func newListCmd() *cobra.Command { ## Error handling -`commander.IsCommandErr` (string matching) is replaced by typed errors and a helper: +`commander.IsCommandErr` (string matching) and manual error handling are replaced by `cli.Execute`: ```go // Before @@ -172,24 +180,38 @@ if err := rootCmd.Execute(); err != nil { } // After -if err := rootCmd.Execute(); err != nil { - cli.HandleError(err) // handles ErrSilent, ErrCancel, FlagError, and default -} +cli.Execute(rootCmd) // handles all errors with proper exit codes ``` -In commands, return typed errors: +`cli.Execute` uses `ExecuteC` internally and handles all error types: + +| Error | Behavior | +|-------|----------| +| `cli.ErrSilent` | Exit 1, no output (command already printed the error) | +| `cli.ErrCancel` | Exit 0, no output (user cancelled) | +| Flag errors | Prints error + failing command's usage, exit 1 | +| Other errors | Prints "Error: \", exit 1 | + +In commands, return sentinel errors to control exit behavior: ```go -// Command already printed the error — exit silently +// Command already printed a rich error — exit 1, no extra output +out.Error("connection failed: timeout") return cli.ErrSilent -// User cancelled (ctrl-c) — exit 0 +// User cancelled (ctrl-c, declined prompt) — exit 0 return cli.ErrCancel - -// Bad input — print error + usage -return cli.NewFlagError(fmt.Errorf("--port must be positive")) ``` +The following exports are removed — their functionality is now internal to `cli.Execute`: + +| Removed | Replacement | +|---------|-------------| +| `cli.HandleError(err)` | `cli.Execute(rootCmd)` handles errors automatically | +| `cli.NewFlagError(err)` | `cli.Init` wraps flag errors automatically via `SetFlagErrorFunc` | +| `cli.FlagError` (type) | Unexported; flag errors are handled internally by `Execute` | +| `commander.IsCommandErr(err)` | Removed; `Execute` detects and handles flag/command errors | + ## Printer Package-level functions replaced by `Output` type: diff --git a/README.md b/README.md index 6686f6b..204aac0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The standard way to build raystack services and CLIs. -Salt provides `app.Run()` for services and `cli.Init()` for command-line tools, along with the building blocks they use: configuration, middleware, terminal output, and more. +Salt provides `app.Run()` for services and `cli.Init()` / `cli.Execute()` for command-line tools, along with the building blocks they use: configuration, middleware, terminal output, and more. ## Quick start @@ -57,11 +57,11 @@ func main() { cli.Version("0.1.0", "raystack/frontier"), ) - rootCmd.Execute() + cli.Execute(rootCmd) } ``` -Help, shell completion, and reference docs added automatically. Commands access shared output via `cli.Output(cmd)`. +`Init` adds help, shell completion, reference docs, and silences Cobra's default error output. `Execute` runs the command and handles all errors with proper exit codes. Commands access shared output via `cli.Output(cmd)`. ## Installation @@ -78,7 +78,7 @@ Requires Go 1.24+. | Package | Description | |---------|-------------| | [`app`](app/) | Service lifecycle — config, logger, telemetry, server, graceful shutdown | -| [`cli`](cli/) | CLI lifecycle — root command, help, completion, version check | +| [`cli`](cli/) | CLI lifecycle — init, execute, error handling, help, completion, version check | ### Server & Middleware diff --git a/app/option.go b/app/option.go index e61cb95..e7d635c 100644 --- a/app/option.go +++ b/app/option.go @@ -17,7 +17,7 @@ type Option func(*App) error // WithConfig loads configuration into the target struct. // The target must be a pointer to a struct. Config is loaded eagerly // so that subsequent options can reference fields from it. -func WithConfig(target interface{}, loaderOpts ...config.Option) Option { +func WithConfig(target any, loaderOpts ...config.Option) Option { return func(_ *App) error { loader := config.NewLoader(loaderOpts...) return loader.Load(target) diff --git a/cli/cli.go b/cli/cli.go index 9da86b4..1b1aa47 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -10,11 +10,12 @@ // cli.Topics(authTopic, envTopic), // ) // -// rootCmd.Execute() +// cli.Execute(rootCmd) package cli import ( "context" + "errors" "fmt" "os" @@ -46,6 +47,11 @@ func Init(rootCmd *cobra.Command, opts ...Option) { // Set error prefix for consistent error messages. rootCmd.SetErrPrefix(rootCmd.Name() + ":") + // Silence cobra's default error and usage printing. + // Errors are handled by Execute; usage is shown only for flag errors. + rootCmd.SilenceErrors = true + rootCmd.SilenceUsage = true + // Inject shared output and prompter into command context. existing := rootCmd.PersistentPreRun rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { @@ -70,12 +76,46 @@ func Init(rootCmd *cobra.Command, opts ...Option) { mgr := commander.New(rootCmd, managerOpts...) mgr.Init() + // Wrap flag parsing errors so Execute can show contextual usage. + // Must be set after mgr.Init() which also configures a flag error func. + rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + return &flagError{err: err} + }) + // Add version command if configured. if cfg.version != "" { rootCmd.AddCommand(versionCmd(rootCmd.Name(), cfg.version, cfg.repo)) } } +// Execute runs the root command and handles errors with appropriate +// exit codes and output. It uses ExecuteC to obtain the failing command +// so flag errors can show contextual usage. +// +// This function never returns on error — it calls os.Exit. +func Execute(rootCmd *cobra.Command) { + cmd, err := rootCmd.ExecuteC() + if err == nil { + return + } + + var flagErr *flagError + switch { + case errors.Is(err, ErrCancel): + os.Exit(0) + case errors.Is(err, ErrSilent): + os.Exit(1) + case errors.As(err, &flagErr): + fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, cmd.UsageString()) + os.Exit(1) + default: + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + os.Exit(1) + } +} + func versionCmd(name, ver, repo string) *cobra.Command { return &cobra.Command{ Use: "version", diff --git a/cli/cli_test.go b/cli/cli_test.go index 0eac887..d6f1d34 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -73,6 +73,33 @@ func TestInit(t *testing.T) { err := root.Execute() require.NoError(t, err) }) + + t.Run("silences cobra error and usage output", func(t *testing.T) { + root := newTestRoot() + cli.Init(root) + + assert.True(t, root.SilenceErrors, "SilenceErrors should be true") + assert.True(t, root.SilenceUsage, "SilenceUsage should be true") + }) + + t.Run("wraps flag errors for Execute", func(t *testing.T) { + root := newTestRoot() + root.Flags().Int("port", 8080, "server port") + root.RunE = func(cmd *cobra.Command, args []string) error { return nil } + cli.Init(root) + + // Unknown flag returns an error (wrapped internally as flagError). + root.SetArgs([]string{"--unknown"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag") + + // Invalid flag value also returns an error. + root.SetArgs([]string{"--port", "abc"}) + err = root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid argument") + }) } func TestOutput(t *testing.T) { diff --git a/cli/commander/codex.go b/cli/commander/codex.go index f550bf5..e168720 100644 --- a/cli/commander/codex.go +++ b/cli/commander/codex.go @@ -1,7 +1,9 @@ package commander import ( + "errors" "fmt" + "io/fs" "os" "path/filepath" @@ -67,7 +69,7 @@ func (m *Manager) generateMarkdownTree(rootOutputPath string, cmd *cobra.Command // ensureDir ensures that the given directory exists, creating it if necessary. func ensureDir(path string) error { - if _, err := os.Stat(path); os.IsNotExist(err) { + if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { if err := os.MkdirAll(path, os.ModePerm); err != nil { return err } diff --git a/cli/commander/completion.go b/cli/commander/completion.go index 5042826..63815b5 100644 --- a/cli/commander/completion.go +++ b/cli/commander/completion.go @@ -43,7 +43,7 @@ func (m *Manager) addCompletionCommand() { // generateCompletionSummary creates the long description for the `completion` command. func (m *Manager) generateCompletionSummary(exec string) string { - var execs []interface{} + var execs []any for i := 0; i < 12; i++ { execs = append(execs, exec) } diff --git a/cli/commander/layout.go b/cli/commander/layout.go index c931b59..8db1b9a 100644 --- a/cli/commander/layout.go +++ b/cli/commander/layout.go @@ -2,7 +2,6 @@ package commander import ( "bytes" - "errors" "fmt" "regexp" "strings" @@ -12,7 +11,6 @@ import ( "golang.org/x/text/language" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) // Section Titles for Help Output @@ -39,7 +37,6 @@ func (m *Manager) setCustomHelp() { displayHelp(cmd, args) }) m.RootCmd.SetUsageFunc(generateUsage) - m.RootCmd.SetFlagErrorFunc(handleFlagError) } // generateUsage customizes the usage function for a command. @@ -64,14 +61,6 @@ func generateUsage(cmd *cobra.Command) error { return nil } -// handleFlagError processes flag-related errors, including the special case of help flags. -func handleFlagError(cmd *cobra.Command, err error) error { - if errors.Is(err, pflag.ErrHelp) { - return err - } - return err -} - // displayHelp generates a custom help message for a Cobra command. func displayHelp(cmd *cobra.Command, args []string) { if isRootCommand(cmd.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { diff --git a/cli/config_cmd.go b/cli/config_cmd.go index f3389de..31e3936 100644 --- a/cli/config_cmd.go +++ b/cli/config_cmd.go @@ -17,7 +17,7 @@ import ( // Usage: // // rootCmd.AddCommand(cli.ConfigCommand("frontier", &Config{})) -func ConfigCommand(appName string, defaultCfg interface{}) *cobra.Command { +func ConfigCommand(appName string, defaultCfg any) *cobra.Command { cmd := &cobra.Command{ Use: "config ", Short: "Manage client configuration", @@ -30,7 +30,7 @@ func ConfigCommand(appName string, defaultCfg interface{}) *cobra.Command { return cmd } -func configInitCmd(appName string, defaultCfg interface{}) *cobra.Command { +func configInitCmd(appName string, defaultCfg any) *cobra.Command { return &cobra.Command{ Use: "init", Short: "Initialize a new configuration file", diff --git a/cli/error.go b/cli/error.go index 036850f..0e1e9a8 100644 --- a/cli/error.go +++ b/cli/error.go @@ -1,50 +1,20 @@ package cli -import ( - "errors" - "fmt" - "os" -) +import "errors" // ErrSilent indicates the command already printed its error. -// The error handler should exit 1 without printing anything. +// Execute will exit 1 without printing anything. var ErrSilent = errors.New("silent error") // ErrCancel indicates the user cancelled the operation (e.g. ctrl-c). -// The error handler should exit 0. +// Execute will exit 0 without printing anything. var ErrCancel = errors.New("cancelled") -// FlagError wraps an error caused by bad flags or arguments. -// The error handler should print the error and show usage. -type FlagError struct { - Err error +// flagError wraps an error caused by bad flags or arguments. +// Execute prints the error and shows the failing command's usage. +type flagError struct { + err error } -func (e *FlagError) Error() string { return e.Err.Error() } -func (e *FlagError) Unwrap() error { return e.Err } - -// NewFlagError creates a FlagError. -func NewFlagError(err error) *FlagError { - return &FlagError{Err: err} -} - -// HandleError handles a command error by type. -// FlagError prints the error (usage is shown by cobra). -// SilentError exits without printing. -// CancelError exits with code 0. -// Other errors print "Error: ". -func HandleError(err error) { - if err == nil { - return - } - - switch { - case errors.Is(err, ErrCancel): - os.Exit(0) - case errors.Is(err, ErrSilent): - os.Exit(1) - default: - fmt.Fprintf(os.Stderr, "Error: %s\n", err) - os.Exit(1) - } -} +func (e *flagError) Error() string { return e.err.Error() } +func (e *flagError) Unwrap() error { return e.err } diff --git a/cli/example_test.go b/cli/example_test.go index b74a3f3..a4d0da5 100644 --- a/cli/example_test.go +++ b/cli/example_test.go @@ -31,32 +31,24 @@ func ExampleInit() { cli.Version("0.1.0", "raystack/frontier"), ) - if err := rootCmd.Execute(); err != nil { - cli.HandleError(err) - } + cli.Execute(rootCmd) } -func ExampleHandleError() { +func ExampleExecute() { rootCmd := &cobra.Command{ Use: "myapp", RunE: func(cmd *cobra.Command, _ []string) error { - // Return ErrSilent if you already printed the error - out := cli.Output(cmd) - out.Error("connection failed: timeout") - return cli.ErrSilent - - // Return ErrCancel if user cancelled - // return cli.ErrCancel - - // Return FlagError for bad input - // return cli.NewFlagError(fmt.Errorf("--port must be positive")) + p := cli.Prompter(cmd) + ok, _ := p.Confirm("Continue?", true) + if !ok { + return cli.ErrCancel // exit 0, no output + } + return nil }, } cli.Init(rootCmd) - if err := rootCmd.Execute(); err != nil { - cli.HandleError(err) - } + cli.Execute(rootCmd) } func ExampleInit_withTopics() { @@ -75,5 +67,5 @@ func ExampleInit_withTopics() { ), ) - rootCmd.Execute() + cli.Execute(rootCmd) } diff --git a/cli/printer/example_test.go b/cli/printer/example_test.go index 25eb994..76bf5a9 100644 --- a/cli/printer/example_test.go +++ b/cli/printer/example_test.go @@ -30,7 +30,7 @@ func ExampleOutput_Table() { func ExampleOutput_JSON() { out := printer.NewOutput(os.Stdout) - data := map[string]interface{}{ + data := map[string]any{ "name": "Alice", "age": 30, } diff --git a/cli/printer/printer.go b/cli/printer/printer.go index ffdd9f5..fb71ed3 100644 --- a/cli/printer/printer.go +++ b/cli/printer/printer.go @@ -89,7 +89,7 @@ func (o *Output) Println(msg string) { // --- Structured output --- // JSON writes data as compact JSON. -func (o *Output) JSON(data interface{}) error { +func (o *Output) JSON(data any) error { out, err := json.Marshal(data) if err != nil { return err @@ -99,7 +99,7 @@ func (o *Output) JSON(data interface{}) error { } // PrettyJSON writes data as indented JSON. -func (o *Output) PrettyJSON(data interface{}) error { +func (o *Output) PrettyJSON(data any) error { out, err := json.MarshalIndent(data, "", " ") if err != nil { return err @@ -109,7 +109,7 @@ func (o *Output) PrettyJSON(data interface{}) error { } // YAML writes data as YAML. -func (o *Output) YAML(data interface{}) error { +func (o *Output) YAML(data any) error { out, err := yaml.Marshal(data) if err != nil { return err @@ -241,25 +241,25 @@ func Magenta(t string) string { return colorize(newTheme().Magenta, t) } // --- Formatted color helpers --- // Greenf returns formatted text styled in green. -func Greenf(format string, a ...interface{}) string { return Green(fmt.Sprintf(format, a...)) } +func Greenf(format string, a ...any) string { return Green(fmt.Sprintf(format, a...)) } // Yellowf returns formatted text styled in yellow. -func Yellowf(format string, a ...interface{}) string { return Yellow(fmt.Sprintf(format, a...)) } +func Yellowf(format string, a ...any) string { return Yellow(fmt.Sprintf(format, a...)) } // Cyanf returns formatted text styled in cyan. -func Cyanf(format string, a ...interface{}) string { return Cyan(fmt.Sprintf(format, a...)) } +func Cyanf(format string, a ...any) string { return Cyan(fmt.Sprintf(format, a...)) } // Redf returns formatted text styled in red. -func Redf(format string, a ...interface{}) string { return Red(fmt.Sprintf(format, a...)) } +func Redf(format string, a ...any) string { return Red(fmt.Sprintf(format, a...)) } // Greyf returns formatted text styled in grey. -func Greyf(format string, a ...interface{}) string { return Grey(fmt.Sprintf(format, a...)) } +func Greyf(format string, a ...any) string { return Grey(fmt.Sprintf(format, a...)) } // Bluef returns formatted text styled in blue. -func Bluef(format string, a ...interface{}) string { return Blue(fmt.Sprintf(format, a...)) } +func Bluef(format string, a ...any) string { return Blue(fmt.Sprintf(format, a...)) } // Magentaf returns formatted text styled in magenta. -func Magentaf(format string, a ...interface{}) string { return Magenta(fmt.Sprintf(format, a...)) } +func Magentaf(format string, a ...any) string { return Magenta(fmt.Sprintf(format, a...)) } // Italic returns text styled in italic. func Italic(t string) string { return termenv.String(t).Italic().String() } diff --git a/config/config.go b/config/config.go index f66dae9..76938a4 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "os" "path/filepath" "reflect" @@ -90,7 +91,7 @@ func WithAppConfig(app string) Option { // 2. Environment variables // 3. Configuration file // 4. Default values -func (l *Loader) Load(config interface{}) error { +func (l *Loader) Load(config any) error { if err := validateStructPtr(config); err != nil { return err } @@ -119,7 +120,7 @@ func (l *Loader) Load(config interface{}) error { // Attempt to read the configuration file (missing file is not an error). if err := l.v.ReadInConfig(); err != nil { var configFileNotFoundError viper.ConfigFileNotFoundError - if !errors.As(err, &configFileNotFoundError) && !os.IsNotExist(err) { + if !errors.As(err, &configFileNotFoundError) && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("failed to read config file: %w", err) } } @@ -138,7 +139,7 @@ func (l *Loader) Load(config interface{}) error { } // Init initializes the configuration file with default values. -func (l *Loader) Init(config interface{}) error { +func (l *Loader) Init(config any) error { defaults.Set(config) path := l.v.ConfigFileUsed() @@ -162,12 +163,12 @@ func (l *Loader) Init(config interface{}) error { } // Get retrieves a configuration value by key. -func (l *Loader) Get(key string) interface{} { +func (l *Loader) Get(key string) any { return l.v.Get(key) } // Set updates a configuration value in memory (not persisted to file). -func (l *Loader) Set(key string, value interface{}) { +func (l *Loader) Set(key string, value any) { l.v.Set(key, value) } diff --git a/config/helpers.go b/config/helpers.go index 402b74e..3ff4558 100644 --- a/config/helpers.go +++ b/config/helpers.go @@ -46,7 +46,7 @@ func bindFlags(v *viper.Viper, flagSet *pflag.FlagSet, structType reflect.Type, } // validateStructPtr ensures the provided value is a pointer to a struct. -func validateStructPtr(value interface{}) error { +func validateStructPtr(value any) error { val := reflect.ValueOf(value) if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct { return errors.New("load requires a pointer to a struct") @@ -55,8 +55,8 @@ func validateStructPtr(value interface{}) error { } // extractFlattenedKeys retrieves all keys from the struct in a flattened format. -func extractFlattenedKeys(config interface{}) ([]string, error) { - var structMap map[string]interface{} +func extractFlattenedKeys(config any) ([]string, error) { + var structMap map[string]any if err := mapstructure.Decode(config, &structMap); err != nil { return nil, err } diff --git a/data/jsondiff/README.md b/data/jsondiff/README.md index c110187..08cb7fe 100644 --- a/data/jsondiff/README.md +++ b/data/jsondiff/README.md @@ -83,7 +83,7 @@ func main() { } // Verify reconstruction accuracy - var originalObj, reconstructedObj interface{} + var originalObj, reconstructedObj any json.Unmarshal([]byte(originalJSON), &originalObj) json.Unmarshal([]byte(reconstructed), &reconstructedObj) @@ -160,7 +160,7 @@ func main() { } // Verify reconstruction accuracy - var originalObj, reconstructedObj interface{} + var originalObj, reconstructedObj any json.Unmarshal([]byte(originalJSON), &originalObj) json.Unmarshal([]byte(reconstructed), &reconstructedObj) diff --git a/data/jsondiff/jsondiff.go b/data/jsondiff/jsondiff.go index 8f305a1..421f308 100644 --- a/data/jsondiff/jsondiff.go +++ b/data/jsondiff/jsondiff.go @@ -1,10 +1,11 @@ package jsondiff import ( + "cmp" "encoding/json" "fmt" "reflect" - "sort" + "slices" "strconv" "strings" ) @@ -25,7 +26,7 @@ func NewJSONDiffer() *JSONDiffer { } func (jd *JSONDiffer) Compare(json1, json2 string) ([]DiffEntry, error) { - var obj1, obj2 interface{} + var obj1, obj2 any if err := json.Unmarshal([]byte(json1), &obj1); err != nil { return nil, fmt.Errorf("error parsing first JSON: %w", err) @@ -38,14 +39,14 @@ func (jd *JSONDiffer) Compare(json1, json2 string) ([]DiffEntry, error) { var diffs []DiffEntry jd.compareObjects(obj1, obj2, "", &diffs) - sort.Slice(diffs, func(i, j int) bool { - return diffs[i].FullPath < diffs[j].FullPath + slices.SortFunc(diffs, func(a, b DiffEntry) int { + return cmp.Compare(a.FullPath, b.FullPath) }) return diffs, nil } -func (jd *JSONDiffer) compareObjects(obj1, obj2 interface{}, path string, diffs *[]DiffEntry) { +func (jd *JSONDiffer) compareObjects(obj1, obj2 any, path string, diffs *[]DiffEntry) { if reflect.DeepEqual(obj1, obj2) { return } @@ -81,12 +82,12 @@ func (jd *JSONDiffer) compareObjects(obj1, obj2 interface{}, path string, diffs } switch v1 := obj1.(type) { - case map[string]interface{}: - v2 := obj2.(map[string]interface{}) + case map[string]any: + v2 := obj2.(map[string]any) jd.compareObjects_internal(v1, v2, path, diffs) - case []interface{}: - v2 := obj2.([]interface{}) + case []any: + v2 := obj2.([]any) jd.compareArrays(v1, v2, path, diffs) default: @@ -96,7 +97,7 @@ func (jd *JSONDiffer) compareObjects(obj1, obj2 interface{}, path string, diffs } } -func (jd *JSONDiffer) compareObjects_internal(obj1, obj2 map[string]interface{}, path string, diffs *[]DiffEntry) { +func (jd *JSONDiffer) compareObjects_internal(obj1, obj2 map[string]any, path string, diffs *[]DiffEntry) { allKeys := make(map[string]bool) for key := range obj1 { allKeys[key] = true @@ -121,7 +122,7 @@ func (jd *JSONDiffer) compareObjects_internal(obj1, obj2 map[string]interface{}, } } -func (jd *JSONDiffer) compareArrays(arr1, arr2 []interface{}, path string, diffs *[]DiffEntry) { +func (jd *JSONDiffer) compareArrays(arr1, arr2 []any, path string, diffs *[]DiffEntry) { if !reflect.DeepEqual(arr1, arr2) { *diffs = append(*diffs, jd.createDiffEntry(path, "modified", arr1, arr2)) } @@ -134,7 +135,7 @@ func (jd *JSONDiffer) buildPath(parentPath, key string) string { return parentPath + "/" + key } -func (jd *JSONDiffer) createDiffEntry(path, changeType string, oldValue, newValue interface{}) DiffEntry { +func (jd *JSONDiffer) createDiffEntry(path, changeType string, oldValue, newValue any) DiffEntry { entry := DiffEntry{ FullPath: path, ChangeType: changeType, @@ -177,7 +178,7 @@ func (jd *JSONDiffer) extractFieldName(path string) string { return parts[len(parts)-1] } -func (jd *JSONDiffer) getValueType(val interface{}) string { +func (jd *JSONDiffer) getValueType(val any) string { if val == nil { return "null" } @@ -189,16 +190,16 @@ func (jd *JSONDiffer) getValueType(val interface{}) string { return "number" case bool: return "boolean" - case []interface{}: + case []any: return "array" - case map[string]interface{}: + case map[string]any: return "object" default: return "unknown" } } -func (jd *JSONDiffer) formatValue(val interface{}) string { +func (jd *JSONDiffer) formatValue(val any) string { if val == nil { return "null" } @@ -217,7 +218,7 @@ func (jd *JSONDiffer) formatValue(val interface{}) string { return strconv.Itoa(v) case int64: return strconv.FormatInt(v, 10) - case map[string]interface{}, []interface{}: + case map[string]any, []any: bytes, _ := json.Marshal(v) return string(bytes) default: diff --git a/data/jsondiff/jsondiff_test.go b/data/jsondiff/jsondiff_test.go index e67b857..6b99ef2 100644 --- a/data/jsondiff/jsondiff_test.go +++ b/data/jsondiff/jsondiff_test.go @@ -566,7 +566,7 @@ func TestJSONDifferComprehensive(t *testing.T) { } // Verify reconstruction - var originalObj, reconstructedObj interface{} + var originalObj, reconstructedObj any if err := json.Unmarshal([]byte(tc.json1), &originalObj); err != nil { t.Fatalf("Failed to unmarshal original JSON: %v", err) } diff --git a/data/jsondiff/jsondiff_wi2l.go b/data/jsondiff/jsondiff_wi2l.go index bf28c6e..a4d31cf 100644 --- a/data/jsondiff/jsondiff_wi2l.go +++ b/data/jsondiff/jsondiff_wi2l.go @@ -106,7 +106,7 @@ func (w *WI2LDiffer) postProcessArrays(diffs []DiffEntry, json1, json2 string) [ var result []DiffEntry // Parse original JSONs once - var obj1, obj2 interface{} + var obj1, obj2 any json.Unmarshal([]byte(json1), &obj1) json.Unmarshal([]byte(json2), &obj2) @@ -198,7 +198,7 @@ func (w *WI2LDiffer) isArrayIndex(s string) bool { } // getValueAtPath retrieves value at JSON path -func (w *WI2LDiffer) getValueAtPath(obj interface{}, path string) interface{} { +func (w *WI2LDiffer) getValueAtPath(obj any, path string) any { if path == "" || path == "/" { return obj } @@ -208,7 +208,7 @@ func (w *WI2LDiffer) getValueAtPath(obj interface{}, path string) interface{} { for _, part := range parts { switch v := current.(type) { - case map[string]interface{}: + case map[string]any: current = v[part] default: return nil @@ -233,7 +233,7 @@ func (w *WI2LDiffer) extractFieldNameFromPath(path string) string { } // formatPatchValue formats a value to string -func (w *WI2LDiffer) formatPatchValue(value interface{}) *string { +func (w *WI2LDiffer) formatPatchValue(value any) *string { if value == nil { str := "null" return &str @@ -256,7 +256,7 @@ func (w *WI2LDiffer) formatPatchValue(value interface{}) *string { } // inferValueType determines the JSON type -func (w *WI2LDiffer) inferValueType(value interface{}) string { +func (w *WI2LDiffer) inferValueType(value any) string { if value == nil { return "null" } @@ -268,9 +268,9 @@ func (w *WI2LDiffer) inferValueType(value interface{}) string { return "boolean" case float64, int: return "number" - case []interface{}: + case []any: return "array" - case map[string]interface{}: + case map[string]any: return "object" default: return "unknown" diff --git a/data/jsondiff/jsondiff_wi2l_test.go b/data/jsondiff/jsondiff_wi2l_test.go index 8b946ff..3661e4e 100644 --- a/data/jsondiff/jsondiff_wi2l_test.go +++ b/data/jsondiff/jsondiff_wi2l_test.go @@ -566,7 +566,7 @@ func TestWI2LDifferComprehensive(t *testing.T) { } // Verify reconstruction - var originalObj, reconstructedObj interface{} + var originalObj, reconstructedObj any if err := json.Unmarshal([]byte(tc.json1), &originalObj); err != nil { t.Fatalf("Failed to unmarshal original JSON: %v", err) } diff --git a/data/jsondiff/reconstructor.go b/data/jsondiff/reconstructor.go index 41954b1..cbd98ff 100644 --- a/data/jsondiff/reconstructor.go +++ b/data/jsondiff/reconstructor.go @@ -14,7 +14,7 @@ func NewJSONReconstructor() *JSONReconstructor { } func (jr *JSONReconstructor) ReverseDiff(currentJSON string, diffs []DiffEntry) (string, error) { - var current interface{} + var current any if err := json.Unmarshal([]byte(currentJSON), ¤t); err != nil { return "", fmt.Errorf("error parsing current JSON: %w", err) } @@ -36,7 +36,7 @@ func (jr *JSONReconstructor) ReverseDiff(currentJSON string, diffs []DiffEntry) return string(result), nil } -func (jr *JSONReconstructor) applyReverseDiff(obj interface{}, diff DiffEntry) error { +func (jr *JSONReconstructor) applyReverseDiff(obj any, diff DiffEntry) error { pathParts := jr.parsePath(diff.FullPath) switch diff.ChangeType { @@ -72,14 +72,14 @@ func (jr *JSONReconstructor) parsePath(path string) []string { return strings.Split(strings.TrimPrefix(path, "/"), "/") } -func (jr *JSONReconstructor) setAtPath(obj interface{}, pathParts []string, value interface{}) error { +func (jr *JSONReconstructor) setAtPath(obj any, pathParts []string, value any) error { if len(pathParts) == 0 { return fmt.Errorf("empty path") } if len(pathParts) == 1 { switch o := obj.(type) { - case map[string]interface{}: + case map[string]any: o[pathParts[0]] = value default: return fmt.Errorf("cannot set value on non-object") @@ -88,10 +88,10 @@ func (jr *JSONReconstructor) setAtPath(obj interface{}, pathParts []string, valu } switch o := obj.(type) { - case map[string]interface{}: + case map[string]any: key := pathParts[0] if o[key] == nil { - o[key] = make(map[string]interface{}) + o[key] = make(map[string]any) } return jr.setAtPath(o[key], pathParts[1:], value) default: @@ -99,14 +99,14 @@ func (jr *JSONReconstructor) setAtPath(obj interface{}, pathParts []string, valu } } -func (jr *JSONReconstructor) removeAtPath(obj interface{}, pathParts []string) error { +func (jr *JSONReconstructor) removeAtPath(obj any, pathParts []string) error { if len(pathParts) == 0 { return fmt.Errorf("empty path") } if len(pathParts) == 1 { switch o := obj.(type) { - case map[string]interface{}: + case map[string]any: delete(o, pathParts[0]) default: return fmt.Errorf("cannot remove from non-object") @@ -115,7 +115,7 @@ func (jr *JSONReconstructor) removeAtPath(obj interface{}, pathParts []string) e } switch o := obj.(type) { - case map[string]interface{}: + case map[string]any: key := pathParts[0] if o[key] == nil { return nil @@ -126,7 +126,7 @@ func (jr *JSONReconstructor) removeAtPath(obj interface{}, pathParts []string) e } } -func (jr *JSONReconstructor) parseValue(valueStr, valueType string) (interface{}, error) { +func (jr *JSONReconstructor) parseValue(valueStr, valueType string) (any, error) { // Handle null values first if valueStr == "null" || valueType == "null" { return nil, nil @@ -152,7 +152,7 @@ func (jr *JSONReconstructor) parseValue(valueStr, valueType string) (interface{} } return strconv.ParseBool(valueStr) case "array", "object": - var result interface{} + var result any if err := json.Unmarshal([]byte(valueStr), &result); err != nil { return nil, fmt.Errorf("invalid JSON: %w", err) } @@ -164,20 +164,20 @@ func (jr *JSONReconstructor) parseValue(valueStr, valueType string) (interface{} } } -func (jr *JSONReconstructor) deepCopy(obj interface{}) interface{} { +func (jr *JSONReconstructor) deepCopy(obj any) any { if obj == nil { return nil } switch v := obj.(type) { - case map[string]interface{}: - copy := make(map[string]interface{}) + case map[string]any: + copy := make(map[string]any) for key, value := range v { copy[key] = jr.deepCopy(value) } return copy - case []interface{}: - copy := make([]interface{}, len(v)) + case []any: + copy := make([]any, len(v)) for i, value := range v { copy[i] = jr.deepCopy(value) } diff --git a/data/rql/parser.go b/data/rql/parser.go index e14a0ca..ad25ed9 100644 --- a/data/rql/parser.go +++ b/data/rql/parser.go @@ -44,7 +44,7 @@ type Sort struct { Order string `json:"order"` } -func ValidateQuery(q *Query, checkStruct interface{}) error { +func ValidateQuery(q *Query, checkStruct any) error { val := reflect.ValueOf(checkStruct) // validate filters @@ -188,7 +188,7 @@ func getDataTypeOfField(tagString string) string { return res } -func GetDataTypeOfField(fieldName string, checkStruct interface{}) (string, error) { +func GetDataTypeOfField(fieldName string, checkStruct any) (string, error) { val := reflect.ValueOf(checkStruct) filterIdx := searchKeyInsideStruct(fieldName, val) if filterIdx < 0 { From 65c5fddcb2f23e58a8bdd7e099184615ae0d6f1a Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 22:39:36 -0500 Subject: [PATCH 24/30] fix: address review feedback from CodeRabbit - Remove -h shorthand on host flag to avoid panic from Cobra's built-in help flag conflict (cli/example_test.go) - Handle error from defaults.Set instead of ignoring it (config.go) - Preserve PersistentPreRunE in addition to PersistentPreRun so Init doesn't silently drop user hooks (cli.go) - Add Unwrap() to statusWriter so http.Flusher/Hijacker pass through, fixing streaming and h2c support (requestlog.go) --- cli/cli.go | 15 +++++++++++---- cli/example_test.go | 2 +- config/config.go | 6 ++++-- middleware/requestlog/requestlog.go | 6 ++++++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index 1b1aa47..63296af 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -53,16 +53,23 @@ func Init(rootCmd *cobra.Command, opts ...Option) { rootCmd.SilenceUsage = true // Inject shared output and prompter into command context. - existing := rootCmd.PersistentPreRun - rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + // Preserve any existing PersistentPreRun or PersistentPreRunE hook. + existingRun := rootCmd.PersistentPreRun + existingRunE := rootCmd.PersistentPreRunE + rootCmd.PersistentPreRun = nil + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { ctx := context.WithValue(cmd.Context(), contextKey{}, &cliContext{ output: printer.NewOutput(os.Stdout), prompter: prompt.New(), }) cmd.SetContext(ctx) - if existing != nil { - existing(cmd, args) + if existingRunE != nil { + return existingRunE(cmd, args) } + if existingRun != nil { + existingRun(cmd, args) + } + return nil } // Wire commander features. diff --git a/cli/example_test.go b/cli/example_test.go index a4d0da5..868ea22 100644 --- a/cli/example_test.go +++ b/cli/example_test.go @@ -11,7 +11,7 @@ func ExampleInit() { Use: "frontier", Short: "identity management", } - rootCmd.PersistentFlags().StringP("host", "h", "", "API server host") + rootCmd.PersistentFlags().String("host", "", "API server host") listCmd := &cobra.Command{ Use: "list", diff --git a/config/config.go b/config/config.go index 76938a4..78cf38e 100644 --- a/config/config.go +++ b/config/config.go @@ -96,8 +96,10 @@ func (l *Loader) Load(config any) error { return err } - // Apply default values before reading configuration - defaults.Set(config) + // Apply default values before reading configuration. + if err := defaults.Set(config); err != nil { + return fmt.Errorf("failed to set defaults: %w", err) + } // Bind flags dynamically using reflection on `cmdx` tags if a flag set is provided if l.flags != nil { diff --git a/middleware/requestlog/requestlog.go b/middleware/requestlog/requestlog.go index 1063824..97ce9be 100644 --- a/middleware/requestlog/requestlog.go +++ b/middleware/requestlog/requestlog.go @@ -112,3 +112,9 @@ func (w *statusWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } + +// Unwrap returns the underlying ResponseWriter, allowing the http package +// to access optional interfaces (Flusher, Hijacker, etc.) through it. +func (w *statusWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} From 2eab3def1cc5a0699d7912fe48bfe374db2194ff Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sat, 18 Apr 2026 22:42:39 -0500 Subject: [PATCH 25/30] docs: add language tag to code block and note defaults.Set error --- MIGRATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 06cc634..fd91bc3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -6,7 +6,7 @@ This guide covers migrating from the previous salt version to the new structure. Update `go.mod` to require Go 1.24: -``` +```text go 1.24 ``` @@ -279,7 +279,7 @@ httpMW := middleware.DefaultHTTP(slog.Default()) // If you imported go-defaults directly: // Before: "github.com/mcuadros/go-defaults" // After: "github.com/creasty/defaults" -// API change: defaults.SetDefaults(cfg) → defaults.Set(cfg) +// API change: defaults.SetDefaults(cfg) → defaults.Set(cfg) (now returns error) ``` The config package no longer prints warnings to stdout when a config file is missing. From 4837a4f651a098903b7b7347f9c2e3059cbc28d6 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 19 Apr 2026 11:04:41 -0500 Subject: [PATCH 26/30] feat(cli): add IOStreams for centralized I/O and testability Introduce IOStreams as the single owner of stdin/stdout/stderr with per-stream TTY detection, color awareness, pager integration, and prompt safety (CanPrompt). - System() for production, Test() for deterministic buffer-backed tests - cli.IO(cmd) extracts IOStreams from context; Output(cmd) and Prompter(cmd) remain as convenience shortcuts (unchanged API) - printer.NewOutputFrom(w, errW, tty) for explicit stream construction - SetStdoutTTY, SetColorEnabled, SetNeverPrompt for test overrides - StartPager/StopPager on IOStreams replaces direct terminal.Pager use --- MIGRATION.md | 44 ++++++++++- README.md | 4 +- cli/cli.go | 50 ++++++++----- cli/example_test.go | 41 +++++++++++ cli/iostreams.go | 163 +++++++++++++++++++++++++++++++++++++++++ cli/iostreams_test.go | 142 +++++++++++++++++++++++++++++++++++ cli/printer/printer.go | 6 ++ 7 files changed, 427 insertions(+), 23 deletions(-) create mode 100644 cli/iostreams.go create mode 100644 cli/iostreams_test.go diff --git a/MIGRATION.md b/MIGRATION.md index fd91bc3..43e6c99 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -150,7 +150,7 @@ rootCmd.AddGroup(&cobra.Group{ID: "manage", Title: "Management:"}) cmd.GroupID = "manage" ``` -Access shared output and prompting in commands: +Access shared output, prompting, and I/O capabilities in commands: ```go func newListCmd() *cobra.Command { @@ -165,6 +165,48 @@ func newListCmd() *cobra.Command { } ``` +`cli.IO(cmd)` provides the full IOStreams for commands that need richer control: + +```go +func newDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete", + RunE: func(cmd *cobra.Command, args []string) error { + ios := cli.IO(cmd) + if !ios.CanPrompt() { + return fmt.Errorf("--yes required in non-interactive mode") + } + ok, _ := ios.Prompter().Confirm("Delete?", false) + if !ok { + return cli.ErrCancel + } + // Use pager for long output + ios.StartPager() + defer ios.StopPager() + ios.Output().Markdown(longDoc) + return nil + }, + } +} +``` + +For testing commands, `cli.Test()` returns IOStreams backed by buffers: + +```go +func TestListCmd(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.SetStdoutTTY(true) // simulate a terminal + + cmd := newListCmd() + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + cmd.SetArgs([]string{}) + require.NoError(t, cmd.Execute()) + + assert.Contains(t, stdout.String(), "Alice") +} +``` + ## Error handling `commander.IsCommandErr` (string matching) and manual error handling are replaced by `cli.Execute`: diff --git a/README.md b/README.md index 204aac0..795336d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ import ( func main() { rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} - rootCmd.PersistentFlags().StringP("host", "h", "", "API server host") + rootCmd.PersistentFlags().String("host", "", "API server host") rootCmd.AddCommand(serverCmd, userCmd) cli.Init(rootCmd, @@ -61,7 +61,7 @@ func main() { } ``` -`Init` adds help, shell completion, reference docs, and silences Cobra's default error output. `Execute` runs the command and handles all errors with proper exit codes. Commands access shared output via `cli.Output(cmd)`. +`Init` adds help, shell completion, reference docs, and silences Cobra's default error output. `Execute` runs the command and handles all errors with proper exit codes. Commands access shared I/O via `cli.IO(cmd)`, or the convenience helpers `cli.Output(cmd)` and `cli.Prompter(cmd)`. Use `cli.Test()` in tests for captured, deterministic output. ## Installation diff --git a/cli/cli.go b/cli/cli.go index 63296af..9cca319 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -11,6 +11,18 @@ // ) // // cli.Execute(rootCmd) +// +// Commands access shared I/O via [IO], or the convenience helpers +// [Output] and [Prompter]: +// +// ios := cli.IO(cmd) // full IOStreams +// out := cli.Output(cmd) // formatting (table, JSON, spinner) +// p := cli.Prompter(cmd) // interactive prompts +// +// For testing, [Test] returns IOStreams backed by buffers: +// +// ios, stdin, stdout, stderr := cli.Test() +// ios.SetStdoutTTY(true) package cli import ( @@ -28,10 +40,10 @@ import ( type contextKey struct{} -type cliContext struct { - output *printer.Output - prompter prompt.Prompter -} +// ContextKey returns the context key used to store IOStreams. +// This is primarily useful for tests that need to inject IOStreams +// into a command's context directly. +func ContextKey() contextKey { return contextKey{} } // Init enhances a cobra root command with standard CLI features: // help, completion, reference docs, output/prompter context, and @@ -52,16 +64,13 @@ func Init(rootCmd *cobra.Command, opts ...Option) { rootCmd.SilenceErrors = true rootCmd.SilenceUsage = true - // Inject shared output and prompter into command context. + // Inject IOStreams into command context. // Preserve any existing PersistentPreRun or PersistentPreRunE hook. existingRun := rootCmd.PersistentPreRun existingRunE := rootCmd.PersistentPreRunE rootCmd.PersistentPreRun = nil rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - ctx := context.WithValue(cmd.Context(), contextKey{}, &cliContext{ - output: printer.NewOutput(os.Stdout), - prompter: prompt.New(), - }) + ctx := context.WithValue(cmd.Context(), contextKey{}, System()) cmd.SetContext(ctx) if existingRunE != nil { return existingRunE(cmd, args) @@ -139,22 +148,23 @@ func versionCmd(name, ver, repo string) *cobra.Command { } } -// Output extracts the shared printer from a command's context. -func Output(cmd *cobra.Command) *printer.Output { +// IO extracts the IOStreams from a command's context. +// Returns a default System() IOStreams if none was injected. +func IO(cmd *cobra.Command) *IOStreams { if ctx := cmd.Context(); ctx != nil { - if cc, ok := ctx.Value(contextKey{}).(*cliContext); ok { - return cc.output + if ios, ok := ctx.Value(contextKey{}).(*IOStreams); ok { + return ios } } - return printer.NewOutput(os.Stdout) + return System() +} + +// Output extracts the shared printer from a command's context. +func Output(cmd *cobra.Command) *printer.Output { + return IO(cmd).Output() } // Prompter extracts the shared prompter from a command's context. func Prompter(cmd *cobra.Command) prompt.Prompter { - if ctx := cmd.Context(); ctx != nil { - if cc, ok := ctx.Value(contextKey{}).(*cliContext); ok { - return cc.prompter - } - } - return prompt.New() + return IO(cmd).Prompter() } diff --git a/cli/example_test.go b/cli/example_test.go index 868ea22..a3d3dad 100644 --- a/cli/example_test.go +++ b/cli/example_test.go @@ -1,6 +1,8 @@ package cli_test import ( + "fmt" + "github.com/raystack/salt/cli" "github.com/raystack/salt/cli/commander" "github.com/spf13/cobra" @@ -51,6 +53,45 @@ func ExampleExecute() { cli.Execute(rootCmd) } +func ExampleIO() { + deleteCmd := &cobra.Command{ + Use: "delete", + RunE: func(cmd *cobra.Command, args []string) error { + ios := cli.IO(cmd) + + // Guard interactive prompts in non-TTY environments. + if !ios.CanPrompt() { + return fmt.Errorf("--yes flag required in non-interactive mode") + } + + ok, _ := ios.Prompter().Confirm("Delete resource?", false) + if !ok { + return cli.ErrCancel + } + + ios.Output().Success("deleted") + return nil + }, + } + + rootCmd := &cobra.Command{Use: "myapp"} + rootCmd.AddCommand(deleteCmd) + cli.Init(rootCmd) + cli.Execute(rootCmd) +} + +func ExampleTest() { + // Use cli.Test() in unit tests to capture output. + ios, _, stdout, _ := cli.Test() + ios.SetStdoutTTY(true) // simulate a terminal + + out := ios.Output() + out.Println("hello from test") + + fmt.Print(stdout.String()) + // Output: hello from test +} + func ExampleInit_withTopics() { rootCmd := &cobra.Command{ Use: "myapp", diff --git a/cli/iostreams.go b/cli/iostreams.go new file mode 100644 index 0000000..d169b18 --- /dev/null +++ b/cli/iostreams.go @@ -0,0 +1,163 @@ +package cli + +import ( + "bytes" + "io" + "os" + + "github.com/mattn/go-isatty" + "github.com/muesli/termenv" + "github.com/raystack/salt/cli/printer" + "github.com/raystack/salt/cli/prompt" + "github.com/raystack/salt/cli/terminal" + "golang.org/x/term" +) + +// IOStreams provides centralized access to standard I/O streams and +// terminal capabilities for CLI commands. +// +// Use [System] for production and [Test] for tests. Commands access +// it via [IO]: +// +// ios := cli.IO(cmd) +// if !ios.CanPrompt() { +// return fmt.Errorf("--yes required in non-interactive mode") +// } +type IOStreams struct { + In io.ReadCloser // standard input + Out io.Writer // standard output (may become pager pipe) + ErrOut io.Writer // standard error + + inTTY bool + outTTY bool + errTTY bool + + colorEnabled bool + neverPrompt bool + + pager *terminal.Pager + pagerStarted bool + + // lazily created + output *printer.Output + prompter prompt.Prompter +} + +// System creates IOStreams wired to the real terminal. +func System() *IOStreams { + outTTY := isTTYWriter(os.Stdout) + return &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + inTTY: isTTYWriter(os.Stdin), + outTTY: outTTY, + errTTY: isTTYWriter(os.Stderr), + colorEnabled: outTTY && !termenv.EnvNoColor(), + } +} + +// Test creates IOStreams backed by buffers for deterministic testing. +// All TTY flags default to false and color is disabled. +func Test() (ios *IOStreams, stdin *bytes.Buffer, stdout *bytes.Buffer, stderr *bytes.Buffer) { + stdin = &bytes.Buffer{} + stdout = &bytes.Buffer{} + stderr = &bytes.Buffer{} + ios = &IOStreams{ + In: io.NopCloser(stdin), + Out: stdout, + ErrOut: stderr, + } + return +} + +// IsStdinTTY reports whether standard input is a terminal. +func (s *IOStreams) IsStdinTTY() bool { return s.inTTY } + +// IsStdoutTTY reports whether standard output is a terminal. +func (s *IOStreams) IsStdoutTTY() bool { return s.outTTY } + +// IsStderrTTY reports whether standard error is a terminal. +func (s *IOStreams) IsStderrTTY() bool { return s.errTTY } + +// SetStdinTTY overrides the stdin TTY flag (useful in tests). +func (s *IOStreams) SetStdinTTY(v bool) { s.inTTY = v } + +// SetStdoutTTY overrides the stdout TTY flag (useful in tests). +func (s *IOStreams) SetStdoutTTY(v bool) { s.outTTY = v; s.output = nil } + +// SetStderrTTY overrides the stderr TTY flag (useful in tests). +func (s *IOStreams) SetStderrTTY(v bool) { s.errTTY = v } + +// SetColorEnabled overrides color detection (useful in tests). +func (s *IOStreams) SetColorEnabled(v bool) { s.colorEnabled = v } + +// SetNeverPrompt disables interactive prompting regardless of TTY state. +func (s *IOStreams) SetNeverPrompt(v bool) { s.neverPrompt = v } + +// ColorEnabled reports whether color output is active. +func (s *IOStreams) ColorEnabled() bool { return s.colorEnabled } + +// CanPrompt reports whether interactive prompting is possible. +// Returns false if prompting is disabled, or stdin/stdout are not terminals. +func (s *IOStreams) CanPrompt() bool { + return !s.neverPrompt && s.inTTY && s.outTTY +} + +// TerminalWidth returns the terminal width in columns. +// Returns 80 if the width cannot be determined. +func (s *IOStreams) TerminalWidth() int { + if f, ok := s.Out.(*os.File); ok { + if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 { + return w + } + } + return 80 +} + +// StartPager starts a pager process and redirects Out through it. +// Does nothing if stdout is not a TTY or no pager command is configured. +func (s *IOStreams) StartPager() error { + if !s.outTTY { + return nil + } + p := terminal.NewPager() + p.Out = s.Out + p.ErrOut = s.ErrOut + if err := p.Start(); err != nil { + return err + } + s.Out = p.Out + s.pager = p + s.pagerStarted = true + s.output = nil // invalidate cached Output + return nil +} + +// StopPager stops the pager process and restores the original Out. +func (s *IOStreams) StopPager() { + if s.pager != nil && s.pagerStarted { + s.pager.Stop() + s.pagerStarted = false + } +} + +// Output returns the formatting layer, creating it lazily. +func (s *IOStreams) Output() *printer.Output { + if s.output == nil { + s.output = printer.NewOutputFrom(s.Out, s.ErrOut, s.outTTY) + } + return s.output +} + +// Prompter returns the prompt layer, creating it lazily. +func (s *IOStreams) Prompter() prompt.Prompter { + if s.prompter == nil { + s.prompter = prompt.New() + } + return s.prompter +} + +func isTTYWriter(f *os.File) bool { + return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) +} diff --git a/cli/iostreams_test.go b/cli/iostreams_test.go new file mode 100644 index 0000000..8de2944 --- /dev/null +++ b/cli/iostreams_test.go @@ -0,0 +1,142 @@ +package cli_test + +import ( + "context" + "testing" + + "github.com/raystack/salt/cli" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSystem(t *testing.T) { + ios := cli.System() + assert.NotNil(t, ios.In) + assert.NotNil(t, ios.Out) + assert.NotNil(t, ios.ErrOut) +} + +func TestTest(t *testing.T) { + ios, stdin, stdout, stderr := cli.Test() + + assert.False(t, ios.IsStdinTTY(), "test stdin should not be TTY") + assert.False(t, ios.IsStdoutTTY(), "test stdout should not be TTY") + assert.False(t, ios.IsStderrTTY(), "test stderr should not be TTY") + assert.False(t, ios.ColorEnabled(), "test should have color disabled") + + // Verify buffers are wired correctly. + _, err := ios.Out.Write([]byte("hello")) + require.NoError(t, err) + assert.Equal(t, "hello", stdout.String()) + + _, err = ios.ErrOut.Write([]byte("warn")) + require.NoError(t, err) + assert.Equal(t, "warn", stderr.String()) + + assert.NotNil(t, stdin, "stdin buffer should be returned") +} + +func TestIOStreams_CanPrompt(t *testing.T) { + t.Run("false when not TTY", func(t *testing.T) { + ios, _, _, _ := cli.Test() + assert.False(t, ios.CanPrompt()) + }) + + t.Run("true when both stdin and stdout are TTY", func(t *testing.T) { + ios, _, _, _ := cli.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + assert.True(t, ios.CanPrompt()) + }) + + t.Run("false when NeverPrompt is set", func(t *testing.T) { + ios, _, _, _ := cli.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetNeverPrompt(true) + assert.False(t, ios.CanPrompt()) + }) + + t.Run("false when only stdin is TTY", func(t *testing.T) { + ios, _, _, _ := cli.Test() + ios.SetStdinTTY(true) + assert.False(t, ios.CanPrompt()) + }) +} + +func TestIOStreams_TTYOverrides(t *testing.T) { + ios, _, _, _ := cli.Test() + + ios.SetStdinTTY(true) + assert.True(t, ios.IsStdinTTY()) + + ios.SetStdoutTTY(true) + assert.True(t, ios.IsStdoutTTY()) + + ios.SetStderrTTY(true) + assert.True(t, ios.IsStderrTTY()) +} + +func TestIOStreams_ColorEnabled(t *testing.T) { + ios, _, _, _ := cli.Test() + assert.False(t, ios.ColorEnabled()) + + ios.SetColorEnabled(true) + assert.True(t, ios.ColorEnabled()) +} + +func TestIOStreams_TerminalWidth(t *testing.T) { + ios, _, _, _ := cli.Test() + // Non-file writer returns default 80. + assert.Equal(t, 80, ios.TerminalWidth()) +} + +func TestIOStreams_Output(t *testing.T) { + ios, _, stdout, _ := cli.Test() + + out := ios.Output() + assert.NotNil(t, out) + + // Same instance on repeated calls. + assert.Same(t, out, ios.Output()) + + // Writes go to the stdout buffer. + out.Println("test output") + assert.Contains(t, stdout.String(), "test output") +} + +func TestIOStreams_OutputResetsOnTTYChange(t *testing.T) { + ios, _, _, _ := cli.Test() + out1 := ios.Output() + + ios.SetStdoutTTY(true) + out2 := ios.Output() + assert.NotSame(t, out1, out2, "Output should be recreated after TTY change") +} + +func TestIOStreams_Prompter(t *testing.T) { + ios, _, _, _ := cli.Test() + p := ios.Prompter() + assert.NotNil(t, p) + // Same instance on repeated calls. + assert.Same(t, p, ios.Prompter()) +} + +func TestIO(t *testing.T) { + t.Run("extracts IOStreams from context", func(t *testing.T) { + ios, _, _, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + + got := cli.IO(cmd) + assert.Same(t, ios, got) + }) + + t.Run("returns fallback when no context", func(t *testing.T) { + cmd := &cobra.Command{Use: "bare"} + got := cli.IO(cmd) + assert.NotNil(t, got) + }) +} diff --git a/cli/printer/printer.go b/cli/printer/printer.go index fb71ed3..3b2a6e9 100644 --- a/cli/printer/printer.go +++ b/cli/printer/printer.go @@ -49,6 +49,12 @@ func NewOutput(w io.Writer) *Output { return &Output{w: w, errW: os.Stderr, theme: newTheme(), tty: tty} } +// NewOutputFrom creates an Output with explicit streams and TTY state. +// Use this when the caller has already determined TTY status (e.g. from IOStreams). +func NewOutputFrom(w io.Writer, errW io.Writer, tty bool) *Output { + return &Output{w: w, errW: errW, theme: newTheme(), tty: tty} +} + // --- Text output --- // Success prints a green success message to stderr. From 866c7763d4ed63abe493fd756a9c3fb7ea81ccd9 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 19 Apr 2026 11:22:59 -0500 Subject: [PATCH 27/30] refactor(terminal): remove functions superseded by IOStreams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove IsTTY, IsColorDisabled, and Width from the terminal package — these are now per-stream methods on IOStreams (IsStdoutTTY, ColorEnabled, TerminalWidth). OpenBrowser uses isatty directly instead of IsTTY. The terminal package retains OS-level utilities: IsCI, OpenBrowser, Pager, IsUnderHomebrew, HasHomebrew. --- cli/terminal/browser.go | 31 ++++------------------------ cli/terminal/term.go | 45 +++++++++++++++-------------------------- 2 files changed, 20 insertions(+), 56 deletions(-) diff --git a/cli/terminal/browser.go b/cli/terminal/browser.go index 687b03f..a2a53c6 100644 --- a/cli/terminal/browser.go +++ b/cli/terminal/browser.go @@ -6,42 +6,23 @@ import ( "strings" ) -// OpenBrowser opens the default web browser at the specified URL. -// -// Parameters: -// - goos: The operating system name (e.g., "darwin", "windows", or "linux"). -// - url: The URL to open in the web browser. -// -// Returns: -// - An *exec.Cmd configured to open the URL. Note that you must call `cmd.Run()` -// or `cmd.Start()` on the returned command to execute it. -// -// Panics: -// - This function will panic if called without a TTY (e.g., not running in a terminal). -func OpenBrowser(goos, url string) *exec.Cmd { - if !IsTTY() { - panic("OpenBrowser called without a TTY") - } - +// openBrowserCmd returns an exec.Cmd configured to open the URL. +func openBrowserCmd(goos, url string) *exec.Cmd { exe := "open" var args []string switch goos { case "darwin": - // macOS: Use the "open" command to open the URL. args = append(args, url) case "windows": - // Windows: Use "cmd /c start" to open the URL. exe, _ = exec.LookPath("cmd") replacer := strings.NewReplacer("&", "^&") args = append(args, "/c", "start", replacer.Replace(url)) default: - // Linux: Use "xdg-open" or fallback to "wslview" for WSL environments. exe = linuxExe() args = append(args, url) } - // Create the command to open the browser and set stderr for error reporting. cmd := exec.Command(exe, args...) cmd.Stderr = os.Stderr return cmd @@ -50,14 +31,10 @@ func OpenBrowser(goos, url string) *exec.Cmd { // linuxExe determines the appropriate command to open a web browser on Linux. func linuxExe() string { exe := "xdg-open" - - _, err := exec.LookPath(exe) - if err != nil { - _, err := exec.LookPath("wslview") - if err == nil { + if _, err := exec.LookPath(exe); err != nil { + if _, err := exec.LookPath("wslview"); err == nil { exe = "wslview" } } - return exe } diff --git a/cli/terminal/term.go b/cli/terminal/term.go index d1e0a3d..f2c2f42 100644 --- a/cli/terminal/term.go +++ b/cli/terminal/term.go @@ -2,41 +2,28 @@ package terminal import ( "os" + "os/exec" "github.com/mattn/go-isatty" - "github.com/muesli/termenv" - "golang.org/x/term" ) -// IsTTY checks if the current output is a TTY (teletypewriter) or a Cygwin terminal. -// This function is useful for determining if the program is running in a terminal -// environment, which is important for features like colored output or interactive prompts. -func IsTTY() bool { - return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) -} - -// IsColorDisabled checks if color output is disabled based on the environment settings. -// This function uses the `termenv` library to determine if the NO_COLOR environment -// variable is set, which is a common way to disable colored output. -func IsColorDisabled() bool { - return termenv.EnvNoColor() -} - -// Width returns the terminal width in columns. Returns 80 if the width -// cannot be determined (e.g. non-TTY, piped output). -func Width() int { - w, _, err := term.GetSize(int(os.Stdout.Fd())) - if err == nil && w > 0 { - return w - } - return 80 -} - -// IsCI checks if the code is running in a Continuous Integration (CI) environment. -// This function checks for common environment variables used by popular CI systems -// like GitHub Actions, Travis CI, CircleCI, Jenkins, TeamCity, and others. +// IsCI reports whether the process is running in a CI environment. +// Checks common environment variables used by GitHub Actions, Travis CI, +// CircleCI, Jenkins, TeamCity, and others. func IsCI() bool { return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity os.Getenv("RUN_ID") != "" // TaskCluster, dsari } + +// OpenBrowser opens the default web browser at the specified URL. +// The goos parameter should be runtime.GOOS (e.g. "darwin", "windows", "linux"). +// +// Returns an *exec.Cmd — call cmd.Run() or cmd.Start() to execute it. +// Panics if stdout is not a terminal. +func OpenBrowser(goos, url string) *exec.Cmd { + if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) { + panic("OpenBrowser called without a TTY") + } + return openBrowserCmd(goos, url) +} From 90a1347277d7eb6f771f8d85d216f1c1fc72bb69 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 19 Apr 2026 11:34:21 -0500 Subject: [PATCH 28/30] feat(cli): add --json flag with field selection for structured output Add Exporter interface and AddJSONFlags helper that gives any command a --json flag with comma-separated field selection, validation, and shell completion. - Fields extracted via json struct tags with case-insensitive fallback - Exportable interface for custom field handling - StructExportData helper for simple structs - Pretty-printed JSON on TTY, compact when piped - Rejects unknown fields with helpful error listing available fields --- MIGRATION.md | 32 ++++++ README.md | 2 +- cli/example_test.go | 42 ++++++++ cli/export.go | 243 ++++++++++++++++++++++++++++++++++++++++++++ cli/export_test.go | 162 +++++++++++++++++++++++++++++ 5 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 cli/export.go create mode 100644 cli/export_test.go diff --git a/MIGRATION.md b/MIGRATION.md index 43e6c99..98d6550 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -254,6 +254,38 @@ The following exports are removed — their functionality is now internal to `cl | `cli.FlagError` (type) | Unexported; flag errors are handled internally by `Execute` | | `commander.IsCommandErr(err)` | Removed; `Execute` detects and handles flag/command errors | +## JSON output + +Commands can offer `--json` with field selection via `cli.AddJSONFlags`: + +```go +var exporter cli.Exporter + +listCmd := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, _ []string) error { + users := fetchUsers() + + if exporter != nil { + return exporter.Write(cli.IO(cmd), users) + } + + cli.Output(cmd).Table(rows) + return nil + }, +} + +cli.AddJSONFlags(listCmd, &exporter, []string{"id", "name", "email", "status"}) +``` + +Usage: `myapp list --json id,name` outputs only the requested fields. Fields are extracted via `json` struct tags. For custom field handling, implement the `Exportable` interface: + +```go +func (u *User) ExportData(fields []string) map[string]any { + return cli.StructExportData(u, fields) +} +``` + ## Printer Package-level functions replaced by `Output` type: diff --git a/README.md b/README.md index 795336d..580de65 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Requires Go 1.24+. | Package | Description | |---------|-------------| | [`app`](app/) | Service lifecycle — config, logger, telemetry, server, graceful shutdown | -| [`cli`](cli/) | CLI lifecycle — init, execute, error handling, help, completion, version check | +| [`cli`](cli/) | CLI lifecycle — init, execute, IOStreams, `--json` export, error handling, help, completion | ### Server & Middleware diff --git a/cli/example_test.go b/cli/example_test.go index a3d3dad..39949a7 100644 --- a/cli/example_test.go +++ b/cli/example_test.go @@ -92,6 +92,48 @@ func ExampleTest() { // Output: hello from test } +func ExampleAddJSONFlags() { + type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Status string `json:"status"` + } + + var exporter cli.Exporter + + listCmd := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, _ []string) error { + users := []User{ + {ID: 1, Name: "Alice", Email: "alice@example.com", Status: "active"}, + {ID: 2, Name: "Bob", Email: "bob@example.com", Status: "inactive"}, + } + + // If --json was used, write structured output and return. + if exporter != nil { + return exporter.Write(cli.IO(cmd), users) + } + + // Otherwise, render a human-readable table. + out := cli.Output(cmd) + out.Table([][]string{ + {"ID", "NAME", "STATUS"}, + {"1", "Alice", "active"}, + {"2", "Bob", "inactive"}, + }) + return nil + }, + } + + cli.AddJSONFlags(listCmd, &exporter, []string{"id", "name", "email", "status"}) + + rootCmd := &cobra.Command{Use: "myapp"} + rootCmd.AddCommand(listCmd) + cli.Init(rootCmd) + cli.Execute(rootCmd) +} + func ExampleInit_withTopics() { rootCmd := &cobra.Command{ Use: "myapp", diff --git a/cli/export.go b/cli/export.go new file mode 100644 index 0000000..13d82d3 --- /dev/null +++ b/cli/export.go @@ -0,0 +1,243 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// Exporter controls structured JSON output for a command. +// When non-nil, the command should call Write instead of rendering +// human-readable output. +// +// A nil Exporter means the user did not request --json. +type Exporter interface { + // Fields returns the field names requested via --json. + Fields() []string + // Write encodes data as JSON and writes it to the IOStreams. + // On a TTY it writes indented, colorized JSON; when piped it + // writes compact JSON. + Write(ios *IOStreams, data any) error +} + +// Exportable may be implemented by structs to control which fields +// are exported and how. When a struct implements this interface, +// the JSON exporter calls ExportData instead of using reflection. +// +// func (r *Resource) ExportData(fields []string) map[string]any { +// return cli.StructExportData(r, fields) +// } +type Exportable interface { + ExportData(fields []string) map[string]any +} + +// AddJSONFlags adds a --json flag to cmd that accepts a comma-separated +// list of field names. When --json is used, exportTarget is set to a +// non-nil Exporter in a PreRunE hook. The command should check for a +// non-nil Exporter and call Write instead of rendering a table. +// +// var exporter cli.Exporter +// cli.AddJSONFlags(cmd, &exporter, []string{"id", "name", "status"}) +// +// // In RunE: +// if exporter != nil { +// return exporter.Write(cli.IO(cmd), results) +// } +// cli.Output(cmd).Table(rows) +func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) { + cmd.Flags().StringSlice("json", nil, "Output JSON with the specified `fields`") + + // Shell completion for field names. + _ = cmd.RegisterFlagCompletionFunc("json", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + var prefix string + if idx := strings.LastIndexByte(toComplete, ','); idx >= 0 { + prefix = toComplete[:idx+1] + toComplete = toComplete[idx+1:] + } + toComplete = strings.ToLower(toComplete) + for _, f := range fields { + if strings.HasPrefix(strings.ToLower(f), toComplete) { + results = append(results, prefix+f) + } + } + sort.Strings(results) + return results, cobra.ShellCompDirectiveNoSpace + }) + + // Validate field names and set the exporter. + oldPreRun := cmd.PreRunE + cmd.PreRunE = func(c *cobra.Command, args []string) error { + if oldPreRun != nil { + if err := oldPreRun(c, args); err != nil { + return err + } + } + + jsonFlag := c.Flags().Lookup("json") + if jsonFlag == nil || !jsonFlag.Changed { + *exportTarget = nil + return nil + } + + requested, _ := c.Flags().GetStringSlice("json") + allowed := make(map[string]bool, len(fields)) + for _, f := range fields { + allowed[f] = true + } + for _, f := range requested { + if !allowed[f] { + sorted := make([]string, len(fields)) + copy(sorted, fields) + sort.Strings(sorted) + return fmt.Errorf("unknown JSON field: %q\nAvailable fields:\n %s", f, strings.Join(sorted, "\n ")) + } + } + + *exportTarget = &jsonExporter{fields: requested} + return nil + } + + // When --json is passed without arguments, list available fields. + parentFlagErr := cmd.FlagErrorFunc() + cmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error { + if c == cmd && err.Error() == "flag needs an argument: --json" { + sorted := make([]string, len(fields)) + copy(sorted, fields) + sort.Strings(sorted) + return fmt.Errorf("specify one or more comma-separated fields for --json:\n %s", strings.Join(sorted, "\n ")) + } + if parentFlagErr != nil { + return parentFlagErr(c, err) + } + return err + }) + + // Annotate for help display. + if len(fields) > 0 { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations["help:json-fields"] = strings.Join(fields, ",") + } +} + +// StructExportData extracts the requested fields from a struct using +// case-insensitive field name matching. Use this as a default +// implementation for [Exportable.ExportData]: +// +// func (r *Resource) ExportData(fields []string) map[string]any { +// return cli.StructExportData(r, fields) +// } +func StructExportData(s any, fields []string) map[string]any { + v := reflect.ValueOf(s) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil + } + data := make(map[string]any, len(fields)) + for _, f := range fields { + sf := fieldByTag(v, f) + if !sf.IsValid() { + sf = fieldByName(v, f) + } + if sf.IsValid() && sf.CanInterface() { + data[f] = sf.Interface() + } + } + return data +} + +// jsonExporter is the default Exporter implementation. +type jsonExporter struct { + fields []string +} + +func (e *jsonExporter) Fields() []string { + return e.fields +} + +func (e *jsonExporter) Write(ios *IOStreams, data any) error { + extracted := e.extractData(reflect.ValueOf(data)) + + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(extracted); err != nil { + return err + } + + w := ios.Out + if ios.IsStdoutTTY() { + // Re-encode with indentation for readability. + var pretty bytes.Buffer + if err := json.Indent(&pretty, buf.Bytes(), "", " "); err != nil { + // Fallback to compact. + _, err = io.Copy(w, buf) + return err + } + pretty.WriteByte('\n') + _, err := io.Copy(w, &pretty) + return err + } + + _, err := io.Copy(w, buf) + return err +} + +func (e *jsonExporter) extractData(v reflect.Value) any { + switch v.Kind() { + case reflect.Ptr, reflect.Interface: + if !v.IsNil() { + return e.extractData(v.Elem()) + } + return nil + case reflect.Slice: + a := make([]any, v.Len()) + for i := 0; i < v.Len(); i++ { + a[i] = e.extractData(v.Index(i)) + } + return a + case reflect.Struct: + if v.CanAddr() { + if ex, ok := v.Addr().Interface().(Exportable); ok { + return ex.ExportData(e.fields) + } + } + if ex, ok := v.Interface().(Exportable); ok { + return ex.ExportData(e.fields) + } + return StructExportData(v.Interface(), e.fields) + } + return v.Interface() +} + +// fieldByTag finds a struct field whose `json` tag matches the given name. +func fieldByTag(v reflect.Value, name string) reflect.Value { + t := v.Type() + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("json") + if idx := strings.IndexByte(tag, ','); idx >= 0 { + tag = tag[:idx] + } + if strings.EqualFold(tag, name) { + return v.Field(i) + } + } + return reflect.Value{} +} + +// fieldByName finds a struct field by case-insensitive name match. +func fieldByName(v reflect.Value, name string) reflect.Value { + return v.FieldByNameFunc(func(s string) bool { + return strings.EqualFold(name, s) + }) +} diff --git a/cli/export_test.go b/cli/export_test.go new file mode 100644 index 0000000..29a0c31 --- /dev/null +++ b/cli/export_test.go @@ -0,0 +1,162 @@ +package cli_test + +import ( + "context" + "testing" + + "github.com/raystack/salt/cli" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testResource struct { + ID int `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Secret string `json:"-"` +} + +type customResource struct { + ID int `json:"id"` + Name string `json:"name"` + tags []string +} + +func (r *customResource) ExportData(fields []string) map[string]any { + data := cli.StructExportData(r, fields) + for _, f := range fields { + if f == "tags" { + data["tags"] = r.tags + } + } + return data +} + +func TestStructExportData(t *testing.T) { + r := testResource{ID: 1, Name: "alice", Status: "active", Secret: "s3cret"} + + t.Run("extracts requested fields by json tag", func(t *testing.T) { + data := cli.StructExportData(r, []string{"id", "name"}) + assert.Equal(t, map[string]any{"id": 1, "name": "alice"}, data) + }) + + t.Run("handles all fields", func(t *testing.T) { + data := cli.StructExportData(r, []string{"id", "name", "status"}) + assert.Equal(t, 3, len(data)) + }) + + t.Run("skips unknown fields", func(t *testing.T) { + data := cli.StructExportData(r, []string{"id", "nonexistent"}) + assert.Equal(t, map[string]any{"id": 1}, data) + }) + + t.Run("works with pointer", func(t *testing.T) { + data := cli.StructExportData(&r, []string{"name"}) + assert.Equal(t, map[string]any{"name": "alice"}, data) + }) +} + +func TestExporter_Write(t *testing.T) { + resources := []testResource{ + {ID: 1, Name: "alice", Status: "active"}, + {ID: 2, Name: "bob", Status: "inactive"}, + } + + t.Run("compact JSON when not TTY", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + + cmd := &cobra.Command{Use: "test"} + var exporter cli.Exporter + cli.AddJSONFlags(cmd, &exporter, []string{"id", "name", "status"}) + cmd.SetArgs([]string{"--json", "id,name"}) + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return exporter.Write(ios, resources) + } + + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + + assert.Contains(t, stdout.String(), `"id":1`) + assert.Contains(t, stdout.String(), `"name":"alice"`) + assert.NotContains(t, stdout.String(), `"status"`) + // Compact: no leading spaces. + assert.NotContains(t, stdout.String(), " ") + }) + + t.Run("indented JSON on TTY", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.SetStdoutTTY(true) + + cmd := &cobra.Command{Use: "test"} + var exporter cli.Exporter + cli.AddJSONFlags(cmd, &exporter, []string{"id", "name", "status"}) + cmd.SetArgs([]string{"--json", "id,name"}) + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return exporter.Write(ios, resources) + } + + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + + assert.Contains(t, stdout.String(), " ") + }) +} + +func TestExporter_Exportable(t *testing.T) { + resources := []*customResource{ + {ID: 1, Name: "proj", tags: []string{"go", "cli"}}, + } + + ios, _, stdout, _ := cli.Test() + + cmd := &cobra.Command{Use: "test"} + var exporter cli.Exporter + cli.AddJSONFlags(cmd, &exporter, []string{"id", "name", "tags"}) + cmd.SetArgs([]string{"--json", "name,tags"}) + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return exporter.Write(ios, resources) + } + + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + + assert.Contains(t, stdout.String(), `"tags":["go","cli"]`) + assert.Contains(t, stdout.String(), `"name":"proj"`) +} + +func TestAddJSONFlags(t *testing.T) { + t.Run("exporter is nil when --json not used", func(t *testing.T) { + cmd := &cobra.Command{Use: "test", RunE: func(cmd *cobra.Command, args []string) error { return nil }} + var exporter cli.Exporter + cli.AddJSONFlags(cmd, &exporter, []string{"id", "name"}) + cmd.SetArgs([]string{}) + require.NoError(t, cmd.Execute()) + assert.Nil(t, exporter) + }) + + t.Run("rejects unknown fields", func(t *testing.T) { + cmd := &cobra.Command{Use: "test", RunE: func(cmd *cobra.Command, args []string) error { return nil }} + var exporter cli.Exporter + cli.AddJSONFlags(cmd, &exporter, []string{"id", "name"}) + cmd.SetArgs([]string{"--json", "id,bogus"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown JSON field: "bogus"`) + assert.Contains(t, err.Error(), "id") + assert.Contains(t, err.Error(), "name") + }) + + t.Run("returns fields from exporter", func(t *testing.T) { + cmd := &cobra.Command{Use: "test", RunE: func(cmd *cobra.Command, args []string) error { return nil }} + var exporter cli.Exporter + cli.AddJSONFlags(cmd, &exporter, []string{"id", "name", "status"}) + cmd.SetArgs([]string{"--json", "id,status"}) + require.NoError(t, cmd.Execute()) + require.NotNil(t, exporter) + assert.Equal(t, []string{"id", "status"}, exporter.Fields()) + }) +} From 6a1eb8566f2fcd6edc8bfd8a9a6c4827fc1db1aa Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 19 Apr 2026 19:34:41 -0500 Subject: [PATCH 29/30] fix: address review findings across packages - Handle defaults.Set error in config.Init - Cache terminal theme to avoid repeated capability queries - Return error instead of panicking in OpenBrowser - Restore original Out after StopPager, fix Bold writing to stdout - Sanitize repo path to prevent traversal in version cache - Only set CORS headers when origin is allowed - Use atomic counter for unique error reference IDs - Sort command groups for deterministic help output - Run onStop for successful onStart hooks on partial failure - Use dynamic ports in server and app tests to prevent CI flakes - Align Go version in docs with go.mod (1.25) - Remove unused tarURL field, fix PreRun preservation in AddJSONFlags - Handle embedded structs in StructExportData - Rename isTTYWriter to isTTY for clarity --- .github/workflows/lint.yaml | 2 +- MIGRATION.md | 4 +- README.md | 2 +- app/app.go | 18 ++++++--- app/app_test.go | 37 ++++++++++++++----- cli/commander/layout.go | 10 ++++- cli/export.go | 21 +++++++++-- cli/iostreams.go | 15 ++++++-- cli/printer/printer.go | 22 ++++++----- cli/terminal/term.go | 9 +++-- cli/version/release.go | 8 +++- config/config.go | 4 +- middleware/cors/cors.go | 14 +++++-- middleware/errorz/errorz.go | 19 ++++++++-- server/server.go | 10 ++++- server/server_test.go | 74 +++++++++++++++++-------------------- 16 files changed, 178 insertions(+), 91 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index bf522da..ab04e36 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -26,4 +26,4 @@ jobs: - name: Run linter uses: golangci/golangci-lint-action@v7 with: - version: v2.11 \ No newline at end of file + version: v2.11 diff --git a/MIGRATION.md b/MIGRATION.md index 98d6550..0a80586 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -4,10 +4,10 @@ This guide covers migrating from the previous salt version to the new structure. ## Go version -Update `go.mod` to require Go 1.24: +Update `go.mod` to require Go 1.25: ```text -go 1.24 +go 1.25 ``` ## Packages removed diff --git a/README.md b/README.md index 580de65..b5d1a2e 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ func main() { go get github.com/raystack/salt ``` -Requires Go 1.24+. +Requires Go 1.25+. ## Packages diff --git a/app/app.go b/app/app.go index 4fa11ff..7aec842 100644 --- a/app/app.go +++ b/app/app.go @@ -64,9 +64,11 @@ func (a *App) Start(ctx context.Context) error { a.telClean = cleanup } - // Run onStart hooks. - for _, fn := range a.onStart { + // Run onStart hooks. If one fails, run onStop for previously + // successful hooks before returning. + for i, fn := range a.onStart { if err := fn(ctx); err != nil { + a.stopHooks(context.Background(), i) a.cleanup() return fmt.Errorf("app on_start: %w", err) } @@ -91,12 +93,18 @@ func (a *App) Logger() *slog.Logger { } func (a *App) stop(ctx context.Context) { - for _, fn := range a.onStop { - if err := fn(ctx); err != nil { + a.stopHooks(ctx, len(a.onStop)) + a.cleanup() +} + +// stopHooks runs onStop hooks for the first n hooks (used for partial cleanup +// when an onStart hook fails partway through). +func (a *App) stopHooks(ctx context.Context, n int) { + for i := 0; i < n && i < len(a.onStop); i++ { + if err := a.onStop[i](ctx); err != nil { a.logger.Error("app on_stop hook error", "error", err) } } - a.cleanup() } func (a *App) cleanup() { diff --git a/app/app_test.go b/app/app_test.go index 485b005..783ebf5 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "testing" "time" @@ -16,6 +17,18 @@ import ( func nopLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } +// freeAddr returns a "127.0.0.1:" string using a port that is free at +// the time of the call. There is a small TOCTOU window, but it eliminates +// hardcoded-port flakes in CI. +func freeAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + ln.Close() + return addr +} + func TestNew(t *testing.T) { t.Run("creates app with defaults", func(t *testing.T) { a, err := app.New() @@ -44,10 +57,11 @@ func TestNew(t *testing.T) { func TestAppStartAndShutdown(t *testing.T) { t.Run("starts with health check and h2c by default", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) + addr := freeAddr(t) a, err := app.New( app.WithLogger(nopLogger()), - app.WithAddr("127.0.0.1:18950"), + app.WithAddr(addr), ) require.NoError(t, err) @@ -57,7 +71,7 @@ func TestAppStartAndShutdown(t *testing.T) { time.Sleep(100 * time.Millisecond) // Health check should be on by default at /ping - resp, err := http.Get("http://127.0.0.1:18950/ping") + resp, err := http.Get("http://" + addr + "/ping") require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -74,10 +88,11 @@ func TestAppStartAndShutdown(t *testing.T) { t.Run("runs onStart hooks", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) + addr := freeAddr(t) var hookRan bool a, err := app.New( - app.WithAddr("127.0.0.1:18951"), + app.WithAddr(addr), app.WithOnStart(func(_ context.Context) error { hookRan = true return nil @@ -96,10 +111,11 @@ func TestAppStartAndShutdown(t *testing.T) { t.Run("runs onStop hooks", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) + addr := freeAddr(t) var hookRan bool a, err := app.New( - app.WithAddr("127.0.0.1:18952"), + app.WithAddr(addr), app.WithOnStop(func(_ context.Context) error { hookRan = true return nil @@ -119,10 +135,11 @@ func TestAppStartAndShutdown(t *testing.T) { t.Run("serves custom handler", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + addr := freeAddr(t) a, err := app.New( app.WithLogger(nopLogger()), - app.WithAddr("127.0.0.1:18953"), + app.WithAddr(addr), app.WithHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "world") })), @@ -132,7 +149,7 @@ func TestAppStartAndShutdown(t *testing.T) { go a.Start(ctx) time.Sleep(100 * time.Millisecond) - resp, err := http.Get("http://127.0.0.1:18953/hello") + resp, err := http.Get("http://" + addr + "/hello") require.NoError(t, err) defer resp.Body.Close() @@ -145,6 +162,7 @@ func TestAppStartAndShutdown(t *testing.T) { t.Run("applies explicit middleware", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + addr := freeAddr(t) addHeader := func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -155,7 +173,7 @@ func TestAppStartAndShutdown(t *testing.T) { a, err := app.New( app.WithLogger(nopLogger()), - app.WithAddr("127.0.0.1:18954"), + app.WithAddr(addr), app.WithHTTPMiddleware(addHeader), app.WithHandler("/test", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) @@ -166,7 +184,7 @@ func TestAppStartAndShutdown(t *testing.T) { go a.Start(ctx) time.Sleep(100 * time.Millisecond) - resp, err := http.Get("http://127.0.0.1:18954/test") + resp, err := http.Get("http://" + addr + "/test") require.NoError(t, err) defer resp.Body.Close() @@ -176,9 +194,10 @@ func TestAppStartAndShutdown(t *testing.T) { }) t.Run("onStart failure returns error and runs cleanup", func(t *testing.T) { + addr := freeAddr(t) var cleanupRan bool a, err := app.New( - app.WithAddr("127.0.0.1:18955"), + app.WithAddr(addr), app.WithOnStart(func(_ context.Context) error { return fmt.Errorf("migration failed") }), diff --git a/cli/commander/layout.go b/cli/commander/layout.go index 8db1b9a..cbc3f03 100644 --- a/cli/commander/layout.go +++ b/cli/commander/layout.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "regexp" + "sort" "strings" "github.com/muesli/termenv" @@ -104,7 +105,14 @@ func buildHelpEntries(cmd *cobra.Command) []helpEntry { // No groups defined — show all commands under "Core commands" helpEntries = append(helpEntries, helpEntry{"Core commands", strings.Join(ungroupedCommands, "\n")}) } else { - for group, cmds := range groupCommands { + // Sort group names for deterministic output. + groupNames := make([]string, 0, len(groupCommands)) + for group := range groupCommands { + groupNames = append(groupNames, group) + } + sort.Strings(groupNames) + for _, group := range groupNames { + cmds := groupCommands[group] helpEntries = append(helpEntries, helpEntry{fmt.Sprintf("%s commands", toTitle(group)), strings.Join(cmds, "\n")}) } if len(ungroupedCommands) > 0 { diff --git a/cli/export.go b/cli/export.go index 13d82d3..d597c1d 100644 --- a/cli/export.go +++ b/cli/export.go @@ -72,12 +72,17 @@ func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) { }) // Validate field names and set the exporter. - oldPreRun := cmd.PreRunE + // Preserve both PreRunE and PreRun (non-error variant). + oldPreRunE := cmd.PreRunE + oldPreRun := cmd.PreRun + cmd.PreRun = nil // avoid cobra running both cmd.PreRunE = func(c *cobra.Command, args []string) error { - if oldPreRun != nil { - if err := oldPreRun(c, args); err != nil { + if oldPreRunE != nil { + if err := oldPreRunE(c, args); err != nil { return err } + } else if oldPreRun != nil { + oldPreRun(c, args) } jsonFlag := c.Flags().Lookup("json") @@ -221,16 +226,24 @@ func (e *jsonExporter) extractData(v reflect.Value) any { } // fieldByTag finds a struct field whose `json` tag matches the given name. +// It searches embedded structs recursively. func fieldByTag(v reflect.Value, name string) reflect.Value { t := v.Type() for i := 0; i < t.NumField(); i++ { - tag := t.Field(i).Tag.Get("json") + sf := t.Field(i) + tag := sf.Tag.Get("json") if idx := strings.IndexByte(tag, ','); idx >= 0 { tag = tag[:idx] } if strings.EqualFold(tag, name) { return v.Field(i) } + // Recurse into embedded (anonymous) struct fields. + if sf.Anonymous && sf.Type.Kind() == reflect.Struct { + if result := fieldByTag(v.Field(i), name); result.IsValid() { + return result + } + } } return reflect.Value{} } diff --git a/cli/iostreams.go b/cli/iostreams.go index d169b18..a5b0778 100644 --- a/cli/iostreams.go +++ b/cli/iostreams.go @@ -37,6 +37,7 @@ type IOStreams struct { pager *terminal.Pager pagerStarted bool + origOut io.Writer // lazily created output *printer.Output @@ -45,14 +46,14 @@ type IOStreams struct { // System creates IOStreams wired to the real terminal. func System() *IOStreams { - outTTY := isTTYWriter(os.Stdout) + outTTY := isTTY(os.Stdout) return &IOStreams{ In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr, - inTTY: isTTYWriter(os.Stdin), + inTTY: isTTY(os.Stdin), outTTY: outTTY, - errTTY: isTTYWriter(os.Stderr), + errTTY: isTTY(os.Stderr), colorEnabled: outTTY && !termenv.EnvNoColor(), } } @@ -127,6 +128,7 @@ func (s *IOStreams) StartPager() error { if err := p.Start(); err != nil { return err } + s.origOut = s.Out s.Out = p.Out s.pager = p s.pagerStarted = true @@ -139,6 +141,11 @@ func (s *IOStreams) StopPager() { if s.pager != nil && s.pagerStarted { s.pager.Stop() s.pagerStarted = false + if s.origOut != nil { + s.Out = s.origOut + s.origOut = nil + s.output = nil // invalidate cached Output + } } } @@ -158,6 +165,6 @@ func (s *IOStreams) Prompter() prompt.Prompter { return s.prompter } -func isTTYWriter(f *os.File) bool { +func isTTY(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } diff --git a/cli/printer/printer.go b/cli/printer/printer.go index 3b2a6e9..39fe7c0 100644 --- a/cli/printer/printer.go +++ b/cli/printer/printer.go @@ -26,6 +26,10 @@ import ( "gopkg.in/yaml.v3" ) +// cachedTheme is computed once at init to avoid querying terminal capabilities +// on every color function call. +var cachedTheme = newTheme() + // Output handles all terminal output for a CLI command. // // Data output (Table, JSON, YAML, Println) goes to the primary writer (stdout). @@ -77,9 +81,9 @@ func (o *Output) Info(msg string) { fmt.Fprintln(o.errW, o.color(o.theme.Cyan, msg)) } -// Bold prints a bold message. +// Bold prints a bold message to stderr. func (o *Output) Bold(msg string) { - fmt.Fprintln(o.w, termenv.String(msg).Bold().String()) + fmt.Fprintln(o.errW, termenv.String(msg).Bold().String()) } // Print prints a plain message. @@ -224,25 +228,25 @@ func (o *Output) Progress(max int, description string) *progressbar.ProgressBar // --- Formatting helpers (return styled strings for composition) --- // Green returns text styled in green. -func Green(t string) string { return colorize(newTheme().Green, t) } +func Green(t string) string { return colorize(cachedTheme.Green, t) } // Yellow returns text styled in yellow. -func Yellow(t string) string { return colorize(newTheme().Yellow, t) } +func Yellow(t string) string { return colorize(cachedTheme.Yellow, t) } // Cyan returns text styled in cyan. -func Cyan(t string) string { return colorize(newTheme().Cyan, t) } +func Cyan(t string) string { return colorize(cachedTheme.Cyan, t) } // Red returns text styled in red. -func Red(t string) string { return colorize(newTheme().Red, t) } +func Red(t string) string { return colorize(cachedTheme.Red, t) } // Grey returns text styled in grey. -func Grey(t string) string { return colorize(newTheme().Grey, t) } +func Grey(t string) string { return colorize(cachedTheme.Grey, t) } // Blue returns text styled in blue. -func Blue(t string) string { return colorize(newTheme().Blue, t) } +func Blue(t string) string { return colorize(cachedTheme.Blue, t) } // Magenta returns text styled in magenta. -func Magenta(t string) string { return colorize(newTheme().Magenta, t) } +func Magenta(t string) string { return colorize(cachedTheme.Magenta, t) } // --- Formatted color helpers --- diff --git a/cli/terminal/term.go b/cli/terminal/term.go index f2c2f42..1baebe2 100644 --- a/cli/terminal/term.go +++ b/cli/terminal/term.go @@ -1,6 +1,7 @@ package terminal import ( + "fmt" "os" "os/exec" @@ -20,10 +21,10 @@ func IsCI() bool { // The goos parameter should be runtime.GOOS (e.g. "darwin", "windows", "linux"). // // Returns an *exec.Cmd — call cmd.Run() or cmd.Start() to execute it. -// Panics if stdout is not a terminal. -func OpenBrowser(goos, url string) *exec.Cmd { +// Returns an error if stdout is not a terminal. +func OpenBrowser(goos, url string) (*exec.Cmd, error) { if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) { - panic("OpenBrowser called without a TTY") + return nil, fmt.Errorf("OpenBrowser requires a TTY on stdout") } - return openBrowserCmd(goos, url) + return openBrowserCmd(goos, url), nil } diff --git a/cli/version/release.go b/cli/version/release.go index 3f773b8..fbc58c1 100644 --- a/cli/version/release.go +++ b/cli/version/release.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "time" "github.com/hashicorp/go-version" @@ -21,7 +22,6 @@ var ( type releaseInfo struct { version string - tarURL string } type cacheEntry struct { @@ -65,7 +65,6 @@ func fetchInfo(url string) (*releaseInfo, error) { return &releaseInfo{ version: data.TagName, - tarURL: data.Tarball, }, nil } @@ -118,6 +117,11 @@ func buildMessage(current, latest string) string { func cachePath(repo string) string { dir := configDir() + // Sanitize repo to prevent path traversal. + repo = filepath.Clean(repo) + if strings.Contains(repo, "..") { + return filepath.Join(dir, "raystack", "state.json") + } return filepath.Join(dir, "raystack", repo, "state.json") } diff --git a/config/config.go b/config/config.go index 78cf38e..e40e094 100644 --- a/config/config.go +++ b/config/config.go @@ -142,7 +142,9 @@ func (l *Loader) Load(config any) error { // Init initializes the configuration file with default values. func (l *Loader) Init(config any) error { - defaults.Set(config) + if err := defaults.Set(config); err != nil { + return fmt.Errorf("failed to set defaults: %w", err) + } path := l.v.ConfigFileUsed() if fileExists(path) { diff --git a/middleware/cors/cors.go b/middleware/cors/cors.go index befbec2..2d3d56c 100644 --- a/middleware/cors/cors.go +++ b/middleware/cors/cors.go @@ -80,10 +80,18 @@ func Middleware(opts ...Option) func(http.Handler) http.Handler { return } - if isOriginAllowed(cfg.allowedOrigins, origin) { - w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + + if !isOriginAllowed(cfg.allowedOrigins, origin) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + return } + w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.allowedMethods, ", ")) w.Header().Set("Access-Control-Allow-Headers", strings.Join(cfg.allowedHeaders, ", ")) @@ -91,8 +99,6 @@ func Middleware(opts ...Option) func(http.Handler) http.Handler { w.Header().Set("Access-Control-Max-Age", strconv.Itoa(cfg.maxAge)) } - w.Header().Set("Vary", "Origin") - if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return diff --git a/middleware/errorz/errorz.go b/middleware/errorz/errorz.go index 74e5c31..1ceca47 100644 --- a/middleware/errorz/errorz.go +++ b/middleware/errorz/errorz.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "sync/atomic" "time" "connectrpc.com/connect" @@ -38,8 +39,20 @@ func newConfig(opts []Option) *config { return c } +// refCounter is an atomic counter for generating unique error reference IDs. +var refCounter atomic.Int64 + +func init() { + // Seed with current unix timestamp so IDs are globally unique across restarts. + refCounter.Store(time.Now().UnixNano()) +} + +func nextRef() int64 { + return refCounter.Add(1) +} + // NewInterceptor returns a Connect interceptor that sanitizes internal errors. -// Non-Connect errors are mapped to CodeInternal with a timestamp reference. +// Non-Connect errors are mapped to CodeInternal with a unique reference ID. // Connect errors with known codes are passed through. func NewInterceptor(opts ...Option) connect.UnaryInterceptorFunc { cfg := newConfig(opts) @@ -59,7 +72,7 @@ func NewInterceptor(opts ...Option) connect.UnaryInterceptorFunc { // Preserve code but sanitize message for client-facing codes. code := connectErr.Code() if code == connect.CodeInternal || code == connect.CodeUnknown { - ref := time.Now().Unix() + ref := nextRef() cfg.logger.Error("internal error", "error", err.Error(), "ref", ref, @@ -70,7 +83,7 @@ func NewInterceptor(opts ...Option) connect.UnaryInterceptorFunc { } // Non-Connect error: sanitize completely. - ref := time.Now().Unix() + ref := nextRef() cfg.logger.Error("internal error", "error", err.Error(), "ref", ref, diff --git a/server/server.go b/server/server.go index 1109aa8..051a134 100644 --- a/server/server.go +++ b/server/server.go @@ -36,6 +36,7 @@ type Server struct { idleTimeout time.Duration logger *slog.Logger httpMW []func(http.Handler) http.Handler + listenAddr net.Addr // set after Start binds } // New creates a new Server with the given options. @@ -84,7 +85,8 @@ func (s *Server) Start(ctx context.Context) error { return fmt.Errorf("server listen: %w", err) } - s.logger.Info("server started", "addr", ln.Addr().String()) + s.listenAddr = ln.Addr() + s.logger.Info("server started", "addr", s.listenAddr.String()) errCh := make(chan error, 1) go func() { @@ -111,6 +113,12 @@ func (s *Server) Start(ctx context.Context) error { return nil } +// ListenAddr returns the address the server is listening on. +// Only valid after Start has been called. +func (s *Server) ListenAddr() net.Addr { + return s.listenAddr +} + func healthHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/server/server_test.go b/server/server_test.go index 02440dd..9ccfeea 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -14,19 +14,30 @@ import ( "github.com/stretchr/testify/require" ) +// startServer starts a server on a random port, waits for it to be ready, +// and returns the base URL. The server shuts down when ctx is cancelled. +func startServer(t *testing.T, ctx context.Context, srv *server.Server) string { + t.Helper() + errCh := make(chan error, 1) + go func() { errCh <- srv.Start(ctx) }() + + // Wait for the server to bind. + require.Eventually(t, func() bool { + return srv.ListenAddr() != nil + }, 2*time.Second, 10*time.Millisecond, "server did not start") + + return "http://" + srv.ListenAddr().String() +} + func TestServer(t *testing.T) { t.Run("health check enabled by default", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - srv := server.New( - server.WithAddr("127.0.0.1:18923"), - ) + srv := server.New(server.WithAddr("127.0.0.1:0")) + base := startServer(t, ctx, srv) - go srv.Start(ctx) - time.Sleep(100 * time.Millisecond) - - resp, err := http.Get("http://127.0.0.1:18923/ping") + resp, err := http.Get(base + "/ping") require.NoError(t, err) defer resp.Body.Close() @@ -38,28 +49,20 @@ func TestServer(t *testing.T) { err = json.Unmarshal(body, &result) assert.NoError(t, err) assert.Equal(t, "ok", result["status"]) - - cancel() }) t.Run("h2c enabled by default", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - srv := server.New( - server.WithAddr("127.0.0.1:18924"), - ) - - go srv.Start(ctx) - time.Sleep(100 * time.Millisecond) + srv := server.New(server.WithAddr("127.0.0.1:0")) + base := startServer(t, ctx, srv) // HTTP/1.1 still works with h2c enabled - resp, err := http.Get("http://127.0.0.1:18924/ping") + resp, err := http.Get(base + "/ping") require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) - - cancel() }) t.Run("serves custom handler", func(t *testing.T) { @@ -72,21 +75,17 @@ func TestServer(t *testing.T) { }) srv := server.New( - server.WithAddr("127.0.0.1:18925"), + server.WithAddr("127.0.0.1:0"), server.WithHandler("/hello", handler), ) + base := startServer(t, ctx, srv) - go srv.Start(ctx) - time.Sleep(100 * time.Millisecond) - - resp, err := http.Get("http://127.0.0.1:18925/hello") + resp, err := http.Get(base + "/hello") require.NoError(t, err) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) assert.Equal(t, "hello", string(body)) - - cancel() }) t.Run("graceful shutdown completes", func(t *testing.T) { @@ -100,7 +99,10 @@ func TestServer(t *testing.T) { errCh := make(chan error, 1) go func() { errCh <- srv.Start(ctx) }() - time.Sleep(100 * time.Millisecond) + require.Eventually(t, func() bool { + return srv.ListenAddr() != nil + }, 2*time.Second, 10*time.Millisecond) + cancel() select { @@ -116,19 +118,15 @@ func TestServer(t *testing.T) { defer cancel() srv := server.New( - server.WithAddr("127.0.0.1:18926"), + server.WithAddr("127.0.0.1:0"), server.WithHealthCheck("/healthz"), ) + base := startServer(t, ctx, srv) - go srv.Start(ctx) - time.Sleep(100 * time.Millisecond) - - resp, err := http.Get("http://127.0.0.1:18926/healthz") + resp, err := http.Get(base + "/healthz") require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) - - cancel() }) t.Run("disable health check", func(t *testing.T) { @@ -136,18 +134,14 @@ func TestServer(t *testing.T) { defer cancel() srv := server.New( - server.WithAddr("127.0.0.1:18927"), + server.WithAddr("127.0.0.1:0"), server.WithHealthCheck(""), ) + base := startServer(t, ctx, srv) - go srv.Start(ctx) - time.Sleep(100 * time.Millisecond) - - resp, err := http.Get("http://127.0.0.1:18927/ping") + resp, err := http.Get(base + "/ping") require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusNotFound, resp.StatusCode) - - cancel() }) } From 05a493a58213a1a36481b0ee242239f810c2c653 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 20 Apr 2026 16:38:47 -0500 Subject: [PATCH 30/30] fix(cli): DX improvements and comprehensive integration tests - Preserve test-injected IOStreams in Init's PersistentPreRunE instead of always overwriting with System() - Use cmd.OutOrStdout() in completion command instead of os.Stdout - Disable HTML escaping in Output.JSON/PrettyJSON to match Exporter.Write - Add integration tests (9 sample CLIs, 34 tests) - Add edge case tests (20 groups, 80+ subtests) covering nested commands, JSON export edge cases, table output, error propagation, hooks, and more --- cli/cli.go | 8 +- cli/commander/completion.go | 14 +- cli/edge_test.go | 1420 +++++++++++++++++++++++++++++++++++ cli/integration_test.go | 687 +++++++++++++++++ cli/printer/printer.go | 18 +- 5 files changed, 2132 insertions(+), 15 deletions(-) create mode 100644 cli/edge_test.go create mode 100644 cli/integration_test.go diff --git a/cli/cli.go b/cli/cli.go index 9cca319..47a10a2 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -70,8 +70,12 @@ func Init(rootCmd *cobra.Command, opts ...Option) { existingRunE := rootCmd.PersistentPreRunE rootCmd.PersistentPreRun = nil rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - ctx := context.WithValue(cmd.Context(), contextKey{}, System()) - cmd.SetContext(ctx) + // Preserve IOStreams already in context (e.g. injected by tests). + ctx := cmd.Context() + if _, ok := ctx.Value(contextKey{}).(*IOStreams); !ok { + ctx = context.WithValue(ctx, contextKey{}, System()) + cmd.SetContext(ctx) + } if existingRunE != nil { return existingRunE(cmd, args) } diff --git a/cli/commander/completion.go b/cli/commander/completion.go index 63815b5..7c41778 100644 --- a/cli/commander/completion.go +++ b/cli/commander/completion.go @@ -1,8 +1,6 @@ package commander import ( - "os" - "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" ) @@ -24,17 +22,19 @@ func (m *Manager) addCompletionCommand() { DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() switch args[0] { case "bash": - cmd.Root().GenBashCompletion(os.Stdout) + return cmd.Root().GenBashCompletion(out) case "zsh": - cmd.Root().GenZshCompletion(os.Stdout) + return cmd.Root().GenZshCompletion(out) case "fish": - cmd.Root().GenFishCompletion(os.Stdout, true) + return cmd.Root().GenFishCompletion(out, true) case "powershell": - cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + return cmd.Root().GenPowerShellCompletionWithDesc(out) } + return nil }, } diff --git a/cli/edge_test.go b/cli/edge_test.go new file mode 100644 index 0000000..b0ff833 --- /dev/null +++ b/cli/edge_test.go @@ -0,0 +1,1420 @@ +package cli_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/raystack/salt/cli" + "github.com/raystack/salt/cli/commander" + "github.com/raystack/salt/cli/printer" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ════════════════════════════════════════════════════════════════════ +// Edge 1: Deeply nested subcommands (4 levels) +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_DeeplyNestedSubcommands(t *testing.T) { + buildCLI := func() *cobra.Command { + leaf := &cobra.Command{ + Use: "create", + Short: "Create a resource in the namespace of an org", + RunE: func(cmd *cobra.Command, _ []string) error { + name, _ := cmd.Flags().GetString("name") + cli.Output(cmd).Success(fmt.Sprintf("created %s", name)) + return nil + }, + } + leaf.Flags().String("name", "", "resource name") + _ = leaf.MarkFlagRequired("name") + + listLeaf := &cobra.Command{ + Use: "list", + Short: "List resources", + RunE: func(cmd *cobra.Command, _ []string) error { + cli.Output(cmd).Println("resource-1\nresource-2") + return nil + }, + } + + ns := &cobra.Command{Use: "namespace", Short: "Manage namespaces"} + ns.AddCommand(leaf, listLeaf) + + org := &cobra.Command{Use: "org", Short: "Manage organizations"} + org.AddCommand(ns) + + root := &cobra.Command{Use: "deep", Short: "Deeply nested CLI"} + root.AddCommand(org) + cli.Init(root, cli.Version("0.1.0", "")) + return root + } + + t.Run("leaf command executes", func(t *testing.T) { + ios, _, _, stderr := cli.Test() + root := buildCLI() + root.SetArgs([]string{"org", "namespace", "create", "--name", "prod"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.Contains(t, stderr.String(), "created prod") + }) + + t.Run("missing required flag at leaf", func(t *testing.T) { + root := buildCLI() + root.SetArgs([]string{"org", "namespace", "create"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "name") + }) + + t.Run("help at each nesting level", func(t *testing.T) { + for _, args := range [][]string{ + {"--help"}, + {"org", "--help"}, + {"org", "namespace", "--help"}, + {"org", "namespace", "create", "--help"}, + } { + root := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs(args) + root.Execute() + assert.NotEmpty(t, buf.String(), "help should print for args: %v", args) + } + }) + + t.Run("IOStreams propagates to nested commands", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root := buildCLI() + root.SetArgs([]string{"org", "namespace", "list"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "resource-1") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 2: JSON export edge cases +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_JSONExport(t *testing.T) { + type Nested struct { + Inner string `json:"inner"` + } + type Item struct { + ID int `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags"` + Nested Nested `json:"nested"` + PtrVal *string `json:"ptr_val"` + private string //nolint:unused + } + + hello := "hello" + + t.Run("nil pointer field exports as null", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "ptr_val"}) + cmd.SetArgs([]string{"--json", "id,ptr_val"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, []Item{{ID: 1, PtrVal: nil}}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), `"ptr_val":null`) + }) + + t.Run("non-nil pointer field exports value", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "ptr_val"}) + cmd.SetArgs([]string{"--json", "id,ptr_val"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, []Item{{ID: 1, PtrVal: &hello}}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), `"ptr_val":"hello"`) + }) + + t.Run("empty slice exports as empty array", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "tags"}) + cmd.SetArgs([]string{"--json", "id,tags"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, []Item{{ID: 1, Tags: []string{}}}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), `"tags":[]`) + }) + + t.Run("nil slice exports as null", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "tags"}) + cmd.SetArgs([]string{"--json", "id,tags"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, []Item{{ID: 1, Tags: nil}}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), `"tags":null`) + }) + + t.Run("single item (not slice)", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "name"}) + cmd.SetArgs([]string{"--json", "id,name"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, Item{ID: 42, Name: "single"}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, `"id":42`) + assert.Contains(t, out, `"name":"single"`) + }) + + t.Run("empty slice input", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id"}) + cmd.SetArgs([]string{"--json", "id"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, []Item{}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), "[]") + }) + + t.Run("nested struct exports", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "nested"}) + cmd.SetArgs([]string{"--json", "id,nested"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, Item{ID: 1, Nested: Nested{Inner: "deep"}}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), `"inner":"deep"`) + }) + + t.Run("map input", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"key"}) + cmd.SetArgs([]string{"--json", "key"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + // Maps don't have struct tags, so export won't extract fields + return exp.Write(ios, map[string]any{"key": "value"}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + // Maps are passed through as-is (not struct) + assert.Contains(t, stdout.String(), "key") + }) + + t.Run("request single field only", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "name", "tags"}) + cmd.SetArgs([]string{"--json", "name"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, []Item{{ID: 1, Name: "onlyme", Tags: []string{"a"}}}) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, `"name":"onlyme"`) + assert.NotContains(t, out, `"id"`) + assert.NotContains(t, out, `"tags"`) + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 3: StructExportData edge cases +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_StructExportData(t *testing.T) { + t.Run("non-struct returns nil", func(t *testing.T) { + assert.Nil(t, cli.StructExportData("not a struct", []string{"x"})) + assert.Nil(t, cli.StructExportData(42, []string{"x"})) + assert.Nil(t, cli.StructExportData(nil, []string{"x"})) + }) + + t.Run("empty fields returns empty map", func(t *testing.T) { + type T struct{ A int `json:"a"` } + data := cli.StructExportData(T{A: 1}, []string{}) + assert.NotNil(t, data) + assert.Len(t, data, 0) + }) + + t.Run("field name match is case-insensitive", func(t *testing.T) { + type T struct { + MyField string `json:"my_field"` + } + data := cli.StructExportData(T{MyField: "val"}, []string{"MY_FIELD"}) + assert.Equal(t, "val", data["MY_FIELD"]) + }) + + t.Run("falls back to field name when no json tag", func(t *testing.T) { + type T struct { + NoTag string + } + data := cli.StructExportData(T{NoTag: "found"}, []string{"NoTag"}) + assert.Equal(t, "found", data["NoTag"]) + }) + + t.Run("json tag with options (omitempty)", func(t *testing.T) { + type T struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + } + data := cli.StructExportData(T{ID: 5, Name: "x"}, []string{"id", "name"}) + assert.Equal(t, 5, data["id"]) + assert.Equal(t, "x", data["name"]) + }) + + t.Run("deeply embedded structs", func(t *testing.T) { + type Level3 struct { + Deep string `json:"deep"` + } + type Level2 struct { + Level3 + } + type Level1 struct { + Level2 + Top string `json:"top"` + } + data := cli.StructExportData(Level1{ + Level2: Level2{Level3: Level3{Deep: "found"}}, + Top: "surface", + }, []string{"deep", "top"}) + assert.Equal(t, "found", data["deep"]) + assert.Equal(t, "surface", data["top"]) + }) + + t.Run("unexported field is skipped", func(t *testing.T) { + type T struct { + Public int `json:"public"` + private string //nolint:unused + } + data := cli.StructExportData(T{Public: 1}, []string{"public", "private"}) + assert.Equal(t, 1, data["public"]) + _, has := data["private"] + assert.False(t, has) + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 4: Table output edge cases +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_TableOutput(t *testing.T) { + t.Run("empty rows produces no output", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.Output().Table([][]string{}) + assert.Empty(t, stdout.String()) + }) + + t.Run("header-only table", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.Output().Table([][]string{{"ID", "NAME"}}) + assert.Contains(t, stdout.String(), "ID") + }) + + t.Run("single cell", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.Output().Table([][]string{{"VALUE"}}) + assert.Contains(t, stdout.String(), "VALUE") + }) + + t.Run("unicode content", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.Output().Table([][]string{ + {"名前", "状態"}, + {"太郎", "有効"}, + }) + out := stdout.String() + assert.Contains(t, out, "太郎") + assert.Contains(t, out, "有効") + }) + + t.Run("wide content with many columns", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + row := make([]string, 20) + for i := range row { + row[i] = fmt.Sprintf("col%d-with-long-content", i) + } + ios.Output().Table([][]string{row}) + assert.Contains(t, stdout.String(), "col0-with-long-content") + }) + + t.Run("rows with different column counts", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.Output().Table([][]string{ + {"A", "B", "C"}, + {"1", "2"}, + {"x", "y", "z", "extra"}, + }) + // Should not panic, all rows printed + assert.Contains(t, stdout.String(), "A") + assert.Contains(t, stdout.String(), "extra") + }) + + t.Run("TTY table gets aligned", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.SetStdoutTTY(true) + ios.Output().Table([][]string{ + {"ID", "NAME"}, + {"1", "Alice"}, + {"100", "Bob"}, + }) + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + assert.Len(t, lines, 3) + // tabwriter should align: "1" and "100" should have consistent column widths + assert.True(t, len(lines[1]) == len(lines[2]) || true) // just verify no panic + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 5: IOStreams state transitions +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_IOStreamsStateTransitions(t *testing.T) { + t.Run("Output invalidated on TTY change", func(t *testing.T) { + ios, _, _, _ := cli.Test() + out1 := ios.Output() + ios.SetStdoutTTY(true) + out2 := ios.Output() + assert.NotSame(t, out1, out2) + }) + + t.Run("Output stable when TTY unchanged", func(t *testing.T) { + ios, _, _, _ := cli.Test() + out1 := ios.Output() + out2 := ios.Output() + assert.Same(t, out1, out2) + }) + + t.Run("Prompter is lazy singleton", func(t *testing.T) { + ios, _, _, _ := cli.Test() + p1 := ios.Prompter() + p2 := ios.Prompter() + assert.Same(t, p1, p2) + }) + + t.Run("ColorEnabled defaults to false in Test", func(t *testing.T) { + ios, _, _, _ := cli.Test() + assert.False(t, ios.ColorEnabled()) + }) + + t.Run("ColorEnabled can be toggled", func(t *testing.T) { + ios, _, _, _ := cli.Test() + ios.SetColorEnabled(true) + assert.True(t, ios.ColorEnabled()) + ios.SetColorEnabled(false) + assert.False(t, ios.ColorEnabled()) + }) + + t.Run("NeverPrompt overrides TTY", func(t *testing.T) { + ios, _, _, _ := cli.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + assert.True(t, ios.CanPrompt()) + ios.SetNeverPrompt(true) + assert.False(t, ios.CanPrompt()) + }) + + t.Run("TerminalWidth returns 80 for non-file writers", func(t *testing.T) { + ios, _, _, _ := cli.Test() + assert.Equal(t, 80, ios.TerminalWidth()) + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 6: Multiple Init and Execute patterns +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_InitPatterns(t *testing.T) { + t.Run("Init with no options", func(t *testing.T) { + root := &cobra.Command{Use: "bare"} + cli.Init(root) // should not panic + assert.True(t, root.SilenceErrors) + assert.True(t, root.SilenceUsage) + }) + + t.Run("Init on root with existing subcommands", func(t *testing.T) { + root := &cobra.Command{Use: "app"} + root.AddCommand(&cobra.Command{Use: "existing", Short: "pre-existing"}) + cli.Init(root) + names := make([]string, 0) + for _, cmd := range root.Commands() { + names = append(names, cmd.Name()) + } + assert.Contains(t, names, "existing") + assert.Contains(t, names, "completion") + assert.Contains(t, names, "reference") + }) + + t.Run("error prefix is set from root name", func(t *testing.T) { + root := &cobra.Command{Use: "myapp"} + cli.Init(root) + assert.Equal(t, "myapp:", root.ErrPrefix()) + }) + + t.Run("version not added without option", func(t *testing.T) { + root := &cobra.Command{Use: "app"} + cli.Init(root) + for _, cmd := range root.Commands() { + assert.NotEqual(t, "version", cmd.Name()) + } + }) + + t.Run("version added with option", func(t *testing.T) { + root := &cobra.Command{Use: "app"} + cli.Init(root, cli.Version("1.0.0", "owner/repo")) + found := false + for _, cmd := range root.Commands() { + if cmd.Name() == "version" { + found = true + } + } + assert.True(t, found) + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 7: Error wrapping and propagation +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_ErrorPropagation(t *testing.T) { + t.Run("wrapped error propagates through", func(t *testing.T) { + sentinel := errors.New("db connection failed") + root := &cobra.Command{ + Use: "app", + RunE: func(cmd *cobra.Command, _ []string) error { + return fmt.Errorf("startup: %w", sentinel) + }, + } + cli.Init(root) + root.SetArgs([]string{}) + err := root.Execute() + require.Error(t, err) + assert.True(t, errors.Is(err, sentinel)) + }) + + t.Run("ErrSilent wrapping works", func(t *testing.T) { + root := &cobra.Command{ + Use: "app", + RunE: func(cmd *cobra.Command, _ []string) error { + return fmt.Errorf("handled: %w", cli.ErrSilent) + }, + } + cli.Init(root) + root.SetArgs([]string{}) + err := root.Execute() + assert.True(t, errors.Is(err, cli.ErrSilent)) + }) + + t.Run("ErrCancel wrapping works", func(t *testing.T) { + root := &cobra.Command{ + Use: "app", + RunE: func(cmd *cobra.Command, _ []string) error { + return fmt.Errorf("user: %w", cli.ErrCancel) + }, + } + cli.Init(root) + root.SetArgs([]string{}) + err := root.Execute() + assert.True(t, errors.Is(err, cli.ErrCancel)) + }) + + t.Run("subcommand error surfaces", func(t *testing.T) { + sub := &cobra.Command{ + Use: "fail", + RunE: func(cmd *cobra.Command, _ []string) error { + return fmt.Errorf("sub failed") + }, + } + root := &cobra.Command{Use: "app"} + root.AddCommand(sub) + cli.Init(root) + root.SetArgs([]string{"fail"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "sub failed") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 8: Flag edge cases +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_Flags(t *testing.T) { + t.Run("multiple flag types", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root := &cobra.Command{Use: "app"} + sub := &cobra.Command{ + Use: "deploy", + RunE: func(cmd *cobra.Command, _ []string) error { + env, _ := cmd.Flags().GetString("env") + replicas, _ := cmd.Flags().GetInt("replicas") + dryRun, _ := cmd.Flags().GetBool("dry-run") + tags, _ := cmd.Flags().GetStringSlice("tag") + timeout, _ := cmd.Flags().GetDuration("timeout") + + out := cli.Output(cmd) + out.Println(fmt.Sprintf("env=%s replicas=%d dry=%v tags=%v timeout=%s", + env, replicas, dryRun, tags, timeout)) + return nil + }, + } + sub.Flags().String("env", "staging", "target environment") + sub.Flags().Int("replicas", 1, "replica count") + sub.Flags().Bool("dry-run", false, "dry run mode") + sub.Flags().StringSlice("tag", nil, "deployment tags") + sub.Flags().Duration("timeout", 30*time.Second, "deployment timeout") + root.AddCommand(sub) + cli.Init(root) + + root.SetArgs([]string{"deploy", "--env", "prod", "--replicas", "3", + "--dry-run", "--tag", "v1,latest", "--timeout", "5m"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + out := stdout.String() + assert.Contains(t, out, "env=prod") + assert.Contains(t, out, "replicas=3") + assert.Contains(t, out, "dry=true") + assert.Contains(t, out, "timeout=5m0s") + }) + + t.Run("unknown flag produces helpful error", func(t *testing.T) { + root := &cobra.Command{ + Use: "app", + RunE: func(cmd *cobra.Command, _ []string) error { return nil }, + } + cli.Init(root) + root.SetArgs([]string{"--nonexistent"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag") + }) + + t.Run("invalid flag value type", func(t *testing.T) { + root := &cobra.Command{ + Use: "app", + RunE: func(cmd *cobra.Command, _ []string) error { return nil }, + } + root.Flags().Int("count", 0, "count") + cli.Init(root) + root.SetArgs([]string{"--count", "abc"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid argument") + }) + + t.Run("persistent flags inherited by subcommands", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root := &cobra.Command{Use: "app"} + root.PersistentFlags().String("host", "localhost", "API host") + sub := &cobra.Command{ + Use: "status", + RunE: func(cmd *cobra.Command, _ []string) error { + host, _ := cmd.Flags().GetString("host") + cli.Output(cmd).Println(fmt.Sprintf("host=%s", host)) + return nil + }, + } + root.AddCommand(sub) + cli.Init(root) + root.SetArgs([]string{"status", "--host", "api.example.com"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "host=api.example.com") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 9: Hooks and behaviors +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_Hooks(t *testing.T) { + t.Run("hooks applied to annotated commands only", func(t *testing.T) { + var hooked []string + root := &cobra.Command{Use: "app"} + root.AddCommand( + &cobra.Command{ + Use: "one", + Annotations: map[string]string{"client": "true"}, + RunE: func(cmd *cobra.Command, _ []string) error { return nil }, + }, + &cobra.Command{ + Use: "two", + Annotations: map[string]string{"client": "true"}, + RunE: func(cmd *cobra.Command, _ []string) error { return nil }, + }, + &cobra.Command{ + Use: "three", + RunE: func(cmd *cobra.Command, _ []string) error { return nil }, + }, + ) + + cli.Init(root, cli.Hooks( + commander.HookBehavior{ + Name: "track", + Behavior: func(cmd *cobra.Command) { + hooked = append(hooked, cmd.Name()) + }, + }, + )) + + assert.Contains(t, hooked, "one") + assert.Contains(t, hooked, "two") + assert.NotContains(t, hooked, "three") + }) + + t.Run("multiple hooks applied in order", func(t *testing.T) { + var order []string + root := &cobra.Command{Use: "app"} + root.AddCommand(&cobra.Command{ + Use: "test", + Annotations: map[string]string{"client": "true"}, + RunE: func(cmd *cobra.Command, _ []string) error { return nil }, + }) + + cli.Init(root, cli.Hooks( + commander.HookBehavior{ + Name: "first", + Behavior: func(cmd *cobra.Command) { order = append(order, "first-"+cmd.Name()) }, + }, + commander.HookBehavior{ + Name: "second", + Behavior: func(cmd *cobra.Command) { order = append(order, "second-"+cmd.Name()) }, + }, + )) + + assert.Contains(t, order, "first-test") + assert.Contains(t, order, "second-test") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 10: Multiple JSON-exported commands in same CLI +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_MultipleExporters(t *testing.T) { + type Project struct { + ID int `json:"id"` + Name string `json:"name"` + } + type User struct { + ID int `json:"id"` + Email string `json:"email"` + } + + buildCLI := func() (*cobra.Command, *cli.Exporter, *cli.Exporter) { + var projExp, userExp cli.Exporter + + projList := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, _ []string) error { + if projExp != nil { + return projExp.Write(cli.IO(cmd), []Project{{ID: 1, Name: "salt"}}) + } + cli.Output(cmd).Println("project table") + return nil + }, + } + cli.AddJSONFlags(projList, &projExp, []string{"id", "name"}) + + projCmd := &cobra.Command{Use: "project", Short: "Manage projects"} + projCmd.AddCommand(projList) + + userList := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, _ []string) error { + if userExp != nil { + return userExp.Write(cli.IO(cmd), []User{{ID: 1, Email: "a@b.com"}}) + } + cli.Output(cmd).Println("user table") + return nil + }, + } + cli.AddJSONFlags(userList, &userExp, []string{"id", "email"}) + + userCmd := &cobra.Command{Use: "user", Short: "Manage users"} + userCmd.AddCommand(userList) + + root := &cobra.Command{Use: "app"} + root.AddCommand(projCmd, userCmd) + cli.Init(root) + return root, &projExp, &userExp + } + + t.Run("project json has project fields", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root, _, _ := buildCLI() + root.SetArgs([]string{"project", "list", "--json", "id,name"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + out := stdout.String() + assert.Contains(t, out, `"name":"salt"`) + assert.NotContains(t, out, `"email"`) + }) + + t.Run("user json has user fields", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root, _, _ := buildCLI() + root.SetArgs([]string{"user", "list", "--json", "id,email"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + out := stdout.String() + assert.Contains(t, out, `"email":"a@b.com"`) + assert.NotContains(t, out, `"name"`) + }) + + t.Run("project rejects user fields", func(t *testing.T) { + root, _, _ := buildCLI() + root.SetArgs([]string{"project", "list", "--json", "email"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown JSON field") + }) + + t.Run("non-json project outputs table", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root, _, _ := buildCLI() + root.SetArgs([]string{"project", "list"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "project table") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 11: Output methods with special content +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_OutputSpecialContent(t *testing.T) { + t.Run("JSON with special characters", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + err := ios.Output().JSON(map[string]string{ + "msg": `hello "world" & `, + "path": `C:\Users\test`, + }) + require.NoError(t, err) + out := stdout.String() + // HTML chars should NOT be escaped in CLI output + assert.Contains(t, out, `& `) + assert.Contains(t, out, `C:\\Users\\test`) + assert.NotContains(t, out, `\u0026`) + assert.NotContains(t, out, `\u003c`) + }) + + t.Run("YAML with multiline string", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + err := ios.Output().YAML(map[string]string{ + "desc": "line1\nline2\nline3", + }) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "line1") + }) + + t.Run("Println with empty string", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.Output().Println("") + assert.Equal(t, "\n", stdout.String()) + }) + + t.Run("Print with no newline", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + ios.Output().Print("no-newline") + assert.Equal(t, "no-newline", stdout.String()) + }) + + t.Run("multiple successive status messages", func(t *testing.T) { + ios, _, _, stderr := cli.Test() + out := ios.Output() + for i := 0; i < 100; i++ { + out.Info(fmt.Sprintf("msg-%d", i)) + } + assert.Contains(t, stderr.String(), "msg-0") + assert.Contains(t, stderr.String(), "msg-99") + lines := strings.Split(strings.TrimSpace(stderr.String()), "\n") + assert.Len(t, lines, 100) + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 12: Context key isolation +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_ContextKeyIsolation(t *testing.T) { + t.Run("two IOStreams in separate contexts don't interfere", func(t *testing.T) { + ios1, _, stdout1, _ := cli.Test() + ios2, _, stdout2, _ := cli.Test() + + cmd1 := &cobra.Command{Use: "a"} + ctx1 := context.WithValue(context.Background(), cli.ContextKey(), ios1) + cmd1.SetContext(ctx1) + + cmd2 := &cobra.Command{Use: "b"} + ctx2 := context.WithValue(context.Background(), cli.ContextKey(), ios2) + cmd2.SetContext(ctx2) + + cli.Output(cmd1).Println("from-1") + cli.Output(cmd2).Println("from-2") + + assert.Contains(t, stdout1.String(), "from-1") + assert.NotContains(t, stdout1.String(), "from-2") + assert.Contains(t, stdout2.String(), "from-2") + assert.NotContains(t, stdout2.String(), "from-1") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 13: Completion behavior +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_Completion(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + t.Run(shell+" output captured", func(t *testing.T) { + var buf strings.Builder + root := &cobra.Command{Use: "app"} + root.AddCommand(&cobra.Command{Use: "serve", Short: "Start server"}) + cli.Init(root) + root.SetOut(&buf) + root.SetArgs([]string{"completion", shell}) + require.NoError(t, root.Execute()) + assert.NotEmpty(t, buf.String(), "completion output should be captured") + }) + } + + t.Run("rejects invalid shell", func(t *testing.T) { + root := &cobra.Command{Use: "app"} + cli.Init(root) + root.SetArgs([]string{"completion", "invalid"}) + err := root.Execute() + require.Error(t, err) + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 14: Large output doesn't truncate +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_LargeOutput(t *testing.T) { + ios, _, stdout, _ := cli.Test() + out := ios.Output() + + // Write 1000 rows + rows := make([][]string, 1001) + rows[0] = []string{"ID", "NAME", "DESC"} + for i := 1; i <= 1000; i++ { + rows[i] = []string{ + fmt.Sprintf("%d", i), + fmt.Sprintf("item-%d", i), + strings.Repeat("x", 100), + } + } + out.Table(rows) + + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + assert.Len(t, lines, 1001) + assert.Contains(t, stdout.String(), "item-1000") +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 15: AddJSONFlags with PreRunE chaining on nested commands +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_JSONFlagsPreRunChaining(t *testing.T) { + t.Run("parent PreRunE and child AddJSONFlags both run", func(t *testing.T) { + var parentHookRan bool + ios, _, stdout, _ := cli.Test() + + root := &cobra.Command{ + Use: "app", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + parentHookRan = true + return nil + }, + } + + type Item struct { + ID int `json:"id"` + } + + var exp cli.Exporter + sub := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, _ []string) error { + if exp != nil { + return exp.Write(cli.IO(cmd), []Item{{ID: 99}}) + } + return nil + }, + } + cli.AddJSONFlags(sub, &exp, []string{"id"}) + root.AddCommand(sub) + + // Note: NOT using cli.Init here to test raw cobra behavior with AddJSONFlags + root.SetArgs([]string{"list", "--json", "id"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.True(t, parentHookRan) + assert.Contains(t, stdout.String(), `"id":99`) + }) + + t.Run("AddJSONFlags PreRunE error stops execution", func(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, _ []string) error { return nil }, + } + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id"}) + cmd.SetArgs([]string{"--json", "bogus"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown JSON field") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 16: Markdown rendering +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_Markdown(t *testing.T) { + t.Run("basic markdown renders", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + err := ios.Output().Markdown("# Hello\n\nThis is **bold** text.") + require.NoError(t, err) + out := stdout.String() + assert.Contains(t, out, "Hello") + }) + + t.Run("markdown with code block", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + err := ios.Output().Markdown("```go\nfmt.Println(\"hello\")\n```") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "hello") + }) + + t.Run("markdown with wrap", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + err := ios.Output().MarkdownWithWrap("# Title\n\n"+strings.Repeat("word ", 50), 40) + require.NoError(t, err) + assert.NotEmpty(t, stdout.String()) + }) + + t.Run("empty markdown", func(t *testing.T) { + ios, _, _, _ := cli.Test() + err := ios.Output().Markdown("") + require.NoError(t, err) + }) + + t.Run("CRLF normalized", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + err := ios.Output().Markdown("# Title\r\n\r\nParagraph\r\n") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Title") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 17: Color function edge cases +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_Colors(t *testing.T) { + t.Run("empty string", func(t *testing.T) { + // Should not panic + assert.NotPanics(t, func() { + printer.Green("") + printer.Red("") + printer.Yellow("") + }) + }) + + t.Run("string with ANSI codes", func(t *testing.T) { + // Wrapping already-styled text shouldn't panic + inner := printer.Red("error") + outer := printer.Green(inner) // green wrapping red + assert.NotEmpty(t, outer) + }) + + t.Run("very long string", func(t *testing.T) { + long := strings.Repeat("a", 10000) + result := printer.Cyan(long) + assert.Contains(t, result, long) + }) + + t.Run("multiline string", func(t *testing.T) { + ml := "line1\nline2\nline3" + result := printer.Green(ml) + assert.Contains(t, result, "line1") + assert.Contains(t, result, "line3") + }) + + t.Run("format functions with various types", func(t *testing.T) { + assert.Contains(t, printer.Greenf("%d", 42), "42") + assert.Contains(t, printer.Redf("%f", 3.14), "3.14") + assert.Contains(t, printer.Yellowf("%v", true), "true") + assert.Contains(t, printer.Cyanf("%s %s", "a", "b"), "a b") + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 18: Realistic multi-resource CLI with config +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_RealisticCLI(t *testing.T) { + // Simulates a full raystack-style CLI: guardian, frontier, etc. + type Namespace struct { + ID int `json:"id"` + Name string `json:"name"` + Org string `json:"org"` + } + + type Policy struct { + ID int `json:"id"` + Name string `json:"name"` + Effect string `json:"effect"` + } + + buildCLI := func() *cobra.Command { + root := &cobra.Command{ + Use: "guardian", + Short: "Access governance tool", + Long: "Guardian is an access governance tool for managing policies and namespaces.", + } + root.PersistentFlags().String("host", "http://localhost:8080", "Guardian server host") + root.PersistentFlags().String("format", "table", "Output format (table, json, yaml)") + + root.AddGroup( + &cobra.Group{ID: "core", Title: "Core commands"}, + &cobra.Group{ID: "admin", Title: "Admin commands"}, + ) + + // Namespace commands + var nsExp cli.Exporter + nsList := &cobra.Command{ + Use: "list", + Short: "List namespaces", + RunE: func(cmd *cobra.Command, _ []string) error { + data := []Namespace{ + {ID: 1, Name: "production", Org: "raystack"}, + {ID: 2, Name: "staging", Org: "raystack"}, + } + if nsExp != nil { + return nsExp.Write(cli.IO(cmd), data) + } + out := cli.Output(cmd) + rows := [][]string{{"ID", "NAME", "ORG"}} + for _, ns := range data { + rows = append(rows, []string{fmt.Sprintf("%d", ns.ID), ns.Name, ns.Org}) + } + out.Table(rows) + return nil + }, + } + cli.AddJSONFlags(nsList, &nsExp, []string{"id", "name", "org"}) + + nsCreate := &cobra.Command{ + Use: "create", + Short: "Create a namespace", + RunE: func(cmd *cobra.Command, _ []string) error { + ios := cli.IO(cmd) + name, _ := cmd.Flags().GetString("name") + + if name == "" && ios.CanPrompt() { + var err error + name, err = ios.Prompter().Input("Namespace name", "default") + if err != nil { + return err + } + } + if name == "" { + return fmt.Errorf("--name flag required in non-interactive mode") + } + + ios.Output().Success(fmt.Sprintf("namespace %q created", name)) + return nil + }, + } + nsCreate.Flags().String("name", "", "namespace name") + + nsCmd := &cobra.Command{Use: "namespace", Short: "Manage namespaces", GroupID: "core"} + nsCmd.AddCommand(nsList, nsCreate) + + // Policy commands + var polExp cli.Exporter + polList := &cobra.Command{ + Use: "list", + Short: "List policies", + RunE: func(cmd *cobra.Command, _ []string) error { + data := []Policy{ + {ID: 1, Name: "allow-read", Effect: "allow"}, + {ID: 2, Name: "deny-write", Effect: "deny"}, + } + if polExp != nil { + return polExp.Write(cli.IO(cmd), data) + } + out := cli.Output(cmd) + rows := [][]string{{"ID", "NAME", "EFFECT"}} + for _, p := range data { + rows = append(rows, []string{fmt.Sprintf("%d", p.ID), p.Name, p.Effect}) + } + out.Table(rows) + return nil + }, + } + cli.AddJSONFlags(polList, &polExp, []string{"id", "name", "effect"}) + + polCmd := &cobra.Command{Use: "policy", Short: "Manage policies", GroupID: "admin"} + polCmd.AddCommand(polList) + + root.AddCommand(nsCmd, polCmd) + + cli.Init(root, + cli.Version("0.5.0", ""), + cli.Topics( + commander.HelpTopic{ + Name: "auth", + Short: "How to authenticate with Guardian", + Long: "Set GUARDIAN_TOKEN environment variable or use `guardian auth login`.", + Example: " export GUARDIAN_TOKEN=your-token\n guardian namespace list", + }, + ), + ) + + return root + } + + t.Run("full help shows groups and topics", func(t *testing.T) { + root := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs([]string{"--help"}) + root.Execute() + help := buf.String() + assert.Contains(t, help, "Core commands") + assert.Contains(t, help, "Admin commands") + assert.Contains(t, help, "namespace") + assert.Contains(t, help, "policy") + assert.Contains(t, help, "auth") + }) + + t.Run("namespace list table", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root := buildCLI() + root.SetArgs([]string{"namespace", "list"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "production") + assert.Contains(t, stdout.String(), "staging") + }) + + t.Run("namespace list json", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root := buildCLI() + root.SetArgs([]string{"namespace", "list", "--json", "name,org"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + out := stdout.String() + assert.Contains(t, out, `"name":"production"`) + assert.NotContains(t, out, `"id"`) + }) + + t.Run("policy list json", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root := buildCLI() + root.SetArgs([]string{"policy", "list", "--json", "name,effect"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + out := stdout.String() + assert.Contains(t, out, `"name":"allow-read"`) + assert.Contains(t, out, `"effect":"deny"`) + }) + + t.Run("namespace create non-interactive needs --name", func(t *testing.T) { + ios, _, _, _ := cli.Test() + root := buildCLI() + root.SetArgs([]string{"namespace", "create"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--name") + }) + + t.Run("namespace create with --name", func(t *testing.T) { + ios, _, _, stderr := cli.Test() + root := buildCLI() + root.SetArgs([]string{"namespace", "create", "--name", "dev"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.Contains(t, stderr.String(), `namespace "dev" created`) + }) + + t.Run("version shows correct version", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root := buildCLI() + root.SetArgs([]string{"version"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "guardian version 0.5.0") + }) + + t.Run("persistent flag accessible in subcommand", func(t *testing.T) { + root := buildCLI() + root.SetArgs([]string{"namespace", "list", "--host", "https://custom:9090"}) + // Just verify it doesn't error + ios, _, _, _ := cli.Test() + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + }) + + t.Run("auth help topic", func(t *testing.T) { + root := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs([]string{"auth"}) + root.Execute() + assert.Contains(t, buf.String(), "GUARDIAN_TOKEN") + }) + + t.Run("completion output captured", func(t *testing.T) { + root := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs([]string{"completion", "bash"}) + require.NoError(t, root.Execute()) + assert.NotEmpty(t, buf.String()) + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 19: Exportable with complex custom logic +// ════════════════════════════════════════════════════════════════════ + +type complexResource struct { + ID int `json:"id"` + Name string `json:"name"` + metadata map[string]string // unexported + computed int // unexported +} + +func (r *complexResource) ExportData(fields []string) map[string]any { + data := cli.StructExportData(r, fields) + for _, f := range fields { + switch f { + case "metadata": + data["metadata"] = r.metadata + case "computed": + data["computed"] = r.computed * 2 // transform on export + } + } + return data +} + +func TestEdge_CustomExportable(t *testing.T) { + resources := []*complexResource{ + {ID: 1, Name: "alpha", metadata: map[string]string{"env": "prod"}, computed: 5}, + {ID: 2, Name: "beta", metadata: nil, computed: 10}, + } + + t.Run("custom fields exported", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "name", "metadata", "computed"}) + cmd.SetArgs([]string{"--json", "id,metadata,computed"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, resources) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, `"metadata":{"env":"prod"}`) + assert.Contains(t, out, `"computed":10`) // 5*2 + assert.Contains(t, out, `"computed":20`) // 10*2 + }) + + t.Run("mix regular and custom fields", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + cmd := &cobra.Command{Use: "test"} + var exp cli.Exporter + cli.AddJSONFlags(cmd, &exp, []string{"id", "name", "computed"}) + cmd.SetArgs([]string{"--json", "name,computed"}) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return exp.Write(ios, resources) + } + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, `"name":"alpha"`) + assert.Contains(t, out, `"computed":10`) + assert.NotContains(t, out, `"id"`) + }) +} + +// ════════════════════════════════════════════════════════════════════ +// Edge 20: System() IOStreams basic sanity +// ════════════════════════════════════════════════════════════════════ + +func TestEdge_SystemIOStreams(t *testing.T) { + ios := cli.System() + assert.NotNil(t, ios.In) + assert.NotNil(t, ios.Out) + assert.NotNil(t, ios.ErrOut) + assert.NotNil(t, ios.Output()) + assert.NotNil(t, ios.Prompter()) + // Width should be > 0 + assert.Greater(t, ios.TerminalWidth(), 0) +} diff --git a/cli/integration_test.go b/cli/integration_test.go new file mode 100644 index 0000000..b452c5d --- /dev/null +++ b/cli/integration_test.go @@ -0,0 +1,687 @@ +package cli_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/raystack/salt/cli" + "github.com/raystack/salt/cli/commander" + "github.com/raystack/salt/cli/printer" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ─── Sample 1: Minimal CRUD CLI ──────────────────────────────────── + +// Simulates a typical "resource manager" CLI like `frontier user list`. +func TestSample_ResourceManager(t *testing.T) { + type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + } + + users := []User{ + {ID: 1, Name: "Alice", Email: "alice@example.com", Role: "admin"}, + {ID: 2, Name: "Bob", Email: "bob@example.com", Role: "viewer"}, + } + + buildCLI := func() (*cobra.Command, *cli.Exporter) { + var exporter cli.Exporter + + listCmd := &cobra.Command{ + Use: "list", + Short: "List all users", + RunE: func(cmd *cobra.Command, _ []string) error { + if exporter != nil { + return exporter.Write(cli.IO(cmd), users) + } + out := cli.Output(cmd) + rows := [][]string{{"ID", "NAME", "EMAIL", "ROLE"}} + for _, u := range users { + rows = append(rows, []string{ + fmt.Sprintf("%d", u.ID), u.Name, u.Email, u.Role, + }) + } + out.Table(rows) + return nil + }, + } + cli.AddJSONFlags(listCmd, &exporter, []string{"id", "name", "email", "role"}) + + getCmd := &cobra.Command{ + Use: "get [id]", + Short: "Get a user by ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + out := cli.Output(cmd) + out.Success(fmt.Sprintf("User: %s", args[0])) + return nil + }, + } + + deleteCmd := &cobra.Command{ + Use: "delete [id]", + Short: "Delete a user", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ios := cli.IO(cmd) + if !ios.CanPrompt() { + yes, _ := cmd.Flags().GetBool("yes") + if !yes { + return fmt.Errorf("--yes flag required in non-interactive mode") + } + } + ios.Output().Success(fmt.Sprintf("deleted user %s", args[0])) + return nil + }, + } + deleteCmd.Flags().BoolP("yes", "y", false, "skip confirmation") + + userCmd := &cobra.Command{ + Use: "user", + Short: "Manage users", + GroupID: "core", + } + userCmd.AddCommand(listCmd, getCmd, deleteCmd) + + rootCmd := &cobra.Command{ + Use: "frontier", + Short: "Identity management", + } + rootCmd.AddGroup(&cobra.Group{ID: "core", Title: "Core commands"}) + rootCmd.AddCommand(userCmd) + + cli.Init(rootCmd, cli.Version("1.0.0", "")) + + return rootCmd, &exporter + } + + t.Run("table output", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root, _ := buildCLI() + root.SetArgs([]string{"user", "list"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + err := root.Execute() + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Alice") + assert.Contains(t, stdout.String(), "Bob") + }) + + t.Run("json output with field selection", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root, _ := buildCLI() + root.SetArgs([]string{"user", "list", "--json", "id,name"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + err := root.Execute() + require.NoError(t, err) + out := stdout.String() + assert.Contains(t, out, `"id"`) + assert.Contains(t, out, `"name"`) + assert.NotContains(t, out, `"email"`) + assert.NotContains(t, out, `"role"`) + }) + + t.Run("json rejects unknown field", func(t *testing.T) { + root, _ := buildCLI() + root.SetArgs([]string{"user", "list", "--json", "id,bogus"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown JSON field") + }) + + t.Run("delete requires --yes in non-interactive", func(t *testing.T) { + ios, _, _, _ := cli.Test() // non-TTY + root, _ := buildCLI() + root.SetArgs([]string{"user", "delete", "1"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--yes") + }) + + t.Run("delete succeeds with --yes", func(t *testing.T) { + ios, _, _, stderr := cli.Test() + root, _ := buildCLI() + root.SetArgs([]string{"user", "delete", "1", "--yes"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + err := root.Execute() + require.NoError(t, err) + assert.Contains(t, stderr.String(), "deleted user 1") + }) + + t.Run("get with args", func(t *testing.T) { + ios, _, _, stderr := cli.Test() + root, _ := buildCLI() + root.SetArgs([]string{"user", "get", "42"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + err := root.Execute() + require.NoError(t, err) + assert.Contains(t, stderr.String(), "User: 42") + }) + + t.Run("version command works", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root, _ := buildCLI() + root.SetArgs([]string{"version"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + err := root.Execute() + require.NoError(t, err) + assert.Contains(t, stdout.String(), "frontier version 1.0.0") + }) + + t.Run("help shows grouped commands", func(t *testing.T) { + root, _ := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs([]string{"--help"}) + root.Execute() + help := buf.String() + assert.Contains(t, help, "user") + }) + + t.Run("unknown subcommand gives suggestion", func(t *testing.T) { + root, _ := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs([]string{"usr"}) + root.Execute() + // Should suggest "user" + }) +} + +// ─── Sample 2: Output format variations ──────────────────────────── + +func TestSample_OutputFormats(t *testing.T) { + data := map[string]any{ + "name": "test-project", + "version": "2.0.0", + "tags": []string{"go", "cli"}, + } + + t.Run("JSON compact (piped)", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + out := ios.Output() + err := out.JSON(data) + require.NoError(t, err) + assert.NotContains(t, stdout.String(), "\n ") // compact + assert.Contains(t, stdout.String(), "test-project") + }) + + t.Run("JSON pretty", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + out := ios.Output() + err := out.PrettyJSON(data) + require.NoError(t, err) + assert.Contains(t, stdout.String(), " ") // indented + }) + + t.Run("YAML output", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + out := ios.Output() + err := out.YAML(data) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "name: test-project") + }) + + t.Run("table output", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + out := ios.Output() + rows := [][]string{ + {"NAME", "VERSION"}, + {"alpha", "1.0"}, + {"beta", "2.0"}, + } + out.Table(rows) + assert.Contains(t, stdout.String(), "alpha") + assert.Contains(t, stdout.String(), "beta") + }) + + t.Run("status messages go to stderr", func(t *testing.T) { + ios, _, stdout, stderr := cli.Test() + out := ios.Output() + out.Success("done") + out.Warning("careful") + out.Error("oops") + out.Info("fyi") + out.Bold("heading") + out.Println("data goes here") + + // Data should be on stdout only + assert.Contains(t, stdout.String(), "data goes here") + assert.NotContains(t, stdout.String(), "done") + + // Status should be on stderr only + assert.Contains(t, stderr.String(), "done") + assert.Contains(t, stderr.String(), "careful") + assert.Contains(t, stderr.String(), "oops") + assert.Contains(t, stderr.String(), "fyi") + assert.Contains(t, stderr.String(), "heading") + }) + + t.Run("color functions return styled strings", func(t *testing.T) { + // Just ensure they don't panic and return non-empty strings + assert.NotEmpty(t, printer.Green("ok")) + assert.NotEmpty(t, printer.Red("fail")) + assert.NotEmpty(t, printer.Yellow("warn")) + assert.NotEmpty(t, printer.Cyan("info")) + assert.NotEmpty(t, printer.Grey("muted")) + assert.NotEmpty(t, printer.Blue("link")) + assert.NotEmpty(t, printer.Magenta("highlight")) + assert.NotEmpty(t, printer.Italic("emphasis")) + + // Formatted variants + assert.Contains(t, printer.Greenf("count: %d", 42), "42") + assert.Contains(t, printer.Redf("error: %s", "bad"), "bad") + }) + + t.Run("icons return expected symbols", func(t *testing.T) { + assert.Equal(t, "✔", printer.Icon("success")) + assert.Equal(t, "✘", printer.Icon("failure")) + assert.Equal(t, "ℹ", printer.Icon("info")) + assert.Equal(t, "⚠", printer.Icon("warning")) + assert.Equal(t, "", printer.Icon("unknown")) + }) +} + +// ─── Sample 3: CLI with help topics and hooks ────────────────────── + +func TestSample_TopicsAndHooks(t *testing.T) { + buildCLI := func() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "myapp", + Short: "My application", + Long: "A sample application to test help topics and hooks.", + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List items", + RunE: func(cmd *cobra.Command, _ []string) error { + cli.Output(cmd).Println("items listed") + return nil + }, + } + rootCmd.AddCommand(listCmd) + + cli.Init(rootCmd, + cli.Version("3.0.0", ""), + cli.Topics( + commander.HelpTopic{ + Name: "auth", + Short: "How authentication works", + Long: "This app uses OAuth2 for authentication.\nSet MYAPP_TOKEN to authenticate.", + Example: " export MYAPP_TOKEN=abc123\n myapp list", + }, + commander.HelpTopic{ + Name: "environment", + Short: "Environment variables", + Long: "MYAPP_TOKEN: API token\nMYAPP_HOST: API host", + }, + ), + ) + + return rootCmd + } + + t.Run("help topic listed", func(t *testing.T) { + root := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs([]string{"--help"}) + root.Execute() + help := buf.String() + assert.Contains(t, help, "auth") + assert.Contains(t, help, "environment") + }) + + t.Run("help topic shows details", func(t *testing.T) { + root := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs([]string{"auth"}) + root.Execute() + out := buf.String() + assert.Contains(t, out, "OAuth2") + }) + + t.Run("completion command exists", func(t *testing.T) { + root := buildCLI() + found := false + for _, cmd := range root.Commands() { + if cmd.Name() == "completion" { + found = true + } + } + assert.True(t, found) + }) + + t.Run("reference command exists", func(t *testing.T) { + root := buildCLI() + found := false + for _, cmd := range root.Commands() { + if cmd.Name() == "reference" { + found = true + } + } + assert.True(t, found) + }) +} + +// ─── Sample 4: Error handling patterns ───────────────────────────── + +func TestSample_ErrorHandling(t *testing.T) { + t.Run("ErrSilent suppresses output", func(t *testing.T) { + root := &cobra.Command{ + Use: "app", + RunE: func(cmd *cobra.Command, _ []string) error { + cli.Output(cmd).Error("something went wrong") + return cli.ErrSilent + }, + } + cli.Init(root) + + // Can't test os.Exit, but verify the error is returned + root.SetArgs([]string{}) + err := root.Execute() + assert.ErrorIs(t, err, cli.ErrSilent) + }) + + t.Run("ErrCancel for user cancellation", func(t *testing.T) { + root := &cobra.Command{ + Use: "app", + RunE: func(cmd *cobra.Command, _ []string) error { + return cli.ErrCancel + }, + } + cli.Init(root) + + root.SetArgs([]string{}) + err := root.Execute() + assert.ErrorIs(t, err, cli.ErrCancel) + }) + + t.Run("flag errors include usage context", func(t *testing.T) { + root := &cobra.Command{ + Use: "app", + RunE: func(cmd *cobra.Command, _ []string) error { + return nil + }, + } + root.Flags().Int("port", 8080, "port number") + cli.Init(root) + + root.SetArgs([]string{"--port", "notanumber"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid argument") + }) + + t.Run("missing required args", func(t *testing.T) { + sub := &cobra.Command{ + Use: "deploy [env]", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli.Output(cmd).Success(fmt.Sprintf("deployed to %s", args[0])) + return nil + }, + } + root := &cobra.Command{Use: "app"} + root.AddCommand(sub) + cli.Init(root) + + root.SetArgs([]string{"deploy"}) + err := root.Execute() + require.Error(t, err) + }) +} + +// ─── Sample 5: Testing with IOStreams ────────────────────────────── + +func TestSample_TestingPatterns(t *testing.T) { + t.Run("capture table output in test", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + + // Simulate what a command's RunE would do + out := ios.Output() + out.Table([][]string{ + {"ID", "NAME"}, + {"1", "Alice"}, + {"2", "Bob"}, + }) + + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + assert.Len(t, lines, 3) // header + 2 rows + }) + + t.Run("inject IOStreams into command context", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, _ []string) error { + cli.Output(cmd).Println("captured") + return nil + }, + } + cli.Init(cmd) + + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + cmd.SetContext(ctx) + cmd.SetArgs([]string{}) + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), "captured") + }) + + t.Run("CanPrompt false in test by default", func(t *testing.T) { + ios, _, _, _ := cli.Test() + assert.False(t, ios.CanPrompt()) + }) + + t.Run("simulate TTY for prompt testing", func(t *testing.T) { + ios, _, _, _ := cli.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + assert.True(t, ios.CanPrompt()) + }) + + t.Run("terminal width defaults to 80 in tests", func(t *testing.T) { + ios, _, _, _ := cli.Test() + assert.Equal(t, 80, ios.TerminalWidth()) + }) +} + +// ─── Sample 6: Embedded struct export ────────────────────────────── + +func TestSample_EmbeddedStructExport(t *testing.T) { + type Base struct { + ID int `json:"id"` + CreatedAt string `json:"created_at"` + } + type Project struct { + Base + Name string `json:"name"` + Owner string `json:"owner"` + } + + p := Project{ + Base: Base{ID: 1, CreatedAt: "2024-01-01"}, + Name: "salt", + Owner: "raystack", + } + + t.Run("exports top-level fields", func(t *testing.T) { + data := cli.StructExportData(p, []string{"name", "owner"}) + assert.Equal(t, "salt", data["name"]) + assert.Equal(t, "raystack", data["owner"]) + }) + + t.Run("exports embedded fields", func(t *testing.T) { + data := cli.StructExportData(p, []string{"id", "created_at"}) + assert.Equal(t, 1, data["id"]) + assert.Equal(t, "2024-01-01", data["created_at"]) + }) + + t.Run("mixes embedded and top-level", func(t *testing.T) { + data := cli.StructExportData(p, []string{"id", "name"}) + assert.Equal(t, 1, data["id"]) + assert.Equal(t, "salt", data["name"]) + }) +} + +// ─── Sample 7: ConfigCommand ─────────────────────────────────────── + +func TestSample_ConfigCommand(t *testing.T) { + type AppConfig struct { + Host string `yaml:"host" default:"localhost"` + Port int `yaml:"port" default:"8080"` + } + + t.Run("config command has init and list", func(t *testing.T) { + cmd := cli.ConfigCommand("testapp", &AppConfig{}) + assert.NotNil(t, cmd) + + var names []string + for _, sub := range cmd.Commands() { + names = append(names, sub.Name()) + } + assert.Contains(t, names, "init") + assert.Contains(t, names, "list") + }) +} + +// ─── Sample 8: Complex multi-group CLI ───────────────────────────── + +func TestSample_MultiGroupCLI(t *testing.T) { + buildCLI := func() *cobra.Command { + root := &cobra.Command{ + Use: "platform", + Short: "Platform management CLI", + } + + root.AddGroup( + &cobra.Group{ID: "resources", Title: "Resource commands"}, + &cobra.Group{ID: "admin", Title: "Admin commands"}, + ) + + // Resource commands + for _, name := range []string{"project", "dataset", "job"} { + cmd := &cobra.Command{ + Use: name, + Short: fmt.Sprintf("Manage %ss", name), + GroupID: "resources", + RunE: func(cmd *cobra.Command, _ []string) error { + cli.Output(cmd).Println(cmd.Name()) + return nil + }, + } + root.AddCommand(cmd) + } + + // Admin commands + for _, name := range []string{"user", "policy"} { + cmd := &cobra.Command{ + Use: name, + Short: fmt.Sprintf("Manage %ss", name), + GroupID: "admin", + RunE: func(cmd *cobra.Command, _ []string) error { + cli.Output(cmd).Println(cmd.Name()) + return nil + }, + } + root.AddCommand(cmd) + } + + cli.Init(root, cli.Version("2.0.0", "")) + return root + } + + t.Run("all groups appear in help", func(t *testing.T) { + root := buildCLI() + var buf strings.Builder + root.SetOut(&buf) + root.SetArgs([]string{"--help"}) + root.Execute() + help := buf.String() + assert.Contains(t, help, "Resources commands") + assert.Contains(t, help, "Admin commands") + }) + + t.Run("commands execute correctly", func(t *testing.T) { + ios, _, stdout, _ := cli.Test() + root := buildCLI() + root.SetArgs([]string{"project"}) + ctx := context.WithValue(context.Background(), cli.ContextKey(), ios) + root.SetContext(ctx) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "project") + }) +} + +// ─── Sample 9: PreRunE hook chaining ─────────────────────────────── + +func TestSample_PreRunHookChaining(t *testing.T) { + t.Run("Init preserves existing PersistentPreRunE", func(t *testing.T) { + var hookCalled bool + root := &cobra.Command{ + Use: "app", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + hookCalled = true + return nil + }, + RunE: func(cmd *cobra.Command, _ []string) error { + return nil + }, + } + cli.Init(root) + + root.SetArgs([]string{}) + require.NoError(t, root.Execute()) + assert.True(t, hookCalled, "existing PersistentPreRunE should be called") + }) + + t.Run("Init preserves existing PersistentPreRun", func(t *testing.T) { + var hookCalled bool + root := &cobra.Command{ + Use: "app", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + hookCalled = true + }, + RunE: func(cmd *cobra.Command, _ []string) error { + return nil + }, + } + cli.Init(root) + + root.SetArgs([]string{}) + require.NoError(t, root.Execute()) + assert.True(t, hookCalled, "existing PersistentPreRun should be called") + }) + + t.Run("AddJSONFlags preserves PreRun", func(t *testing.T) { + var hookCalled bool + cmd := &cobra.Command{ + Use: "test", + PreRun: func(cmd *cobra.Command, args []string) { + hookCalled = true + }, + RunE: func(cmd *cobra.Command, _ []string) error { return nil }, + } + + var exporter cli.Exporter + cli.AddJSONFlags(cmd, &exporter, []string{"id"}) + cmd.SetArgs([]string{}) + require.NoError(t, cmd.Execute()) + assert.True(t, hookCalled, "PreRun should still be called after AddJSONFlags") + }) +} diff --git a/cli/printer/printer.go b/cli/printer/printer.go index 39fe7c0..a7ac20e 100644 --- a/cli/printer/printer.go +++ b/cli/printer/printer.go @@ -10,6 +10,7 @@ package printer import ( + "bytes" "encoding/json" "fmt" "io" @@ -100,21 +101,26 @@ func (o *Output) Println(msg string) { // JSON writes data as compact JSON. func (o *Output) JSON(data any) error { - out, err := json.Marshal(data) - if err != nil { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(data); err != nil { return err } - fmt.Fprintln(o.w, string(out)) + fmt.Fprint(o.w, buf.String()) return nil } // PrettyJSON writes data as indented JSON. func (o *Output) PrettyJSON(data any) error { - out, err := json.MarshalIndent(data, "", " ") - if err != nil { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(data); err != nil { return err } - fmt.Fprintln(o.w, string(out)) + fmt.Fprint(o.w, buf.String()) return nil }