diff --git a/internal/acceptance/clients/conditions.go b/internal/acceptance/clients/conditions.go index 619d31a94e..c48ac208a9 100644 --- a/internal/acceptance/clients/conditions.go +++ b/internal/acceptance/clients/conditions.go @@ -119,7 +119,7 @@ func getReleaseFromEnv(t *testing.T) string { // release. Releases are named such as 'stable/mitaka', master, etc. func SkipRelease(t *testing.T, release string) { current := getReleaseFromEnv(t) - if current == release { + if current == strings.TrimPrefix(release, "stable/") { t.Skipf("this is not supported in %s", release) } } diff --git a/internal/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go b/internal/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go index fee8cacd38..5f4d771614 100644 --- a/internal/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go +++ b/internal/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go @@ -4,6 +4,7 @@ package layer3 import ( "context" + "strings" "testing" "github.com/gophercloud/gophercloud/v2/internal/acceptance/clients" @@ -203,3 +204,62 @@ func TestLayer3FloatingIPsCreateDeleteBySubnetID(t *testing.T) { DeleteFloatingIP(t, client, fip.ID) } + +func TestLayer3FloatingIPsRevision(t *testing.T) { + client, err := clients.NewNetworkV2Client() + th.AssertNoErr(t, err) + + choices, err := clients.AcceptanceTestChoicesFromEnv() + th.AssertNoErr(t, err) + + fip, err := CreateFloatingIP(t, client, choices.ExternalNetworkID, "") + th.AssertNoErr(t, err) + defer DeleteFloatingIP(t, client, fip.ID) + + tools.PrintResource(t, fip) + + // Store the current revision number. + oldRevisionNumber := fip.RevisionNumber + + // Update the fip without revision number. + // This should work. + newDescription := "" + updateOpts := &floatingips.UpdateOpts{ + Description: &newDescription, + } + fip, err = floatingips.Update(context.TODO(), client, fip.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, fip) + + // This should fail due to an old revision number. + newDescription = "new description" + updateOpts = &floatingips.UpdateOpts{ + Description: &newDescription, + RevisionNumber: &oldRevisionNumber, + } + _, err = floatingips.Update(context.TODO(), client, fip.ID, updateOpts).Extract() + th.AssertErr(t, err) + if !strings.Contains(err.Error(), "RevisionNumberConstraintFailed") { + t.Fatalf("expected to see an error of type RevisionNumberConstraintFailed, but got the following error instead: %v", err) + } + + // Reread the fip to show that it did not change. + fip, err = floatingips.Get(context.TODO(), client, fip.ID).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, fip) + + // This should work because now we do provide a valid revision number. + newDescription = "new description" + updateOpts = &floatingips.UpdateOpts{ + Description: &newDescription, + RevisionNumber: &fip.RevisionNumber, + } + fip, err = floatingips.Update(context.TODO(), client, fip.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, fip) + + th.AssertEquals(t, fip.Description, newDescription) +} diff --git a/internal/acceptance/openstack/networking/v2/extensions/layer3/routers_test.go b/internal/acceptance/openstack/networking/v2/extensions/layer3/routers_test.go index 8d1800f719..5930be2a1e 100644 --- a/internal/acceptance/openstack/networking/v2/extensions/layer3/routers_test.go +++ b/internal/acceptance/openstack/networking/v2/extensions/layer3/routers_test.go @@ -4,6 +4,7 @@ package layer3 import ( "context" + "strings" "testing" "github.com/gophercloud/gophercloud/v2/internal/acceptance/clients" @@ -213,3 +214,70 @@ func TestLayer3RouterAgents(t *testing.T) { th.AssertEquals(t, found, true) } + +func TestLayer3RouterRevision(t *testing.T) { + // https://bugs.launchpad.net/neutron/+bug/2101871 + clients.SkipRelease(t, "stable/2023.2") + client, err := clients.NewNetworkV2Client() + th.AssertNoErr(t, err) + + network, err := networking.CreateNetwork(t, client) + th.AssertNoErr(t, err) + defer networking.DeleteNetwork(t, client, network.ID) + + router, err := CreateRouter(t, client, network.ID) + th.AssertNoErr(t, err) + defer DeleteRouter(t, client, router.ID) + + tools.PrintResource(t, router) + + // Store the current revision number. + oldRevisionNumber := router.RevisionNumber + + // Update the router without revision number. + // This should work. + newName := tools.RandomString("TESTACC-", 8) + newDescription := "" + updateOpts := &routers.UpdateOpts{ + Name: newName, + Description: &newDescription, + } + router, err = routers.Update(context.TODO(), client, router.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, router) + + // This should fail due to an old revision number. + newDescription = "new description" + updateOpts = &routers.UpdateOpts{ + Name: newName, + Description: &newDescription, + RevisionNumber: &oldRevisionNumber, + } + _, err = routers.Update(context.TODO(), client, router.ID, updateOpts).Extract() + th.AssertErr(t, err) + if !strings.Contains(err.Error(), "RevisionNumberConstraintFailed") { + t.Fatalf("expected to see an error of type RevisionNumberConstraintFailed, but got the following error instead: %v", err) + } + + // Reread the router to show that it did not change. + router, err = routers.Get(context.TODO(), client, router.ID).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, router) + + // This should work because now we do provide a valid revision number. + newDescription = "new description" + updateOpts = &routers.UpdateOpts{ + Name: newName, + Description: &newDescription, + RevisionNumber: &router.RevisionNumber, + } + router, err = routers.Update(context.TODO(), client, router.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, router) + + th.AssertEquals(t, router.Name, newName) + th.AssertEquals(t, router.Description, newDescription) +} diff --git a/internal/acceptance/openstack/networking/v2/extensions/qos/policies/policies_test.go b/internal/acceptance/openstack/networking/v2/extensions/qos/policies/policies_test.go index fc9563df38..9ee2304c75 100644 --- a/internal/acceptance/openstack/networking/v2/extensions/qos/policies/policies_test.go +++ b/internal/acceptance/openstack/networking/v2/extensions/qos/policies/policies_test.go @@ -4,6 +4,7 @@ package policies import ( "context" + "strings" "testing" "github.com/gophercloud/gophercloud/v2/internal/acceptance/clients" @@ -59,3 +60,68 @@ func TestPoliciesCRUD(t *testing.T) { th.AssertEquals(t, found, true) } + +func TestPoliciesRevision(t *testing.T) { + client, err := clients.NewNetworkV2Client() + th.AssertNoErr(t, err) + + // Skip these tests if we don't have the required extension + v2.RequireNeutronExtension(t, client, "qos") + + // Create a policy + policy, err := CreateQoSPolicy(t, client) + th.AssertNoErr(t, err) + defer DeleteQoSPolicy(t, client, policy.ID) + + tools.PrintResource(t, policy) + + // Store the current revision number. + oldRevisionNumber := policy.RevisionNumber + + // Update the policy without revision number. + // This should work. + newName := tools.RandomString("TESTACC-", 8) + newDescription := "" + updateOpts := &policies.UpdateOpts{ + Name: newName, + Description: &newDescription, + } + policy, err = policies.Update(context.TODO(), client, policy.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, policy) + + // This should fail due to an old revision number. + newDescription = "new description" + updateOpts = &policies.UpdateOpts{ + Name: newName, + Description: &newDescription, + RevisionNumber: &oldRevisionNumber, + } + _, err = policies.Update(context.TODO(), client, policy.ID, updateOpts).Extract() + th.AssertErr(t, err) + if !strings.Contains(err.Error(), "RevisionNumberConstraintFailed") { + t.Fatalf("expected to see an error of type RevisionNumberConstraintFailed, but got the following error instead: %v", err) + } + + // Reread the policy to show that it did not change. + policy, err = policies.Get(context.TODO(), client, policy.ID).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, policy) + + // This should work because now we do provide a valid revision number. + newDescription = "new description" + updateOpts = &policies.UpdateOpts{ + Name: newName, + Description: &newDescription, + RevisionNumber: &policy.RevisionNumber, + } + policy, err = policies.Update(context.TODO(), client, policy.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, policy) + + th.AssertEquals(t, policy.Name, newName) + th.AssertEquals(t, policy.Description, newDescription) +} diff --git a/internal/acceptance/openstack/networking/v2/extensions/security_test.go b/internal/acceptance/openstack/networking/v2/extensions/security_test.go index 8d855bbb0a..33fc3f176d 100644 --- a/internal/acceptance/openstack/networking/v2/extensions/security_test.go +++ b/internal/acceptance/openstack/networking/v2/extensions/security_test.go @@ -4,6 +4,7 @@ package extensions import ( "context" + "strings" "testing" "github.com/gophercloud/gophercloud/v2/internal/acceptance/clients" @@ -93,3 +94,65 @@ func TestSecurityGroupsPort(t *testing.T) { tools.PrintResource(t, port) } + +func TestSecurityGroupsRevision(t *testing.T) { + client, err := clients.NewNetworkV2Client() + th.AssertNoErr(t, err) + + // Create a group + group, err := CreateSecurityGroup(t, client) + th.AssertNoErr(t, err) + defer DeleteSecurityGroup(t, client, group.ID) + + tools.PrintResource(t, group) + + // Store the current revision number. + oldRevisionNumber := group.RevisionNumber + + // Update the group without revision number. + // This should work. + newName := tools.RandomString("TESTACC-", 8) + newDescription := "" + updateOpts := &groups.UpdateOpts{ + Name: newName, + Description: &newDescription, + } + group, err = groups.Update(context.TODO(), client, group.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, group) + + // This should fail due to an old revision number. + newDescription = "new description" + updateOpts = &groups.UpdateOpts{ + Name: newName, + Description: &newDescription, + RevisionNumber: &oldRevisionNumber, + } + _, err = groups.Update(context.TODO(), client, group.ID, updateOpts).Extract() + th.AssertErr(t, err) + if !strings.Contains(err.Error(), "RevisionNumberConstraintFailed") { + t.Fatalf("expected to see an error of type RevisionNumberConstraintFailed, but got the following error instead: %v", err) + } + + // Reread the group to show that it did not change. + group, err = groups.Get(context.TODO(), client, group.ID).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, group) + + // This should work because now we do provide a valid revision number. + newDescription = "new description" + updateOpts = &groups.UpdateOpts{ + Name: newName, + Description: &newDescription, + RevisionNumber: &group.RevisionNumber, + } + group, err = groups.Update(context.TODO(), client, group.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, group) + + th.AssertEquals(t, group.Name, newName) + th.AssertEquals(t, group.Description, newDescription) +} diff --git a/internal/acceptance/openstack/networking/v2/extensions/subnetpools/subnetpools_test.go b/internal/acceptance/openstack/networking/v2/extensions/subnetpools/subnetpools_test.go index 8a0575d8fe..8c9cb3120b 100644 --- a/internal/acceptance/openstack/networking/v2/extensions/subnetpools/subnetpools_test.go +++ b/internal/acceptance/openstack/networking/v2/extensions/subnetpools/subnetpools_test.go @@ -4,6 +4,7 @@ package v2 import ( "context" + "strings" "testing" "github.com/gophercloud/gophercloud/v2/internal/acceptance/clients" @@ -55,3 +56,63 @@ func TestSubnetPoolsCRUD(t *testing.T) { th.AssertEquals(t, found, true) } + +func TestSubnetPoolsRevision(t *testing.T) { + client, err := clients.NewNetworkV2Client() + th.AssertNoErr(t, err) + + // Create a subnetpool + subnetPool, err := CreateSubnetPool(t, client) + th.AssertNoErr(t, err) + defer DeleteSubnetPool(t, client, subnetPool.ID) + + // Store the current revision number. + oldRevisionNumber := subnetPool.RevisionNumber + + // Update the subnet pool without revision number. + // This should work. + newName := tools.RandomString("TESTACC-", 8) + newDescription := "" + updateOpts := &subnetpools.UpdateOpts{ + Name: newName, + Description: &newDescription, + } + subnetPool, err = subnetpools.Update(context.TODO(), client, subnetPool.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, subnetPool) + + // This should fail due to an old revision number. + newDescription = "new description" + updateOpts = &subnetpools.UpdateOpts{ + Name: newName, + Description: &newDescription, + RevisionNumber: &oldRevisionNumber, + } + _, err = subnetpools.Update(context.TODO(), client, subnetPool.ID, updateOpts).Extract() + th.AssertErr(t, err) + if !strings.Contains(err.Error(), "RevisionNumberConstraintFailed") { + t.Fatalf("expected to see an error of type RevisionNumberConstraintFailed, but got the following error instead: %v", err) + } + + // Reread the subnet pool to show that it did not change. + subnetPool, err = subnetpools.Get(context.TODO(), client, subnetPool.ID).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, subnetPool) + + // This should work because now we do provide a valid revision number. + newDescription = "new description" + updateOpts = &subnetpools.UpdateOpts{ + Name: newName, + Description: &newDescription, + RevisionNumber: &subnetPool.RevisionNumber, + } + subnetPool, err = subnetpools.Update(context.TODO(), client, subnetPool.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, subnetPool) + + th.AssertEquals(t, subnetPool.Name, newName) + th.AssertEquals(t, subnetPool.Description, newDescription) +} diff --git a/internal/acceptance/openstack/networking/v2/extensions/trunks/trunks_test.go b/internal/acceptance/openstack/networking/v2/extensions/trunks/trunks_test.go index 65ebd90a78..3e0af63175 100644 --- a/internal/acceptance/openstack/networking/v2/extensions/trunks/trunks_test.go +++ b/internal/acceptance/openstack/networking/v2/extensions/trunks/trunks_test.go @@ -5,6 +5,7 @@ package trunks import ( "context" "sort" + "strings" "testing" "github.com/gophercloud/gophercloud/v2/internal/acceptance/clients" @@ -17,56 +18,40 @@ import ( func TestTrunkCRUD(t *testing.T) { client, err := clients.NewNetworkV2Client() - if err != nil { - t.Fatalf("Unable to create a network client: %v", err) - } + th.AssertNoErr(t, err) // Skip these tests if we don't have the required extension v2.RequireNeutronExtension(t, client, "trunk") // Create Network network, err := v2.CreateNetwork(t, client) - if err != nil { - t.Fatalf("Unable to create network: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeleteNetwork(t, client, network.ID) // Create Subnet subnet, err := v2.CreateSubnet(t, client, network.ID) - if err != nil { - t.Fatalf("Unable to create subnet: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeleteSubnet(t, client, subnet.ID) // Create port parentPort, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, parentPort.ID) subport1, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, subport1.ID) subport2, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, subport2.ID) trunk, err := CreateTrunk(t, client, parentPort.ID, subport1.ID, subport2.ID) - if err != nil { - t.Fatalf("Unable to create trunk: %v", err) - } + th.AssertNoErr(t, err) defer DeleteTrunk(t, client, trunk.ID) _, err = trunks.Get(context.TODO(), client, trunk.ID).Extract() - if err != nil { - t.Fatalf("Unable to get trunk: %v", err) - } + th.AssertNoErr(t, err) // Update Trunk name := "" @@ -76,9 +61,7 @@ func TestTrunkCRUD(t *testing.T) { Description: &description, } updatedTrunk, err := trunks.Update(context.TODO(), client, trunk.ID, updateOpts).Extract() - if err != nil { - t.Fatalf("Unable to update trunk: %v", err) - } + th.AssertNoErr(t, err) if trunk.Name == updatedTrunk.Name { t.Fatalf("Trunk name was not updated correctly") @@ -93,9 +76,7 @@ func TestTrunkCRUD(t *testing.T) { // Get subports subports, err := trunks.GetSubports(context.TODO(), client, trunk.ID).Extract() - if err != nil { - t.Fatalf("Unable to get subports from the Trunk: %v", err) - } + th.AssertNoErr(t, err) th.AssertDeepEquals(t, trunk.Subports[0], subports[0]) th.AssertDeepEquals(t, trunk.Subports[1], subports[1]) @@ -104,22 +85,16 @@ func TestTrunkCRUD(t *testing.T) { func TestTrunkList(t *testing.T) { client, err := clients.NewNetworkV2Client() - if err != nil { - t.Fatalf("Unable to create a network client: %v", err) - } + th.AssertNoErr(t, err) // Skip these tests if we don't have the required extension v2.RequireNeutronExtension(t, client, "trunk") allPages, err := trunks.List(client, nil).AllPages(context.TODO()) - if err != nil { - t.Fatalf("Unable to list trunks: %v", err) - } + th.AssertNoErr(t, err) allTrunks, err := trunks.ExtractTrunks(allPages) - if err != nil { - t.Fatalf("Unable to extract trunks: %v", err) - } + th.AssertNoErr(t, err) for _, trunk := range allTrunks { tools.PrintResource(t, trunk) @@ -128,50 +103,36 @@ func TestTrunkList(t *testing.T) { func TestTrunkSubportOperation(t *testing.T) { client, err := clients.NewNetworkV2Client() - if err != nil { - t.Fatalf("Unable to create a network client: %v", err) - } + th.AssertNoErr(t, err) // Skip these tests if we don't have the required extension v2.RequireNeutronExtension(t, client, "trunk") // Create Network network, err := v2.CreateNetwork(t, client) - if err != nil { - t.Fatalf("Unable to create network: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeleteNetwork(t, client, network.ID) // Create Subnet subnet, err := v2.CreateSubnet(t, client, network.ID) - if err != nil { - t.Fatalf("Unable to create subnet: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeleteSubnet(t, client, subnet.ID) // Create port parentPort, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, parentPort.ID) subport1, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, subport1.ID) subport2, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, subport2.ID) trunk, err := CreateTrunk(t, client, parentPort.ID) - if err != nil { - t.Fatalf("Unable to create trunk: %v", err) - } + th.AssertNoErr(t, err) defer DeleteTrunk(t, client, trunk.ID) // Add subports to the trunk @@ -190,9 +151,7 @@ func TestTrunkSubportOperation(t *testing.T) { }, } updatedTrunk, err := trunks.AddSubports(context.TODO(), client, trunk.ID, addSubportsOpts).Extract() - if err != nil { - t.Fatalf("Unable to add subports to the Trunk: %v", err) - } + th.AssertNoErr(t, err) th.AssertEquals(t, 2, len(updatedTrunk.Subports)) th.AssertDeepEquals(t, addSubportsOpts.Subports[0], updatedTrunk.Subports[0]) th.AssertDeepEquals(t, addSubportsOpts.Subports[1], updatedTrunk.Subports[1]) @@ -205,58 +164,42 @@ func TestTrunkSubportOperation(t *testing.T) { }, } updatedAgainTrunk, err := trunks.RemoveSubports(context.TODO(), client, trunk.ID, subRemoveOpts).Extract() - if err != nil { - t.Fatalf("Unable to remove subports from the Trunk: %v", err) - } + th.AssertNoErr(t, err) th.AssertDeepEquals(t, trunk.Subports, updatedAgainTrunk.Subports) } func TestTrunkTags(t *testing.T) { client, err := clients.NewNetworkV2Client() - if err != nil { - t.Fatalf("Unable to create a network client: %v", err) - } + th.AssertNoErr(t, err) // Skip these tests if we don't have the required extension v2.RequireNeutronExtension(t, client, "trunk") // Create Network network, err := v2.CreateNetwork(t, client) - if err != nil { - t.Fatalf("Unable to create network: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeleteNetwork(t, client, network.ID) // Create Subnet subnet, err := v2.CreateSubnet(t, client, network.ID) - if err != nil { - t.Fatalf("Unable to create subnet: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeleteSubnet(t, client, subnet.ID) // Create port parentPort, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, parentPort.ID) subport1, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, subport1.ID) subport2, err := v2.CreatePort(t, client, network.ID, subnet.ID) - if err != nil { - t.Fatalf("Unable to create port: %v", err) - } + th.AssertNoErr(t, err) defer v2.DeletePort(t, client, subport2.ID) trunk, err := CreateTrunk(t, client, parentPort.ID, subport1.ID, subport2.ID) - if err != nil { - t.Fatalf("Unable to create trunk: %v", err) - } + th.AssertNoErr(t, err) defer DeleteTrunk(t, client, trunk.ID) tagReplaceAllOpts := attributestags.ReplaceAllOpts{ @@ -264,14 +207,10 @@ func TestTrunkTags(t *testing.T) { Tags: []string{"a", "b", "c"}, } _, err = attributestags.ReplaceAll(context.TODO(), client, "trunks", trunk.ID, tagReplaceAllOpts).Extract() - if err != nil { - t.Fatalf("Unable to set trunk tags: %v", err) - } + th.AssertNoErr(t, err) gtrunk, err := trunks.Get(context.TODO(), client, trunk.ID).Extract() - if err != nil { - t.Fatalf("Unable to get trunk: %v", err) - } + th.AssertNoErr(t, err) tags := gtrunk.Tags sort.Strings(tags) // Ensure ordering, older OpenStack versions aren't sorted... th.AssertDeepEquals(t, []string{"a", "b", "c"}, tags) @@ -297,3 +236,90 @@ func TestTrunkTags(t *testing.T) { th.AssertNoErr(t, err) th.AssertEquals(t, 0, len(tags)) } + +func TestTrunkRevision(t *testing.T) { + client, err := clients.NewNetworkV2Client() + th.AssertNoErr(t, err) + + // Skip these tests if we don't have the required extension + v2.RequireNeutronExtension(t, client, "trunk") + + // Create Network + network, err := v2.CreateNetwork(t, client) + th.AssertNoErr(t, err) + defer v2.DeleteNetwork(t, client, network.ID) + + // Create Subnet + subnet, err := v2.CreateSubnet(t, client, network.ID) + th.AssertNoErr(t, err) + defer v2.DeleteSubnet(t, client, subnet.ID) + + // Create port + parentPort, err := v2.CreatePort(t, client, network.ID, subnet.ID) + th.AssertNoErr(t, err) + defer v2.DeletePort(t, client, parentPort.ID) + + subport1, err := v2.CreatePort(t, client, network.ID, subnet.ID) + th.AssertNoErr(t, err) + defer v2.DeletePort(t, client, subport1.ID) + + subport2, err := v2.CreatePort(t, client, network.ID, subnet.ID) + th.AssertNoErr(t, err) + defer v2.DeletePort(t, client, subport2.ID) + + trunk, err := CreateTrunk(t, client, parentPort.ID, subport1.ID, subport2.ID) + th.AssertNoErr(t, err) + defer DeleteTrunk(t, client, trunk.ID) + + tools.PrintResource(t, trunk) + + // Store the current revision number. + oldRevisionNumber := trunk.RevisionNumber + + // Update the trunk without revision number. + // This should work. + newName := tools.RandomString("TESTACC-", 8) + newDescription := "" + updateOpts := &trunks.UpdateOpts{ + Name: &newName, + Description: &newDescription, + } + trunk, err = trunks.Update(context.TODO(), client, trunk.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, trunk) + + // This should fail due to an old revision number. + newDescription = "new description" + updateOpts = &trunks.UpdateOpts{ + Name: &newName, + Description: &newDescription, + RevisionNumber: &oldRevisionNumber, + } + _, err = trunks.Update(context.TODO(), client, trunk.ID, updateOpts).Extract() + th.AssertErr(t, err) + if !strings.Contains(err.Error(), "RevisionNumberConstraintFailed") { + t.Fatalf("expected to see an error of type RevisionNumberConstraintFailed, but got the following error instead: %v", err) + } + + // Reread the trunk to show that it did not change. + trunk, err = trunks.Get(context.TODO(), client, trunk.ID).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, trunk) + + // This should work because now we do provide a valid revision number. + newDescription = "new description" + updateOpts = &trunks.UpdateOpts{ + Name: &newName, + Description: &newDescription, + RevisionNumber: &trunk.RevisionNumber, + } + trunk, err = trunks.Update(context.TODO(), client, trunk.ID, updateOpts).Extract() + th.AssertNoErr(t, err) + + tools.PrintResource(t, trunk) + + th.AssertEquals(t, trunk.Name, newName) + th.AssertEquals(t, trunk.Description, newDescription) +} diff --git a/openstack/networking/v2/extensions/layer3/floatingips/requests.go b/openstack/networking/v2/extensions/layer3/floatingips/requests.go index a3afb0403c..be8949d693 100644 --- a/openstack/networking/v2/extensions/layer3/floatingips/requests.go +++ b/openstack/networking/v2/extensions/layer3/floatingips/requests.go @@ -2,6 +2,7 @@ package floatingips import ( "context" + "fmt" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/pagination" @@ -37,6 +38,7 @@ type ListOpts struct { TagsAny string `q:"tags-any"` NotTags string `q:"not-tags"` NotTagsAny string `q:"not-tags-any"` + RevisionNumber *int `q:"revision_number"` } // ToNetworkListQuery formats a ListOpts into a query string. @@ -144,6 +146,11 @@ type UpdateOpts struct { Description *string `json:"description,omitempty"` PortID *string `json:"port_id,omitempty"` FixedIP string `json:"fixed_ip_address,omitempty"` + + // RevisionNumber implements extension:standard-attr-revisions. If != "" it + // will set revision_number=%s. If the revision number does not match, the + // update will fail. + RevisionNumber *int `json:"-" h:"If-Match"` } // ToFloatingIPUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder @@ -171,8 +178,19 @@ func Update(ctx context.Context, c *gophercloud.ServiceClient, id string, opts U r.Err = err return } + h, err := gophercloud.BuildHeaders(opts) + if err != nil { + r.Err = err + return + } + for k := range h { + if k == "If-Match" { + h[k] = fmt.Sprintf("revision_number=%s", h[k]) + } + } resp, err := c.Put(ctx, resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ - OkCodes: []int{200}, + MoreHeaders: h, + OkCodes: []int{200}, }) _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) return diff --git a/openstack/networking/v2/extensions/layer3/floatingips/results.go b/openstack/networking/v2/extensions/layer3/floatingips/results.go index 50740ebf30..7ea6160032 100644 --- a/openstack/networking/v2/extensions/layer3/floatingips/results.go +++ b/openstack/networking/v2/extensions/layer3/floatingips/results.go @@ -56,6 +56,9 @@ type FloatingIP struct { // Tags optionally set via extensions/attributestags Tags []string `json:"tags"` + + // RevisionNumber optionally set via extensions/standard-attr-revisions + RevisionNumber int `json:"revision_number"` } func (r *FloatingIP) UnmarshalJSON(b []byte) error { diff --git a/openstack/networking/v2/extensions/layer3/routers/requests.go b/openstack/networking/v2/extensions/layer3/routers/requests.go index dcc7977a46..f6ca654841 100644 --- a/openstack/networking/v2/extensions/layer3/routers/requests.go +++ b/openstack/networking/v2/extensions/layer3/routers/requests.go @@ -2,6 +2,7 @@ package routers import ( "context" + "fmt" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/pagination" @@ -13,22 +14,23 @@ import ( // sort by a particular network attribute. SortDir sets the direction, and is // either `asc' or `desc'. Marker and Limit are used for pagination. type ListOpts struct { - ID string `q:"id"` - Name string `q:"name"` - Description string `q:"description"` - AdminStateUp *bool `q:"admin_state_up"` - Distributed *bool `q:"distributed"` - Status string `q:"status"` - TenantID string `q:"tenant_id"` - ProjectID string `q:"project_id"` - Limit int `q:"limit"` - Marker string `q:"marker"` - SortKey string `q:"sort_key"` - SortDir string `q:"sort_dir"` - Tags string `q:"tags"` - TagsAny string `q:"tags-any"` - NotTags string `q:"not-tags"` - NotTagsAny string `q:"not-tags-any"` + ID string `q:"id"` + Name string `q:"name"` + Description string `q:"description"` + AdminStateUp *bool `q:"admin_state_up"` + Distributed *bool `q:"distributed"` + Status string `q:"status"` + TenantID string `q:"tenant_id"` + ProjectID string `q:"project_id"` + Limit int `q:"limit"` + Marker string `q:"marker"` + SortKey string `q:"sort_key"` + SortDir string `q:"sort_dir"` + Tags string `q:"tags"` + TagsAny string `q:"tags-any"` + NotTags string `q:"not-tags"` + NotTagsAny string `q:"not-tags-any"` + RevisionNumber *int `q:"revision_number"` } // List returns a Pager which allows you to iterate over a collection of @@ -112,6 +114,11 @@ type UpdateOpts struct { Distributed *bool `json:"distributed,omitempty"` GatewayInfo *GatewayInfo `json:"external_gateway_info,omitempty"` Routes *[]Route `json:"routes,omitempty"` + + // RevisionNumber implements extension:standard-attr-revisions. If != "" it + // will set revision_number=%s. If the revision number does not match, the + // update will fail. + RevisionNumber *int `json:"-" h:"If-Match"` } // ToRouterUpdateMap builds an update body based on UpdateOpts. @@ -130,8 +137,19 @@ func Update(ctx context.Context, c *gophercloud.ServiceClient, id string, opts U r.Err = err return } + h, err := gophercloud.BuildHeaders(opts) + if err != nil { + r.Err = err + return + } + for k := range h { + if k == "If-Match" { + h[k] = fmt.Sprintf("revision_number=%s", h[k]) + } + } resp, err := c.Put(ctx, resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ - OkCodes: []int{200}, + MoreHeaders: h, + OkCodes: []int{200}, }) _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) return diff --git a/openstack/networking/v2/extensions/layer3/routers/results.go b/openstack/networking/v2/extensions/layer3/routers/results.go index 25e8ab7b59..d75615b773 100644 --- a/openstack/networking/v2/extensions/layer3/routers/results.go +++ b/openstack/networking/v2/extensions/layer3/routers/results.go @@ -77,6 +77,15 @@ type Router struct { // Tags optionally set via extensions/attributestags Tags []string `json:"tags"` + + // RevisionNumber optionally set via extensions/standard-attr-revisions + RevisionNumber int `json:"revision_number"` + + // Timestamp when the router was created + CreatedAt time.Time `json:"created_at"` + + // Timestamp when the router was last updated + UpdatedAt time.Time `json:"updated_at"` } // RouterPage is the page returned by a pager when traversing over a diff --git a/openstack/networking/v2/extensions/qos/policies/requests.go b/openstack/networking/v2/extensions/qos/policies/requests.go index 88d9f85fb8..832ea2d73e 100644 --- a/openstack/networking/v2/extensions/qos/policies/requests.go +++ b/openstack/networking/v2/extensions/qos/policies/requests.go @@ -2,6 +2,7 @@ package policies import ( "context" + "fmt" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" @@ -134,7 +135,6 @@ type ListOpts struct { ProjectID string `q:"project_id"` Name string `q:"name"` Description string `q:"description"` - RevisionNumber *int `q:"revision_number"` IsDefault *bool `q:"is_default"` Shared *bool `q:"shared"` Limit int `q:"limit"` @@ -145,6 +145,7 @@ type ListOpts struct { TagsAny string `q:"tags-any"` NotTags string `q:"not-tags"` NotTagsAny string `q:"not-tags-any"` + RevisionNumber *int `q:"revision_number"` } // ToPolicyListQuery formats a ListOpts into a query string. @@ -243,6 +244,11 @@ type UpdateOpts struct { // IsDefault indicates if this QoS policy is default policy or not. IsDefault *bool `json:"is_default,omitempty"` + + // RevisionNumber implements extension:standard-attr-revisions. If != "" it + // will set revision_number=%s. If the revision number does not match, the + // update will fail. + RevisionNumber *int `json:"-" h:"If-Match"` } // ToPolicyUpdateMap builds a request body from UpdateOpts. @@ -258,8 +264,19 @@ func Update(ctx context.Context, c *gophercloud.ServiceClient, policyID string, r.Err = err return } + h, err := gophercloud.BuildHeaders(opts) + if err != nil { + r.Err = err + return + } + for k := range h { + if k == "If-Match" { + h[k] = fmt.Sprintf("revision_number=%s", h[k]) + } + } resp, err := c.Put(ctx, updateURL(c, policyID), b, &r.Body, &gophercloud.RequestOpts{ - OkCodes: []int{200}, + MoreHeaders: h, + OkCodes: []int{200}, }) _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) return diff --git a/openstack/networking/v2/extensions/qos/policies/results.go b/openstack/networking/v2/extensions/qos/policies/results.go index 70c70379d7..c35853bac8 100644 --- a/openstack/networking/v2/extensions/qos/policies/results.go +++ b/openstack/networking/v2/extensions/qos/policies/results.go @@ -79,7 +79,7 @@ type Policy struct { // Shared indicates whether this policy is shared across all projects. Shared bool `json:"shared"` - // RevisionNumber represents revision number of the policy. + // RevisionNumber optionally set via extensions/standard-attr-revisions RevisionNumber int `json:"revision_number"` // Rules represents QoS rules of the policy. diff --git a/openstack/networking/v2/extensions/security/groups/requests.go b/openstack/networking/v2/extensions/security/groups/requests.go index fabb744114..95f7bfabdf 100644 --- a/openstack/networking/v2/extensions/security/groups/requests.go +++ b/openstack/networking/v2/extensions/security/groups/requests.go @@ -2,6 +2,7 @@ package groups import ( "context" + "fmt" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/pagination" @@ -13,20 +14,21 @@ import ( // sort by a particular network attribute. SortDir sets the direction, and is // either `asc' or `desc'. Marker and Limit are used for pagination. type ListOpts struct { - ID string `q:"id"` - Name string `q:"name"` - Description string `q:"description"` - Stateful *bool `q:"stateful"` - TenantID string `q:"tenant_id"` - ProjectID string `q:"project_id"` - Limit int `q:"limit"` - Marker string `q:"marker"` - SortKey string `q:"sort_key"` - SortDir string `q:"sort_dir"` - Tags string `q:"tags"` - TagsAny string `q:"tags-any"` - NotTags string `q:"not-tags"` - NotTagsAny string `q:"not-tags-any"` + ID string `q:"id"` + Name string `q:"name"` + Description string `q:"description"` + Stateful *bool `q:"stateful"` + TenantID string `q:"tenant_id"` + ProjectID string `q:"project_id"` + Limit int `q:"limit"` + Marker string `q:"marker"` + SortKey string `q:"sort_key"` + SortDir string `q:"sort_dir"` + Tags string `q:"tags"` + TagsAny string `q:"tags-any"` + NotTags string `q:"not-tags"` + NotTagsAny string `q:"not-tags-any"` + RevisionNumber *int `q:"revision_number"` } // List returns a Pager which allows you to iterate over a collection of @@ -104,6 +106,11 @@ type UpdateOpts struct { // Stateful indicates if the security group is stateful or stateless. Stateful *bool `json:"stateful,omitempty"` + + // RevisionNumber implements extension:standard-attr-revisions. If != "" it + // will set revision_number=%s. If the revision number does not match, the + // update will fail. + RevisionNumber *int `json:"-" h:"If-Match"` } // ToSecGroupUpdateMap builds a request body from UpdateOpts. @@ -118,9 +125,20 @@ func Update(ctx context.Context, c *gophercloud.ServiceClient, id string, opts U r.Err = err return } + h, err := gophercloud.BuildHeaders(opts) + if err != nil { + r.Err = err + return + } + for k := range h { + if k == "If-Match" { + h[k] = fmt.Sprintf("revision_number=%s", h[k]) + } + } resp, err := c.Put(ctx, resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ - OkCodes: []int{200}, + MoreHeaders: h, + OkCodes: []int{200}, }) _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) return diff --git a/openstack/networking/v2/extensions/security/groups/results.go b/openstack/networking/v2/extensions/security/groups/results.go index b3aa2efb48..6a8a16fa86 100644 --- a/openstack/networking/v2/extensions/security/groups/results.go +++ b/openstack/networking/v2/extensions/security/groups/results.go @@ -41,6 +41,9 @@ type SecGroup struct { // Tags optionally set via extensions/attributestags Tags []string `json:"tags"` + + // RevisionNumber optionally set via extensions/standard-attr-revisions + RevisionNumber int `json:"revision_number"` } func (r *SecGroup) UnmarshalJSON(b []byte) error { diff --git a/openstack/networking/v2/extensions/security/rules/requests.go b/openstack/networking/v2/extensions/security/rules/requests.go index 8976224b42..77768a3dac 100644 --- a/openstack/networking/v2/extensions/security/rules/requests.go +++ b/openstack/networking/v2/extensions/security/rules/requests.go @@ -29,6 +29,7 @@ type ListOpts struct { Marker string `q:"marker"` SortKey string `q:"sort_key"` SortDir string `q:"sort_dir"` + RevisionNumber *int `q:"revision_number"` } // List returns a Pager which allows you to iterate over a collection of diff --git a/openstack/networking/v2/extensions/security/rules/results.go b/openstack/networking/v2/extensions/security/rules/results.go index 0901ced578..8a3355dfe0 100644 --- a/openstack/networking/v2/extensions/security/rules/results.go +++ b/openstack/networking/v2/extensions/security/rules/results.go @@ -1,6 +1,8 @@ package rules import ( + "time" + "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/pagination" ) @@ -56,6 +58,15 @@ type SecGroupRule struct { // ProjectID is the project owner of this security group rule. ProjectID string `json:"project_id"` + + // RevisionNumber optionally set via extensions/standard-attr-revisions + RevisionNumber int `json:"revision_number"` + + // Timestamp when the rule was created + CreatedAt time.Time `json:"created_at"` + + // Timestamp when the rule was last updated + UpdatedAt time.Time `json:"updated_at"` } // SecGroupRulePage is the page returned by a pager when traversing over a diff --git a/openstack/networking/v2/extensions/subnetpools/requests.go b/openstack/networking/v2/extensions/subnetpools/requests.go index 8823e94b2f..b18427a6e7 100644 --- a/openstack/networking/v2/extensions/subnetpools/requests.go +++ b/openstack/networking/v2/extensions/subnetpools/requests.go @@ -2,6 +2,7 @@ package subnetpools import ( "context" + "fmt" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/pagination" @@ -33,7 +34,6 @@ type ListOpts struct { Shared *bool `q:"shared"` Description string `q:"description"` IsDefault *bool `q:"is_default"` - RevisionNumber int `q:"revision_number"` Limit int `q:"limit"` Marker string `q:"marker"` SortKey string `q:"sort_key"` @@ -42,6 +42,8 @@ type ListOpts struct { TagsAny string `q:"tags-any"` NotTags string `q:"not-tags"` NotTagsAny string `q:"not-tags-any"` + // type int does not allow to filter with revision_number=0 + RevisionNumber int `q:"revision_number"` } // ToSubnetPoolListQuery formats a ListOpts into a query string. @@ -201,6 +203,11 @@ type UpdateOpts struct { // IsDefault indicates if the subnetpool is default pool or not. IsDefault *bool `json:"is_default,omitempty"` + + // RevisionNumber implements extension:standard-attr-revisions. If != "" it + // will set revision_number=%s. If the revision number does not match, the + // update will fail. + RevisionNumber *int `json:"-" h:"If-Match"` } // ToSubnetPoolUpdateMap builds a request body from UpdateOpts. @@ -216,8 +223,19 @@ func Update(ctx context.Context, c *gophercloud.ServiceClient, subnetPoolID stri r.Err = err return } + h, err := gophercloud.BuildHeaders(opts) + if err != nil { + r.Err = err + return + } + for k := range h { + if k == "If-Match" { + h[k] = fmt.Sprintf("revision_number=%s", h[k]) + } + } resp, err := c.Put(ctx, updateURL(c, subnetPoolID), b, &r.Body, &gophercloud.RequestOpts{ - OkCodes: []int{200}, + MoreHeaders: h, + OkCodes: []int{200}, }) _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) return diff --git a/openstack/networking/v2/extensions/trunks/requests.go b/openstack/networking/v2/extensions/trunks/requests.go index a58d81ac17..ea4dba2507 100644 --- a/openstack/networking/v2/extensions/trunks/requests.go +++ b/openstack/networking/v2/extensions/trunks/requests.go @@ -2,6 +2,7 @@ package trunks import ( "context" + "fmt" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/pagination" @@ -63,21 +64,22 @@ type ListOptsBuilder interface { // by a particular trunk attribute. SortDir sets the direction, and is either // `asc' or `desc'. Marker and Limit are used for pagination. type ListOpts struct { - AdminStateUp *bool `q:"admin_state_up"` - Description string `q:"description"` - ID string `q:"id"` - Name string `q:"name"` - PortID string `q:"port_id"` + AdminStateUp *bool `q:"admin_state_up"` + Description string `q:"description"` + ID string `q:"id"` + Name string `q:"name"` + PortID string `q:"port_id"` + Status string `q:"status"` + TenantID string `q:"tenant_id"` + ProjectID string `q:"project_id"` + SortDir string `q:"sort_dir"` + SortKey string `q:"sort_key"` + Tags string `q:"tags"` + TagsAny string `q:"tags-any"` + NotTags string `q:"not-tags"` + NotTagsAny string `q:"not-tags-any"` + // TODO change type to *int for consistency RevisionNumber string `q:"revision_number"` - Status string `q:"status"` - TenantID string `q:"tenant_id"` - ProjectID string `q:"project_id"` - SortDir string `q:"sort_dir"` - SortKey string `q:"sort_key"` - Tags string `q:"tags"` - TagsAny string `q:"tags-any"` - NotTags string `q:"not-tags"` - NotTagsAny string `q:"not-tags-any"` } // ToTrunkListQuery formats a ListOpts into a query string. @@ -122,6 +124,11 @@ type UpdateOpts struct { AdminStateUp *bool `json:"admin_state_up,omitempty"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` + + // RevisionNumber implements extension:standard-attr-revisions. If != "" it + // will set revision_number=%s. If the revision number does not match, the + // update will fail. + RevisionNumber *int `json:"-" h:"If-Match"` } func (opts UpdateOpts) ToTrunkUpdateMap() (map[string]any, error) { @@ -134,8 +141,19 @@ func Update(ctx context.Context, c *gophercloud.ServiceClient, id string, opts U r.Err = err return } + h, err := gophercloud.BuildHeaders(opts) + if err != nil { + r.Err = err + return + } + for k := range h { + if k == "If-Match" { + h[k] = fmt.Sprintf("revision_number=%s", h[k]) + } + } resp, err := c.Put(ctx, updateURL(c, id), body, &r.Body, &gophercloud.RequestOpts{ - OkCodes: []int{200}, + MoreHeaders: h, + OkCodes: []int{200}, }) _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) return diff --git a/openstack/networking/v2/extensions/trunks/results.go b/openstack/networking/v2/extensions/trunks/results.go index 20edcf9a88..72efd636fb 100644 --- a/openstack/networking/v2/extensions/trunks/results.go +++ b/openstack/networking/v2/extensions/trunks/results.go @@ -81,6 +81,7 @@ type Trunk struct { // if the resource has not been updated, this field will show as null. UpdatedAt time.Time `json:"updated_at"` + // RevisionNumber optionally set via extensions/standard-attr-revisions RevisionNumber int `json:"revision_number"` // UUID of the trunk's parent port diff --git a/openstack/networking/v2/networks/requests.go b/openstack/networking/v2/networks/requests.go index 29d14187ab..d4dd64ff93 100644 --- a/openstack/networking/v2/networks/requests.go +++ b/openstack/networking/v2/networks/requests.go @@ -20,22 +20,23 @@ type ListOptsBuilder interface { // by a particular network attribute. SortDir sets the direction, and is either // `asc' or `desc'. Marker and Limit are used for pagination. type ListOpts struct { - Status string `q:"status"` - Name string `q:"name"` - Description string `q:"description"` - AdminStateUp *bool `q:"admin_state_up"` - TenantID string `q:"tenant_id"` - ProjectID string `q:"project_id"` - Shared *bool `q:"shared"` - ID string `q:"id"` - Marker string `q:"marker"` - Limit int `q:"limit"` - SortKey string `q:"sort_key"` - SortDir string `q:"sort_dir"` - Tags string `q:"tags"` - TagsAny string `q:"tags-any"` - NotTags string `q:"not-tags"` - NotTagsAny string `q:"not-tags-any"` + Status string `q:"status"` + Name string `q:"name"` + Description string `q:"description"` + AdminStateUp *bool `q:"admin_state_up"` + TenantID string `q:"tenant_id"` + ProjectID string `q:"project_id"` + Shared *bool `q:"shared"` + ID string `q:"id"` + Marker string `q:"marker"` + Limit int `q:"limit"` + SortKey string `q:"sort_key"` + SortDir string `q:"sort_dir"` + Tags string `q:"tags"` + TagsAny string `q:"tags-any"` + NotTags string `q:"not-tags"` + NotTagsAny string `q:"not-tags-any"` + RevisionNumber *int `q:"revision_number"` } // ToNetworkListQuery formats a ListOpts into a query string. diff --git a/openstack/networking/v2/ports/requests.go b/openstack/networking/v2/ports/requests.go index 218c2897f7..bfff2dffb2 100644 --- a/openstack/networking/v2/ports/requests.go +++ b/openstack/networking/v2/ports/requests.go @@ -41,6 +41,7 @@ type ListOpts struct { TagsAny string `q:"tags-any"` NotTags string `q:"not-tags"` NotTagsAny string `q:"not-tags-any"` + RevisionNumber *int `q:"revision_number"` SecurityGroups []string `q:"security_groups"` FixedIPs []FixedIPOpts } diff --git a/openstack/networking/v2/subnets/requests.go b/openstack/networking/v2/subnets/requests.go index db597d6864..150afd7394 100644 --- a/openstack/networking/v2/subnets/requests.go +++ b/openstack/networking/v2/subnets/requests.go @@ -42,6 +42,7 @@ type ListOpts struct { TagsAny string `q:"tags-any"` NotTags string `q:"not-tags"` NotTagsAny string `q:"not-tags-any"` + RevisionNumber *int `q:"revision_number"` } // ToSubnetListQuery formats a ListOpts into a query string. diff --git a/openstack/networking/v2/subnets/results.go b/openstack/networking/v2/subnets/results.go index 7d5ba13cc5..01c6acc070 100644 --- a/openstack/networking/v2/subnets/results.go +++ b/openstack/networking/v2/subnets/results.go @@ -1,6 +1,8 @@ package subnets import ( + "time" + "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/pagination" ) @@ -121,6 +123,12 @@ type Subnet struct { // RevisionNumber optionally set via extensions/standard-attr-revisions RevisionNumber int `json:"revision_number"` + + // Timestamp when the subnet was created + CreatedAt time.Time `json:"created_at"` + + // Timestamp when the subnet was last updated + UpdatedAt time.Time `json:"updated_at"` } // SubnetPage is the page returned by a pager when traversing over a collection