diff --git a/cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.cassette.yaml b/cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.cassette.yaml new file mode 100644 index 0000000000..5ea3c6db5d --- /dev/null +++ b/cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.cassette.yaml @@ -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: "" diff --git a/cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.golden b/cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.golden index 3b6c12b6a0..f2736933e0 100644 --- a/cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.golden +++ b/cmd/scw/testdata/test-all-usage-rdb-acl-add-usage.golden @@ -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: diff --git a/docs/commands/rdb.md b/docs/commands/rdb.md index c90d6c6d2c..0e1d9d9179 100644 --- a/docs/commands/rdb.md +++ b/docs/commands/rdb.md @@ -95,7 +95,7 @@ Add an additional ACL rule to a Database Instance. **Usage:** ``` -scw rdb acl add [arg=value ...] +scw rdb acl add [arg=value ...] ``` @@ -103,9 +103,9 @@ scw rdb acl add [arg=value ...] | 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 | diff --git a/internal/namespaces/rdb/v1/custom_acl.go b/internal/namespaces/rdb/v1/custom_acl.go index 633fc83f2d..7557ebeaf2 100644 --- a/internal/namespaces/rdb/v1/custom_acl.go +++ b/internal/namespaces/rdb/v1/custom_acl.go @@ -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 @@ -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, }, { @@ -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) @@ -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{ @@ -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) @@ -175,7 +211,6 @@ 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, @@ -183,26 +218,51 @@ func aclDeleteBuilder(c *core.Command) *core.Command { 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{ diff --git a/internal/namespaces/rdb/v1/custom_acl_test.go b/internal/namespaces/rdb/v1/custom_acl_test.go index d6e63ba8c8..5fe5504a7f 100644 --- a/internal/namespaces/rdb/v1/custom_acl_test.go +++ b/internal/namespaces/rdb/v1/custom_acl_test.go @@ -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) { diff --git a/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-general-description-and-specific-descriptions.cassette.yaml b/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-general-description-and-specific-descriptions.cassette.yaml new file mode 100644 index 0000000000..361d1a80fb --- /dev/null +++ b/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-general-description-and-specific-descriptions.cassette.yaml @@ -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: "" diff --git a/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-individual-descriptions.cassette.yaml b/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-individual-descriptions.cassette.yaml new file mode 100644 index 0000000000..de8f623497 --- /dev/null +++ b/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-individual-descriptions.cassette.yaml @@ -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: "" diff --git a/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-partial-descriptions.cassette.yaml b/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-partial-descriptions.cassette.yaml new file mode 100644 index 0000000000..7379aad17c --- /dev/null +++ b/internal/namespaces/rdb/v1/testdata/test-set-acl-multiple-with-partial-descriptions.cassette.yaml @@ -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: + - 608f88c2-b8e7-45ed-905a-c2b45500e92b + status: 401 Unauthorized + code: 401 + duration: ""