Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.cassette.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
version: 1
interactions:
- request:
body: '{"message":"authentication is denied","method":"api_key","reason":"not_found","type":"denied_authentication"}'
form: {}
headers:
User-Agent:
- scaleway-sdk-go/v1.0.0-beta.35.0.20250917154444-1d3cdbf4ce0d (go1.24.6; darwin;
amd64) cli-e2e-test
url: https://api.scaleway.com/iam/v1alpha1/api-keys/SCWXXXXXXXXXXXXXXXXX
method: GET
response:
body: '{"message":"authentication is denied","method":"api_key","reason":"not_found","type":"denied_authentication"}'
headers:
Content-Length:
- "109"
Content-Security-Policy:
- default-src 'none'; frame-ancestors 'none'
Content-Type:
- application/json
Date:
- Mon, 29 Sep 2025 08:31:55 GMT
Server:
- Scaleway API Gateway (fr-par-1;edge01)
Strict-Transport-Security:
- max-age=63072000
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- DENY
X-Request-Id:
- 32b2b5f8-48c2-4aef-b89e-e8da1b64cd32
status: 401 Unauthorized
code: 401
duration: ""
3 changes: 2 additions & 1 deletion cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.golden
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ USAGE:
ARGS:
acl-rule-ips IP addresses defined in the ACL rules of the Database Instance
instance-id ID of the Database Instance
[description] Description of the ACL rule. Indexes are not yet supported so the description will be applied to all the rules of the command.
[description] Description of the ACL rule. If multiple IPs are provided, this description will be applied to all rules unless specific descriptions are provided.
[descriptions] Descriptions of the ACL rules
[region=fr-par] Region to target. If none is passed will use default region from the config

FLAGS:
Expand Down
7 changes: 4 additions & 3 deletions docs/commands/rdb.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,10 @@ scw rdb acl add <acl-rule-ips ...> [arg=value ...]

| Name | | Description |
|------|---|-------------|
| acl-rule-ips | Required | IP addresses defined in the ACL rules of the Database Instance |
| acl-rule-ips | | IP addresses defined in the ACL rules of the Database Instance |
| instance-id | Required | ID of the Database Instance |
| description | | Description of the ACL rule. Indexes are not yet supported so the description will be applied to all the rules of the command. |
| description | | Description of the ACL rule. If multiple IPs are provided, this description will be applied to all rules unless specific descriptions are provided. |
| descriptions | | Descriptions of the ACL rules |
| region | Default: `fr-par` | Region to target. If none is passed will use default region from the config |


Expand All @@ -125,7 +126,7 @@ scw rdb acl delete <acl-rule-ips ...> [arg=value ...]

| Name | | Description |
|------|---|-------------|
| acl-rule-ips | Required | IP addresses defined in the ACL rules of the Database Instance |
| acl-rule-ips | | IP addresses defined in the ACL rules of the Database Instance |
| instance-id | Required | ID of the Database Instance |
| region | Default: `fr-par` | Region to target. If none is passed will use default region from the config |

Expand Down
111 changes: 82 additions & 29 deletions internal/namespaces/rdb/v1/custom_acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,17 @@ var aclRuleActionMarshalSpecs = human.EnumMarshalSpecs{
}

type rdbACLCustomArgs struct {
Region scw.Region
InstanceID string
ACLRuleIPs scw.IPNet
Description string
Region scw.Region
InstanceID string
ACLRuleIPs []scw.IPNet
}

type rdbACLAddCustomArgs struct {
Region scw.Region
InstanceID string
ACLRuleIPs []scw.IPNet
Description string
Descriptions []string
}

