diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 5cef9d4..ab04e36 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 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 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..0a80586 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,402 @@ +# Migration Guide + +This guide covers migrating from the previous salt version to the new structure. + +## Go version + +Update `go.mod` to require Go 1.25: + +```text +go 1.25 +``` + +## 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 + +`cli.Init()` enhances your root command with standard features and `cli.Execute()` runs it with proper error handling: + +```go +// Before +rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} +mgr := commander.New(rootCmd, commander.WithTopics(topics)) +mgr.Init() +rootCmd.AddCommand(serverCmd, configCmd) + +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" + +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.Topics(topics...), +) + +cli.Execute(rootCmd) +``` + +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, prompting, and I/O capabilities 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 + }, + } +} +``` + +`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`: + +```go +// Before +if err := rootCmd.Execute(); err != nil { + if commander.IsCommandErr(err) { + // show usage + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +// After +cli.Execute(rootCmd) // handles all errors with proper exit codes +``` + +`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 a rich error — exit 1, no extra output +out.Error("connection failed: timeout") +return cli.ErrSilent + +// User cancelled (ctrl-c, declined prompt) — exit 0 +return cli.ErrCancel +``` + +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 | + +## 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: + +```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) (now returns error) +``` + +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 | +|-------------------|-------------| +| `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 | +|----------|-----------| +| `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 | diff --git a/README.md b/README.md index d54f423..b5d1a2e 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,138 @@ -# 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 `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 + +### Service + +```go +package main + +import ( + "log/slog" + + "github.com/raystack/salt/app" + "github.com/raystack/salt/config" + "github.com/raystack/salt/middleware" +) -To use, run the following command: +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" + "github.com/spf13/cobra" +) + +func main() { + rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} + rootCmd.PersistentFlags().String("host", "", "API server host") + rootCmd.AddCommand(serverCmd, userCmd) + + cli.Init(rootCmd, + cli.Version("0.1.0", "raystack/frontier"), + ) + + cli.Execute(rootCmd) +} +``` + +`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 ``` go get github.com/raystack/salt ``` +Requires Go 1.25+. + ## 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 — init, execute, IOStreams, `--json` export, error handling, help, completion | -- **`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 diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..7aec842 --- /dev/null +++ b/app/app.go @@ -0,0 +1,114 @@ +// 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, 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. 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) + } + } + + // 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) { + 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) + } + } +} + +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..783ebf5 --- /dev/null +++ b/app/app_test.go @@ -0,0 +1,218 @@ +package app_test + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "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) } + +// 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() + 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()) + addr := freeAddr(t) + + a, err := app.New( + app.WithLogger(nopLogger()), + app.WithAddr(addr), + ) + 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://" + addr + "/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()) + addr := freeAddr(t) + + var hookRan bool + a, err := app.New( + app.WithAddr(addr), + 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()) + addr := freeAddr(t) + + var hookRan bool + a, err := app.New( + app.WithAddr(addr), + 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() + addr := freeAddr(t) + + a, err := app.New( + app.WithLogger(nopLogger()), + app.WithAddr(addr), + 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://" + addr + "/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() + addr := freeAddr(t) + + 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(addr), + 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://" + addr + "/test") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, "salt", resp.Header.Get("X-Custom")) + + cancel() + }) + + 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(addr), + 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") + }) +} 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..e7d635c --- /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 any, 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 + } +} 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/cli.go b/cli/cli.go new file mode 100644 index 0000000..47a10a2 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,174 @@ +// Package cli provides CLI enhancements for raystack applications. +// +// Usage: +// +// rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"} +// rootCmd.AddCommand(serverCmd, userCmd) +// +// cli.Init(rootCmd, +// cli.Version("0.1.0", "raystack/frontier"), +// cli.Topics(authTopic, envTopic), +// ) +// +// 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 ( + "context" + "errors" + "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{} + +// 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 +// 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 := &options{} + for _, opt := range opts { + opt(cfg) + } + + // 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 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 { + // 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) + } + if existingRun != nil { + existingRun(cmd, args) + } + return nil + } + + // Wire commander features. + var managerOpts []func(*commander.Manager) + if len(cfg.topics) > 0 { + managerOpts = append(managerOpts, commander.WithTopics(cfg.topics)) + } + if len(cfg.hooks) > 0 { + managerOpts = append(managerOpts, commander.WithHooks(cfg.hooks)) + } + 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", + Short: "Show version information", + Run: func(cmd *cobra.Command, _ []string) { + out := Output(cmd) + out.Println(fmt.Sprintf("%s version %s", name, ver)) + if repo != "" { + if msg := version.CheckForUpdate(ver, repo); msg != "" { + out.Warning(msg) + } + } + }, + } +} + +// 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 ios, ok := ctx.Value(contextKey{}).(*IOStreams); ok { + return ios + } + } + 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 { + return IO(cmd).Prompter() +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..d6f1d34 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,138 @@ +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) + }) + + 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) { + 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") + }) +} diff --git a/cli/commander/codex.go b/cli/commander/codex.go index 9636ee0..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" @@ -13,12 +15,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) }, @@ -69,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 08a0ed8..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" ) @@ -23,18 +21,20 @@ func (m *Manager) addCompletionCommand() { Long: summary, DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, - Args: cobra.ExactValidArgs(1), - Run: func(cmd *cobra.Command, args []string) { + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + 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 }, } @@ -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 580f2b2..cbc3f03 100644 --- a/cli/commander/layout.go +++ b/cli/commander/layout.go @@ -2,9 +2,9 @@ package commander import ( "bytes" - "errors" "fmt" "regexp" + "sort" "strings" "github.com/muesli/termenv" @@ -12,13 +12,11 @@ import ( "golang.org/x/text/language" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) // Section Titles for Help Output const ( usage = "Usage" - corecmd = "Core commands" othercmd = "Other commands" helpcmd = "Help topics" flags = "Flags" @@ -40,7 +38,6 @@ func (m *Manager) setCustomHelp() { displayHelp(cmd, args) }) m.RootCmd.SetUsageFunc(generateUsage) - m.RootCmd.SetFlagErrorFunc(handleFlagError) } // generateUsage customizes the usage function for a command. @@ -65,14 +62,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" { @@ -86,7 +75,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 +84,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 +101,23 @@ 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 { + // 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 { + 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..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 { @@ -12,7 +8,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 +61,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() } @@ -100,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/commander/reference.go b/cli/commander/reference.go index aea327f..7bea35f 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" ) @@ -17,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)") @@ -34,23 +30,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/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 new file mode 100644 index 0000000..31e3936 --- /dev/null +++ b/cli/config_cmd.go @@ -0,0 +1,64 @@ +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 any) *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 any) *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Initialize a new configuration file", + Example: fmt.Sprintf(" $ %s config init", appName), + 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), + 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/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/error.go b/cli/error.go new file mode 100644 index 0000000..0e1e9a8 --- /dev/null +++ b/cli/error.go @@ -0,0 +1,20 @@ +package cli + +import "errors" + +// ErrSilent indicates the command already printed its error. +// Execute will exit 1 without printing anything. +var ErrSilent = errors.New("silent error") + +// ErrCancel indicates the user cancelled the operation (e.g. ctrl-c). +// Execute will exit 0 without printing anything. +var ErrCancel = errors.New("cancelled") + +// 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 } diff --git a/cli/example_test.go b/cli/example_test.go new file mode 100644 index 0000000..39949a7 --- /dev/null +++ b/cli/example_test.go @@ -0,0 +1,154 @@ +package cli_test + +import ( + "fmt" + + "github.com/raystack/salt/cli" + "github.com/raystack/salt/cli/commander" + "github.com/spf13/cobra" +) + +func ExampleInit() { + rootCmd := &cobra.Command{ + Use: "frontier", + Short: "identity management", + } + rootCmd.PersistentFlags().String("host", "", "API server host") + + 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"}, + }) + return nil + }, + } + rootCmd.AddCommand(listCmd) + + cli.Init(rootCmd, + cli.Version("0.1.0", "raystack/frontier"), + ) + + cli.Execute(rootCmd) +} + +func ExampleExecute() { + rootCmd := &cobra.Command{ + Use: "myapp", + RunE: func(cmd *cobra.Command, _ []string) error { + p := cli.Prompter(cmd) + ok, _ := p.Confirm("Continue?", true) + if !ok { + return cli.ErrCancel // exit 0, no output + } + return nil + }, + } + + cli.Init(rootCmd) + 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 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", + Short: "my application", + } + + cli.Init(rootCmd, + cli.Topics( + commander.HelpTopic{ + Name: "auth", + Short: "How authentication works", + Long: "Detailed explanation of authentication...", + }, + ), + ) + + cli.Execute(rootCmd) +} diff --git a/cli/export.go b/cli/export.go new file mode 100644 index 0000000..d597c1d --- /dev/null +++ b/cli/export.go @@ -0,0 +1,256 @@ +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. + // 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 oldPreRunE != nil { + if err := oldPreRunE(c, args); err != nil { + return err + } + } else if oldPreRun != nil { + oldPreRun(c, args) + } + + 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. +// It searches embedded structs recursively. +func fieldByTag(v reflect.Value, name string) reflect.Value { + t := v.Type() + for i := 0; i < t.NumField(); i++ { + 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{} +} + +// 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()) + }) +} 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/iostreams.go b/cli/iostreams.go new file mode 100644 index 0000000..a5b0778 --- /dev/null +++ b/cli/iostreams.go @@ -0,0 +1,170 @@ +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 + origOut io.Writer + + // lazily created + output *printer.Output + prompter prompt.Prompter +} + +// System creates IOStreams wired to the real terminal. +func System() *IOStreams { + outTTY := isTTY(os.Stdout) + return &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + inTTY: isTTY(os.Stdin), + outTTY: outTTY, + errTTY: isTTY(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.origOut = s.Out + 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 + if s.origOut != nil { + s.Out = s.origOut + s.origOut = nil + s.output = nil // invalidate cached Output + } + } +} + +// 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 isTTY(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/option.go b/cli/option.go new file mode 100644 index 0000000..a0358d5 --- /dev/null +++ b/cli/option.go @@ -0,0 +1,32 @@ +package cli + +import "github.com/raystack/salt/cli/commander" + +type options struct { + version string + repo string + topics []commander.HelpTopic + hooks []commander.HookBehavior +} + +// Option configures cli.Init. +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 *options) { + c.version = ver + c.repo = repo + } +} + +// Topics adds help topics to the CLI. +func Topics(topics ...commander.HelpTopic) Option { + 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 *options) { c.hooks = append(c.hooks, hooks...) } +} 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/example_test.go b/cli/printer/example_test.go new file mode 100644 index 0000000..76bf5a9 --- /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]any{ + "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/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/printer.go b/cli/printer/printer.go new file mode 100644 index 0000000..a7ac20e --- /dev/null +++ b/cli/printer/printer.go @@ -0,0 +1,326 @@ +// 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 ( + "bytes" + "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" +) + +// 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). +// 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 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, 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. +func (o *Output) Success(msg string) { + fmt.Fprintln(o.errW, o.color(o.theme.Green, msg)) +} + +// Warning prints a yellow warning message to stderr. +func (o *Output) Warning(msg string) { + fmt.Fprintln(o.errW, o.color(o.theme.Yellow, msg)) +} + +// Error prints a red error message to stderr. +func (o *Output) Error(msg string) { + fmt.Fprintln(o.errW, o.color(o.theme.Red, msg)) +} + +// Info prints a cyan informational message to stderr. +func (o *Output) Info(msg string) { + fmt.Fprintln(o.errW, o.color(o.theme.Cyan, msg)) +} + +// Bold prints a bold message to stderr. +func (o *Output) Bold(msg string) { + fmt.Fprintln(o.errW, 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 any) error { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(data); err != nil { + return err + } + fmt.Fprint(o.w, buf.String()) + return nil +} + +// PrettyJSON writes data as indented JSON. +func (o *Output) PrettyJSON(data any) error { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(data); err != nil { + return err + } + fmt.Fprint(o.w, buf.String()) + return nil +} + +// YAML writes data as YAML. +func (o *Output) YAML(data any) 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 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) { + 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")) + } + } +} + +// --- 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.errW + s.Start() + return &Indicator{s} +} + +// Progress creates a progress bar on stderr. +func (o *Output) Progress(max int, description string) *progressbar.ProgressBar { + return progressbar.NewOptions(max, + progressbar.OptionSetWriter(o.errW), + 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(cachedTheme.Green, t) } + +// Yellow returns text styled in yellow. +func Yellow(t string) string { return colorize(cachedTheme.Yellow, t) } + +// Cyan returns text styled in cyan. +func Cyan(t string) string { return colorize(cachedTheme.Cyan, t) } + +// Red returns text styled in red. +func Red(t string) string { return colorize(cachedTheme.Red, t) } + +// Grey returns text styled in grey. +func Grey(t string) string { return colorize(cachedTheme.Grey, t) } + +// Blue returns text styled in blue. +func Blue(t string) string { return colorize(cachedTheme.Blue, t) } + +// Magenta returns text styled in magenta. +func Magenta(t string) string { return colorize(cachedTheme.Magenta, t) } + +// --- Formatted color helpers --- + +// Greenf returns formatted text styled in green. +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 ...any) string { return Yellow(fmt.Sprintf(format, a...)) } + +// Cyanf returns formatted text styled in cyan. +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 ...any) string { return Red(fmt.Sprintf(format, a...)) } + +// Greyf returns formatted text styled in grey. +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 ...any) string { return Blue(fmt.Sprintf(format, a...)) } + +// Magentaf returns formatted text styled in magenta. +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() } + +// 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/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/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) +} diff --git a/cli/prompt/prompt.go b/cli/prompt/prompt.go new file mode 100644 index 0000000..83a1c70 --- /dev/null +++ b/cli/prompt/prompt.go @@ -0,0 +1,154 @@ +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) + Password(message 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 +} + +// 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: +// - 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/releaser/release.go b/cli/releaser/release.go deleted file mode 100644 index a821fe1..0000000 --- a/cli/releaser/release.go +++ /dev/null @@ -1,122 +0,0 @@ -package releaser - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/hashicorp/go-version" - "github.com/pkg/errors" -) - -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" -) - -// Info holds information about a software release. -type Info struct { - Version string // Version of the release - TarURL string // Tarball URL of the release -} - -// 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} - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, errors.Wrap(err, "failed to create HTTP request") - } - 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) - } - defer func() { - if resp.Body != nil { - resp.Body.Close() - } - }() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code %d from URL: %s", resp.StatusCode, url) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrap(err, "failed to read response body") - } - - var data struct { - TagName string `json:"tag_name"` - Tarball string `json:"tarball_url"` - } - if err = json.Unmarshal(body, &data); err != nil { - return nil, errors.Wrapf(err, "failed to parse JSON response") - } - - return &Info{ - 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) { - currentVersion, err := version.NewVersion(current) - if err != nil { - return false, errors.Wrap(err, "invalid current version format") - } - - latestVersion, err := version.NewVersion(latest) - if err != nil { - return false, errors.Wrap(err, "invalid latest version format") - } - - 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. -func CheckForUpdate(currentVersion, repo string) string { - releaseURL := fmt.Sprintf(APIFormat, repo) - info, err := FetchInfo(releaseURL) - if err != nil { - return "" - } - - 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) -} 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/terminal/browser.go b/cli/terminal/browser.go new file mode 100644 index 0000000..a2a53c6 --- /dev/null +++ b/cli/terminal/browser.go @@ -0,0 +1,40 @@ +package terminal + +import ( + "os" + "os/exec" + "strings" +) + +// 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": + args = append(args, url) + case "windows": + exe, _ = exec.LookPath("cmd") + replacer := strings.NewReplacer("&", "^&") + args = append(args, "/c", "start", replacer.Replace(url)) + default: + exe = linuxExe() + args = append(args, url) + } + + cmd := exec.Command(exe, args...) + cmd.Stderr = os.Stderr + return cmd +} + +// linuxExe determines the appropriate command to open a web browser on Linux. +func linuxExe() string { + exe := "xdg-open" + if _, err := exec.LookPath(exe); err != nil { + if _, err := exec.LookPath("wslview"); err == nil { + exe = "wslview" + } + } + return exe +} 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/terminal/term.go b/cli/terminal/term.go new file mode 100644 index 0000000..1baebe2 --- /dev/null +++ b/cli/terminal/term.go @@ -0,0 +1,30 @@ +package terminal + +import ( + "fmt" + "os" + "os/exec" + + "github.com/mattn/go-isatty" +) + +// 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. +// 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()) { + return nil, fmt.Errorf("OpenBrowser requires a TTY on stdout") + } + return openBrowserCmd(goos, url), nil +} diff --git a/cli/terminator/browser.go b/cli/terminator/browser.go deleted file mode 100644 index c8127d5..0000000 --- a/cli/terminator/browser.go +++ /dev/null @@ -1,63 +0,0 @@ -package terminator - -import ( - "os" - "os/exec" - "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") - } - - 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 -} - -// 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 { - exe = "wslview" - } - } - - return exe -} diff --git a/cli/terminator/term.go b/cli/terminator/term.go deleted file mode 100644 index 23b66d8..0000000 --- a/cli/terminator/term.go +++ /dev/null @@ -1,31 +0,0 @@ -package terminator - -import ( - "os" - - "github.com/mattn/go-isatty" - "github.com/muesli/termenv" -) - -// 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() -} - -// 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. -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 -} diff --git a/cli/version/release.go b/cli/version/release.go new file mode 100644 index 0000000..fbc58c1 --- /dev/null +++ b/cli/version/release.go @@ -0,0 +1,172 @@ +package version + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/hashicorp/go-version" +) + +var ( + timeout = time.Second * 1 + apiFormat = "https://api.github.com/repos/%s/releases/latest" + cacheTTL = 24 * time.Hour +) + +type releaseInfo struct { + version 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) + if err != nil { + 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, fmt.Errorf("failed to fetch release information from URL: %s: %w", url, err) + } + defer func() { + if resp.Body != nil { + resp.Body.Close() + } + }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d from URL: %s", resp.StatusCode, url) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var data struct { + TagName string `json:"tag_name"` + Tarball string `json:"tarball_url"` + } + if err = json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %w", err) + } + + return &releaseInfo{ + version: data.TagName, + }, nil +} + +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) + } + + latestVersion, err := version.NewVersion(latest) + if err != nil { + return false, fmt.Errorf("invalid latest version format: %w", err) + } + + return currentVersion.GreaterThanOrEqual(latestVersion), nil +} + +// 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 "" + } + + // 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() + // 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") +} + +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) +} + +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") +} diff --git a/config/config.go b/config/config.go index c82ae54..e40e094 100644 --- a/config/config.go +++ b/config/config.go @@ -4,13 +4,14 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "os" "path/filepath" "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" @@ -90,13 +91,15 @@ 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 } - // Apply default values before reading configuration - defaults.SetDefaults(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 { @@ -116,11 +119,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) && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to read config file: %w", err) } } @@ -138,8 +141,10 @@ 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) +func (l *Loader) Init(config any) error { + if err := defaults.Set(config); err != nil { + return fmt.Errorf("failed to set defaults: %w", err) + } path := l.v.ConfigFileUsed() if fileExists(path) { @@ -162,12 +167,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/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) +} 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 { 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..630e81a 100644 --- a/go.mod +++ b/go.mod @@ -1,164 +1,126 @@ 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/hashicorp/go-version v1.9.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/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 - google.golang.org/grpc v1.67.1 - google.golang.org/protobuf v1.35.1 + 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.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 ) 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/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 + 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/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 + 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/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.11 // 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/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.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/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/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/fatih/color v1.18.0 // 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/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/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/gorilla/css v1.0.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // 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/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/lucasb-eyer/go-colorful v1.3.0 // 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/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/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.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 - 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/stretchr/objx v0.5.2 // 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/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/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.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 - 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 + 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.43.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 ) diff --git a/go.sum b/go.sum index 53d4008..9760743 100644 --- a/go.sum +++ b/go.sum @@ -1,343 +1,205 @@ -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/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/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/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/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= +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.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= +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/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/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.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/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/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= 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/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/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.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/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/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.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/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/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.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= 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/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/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-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= +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/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/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.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= 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/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/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.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/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/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= -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/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +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.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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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.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/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/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= @@ -348,231 +210,80 @@ 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/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/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= +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= -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.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.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.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= +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.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.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-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= -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= +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-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/middleware/cors/cors.go b/middleware/cors/cors.go new file mode 100644 index 0000000..2d3d56c --- /dev/null +++ b/middleware/cors/cors.go @@ -0,0 +1,119 @@ +// 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 + } + + 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, ", ")) + + if cfg.maxAge > 0 { + w.Header().Set("Access-Control-Max-Age", strconv.Itoa(cfg.maxAge)) + } + + 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..1ceca47 --- /dev/null +++ b/middleware/errorz/errorz.go @@ -0,0 +1,97 @@ +// Package errorz provides error sanitization middleware for Connect services. +package errorz + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync/atomic" + "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 +} + +// 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 unique reference ID. +// 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 := nextRef() + 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 := nextRef() + 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..97ce9be --- /dev/null +++ b/middleware/requestlog/requestlog.go @@ -0,0 +1,120 @@ +// 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) +} + +// 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 +} 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/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/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/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..051a134 --- /dev/null +++ b/server/server.go @@ -0,0 +1,126 @@ +// 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 + listenAddr net.Addr // set after Start binds +} + +// 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.listenAddr = ln.Addr() + s.logger.Info("server started", "addr", s.listenAddr.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 +} + +// 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) + fmt.Fprint(w, `{"status":"ok"}`) +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..9ccfeea --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,147 @@ +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" +) + +// 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:0")) + base := startServer(t, ctx, srv) + + resp, err := http.Get(base + "/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"]) + }) + + 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:0")) + base := startServer(t, ctx, srv) + + // HTTP/1.1 still works with h2c enabled + resp, err := http.Get(base + "/ping") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + 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:0"), + server.WithHandler("/hello", handler), + ) + base := startServer(t, ctx, srv) + + 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)) + }) + + 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) }() + + require.Eventually(t, func() bool { + return srv.ListenAddr() != nil + }, 2*time.Second, 10*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:0"), + server.WithHealthCheck("/healthz"), + ) + base := startServer(t, ctx, srv) + + resp, err := http.Get(base + "/healthz") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + 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:0"), + server.WithHealthCheck(""), + ) + base := startServer(t, ctx, srv) + + resp, err := http.Get(base + "/ping") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} 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 -}