diff --git a/cmd/commands.go b/cmd/commands.go index b749af54c..77e71066d 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -320,6 +320,12 @@ var ( &databaseInfoCommand, &databaseCreateCommand, &databaseDestroyCommand, + + // Firewall rules + &databaseFirewallRulesListCommand, + &databaseFirewallRulesAddCommand, + &databaseFirewallRulesRemoveCommand, + &databaseFirewallManagedRangesCommand, } globalCommands = []*cli.Command{ diff --git a/cmd/firewall_rules.go b/cmd/firewall_rules.go new file mode 100644 index 000000000..7a6599f20 --- /dev/null +++ b/cmd/firewall_rules.go @@ -0,0 +1,229 @@ +package cmd + +import ( + "context" + "errors" + + "github.com/urfave/cli/v3" + + "github.com/Scalingo/cli/cmd/autocomplete" + "github.com/Scalingo/cli/dbng" + "github.com/Scalingo/cli/detect" + "github.com/Scalingo/cli/io" + "github.com/Scalingo/cli/utils" + "github.com/Scalingo/go-scalingo/v8" +) + +var ( + databaseFirewallRulesListCommand = cli.Command{ + Name: "database-firewall-rules", + Category: "Databases NG", + Usage: "List firewall rules of a database", + ArgsUsage: "database-id", + Flags: []cli.Flag{databaseFlag()}, + Description: CommandDescription{ + Description: "List all firewall rules of a database next generation", + Examples: []string{ + "scalingo database-firewall-rules my-db-id", + "scalingo --database my-db database-firewall-rules", + }, + SeeAlso: []string{"database-firewall-rules-add", "database-firewall-rules-remove", "database-firewall-managed-ranges"}, + }.Render(), + Action: func(ctx context.Context, c *cli.Command) error { + databaseName, addonID, err := detect.GetDatabaseFromArgs(ctx, c) + if errors.Is(err, detect.ErrTooManyArguments) { + io.Error(err) + return cli.ShowCommandHelp(ctx, c, "database-firewall-rules") + } + if err != nil { + errorQuit(ctx, err) + } + + utils.CheckForConsent(ctx, databaseName, utils.ConsentTypeDBs) + + err = dbng.FirewallRulesList(ctx, databaseName, addonID) + if err != nil { + errorQuit(ctx, err) + } + + return nil + }, + ShellComplete: func(ctx context.Context, c *cli.Command) { + _ = autocomplete.CmdFlagsAutoComplete(c, "database-firewall-rules") + _ = autocomplete.DatabasesNgListAutoComplete(ctx) + }, + } + + databaseFirewallRulesAddCommand = cli.Command{ + Name: "database-firewall-rules-add", + Category: "Databases NG", + Usage: "Add a firewall rule to a database", + ArgsUsage: "database-id", + Flags: []cli.Flag{ + databaseFlag(), + &cli.StringFlag{Name: "cidr", Usage: "CIDR range (e.g., 203.0.113.0/24)"}, + &cli.StringFlag{Name: "label", Usage: "Label for the custom rule"}, + &cli.StringFlag{Name: "managed-range", Usage: "Managed range ID"}, + }, + Description: CommandDescription{ + Description: "Add a firewall rule to a database next generation. Either --cidr or --managed-range must be specified, but not both.", + Examples: []string{ + "scalingo database-firewall-rules-add my-db-id --cidr 203.0.113.0/24 --label \"Office network\"", + "scalingo --database my-db database-firewall-rules-add --managed-range mr-scalingo-osc-fr1", + }, + SeeAlso: []string{"database-firewall-rules", "database-firewall-rules-remove", "database-firewall-managed-ranges"}, + }.Render(), + Action: func(ctx context.Context, c *cli.Command) error { + databaseName, addonID, err := detect.GetDatabaseFromArgs(ctx, c) + if errors.Is(err, detect.ErrTooManyArguments) { + io.Error(err) + return cli.ShowCommandHelp(ctx, c, "database-firewall-rules-add") + } + if err != nil { + errorQuit(ctx, err) + } + + cidr := c.String("cidr") + label := c.String("label") + managedRange := c.String("managed-range") + + if cidr == "" && managedRange == "" { + io.Error("Either --cidr or --managed-range must be specified") + return cli.ShowCommandHelp(ctx, c, "database-firewall-rules-add") + } + + if cidr != "" && managedRange != "" { + io.Error("Cannot specify both --cidr and --managed-range") + return cli.ShowCommandHelp(ctx, c, "database-firewall-rules-add") + } + + utils.CheckForConsent(ctx, databaseName, utils.ConsentTypeDBs) + + var params scalingo.FirewallRuleCreateParams + if cidr != "" { + params = scalingo.FirewallRuleCreateParams{ + Type: scalingo.FirewallRuleTypeCustomRange, + CIDR: cidr, + Label: label, + } + } else { + params = scalingo.FirewallRuleCreateParams{ + Type: scalingo.FirewallRuleTypeManagedRange, + RangeID: managedRange, + Label: label, + } + } + + err = dbng.FirewallRulesAdd(ctx, databaseName, addonID, params) + if err != nil { + errorQuit(ctx, err) + } + + return nil + }, + ShellComplete: func(ctx context.Context, c *cli.Command) { + _ = autocomplete.CmdFlagsAutoComplete(c, "database-firewall-rules-add") + _ = autocomplete.DatabasesNgListAutoComplete(ctx) + }, + } + + databaseFirewallRulesRemoveCommand = cli.Command{ + Name: "database-firewall-rules-remove", + Category: "Databases NG", + Usage: "Remove a firewall rule from a database", + ArgsUsage: "database-id rule-id", + Flags: []cli.Flag{databaseFlag()}, + Description: CommandDescription{ + Description: "Remove a firewall rule from a database next generation", + Examples: []string{ + "scalingo database-firewall-rules-remove my-db-id rule-id", + "scalingo --database my-db database-firewall-rules-remove rule-id", + }, + SeeAlso: []string{"database-firewall-rules", "database-firewall-rules-add", "database-firewall-managed-ranges"}, + }.Render(), + Action: func(ctx context.Context, c *cli.Command) error { + var databaseName, addonID, ruleID string + var err error + + args := c.Args().Slice() + switch len(args) { + case 0: + // No arguments provided + io.Error("Missing required argument: rule-id") + return cli.ShowCommandHelp(ctx, c, "database-firewall-rules-remove") + case 1: + // Only rule-id provided, database from --database flag + databaseName, addonID = detect.GetCurrentDatabase(ctx, c) + ruleID = args[0] + case 2: + // Both database-name and rule-id provided as positional args + databaseName = args[0] + ruleID = args[1] + addonID, err = detect.GetAddonIDFromDatabase(ctx, databaseName) + if err != nil { + errorQuit(ctx, err) + } + default: + io.Error("Invalid number of arguments") + return cli.ShowCommandHelp(ctx, c, "database-firewall-rules-remove") + } + + if databaseName == "" { + io.Error("Please provide a database name or use --database flag") + return cli.ShowCommandHelp(ctx, c, "database-firewall-rules-remove") + } + + utils.CheckForConsent(ctx, databaseName, utils.ConsentTypeDBs) + + err = dbng.FirewallRulesRemove(ctx, databaseName, addonID, ruleID) + if err != nil { + errorQuit(ctx, err) + } + + return nil + }, + ShellComplete: func(ctx context.Context, c *cli.Command) { + _ = autocomplete.CmdFlagsAutoComplete(c, "database-firewall-rules-remove") + _ = autocomplete.DatabasesNgListAutoComplete(ctx) + }, + } + + databaseFirewallManagedRangesCommand = cli.Command{ + Name: "database-firewall-managed-ranges", + Category: "Databases NG", + Usage: "List available managed ranges for a database", + ArgsUsage: "database-id", + Flags: []cli.Flag{databaseFlag()}, + Description: CommandDescription{ + Description: "List all available managed ranges for firewall rules of a database next generation", + Examples: []string{ + "scalingo database-firewall-managed-ranges my-db-id", + "scalingo --database my-db database-firewall-managed-ranges", + }, + SeeAlso: []string{"database-firewall-rules", "database-firewall-rules-add", "database-firewall-rules-remove"}, + }.Render(), + Action: func(ctx context.Context, c *cli.Command) error { + databaseName, addonID, err := detect.GetDatabaseFromArgs(ctx, c) + if errors.Is(err, detect.ErrTooManyArguments) { + io.Error(err) + return cli.ShowCommandHelp(ctx, c, "database-firewall-managed-ranges") + } + if err != nil { + errorQuit(ctx, err) + } + + utils.CheckForConsent(ctx, databaseName, utils.ConsentTypeDBs) + + err = dbng.FirewallManagedRangesList(ctx, databaseName, addonID) + if err != nil { + errorQuit(ctx, err) + } + + return nil + }, + ShellComplete: func(ctx context.Context, c *cli.Command) { + _ = autocomplete.CmdFlagsAutoComplete(c, "database-firewall-managed-ranges") + _ = autocomplete.DatabasesNgListAutoComplete(ctx) + }, + } +) diff --git a/dbng/firewall_rules.go b/dbng/firewall_rules.go new file mode 100644 index 000000000..b08649a20 --- /dev/null +++ b/dbng/firewall_rules.go @@ -0,0 +1,114 @@ +package dbng + +import ( + "context" + "os" + + "github.com/olekukonko/tablewriter" + + "github.com/Scalingo/cli/config" + "github.com/Scalingo/cli/io" + "github.com/Scalingo/go-scalingo/v8" + "github.com/Scalingo/go-utils/errors/v2" +) + +func FirewallRulesList(ctx context.Context, databaseID, addonID string) error { + c, err := config.ScalingoClient(ctx) + if err != nil { + return errors.Wrap(ctx, err, "get Scalingo client") + } + + rules, err := c.Preview().FirewallRulesList(ctx, databaseID, addonID) + if err != nil { + return errors.Wrap(ctx, err, "list firewall rules") + } + + if len(rules) == 0 { + io.Status("No firewall rules configured for this database.") + return nil + } + + t := tablewriter.NewWriter(os.Stdout) + t.Header([]string{"ID", "Type", "CIDR or Range ID", "Label"}) + + for _, rule := range rules { + cidrOrRange := rule.CIDR + if rule.Type == scalingo.FirewallRuleTypeManagedRange { + cidrOrRange = rule.RangeID + } + + _ = t.Append([]string{ + rule.ID, + string(rule.Type), + cidrOrRange, + rule.Label, + }) + } + _ = t.Render() + + return nil +} + +func FirewallRulesAdd(ctx context.Context, databaseID, addonID string, params scalingo.FirewallRuleCreateParams) error { + c, err := config.ScalingoClient(ctx) + if err != nil { + return errors.Wrap(ctx, err, "get Scalingo client") + } + + rule, err := c.Preview().FirewallRulesCreate(ctx, databaseID, addonID, params) + if err != nil { + return errors.Wrap(ctx, err, "add firewall rule") + } + + io.Statusf("Firewall rule '%s' has been added to database '%s'.\n", rule.ID, databaseID) + io.Warning("Expect some delay for the firewall rules to be applied.") + + return nil +} + +func FirewallRulesRemove(ctx context.Context, databaseID, addonID, ruleID string) error { + c, err := config.ScalingoClient(ctx) + if err != nil { + return errors.Wrap(ctx, err, "get Scalingo client") + } + + err = c.Preview().FirewallRulesDestroy(ctx, databaseID, addonID, ruleID) + if err != nil { + return errors.Wrap(ctx, err, "remove firewall rule") + } + + io.Statusf("Firewall rule '%s' has been removed from database '%s'.\n", ruleID, databaseID) + io.Warning("Expect some delay for the firewall rules to be applied.") + + return nil +} + +func FirewallManagedRangesList(ctx context.Context, databaseID, addonID string) error { + c, err := config.ScalingoClient(ctx) + if err != nil { + return errors.Wrap(ctx, err, "get Scalingo client") + } + + ranges, err := c.Preview().FirewallRulesGetManagedRanges(ctx, databaseID, addonID) + if err != nil { + return errors.Wrap(ctx, err, "list managed ranges") + } + + if len(ranges) == 0 { + io.Status("No managed ranges available.") + return nil + } + + t := tablewriter.NewWriter(os.Stdout) + t.Header([]string{"ID", "Name"}) + + for _, r := range ranges { + _ = t.Append([]string{ + r.ID, + r.Name, + }) + } + _ = t.Render() + + return nil +} diff --git a/detect/app.go b/detect/app.go index 5ef0f35c9..5f6a95b50 100644 --- a/detect/app.go +++ b/detect/app.go @@ -18,6 +18,60 @@ import ( var errDatabaseNotFound = stderrors.New("database name not found") +// GetAddonIDFromDatabase resolves the addon ID from a database name by calling the API. +// This is useful when the database name is provided as a positional argument. +func GetAddonIDFromDatabase(ctx context.Context, databaseName string) (string, error) { + client, err := config.ScalingoClient(ctx) + if err != nil { + return "", errors.Wrap(ctx, err, "get Scalingo client") + } + + // AddonsList works for both apps and DBNG databases (same API endpoint). + // A DBNG database is modeled as an app with a single addon (itself), + // whereas an application can have multiple addons (postgresql, redis, etc.). + // If multiple addons are returned, the ID is likely an application, not a database. + addons, err := client.AddonsList(ctx, databaseName) + if err != nil { + return "", errors.Wrap(ctx, err, "list addons") + } + + if len(addons) == 0 { + return "", errors.Newf(ctx, "no addon found for database %s", databaseName) + } + + if len(addons) > 1 { + return "", errors.Newf(ctx, "multiple addons found for %s, it may be an application", databaseName) + } + + return addons[0].ID, nil +} + +// ErrTooManyArguments is returned when too many arguments are provided +var ErrTooManyArguments = stderrors.New("too many arguments") + +// GetDatabaseFromArgs resolves the database name and addon ID from command arguments. +// If no positional argument is provided, it uses the --database flag. +// If one positional argument is provided, it uses it as the database name. +// Returns ErrTooManyArguments if more than one argument is provided. +func GetDatabaseFromArgs(ctx context.Context, c *cli.Command) (string, string, error) { + switch c.Args().Len() { + case 0: + // No positional arg, database from --database flag + databaseName, addonID := GetCurrentDatabase(ctx, c) + return databaseName, addonID, nil + case 1: + // database-name provided as positional arg + databaseName := c.Args().First() + addonID, err := GetAddonIDFromDatabase(ctx, databaseName) + if err != nil { + return "", "", err + } + return databaseName, addonID, nil + default: + return "", "", ErrTooManyArguments + } +} + // GetCurrentDatabase is a helper to get the current database name and UUID. func GetCurrentDatabase(ctx context.Context, c *cli.Command) (string, string) { currentDatabase, databaseUUID, err := currentDatabaseNameAndUUID(ctx, c)