type CustomACLResult struct {
Expand All @@ -45,12 +52,12 @@ func rdbACLCustomResultMarshalerFunc(i any, opt *human.MarshalOpt) (string, erro
}

func aclAddBuilder(c *core.Command) *core.Command {
c.ArgsType = reflect.TypeOf(rdbACLCustomArgs{})
c.ArgsType = reflect.TypeOf(rdbACLAddCustomArgs{})
c.ArgSpecs = core.ArgSpecs{
{
Name: "acl-rule-ips",
Short: "IP addresses defined in the ACL rules of the Database Instance",
Required: true,
Required: false,
Positional: true,
},
{
Expand All @@ -61,12 +68,19 @@ func aclAddBuilder(c *core.Command) *core.Command {
},
{
Name: "description",
Short: "Description of the ACL rule. Indexes are not yet supported so the description will be applied to all the rules of the command.",
Short: "Description of the ACL rule. If multiple IPs are provided, this description will be applied to all rules unless specific descriptions are provided.",
Required: false,
Positional: false,
},
{
Name: "descriptions",
Short: "Descriptions of the ACL rules",
Required: false,
Positional: false,
},
core.RegionArgSpec(),
}
c.AcceptMultiplePositionalArgs = true

c.Interceptor = func(ctx context.Context, argsI any, runner core.CommandRunner) (any, error) {
respI, err := runner(ctx, argsI)
Expand All @@ -78,39 +92,53 @@ func aclAddBuilder(c *core.Command) *core.Command {
}

c.Run = func(ctx context.Context, argsI any) (i any, e error) {
args := argsI.(*rdbACLCustomArgs)
args := argsI.(*rdbACLAddCustomArgs)
client := core.ExtractClient(ctx)
api := rdb.NewAPI(client)

description := args.Description
if description == "" {
description = "Allow " + args.ACLRuleIPs.String()
// Build rules with general and specific descriptions
rules := make([]*rdb.ACLRuleRequest, 0, len(args.ACLRuleIPs))
for i, ip := range args.ACLRuleIPs {
description := args.Description
if description == "" {
description = "Allow " + ip.String()
}
if i < len(args.Descriptions) && args.Descriptions[i] != "" {
description = args.Descriptions[i]
}
rules = append(rules, &rdb.ACLRuleRequest{
IP: ip,
Description: description,
})
}

rule, err := api.AddInstanceACLRules(&rdb.AddInstanceACLRulesRequest{
Region: args.Region,
InstanceID: args.InstanceID,
Rules: []*rdb.ACLRuleRequest{
{
IP: args.ACLRuleIPs,
Description: description,
},
},
Rules: rules,
}, scw.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("failed to add ACL rule: %w", err)
}

// Create success message
var message string
if len(args.ACLRuleIPs) == 1 {
message = fmt.Sprintf("ACL rule %s successfully added", args.ACLRuleIPs[0].String())
} else {
message = fmt.Sprintf("%d ACL rules successfully added", len(args.ACLRuleIPs))
}

return &CustomACLResult{
Rules: rule.Rules,
Success: core.SuccessResult{
Message: fmt.Sprintf("ACL rule %s successfully added", args.ACLRuleIPs.String()),
Message: message,
},
}, nil
}

c.WaitFunc = func(ctx context.Context, argsI, respI any) (any, error) {
args := argsI.(*rdbACLCustomArgs)
args := argsI.(*rdbACLAddCustomArgs)
api := rdb.NewAPI(core.ExtractClient(ctx))

_, err := api.WaitForInstance(&rdb.WaitForInstanceRequest{
Expand All @@ -135,7 +163,7 @@ func aclDeleteBuilder(c *core.Command) *core.Command {
{
Name: "acl-rule-ips",
Short: "IP addresses defined in the ACL rules of the Database Instance",
Required: true,
Required: false,
Positional: true,
},
{
Expand All @@ -146,6 +174,7 @@ func aclDeleteBuilder(c *core.Command) *core.Command {
},
core.RegionArgSpec(),
}
c.AcceptMultiplePositionalArgs = true

c.Interceptor = func(ctx context.Context, argsI any, runner core.CommandRunner) (any, error) {
respI, err := runner(ctx, argsI)
Expand Down Expand Up @@ -175,34 +204,58 @@ func aclDeleteBuilder(c *core.Command) *core.Command {

// The API returns 200 OK even if the rule was not set in the first place, so we have to check if the rule was present
// before deleting it to warn them if nothing was done
ruleWasSet := false
rules, err := api.ListInstanceACLRules(&rdb.ListInstanceACLRulesRequest{
Region: args.Region,
InstanceID: args.InstanceID,
}, scw.WithContext(ctx), scw.WithAllPages())
if err != nil {
return nil, fmt.Errorf("failed to list ACL rules: %w", err)
}

// Check which rules were actually set
existingIPs := make(map[string]bool)
for _, rule := range rules.Rules {
if rule.IP.String() == args.ACLRuleIPs.String() {
ruleWasSet = true
}
existingIPs[rule.IP.String()] = true
}

// Convert IPs to strings for deletion
ipStrings := make([]string, len(args.ACLRuleIPs))
for i, ip := range args.ACLRuleIPs {
ipStrings[i] = ip.String()
}

_, err = api.DeleteInstanceACLRules(&rdb.DeleteInstanceACLRulesRequest{
Region: args.Region,
InstanceID: args.InstanceID,
ACLRuleIPs: []string{args.ACLRuleIPs.String()},
ACLRuleIPs: ipStrings,
}, scw.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("failed to remove ACL rule: %w", err)
return nil, fmt.Errorf("failed to remove ACL rules: %w", err)
}

// Count how many rules were actually deleted
deletedCount := 0
for _, ip := range args.ACLRuleIPs {
if existingIPs[ip.String()] {
deletedCount++
}
}

var message string
if ruleWasSet {
message = fmt.Sprintf("ACL rule %s successfully deleted", args.ACLRuleIPs.String())
if len(args.ACLRuleIPs) == 1 {
if deletedCount > 0 {
message = fmt.Sprintf(
"ACL rule %s successfully deleted",
args.ACLRuleIPs[0].String(),
)
} else {
message = fmt.Sprintf("ACL rule %s was not set", args.ACLRuleIPs[0].String())
}
} else {
message = fmt.Sprintf("ACL rule %s was not set", args.ACLRuleIPs.String())
message = fmt.Sprintf("%d ACL rules successfully deleted", deletedCount)
if deletedCount < len(args.ACLRuleIPs) {
message += fmt.Sprintf(" (%d were not set)", len(args.ACLRuleIPs)-deletedCount)
}
}

return &CustomACLResult{
Expand Down
51 changes: 51 additions & 0 deletions internal/namespaces/rdb/v1/custom_acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,57 @@ func Test_SetACL(t *testing.T) {
),
AfterFunc: deleteInstance(),
}))

t.Run("Multiple with individual descriptions", core.Test(&core.TestConfig{
Commands: rdb.GetCommands(),
BeforeFunc: core.BeforeFuncCombine(
fetchLatestEngine("PostgreSQL"),
createInstance("{{.latestEngine}}"),
),
Cmd: "scw rdb acl add 1.1.1.1 2.2.2.2 3.3.3.3 instance-id={{ .Instance.ID }} descriptions.0=first descriptions.1=second descriptions.2=third --wait",
Check: core.TestCheckCombine(
core.TestCheckGolden(),
func(t *testing.T, ctx *core.CheckFuncCtx) {
t.Helper()
verifyACL(t, ctx, []string{"0.0.0.0/0", "1.1.1.1/32", "2.2.2.2/32", "3.3.3.3/32"})
},
),
AfterFunc: deleteInstance(),
}))

t.Run("Multiple with partial descriptions", core.Test(&core.TestConfig{
Commands: rdb.GetCommands(),
BeforeFunc: core.BeforeFuncCombine(
fetchLatestEngine("PostgreSQL"),
createInstance("{{.latestEngine}}"),
),
Cmd: "scw rdb acl add 1.1.1.1 2.2.2.2 3.3.3.3 instance-id={{ .Instance.ID }} descriptions.0=first descriptions.1=second descriptions.2=third --wait",
Check: core.TestCheckCombine(
core.TestCheckGolden(),
func(t *testing.T, ctx *core.CheckFuncCtx) {
t.Helper()
verifyACL(t, ctx, []string{"0.0.0.0/0", "1.1.1.1/32", "2.2.2.2/32", "3.3.3.3/32"})
},
),
AfterFunc: deleteInstance(),
}))

t.Run("Multiple with general description and specific descriptions", core.Test(&core.TestConfig{
Commands: rdb.GetCommands(),
BeforeFunc: core.BeforeFuncCombine(
fetchLatestEngine("PostgreSQL"),
createInstance("{{.latestEngine}}"),
),
Cmd: "scw rdb acl add 1.1.1.1 2.2.2.2 3.3.3.3 instance-id={{ .Instance.ID }} description=default descriptions.0=first descriptions.1=second --wait",
Check: core.TestCheckCombine(
core.TestCheckGolden(),
func(t *testing.T, ctx *core.CheckFuncCtx) {
t.Helper()
verifyACL(t, ctx, []string{"0.0.0.0/0", "1.1.1.1/32", "2.2.2.2/32", "3.3.3.3/32"})
},
),
AfterFunc: deleteInstance(),
}))
}

func verifyACLCustomResponse(t *testing.T, res *rdb.CustomACLResult, expectedRules []string) {
Expand Down
Loading
Loading