diff --git a/.run/CMD.run.xml b/.run/CMD.run.xml index dd80d5d..f82ebd3 100644 --- a/.run/CMD.run.xml +++ b/.run/CMD.run.xml @@ -2,6 +2,7 @@ + diff --git a/README.md b/README.md index faa6f54..1623dac 100644 --- a/README.md +++ b/README.md @@ -18,21 +18,22 @@ agbridge [flags] ### Flags -| Flag | Description | Default | -|--------------------|------------------------------------------------------------------------------------------------------------------|---------------| -| `--version` | Displays the application version and exits. | | -| `--config` | Path to a configuration file for AGBridge. This flag cannot be used with `--profile-name` or `--resource-id`. | | -| `--profile-name` | Specifies the AWS profile name to access resources. Requires `--resource-id` to be specified. | | -| `--resource-id` | Specifies the resource ID of the AWS API gateway. Required if `--config` is not provided. | | -| `--log-level` | Sets the logging level for output messages. Options: `debug`, `info`, `warn`, `error`, `fatal`. | `info` | -| `--listen-address` | Address where AGBridge will listen for incoming requests. Format should be `host:port`. | `:8080` | +| Flag | Description | Default | +|--------------------|----------------------------------------------------------------------------------------------------------------------------|---------| +| `--version` | Displays the application version and exits. | | +| `--config` | Path to a configuration file for AGBridge. This flag cannot be used with `--profile-name`, `--rest-api-id`, or `--region`. | | +| `--profile-name` | Specifies the AWS profile name to access resources. Requires `--rest-api-id` and `--region` to be specified. | | +| `--rest-api-id` | Specifies the Rest API ID of the AWS API gateway. Required if `--config` is not provided. | | +| `--region` | Specifies the AWS region for the API gateway. Requires `--rest-api-id` and `--profile-name`. | | +| `--log-level` | Sets the logging level for output messages. Options: `debug`, `info`, `warn`, `error`, `fatal`. | `info` | +| `--listen-address` | Address where AGBridge will listen for incoming requests. Format should be `host:port`. | `:8080` | ### Examples -#### Specify Resource with Profile +#### Specify API GW with Profile Specify a resource and profile to access a private API gateway: ```bash -agbridge --profile-name=myprofile --resource-id=12345 +agbridge --profile-name=myprofile --rest-api-id=12345 ``` #### Load a Specific Configuration File @@ -77,7 +78,7 @@ agbridge --listen-address=:9090 ``` 2. Run the container with appropriate flags. For example: ```bash - docker run --rm -it -p 8080:8080 ghcr.io/oscarbc96/agbridge:latest --profile-name=myprofile --resource-id=12345 --listen-address=:8080 + docker run --rm -it -p 8080:8080 ghcr.io/oscarbc96/agbridge:latest --profile-name=myprofile --rest-api-id=12345 --listen-address=:8080 ``` ### Option 4: Build from Source diff --git a/cmd/flags.go b/cmd/flags.go index 0e56733..e0a2663 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -10,6 +10,11 @@ import ( "github.com/oscarbc96/agbridge/pkg/log" ) +const ( + DefaultConfigFileYaml = "agbridge.yaml" + DefaultConfigFileYml = "agbridge.yml" +) + func setCustomUsage() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) @@ -22,8 +27,8 @@ Examples: # Use a specific config file %[1]s --config=config.yaml - # Set profile name with a resource ID - %[1]s --profile-name=myprofile --resource-id=12345 + # Set profile name with a Rest API ID + %[1]s --profile-name=myprofile --rest-api-id=12345 # Set log level to debug %[1]s --log-level=debug @@ -38,79 +43,92 @@ Examples: } type Flags struct { - Version bool Config string - ProfileName string - ResourceID string ListenAddress string LogLevel log.Level + ProfileName string + Region string + RestAPIID string + Version bool } func parseFlags() (*Flags, error) { + setCustomUsage() + version := flag.Bool("version", false, "Displays the application version and exits.") - config := flag.String("config", "", "Specifies the path to a configuration file (cannot be used with --profile-name or --resource-id).") - profileName := flag.String("profile-name", "", "Specifies the profile name (requires --resource-id to be specified).") - resourceID := flag.String("resource-id", "", "Specifies the resource ID (required if --config is not provided).") + config := flag.String("config", "", "Specifies the path to a configuration file (cannot be used with --profile-name, --rest-api-id, or --region).") + profileName := flag.String("profile-name", "", "Specifies the profile name (requires --rest-api-id and --region to be specified).") + restAPIID := flag.String("rest-api-id", "", "Specifies the Rest API ID (required if --config is not provided).") + region := flag.String("region", "", "Specifies the AWS region to use with --profile-name and --rest-api-id.") logLevelStr := flag.String("log-level", "info", "Sets the log verbosity level. Options: debug, info, warn, error, fatal.") listenAddress := flag.String("listen-address", ":8080", "Address where the proxy server will listen for incoming requests.") flag.Parse() - // Check for version flag if *version { return &Flags{Version: true}, nil } - // Parse log level logLevel, err := log.ParseLogLevel(*logLevelStr) if err != nil { - return nil, err + return &Flags{LogLevel: logLevel}, err } flags := &Flags{ Version: *version, Config: *config, ProfileName: *profileName, - ResourceID: *resourceID, + RestAPIID: *restAPIID, ListenAddress: *listenAddress, LogLevel: logLevel, + Region: *region, } // Validate listen address format if _, _, err := net.SplitHostPort(*listenAddress); err != nil { - return flags, fmt.Errorf("invalid listen address format") + return flags, fmt.Errorf("invalid listen address format: %w", err) } // Check if a custom config file is specified and verify its existence if *config != "" { - // If config is specified, it must not be combined with other flags - if *profileName != "" || *resourceID != "" { - return flags, errors.New("--config cannot be combined with --profile-name or --resource-id") + if *profileName != "" || *restAPIID != "" || *region != "" { + return flags, errors.New("`--config` cannot be combined with `--profile-name`, `--rest-api-id`, or `--region`") } - // Ensure the config file exists if _, err := os.Stat(*config); os.IsNotExist(err) { - return flags, errors.New("config file does not exist") + return flags, fmt.Errorf("config file does not exist: %w", err) } } else { - // If config is not specified, check the necessity of resource ID - if *resourceID == "" && *profileName != "" { - return flags, errors.New("--profile-name requires --resource-id to be specified") + // If no --config, check the rules for --rest-api-id, --region, and --profile-name + + // --profile-name requires both --region and --rest-api-id + if *profileName != "" && (*restAPIID == "" || *region == "") { + return flags, errors.New("`--profile-name` requires both `--region` and `--rest-api-id` to be specified") + } + + // --region requires --rest-api-id + if *region != "" && *restAPIID == "" { + return flags, errors.New("`--region` requires `--rest-api-id` to be specified") } - // If no config and no resource ID, check for default config files - if *resourceID == "" { - if _, err := os.Stat("agbridge.yaml"); os.IsNotExist(err) { - if _, err := os.Stat("agbridge.yml"); os.IsNotExist(err) { - return flags, errors.New("please provide --resource-id, --config, or ensure agbridge.yaml or agbridge.yml exists") - } else { - flags.Config = "agbridge.yml" // Default to agbridge.yml if it exists - } - } else { - flags.Config = "agbridge.yaml" // Default to agbridge.yaml if it exists + // If neither --config nor --rest-api-id is provided, fallback to default config file check + if *restAPIID == "" { + configFile, err := checkConfigFileExists(DefaultConfigFileYml, DefaultConfigFileYaml) + if err != nil { + return flags, errors.New("please provide `--rest-api-id`, `--config`, or ensure agbridge.yaml or agbridge.yml exists") } + flags.Config = configFile } } return flags, nil } + +func checkConfigFileExists(filenames ...string) (string, error) { + for _, filename := range filenames { + if _, err := os.Stat(filename); err == nil { + return filename, nil + } + } + return "", errors.New("no config file found") +} diff --git a/cmd/flags_test.go b/cmd/flags_test.go index 40d440e..df80cac 100644 --- a/cmd/flags_test.go +++ b/cmd/flags_test.go @@ -14,233 +14,268 @@ func resetFlags() { flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) } -func TestParseFlags_VersionOnly(t *testing.T) { - resetFlags() - - os.Args = []string{"cmd", "--version"} - - opts, err := parseFlags() - - require.NoError(t, err, "Expected no error") - assert.True(t, opts.Version, "Expected version flag to be true") -} - -func TestParseFlags_IncompatibleFlagsConfigAndProfile(t *testing.T) { - resetFlags() - os.Args = []string{"cmd", "--config", "config.yaml", "--profile-name", "testprofile"} - - _, err := parseFlags() - - require.Error(t, err, "Expected incompatible flags error") - assert.EqualError(t, err, "--config cannot be combined with --profile-name or --resource-id") -} - -func TestParseFlags_IncompatibleFlagsConfigAndResourceID(t *testing.T) { - resetFlags() - os.Args = []string{"cmd", "--config", "config.yaml", "--resource-id", "12345"} - - _, err := parseFlags() - - require.Error(t, err, "Expected incompatible flags error") - assert.EqualError(t, err, "--config cannot be combined with --profile-name or --resource-id") -} - -func TestParseFlags_IncompatibleFlagsConfigResourceIDAndProfile(t *testing.T) { - resetFlags() - os.Args = []string{"cmd", "--config", "config.yaml", "--resource-id", "12345", "--profile-name", "testprofile"} - - _, err := parseFlags() - - require.Error(t, err, "Expected incompatible flags error") - assert.EqualError(t, err, "--config cannot be combined with --profile-name or --resource-id") -} - -func TestParseFlags_IncompatibleFlagsProfile(t *testing.T) { - resetFlags() - os.Args = []string{"cmd", "--profile-name", "testprofile"} - - _, err := parseFlags() - - require.Error(t, err, "Expected incompatible flags error") - assert.EqualError(t, err, "--profile-name requires --resource-id to be specified") -} - -func TestParseFlags_ValidResourceID(t *testing.T) { - resetFlags() - os.Args = []string{"cmd", "--resource-id", "12345"} - - opts, err := parseFlags() - - require.NoError(t, err, "Expected no error") - assert.Equal(t, "12345", opts.ResourceID, "Resource ID mismatch") - assert.Empty(t, opts.ProfileName, "Expected profile name to be empty") - assert.Equal(t, log.LevelInfo, opts.LogLevel, "Default log level mismatch") - assert.Equal(t, ":8080", opts.ListenAddress, "Default listen address mismatch") -} - -func TestParseFlags_ValidResourceIDAndProfileName(t *testing.T) { - resetFlags() - os.Args = []string{"cmd", "--resource-id", "12345", "--profile-name", "testprofile"} - - opts, err := parseFlags() - - require.NoError(t, err, "Expected no error") - assert.Equal(t, "12345", opts.ResourceID, "Resource ID mismatch") - assert.Equal(t, "testprofile", opts.ProfileName, "Profile name mismatch") - assert.Equal(t, log.LevelInfo, opts.LogLevel, "Default log level mismatch") - assert.Equal(t, ":8080", opts.ListenAddress, "Default listen address mismatch") -} - -func TestParseFlags_NoFlags(t *testing.T) { - resetFlags() - - // Create a temporary default config file - file, err := os.Create("agbridge.yaml") - require.NoError(t, err) - file.Close() - defer os.Remove("agbridge.yaml") - - os.Args = []string{"cmd"} - - opts, err := parseFlags() - - require.NoError(t, err, "Expected no error") - assert.Equal(t, log.LevelInfo, opts.LogLevel, "Default log level mismatch") - assert.Equal(t, "agbridge.yaml", opts.Config, "Config file mismatch") - assert.Equal(t, ":8080", opts.ListenAddress, "Default listen address mismatch") -} - -func TestParseFlags_ValidConfig(t *testing.T) { - resetFlags() - - // Create a temporary config file to simulate a valid --config file - tmpFile, err := os.CreateTemp("", "config.yaml") - require.NoError(t, err, "Failed to create temporary config file") - defer os.Remove(tmpFile.Name()) // Clean up after test - - os.Args = []string{"cmd", "--config", tmpFile.Name()} - - opts, err := parseFlags() - - require.NoError(t, err, "Expected no error") - assert.EqualValues(t, log.LevelInfo, opts.LogLevel, "Default log level mismatch") - assert.Equal(t, tmpFile.Name(), opts.Config, "Config file path mismatch") - assert.Equal(t, ":8080", opts.ListenAddress, "Default listen address mismatch") -} - -func TestParseFlags_InvalidConfigFileNotExist(t *testing.T) { - resetFlags() - - os.Args = []string{"cmd", "--config", "nonexistent.yaml"} - - _, err := parseFlags() - - require.Error(t, err, "Expected error for nonexistent config file") - assert.EqualError(t, err, "config file does not exist") -} - -func TestParseFlags_NoDefaultConfigFiles(t *testing.T) { - resetFlags() - - os.Args = []string{"cmd"} - - _, err := parseFlags() - - require.Error(t, err, "Expected error when no default config files are present") - assert.EqualError(t, err, "please provide --resource-id, --config, or ensure agbridge.yaml or agbridge.yml exists") -} - -func TestParseFlags_OnlyAgbridgeYmlExists(t *testing.T) { - resetFlags() - - // Ensure agbridge.yaml does not exist and create agbridge.yml - os.Remove("agbridge.yaml") - tmpFile, err := os.Create("agbridge.yml") - require.NoError(t, err, "Failed to create agbridge.yml") - defer os.Remove("agbridge.yml") - tmpFile.Close() - - // Run with no flags to trigger the default config file check - os.Args = []string{"cmd"} - - opts, err := parseFlags() - - require.NoError(t, err, "Expected no error when agbridge.yml exists") - assert.Equal(t, "agbridge.yml", opts.Config, "Config file path should default to agbridge.yml") - assert.Equal(t, log.LevelInfo, opts.LogLevel, "Default log level mismatch") - assert.Equal(t, ":8080", opts.ListenAddress, "Default listen address mismatch") -} - -func TestParseFlags_ValidLogLevel(t *testing.T) { - resetFlags() - - // Create a temporary default config file to avoid missing file error - file, err := os.Create("agbridge.yaml") - require.NoError(t, err) - file.Close() - defer os.Remove("agbridge.yaml") - - os.Args = []string{"cmd", "--log-level", "debug"} - - opts, err := parseFlags() - - require.NoError(t, err, "Expected no error") - assert.EqualValues(t, log.LevelDebug, opts.LogLevel, "Default log level mismatch") - assert.Equal(t, ":8080", opts.ListenAddress, "Default listen address mismatch") +func TestParseFlags(t *testing.T) { + tests := []struct { + name string + args []string + expErr string + expOpts *Flags + }{ + { + name: "Version only", + args: []string{"cmd", "--version"}, + expErr: "", + expOpts: &Flags{ + Version: true, + }, + }, + { + name: "No Flags with default config", + args: []string{"cmd"}, + expErr: "", + expOpts: &Flags{ + Config: "agbridge.yaml", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Valid RestAPIID only", + args: []string{"cmd", "--rest-api-id", "12345"}, + expErr: "", + expOpts: &Flags{ + RestAPIID: "12345", + ProfileName: "", + Region: "", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Valid Region and RestAPIID", + args: []string{"cmd", "--region", "eu-west-1", "--rest-api-id", "12345"}, + expErr: "", + expOpts: &Flags{ + RestAPIID: "12345", + ProfileName: "", + Region: "eu-west-1", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Valid Region, RestAPIID, and ProfileName", + args: []string{"cmd", "--region", "eu-west-1", "--rest-api-id", "12345", "--profile-name", "patata"}, + expErr: "", + expOpts: &Flags{ + RestAPIID: "12345", + ProfileName: "patata", + Region: "eu-west-1", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "ProfileName without Region and RestAPIID", + args: []string{"cmd", "--profile-name", "patata"}, + expErr: "`--profile-name` requires both `--region` and `--rest-api-id` to be specified", + expOpts: &Flags{ + ProfileName: "patata", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Region without RestAPIID", + args: []string{"cmd", "--region", "eu-west-1"}, + expErr: "`--region` requires `--rest-api-id` to be specified", + expOpts: &Flags{ + Region: "eu-west-1", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Config and RestAPIID", + args: []string{"cmd", "--config", "config.yaml", "--rest-api-id", "12345"}, + expErr: "`--config` cannot be combined with `--profile-name`, `--rest-api-id`, or `--region`", + expOpts: &Flags{ + Config: "config.yaml", + RestAPIID: "12345", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Config and ProfileName", + args: []string{"cmd", "--config", "config.yaml", "--profile-name", "testprofile"}, + expErr: "`--config` cannot be combined with `--profile-name`, `--rest-api-id`, or `--region`", + expOpts: &Flags{ + Config: "config.yaml", + ProfileName: "testprofile", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Config and Region", + args: []string{"cmd", "--config", "config.yaml", "--region", "eu-west-1"}, + expErr: "`--config` cannot be combined with `--profile-name`, `--rest-api-id`, or `--region`", + expOpts: &Flags{ + Config: "config.yaml", + Region: "eu-west-1", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Invalid Config File", + args: []string{"cmd", "--config", "nonexistent.yaml"}, + expErr: "config file does not exist: stat nonexistent.yaml: no such file or directory", + expOpts: &Flags{ + Config: "nonexistent.yaml", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "No Default Config Files", + args: []string{"cmd"}, + expErr: "please provide `--rest-api-id`, `--config`, or ensure agbridge.yaml or agbridge.yml exists", + expOpts: &Flags{ + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Only agbridge.yml exists", + args: []string{"cmd"}, + expErr: "", + expOpts: &Flags{ + Config: "agbridge.yml", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Only agbridge.yaml exists", + args: []string{"cmd"}, + expErr: "", + expOpts: &Flags{ + Config: "agbridge.yaml", + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Valid Listen Address", + args: []string{"cmd", "--listen-address", ":9090"}, + expErr: "please provide `--rest-api-id`, `--config`, or ensure agbridge.yaml or agbridge.yml exists", + expOpts: &Flags{ + ListenAddress: ":9090", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Invalid Listen Address", + args: []string{"cmd", "--listen-address", "qwerty"}, + expErr: "invalid listen address format: address qwerty: missing port in address", + expOpts: &Flags{ + ListenAddress: "qwerty", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Valid LogLevel - Debug", + args: []string{"cmd", "--log-level", "debug"}, + expErr: "please provide `--rest-api-id`, `--config`, or ensure agbridge.yaml or agbridge.yml exists", + expOpts: &Flags{ + ListenAddress: ":8080", + LogLevel: log.LevelDebug, + }, + }, + { + name: "Valid LogLevel - Info", + args: []string{"cmd", "--log-level", "info"}, + expErr: "please provide `--rest-api-id`, `--config`, or ensure agbridge.yaml or agbridge.yml exists", + expOpts: &Flags{ + ListenAddress: ":8080", + LogLevel: log.LevelInfo, + }, + }, + { + name: "Valid LogLevel - Warn", + args: []string{"cmd", "--log-level", "warn"}, + expErr: "please provide `--rest-api-id`, `--config`, or ensure agbridge.yaml or agbridge.yml exists", + expOpts: &Flags{ + ListenAddress: ":8080", + LogLevel: log.LevelWarn, + }, + }, + { + name: "Valid LogLevel - Error", + args: []string{"cmd", "--log-level", "error"}, + expErr: "please provide `--rest-api-id`, `--config`, or ensure agbridge.yaml or agbridge.yml exists", + expOpts: &Flags{ + ListenAddress: ":8080", + LogLevel: log.LevelError, + }, + }, + { + name: "Valid LogLevel - Fatal", + args: []string{"cmd", "--log-level", "fatal"}, + expErr: "please provide `--rest-api-id`, `--config`, or ensure agbridge.yaml or agbridge.yml exists", + expOpts: &Flags{ + ListenAddress: ":8080", + LogLevel: log.LevelFatal, + }, + }, + { + name: "Invalid LogLevel", + args: []string{"cmd", "--log-level", "verbose"}, + expErr: "invalid log level: must be one of debug, info, warn, error, fatal", + expOpts: &Flags{ + LogLevel: log.LevelInfo, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetFlags() + + if tt.name == "No Flags with default config" || tt.name == "Only agbridge.yaml exists" { + cleanup := setupConfigFile(t, DefaultConfigFileYaml) + defer cleanup() + } + + if tt.name == "Only agbridge.yml exists" { + os.Remove("agbridge.yaml") + cleanup := setupConfigFile(t, DefaultConfigFileYml) + defer cleanup() + } + + os.Args = tt.args + + opts, err := parseFlags() + + if tt.expErr == "" { + require.NoError(t, err, "Expected no error") + } else { + require.Error(t, err, "Expected error") + require.EqualError(t, err, tt.expErr) + } + assert.Equal(t, tt.expOpts, opts, "Options mismatch") + }) + } } -func TestParseFlags_InvalidLogLevel(t *testing.T) { - resetFlags() - - os.Args = []string{"cmd", "--log-level", "verbose"} - - _, err := parseFlags() - - require.Error(t, err, "Expected invalid log level error") - assert.EqualError(t, err, "invalid log level: must be one of debug, info, warn, error, fatal") -} - -func TestParseFlags_ValidListenAddress(t *testing.T) { - resetFlags() - - // Create a temporary default config file - file, err := os.Create("agbridge.yaml") +func setupConfigFile(t *testing.T, filename string) func() { + file, err := os.Create(filename) require.NoError(t, err) file.Close() - defer os.Remove("agbridge.yaml") - - os.Args = []string{"cmd", "--listen-address", ":9090"} - - opts, err := parseFlags() - require.NoError(t, err, "Expected no error") - assert.Equal(t, ":9090", opts.ListenAddress, "Listen address mismatch") - assert.Equal(t, log.LevelInfo, opts.LogLevel, "Default log level mismatch") -} - -func TestParseFlags_InvalidListenAddress(t *testing.T) { - resetFlags() - - os.Args = []string{"cmd", "--listen-address", "qwerty"} - - _, err := parseFlags() - - require.Error(t, err, "Expected invalid listen address error") - assert.EqualError(t, err, "invalid listen address format") -} - -func TestParseFlags_DefaultListenAddress(t *testing.T) { - resetFlags() - - // Create a temporary default config file to avoid missing file error - file, err := os.Create("agbridge.yaml") - require.NoError(t, err) - file.Close() - defer os.Remove("agbridge.yaml") - - os.Args = []string{"cmd"} - opts, err := parseFlags() - require.NoError(t, err, "Expected no error") - assert.Equal(t, ":8080", opts.ListenAddress, "Default listen address mismatch") - assert.Equal(t, log.LevelInfo, opts.LogLevel, "Default log level mismatch") + return func() { + os.Remove(filename) + } } diff --git a/cmd/main.go b/cmd/main.go index bb33f74..9cad29d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,9 +1,16 @@ package main import ( + "context" + "errors" "fmt" + "net/http" + "os/signal" + "syscall" + "time" "github.com/oscarbc96/agbridge/pkg/log" + "github.com/oscarbc96/agbridge/pkg/proxy" ) var ( @@ -12,21 +19,68 @@ var ( date = "unknown" ) -func main() { - setCustomUsage() - options, err := parseFlags() +func loadProxyConfig(flags *Flags) (*proxy.Config, error) { + if flags.RestAPIID != "" { + return proxy.NewConfig(flags.RestAPIID, flags.ProfileName, flags.Region), nil + } + + cfg, err := proxy.LoadConfig(flags.Config) + if err != nil { + return nil, fmt.Errorf("couldn't load config file: %w", err) + } - log.Setup(options.LogLevel) + return cfg, nil +} +func main() { + flags, err := parseFlags() + // Setup logging, before raising errors of flags parsing + log.Setup(flags.LogLevel) if err != nil { log.Fatal(err.Error()) } - if options.Version { + if flags.Version { fmt.Printf("%s, commit %s, built at %s\n", version, commit, date) - return } - log.Info("", log.String("resource-id", options.ResourceID), log.String("profile-name", options.ProfileName), log.String("config", options.Config), log.String("listen-address", options.ListenAddress)) + cfg, err := loadProxyConfig(flags) + if err != nil { + log.Fatal("Failed to load configuration", log.Err(err)) + } + + handlerMapping, err := cfg.Validate() + if err != nil { + log.Fatal("Couldn't validate config", log.Err(err)) + } + + err = proxy.PrintMappings(handlerMapping) + if err != nil { + log.Fatal("Failed to print mappings", log.Err(err)) + } + + proxy := proxy.NewProxy(flags.ListenAddress, handlerMapping) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + log.Info("Starting proxy", log.String("addr", proxy.Addr())) + if err := proxy.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal("Proxy server error", log.Err(err)) + } + }() + + <-ctx.Done() + log.Info("Shutdown signal received, stopping proxy server...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := proxy.Shutdown(shutdownCtx); err != nil { + log.Fatal("Failed to stop proxy server gracefully", log.Err(err)) + } else { + log.Info("Proxy server stopped successfully") + } } diff --git a/go.mod b/go.mod index d0b6eaf..ad1a355 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,15 @@ require ( github.com/aws/aws-sdk-go-v2 v1.32.3 github.com/aws/aws-sdk-go-v2/config v1.28.1 github.com/aws/aws-sdk-go-v2/service/apigateway v1.27.3 + github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 + github.com/jedib0t/go-pretty/v6 v6.6.1 github.com/rs/zerolog v1.33.0 + github.com/samber/lo v1.47.0 github.com/samber/slog-zerolog/v2 v2.7.0 github.com/stretchr/testify v1.9.0 github.com/testcontainers/testcontainers-go v0.34.0 github.com/testcontainers/testcontainers-go/modules/localstack v0.34.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -29,7 +33,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 // indirect github.com/aws/smithy-go v1.22.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -51,6 +54,7 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect @@ -63,7 +67,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/samber/lo v1.47.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/slog-common v0.17.1 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect @@ -83,5 +87,4 @@ require ( golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect google.golang.org/protobuf v1.33.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1e8c24f..010aae0 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc= +github.com/jedib0t/go-pretty/v6 v6.6.1/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= @@ -93,6 +95,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= @@ -117,6 +121,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= diff --git a/pkg/apigateway/list.go b/pkg/apigateway/list.go deleted file mode 100644 index d044291..0000000 --- a/pkg/apigateway/list.go +++ /dev/null @@ -1,21 +0,0 @@ -package apigateway - -import ( - "context" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/apigateway" - "github.com/aws/aws-sdk-go-v2/service/apigateway/types" -) - -func ListAPIGateways(config aws.Config) ([]types.RestApi, error) { - apiClient := apigateway.NewFromConfig(config) - input := &apigateway.GetRestApisInput{} - - result, err := apiClient.GetRestApis(context.TODO(), input) - if err != nil { - return nil, err - } - - return result.Items, nil -} diff --git a/pkg/apigateway/list_test.go b/pkg/apigateway/list_test.go deleted file mode 100644 index 3448694..0000000 --- a/pkg/apigateway/list_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package apigateway - -import ( - "testing" - - "github.com/oscarbc96/agbridge/internal/testutil" - "github.com/stretchr/testify/suite" -) - -type APIGatewayTestSuite struct { - testutil.BaseTestSuite -} - -func (suite *APIGatewayTestSuite) SetupTest() { - _, err := testutil.CreateAPIGateway(*suite.Config, "test") - suite.Require().NoError(err, "failed to create test API gateway") -} - -func (suite *APIGatewayTestSuite) TestListAPIGateways() { - apigws, err := ListAPIGateways(*suite.Config) - - suite.Require().NoError(err, "expected no error listing API gateways") - suite.Len(apigws, 1, "expected exactly one API gateway") -} - -func TestAPIGatewayTestSuite(t *testing.T) { - suite.Run(t, new(APIGatewayTestSuite)) -} diff --git a/pkg/awsutils/api_gateway_describe.go b/pkg/awsutils/api_gateway_describe.go new file mode 100644 index 0000000..196451d --- /dev/null +++ b/pkg/awsutils/api_gateway_describe.go @@ -0,0 +1,30 @@ +package awsutils + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/apigateway" + "github.com/aws/aws-sdk-go-v2/service/apigateway/types" +) + +func DescribeAPIGateway(config aws.Config, apiID string) ([]types.Resource, error) { + client := apigateway.NewFromConfig(config) + input := &apigateway.GetResourcesInput{ + RestApiId: aws.String(apiID), + } + + var result []types.Resource + ctx := context.TODO() + paginator := apigateway.NewGetResourcesPaginator(client, input) + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to retrieve resources: %w", err) + } + result = append(result, page.Items...) + } + + return result, nil +} diff --git a/pkg/awsutils/api_gateway_describe_test.go b/pkg/awsutils/api_gateway_describe_test.go new file mode 100644 index 0000000..bc44100 --- /dev/null +++ b/pkg/awsutils/api_gateway_describe_test.go @@ -0,0 +1,33 @@ +package awsutils + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/service/apigateway" + "github.com/oscarbc96/agbridge/internal/testutil" + "github.com/stretchr/testify/suite" +) + +type APIGatewayTestSuite struct { + testutil.BaseTestSuite + + ApiGateway *apigateway.CreateRestApiOutput +} + +func (suite *APIGatewayTestSuite) SetupTest() { + apigw, err := testutil.CreateAPIGateway(*suite.Config, "test") + suite.Require().NoError(err, "failed to create test API gateway") + suite.ApiGateway = apigw +} + +func (suite *APIGatewayTestSuite) TestDescribeAPIGateway() { + apigws, err := DescribeAPIGateway(*suite.Config, *suite.ApiGateway.Id) + + suite.Require().NoError(err, "expected no error Describing API gateways") + suite.Len(apigws, 1, "expected exactly one API gateway") + suite.Equal(*suite.ApiGateway.RootResourceId, *apigws[0].Id, "expected API Gateway ID") +} + +func TestAPIGatewayTestSuite(t *testing.T) { + suite.Run(t, new(APIGatewayTestSuite)) +} diff --git a/pkg/awsutils/config.go b/pkg/awsutils/config.go new file mode 100644 index 0000000..b913f50 --- /dev/null +++ b/pkg/awsutils/config.go @@ -0,0 +1,31 @@ +package awsutils + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" +) + +func LoadConfigFor(profile, region string) (*aws.Config, error) { + var options []func(*config.LoadOptions) error + + if profile != "" { + options = append(options, config.WithSharedConfigProfile(profile)) + } + + if region != "" { + options = append(options, config.WithRegion(region)) + } + + cfg, err := config.LoadDefaultConfig( + context.TODO(), + options..., + ) + if err != nil { + return nil, fmt.Errorf("unable to load SDK config (profile: %s, region: %s), %w", profile, region, err) + } + + return &cfg, nil +} diff --git a/pkg/awsutils/sts.go b/pkg/awsutils/sts.go new file mode 100644 index 0000000..3f3211d --- /dev/null +++ b/pkg/awsutils/sts.go @@ -0,0 +1,20 @@ +package awsutils + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +func GetAWSAccountDetails(config aws.Config) (string, string, error) { + stsClient := sts.NewFromConfig(config) + + callerIdentity, err := stsClient.GetCallerIdentity(context.TODO(), &sts.GetCallerIdentityInput{}) + if err != nil { + return "", "", fmt.Errorf("failed to get caller identity: %w", err) + } + + return *callerIdentity.Account, *callerIdentity.Arn, nil +} diff --git a/pkg/log/main.go b/pkg/log/main.go index ff4a0f9..615f4c2 100644 --- a/pkg/log/main.go +++ b/pkg/log/main.go @@ -13,19 +13,13 @@ const ( ) var ( - With = slog.With - Group = slog.Group + With = slog.With - Debug = slog.Debug - Info = slog.Info - Warn = slog.Warn - Error = slog.Error + Info = slog.Info - Any = slog.Any - String = slog.String - Time = slog.Time - Int = slog.Int - Bool = slog.Bool + Duration = slog.Duration + Int = slog.Int + String = slog.String ) type Level = slog.Level diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go new file mode 100644 index 0000000..a9f4525 --- /dev/null +++ b/pkg/proxy/config.go @@ -0,0 +1,86 @@ +package proxy + +import ( + "fmt" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/oscarbc96/agbridge/pkg/awsutils" + "github.com/samber/lo" + "gopkg.in/yaml.v3" +) + +type GatewayConfig struct { + RestAPIID string `yaml:"rest_api_id"` + ProfileName string `yaml:"profile_name"` + Region string `yaml:"region"` +} + +type Config struct { + Gateways []GatewayConfig `yaml:"gateways"` +} + +func (c *Config) Validate() (map[string]Handler, error) { + var ( + awsCfg *aws.Config + err error + result = make(map[string]Handler) + ) + + for _, gw := range c.Gateways { + awsCfg, err = awsutils.LoadConfigFor(gw.ProfileName, gw.Region) + if err != nil { + return nil, fmt.Errorf("couldn't load AWS Config for profile %s: %w", gw.ProfileName, err) + } + + resources, err := awsutils.DescribeAPIGateway(*awsCfg, gw.RestAPIID) + if err != nil { + return nil, fmt.Errorf("couldn't describe API Gateway for RestAPIID %s: %w", gw.RestAPIID, err) + } + + for _, resource := range resources { + if _, exists := result[*resource.Path]; exists { + return nil, fmt.Errorf("duplicate path %s found in the configuration for Rest API ID %s", *resource.Path, gw.RestAPIID) + } + + if resource.ResourceMethods != nil { + result[*resource.Path] = Handler{ + ResourceID: *resource.Id, + RestAPIID: gw.RestAPIID, + Methods: lo.Keys(resource.ResourceMethods), + Config: *awsCfg, + } + } + } + } + + return result, nil +} + +func LoadConfig(filename string) (*Config, error) { + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("failed to open Config file: %w", err) + } + defer file.Close() + + var config Config + decoder := yaml.NewDecoder(file) + if err := decoder.Decode(&config); err != nil { + return nil, fmt.Errorf("failed to parse Config file: %w", err) + } + + return &config, nil +} + +func NewConfig(restAPIID, profileName, region string) *Config { + return &Config{ + Gateways: []GatewayConfig{ + { + RestAPIID: restAPIID, + ProfileName: profileName, + Region: region, + }, + }, + } +} diff --git a/pkg/proxy/handler.go b/pkg/proxy/handler.go new file mode 100644 index 0000000..8d67180 --- /dev/null +++ b/pkg/proxy/handler.go @@ -0,0 +1,116 @@ +package proxy + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/apigateway" + "github.com/oscarbc96/agbridge/pkg/log" + "github.com/samber/lo" +) + +type Handler struct { + ResourceID string + RestAPIID string + Methods []string + Config aws.Config +} + +func defaultHandleRequest(w http.ResponseWriter, r *http.Request, handlerMapping map[string]Handler) { + start := time.Now() + + pathWithoutQuery := getURLWithoutQuery(r.URL) + handler, ok := handlerMapping[pathWithoutQuery] + if !ok { + handleError(w, r, nil, "Handler not found") + return + } + + if !lo.Contains(handler.Methods, r.Method) { + handleError(w, r, nil, "Method not supported") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, r, err, "Error reading request body") + return + } + + client := apigateway.NewFromConfig(handler.Config) + resp, err := client.TestInvokeMethod( + context.Background(), + &apigateway.TestInvokeMethodInput{ + ResourceId: &handler.ResourceID, + RestApiId: &handler.RestAPIID, + HttpMethod: &r.Method, + PathWithQueryString: aws.String(r.URL.String()), + Body: aws.String(string(body)), + MultiValueHeaders: r.Header, + }, + ) + if err != nil { + handleError(w, r, err, "Error calling API Gateway") + return + } + + // Copy HTTP status code from test-invoke response to the proxy response + w.WriteHeader(int(resp.Status)) + // Copy the headers from test-invoke response to the proxy response + for key, value := range resp.Headers { + w.Header().Set(key, value) + } + for key, values := range resp.MultiValueHeaders { + for _, value := range values { + w.Header().Add(key, value) + } + } + // Copy the body from test-invoke response to the proxy response + _, err = io.Copy(w, strings.NewReader(*resp.Body)) + if err != nil { + handleError(w, r, err, "Error copying response body") + return + } + + log.Info( + r.URL.String(), + log.String("method", r.Method), + log.Int("status_code", int(resp.Status)), + log.Duration("elapsed_ms", time.Since(start)), + ) +} + +func getURLWithoutQuery(u *url.URL) string { + uCopy := *u + uCopy.RawQuery = "" + return strings.TrimRight(uCopy.String(), "/") +} + +func handleError(w http.ResponseWriter, r *http.Request, err error, message string) { + logger := log.With( + log.String("path", r.URL.String()), + log.String("method", r.Method), + ) + + if err != nil { + http.Error( + w, + fmt.Sprintf("Raised from AGBridge: %s Error: %s", message, err.Error()), + http.StatusInternalServerError, + ) + logger.Error(message, log.Err(err)) + } else { + http.Error( + w, + fmt.Sprintf("Raised from AGBridge: %s", message), + http.StatusInternalServerError, + ) + logger.Error(message) + } +} diff --git a/pkg/proxy/main.go b/pkg/proxy/main.go new file mode 100644 index 0000000..1b897b6 --- /dev/null +++ b/pkg/proxy/main.go @@ -0,0 +1,37 @@ +package proxy + +import ( + "context" + "net/http" +) + +type Proxy struct { + server *http.Server +} + +func NewProxy(listenAddress string, handlerMapping map[string]Handler) *Proxy { + handler := func(w http.ResponseWriter, r *http.Request) { + defaultHandleRequest(w, r, handlerMapping) + } + + proxy := &Proxy{ + server: &http.Server{ + Addr: listenAddress, + Handler: http.HandlerFunc(handler), + }, + } + + return proxy +} + +func (p *Proxy) Start() error { + return p.server.ListenAndServe() +} + +func (p *Proxy) Shutdown(ctx context.Context) error { + return p.server.Shutdown(ctx) +} + +func (p *Proxy) Addr() string { + return p.server.Addr +} diff --git a/pkg/proxy/table.go b/pkg/proxy/table.go new file mode 100644 index 0000000..20b8193 --- /dev/null +++ b/pkg/proxy/table.go @@ -0,0 +1,43 @@ +package proxy + +import ( + "os" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/oscarbc96/agbridge/pkg/awsutils" +) + +func PrintMappings(handlerMapping map[string]Handler) error { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Path", "Methods", "Rest API ID", "Resource ID", "Account ID", "Region", "Identity"}) + t.SetColumnConfigs([]table.ColumnConfig{ + {Name: "Rest API ID", AutoMerge: true}, + {Name: "Account ID", AutoMerge: true}, + {Name: "Region", AutoMerge: true}, + {Name: "Identity", WidthMax: 40, AutoMerge: true}, + }) + + for path, handler := range handlerMapping { + accountID, identity, err := awsutils.GetAWSAccountDetails(handler.Config) + if err != nil { + return err + } + + t.AppendRow(table.Row{ + path, + handler.Methods, + handler.RestAPIID, + handler.ResourceID, + accountID, + handler.Config.Region, + identity, + }) + } + + t.SetStyle(table.StyleLight) + t.Style().Options.SeparateRows = true + t.Render() + + return nil +}