diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..cc702f3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,133 @@ +# golangci-lint configuration for version 2.x +# See https://golangci-lint.run/usage/configuration/ +version: 2 + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + # Enable most linters for comprehensive code quality checks + enable: + # Default linters (always enabled): + # errcheck, govet, ineffassign, staticcheck, unused + + # Style & best practices + - misspell # Find commonly misspelled words + - revive # Fast, configurable linter + - gofmt # Check code formatting + - goimports # Check import formatting + - godot # Check if comments end in a period + - dupword # Check for duplicate words + + # Security + - gosec # Inspect code for security problems + + # Bugs & correctness + - bodyclose # Check HTTP response body is closed + - contextcheck # Check context usage + - errorlint # Check error wrapping + - durationcheck # Check duration multiplication + - copyloopvar # Check loop variable copying + + # Code quality + - gocritic # Meta-linter with many checks + - goconst # Find repeated strings + - gocyclo # Check cyclomatic complexity + - funlen # Check function length + - nestif # Check deeply nested if statements + + # Code organization + - decorder # Check declaration order + - grouper # Analyze expression groups + + # Simplification + - unconvert # Remove unnecessary type conversions + - unparam # Find unused function parameters + +linters-settings: + errcheck: + check-blank: false + check-type-assertions: false + + govet: + settings: + shadow: + strict: false + + staticcheck: + checks: ["all"] + + revive: + rules: + - name: var-naming + disabled: true + - name: dot-imports + disabled: false + + funlen: + lines: 150 + statements: 80 + + gocyclo: + min-complexity: 20 + + nestif: + min-complexity: 5 + + gocritic: + enabled-checks: + - appendAssign + - assignOp + - boolExprSimplify + - builtinShadow + - captLocal + - caseOrder + - defaultCaseOrder + - dupArg + - dupBranchBody + - dupCase + - elseif + - emptyFallthrough + - emptyStringTest + - equalFold + - flagDeref + - ifElseChain + - indexAlloc + - methodExprCall + - rangeExprCopy + - rangeValCopy + - regexpMust + - singleCaseSwitch + - sloppyLen + - stringXbytes + - switchTrue + - typeAssertChain + - typeSwitchVar + - underef + - unlambda + - unnecessaryBlock + - unslice + - valSwap + - weakCond + - wrapperFunc + - yodaStyleExpr + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + + exclude-rules: + # Exclude some linters from running on tests files + - path: _test\.go + linters: + - errcheck + - gosec + +output: + formats: + colored-line-number: + path: stdout + print-issued-lines: true + print-linter-name: true \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1af961e --- /dev/null +++ b/Makefile @@ -0,0 +1,204 @@ +.PHONY: help test test-unit test-integration test-all test-coverage dynamo-start dynamo-stop dynamo-clean build clean lint fmt vet setup + +# Default target +help: + @echo "DynamoDB Utilities - Available Commands" + @echo "========================================" + @echo " make setup - Setup development environment (install dependencies)" + @echo "" + @echo " make test - Run unit tests only" + @echo " make test-unit - Run unit tests only (alias)" + @echo " make test-integration - Run integration tests (requires DynamoDB Local)" + @echo " make test-all - Run all tests with DynamoDB Local" + @echo " make test-coverage - Generate test coverage report" + @echo "" + @echo " make lint - Run all linters (golangci-lint, go fmt, go vet)" + @echo " make fmt - Format code with go fmt" + @echo " make vet - Run go vet" + @echo "" + @echo " make dynamo-start - Start DynamoDB Local in Docker" + @echo " make dynamo-stop - Stop DynamoDB Local" + @echo " make dynamo-clean - Stop and remove DynamoDB Local container" + @echo "" + @echo " make build - Build the application" + @echo " make clean - Clean build artifacts" + +# Setup development environment +setup: + @echo "Setting up development environment..." + @echo "" + @echo "Checking dependencies..." + @echo "" + + @# Check Go version + @echo "✓ Go version:" + @go version + @echo "" + + @# Check Docker + @if command -v docker >/dev/null 2>&1; then \ + echo "✓ Docker is installed"; \ + docker --version; \ + else \ + echo "✗ Docker is not installed"; \ + echo " Please install Docker: https://docs.docker.com/get-docker/"; \ + fi + @echo "" + + @# Install/Check golangci-lint + @if command -v golangci-lint >/dev/null 2>&1; then \ + echo "✓ golangci-lint is already installed"; \ + golangci-lint --version; \ + else \ + echo "Installing golangci-lint..."; \ + if [ "$$(uname)" = "Darwin" ]; then \ + if command -v brew >/dev/null 2>&1; then \ + brew install golangci-lint; \ + else \ + echo "Homebrew not found. Installing via curl..."; \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin; \ + fi; \ + elif [ "$$(uname)" = "Linux" ]; then \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin; \ + else \ + echo "Please install golangci-lint manually: https://golangci-lint.run/usage/install/"; \ + fi; \ + if command -v golangci-lint >/dev/null 2>&1; then \ + echo "✓ golangci-lint installed successfully"; \ + fi; \ + fi + @echo "" + + @# Install Go dependencies + @echo "Installing Go dependencies..." + @go mod download + @go mod tidy + @echo "✓ Go dependencies installed" + @echo "" + + @# Verify setup + @echo "Verifying setup..." + @go mod verify + @echo "✓ Go modules verified" + @echo "" + + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo "✓ Development environment setup complete!" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo "" + @echo "Next steps:" + @echo " 1. Run 'make build' to build the application" + @echo " 2. Run 'make test' to run unit tests" + @echo " 3. Run 'make lint' to check code quality" + @echo " 4. Run 'make help' to see all available commands" + @echo "" + +# Build the application +build: + @echo "Building dynamoutil..." + go build -o bin/dynamoutil . + @echo "Build complete: bin/dynamoutil" + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -rf bin/ + rm -f coverage.out coverage.html + @echo "Clean complete" + +# Run unit tests only +test: test-unit + +test-unit: + @echo "Running unit tests..." + go test -v -short ./... + +# Start DynamoDB Local in Docker +dynamo-start: + @echo "Starting DynamoDB Local..." + @if [ "$$(docker ps -q -f name=dynamodb-local)" ]; then \ + echo "DynamoDB Local is already running"; \ + else \ + docker run -d --name dynamodb-local -p 8000:8000 amazon/dynamodb-local; \ + echo "Waiting for DynamoDB Local to start..."; \ + sleep 3; \ + echo "DynamoDB Local started on http://localhost:8000"; \ + fi + +# Stop DynamoDB Local +dynamo-stop: + @echo "Stopping DynamoDB Local..." + @docker stop dynamodb-local 2>/dev/null || echo "DynamoDB Local is not running" + +# Stop and remove DynamoDB Local container +dynamo-clean: + @echo "Cleaning up DynamoDB Local..." + @docker stop dynamodb-local 2>/dev/null || true + @docker rm dynamodb-local 2>/dev/null || true + @echo "DynamoDB Local cleaned up" + +# Run integration tests (requires DynamoDB Local) +test-integration: dynamo-clean dynamo-start + @echo "Running integration tests..." + @sleep 2 + @AWS_ACCESS_KEY_ID=testaccesskeyid \ + AWS_SECRET_ACCESS_KEY=testsecretaccesskey \ + DYNAMODB_ENDPOINT=http://localhost:8000 \ + go test -v -tags=integration ./pkg/db; \ + TEST_EXIT_CODE=$$?; \ + $(MAKE) dynamo-clean; \ + exit $$TEST_EXIT_CODE + +# Run all tests with DynamoDB Local +test-all: dynamo-start + @echo "Running all tests..." + @sleep 2 + DYNAMODB_ENDPOINT=http://localhost:8000 go test -v -tags=integration ./... + @echo "" + @echo "All tests completed!" + +# Generate test coverage report +test-coverage: dynamo-start + @echo "Generating test coverage report..." + @sleep 2 + DYNAMODB_ENDPOINT=http://localhost:8000 go test -coverprofile=coverage.out -tags=integration ./... + go tool cover -html=coverage.out -o coverage.html + @echo "" + @echo "Coverage report generated: coverage.html" + @echo "Opening coverage report in browser..." + @command -v open >/dev/null 2>&1 && open coverage.html || \ + command -v xdg-open >/dev/null 2>&1 && xdg-open coverage.html || \ + echo "Please open coverage.html manually" + +# Run tests in CI environment +test-ci: dynamo-start + @echo "Running tests in CI mode..." + @sleep 2 + DYNAMODB_ENDPOINT=http://localhost:8000 go test -v -tags=integration -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + +# Format code with go fmt +fmt: + @echo "Running go fmt..." + @gofmt -l -w . + @echo "Formatting complete" + +# Run go vet +vet: + @echo "Running go vet..." + @go vet ./... + @echo "go vet complete" + +# Run all linters +lint: fmt vet + @echo "Running golangci-lint..." + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run ./...; \ + echo "Linting complete"; \ + else \ + echo "⚠️ golangci-lint is not installed"; \ + echo "Install with: brew install golangci-lint (macOS)"; \ + echo "Or visit: https://golangci-lint.run/usage/install/"; \ + echo ""; \ + echo "Continuing with go fmt and go vet only..."; \ + fi \ No newline at end of file diff --git a/README.md b/README.md index 932d16c..c0846d5 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,142 @@ -

Dynamoutil🚀

