diff --git a/CHANGELOG.md b/CHANGELOG.md index 2520ac743b..859a950867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## v2.4.0 (2024-12-18) + +* [GH-3270](https://github.com/gophercloud/gophercloud/pull/3270) [v2] SG rules: implement bulk create +* [GH-3273](https://github.com/gophercloud/gophercloud/pull/3273) [v2] Add missing fields in Objectstorage and compute API + ## v2.3.0 (2024-12-06) * [GH-3213](https://github.com/gophercloud/gophercloud/pull/3213) [v2] Handle nova api version > 2.87 for hypervisor diff --git a/internal/acceptance/openstack/networking/v2/extensions/extensions.go b/internal/acceptance/openstack/networking/v2/extensions/extensions.go index 4de1401f4d..535bd950ab 100644 --- a/internal/acceptance/openstack/networking/v2/extensions/extensions.go +++ b/internal/acceptance/openstack/networking/v2/extensions/extensions.go @@ -43,8 +43,8 @@ func CreateExternalNetwork(t *testing.T, client *gophercloud.ServiceClient) (*ne t.Logf("Created external network: %s", networkName) - th.AssertEquals(t, network.Name, networkName) - th.AssertEquals(t, network.Description, networkDescription) + th.AssertEquals(t, networkName, network.Name) + th.AssertEquals(t, networkDescription, network.Description) return network, nil } @@ -74,9 +74,9 @@ func CreatePortWithSecurityGroup(t *testing.T, client *gophercloud.ServiceClient t.Logf("Successfully created port: %s", portName) - th.AssertEquals(t, port.Name, portName) - th.AssertEquals(t, port.Description, portDescription) - th.AssertEquals(t, port.NetworkID, networkID) + th.AssertEquals(t, portName, port.Name) + th.AssertEquals(t, portDescription, port.Description) + th.AssertEquals(t, networkID, port.NetworkID) return port, nil } @@ -101,8 +101,8 @@ func CreateSecurityGroup(t *testing.T, client *gophercloud.ServiceClient) (*grou t.Logf("Created security group: %s", secGroup.ID) - th.AssertEquals(t, secGroup.Name, secGroupName) - th.AssertEquals(t, secGroup.Description, secGroupDescription) + th.AssertEquals(t, secGroupName, secGroup.Name) + th.AssertEquals(t, secGroupDescription, secGroup.Description) return secGroup, nil } @@ -140,6 +140,44 @@ func CreateSecurityGroupRule(t *testing.T, client *gophercloud.ServiceClient, se return rule, nil } +// CreateSecurityGroupRulesBulk will create security group rules with a random name +// and random port between 80 and 99. +// An error will be returned if one was failed to be created. +func CreateSecurityGroupRulesBulk(t *testing.T, client *gophercloud.ServiceClient, secGroupID string) ([]rules.SecGroupRule, error) { + t.Logf("Attempting to bulk create security group rules in group: %s", secGroupID) + + sgRulesCreateOpts := make([]rules.CreateOpts, 3) + for i := range 3 { + description := "Rule description" + fromPort := tools.RandomInt(80, 89) + toPort := tools.RandomInt(90, 99) + + sgRulesCreateOpts[i] = rules.CreateOpts{ + Description: description, + Direction: "ingress", + EtherType: "IPv4", + SecGroupID: secGroupID, + PortRangeMin: fromPort, + PortRangeMax: toPort, + Protocol: rules.ProtocolTCP, + } + } + + rules, err := rules.CreateBulk(context.TODO(), client, sgRulesCreateOpts).Extract() + if err != nil { + return rules, err + } + + for i, rule := range rules { + t.Logf("Created security group rule: %s", rule.ID) + + th.AssertEquals(t, sgRulesCreateOpts[i].SecGroupID, rule.SecGroupID) + th.AssertEquals(t, sgRulesCreateOpts[i].Description, rule.Description) + } + + return rules, nil +} + // DeleteSecurityGroup will delete a security group of a specified ID. // A fatal error will occur if the deletion failed. This works best as a // deferred function diff --git a/internal/acceptance/openstack/networking/v2/extensions/security_test.go b/internal/acceptance/openstack/networking/v2/extensions/security_test.go index 670735ec5e..8d855bbb0a 100644 --- a/internal/acceptance/openstack/networking/v2/extensions/security_test.go +++ b/internal/acceptance/openstack/networking/v2/extensions/security_test.go @@ -26,6 +26,12 @@ func TestSecurityGroupsCreateUpdateDelete(t *testing.T) { th.AssertNoErr(t, err) defer DeleteSecurityGroupRule(t, client, rule.ID) + rules, err := CreateSecurityGroupRulesBulk(t, client, group.ID) + th.AssertNoErr(t, err) + for _, r := range rules { + defer DeleteSecurityGroupRule(t, client, r.ID) + } + tools.PrintResource(t, group) var name = "Update group" diff --git a/openstack/compute/v2/servers/results.go b/openstack/compute/v2/servers/results.go index dc51564176..385001c8dd 100644 --- a/openstack/compute/v2/servers/results.go +++ b/openstack/compute/v2/servers/results.go @@ -279,6 +279,10 @@ type Server struct { // AvailabilityZone is the availabilty zone the server is in. AvailabilityZone string `json:"OS-EXT-AZ:availability_zone"` + + // Locked indicates the lock status of the server + // This requires microversion 2.9 or later + Locked *bool `json:"locked"` } type AttachedVolume struct { diff --git a/openstack/compute/v2/servers/testing/fixtures_test.go b/openstack/compute/v2/servers/testing/fixtures_test.go index 1c25b40074..a9c3980d88 100644 --- a/openstack/compute/v2/servers/testing/fixtures_test.go +++ b/openstack/compute/v2/servers/testing/fixtures_test.go @@ -158,7 +158,8 @@ const ServerListBody = ` "progress": 0, "OS-EXT-STS:power_state": 1, "config_drive": "", - "metadata": {} + "metadata": {}, + "locked": true }, { "status": "ACTIVE", @@ -297,7 +298,8 @@ const SingleServerBody = ` "progress": 0, "OS-EXT-STS:power_state": 1, "config_drive": "", - "metadata": {} + "metadata": {}, + "locked": true } } ` @@ -631,6 +633,7 @@ var ( TerminatedAt: time.Time{}, DiskConfig: servers.Manual, AvailabilityZone: "nova", + Locked: func() *bool { b := true; return &b }(), } ConsoleOutput = "abc" diff --git a/openstack/compute/v2/servers/testing/requests_test.go b/openstack/compute/v2/servers/testing/requests_test.go index b6ebefce5e..b50ea185c9 100644 --- a/openstack/compute/v2/servers/testing/requests_test.go +++ b/openstack/compute/v2/servers/testing/requests_test.go @@ -774,6 +774,7 @@ func TestGetFaultyServer(t *testing.T) { FaultyServer := ServerDerp FaultyServer.Fault = DerpFault + FaultyServer.Locked = nil th.CheckDeepEquals(t, FaultyServer, *actual) } @@ -1145,6 +1146,7 @@ func TestCreateServerWithTags(t *testing.T) { tags := []string{"foo", "bar"} ServerDerpTags := ServerDerp ServerDerpTags.Tags = &tags + ServerDerpTags.Locked = nil createOpts := servers.CreateOpts{ Name: "derp", diff --git a/openstack/networking/v2/extensions/security/rules/requests.go b/openstack/networking/v2/extensions/security/rules/requests.go index f2ea9f2d16..8976224b42 100644 --- a/openstack/networking/v2/extensions/security/rules/requests.go +++ b/openstack/networking/v2/extensions/security/rules/requests.go @@ -150,6 +150,23 @@ func Create(ctx context.Context, c *gophercloud.ServiceClient, opts CreateOptsBu return } +// CreateBulk is an operation which adds new security group rules and associates them +// with an existing security group (whose ID is specified in CreateOpts). +// As of Dalmatian (2024.2) neutron only allows bulk creation of rules when +// they all belong to the same tenant and security group. +// https://github.com/openstack/neutron/blob/6183792/neutron/db/securitygroups_db.py#L814-L828 +func CreateBulk(ctx context.Context, c *gophercloud.ServiceClient, opts []CreateOpts) (r CreateBulkResult) { + body, err := gophercloud.BuildRequestBody(opts, "security_group_rules") + if err != nil { + r.Err = err + return + } + + resp, err := c.Post(ctx, rootURL(c), body, &r.Body, nil) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + // Get retrieves a particular security group rule based on its unique ID. func Get(ctx context.Context, c *gophercloud.ServiceClient, id string) (r GetResult) { resp, err := c.Get(ctx, resourceURL(c, id), &r.Body, nil) diff --git a/openstack/networking/v2/extensions/security/rules/results.go b/openstack/networking/v2/extensions/security/rules/results.go index cfdb27fa17..0901ced578 100644 --- a/openstack/networking/v2/extensions/security/rules/results.go +++ b/openstack/networking/v2/extensions/security/rules/results.go @@ -103,6 +103,10 @@ type commonResult struct { gophercloud.Result } +type bulkResult struct { + gophercloud.Result +} + // Extract is a function that accepts a result and extracts a security rule. func (r commonResult) Extract() (*SecGroupRule, error) { var s struct { @@ -112,12 +116,27 @@ func (r commonResult) Extract() (*SecGroupRule, error) { return s.SecGroupRule, err } +// Extract is a function that accepts a result and extracts security rules. +func (r bulkResult) Extract() ([]SecGroupRule, error) { + var s struct { + SecGroupRules []SecGroupRule `json:"security_group_rules"` + } + err := r.ExtractInto(&s) + return s.SecGroupRules, err +} + // CreateResult represents the result of a create operation. Call its Extract // method to interpret it as a SecGroupRule. type CreateResult struct { commonResult } +// CreateBulkResult represents the result of a bulk create operation. Call its +// Extract method to interpret it as a slice of SecGroupRules. +type CreateBulkResult struct { + bulkResult +} + // GetResult represents the result of a get operation. Call its Extract // method to interpret it as a SecGroupRule. type GetResult struct { diff --git a/openstack/networking/v2/extensions/security/rules/testing/requests_test.go b/openstack/networking/v2/extensions/security/rules/testing/requests_test.go index 843d7eb202..10aa461908 100644 --- a/openstack/networking/v2/extensions/security/rules/testing/requests_test.go +++ b/openstack/networking/v2/extensions/security/rules/testing/requests_test.go @@ -222,6 +222,101 @@ func TestCreateAnyProtocol(t *testing.T) { th.AssertNoErr(t, err) } +func TestCreateBulk(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/v2.0/security-group-rules", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "security_group_rules": [ + { + "description": "test description of rule", + "direction": "ingress", + "port_range_min": 80, + "ethertype": "IPv4", + "port_range_max": 80, + "protocol": "tcp", + "remote_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5", + "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a" + }, + { + "description": "test description of rule", + "direction": "ingress", + "port_range_min": 443, + "ethertype": "IPv4", + "port_range_max": 443, + "protocol": "tcp", + "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a" + } + ] +} + `) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + fmt.Fprint(w, ` +{ + "security_group_rules": [ + { + "description": "test description of rule", + "direction": "ingress", + "ethertype": "IPv4", + "port_range_max": 80, + "port_range_min": 80, + "protocol": "tcp", + "remote_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5", + "remote_ip_prefix": null, + "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a", + "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550" + }, + { + "description": "test description of rule", + "direction": "ingress", + "ethertype": "IPv4", + "port_range_max": 443, + "port_range_min": 443, + "protocol": "tcp", + "remote_group_id": null, + "remote_ip_prefix": null, + "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a", + "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550" + } + ] +} + `) + }) + + opts := []rules.CreateOpts{ + { + Description: "test description of rule", + Direction: "ingress", + PortRangeMin: 80, + EtherType: rules.EtherType4, + PortRangeMax: 80, + Protocol: "tcp", + RemoteGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5", + SecGroupID: "a7734e61-b545-452d-a3cd-0189cbd9747a", + }, + { + Description: "test description of rule", + Direction: "ingress", + PortRangeMin: 443, + EtherType: rules.EtherType4, + PortRangeMax: 443, + Protocol: "tcp", + SecGroupID: "a7734e61-b545-452d-a3cd-0189cbd9747a", + }, + } + _, err := rules.CreateBulk(context.TODO(), fake.ServiceClient(), opts).Extract() + th.AssertNoErr(t, err) +} + func TestRequiredCreateOpts(t *testing.T) { res := rules.Create(context.TODO(), fake.ServiceClient(), rules.CreateOpts{Direction: rules.DirIngress}) if res.Err == nil { diff --git a/openstack/objectstorage/v1/containers/results.go b/openstack/objectstorage/v1/containers/results.go index 8e71c15063..fddfa26bf1 100644 --- a/openstack/objectstorage/v1/containers/results.go +++ b/openstack/objectstorage/v1/containers/results.go @@ -111,6 +111,8 @@ type GetHeader struct { TempURLKey2 string `json:"X-Container-Meta-Temp-URL-Key-2"` Timestamp float64 `json:"X-Timestamp,string"` VersionsEnabled bool `json:"-"` + SyncKey string `json:"X-Sync-Key"` + SyncTo string `json:"X-Sync-To"` } func (r *GetHeader) UnmarshalJSON(b []byte) error { diff --git a/openstack/objectstorage/v1/containers/testing/fixtures.go b/openstack/objectstorage/v1/containers/testing/fixtures.go index 55f4f723b6..35c0f734cf 100644 --- a/openstack/objectstorage/v1/containers/testing/fixtures.go +++ b/openstack/objectstorage/v1/containers/testing/fixtures.go @@ -258,6 +258,8 @@ func HandleGetContainerSuccessfully(t *testing.T, options ...option) { w.Header().Set("X-Trans-Id", "tx554ed59667a64c61866f1-0057b4ba37") w.Header().Set("X-Storage-Policy", "test_policy") w.Header().Set("X-Versions-Enabled", "True") + w.Header().Set("X-Sync-Key", "272465181849") + w.Header().Set("X-Sync-To", "anotherContainer") w.WriteHeader(http.StatusNoContent) }) } diff --git a/openstack/objectstorage/v1/containers/testing/requests_test.go b/openstack/objectstorage/v1/containers/testing/requests_test.go index 9a2074d510..fedf0f2fe6 100644 --- a/openstack/objectstorage/v1/containers/testing/requests_test.go +++ b/openstack/objectstorage/v1/containers/testing/requests_test.go @@ -244,6 +244,8 @@ func TestGetContainer(t *testing.T) { StoragePolicy: "test_policy", Timestamp: 1471298837.95721, VersionsEnabled: true, + SyncKey: "272465181849", + SyncTo: "anotherContainer", } actual, err := res.Extract() th.AssertNoErr(t, err) diff --git a/params.go b/params.go index 09b322a6a2..4a2ed6c942 100644 --- a/params.go +++ b/params.go @@ -11,9 +11,11 @@ import ( ) /* -BuildRequestBody builds a map[string]interface from the given `struct`. If -parent is not an empty string, the final map[string]interface returned will -encapsulate the built one. For example: +BuildRequestBody builds a map[string]interface from the given `struct`, or +collection of `structs`. If parent is not an empty string, the final +map[string]interface returned will encapsulate the built one. Parent is +required when passing a list of `structs`. +For example: disk := 1 createOpts := flavors.CreateOpts{ @@ -27,7 +29,29 @@ encapsulate the built one. For example: body, err := gophercloud.BuildRequestBody(createOpts, "flavor") -The above example can be run as-is, however it is recommended to look at how + + opts := []rules.CreateOpts{ + { + Direction: "ingress", + PortRangeMin: 80, + EtherType: rules.EtherType4, + PortRangeMax: 80, + Protocol: "tcp", + SecGroupID: "a7734e61-b545-452d-a3cd-0189cbd9747a", + }, + { + Direction: "ingress", + PortRangeMin: 443, + EtherType: rules.EtherType4, + PortRangeMax: 443, + Protocol: "tcp", + SecGroupID: "a7734e61-b545-452d-a3cd-0189cbd9747a", + }, + } + + body, err := gophercloud.BuildRequestBody(opts, "security_group_rules") + +The above examples can be run as-is, however it is recommended to look at how BuildRequestBody is used within Gophercloud to more fully understand how it fits within the request process as a whole rather than use it directly as shown above. @@ -44,7 +68,8 @@ func BuildRequestBody(opts any, parent string) (map[string]any, error) { } optsMap := make(map[string]any) - if optsValue.Kind() == reflect.Struct { + switch optsValue.Kind() { + case reflect.Struct: //fmt.Printf("optsValue.Kind() is a reflect.Struct: %+v\n", optsValue.Kind()) for i := 0; i < optsValue.NumField(); i++ { v := optsValue.Field(i) @@ -184,9 +209,22 @@ func BuildRequestBody(opts any, parent string) (map[string]any, error) { } //fmt.Printf("optsMap after parent added: %+v\n", optsMap) return optsMap, nil + case reflect.Slice, reflect.Array: + optsMaps := make([]map[string]any, optsValue.Len()) + for i := 0; i < optsValue.Len(); i++ { + b, err := BuildRequestBody(optsValue.Index(i).Interface(), "") + if err != nil { + return nil, err + } + optsMaps[i] = b + } + if parent == "" { + return nil, fmt.Errorf("Parent is required when passing an array or a slice.") + } + return map[string]any{parent: optsMaps}, nil } - // Return an error if the underlying type of 'opts' isn't a struct. - return nil, fmt.Errorf("Options type is not a struct.") + // Return an error if we can't work with the underlying type of 'opts' + return nil, fmt.Errorf("Options type is not a struct, a slice, or an array.") } // EnabledState is a convenience type, mostly used in Create and Update diff --git a/provider_client.go b/provider_client.go index 26c925ceff..ad3edc92d6 100644 --- a/provider_client.go +++ b/provider_client.go @@ -13,7 +13,7 @@ import ( // DefaultUserAgent is the default User-Agent string set in the request header. const ( - DefaultUserAgent = "gophercloud/v2.3.0" + DefaultUserAgent = "gophercloud/v2.4.0" DefaultMaxBackoffRetries = 60 )