Skip to content
Open
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
6 changes: 3 additions & 3 deletions docs/commands/rdb.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,17 @@ Add an additional ACL rule to a Database Instance.
**Usage:**

```
scw rdb acl add <acl-rule-ips ...> [arg=value ...]
scw rdb acl add [arg=value ...]
```


**Args:**

| Name | | Description |
|------|---|-------------|
| acl-rule-ips | Required | 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. |
| rules.{index}.ip | | IP addresses defined in the ACL rules of the Database Instance |
| rules.{index}.description | | Description of the ACL rule. Use rules.0.description, rules.1.description, etc. to specify individual descriptions for each rule. |
| region | Default: `fr-par` | Region to target. If none is passed will use default region from the config |


Expand Down
108 changes: 84 additions & 24 deletions internal/namespaces/rdb/v1/custom_acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ var aclRuleActionMarshalSpecs = human.EnumMarshalSpecs{
}

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

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

type rdbACLAddPosArgs struct {
Region scw.Region
InstanceID string
ACLRuleIPs scw.IPNet
Expand All @@ -45,12 +59,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 +75,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 +99,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 Down Expand Up @@ -146,6 +181,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 +211,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.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.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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
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.7+dev (go1.24.6; darwin; amd64) cli-e2e-test
url: https://api.scaleway.com/rdb/v1/regions/fr-par/database-engines
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:56:57 GMT
Server:
- Scaleway API Gateway (fr-par-1;edge03)
Strict-Transport-Security:
- max-age=63072000
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- DENY
X-Request-Id:
- 6a07ae8d-8dc9-4a04-acdd-92942462daa8
status: 401 Unauthorized
code: 401
duration: ""
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
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.7+dev (go1.24.6; darwin; amd64) cli-e2e-test
url: https://api.scaleway.com/rdb/v1/regions/fr-par/database-engines
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:56:57 GMT
Server:
- Scaleway API Gateway (fr-par-1;edge03)
Strict-Transport-Security:
- max-age=63072000
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- DENY
X-Request-Id:
- b85e4755-c3fc-427c-a05a-4b953f8b1ad9
status: 401 Unauthorized
code: 401
duration: ""
Loading
Loading