+# Dynamoutil -Collection of useful commands for DynamoDB. +> This project is forked from [daangn/dynamoutil](https://github.com/daangn/dynamoutil) + +A powerful CLI tool for managing AWS DynamoDB tables with interactive mode support. + +## Features + +- 🔄 **Copy** - Copy data between DynamoDB tables across regions or accounts +- 💾 **Dump** - Export table data to clean JSON format +- ✏️ **Rename** - Bulk rename attributes across all table items +- ✨ **Interactive Mode** - User-friendly prompts with table selection and validation ## Installation -### Via go get: +```sh +go install github.com/daangn/dynamoutil@latest +``` + +Or download from [releases](https://github.com/daangn/dynamoutil/releases). + +## Quick Start + +Use interactive mode for the easiest experience: ```sh -$ go get -u github.com/daangn/dynamoutil +dynamoutil copy --interactive +dynamoutil dump --interactive +dynamoutil rename --interactive ``` -### Download an executable binary +## Usage -[Github Releases Link](https://github.com/daangn/dynamoutil/releases) +### Copy -## Copy a dynamodb table from remote to local +Copy data between tables: -### Write a config file. +```sh +# Interactive mode +dynamoutil copy --interactive + +# Direct arguments +dynamoutil copy \ + --origin-region ap-northeast-2 --origin-table source-table \ + --target-region us-east-1 --target-table destination-table + +# Config file +dynamoutil copy default +``` + +Config file example (`.dynamoutil.yaml`): -.dynamoutil.yaml ```yaml copy: - service: "default" - ## Origin tables to copy. origin: region: "ap-northeast-2" - table: "remote-aws-table" - ## Target table to store. + table: "source-table" target: - region: "ap-northeast-2" - endpoint: "http://localhost:8000" - table: "local-aws-table" - ## Must match keys of target dynamodb. - # accessKeyID: "123" - # secretAccessKey: "123" + region: "us-east-1" + table: "target-table" + billingConfig: # Optional: for creating new tables + billingMode: "ON_DEMAND" # or "PROVISIONED" + readCapacityUnits: 5 + writeCapacityUnits: 5 ``` -### Run "copy" command. +### Dump + +Export table data to JSON: ```sh -$ dynamoutil -c .dynamoutil.yaml copy -Config file:.dynamoutil.yaml +# Interactive mode +dynamoutil dump --interactive -Origin region: ap-northeast-2 table: remote-aws-table endpoint: -Target region: ap-northeast-2 table: local-aws-table endpoint: http://localhost:8000 +# Direct arguments +dynamoutil dump --table my-table --region ap-northeast-2 --filename output.json -Are you sure about copying all items from remote-aws-table? [Y/n] +# Config file +dynamoutil dump default ``` -## Dump a dynamodb table from remote - -### Write a config file. +Config file example: ```yaml dump: - service: "default" db: region: "ap-northeast-2" - # endpoint: "http://localhost:8000" - table: "remote-dynamodb-table-name" - output: json - # Default name is dynamodb's table name - filename: "remote-dynamodb-table-name" + table: "my-table" + output: json # or jsonRaw + filename: "output.json" ``` -### Run "dump" command. +Output formats: +- `json`: Pretty-printed JSON array +- `jsonRaw`: One JSON object per line (efficient for large datasets) -```sh -$ dynamoutil -c .dynamoutil.yaml dump +### Rename -Config file:.dynamoutil.yaml - -service: default region: ap-northeast-2 table: remote-aws-table endpoint: output: json - -Are you sure about dumping all items from rocket-chat-alpha-message? [Y/n] Y - - Writes 1828 items. 380.71 items/s -``` +Bulk rename attributes: ```sh -# The common data structure when you do the DynamoDB dump via AWS Glue or DynamoDB Stream, -## DynamoDB S3 Export -{Item:{"PartitionKey": {"S": "partition_key_value"},"SortKey": {"N": "sort_key_value"}}} -## DynamoDB Stream event & AWS Glue -{"PartitionKey": {"S": "partition_key_value"},"SortKey": {"N": "sort_key_value"}} - -# When you do the dump with Dynamoutil, -{"PartitionKey": "partition_key_value","SortKey": "sort_key_value"} -``` +# Interactive mode +dynamoutil rename --interactive -## Rename attributes in a dynamodb table +# Config file +dynamoutil rename default +``` -### Write a config file. +Config file example: ```yaml rename: - service: "default" target: region: "ap-northeast-2" - table: "local-dynamodb-table-name" - ## Must match keys of target dynamodb. - # endpoint: "http://localhost:54000" - # accessKeyID: "123" - # secretAccessKey: "123" + table: "my-table" rename: - - before: "oldAttributeName1" - after: "newAttributeName1" - - before: "oldAttributeName2" - after: "newAttributeName2" + - before: "oldName1" + after: "newName1" + - before: "oldName2" + after: "newName2" ``` -### Run "rename" command. +## Development ```sh -$ dynamoutil -c .dynamoutil.yaml rename -Config file:.dynamoutil.yaml - -Target region: ap-northeast-2 table: local-dynamodb-table-name endpoint: - -Are you sure about renaming attributes in local-dynamodb-table-name? [Y/n] Y - - Time spent: 12.3s. Read 5000 items, Processed 4500 items. 365.85 items/s - -Renamed 4500 items of local-dynamodb-table-name table. -Execution Time: 12.30 seconds -Avg: 365.85 ops/s - -Detailed Rename Metrics: -oldAttributeName1 -> newAttributeName1: 3000 items changed, Total Time: 8.15 seconds, Avg Time per item: 0.0027 seconds -oldAttributeName2 -> newAttributeName2: 1500 items changed, Total Time: 4.15 seconds, Avg Time per item: 0.0028 seconds +make setup # Install dependencies +make build # Build binary +make test # Run tests +make lint # Run linters ``` -#### About the Rename Command -The rename command reads pairs of attributes to be renamed from the configuration file and updates them in the specified DynamoDB table. It processes items in batches, ensuring that attributes are renamed efficiently while maintaining data integrity. Metrics are provided to track the time taken for each rename operation, the number of items processed, and the average processing time. - -Use this command to refactor your DynamoDB schema, making changes to attribute names without affecting the underlying data structure. - -## Author - -* Github: - - [@novemberde](https://github.com/novemberde) - - [@mingrammer](https://github.com/mingrammer) - - [@erickim713](https://github.com/erickim713) - -## 🤝 Contributing - -Contributions, issues and feature requests are welcome!
Feel free to check [issues page](/daangn/dynamoutil/issues). +## Authors -*This repository only allows Pull Request to apply on master branch.* +- [@novemberde](https://github.com/novemberde) +- [@mingrammer](https://github.com/mingrammer) +- [@erickim713](https://github.com/erickim713) ## License diff --git a/cmd/copy.go b/cmd/copy.go index 5c8766d..13811ea 100644 --- a/cmd/copy.go +++ b/cmd/copy.go @@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -16,26 +16,69 @@ limitations under the License. package cmd import ( + "context" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/daangn/dynamoutil/pkg/awsutil" "github.com/daangn/dynamoutil/pkg/config" "github.com/daangn/dynamoutil/pkg/db" + "github.com/daangn/dynamoutil/pkg/display" + "github.com/daangn/dynamoutil/pkg/prompt" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) const defaultService = "default" +var ( + interactiveMode bool + copyOriginRegion string + copyOriginTable string + copyTargetRegion string + copyTargetTable string +) + // copyCmd represents the copy command var copyCmd = &cobra.Command{ - Use: "copy", - Short: "Copy items from the origin table, and import on the target table", - Long: `This command is working based on DynamoDB's BatchGetItems and BatchWriteItems. - This requires read and write capacity of DynamoDB. If you turn on the flag 'on demand' - on DynamoDB, please check before executing this command to prevent from billing costs by AWS.`, + Use: "copy [service]", + Short: "Copy items from the origin table to the target table", + Long: `Copy items from the origin DynamoDB table to the target table using BatchGetItems and BatchWriteItems. + +This command supports two modes: +1. Config mode: Use predefined configurations from .dynamoutil.yaml +2. Interactive mode: Use --interactive flag to select tables interactively`, + Example: ` # Use config file + dynamoutil copy myservice + + # Interactive mode + dynamoutil copy --interactive + + # Specify tables directly + dynamoutil copy --origin-table users --target-table users-backup --origin-region us-east-1 --target-region us-west-2`, Args: cobra.RangeArgs(0, 1), PreRun: func(cmd *cobra.Command, args []string) { - config.MustReadCfgFile() + if !interactiveMode && copyOriginTable == "" { + config.MustReadCfgFile() + } }, Run: func(cmd *cobra.Command, args []string) { + ctx := context.Background() + + // Interactive mode + if interactiveMode { + runInteractiveCopy(ctx) + return + } + + // Direct table specification + if copyOriginTable != "" { + runDirectCopy() + return + } + + // Config-based mode service := defaultService if len(args) == 1 { service = args[0] @@ -44,15 +87,229 @@ var copyCmd = &cobra.Command{ for _, cfg := range config.MustBind().Copy { if cfg.Service == service { if err := db.Copy(cfg); err != nil { - log.Fatal().Msgf("failed to sync: %s", err) + log.Fatal().Msgf("failed to copy: %s", err) } return } } + display.Error("Service '" + service + "' not found in configuration") log.Error().Msgf("'%s' is not a valid service", service) }, } +func runInteractiveCopy(ctx context.Context) { + display.Header("Interactive Copy Mode") + + // Get origin region + originRegion, err := prompt.Input("Origin AWS Region", "us-east-1") + if err != nil { + display.Error("Failed to get origin region: " + err.Error()) + return + } + + // Create origin config and client + originConfig := &config.DynamoDBConfig{ + Region: originRegion, + } + + display.Info("Connecting to origin region: " + originRegion) + + // Create DynamoDB client to list tables + originClient, err := db.CreateClient(originConfig) + if err != nil { + display.Warning("Could not connect to DynamoDB: " + err.Error()) + display.Info("You can still enter table name manually") + } + + // Select origin table (with listing if client available) + var originTable string + if originClient != nil { + originTable, err = awsutil.SelectTable(ctx, originClient, "Select Origin Table") + if err != nil { + display.Error("Failed to select origin table: " + err.Error()) + return + } + } else { + originTable, err = prompt.Input("Origin Table Name", "") + if err != nil { + display.Error("Failed to get origin table: " + err.Error()) + return + } + } + + // Get target region + targetRegion, err := prompt.Input("Target AWS Region", originRegion) + if err != nil { + display.Error("Failed to get target region: " + err.Error()) + return + } + + // Create target config and client + targetConfig := &config.DynamoDBConfig{ + Region: targetRegion, + } + + // Select target table (with listing if in same region or create new client) + var targetTable string + var targetClient *dynamodb.Client + + if targetRegion == originRegion && originClient != nil { + targetClient = originClient + } else { + display.Info("Connecting to target region: " + targetRegion) + targetClient, err = db.CreateClient(targetConfig) + if err != nil { + display.Warning("Could not connect to target DynamoDB: " + err.Error()) + } + } + + if targetClient != nil { + targetTable, err = awsutil.SelectTable(ctx, targetClient, "Select Target Table") + if err != nil { + display.Error("Failed to select target table: " + err.Error()) + return + } + } else { + targetTable, err = prompt.Input("Target Table Name", originTable+"-copy") + if err != nil { + display.Error("Failed to get target table: " + err.Error()) + return + } + } + + // Show summary + display.Summary("Copy Configuration", map[string]string{ + "Origin Region": originRegion, + "Origin Table": originTable, + "Target Region": targetRegion, + "Target Table": targetTable, + }) + + // Confirm + if !prompt.Confirm("Proceed with copy operation?") { + display.Info("Copy operation cancelled") + return + } + + // Execute copy + originConfig.TableName = originTable + targetConfig.TableName = targetTable + + cfg := &config.DynamoDBCopyConfig{ + Service: "interactive", + Origin: originConfig, + Target: targetConfig, + } + + // Check if target table exists, if not prompt for billing configuration + billingCfg, err := promptForBillingConfigIfNeeded(ctx, targetClient, targetTable, targetRegion) + if err != nil { + display.Error("Failed to configure billing: " + err.Error()) + return + } + cfg.BillingConfig = billingCfg + + if err := db.Copy(cfg); err != nil { + display.Error("Copy failed: " + err.Error()) + log.Fatal().Msgf("failed to copy: %s", err) + } +} + +// promptForBillingConfigIfNeeded checks if target table exists and prompts for billing config if not +func promptForBillingConfigIfNeeded(ctx context.Context, client *dynamodb.Client, tableName, region string) (*config.TableBillingConfig, error) { + if client == nil { + display.Warning("Cannot check if target table exists - no client available") + return nil, nil + } + + // Check if target table exists + _, err := client.DescribeTable(ctx, &dynamodb.DescribeTableInput{ + TableName: &tableName, + }) + + if err == nil { + // Table exists, no billing config needed + return nil, nil + } + + // Check if error is "table not found" + if !strings.Contains(err.Error(), "ResourceNotFoundException") { + return nil, fmt.Errorf("failed to describe target table: %w", err) + } + + // Table doesn't exist, prompt for billing configuration + display.Warning(fmt.Sprintf("Target table '%s' does not exist in region '%s'", tableName, region)) + + if !prompt.Confirm("Do you want to create the target table?") { + return nil, fmt.Errorf("target table does not exist and user declined to create it") + } + + // Prompt for billing mode + display.Info("Select billing mode for the new target table:") + _, billingChoice, err := prompt.Select("Billing Mode", []string{"ON_DEMAND (Pay per request)", "PROVISIONED (Specify RCU/WCU)"}) + if err != nil { + return nil, fmt.Errorf("failed to select billing mode: %w", err) + } + + cfg := &config.TableBillingConfig{} + + if strings.HasPrefix(billingChoice, "ON_DEMAND") { + cfg.BillingMode = "ON_DEMAND" + display.Success("Using ON_DEMAND billing mode") + } else { + cfg.BillingMode = "PROVISIONED" + + // Prompt for RCU with validation + minCap := 1 + rcu, err := prompt.InputInt("Read Capacity Units (RCU)", 5, &minCap, nil) + if err != nil { + return nil, fmt.Errorf("failed to get RCU: %w", err) + } + rcu64 := int64(rcu) + cfg.ReadCapacityUnits = &rcu64 + + // Prompt for WCU with validation + wcu, err := prompt.InputInt("Write Capacity Units (WCU)", 5, &minCap, nil) + if err != nil { + return nil, fmt.Errorf("failed to get WCU: %w", err) + } + wcu64 := int64(wcu) + cfg.WriteCapacityUnits = &wcu64 + + display.Success(fmt.Sprintf("Using PROVISIONED mode with RCU=%d, WCU=%d", rcu, wcu)) + } + + return cfg, nil +} + +func runDirectCopy() { + originConfig := &config.DynamoDBConfig{ + Region: copyOriginRegion, + TableName: copyOriginTable, + } + targetConfig := &config.DynamoDBConfig{ + Region: copyTargetRegion, + TableName: copyTargetTable, + } + + cfg := &config.DynamoDBCopyConfig{ + Service: "direct", + Origin: originConfig, + Target: targetConfig, + } + + if err := db.Copy(cfg); err != nil { + display.Error("Copy failed: " + err.Error()) + log.Fatal().Msgf("failed to copy: %s", err) + } +} + func init() { rootCmd.AddCommand(copyCmd) + + copyCmd.Flags().BoolVarP(&interactiveMode, "interactive", "i", false, "Run in interactive mode") + copyCmd.Flags().StringVar(©OriginRegion, "origin-region", "us-east-1", "Origin AWS region") + copyCmd.Flags().StringVar(©OriginTable, "origin-table", "", "Origin table name") + copyCmd.Flags().StringVar(©TargetRegion, "target-region", "", "Target AWS region (defaults to origin region)") + copyCmd.Flags().StringVar(©TargetTable, "target-table", "", "Target table name") } diff --git a/cmd/dump.go b/cmd/dump.go index 28b8ac6..13c2aee 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -1,24 +1,68 @@ package cmd import ( + "context" + + "github.com/daangn/dynamoutil/pkg/awsutil" "github.com/daangn/dynamoutil/pkg/config" "github.com/daangn/dynamoutil/pkg/db" + "github.com/daangn/dynamoutil/pkg/display" + "github.com/daangn/dynamoutil/pkg/prompt" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) +var ( + dumpInteractive bool + dumpRegion string + dumpTable string + dumpFilename string + dumpFormat string +) + // dumpCmd represents the dump command var dumpCmd = &cobra.Command{ - Use: "dump", - Short: "Dump items from the remote table", - Long: `This command is working based on DynamoDB's BatchGetItems and BatchWriteItems. - This requires read and write capacity of DynamoDB. If you turn on the flag 'on demand' - on DynamoDB, please check before executing this command to prevent from billing costs by AWS.`, + Use: "dump [service]", + Short: "Dump items from a DynamoDB table to a file", + Long: `Dump all items from a DynamoDB table to a local file. + +This command supports two modes: +1. Config mode: Use predefined configurations from .dynamoutil.yaml +2. Interactive mode: Use --interactive flag to select table interactively + +Supported output formats: +- json: Pretty-printed JSON array +- jsonRaw: One JSON object per line (for large datasets)`, + Example: ` # Use config file + dynamoutil dump myservice + + # Interactive mode + dynamoutil dump --interactive + + # Specify table directly + dynamoutil dump --table users --region us-east-1 --filename users-backup.json --format json`, Args: cobra.RangeArgs(0, 1), PreRun: func(cmd *cobra.Command, args []string) { - config.MustReadCfgFile() + if !dumpInteractive && dumpTable == "" { + config.MustReadCfgFile() + } }, Run: func(cmd *cobra.Command, args []string) { + ctx := context.Background() + + // Interactive mode + if dumpInteractive { + runInteractiveDump(ctx) + return + } + + // Direct table specification + if dumpTable != "" { + runDirectDump() + return + } + + // Config-based mode service := defaultService if len(args) == 1 { service = args[0] @@ -27,15 +71,134 @@ var dumpCmd = &cobra.Command{ for _, cfg := range config.MustBind().Dump { if cfg.Service == service { if err := db.Dump(cfg); err != nil { - log.Fatal().Msgf("failed to sync: %s", err) + log.Fatal().Msgf("failed to dump: %s", err) } return } } + display.Error("Service '" + service + "' not found in configuration") log.Error().Msgf("'%s' is not a valid service", service) }, } +func runInteractiveDump(ctx context.Context) { + display.Header("Interactive Dump Mode") + + // Get region + region, err := prompt.Input("AWS Region", "us-east-1") + if err != nil { + display.Error("Failed to get region: " + err.Error()) + return + } + + // Create config and client + dumpConfig := &config.DynamoDBConfig{ + Region: region, + } + + display.Info("Connecting to region: " + region) + + // Create DynamoDB client to list tables + client, err := db.CreateClient(dumpConfig) + if err != nil { + display.Warning("Could not connect to DynamoDB: " + err.Error()) + display.Info("You can still enter table name manually") + } + + // Select table (with listing if client available) + var tableName string + if client != nil { + tableName, err = awsutil.SelectTable(ctx, client, "Select Table") + if err != nil { + display.Error("Failed to select table: " + err.Error()) + return + } + } else { + tableName, err = prompt.Input("Table Name", "") + if err != nil { + display.Error("Failed to get table name: " + err.Error()) + return + } + } + + // Get filename + filename, err := prompt.Input("Output Filename", tableName+"-dump.json") + if err != nil { + display.Error("Failed to get filename: " + err.Error()) + return + } + + // Select format + _, format, err := prompt.Select("Output Format", []string{"json", "jsonRaw"}) + if err != nil { + display.Error("Failed to get format: " + err.Error()) + return + } + + // Show summary + display.Summary("Dump Configuration", map[string]string{ + "Region": region, + "Table": tableName, + "Filename": filename, + "Format": format, + }) + + // Confirm + if !prompt.Confirm("Proceed with dump operation?") { + display.Info("Dump operation cancelled") + return + } + + // Execute dump + outputFormat := config.DefaultOutput + if format == "json" { + outputFormat = config.OutputJSON + } + + cfg := &config.DynamoDBDumpConfig{ + Service: "interactive", + FileName: filename, + Output: outputFormat, + DynamoDB: config.DynamoDBConfig{ + Region: region, + TableName: tableName, + }, + } + + if err := db.Dump(cfg); err != nil { + display.Error("Dump failed: " + err.Error()) + log.Fatal().Msgf("failed to dump: %s", err) + } +} + +func runDirectDump() { + outputFormat := config.DefaultOutput + if dumpFormat == "json" { + outputFormat = config.OutputJSON + } + + cfg := &config.DynamoDBDumpConfig{ + Service: "direct", + FileName: dumpFilename, + Output: outputFormat, + DynamoDB: config.DynamoDBConfig{ + Region: dumpRegion, + TableName: dumpTable, + }, + } + + if err := db.Dump(cfg); err != nil { + display.Error("Dump failed: " + err.Error()) + log.Fatal().Msgf("failed to dump: %s", err) + } +} + func init() { rootCmd.AddCommand(dumpCmd) + + dumpCmd.Flags().BoolVarP(&dumpInteractive, "interactive", "i", false, "Run in interactive mode") + dumpCmd.Flags().StringVar(&dumpRegion, "region", "us-east-1", "AWS region") + dumpCmd.Flags().StringVar(&dumpTable, "table", "", "Table name") + dumpCmd.Flags().StringVarP(&dumpFilename, "filename", "f", "", "Output filename") + dumpCmd.Flags().StringVar(&dumpFormat, "format", "jsonRaw", "Output format (json or jsonRaw)") } diff --git a/cmd/rename.go b/cmd/rename.go index f44742a..e9cf3e1 100644 --- a/cmd/rename.go +++ b/cmd/rename.go @@ -1,24 +1,67 @@ package cmd import ( + "context" + "fmt" + "strings" + + "github.com/daangn/dynamoutil/pkg/awsutil" "github.com/daangn/dynamoutil/pkg/config" "github.com/daangn/dynamoutil/pkg/db" + "github.com/daangn/dynamoutil/pkg/display" + "github.com/daangn/dynamoutil/pkg/prompt" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) +var ( + renameInteractive bool + renameRegion string + renameTable string + renameAttributes []string +) + // renameCmd represents the rename command var renameCmd = &cobra.Command{ - Use: "rename", - Short: "Rename attributes in the DynamoDB table as defined in the configuration", - Long: `This command allows renaming of attributes in DynamoDB items based on the - before-after pairs defined in the configuration file. This requires read and write capacity - on the DynamoDB table.`, + Use: "rename [service]", + Short: "Rename attributes in DynamoDB table items", + Long: `Rename attributes in all items of a DynamoDB table. + +This command supports two modes: +1. Config mode: Use predefined configurations from .dynamoutil.yaml +2. Interactive mode: Use --interactive flag to define rename mappings interactively + +Attributes are specified as "before:after" pairs.`, + Example: ` # Use config file + dynamoutil rename myservice + + # Interactive mode + dynamoutil rename --interactive + + # Specify directly + dynamoutil rename --table users --region us-east-1 --attributes old_name:new_name,user_id:userId`, Args: cobra.RangeArgs(0, 1), PreRun: func(cmd *cobra.Command, args []string) { - config.MustReadCfgFile() + if !renameInteractive && renameTable == "" { + config.MustReadCfgFile() + } }, Run: func(cmd *cobra.Command, args []string) { + ctx := context.Background() + + // Interactive mode + if renameInteractive { + runInteractiveRename(ctx) + return + } + + // Direct specification + if renameTable != "" { + runDirectRename() + return + } + + // Config-based mode service := defaultService if len(args) == 1 { service = args[0] @@ -32,10 +75,146 @@ var renameCmd = &cobra.Command{ return } } + display.Error("Service '" + service + "' not found in configuration") log.Error().Msgf("'%s' is not a valid service", service) }, } +func runInteractiveRename(ctx context.Context) { + display.Header("Interactive Rename Mode") + + // Get region + region, err := prompt.Input("AWS Region", "us-east-1") + if err != nil { + display.Error("Failed to get region: " + err.Error()) + return + } + + // Create config and client + renameConfig := &config.DynamoDBConfig{ + Region: region, + } + + display.Info("Connecting to region: " + region) + + // Create DynamoDB client to list tables + client, err := db.CreateClient(renameConfig) + if err != nil { + display.Warning("Could not connect to DynamoDB: " + err.Error()) + display.Info("You can still enter table name manually") + } + + // Select table (with listing if client available) + var tableName string + if client != nil { + tableName, err = awsutil.SelectTable(ctx, client, "Select Table") + if err != nil { + display.Error("Failed to select table: " + err.Error()) + return + } + } else { + tableName, err = prompt.Input("Table Name", "") + if err != nil { + display.Error("Failed to get table name: " + err.Error()) + return + } + } + + display.Info("Enter attribute rename mappings (one per line, format: old_name:new_name)") + display.Info("Press Enter with empty input to finish") + + var renames []config.RenameAttribute + for { + mapping, err := prompt.Input("Rename mapping (or empty to finish)", "") + if err != nil || mapping == "" { + break + } + + parts := strings.Split(mapping, ":") + if len(parts) != 2 { + display.Warning("Invalid format. Use: old_name:new_name") + continue + } + + renames = append(renames, config.RenameAttribute{ + Before: strings.TrimSpace(parts[0]), + After: strings.TrimSpace(parts[1]), + }) + display.Success("Added: " + parts[0] + " → " + parts[1]) + } + + if len(renames) == 0 { + display.Warning("No rename mappings provided") + return + } + + // Show summary + summaryMap := map[string]string{ + "Region": region, + "Table": tableName, + } + for i, r := range renames { + summaryMap[fmt.Sprintf("Rename %d", i+1)] = r.Before + " → " + r.After + } + display.Summary("Rename Configuration", summaryMap) + + // Confirm + if !prompt.Confirm("Proceed with rename operation?") { + display.Info("Rename operation cancelled") + return + } + + // Execute rename + cfg := &config.DynamoDBRenameConfig{ + Service: "interactive", + Target: &config.DynamoDBConfig{ + Region: region, + TableName: tableName, + }, + Rename: renames, + } + + if err := db.Rename(cfg); err != nil { + display.Error("Rename failed: " + err.Error()) + log.Fatal().Msgf("failed to rename: %s", err) + } +} + +func runDirectRename() { + var renames []config.RenameAttribute + + for _, attr := range renameAttributes { + parts := strings.Split(attr, ":") + if len(parts) != 2 { + display.Error("Invalid attribute format: " + attr + " (expected: old:new)") + return + } + renames = append(renames, config.RenameAttribute{ + Before: strings.TrimSpace(parts[0]), + After: strings.TrimSpace(parts[1]), + }) + } + + cfg := &config.DynamoDBRenameConfig{ + Service: "direct", + Target: &config.DynamoDBConfig{ + Region: renameRegion, + TableName: renameTable, + }, + Rename: renames, + } + + if err := db.Rename(cfg); err != nil { + display.Error("Rename failed: " + err.Error()) + log.Fatal().Msgf("failed to rename: %s", err) + } +} + func init() { rootCmd.AddCommand(renameCmd) + + renameCmd.Flags().BoolVarP(&renameInteractive, "interactive", "i", false, "Run in interactive mode") + renameCmd.Flags().StringVar(&renameRegion, "region", "us-east-1", "AWS region") + renameCmd.Flags().StringVar(&renameTable, "table", "", "Table name") + renameCmd.Flags().StringSliceVarP(&renameAttributes, "attributes", "a", []string{}, "Attribute mappings (old:new,old2:new2)") } diff --git a/cmd/root.go b/cmd/root.go index 45e92ff..43af5b1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -19,25 +19,64 @@ import ( "fmt" "os" - "github.com/spf13/cobra" - + aurora "github.com/logrusorgru/aurora" homedir "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" "github.com/spf13/viper" + + "github.com/daangn/dynamoutil/pkg/display" ) var cfgFile string +var showBanner bool // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "dynamoutil", - Short: "A bundle of commands for DyanmoDB", - Long: ``, + Short: "🚀 A powerful CLI tool for managing DynamoDB tables", + Long: ` +╔══════════════════════════════════════════════════════════╗ +║ DynamoDB Utilities - CLI Tool ║ +╚══════════════════════════════════════════════════════════╝ + +A comprehensive command-line tool for managing AWS DynamoDB tables. + +Features: + • Copy tables between regions or accounts + • Dump table data to JSON files + • Rename attributes across all table items + • Interactive mode for easy operation + • Support for both config files and direct CLI arguments + +For more information on each command, use: + dynamoutil [command] --help`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if showBanner { + display.Banner() + } + }, + Run: func(cmd *cobra.Command, args []string) { + display.Banner() + fmt.Println(aurora.Cyan("Available commands:")) + fmt.Printf("%s - Copy items from origin table to target table\n", aurora.Green(" copy ")) + fmt.Printf("%s - Dump table items to a JSON file\n", aurora.Green(" dump ")) + fmt.Printf("%s - Rename attributes in table items\n", aurora.Green(" rename ")) + fmt.Println() + fmt.Println(aurora.Yellow("Use 'dynamoutil [command] --help' for more information about a command.")) + fmt.Println(aurora.Yellow("Use 'dynamoutil [command] --interactive' for interactive mode.")) + fmt.Println() + fmt.Println(aurora.Cyan("Examples:")) + fmt.Println(" dynamoutil copy --interactive") + fmt.Println(" dynamoutil dump --table users --region us-east-1") + fmt.Println(" dynamoutil rename --interactive") + fmt.Println() + }, } // Execute ... func Execute() { if err := rootCmd.Execute(); err != nil { - fmt.Println(err) + display.Error(err.Error()) os.Exit(1) } } @@ -45,7 +84,7 @@ func Execute() { func init() { cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default is $HOME/.dynamoutil.yaml)") - rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + rootCmd.PersistentFlags().BoolVar(&showBanner, "banner", false, "show banner on command execution") } // initConfig reads in config file and ENV variables if set. @@ -55,11 +94,12 @@ func initConfig() { } else { home, err := homedir.Dir() if err != nil { - fmt.Println(err) + display.Error("Failed to determine home directory: " + err.Error()) os.Exit(1) } viper.AddConfigPath(home) + viper.AddConfigPath(".") viper.SetConfigName(".dynamoutil") } diff --git a/go.mod b/go.mod index 1cf0e4e..561879f 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,14 @@ module github.com/daangn/dynamoutil go 1.14 require ( - github.com/aws/aws-sdk-go v1.33.7 + github.com/aws/aws-sdk-go-v2 v1.17.3 + github.com/aws/aws-sdk-go-v2/config v1.18.7 + github.com/aws/aws-sdk-go-v2/credentials v1.13.7 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.18.1 + github.com/briandowns/spinner v1.23.2 github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible + github.com/manifoldco/promptui v0.9.0 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.3.2 // indirect github.com/pelletier/go-toml v1.8.0 // indirect @@ -17,7 +22,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.7.0 - golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect - golang.org/x/text v0.3.3 // indirect + github.com/stretchr/testify v1.6.1 // indirect + golang.org/x/text v0.4.0 // indirect gopkg.in/ini.v1 v1.57.0 // indirect ) diff --git a/go.sum b/go.sum index e29ab9e..5d69b4b 100644 --- a/go.sum +++ b/go.sum @@ -21,13 +21,49 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/aws/aws-sdk-go v1.33.7 h1:vOozL5hmWHHriRviVTQnUwz8l05RS0rehmEFymI+/x8= -github.com/aws/aws-sdk-go v1.33.7/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go-v2 v1.17.3 h1:shN7NlnVzvDUgPQ+1rLMSxY8OWRNDRYtiqe0p/PgrhY= +github.com/aws/aws-sdk-go-v2 v1.17.3/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= +github.com/aws/aws-sdk-go-v2/config v1.18.7 h1:V94lTcix6jouwmAsgQMAEBozVAGJMFhVj+6/++xfe3E= +github.com/aws/aws-sdk-go-v2/config v1.18.7/go.mod h1:OZYsyHFL5PB9UpyS78NElgKs11qI/B5KJau2XOJDXHA= +github.com/aws/aws-sdk-go-v2/credentials v1.13.7 h1:qUUcNS5Z1092XBFT66IJM7mYkMwgZ8fcC8YDIbEwXck= +github.com/aws/aws-sdk-go-v2/credentials v1.13.7/go.mod h1:AdCcbZXHQCjJh6NaH3pFaw8LUeBFn5+88BZGMVGuBT8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 h1:j9wi1kQ8b+e0FBVHxCqCGo4kxDU175hoDHcWAi0sauU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21/go.mod h1:ugwW57Z5Z48bpvUyZuaPy4Kv+vEfJWnIrky7RmkBvJg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 h1:I3cakv2Uy1vNmmhRQmFptYDxOvBnwCdNwyw63N0RaRU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27/go.mod h1:a1/UpzeyBBerajpnP5nGZa9mGzsBn5cOKxm6NWQsvoI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 h1:5NbbMrIzmUn/TXFqAle6mgrH5m9cOvMLRGL7pnG8tRE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21/go.mod h1:+Gxn8jYn5k9ebfHEqlhrMirFjSW0v0C9fI+KN5vk2kE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 h1:KeTxcGdNnQudb46oOl4d90f2I33DF/c6q3RnZAmvQdQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28/go.mod h1:yRZVr/iT0AqyHeep00SZ4YfBAKojXz08w3XMBscdi0c= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.18.1 h1:xmKa+GjQxvzK5xZNzrcybXuPOvjYX9JDWNkXF7fNr5c= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.18.1/go.mod h1:uP2wpt43//qh6NqMFslaRu53A2YbnFStkV4Wn1Ldels= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.21 h1:UYhcXvg66FBsZKRpXtNc4w+2rwaTHzST/zhpQBxzhPo= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.21/go.mod h1:NXJls8x8f9zVSaf+EKKoonqaahWK69MUWm6w6ob0FHs= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 h1:5C6XgTViSb0bunmU57b3CT+MhxULqHH2721FVA+/kDM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21/go.mod h1:lRToEJsn+DRA9lW4O9L9+/3hjTkUzlzyzHqn8MTds5k= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.28 h1:gItLq3zBYyRDPmqAClgzTH8PBjDQGeyptYGHIwtYYNA= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.28/go.mod h1:wo/B7uUm/7zw/dWhBJ4FXuw1sySU5lyIhVg1Bu2yL9A= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.11 h1:KCacyVSs/wlcPGx37hcbT3IGYO8P8Jx+TgSDhAXtQMY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.11/go.mod h1:TZSH7xLO7+phDtViY/KUp9WGCJMQkLJ/VpgkTFd5gh8= +github.com/aws/aws-sdk-go-v2/service/sts v1.17.7 h1:9Mtq1KM6nD8/+HStvWcvYnixJ5N85DX+P+OY3kI3W2k= +github.com/aws/aws-sdk-go-v2/service/sts v1.17.7/go.mod h1:+lGbb3+1ugwKrNTWcf2RT05Xmp543B06zDFTwiTLp7I= +github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= +github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= +github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -42,6 +78,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= @@ -51,7 +88,6 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= @@ -67,6 +103,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 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.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -103,8 +141,10 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= -github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -126,8 +166,14 @@ github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/z github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -204,14 +250,15 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= @@ -224,6 +271,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -242,6 +290,7 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -257,8 +306,8 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -267,13 +316,16 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -281,13 +333,23 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -308,6 +370,8 @@ golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -344,8 +408,11 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/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 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go index 34be614..88a717b 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, diff --git a/pkg/awsutil/dynamodb.go b/pkg/awsutil/dynamodb.go new file mode 100644 index 0000000..6ce99db --- /dev/null +++ b/pkg/awsutil/dynamodb.go @@ -0,0 +1,67 @@ +package awsutil + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/daangn/dynamoutil/pkg/display" + "github.com/daangn/dynamoutil/pkg/prompt" + aurora "github.com/logrusorgru/aurora" +) + +// ListTables fetches and returns a list of DynamoDB table names +func ListTables(ctx context.Context, client *dynamodb.Client) ([]string, error) { + s := display.Spinner("Fetching DynamoDB tables...") + defer s.Stop() + + var tables []string + var lastEvaluatedTableName *string + + for { + input := &dynamodb.ListTablesInput{ + ExclusiveStartTableName: lastEvaluatedTableName, + } + + result, err := client.ListTables(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to list tables: %w", err) + } + + tables = append(tables, result.TableNames...) + + if result.LastEvaluatedTableName == nil { + break + } + lastEvaluatedTableName = result.LastEvaluatedTableName + } + + return tables, nil +} + +// SelectTable prompts the user to select a DynamoDB table from available tables +func SelectTable(ctx context.Context, client *dynamodb.Client, label string) (string, error) { + tables, err := ListTables(ctx, client) + if err != nil { + return "", err + } + + if len(tables) == 0 { + return "", fmt.Errorf("no tables found") + } + + // Add option to enter custom table name + tables = append(tables, aurora.Yellow("→ Enter custom table name").String()) + + _, selected, err := prompt.Select(label, tables) + if err != nil { + return "", err + } + + // If user selected custom input option + if selected == aurora.Yellow("→ Enter custom table name").String() { + return prompt.Input("Enter table name", "") + } + + return selected, nil +} diff --git a/pkg/awsutil/dynamodb_test.go b/pkg/awsutil/dynamodb_test.go new file mode 100644 index 0000000..66a04a6 --- /dev/null +++ b/pkg/awsutil/dynamodb_test.go @@ -0,0 +1,87 @@ +package awsutil + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb" +) + +// mockDynamoDBClient is a mock implementation for testing +type mockDynamoDBClient struct { + tables []string + listTablesErr error + paginatedPages [][]string +} + +func (m *mockDynamoDBClient) ListTables(ctx context.Context, params *dynamodb.ListTablesInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ListTablesOutput, error) { + if m.listTablesErr != nil { + return nil, m.listTablesErr + } + + // Simple pagination simulation + if len(m.paginatedPages) > 0 { + pageIndex := 0 + if params.ExclusiveStartTableName != nil { + // Find which page we're on based on the last table name + for i, page := range m.paginatedPages { + if len(page) > 0 && page[len(page)-1] == *params.ExclusiveStartTableName { + pageIndex = i + 1 + break + } + } + } + + if pageIndex < len(m.paginatedPages) { + output := &dynamodb.ListTablesOutput{ + TableNames: m.paginatedPages[pageIndex], + } + if pageIndex < len(m.paginatedPages)-1 { + lastTable := m.paginatedPages[pageIndex][len(m.paginatedPages[pageIndex])-1] + output.LastEvaluatedTableName = &lastTable + } + return output, nil + } + } + + return &dynamodb.ListTablesOutput{ + TableNames: m.tables, + }, nil +} + +func TestListTables(t *testing.T) { + t.Run("successful_list", func(t *testing.T) { + // Note: This test requires a real DynamoDB client or more sophisticated mocking + // The current implementation cannot be easily tested without interface extraction + // This is a placeholder to show the structure + t.Skip("Skipping - requires interface-based client for proper mocking") + }) + + t.Run("empty_list", func(t *testing.T) { + t.Skip("Skipping - requires interface-based client for proper mocking") + }) + + t.Run("error_handling", func(t *testing.T) { + t.Skip("Skipping - requires interface-based client for proper mocking") + }) +} + +func TestSelectTable(t *testing.T) { + t.Run("function_signature", func(t *testing.T) { + // Verify function exists with correct signature + var fn func(context.Context, *dynamodb.Client, string) (string, error) = SelectTable + if fn == nil { + t.Error("SelectTable function is nil") + } + }) +} + +// Note: For proper testing of awsutil functions, consider: +// 1. Creating an interface for DynamoDB client operations +// 2. Using that interface in ListTables and SelectTable functions +// 3. Implementing mock clients that satisfy the interface +// +// Example interface: +// type DynamoDBListTablesAPI interface { +// ListTables(ctx context.Context, params *dynamodb.ListTablesInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ListTablesOutput, error) +// } diff --git a/pkg/config/config.go b/pkg/config/config.go index ff38558..fb8e01a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -3,10 +3,9 @@ package config import ( "fmt" + aurora "github.com/logrusorgru/aurora" "github.com/rs/zerolog/log" "github.com/spf13/viper" - - . "github.com/logrusorgru/aurora" ) // Config represents a global configuration @@ -71,11 +70,19 @@ type DynamoDBRenameConfig struct { Rename []RenameAttribute `mapstructure:"rename"` } +// TableBillingConfig represents billing configuration for table creation +type TableBillingConfig struct { + BillingMode string `mapstructure:"billingMode"` // "ON_DEMAND" or "PROVISIONED" + ReadCapacityUnits *int64 `mapstructure:"readCapacityUnits"` + WriteCapacityUnits *int64 `mapstructure:"writeCapacityUnits"` +} + // DynamoDBCopyConfig maps origin and target configs for DynamoDB type DynamoDBCopyConfig struct { - Service string `mapstructure:"service"` - Origin *DynamoDBConfig `mapstructure:"origin"` - Target *DynamoDBConfig `mapstructure:"target"` + Service string `mapstructure:"service"` + Origin *DynamoDBConfig `mapstructure:"origin"` + Target *DynamoDBConfig `mapstructure:"target"` + BillingConfig *TableBillingConfig `mapstructure:"billingConfig"` // Optional: for target table creation } // DynamoDBDumpConfig maps dump configs for DynamoDB @@ -132,5 +139,5 @@ func MustReadCfgFile() { if err := viper.ReadInConfig(); err != nil { log.Fatal().Err(err).Msgf("couldn't read the config file: %s", viper.ConfigFileUsed()) } - fmt.Println(Blue("Config file:" + viper.ConfigFileUsed() + "\n")) + fmt.Println(aurora.Blue("Config file:" + viper.ConfigFileUsed() + "\n")) } diff --git a/pkg/db/copy.go b/pkg/db/copy.go index 420eff8..0263151 100644 --- a/pkg/db/copy.go +++ b/pkg/db/copy.go @@ -1,46 +1,26 @@ package db import ( - "bufio" + "context" "fmt" - "os" "strings" "sync" "sync/atomic" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/daangn/dynamoutil/pkg/config" + aurora "github.com/logrusorgru/aurora" "github.com/rs/zerolog/log" - - . "github.com/logrusorgru/aurora" ) // Copy copy dynamodb items from origin to target table. // This performs BatchGetItems from origin dynamodb table, and // BatchPutItems to target dynamodb table. func Copy(cfg *config.DynamoDBCopyConfig) error { - fmt.Println( - Bold(Green("Origin")), - BrightBlue("region: ").String()+cfg.Origin.Region+" ", - BrightBlue("table: ").String()+cfg.Origin.TableName+" ", - BrightBlue("endpoint: ").String()+cfg.Origin.Endpoint, - ) - fmt.Println( - Bold(Green("Target")), - BrightBlue("region: ").String()+cfg.Target.Region+" ", - BrightBlue("table: ").String()+cfg.Target.TableName+" ", - BrightBlue("endpoint: ").String()+cfg.Target.Endpoint, - ) - - fmt.Printf("\nAre you sure about copying all items from %s? [Y/n] ", BrightBlue(cfg.Origin.TableName)) - yn, _ := bufio.NewReader(os.Stdin).ReadString('\n') - if strings.Trim(yn, "\n") != "Y" { - fmt.Println(Green("Goodbye👋")) - return nil - } - fmt.Print("\n") + ctx := context.TODO() originDB, err := new(cfg.Origin) if err != nil { @@ -51,66 +31,65 @@ func Copy(cfg *config.DynamoDBCopyConfig) error { log.Fatal().Err(err).Msg("Failed to connect to target database. Check .dynamoutil.yaml or target database status") } - oo, err := originDB.DescribeTable(&dynamodb.DescribeTableInput{ + oo, err := originDB.DescribeTable(ctx, &dynamodb.DescribeTableInput{ TableName: &cfg.Origin.TableName, }) if err != nil { log.Fatal().Err(err).Msg("Origin table does not exist") } - _, err = targetDB.DescribeTable(&dynamodb.DescribeTableInput{ + _, err = targetDB.DescribeTable(ctx, &dynamodb.DescribeTableInput{ TableName: &cfg.Target.TableName, }) if err != nil { if strings.Contains(err.Error(), "ResourceNotFoundException") { - fmt.Printf("\nTable does not exist on <%s>.\nDo you want to create %s table at target endpoint?[Y/n] ", - BrightBlue(fmt.Sprintf("%s %s %s", cfg.Target.Region, cfg.Target.TableName, cfg.Target.Endpoint)), - BrightBlue(cfg.Target.TableName), - ) - yn, _ := bufio.NewReader(os.Stdin).ReadString('\n') - if strings.Trim(yn, "\n") != "Y" { - fmt.Println("Goodbye~ 👋") - return nil + // Target table doesn't exist, create it if billing config is provided + if cfg.BillingConfig == nil { + return fmt.Errorf("target table does not exist and no billing configuration provided") } cti := &dynamodb.CreateTableInput{ KeySchema: oo.Table.KeySchema, AttributeDefinitions: oo.Table.AttributeDefinitions, - BillingMode: oo.Table.BillingModeSummary.BillingMode, TableName: &cfg.Target.TableName, } - wcu := oo.Table.ProvisionedThroughput.WriteCapacityUnits - if *wcu < 1 { - wcu = aws.Int64(1) - } - - rcu := oo.Table.ProvisionedThroughput.ReadCapacityUnits - if *rcu < 1 { - rcu = aws.Int64(1) - } - cti.ProvisionedThroughput = &dynamodb.ProvisionedThroughput{ - ReadCapacityUnits: rcu, - WriteCapacityUnits: wcu, + // Set billing mode from configuration + if cfg.BillingConfig.BillingMode == "ON_DEMAND" { + cti.BillingMode = types.BillingModePayPerRequest + } else { + cti.BillingMode = types.BillingModeProvisioned + if cfg.BillingConfig.ReadCapacityUnits != nil && cfg.BillingConfig.WriteCapacityUnits != nil { + cti.ProvisionedThroughput = &types.ProvisionedThroughput{ + ReadCapacityUnits: cfg.BillingConfig.ReadCapacityUnits, + WriteCapacityUnits: cfg.BillingConfig.WriteCapacityUnits, + } + } else { + return fmt.Errorf("provisioned mode requires read and write capacity units") + } } if len(oo.Table.GlobalSecondaryIndexes) > 0 { - var gsi []*dynamodb.GlobalSecondaryIndex + var gsi []types.GlobalSecondaryIndex for _, idx := range oo.Table.GlobalSecondaryIndexes { - gsi = append(gsi, &dynamodb.GlobalSecondaryIndex{ - IndexName: idx.IndexName, - KeySchema: idx.KeySchema, - Projection: idx.Projection, - ProvisionedThroughput: cti.ProvisionedThroughput, - }) + newIdx := types.GlobalSecondaryIndex{ + IndexName: idx.IndexName, + KeySchema: idx.KeySchema, + Projection: idx.Projection, + } + // Only set provisioned throughput for GSI if using PROVISIONED billing mode + if cti.BillingMode == types.BillingModeProvisioned { + newIdx.ProvisionedThroughput = cti.ProvisionedThroughput + } + gsi = append(gsi, newIdx) } cti.GlobalSecondaryIndexes = gsi } if len(oo.Table.LocalSecondaryIndexes) > 0 { - var lsi []*dynamodb.LocalSecondaryIndex + var lsi []types.LocalSecondaryIndex for _, idx := range oo.Table.LocalSecondaryIndexes { - lsi = append(lsi, &dynamodb.LocalSecondaryIndex{ + lsi = append(lsi, types.LocalSecondaryIndex{ IndexName: idx.IndexName, KeySchema: idx.KeySchema, Projection: idx.Projection, @@ -119,17 +98,19 @@ func Copy(cfg *config.DynamoDBCopyConfig) error { cti.LocalSecondaryIndexes = lsi } - _, err := targetDB.CreateTable(cti) + fmt.Printf("Creating target table %s...\n", aurora.BrightBlue(cfg.Target.TableName)) + _, err := targetDB.CreateTable(ctx, cti) if err != nil { - log.Fatal().Err(err).Msg("Failed to create target dynamodb") + return fmt.Errorf("failed to create target table: %w", err) } + fmt.Println(aurora.Green("✓ Target table created successfully")) } else { - log.Fatal().Err(err).Msg("Failed to describe target dynamodb table") + return fmt.Errorf("failed to describe target table: %w", err) } } fmt.Println() - var lastKey map[string]*dynamodb.AttributeValue + var lastKey map[string]types.AttributeValue wg := sync.WaitGroup{} now := time.Now() @@ -138,14 +119,14 @@ func Copy(cfg *config.DynamoDBCopyConfig) error { go func() { for { time.Sleep(time.Millisecond * 100) - fmt.Printf("\r\tTime spent: %.1f. Read %d items, Writes %d items. %.2f items/s", time.Since(now).Seconds(), Blue(readOps), Blue(ops), Blue(float64(ops)/(time.Since(now).Seconds()))) + fmt.Printf("\r\tTime spent: %.1f. Read %d items, Writes %d items. %.2f items/s", time.Since(now).Seconds(), aurora.Blue(readOps), aurora.Blue(ops), aurora.Blue(float64(ops)/(time.Since(now).Seconds()))) } }() for { - o, err := originDB.Scan(&dynamodb.ScanInput{ + o, err := originDB.Scan(ctx, &dynamodb.ScanInput{ TableName: &cfg.Origin.TableName, - Limit: aws.Int64(2500), + Limit: aws.Int32(2500), ExclusiveStartKey: lastKey, }) if err != nil { @@ -154,19 +135,19 @@ func Copy(cfg *config.DynamoDBCopyConfig) error { atomic.AddInt32(&readOps, int32(len(o.Items))) var ( - chunks [][]*dynamodb.WriteRequest - wrs []*dynamodb.WriteRequest + chunks [][]types.WriteRequest + wrs []types.WriteRequest ) cnt := len(o.Items) for i, item := range o.Items { - wrs = append(wrs, &dynamodb.WriteRequest{ - PutRequest: &dynamodb.PutRequest{ + wrs = append(wrs, types.WriteRequest{ + PutRequest: &types.PutRequest{ Item: item, }, }) if (i+1)%25 == 0 || i == cnt-1 { chunks = append(chunks, wrs) - wrs = []*dynamodb.WriteRequest{} + wrs = []types.WriteRequest{} } } @@ -175,7 +156,7 @@ func Copy(cfg *config.DynamoDBCopyConfig) error { defer wg.Done() for _, ch := range chunks { - batchWrite(targetDB, map[string][]*dynamodb.WriteRequest{ + batchWrite(ctx, targetDB, map[string][]types.WriteRequest{ cfg.Target.TableName: ch, }) atomic.AddInt32(&ops, int32(len(ch))) @@ -196,10 +177,10 @@ func Copy(cfg *config.DynamoDBCopyConfig) error { fmt.Print("\n\n") fmt.Printf("Copied %d items of %s table.\nExecution Time: %.2f seconds\nAvg: %.2f ops/s\n", - Green(ops), - BrightBlue(cfg.Origin.TableName), - Green(since.Seconds()), - Green(float64(ops)/since.Seconds()), + aurora.Green(ops), + aurora.BrightBlue(cfg.Origin.TableName), + aurora.Green(since.Seconds()), + aurora.Green(float64(ops)/since.Seconds()), ) return nil } diff --git a/pkg/db/copy_test.go b/pkg/db/copy_test.go new file mode 100644 index 0000000..0496388 --- /dev/null +++ b/pkg/db/copy_test.go @@ -0,0 +1,85 @@ +package db + +import ( + "testing" + + "github.com/daangn/dynamoutil/pkg/config" +) + +func TestCopyValidation(t *testing.T) { + tests := []struct { + name string + cfg *config.DynamoDBCopyConfig + wantErr bool + }{ + { + name: "valid configuration", + cfg: &config.DynamoDBCopyConfig{ + Service: "test-service", + Origin: &config.DynamoDBConfig{ + Region: "us-east-1", + TableName: "origin-table", + }, + Target: &config.DynamoDBConfig{ + Region: "us-west-2", + TableName: "target-table", + }, + }, + wantErr: false, + }, + { + name: "nil origin", + cfg: &config.DynamoDBCopyConfig{ + Service: "test-service", + Target: &config.DynamoDBConfig{ + Region: "us-west-2", + TableName: "target-table", + }, + }, + wantErr: true, + }, + { + name: "nil target", + cfg: &config.DynamoDBCopyConfig{ + Service: "test-service", + Origin: &config.DynamoDBConfig{ + Region: "us-east-1", + TableName: "origin-table", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.cfg.Origin == nil || tt.cfg.Target == nil { + if !tt.wantErr { + t.Error("Expected error for nil origin or target") + } + } + }) + } +} + +func TestCopyChunking(t *testing.T) { + // Test that items are properly chunked into batches of 25 + items := make([]map[string]interface{}, 100) + for i := 0; i < 100; i++ { + items[i] = map[string]interface{}{ + "id": i, + } + } + + chunkSize := 25 + expectedChunks := 4 // 100 items / 25 = 4 chunks + + chunks := 0 + for i := 0; i < len(items); i += chunkSize { + chunks++ + } + + if chunks != expectedChunks { + t.Errorf("Expected %d chunks, got %d", expectedChunks, chunks) + } +} diff --git a/pkg/db/db.go b/pkg/db/db.go index 73d3250..606cd95 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -1,11 +1,14 @@ package db import ( - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/daangn/dynamoutil/pkg/config" + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + dynamoconfig "github.com/daangn/dynamoutil/pkg/config" "github.com/rs/zerolog/log" ) @@ -25,8 +28,8 @@ type SyncDynamoDBConfig struct { Target *DynamoDBConfig } -func batchWrite(db *dynamodb.DynamoDB, r map[string][]*dynamodb.WriteRequest) { - o, err := db.BatchWriteItem(&dynamodb.BatchWriteItemInput{ +func batchWrite(ctx context.Context, db *dynamodb.Client, r map[string][]types.WriteRequest) { + o, err := db.BatchWriteItem(ctx, &dynamodb.BatchWriteItemInput{ RequestItems: r, }) if err != nil { @@ -35,32 +38,62 @@ func batchWrite(db *dynamodb.DynamoDB, r map[string][]*dynamodb.WriteRequest) { for _, v := range o.UnprocessedItems { if len(v) > 0 { - batchWrite(db, o.UnprocessedItems) + batchWrite(ctx, db, o.UnprocessedItems) } } } -func new(cfg *config.DynamoDBConfig) (*dynamodb.DynamoDB, error) { - conf := &aws.Config{} - conf.Region = &cfg.Region +// CreateClient creates a new DynamoDB client with the given configuration +func CreateClient(cfg *dynamoconfig.DynamoDBConfig) (*dynamodb.Client, error) { + return new(cfg) +} - if cfg.Endpoint != "" { - conf.Endpoint = aws.String(cfg.Endpoint) - } +func new(cfg *dynamoconfig.DynamoDBConfig) (*dynamodb.Client, error) { + ctx := context.TODO() + var opts []func(*config.LoadOptions) error + opts = append(opts, config.WithRegion(cfg.Region)) + + // For DynamoDB Local or when static credentials are provided, + // use them directly instead of the default credential chain if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { - cred := credentials.NewCredentials(&credentials.StaticProvider{ - Value: credentials.Value{ - AccessKeyID: cfg.AccessKeyID, - SecretAccessKey: cfg.SecretAccessKey, - }, - }) - conf.WithCredentials(cred) + opts = append(opts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider( + cfg.AccessKeyID, + cfg.SecretAccessKey, + "", + ), + )) } - ss, err := session.NewSession(conf) + awsCfg, err := config.LoadDefaultConfig(ctx, opts...) if err != nil { return nil, err } - return dynamodb.New(ss), nil + + var clientOpts []func(*dynamodb.Options) + if cfg.Endpoint != "" { + // For DynamoDB Local, we need to use a custom endpoint resolver + // that sets both the URL and SigningRegion + customResolver := dynamodb.EndpointResolverFunc(func(region string, options dynamodb.EndpointResolverOptions) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: cfg.Endpoint, + SigningRegion: cfg.Region, + }, nil + }) + clientOpts = append(clientOpts, dynamodb.WithEndpointResolver(customResolver)) + + // Also set credentials directly on the client if provided + if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { + clientOpts = append(clientOpts, func(o *dynamodb.Options) { + o.Credentials = credentials.NewStaticCredentialsProvider( + cfg.AccessKeyID, + cfg.SecretAccessKey, + "", + ) + }) + } + } + + return dynamodb.NewFromConfig(awsCfg, clientOpts...), nil } diff --git a/pkg/db/db_test.go b/pkg/db/db_test.go new file mode 100644 index 0000000..2b36fee --- /dev/null +++ b/pkg/db/db_test.go @@ -0,0 +1,87 @@ +package db + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/daangn/dynamoutil/pkg/config" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + cfg *config.DynamoDBConfig + wantErr bool + }{ + { + name: "valid config with region only", + cfg: &config.DynamoDBConfig{ + Region: "us-east-1", + TableName: "test-table", + }, + wantErr: false, + }, + { + name: "valid config with endpoint", + cfg: &config.DynamoDBConfig{ + Region: "us-east-1", + TableName: "test-table", + Endpoint: "http://localhost:8000", + }, + wantErr: false, + }, + { + name: "valid config with static credentials", + cfg: &config.DynamoDBConfig{ + Region: "us-east-1", + TableName: "test-table", + AccessKeyID: "test-key", + SecretAccessKey: "test-secret", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := new(tt.cfg) + if (err != nil) != tt.wantErr { + t.Errorf("new() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && client == nil { + t.Error("new() returned nil client") + } + }) + } +} + +func TestBatchWriteRecursion(t *testing.T) { + // This test verifies that batchWrite handles unprocessed items correctly + // Note: This is a unit test that would need mocking for full coverage + ctx := context.Background() + + // Create a minimal test to verify the function signature + t.Run("function exists", func(t *testing.T) { + // Just verify the function can be called (will fail without a real client) + // In a real test, we'd use a mock DynamoDB client + defer func() { + if r := recover(); r == nil { + // Function exists and can be called + } + }() + + // This would panic without a valid client, which is expected + // In production tests, use a mock client + var nilClient *dynamodb.Client + batchWrite(ctx, nilClient, map[string][]types.WriteRequest{}) + }) +} + +func TestBatchWriteRetries(t *testing.T) { + // Test that batchWrite properly handles unprocessed items + // This would require a mock implementation in a real test suite + t.Skip("Requires mock DynamoDB client") +} diff --git a/pkg/db/dump.go b/pkg/db/dump.go index 546a3ed..69b2c26 100644 --- a/pkg/db/dump.go +++ b/pkg/db/dump.go @@ -2,35 +2,38 @@ package db import ( "bufio" + "context" "encoding/json" "fmt" "os" "strings" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/daangn/dynamoutil/pkg/config" "github.com/daangn/dynamoutil/pkg/util" + aurora "github.com/logrusorgru/aurora" "github.com/rs/zerolog/log" - - . "github.com/logrusorgru/aurora" ) // Dump make a file func Dump(cfg *config.DynamoDBDumpConfig) error { + ctx := context.TODO() + fmt.Println( - Bold(Green("service: ").String()+cfg.Service+" "), - BrightBlue("region: ").String()+cfg.DynamoDB.Region+" ", - BrightBlue("table: ").String()+cfg.DynamoDB.TableName+" ", - BrightBlue("endpoint: ").String()+cfg.DynamoDB.Endpoint+" ", - BrightBlue("output: ").String()+string(cfg.Output)+" ", + aurora.Bold(aurora.Green("service: ").String()+cfg.Service+" "), + aurora.BrightBlue("region: ").String()+cfg.DynamoDB.Region+" ", + aurora.BrightBlue("table: ").String()+cfg.DynamoDB.TableName+" ", + aurora.BrightBlue("endpoint: ").String()+cfg.DynamoDB.Endpoint+" ", + aurora.BrightBlue("output: ").String()+string(cfg.Output)+" ", ) - fmt.Printf("\nAre you sure about dumping all items from %s? [Y/n] ", BrightBlue(cfg.DynamoDB.TableName)) + fmt.Printf("\nAre you sure about dumping all items from %s? [Y/n] ", aurora.BrightBlue(cfg.DynamoDB.TableName)) yn, _ := bufio.NewReader(os.Stdin).ReadString('\n') if strings.Trim(yn, "\n") != "Y" { - fmt.Println(Green("Goodbye👋")) + fmt.Println(aurora.Green("Goodbye👋")) return nil } fmt.Print("\n") @@ -55,16 +58,16 @@ func Dump(cfg *config.DynamoDBDumpConfig) error { go func() { for { time.Sleep(time.Millisecond * 100) - fmt.Printf("\r Writes %d items. %.2f items/s", Blue(ops), Blue(float64(ops)/(time.Since(now).Seconds()))) + fmt.Printf("\r Writes %d items. %.2f items/s", aurora.Blue(ops), aurora.Blue(float64(ops)/(time.Since(now).Seconds()))) } }() file.Write(cfg.Output.DumpPrefix()) - var lastKey map[string]*dynamodb.AttributeValue + var lastKey map[string]types.AttributeValue for { - o, err := remoteDB.Scan(&dynamodb.ScanInput{ + o, err := remoteDB.Scan(ctx, &dynamodb.ScanInput{ TableName: &cfg.DynamoDB.TableName, - Limit: aws.Int64(10000), + Limit: aws.Int32(10000), ExclusiveStartKey: lastKey, }) if err != nil { diff --git a/pkg/db/dump_test.go b/pkg/db/dump_test.go new file mode 100644 index 0000000..f82d6eb --- /dev/null +++ b/pkg/db/dump_test.go @@ -0,0 +1,116 @@ +package db + +import ( + "os" + "testing" + + "github.com/daangn/dynamoutil/pkg/config" +) + +func TestDumpOutputFormats(t *testing.T) { + tests := []struct { + name string + output config.Output + wantEmpty bool + }{ + { + name: "json output", + output: config.OutputJSON, + wantEmpty: false, + }, + { + name: "json raw output", + output: config.OutputJSONRaw, + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix := tt.output.DumpPrefix() + delimiter := tt.output.DumpDelimiter() + suffix := tt.output.DumpSuffix() + + if tt.wantEmpty { + if len(prefix) != 0 || len(suffix) != 0 { + t.Errorf("Expected empty prefix/suffix for %s", tt.name) + } + if string(delimiter) != "\n" { + t.Errorf("Expected newline delimiter for %s", tt.name) + } + } else { + if len(prefix) == 0 || len(suffix) == 0 { + t.Errorf("Expected non-empty prefix/suffix for %s", tt.name) + } + if string(delimiter) != "," { + t.Errorf("Expected comma delimiter for %s", tt.name) + } + } + }) + } +} + +func TestDumpConfigValidation(t *testing.T) { + tests := []struct { + name string + cfg *config.DynamoDBDumpConfig + wantErr bool + }{ + { + name: "valid configuration", + cfg: &config.DynamoDBDumpConfig{ + Service: "test-service", + FileName: "test-dump.json", + Output: config.OutputJSON, + DynamoDB: config.DynamoDBConfig{ + Region: "us-east-1", + TableName: "test-table", + }, + }, + wantErr: false, + }, + { + name: "empty filename", + cfg: &config.DynamoDBDumpConfig{ + Service: "test-service", + Output: config.OutputJSON, + DynamoDB: config.DynamoDBConfig{ + Region: "us-east-1", + TableName: "test-table", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.cfg.FileName == "" && !tt.wantErr { + t.Error("Expected error for empty filename") + } + }) + } +} + +func TestDumpFileCreation(t *testing.T) { + // Test that dump can create output files + tmpFile := "/tmp/test-dump.json" + defer os.Remove(tmpFile) + + file, err := os.Create(tmpFile) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + defer file.Close() + + // Write test data + output := config.OutputJSON + file.Write(output.DumpPrefix()) + file.Write([]byte(`{"id":{"S":"test"}}`)) + file.Write(output.DumpSuffix()) + + // Verify file exists + if _, err := os.Stat(tmpFile); os.IsNotExist(err) { + t.Error("Output file was not created") + } +} diff --git a/pkg/db/integration_test.go b/pkg/db/integration_test.go new file mode 100644 index 0000000..e6d1b09 --- /dev/null +++ b/pkg/db/integration_test.go @@ -0,0 +1,349 @@ +//go:build integration +// +build integration + +package db + +import ( + "context" + "fmt" + "math/rand" + "os" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/daangn/dynamoutil/pkg/config" +) + +const ( + testEndpoint = "http://localhost:8000" + testRegion = "us-east-1" + // DynamoDB Local 2.0+ requires alphanumeric-only credentials (no special chars) + testAccessKeyID = "testaccesskeyid" + testSecretAccessKey = "testsecretaccesskey" +) + +// generateRandomTableName creates a unique table name with random suffix +func generateRandomTableName(prefix string) string { + rand.Seed(time.Now().UnixNano()) + suffix := rand.Intn(999999) + return fmt.Sprintf("%s-%d", prefix, suffix) +} + +// setupTestTable creates a test DynamoDB table for integration tests +func setupTestTable(t *testing.T, tableName string) *dynamodb.Client { + cfg := &config.DynamoDBConfig{ + Region: testRegion, + TableName: tableName, + Endpoint: testEndpoint, + AccessKeyID: testAccessKeyID, + SecretAccessKey: testSecretAccessKey, + } + + client, err := new(cfg) + if err != nil { + t.Fatalf("Failed to create DynamoDB client: %v", err) + } + + ctx := context.Background() + + // Create table + _, err = client.CreateTable(ctx, &dynamodb.CreateTableInput{ + TableName: aws.String(tableName), + KeySchema: []types.KeySchemaElement{ + { + AttributeName: aws.String("id"), + KeyType: types.KeyTypeHash, + }, + }, + AttributeDefinitions: []types.AttributeDefinition{ + { + AttributeName: aws.String("id"), + AttributeType: types.ScalarAttributeTypeS, + }, + }, + BillingMode: types.BillingModePayPerRequest, + }) + + if err != nil { + t.Fatalf("Failed to create table: %v", err) + } + + // Wait for table to be active + time.Sleep(2 * time.Second) + + return client +} + +// teardownTestTable deletes the test table +func teardownTestTable(t *testing.T, client *dynamodb.Client, tableName string) { + ctx := context.Background() + _, err := client.DeleteTable(ctx, &dynamodb.DeleteTableInput{ + TableName: aws.String(tableName), + }) + if err != nil { + t.Logf("Warning: Failed to delete table: %v", err) + } +} + +// populateTestData adds test items to a table +func populateTestData(t *testing.T, client *dynamodb.Client, tableName string, count int) { + ctx := context.Background() + + for i := 0; i < count; i++ { + item := map[string]types.AttributeValue{ + "id": &types.AttributeValueMemberS{ + Value: fmt.Sprintf("test-id-%d", i), + }, + "name": &types.AttributeValueMemberS{ + Value: fmt.Sprintf("Test Name %d", i), + }, + "value": &types.AttributeValueMemberN{ + Value: fmt.Sprintf("%d", i*100), + }, + } + + _, err := client.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(tableName), + Item: item, + }) + + if err != nil { + t.Fatalf("Failed to put item: %v", err) + } + } +} + +func TestIntegrationCopy(t *testing.T) { + if os.Getenv("DYNAMODB_ENDPOINT") == "" { + t.Skip("Skipping integration test: DYNAMODB_ENDPOINT not set") + } + + originTable := generateRandomTableName("test-origin-table") + targetTable := generateRandomTableName("test-target-table") + + t.Logf("Using origin table: %s", originTable) + t.Logf("Using target table: %s", targetTable) + + // Setup + originClient := setupTestTable(t, originTable) + targetClient := setupTestTable(t, targetTable) + defer teardownTestTable(t, originClient, originTable) + defer teardownTestTable(t, targetClient, targetTable) + + // Populate origin table with test data + itemCount := 50 + populateTestData(t, originClient, originTable, itemCount) + + // Create copy configuration + cfg := &config.DynamoDBCopyConfig{ + Service: "integration-test", + Origin: &config.DynamoDBConfig{ + Region: testRegion, + TableName: originTable, + Endpoint: testEndpoint, + AccessKeyID: testAccessKeyID, + SecretAccessKey: testSecretAccessKey, + }, + Target: &config.DynamoDBConfig{ + Region: testRegion, + TableName: targetTable, + Endpoint: testEndpoint, + AccessKeyID: testAccessKeyID, + SecretAccessKey: testSecretAccessKey, + }, + } + + // Note: Copy() requires user input, so we test the underlying logic + t.Run("verify origin data", func(t *testing.T) { + ctx := context.Background() + result, err := originClient.Scan(ctx, &dynamodb.ScanInput{ + TableName: aws.String(originTable), + }) + + if err != nil { + t.Fatalf("Failed to scan origin table: %v", err) + } + + if len(result.Items) != itemCount { + t.Errorf("Expected %d items in origin, got %d", itemCount, len(result.Items)) + } + }) + + t.Run("copy items manually", func(t *testing.T) { + ctx := context.Background() + + // Scan origin + result, err := originClient.Scan(ctx, &dynamodb.ScanInput{ + TableName: aws.String(originTable), + }) + if err != nil { + t.Fatalf("Failed to scan origin: %v", err) + } + + // Copy items in batches + var writeRequests []types.WriteRequest + for _, item := range result.Items { + writeRequests = append(writeRequests, types.WriteRequest{ + PutRequest: &types.PutRequest{ + Item: item, + }, + }) + + // Batch write when we have 25 items + if len(writeRequests) == 25 { + batchWrite(ctx, targetClient, map[string][]types.WriteRequest{ + targetTable: writeRequests, + }) + writeRequests = []types.WriteRequest{} + } + } + + // Write remaining items + if len(writeRequests) > 0 { + batchWrite(ctx, targetClient, map[string][]types.WriteRequest{ + targetTable: writeRequests, + }) + } + + // Verify target + targetResult, err := targetClient.Scan(ctx, &dynamodb.ScanInput{ + TableName: aws.String(targetTable), + }) + if err != nil { + t.Fatalf("Failed to scan target: %v", err) + } + + if len(targetResult.Items) != itemCount { + t.Errorf("Expected %d items in target, got %d", itemCount, len(targetResult.Items)) + } + }) + + _ = cfg // Use cfg to avoid unused variable warning +} + +func TestIntegrationDump(t *testing.T) { + if os.Getenv("DYNAMODB_ENDPOINT") == "" { + t.Skip("Skipping integration test: DYNAMODB_ENDPOINT not set") + } + + tableName := generateRandomTableName("test-dump-table") + t.Logf("Using dump table: %s", tableName) + + // Setup + client := setupTestTable(t, tableName) + defer teardownTestTable(t, client, tableName) + + // Populate with test data + itemCount := 30 + populateTestData(t, client, tableName, itemCount) + + t.Run("dump to file", func(t *testing.T) { + tmpFile := "/tmp/test-dump-integration.json" + defer os.Remove(tmpFile) + + cfg := &config.DynamoDBDumpConfig{ + Service: "integration-test", + FileName: tmpFile, + Output: config.OutputJSON, + DynamoDB: config.DynamoDBConfig{ + Region: testRegion, + TableName: tableName, + Endpoint: testEndpoint, + AccessKeyID: testAccessKeyID, + SecretAccessKey: testSecretAccessKey, + }, + } + + // Create and write to file + file, err := os.Create(cfg.FileName) + if err != nil { + t.Fatalf("Failed to create dump file: %v", err) + } + defer file.Close() + + ctx := context.Background() + file.Write(cfg.Output.DumpPrefix()) + + result, err := client.Scan(ctx, &dynamodb.ScanInput{ + TableName: aws.String(tableName), + }) + if err != nil { + t.Fatalf("Failed to scan table: %v", err) + } + + for i, item := range result.Items { + // In real implementation, items would be marshaled properly + _ = item + if i > 0 { + file.Write(cfg.Output.DumpDelimiter()) + } + } + + file.Write(cfg.Output.DumpSuffix()) + + // Verify file exists and has content + info, err := os.Stat(tmpFile) + if err != nil { + t.Fatalf("Failed to stat dump file: %v", err) + } + + if info.Size() == 0 { + t.Error("Dump file is empty") + } + }) +} + +func TestIntegrationBatchWrite(t *testing.T) { + if os.Getenv("DYNAMODB_ENDPOINT") == "" { + t.Skip("Skipping integration test: DYNAMODB_ENDPOINT not set") + } + + tableName := generateRandomTableName("test-batch-write-table") + t.Logf("Using batch write table: %s", tableName) + + client := setupTestTable(t, tableName) + defer teardownTestTable(t, client, tableName) + + t.Run("batch write with unprocessed items", func(t *testing.T) { + ctx := context.Background() + + // Create batch of items + var writeRequests []types.WriteRequest + for i := 0; i < 10; i++ { + item := map[string]types.AttributeValue{ + "id": &types.AttributeValueMemberS{ + Value: fmt.Sprintf("batch-test-%d", i), + }, + "data": &types.AttributeValueMemberS{ + Value: fmt.Sprintf("Batch data %d", i), + }, + } + + writeRequests = append(writeRequests, types.WriteRequest{ + PutRequest: &types.PutRequest{ + Item: item, + }, + }) + } + + // Test batchWrite function + batchWrite(ctx, client, map[string][]types.WriteRequest{ + tableName: writeRequests, + }) + + // Verify items were written + result, err := client.Scan(ctx, &dynamodb.ScanInput{ + TableName: aws.String(tableName), + }) + if err != nil { + t.Fatalf("Failed to scan after batch write: %v", err) + } + + if len(result.Items) != 10 { + t.Errorf("Expected 10 items after batch write, got %d", len(result.Items)) + } + }) +} diff --git a/pkg/db/rename.go b/pkg/db/rename.go index 9f5e54f..f3987c5 100644 --- a/pkg/db/rename.go +++ b/pkg/db/rename.go @@ -2,6 +2,7 @@ package db import ( "bufio" + "context" "fmt" "os" "strings" @@ -9,12 +10,12 @@ import ( "sync/atomic" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/daangn/dynamoutil/pkg/config" + aurora "github.com/logrusorgru/aurora" "github.com/rs/zerolog/log" - - . "github.com/logrusorgru/aurora" ) // RenameMetrics holds metrics for each rename operation. @@ -25,17 +26,19 @@ type renameMetrics struct { // Rename reads before-after pairs from the YAML file and renames attributes in a DynamoDB table. func Rename(cfg *config.DynamoDBRenameConfig) error { + ctx := context.TODO() + fmt.Println( - Bold(Green("Target")), - BrightBlue("region: ").String()+cfg.Target.Region+" ", - BrightBlue("table: ").String()+cfg.Target.TableName+" ", - BrightBlue("endpoint: ").String()+cfg.Target.Endpoint, + aurora.Bold(aurora.Green("Target")), + aurora.BrightBlue("region: ").String()+cfg.Target.Region+" ", + aurora.BrightBlue("table: ").String()+cfg.Target.TableName+" ", + aurora.BrightBlue("endpoint: ").String()+cfg.Target.Endpoint, ) - fmt.Printf("\nAre you sure about renaming attributes in %s? [Y/n] ", BrightBlue(cfg.Target.TableName)) + fmt.Printf("\nAre you sure about renaming attributes in %s? [Y/n] ", aurora.BrightBlue(cfg.Target.TableName)) yn, _ := bufio.NewReader(os.Stdin).ReadString('\n') if strings.Trim(yn, "\n") != "Y" { - fmt.Println(Green("Goodbye👋")) + fmt.Println(aurora.Green("Goodbye👋")) return nil } fmt.Print("\n") @@ -45,7 +48,7 @@ func Rename(cfg *config.DynamoDBRenameConfig) error { log.Fatal().Err(err).Msg("Failed to connect to target database. Check .dynamoutil.yaml or target database status") } - oo, err := targetDB.DescribeTable(&dynamodb.DescribeTableInput{ + oo, err := targetDB.DescribeTable(ctx, &dynamodb.DescribeTableInput{ TableName: &cfg.Target.TableName, }) if err != nil { @@ -55,16 +58,16 @@ func Rename(cfg *config.DynamoDBRenameConfig) error { // Extract the primary key attributes from the KeySchema var partitionKey, sortKey string for _, keyElement := range oo.Table.KeySchema { - if *keyElement.KeyType == "HASH" { + if keyElement.KeyType == types.KeyTypeHash { partitionKey = *keyElement.AttributeName - } else if *keyElement.KeyType == "RANGE" { + } else if keyElement.KeyType == types.KeyTypeRange { sortKey = *keyElement.AttributeName } } fmt.Printf("Partition Key: %s, Sort Key: %s\n", partitionKey, sortKey) fmt.Println() - var lastKey map[string]*dynamodb.AttributeValue + var lastKey map[string]types.AttributeValue // Metrics for each rename operation metrics := make(map[string]*renameMetrics) @@ -81,15 +84,15 @@ func Rename(cfg *config.DynamoDBRenameConfig) error { go func() { for { time.Sleep(time.Millisecond * 100) - fmt.Printf("\r\tTime spent: %.1f. Read %d items, Processed %d items. %.2f items/s", time.Since(now).Seconds(), Blue(readOps), Blue(ops), Blue(float64(ops)/(time.Since(now).Seconds()))) + fmt.Printf("\r\tTime spent: %.1f. Read %d items, Processed %d items. %.2f items/s", time.Since(now).Seconds(), aurora.Blue(readOps), aurora.Blue(ops), aurora.Blue(float64(ops)/(time.Since(now).Seconds()))) } }() // Scan and process items for { - o, err := targetDB.Scan(&dynamodb.ScanInput{ + o, err := targetDB.Scan(ctx, &dynamodb.ScanInput{ TableName: &cfg.Target.TableName, - Limit: aws.Int64(2500), + Limit: aws.Int32(2500), ExclusiveStartKey: lastKey, }) if err != nil { @@ -98,10 +101,10 @@ func Rename(cfg *config.DynamoDBRenameConfig) error { atomic.AddInt32(&readOps, int32(len(o.Items))) var ( - deleteChunks [][]*dynamodb.WriteRequest - putChunks [][]*dynamodb.WriteRequest - deleteWrs []*dynamodb.WriteRequest - putWrs []*dynamodb.WriteRequest + deleteChunks [][]types.WriteRequest + putChunks [][]types.WriteRequest + deleteWrs []types.WriteRequest + putWrs []types.WriteRequest ) for _, item := range o.Items { @@ -126,33 +129,33 @@ func Rename(cfg *config.DynamoDBRenameConfig) error { atomic.AddInt32(&ops, 1) - key := map[string]*dynamodb.AttributeValue{ + key := map[string]types.AttributeValue{ partitionKey: item[partitionKey], sortKey: item[sortKey], } // Prepare DeleteRequest and PutRequest - deleteRequest := &dynamodb.DeleteRequest{Key: key} - putRequest := &dynamodb.PutRequest{Item: item} + deleteRequest := &types.DeleteRequest{Key: key} + putRequest := &types.PutRequest{Item: item} // Add the Delete request to deleteWrs - deleteWrs = append(deleteWrs, &dynamodb.WriteRequest{ + deleteWrs = append(deleteWrs, types.WriteRequest{ DeleteRequest: deleteRequest, }) // Add the Put request to putWrs - putWrs = append(putWrs, &dynamodb.WriteRequest{ + putWrs = append(putWrs, types.WriteRequest{ PutRequest: putRequest, }) // Batch in chunks of 25 requests as DynamoDB limits if len(deleteWrs) >= 25 { deleteChunks = append(deleteChunks, deleteWrs) - deleteWrs = []*dynamodb.WriteRequest{} + deleteWrs = []types.WriteRequest{} } if len(putWrs) >= 25 { putChunks = append(putChunks, putWrs) - putWrs = []*dynamodb.WriteRequest{} + putWrs = []types.WriteRequest{} } // Record time taken for renaming this item @@ -173,9 +176,9 @@ func Rename(cfg *config.DynamoDBRenameConfig) error { // Process delete requests for _, chunk := range deleteChunks { wg.Add(1) - go func(chunk []*dynamodb.WriteRequest) { + go func(chunk []types.WriteRequest) { defer wg.Done() - batchWrite(targetDB, map[string][]*dynamodb.WriteRequest{ + batchWrite(ctx, targetDB, map[string][]types.WriteRequest{ cfg.Target.TableName: chunk, }) }(chunk) @@ -187,9 +190,9 @@ func Rename(cfg *config.DynamoDBRenameConfig) error { // Process put requests for _, chunk := range putChunks { wg.Add(1) - go func(chunk []*dynamodb.WriteRequest) { + go func(chunk []types.WriteRequest) { defer wg.Done() - batchWrite(targetDB, map[string][]*dynamodb.WriteRequest{ + batchWrite(ctx, targetDB, map[string][]types.WriteRequest{ cfg.Target.TableName: chunk, }) }(chunk) @@ -209,26 +212,26 @@ func Rename(cfg *config.DynamoDBRenameConfig) error { fmt.Print("\n\n") fmt.Printf("Renamed %d items of %s table.\nExecution Time: %.2f seconds\nAvg: %.2f ops/s\n", - Green(ops), - BrightBlue(cfg.Target.TableName), - Green(since.Seconds()), - Green(float64(ops)/since.Seconds()), + aurora.Green(ops), + aurora.BrightBlue(cfg.Target.TableName), + aurora.Green(since.Seconds()), + aurora.Green(float64(ops)/since.Seconds()), ) // Print metrics for each rename operation fmt.Println("\nDetailed Rename Metrics:") for key, metric := range metrics { if metric.Count == 0 { - fmt.Printf("%s: No items changed\n", BrightBlue(key)) + fmt.Printf("%s: No items changed\n", aurora.BrightBlue(key)) continue } avgTime := metric.Duration.Seconds() / float64(metric.Count) fmt.Printf("%s: %d items changed, Total Time: %.2f seconds, Avg Time per item: %.4f seconds\n", - BrightBlue(key), - Green(metric.Count), - Green(metric.Duration.Seconds()), - Green(avgTime), + aurora.BrightBlue(key), + aurora.Green(metric.Count), + aurora.Green(metric.Duration.Seconds()), + aurora.Green(avgTime), ) } diff --git a/pkg/display/display.go b/pkg/display/display.go new file mode 100644 index 0000000..d629fb5 --- /dev/null +++ b/pkg/display/display.go @@ -0,0 +1,77 @@ +package display + +import ( + "fmt" + "time" + + "github.com/briandowns/spinner" + aurora "github.com/logrusorgru/aurora" +) + +// Banner displays the application banner +func Banner() { + banner := ` +╔══════════════════════════════════════════════════════════╗ +║ ║ +║ ____ _ _ _ ║ +║ | _ \ _ _ _ __ __ _ _ __ ___ | | | | |_ ___ ║ +║ | | | | | | | '_ \ / _' | '_ ' _ \ | | | | __| | | | ║ +║ | |_| | |_| | | | | (_| | | | | | | | |_| | |_ | | | ║ +║ |____/ \__, |_| |_|\__,_|_| |_| |_| \___/ \__|_|_|_| ║ +║ |___/ ║ +║ ║ +║ 🚀 DynamoDB Utilities - Interactive CLI 🚀 ║ +║ ║ +╚══════════════════════════════════════════════════════════╝ +` + fmt.Println(aurora.Cyan(banner)) +} + +// Spinner creates and returns a new spinner with the given message +func Spinner(message string) *spinner.Spinner { + s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + s.Suffix = " " + message + s.Color("cyan") + s.Start() + return s +} + +// Success prints a success message with a checkmark +func Success(message string) { + fmt.Printf("%s %s\n", aurora.Green("✓"), aurora.Bold(message)) +} + +// Error prints an error message with an X +func Error(message string) { + fmt.Printf("%s %s\n", aurora.Red("✗"), aurora.Bold(message)) +} + +// Info prints an info message with an info icon +func Info(message string) { + fmt.Printf("%s %s\n", aurora.Blue("ℹ"), message) +} + +// Warning prints a warning message with a warning icon +func Warning(message string) { + fmt.Printf("%s %s\n", aurora.Yellow("⚠"), message) +} + +// Header prints a section header +func Header(message string) { + fmt.Println() + fmt.Println(aurora.Bold(aurora.Cyan("═══ " + message + " ═══"))) + fmt.Println() +} + +// Summary prints a summary with key-value pairs +func Summary(title string, items map[string]string) { + fmt.Println() + fmt.Println(aurora.Bold(aurora.Cyan("═══ " + title + " ═══"))) + for key, value := range items { + fmt.Printf(" %s: %s\n", + aurora.Bold(key), + aurora.BrightBlue(value), + ) + } + fmt.Println() +} diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go new file mode 100644 index 0000000..124a4be --- /dev/null +++ b/pkg/display/display_test.go @@ -0,0 +1,130 @@ +package display + +import ( + "bytes" + "io" + "os" + "strings" + "testing" +) + +// captureOutput captures stdout during function execution +func captureOutput(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} + +func TestBanner(t *testing.T) { + output := captureOutput(func() { + Banner() + }) + + if !strings.Contains(output, "DynamoDB Utilities") { + t.Error("Banner should contain 'DynamoDB Utilities'") + } + if !strings.Contains(output, "Interactive CLI") { + t.Error("Banner should contain 'Interactive CLI'") + } +} + +func TestSuccess(t *testing.T) { + output := captureOutput(func() { + Success("Test message") + }) + + if !strings.Contains(output, "Test message") { + t.Error("Success should output the message") + } + if !strings.Contains(output, "✓") { + t.Error("Success should contain checkmark") + } +} + +func TestError(t *testing.T) { + output := captureOutput(func() { + Error("Error message") + }) + + if !strings.Contains(output, "Error message") { + t.Error("Error should output the message") + } + if !strings.Contains(output, "✗") { + t.Error("Error should contain X mark") + } +} + +func TestInfo(t *testing.T) { + output := captureOutput(func() { + Info("Info message") + }) + + if !strings.Contains(output, "Info message") { + t.Error("Info should output the message") + } + if !strings.Contains(output, "ℹ") { + t.Error("Info should contain info icon") + } +} + +func TestWarning(t *testing.T) { + output := captureOutput(func() { + Warning("Warning message") + }) + + if !strings.Contains(output, "Warning message") { + t.Error("Warning should output the message") + } + if !strings.Contains(output, "⚠") { + t.Error("Warning should contain warning icon") + } +} + +func TestHeader(t *testing.T) { + output := captureOutput(func() { + Header("Test Header") + }) + + if !strings.Contains(output, "Test Header") { + t.Error("Header should output the message") + } + if !strings.Contains(output, "═══") { + t.Error("Header should contain decoration") + } +} + +func TestSummary(t *testing.T) { + output := captureOutput(func() { + Summary("Test Summary", map[string]string{ + "Key1": "Value1", + "Key2": "Value2", + }) + }) + + if !strings.Contains(output, "Test Summary") { + t.Error("Summary should contain title") + } + if !strings.Contains(output, "Key1") || !strings.Contains(output, "Value1") { + t.Error("Summary should contain key-value pairs") + } + if !strings.Contains(output, "Key2") || !strings.Contains(output, "Value2") { + t.Error("Summary should contain all key-value pairs") + } +} + +func TestSpinner(t *testing.T) { + s := Spinner("Loading...") + if s == nil { + t.Error("Spinner should not be nil") + } + s.Stop() +} diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go new file mode 100644 index 0000000..15e9a87 --- /dev/null +++ b/pkg/prompt/prompt.go @@ -0,0 +1,326 @@ +package prompt + +import ( + "fmt" + "strings" + + aurora "github.com/logrusorgru/aurora" + "github.com/manifoldco/promptui" +) + +// Confirm prompts the user for yes/no confirmation with a colorful interface +func Confirm(message string) bool { + // Add helpful hint about default + templates := &promptui.PromptTemplates{ + Prompt: "{{ . }} ", + Valid: "{{ . | green }} ", + Invalid: "{{ . | red }} ", + Success: "{{ . | bold }} ", + } + + prompt := promptui.Prompt{ + Label: aurora.Sprintf("%s %s", aurora.Bold(aurora.Cyan(message)), aurora.Gray(12, "[Y/n]")), + IsConfirm: true, + Templates: templates, + } + + result, err := prompt.Run() + if err != nil { + return false + } + + result = strings.ToLower(strings.TrimSpace(result)) + return result == "y" || result == "yes" || result == "" +} + +// Input prompts the user for text input with default value shown +func Input(label string, defaultValue string) (string, error) { + // Show default value in gray if provided + displayLabel := aurora.Bold(aurora.Cyan(label)).String() + if defaultValue != "" { + displayLabel = fmt.Sprintf("%s %s", + aurora.Bold(aurora.Cyan(label)), + aurora.Gray(12, fmt.Sprintf("[%s]", defaultValue))) + } + + templates := &promptui.PromptTemplates{ + Prompt: "{{ . }} ", + Valid: "{{ . | green }} ", + Invalid: "{{ . | red }} ", + Success: "{{ . | bold }} ", + } + + prompt := promptui.Prompt{ + Label: displayLabel, + Default: defaultValue, + Templates: templates, + } + + result, err := prompt.Run() + if err != nil { + return "", err + } + + return strings.TrimSpace(result), nil +} + +// Select prompts the user to select from a list of options with arrow keys +func Select(label string, items []string) (int, string, error) { + // Add helpful instructions + fmt.Println(aurora.Gray(12, "Use arrow keys ↑↓ to navigate, Enter to select")) + + templates := &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: aurora.Sprintf("%s {{ . | cyan | bold }}", promptui.IconSelect), + Inactive: " {{ . }}", + Selected: aurora.Sprintf("%s {{ . | green | bold }}", promptui.IconGood), + Details: ` +{{ "Selection:" | faint }} {{ . }}`, + } + + // Set size based on number of items (max 15) + size := len(items) + if size > 15 { + size = 15 + } else if size < 5 { + size = 5 + } + + prompt := promptui.Select{ + Label: aurora.Bold(aurora.Cyan(label)).String(), + Items: items, + Size: size, + Templates: templates, + HideHelp: false, + } + + return prompt.Run() +} + +// MultiSelect allows selecting multiple items from a list +// Uses a checkbox-style interface where users can toggle selections by number +func MultiSelect(label string, options []string) ([]string, error) { + if len(options) == 0 { + return []string{}, nil + } + + selected := make(map[int]bool) + + // Helper function to display the menu + displayMenu := func() { + // Clear screen for better UX + fmt.Print("\033[H\033[2J") + + // Header + fmt.Println(aurora.Bold(aurora.Cyan("╔══════════════════════════════════════════════════════════╗"))) + fmt.Printf("%s %s\n", + aurora.Bold(aurora.Cyan("║")), + aurora.Bold(aurora.White(label))) + fmt.Println(aurora.Bold(aurora.Cyan("╚══════════════════════════════════════════════════════════╝"))) + fmt.Println() + + // Instructions + fmt.Println(aurora.Gray(12, "Instructions:")) + fmt.Println(aurora.Gray(12, " • Enter numbers to toggle selection (e.g., '1' or '1,3,5')")) + fmt.Println(aurora.Gray(12, " • Press Enter (empty) when finished")) + fmt.Println(aurora.Gray(12, " • Type 'all' to select all, 'none' to clear all")) + fmt.Println() + + // Show options with checkboxes + for i, opt := range options { + var checkbox, itemText string + if selected[i] { + checkbox = aurora.Green("✓").String() + itemText = aurora.Bold(aurora.Green(opt)).String() + } else { + checkbox = aurora.Gray(12, "○").String() + itemText = aurora.Gray(14, opt).String() + } + fmt.Printf(" [%s] %s. %s\n", checkbox, aurora.Cyan(fmt.Sprintf("%2d", i+1)), itemText) + } + fmt.Println() + + // Show current selection count + selectedCount := 0 + for range selected { + selectedCount++ + } + if selectedCount > 0 { + fmt.Printf("%s %d items selected\n\n", + aurora.Blue("ℹ"), + selectedCount) + } + } + + for { + displayMenu() + + templates := &promptui.PromptTemplates{ + Prompt: "{{ . }} ", + Valid: "{{ . | green }} ", + Invalid: "{{ . | red }} ", + Success: "{{ . | bold }} ", + } + + p := promptui.Prompt{ + Label: aurora.Cyan("Selection").String(), + Templates: templates, + } + + input, err := p.Run() + if err != nil { + return nil, err + } + + input = strings.TrimSpace(strings.ToLower(input)) + + // Empty input means done + if input == "" { + break + } + + // Handle special commands + if input == "all" { + for i := range options { + selected[i] = true + } + continue + } + + if input == "none" || input == "clear" { + selected = make(map[int]bool) + continue + } + + // Parse comma-separated numbers + numbers := strings.Split(input, ",") + hasError := false + + for _, numStr := range numbers { + numStr = strings.TrimSpace(numStr) + if numStr == "" { + continue + } + + var idx int + _, err := fmt.Sscanf(numStr, "%d", &idx) + if err != nil || idx < 1 || idx > len(options) { + fmt.Println(aurora.Yellow(fmt.Sprintf("⚠ Invalid selection: '%s' (valid range: 1-%d)", numStr, len(options)))) + hasError = true + continue + } + + // Toggle selection + selected[idx-1] = !selected[idx-1] + } + + if hasError { + fmt.Println(aurora.Gray(12, "Press Enter to continue...")) + fmt.Scanln() + } + } + + // Build result from selected items + var result []string + for i, opt := range options { + if selected[i] { + result = append(result, opt) + } + } + + // Clear screen and show final selection + fmt.Print("\033[H\033[2J") + if len(result) > 0 { + fmt.Printf("%s Selected %d items\n", aurora.Green("✓"), len(result)) + } + + return result, nil +} + +// InputWithValidation prompts for input with a validation function +func InputWithValidation(label string, defaultValue string, validate func(string) error) (string, error) { + // Show default value in gray if provided + displayLabel := aurora.Bold(aurora.Cyan(label)).String() + if defaultValue != "" { + displayLabel = fmt.Sprintf("%s %s", + aurora.Bold(aurora.Cyan(label)), + aurora.Gray(12, fmt.Sprintf("[%s]", defaultValue))) + } + + templates := &promptui.PromptTemplates{ + Prompt: "{{ . }} ", + Valid: "{{ . | green }} ", + Invalid: "{{ . | red }} ", + Success: "{{ . | bold }} ", + } + + prompt := promptui.Prompt{ + Label: displayLabel, + Default: defaultValue, + Templates: templates, + Validate: func(input string) error { + if validate != nil { + return validate(input) + } + return nil + }, + } + + result, err := prompt.Run() + if err != nil { + return "", err + } + + return strings.TrimSpace(result), nil +} + +// InputInt prompts for an integer input with optional range validation +func InputInt(label string, defaultValue int, min, max *int) (int, error) { + defaultStr := fmt.Sprintf("%d", defaultValue) + if min != nil && max != nil { + label = fmt.Sprintf("%s (range: %d-%d)", label, *min, *max) + } else if min != nil { + label = fmt.Sprintf("%s (min: %d)", label, *min) + } else if max != nil { + label = fmt.Sprintf("%s (max: %d)", label, *max) + } + + validate := func(input string) error { + var num int + _, err := fmt.Sscanf(input, "%d", &num) + if err != nil { + return fmt.Errorf("please enter a valid number") + } + + if min != nil && num < *min { + return fmt.Errorf("value must be at least %d", *min) + } + + if max != nil && num > *max { + return fmt.Errorf("value must be at most %d", *max) + } + + return nil + } + + result, err := InputWithValidation(label, defaultStr, validate) + if err != nil { + return 0, err + } + + var num int + fmt.Sscanf(result, "%d", &num) + return num, nil +} + +// InputRequired prompts for required (non-empty) input +func InputRequired(label string) (string, error) { + validate := func(input string) error { + if strings.TrimSpace(input) == "" { + return fmt.Errorf("this field is required") + } + return nil + } + + return InputWithValidation(label, "", validate) +} diff --git a/pkg/prompt/prompt_test.go b/pkg/prompt/prompt_test.go new file mode 100644 index 0000000..12d51d5 --- /dev/null +++ b/pkg/prompt/prompt_test.go @@ -0,0 +1,82 @@ +package prompt + +import ( + "reflect" + "testing" +) + +// TestConfirm tests the Confirm function signature +func TestConfirm(t *testing.T) { + // This is a placeholder test since Confirm requires interactive input + // In a real scenario, you would mock the promptui interaction + t.Run("function_signature", func(t *testing.T) { + // Verify the function has correct signature + fnType := reflect.TypeOf(Confirm) + if fnType.Kind() != reflect.Func { + t.Error("Confirm should be a function") + } + if fnType.NumIn() != 1 || fnType.In(0).Kind() != reflect.String { + t.Error("Confirm should accept a single string parameter") + } + if fnType.NumOut() != 1 || fnType.Out(0).Kind() != reflect.Bool { + t.Error("Confirm should return a boolean") + } + }) +} + +// TestInput tests the Input function signature +func TestInput(t *testing.T) { + t.Run("function_signature", func(t *testing.T) { + // Verify the function has correct signature + fnType := reflect.TypeOf(Input) + if fnType.Kind() != reflect.Func { + t.Error("Input should be a function") + } + if fnType.NumIn() != 2 { + t.Error("Input should accept two parameters") + } + if fnType.NumOut() != 2 { + t.Error("Input should return two values (string, error)") + } + }) +} + +// TestSelect tests the Select function signature +func TestSelect(t *testing.T) { + t.Run("function_signature", func(t *testing.T) { + // Verify the function has correct signature + fnType := reflect.TypeOf(Select) + if fnType.Kind() != reflect.Func { + t.Error("Select should be a function") + } + if fnType.NumIn() != 2 { + t.Error("Select should accept two parameters") + } + if fnType.NumOut() != 3 { + t.Error("Select should return three values (int, string, error)") + } + }) +} + +// TestMultiSelect tests the MultiSelect function signature +func TestMultiSelect(t *testing.T) { + t.Run("function_signature", func(t *testing.T) { + // Verify the function has correct signature + fnType := reflect.TypeOf(MultiSelect) + if fnType.Kind() != reflect.Func { + t.Error("MultiSelect should be a function") + } + if fnType.NumIn() != 2 { + t.Error("MultiSelect should accept two parameters") + } + if fnType.NumOut() != 2 { + t.Error("MultiSelect should return two values ([]string, error)") + } + }) +} + +// Note: These functions require interactive terminal input, making them difficult to unit test. +// For integration testing, consider: +// 1. Using expect-like tools to simulate user input +// 2. Creating mock implementations using interfaces +// 3. Testing in an actual terminal environment with automated input