Repository: gophercloud/gophercloud

Issue:
Add Portgroups from Baremetal API
See https://github.com/gophercloud/gophercloud/issues/1429

Adding portgroups, which is documented here: https://docs.openstack.org/api-ref/baremetal/#portgroups-portgroups

This will be done similarly to https://github.com/gophercloud/gophercloud/pull/2722

Hints from issue comments:
@Sharpz7: I will take this on myself

Solution patch:
diff --git a/.github/workflows/codeql-analysis.yaml b/.github/workflows/codeql-analysis.yaml
index b9492ca44..a9695c7cf 100644
--- a/.github/workflows/codeql-analysis.yaml
+++ b/.github/workflows/codeql-analysis.yaml
@@ -20,7 +20,7 @@ jobs:
     - name: Setup Go
       uses: actions/setup-go@v5
       with:
-        go-version: "1.22"
+        go-version: "1.23"
 
     - name: Checkout repository
       uses: actions/checkout@v4
diff --git a/.github/workflows/functional-baremetal.yaml b/.github/workflows/functional-baremetal.yaml
index ac2d37d6f..008f7a3d3 100644
--- a/.github/workflows/functional-baremetal.yaml
+++ b/.github/workflows/functional-baremetal.yaml
@@ -91,7 +91,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-basic.yaml b/.github/workflows/functional-basic.yaml
index 392ea1f06..0fc650a1f 100644
--- a/.github/workflows/functional-basic.yaml
+++ b/.github/workflows/functional-basic.yaml
@@ -43,7 +43,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-blockstorage.yaml b/.github/workflows/functional-blockstorage.yaml
index c0b58484a..26e81321e 100644
--- a/.github/workflows/functional-blockstorage.yaml
+++ b/.github/workflows/functional-blockstorage.yaml
@@ -42,7 +42,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-compute.yaml b/.github/workflows/functional-compute.yaml
index 0ad2dfa8d..a01dbdfc9 100644
--- a/.github/workflows/functional-compute.yaml
+++ b/.github/workflows/functional-compute.yaml
@@ -42,7 +42,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-containerinfra.yaml b/.github/workflows/functional-containerinfra.yaml
index a2ed18368..634685f60 100644
--- a/.github/workflows/functional-containerinfra.yaml
+++ b/.github/workflows/functional-containerinfra.yaml
@@ -59,7 +59,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-dns.yaml b/.github/workflows/functional-dns.yaml
index 6b5202398..f829002ba 100644
--- a/.github/workflows/functional-dns.yaml
+++ b/.github/workflows/functional-dns.yaml
@@ -43,7 +43,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-fwaas_v2.yaml b/.github/workflows/functional-fwaas_v2.yaml
index d308ade3e..297da6ff2 100644
--- a/.github/workflows/functional-fwaas_v2.yaml
+++ b/.github/workflows/functional-fwaas_v2.yaml
@@ -47,7 +47,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-identity.yaml b/.github/workflows/functional-identity.yaml
index 7a9d4ffae..6db4b9d11 100644
--- a/.github/workflows/functional-identity.yaml
+++ b/.github/workflows/functional-identity.yaml
@@ -40,7 +40,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-image.yaml b/.github/workflows/functional-image.yaml
index c6b956b88..3b66c8598 100644
--- a/.github/workflows/functional-image.yaml
+++ b/.github/workflows/functional-image.yaml
@@ -40,7 +40,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-keymanager.yaml b/.github/workflows/functional-keymanager.yaml
index 595a09669..08ee8546e 100644
--- a/.github/workflows/functional-keymanager.yaml
+++ b/.github/workflows/functional-keymanager.yaml
@@ -42,7 +42,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-loadbalancer.yaml b/.github/workflows/functional-loadbalancer.yaml
index 11d67aef3..ec7b076ee 100644
--- a/.github/workflows/functional-loadbalancer.yaml
+++ b/.github/workflows/functional-loadbalancer.yaml
@@ -43,7 +43,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-messaging.yaml b/.github/workflows/functional-messaging.yaml
index 87c5b93de..d3e3169f6 100644
--- a/.github/workflows/functional-messaging.yaml
+++ b/.github/workflows/functional-messaging.yaml
@@ -43,7 +43,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-networking.yaml b/.github/workflows/functional-networking.yaml
index 67ab11960..8722b6789 100644
--- a/.github/workflows/functional-networking.yaml
+++ b/.github/workflows/functional-networking.yaml
@@ -57,7 +57,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-objectstorage.yaml b/.github/workflows/functional-objectstorage.yaml
index c71d17f37..ff88efbc1 100644
--- a/.github/workflows/functional-objectstorage.yaml
+++ b/.github/workflows/functional-objectstorage.yaml
@@ -46,7 +46,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-orchestration.yaml b/.github/workflows/functional-orchestration.yaml
index 71213eea5..9928aba17 100644
--- a/.github/workflows/functional-orchestration.yaml
+++ b/.github/workflows/functional-orchestration.yaml
@@ -42,7 +42,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-placement.yaml b/.github/workflows/functional-placement.yaml
index 15982c531..2f182285f 100644
--- a/.github/workflows/functional-placement.yaml
+++ b/.github/workflows/functional-placement.yaml
@@ -40,7 +40,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-sharedfilesystems.yaml b/.github/workflows/functional-sharedfilesystems.yaml
index d2702759a..56480d7d5 100644
--- a/.github/workflows/functional-sharedfilesystems.yaml
+++ b/.github/workflows/functional-sharedfilesystems.yaml
@@ -56,7 +56,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/functional-workflow.yaml b/.github/workflows/functional-workflow.yaml
index f124fc641..3ac1cdeb1 100644
--- a/.github/workflows/functional-workflow.yaml
+++ b/.github/workflows/functional-workflow.yaml
@@ -49,7 +49,7 @@ jobs:
       - name: Checkout go
         uses: actions/setup-go@v5
         with:
-          go-version: '^1.22'
+          go-version: '^1.23'
       - name: Run Gophercloud acceptance tests
         run: ./script/acceptancetest
         env:
diff --git a/.github/workflows/label-issue.yaml b/.github/workflows/label-issue.yaml
index a1fbff16e..45ab4cbe3 100644
--- a/.github/workflows/label-issue.yaml
+++ b/.github/workflows/label-issue.yaml
@@ -11,6 +11,7 @@ jobs:
     runs-on: ubuntu-latest
     permissions:
       issues: write
+      pull-requests: write
     steps:
     - run: gh issue edit "$NUMBER" --remove-label "needinfo"
       env:
diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml
index 11fddb5f6..1489ec0e3 100644
--- a/.github/workflows/unit.yaml
+++ b/.github/workflows/unit.yaml
@@ -13,7 +13,7 @@ jobs:
       fail-fast: false
       matrix:
         go-version:
-          - "1.22.3"
+          - "1.23.0"
           - "1"
 
     steps:
diff --git a/go.mod b/go.mod
index 92836172e..2ef0165ca 100644
--- a/go.mod
+++ b/go.mod
@@ -1,10 +1,12 @@
 module github.com/gophercloud/gophercloud/v2
 
-go 1.22
+go 1.23.0
+
+toolchain go1.23.6
 
 require (
-	golang.org/x/crypto v0.32.0
+	golang.org/x/crypto v0.35.0
 	gopkg.in/yaml.v2 v2.4.0
 )
 
-require golang.org/x/sys v0.29.0 // indirect
+require golang.org/x/sys v0.30.0 // indirect
diff --git a/go.sum b/go.sum
index 355c39493..4ca510733 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,9 @@
-golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
-golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
-golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
+golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
+golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
+golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
diff --git a/internal/acceptance/openstack/baremetal/v1/baremetal.go b/internal/acceptance/openstack/baremetal/v1/baremetal.go
index a47f942da..4c121d18c 100644
--- a/internal/acceptance/openstack/baremetal/v1/baremetal.go
+++ b/internal/acceptance/openstack/baremetal/v1/baremetal.go
@@ -9,6 +9,7 @@ import (
 	"github.com/gophercloud/gophercloud/v2/internal/acceptance/tools"
 	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/allocations"
 	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/nodes"
+	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/portgroups"
 	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/ports"
 )
 
@@ -76,6 +77,29 @@ func DeleteAllocation(t *testing.T, client *gophercloud.ServiceClient, allocatio
 	t.Logf("Deleted allocation: %s", allocation.UUID)
 }
 
+// CreatePortGroup creates an allocation
+func CreatePortGroup(t *testing.T, client *gophercloud.ServiceClient, node *nodes.Node) (*portgroups.PortGroup, error) {
+	name := tools.RandomString("ACPTTEST", 16)
+	t.Logf("Attempting to create bare metal allocation: %s", name)
+
+	allocation, err := portgroups.Create(context.TODO(), client, portgroups.CreateOpts{
+		Name:     name,
+		NodeUUID: node.UUID,
+	}).Extract()
+
+	return allocation, err
+}
+
+// DeletePortGroup deletes a bare metal portgroup via its UUID.
+func DeletePortGroup(t *testing.T, client *gophercloud.ServiceClient, portgroup *portgroups.PortGroup) {
+	err := portgroups.Delete(context.TODO(), client, portgroup.UUID).ExtractErr()
+	if err != nil {
+		t.Fatalf("Unable to delete portgroup %s: %s", portgroup.UUID, err)
+	}
+
+	t.Logf("Deleted portgroup: %s", portgroup.UUID)
+}
+
 // CreateFakeNode creates a node with fake-hardware.
 func CreateFakeNode(t *testing.T, client *gophercloud.ServiceClient) (*nodes.Node, error) {
 	name := tools.RandomString("ACPTTEST", 16)
diff --git a/openstack/baremetal/v1/nodes/requests.go b/openstack/baremetal/v1/nodes/requests.go
index 8b2eb4b21..cddd269a3 100644
--- a/openstack/baremetal/v1/nodes/requests.go
+++ b/openstack/baremetal/v1/nodes/requests.go
@@ -1002,3 +1002,61 @@ func DetachVirtualMedia(ctx context.Context, client *gophercloud.ServiceClient,
 	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
 	return
 }
+
+// VirtualInterfaceOpts defines options for attaching a VIF to a node
+type VirtualInterfaceOpts struct {
+	// The UUID or name of the VIF
+	ID string `json:"id" required:"true"`
+	// The UUID of a port to attach the VIF to. Cannot be specified with PortgroupUUID
+	PortUUID string `json:"port_uuid,omitempty"`
+	// The UUID of a portgroup to attach the VIF to. Cannot be specified with PortUUID
+	PortgroupUUID string `json:"portgroup_uuid,omitempty"`
+}
+
+// VirtualInterfaceOptsBuilder allows extensions to add additional parameters to the
+// AttachVirtualInterface request.
+type VirtualInterfaceOptsBuilder interface {
+	ToVirtualInterfaceMap() (map[string]any, error)
+}
+
+// ToVirtualInterfaceMap assembles a request body based on the contents of a VirtualInterfaceOpts.
+func (opts VirtualInterfaceOpts) ToVirtualInterfaceMap() (map[string]any, error) {
+	if opts.PortUUID != "" && opts.PortgroupUUID != "" {
+		return nil, fmt.Errorf("cannot specify both port_uuid and portgroup_uuid")
+	}
+
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// ListVirtualInterfaces returns a list of VIFs that are attached to the node.
+func ListVirtualInterfaces(ctx context.Context, client *gophercloud.ServiceClient, id string) (r ListVirtualInterfacesResult) {
+	resp, err := client.Get(ctx, virtualInterfaceURL(client, id), &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
+// AttachVirtualInterface attaches a VIF to a node.
+func AttachVirtualInterface(ctx context.Context, client *gophercloud.ServiceClient, id string, opts VirtualInterfaceOptsBuilder) (r VirtualInterfaceAttachResult) {
+	reqBody, err := opts.ToVirtualInterfaceMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+
+	resp, err := client.Post(ctx, virtualInterfaceURL(client, id), reqBody, nil, &gophercloud.RequestOpts{
+		OkCodes: []int{204},
+	})
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
+// DetachVirtualInterface detaches a VIF from a node.
+func DetachVirtualInterface(ctx context.Context, client *gophercloud.ServiceClient, id string, vifID string) (r VirtualInterfaceDetachResult) {
+	resp, err := client.Delete(ctx, virtualInterfaceDeleteURL(client, id, vifID), &gophercloud.RequestOpts{
+		OkCodes: []int{204},
+	})
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
diff --git a/openstack/baremetal/v1/nodes/results.go b/openstack/baremetal/v1/nodes/results.go
index 507e0d6a4..3e1976d3e 100644
--- a/openstack/baremetal/v1/nodes/results.go
+++ b/openstack/baremetal/v1/nodes/results.go
@@ -710,3 +710,40 @@ type VirtualMediaAttachResult struct {
 type VirtualMediaDetachResult struct {
 	gophercloud.ErrResult
 }
+
+// VirtualInterfaceAttachResult is the response from an AttachVirtualInterface operation.
+type VirtualInterfaceAttachResult struct {
+	gophercloud.ErrResult
+}
+
+// VirtualInterfaceDetachResult is the response from a DetachVirtualInterface operation.
+type VirtualInterfaceDetachResult struct {
+	gophercloud.ErrResult
+}
+
+// VIF represents a virtual interface attached to a node.
+type VIF struct {
+	// The UUID or name of the VIF
+	ID string `json:"id"`
+}
+
+// ListVirtualInterfacesResult is the response from a ListVirtualInterfaces operation.
+type ListVirtualInterfacesResult struct {
+	gophercloud.Result
+	gophercloud.HeaderResult
+}
+
+// Extract interprets any ListVirtualInterfacesResult as a list of VIFs.
+func (r ListVirtualInterfacesResult) Extract() ([]VIF, error) {
+	var s struct {
+		VIFs []VIF `json:"vifs"`
+	}
+
+	err := r.Result.ExtractInto(&s)
+	return s.VIFs, err
+}
+
+// ExtractHeader interprets any ListVirtualInterfacesResult as a HeaderResult.
+func (r ListVirtualInterfacesResult) ExtractHeader() (gophercloud.HeaderResult, error) {
+	return r.HeaderResult, nil
+}
diff --git a/openstack/baremetal/v1/nodes/urls.go b/openstack/baremetal/v1/nodes/urls.go
index 2948bb659..b86e4820e 100644
--- a/openstack/baremetal/v1/nodes/urls.go
+++ b/openstack/baremetal/v1/nodes/urls.go
@@ -89,3 +89,11 @@ func firmwareListURL(client *gophercloud.ServiceClient, id string) string {
 func virtualMediaURL(client *gophercloud.ServiceClient, id string) string {
 	return client.ServiceURL("nodes", id, "vmedia")
 }
+
+func virtualInterfaceURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("nodes", id, "vifs")
+}
+
+func virtualInterfaceDeleteURL(client *gophercloud.ServiceClient, id string, vifID string) string {
+	return client.ServiceURL("nodes", id, "vifs", vifID)
+}
diff --git a/openstack/baremetal/v1/portgroups/requests.go b/openstack/baremetal/v1/portgroups/requests.go
new file mode 100644
index 000000000..9a0675532
--- /dev/null
+++ b/openstack/baremetal/v1/portgroups/requests.go
@@ -0,0 +1,149 @@
+package portgroups
+
+import (
+	"context"
+
+	"github.com/gophercloud/gophercloud/v2"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+	ToPortGroupCreateMap() (map[string]any, error)
+}
+
+// CreateOpts specifies port group creation parameters
+type CreateOpts struct {
+	// NodeUUID is the UUID of the Node this resource belongs to
+	NodeUUID string `json:"node_uuid" required:"true"`
+
+	// Address is the physical hardware address of this Portgroup,
+	// typically the hardware MAC address
+	Address string `json:"address,omitempty"`
+
+	// Name is a human-readable identifier for the Portgroup resource
+	Name string `json:"name,omitempty"`
+
+	// Mode is the mode of the port group. For possible values, refer to
+	// https://www.kernel.org/doc/Documentation/networking/bonding.txt
+	// If not specified, it will be set to the value of the
+	// [DEFAULT]default_portgroup_mode configuration option.
+	// When set, cannot be removed from the port group.
+	Mode string `json:"mode,omitempty"`
+
+	// StandalonePortsSupported indicates whether ports that are members
+	// of this portgroup can be used as stand-alone ports
+	StandalonePortsSupported bool `json:"standalone_ports_supported,omitempty"`
+
+	// Properties contains key/value properties related to the port
+	// group's configuration
+	Properties map[string]interface{} `json:"properties,omitempty"`
+
+	// Extra is a set of one or more arbitrary metadata key and value pairs
+	Extra map[string]string `json:"extra,omitempty"`
+
+	// UUID is the UUID for the resource
+	UUID string `json:"uuid,omitempty"`
+}
+
+// ToPortGroupCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToPortGroupCreateMap() (map[string]any, error) {
+	body, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		return nil, err
+	}
+
+	return body, nil
+}
+
+// Create requests a node to be created
+func Create(ctx context.Context, client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	reqBody, err := opts.ToPortGroupCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+
+	resp, err := client.Post(ctx, createURL(client), reqBody, &r.Body, nil)
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the List request.
+type ListOptsBuilder interface {
+	ToPortGroupListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing specific query parameters to the API.
+type ListOpts struct {
+	// Node filters the list to return only Portgroups associated with this
+	// specific node (name or UUID)
+	Node string `q:"node,omitempty"`
+
+	// Address filters the list to return only Portgroups with the specified
+	// physical hardware address (typically MAC)
+	Address string `q:"address,omitempty"`
+
+	// Fields specifies which fields to return in the response
+	// For example: "uuid,name" will return only those fields
+	Fields []string `q:"fields,omitempty"`
+
+	// Limit requests a page size of items. Returns a number of items up to a limit value.
+	// Use with marker to implement pagination. Cannot exceed max_limit set in configuration.
+	Limit int `q:"limit,omitempty"`
+
+	// Marker is the ID of the last-seen item. Use with limit to implement pagination.
+	// Use the ID from the response as marker in subsequent limited requests.
+	Marker string `q:"marker,omitempty"`
+
+	// SortDir sorts the response by the requested direction.
+	// Valid values are "asc" or "desc". Default is "asc".
+	SortDir string `q:"sort_dir,omitempty"`
+
+	// SortKey sorts the response by this attribute value.
+	// Default is "id". Multiple sort key/direction pairs can be specified.
+	SortKey string `q:"sort_key,omitempty"`
+
+	// Detail indicates whether to show detailed information about the resource.
+	// Cannot be true if Fields parameter is specified.
+	Detail bool `q:"detail,omitempty"`
+}
+
+// ToPortGroupListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToPortGroupListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List makes a request against the API to list portgroups accessible to you.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(client)
+	if opts != nil {
+		query, err := opts.ToPortGroupListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return PortGroupsPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get requests the details of an portgroup by ID.
+func Get(ctx context.Context, client *gophercloud.ServiceClient, id string) (r GetResult) {
+	resp, err := client.Get(ctx, getURL(client, id), &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
+// Delete requests the deletion of an portgroup
+func Delete(ctx context.Context, client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	resp, err := client.Delete(ctx, deleteURL(client, id), nil)
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
diff --git a/openstack/baremetal/v1/portgroups/results.go b/openstack/baremetal/v1/portgroups/results.go
new file mode 100644
index 000000000..1c0c14191
--- /dev/null
+++ b/openstack/baremetal/v1/portgroups/results.go
@@ -0,0 +1,132 @@
+package portgroups
+
+import (
+	"time"
+
+	"github.com/gophercloud/gophercloud/v2"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+)
+
+// ResourceLink represents a link with href and rel attributes
+type ResourceLink struct {
+	Href string `json:"href"`
+	Rel  string `json:"rel"`
+}
+
+// PortGroup represents a port group in the baremetal service
+// https://docs.openstack.org/api-ref/baremetal/#portgroups-portgroups
+type PortGroup struct {
+	// Human-readable identifier for the Portgroup resource. May be undefined.
+	Name string `json:"name"`
+
+	// The UUID for the resource.
+	UUID string `json:"uuid"`
+
+	// Physical hardware address of this Portgroup, typically the hardware MAC address.
+	Address string `json:"address,omitempty"`
+
+	// UUID of the Node this resource belongs to.
+	NodeUUID string `json:"node_uuid"`
+
+	// Indicates whether ports that are members of this portgroup can be used as
+	// stand-alone ports.
+	StandalonePortsSupported bool `json:"standalone_ports_supported"`
+
+	// Internal metadata set and stored by the Portgroup. This field is read-only.
+	InternalInfo map[string]any `json:"internal_info"`
+
+	// A set of one or more arbitrary metadata key and value pairs.
+	Extra map[string]any `json:"extra"`
+
+	// Mode of the port group. For possible values, refer to
+	// https://www.kernel.org/doc/Documentation/networking/bonding.txt
+	Mode string `json:"mode"`
+
+	// Key/value properties related to the port group's configuration.
+	Properties map[string]any `json:"properties"`
+
+	// The UTC date and time when the resource was created, ISO 8601 format.
+	CreatedAt time.Time `json:"created_at"`
+
+	// The UTC date and time when the resource was updated, ISO 8601 format.
+	// May be "null".
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// A list of relative links. Includes the self and bookmark links.
+	Links []ResourceLink `json:"links"`
+
+	// Links to the collection of ports belonging to this portgroup.
+	Ports []ResourceLink `json:"ports"`
+}
+
+type portgroupsResult struct {
+	gophercloud.Result
+}
+
+func (r portgroupsResult) Extract() (*PortGroup, error) {
+	var s PortGroup
+	err := r.ExtractInto(&s)
+	return &s, err
+}
+
+func (r portgroupsResult) ExtractInto(v any) error {
+	return r.Result.ExtractIntoStructPtr(v, "")
+}
+
+func ExtractPortGroupsInto(r pagination.Page, v any) error {
+	return r.(PortGroupsPage).Result.ExtractIntoSlicePtr(v, "portgroups")
+}
+
+// PortGroupsPage abstracts the raw results of making a List() request against
+// the API.
+type PortGroupsPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if a page contains no PortGroup results.
+func (r PortGroupsPage) IsEmpty() (bool, error) {
+	if r.StatusCode == 204 {
+		return true, nil
+	}
+
+	s, err := ExtractPortGroups(r)
+	return len(s) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the
+// next page of results.
+func (r PortGroupsPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"portgroups_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractPortGroups interprets the results of a single page from a List() call,
+// producing a slice of PortGroup entities.
+func ExtractPortGroups(r pagination.Page) ([]PortGroup, error) {
+	var s []PortGroup
+	err := ExtractPortGroupsInto(r, &s)
+	return s, err
+}
+
+// GetResult is the response from a Get operation. Call its Extract
+// method to interpret it as a PortGroup.
+type GetResult struct {
+	portgroupsResult
+}
+
+// CreateResult is the response from a Create operation.
+type CreateResult struct {
+	portgroupsResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its ExtractErr
+// method to determine if the call succeeded or failed.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/baremetal/v1/portgroups/urls.go b/openstack/baremetal/v1/portgroups/urls.go
new file mode 100644
index 000000000..3320a8e17
--- /dev/null
+++ b/openstack/baremetal/v1/portgroups/urls.go
@@ -0,0 +1,23 @@
+package portgroups
+
+import "github.com/gophercloud/gophercloud/v2"
+
+func createURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("portgroups")
+}
+
+func listURL(client *gophercloud.ServiceClient) string {
+	return createURL(client)
+}
+
+func resourceURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("portgroups", id)
+}
+
+func deleteURL(client *gophercloud.ServiceClient, id string) string {
+	return resourceURL(client, id)
+}
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+	return resourceURL(client, id)
+}
diff --git a/openstack/blockstorage/v2/quotasets/results.go b/openstack/blockstorage/v2/quotasets/results.go
index e75aea5b5..179427f4b 100644
--- a/openstack/blockstorage/v2/quotasets/results.go
+++ b/openstack/blockstorage/v2/quotasets/results.go
@@ -111,6 +111,47 @@ type QuotaUsageSet struct {
 	// Note: allocated attribute is available only when nested quota is
 	// enabled.
 	Groups QuotaUsage `json:"groups"`
+
+	// Extra is a collection of key/values that has the size (GB) usage information
+	// per volume_type. Note: allocated attribute is available only when nested
+	// quota is enabled.
+	Extra map[string]QuotaUsage `json:"-"`
+}
+
+// UnmarshalJSON is used on QuotaUsageSet to unmarshal extra keys that are
+// used to represent QuotaUsage per volume_type.
+func (r *QuotaUsageSet) UnmarshalJSON(b []byte) error {
+	type tmp QuotaUsageSet
+	var s struct {
+		tmp
+		Extra map[string]QuotaUsage `json:"extra"`
+	}
+	err := json.Unmarshal(b, &s)
+	if err != nil {
+		return err
+	}
+	*r = QuotaUsageSet(s.tmp)
+
+	var result any
+	err = json.Unmarshal(b, &result)
+	if err != nil {
+		return err
+	}
+
+	// process remaining items as separate QuotaUsage objects.
+	if resultMap, ok := result.(map[string]any); ok {
+		tmpb, err := json.Marshal(gophercloud.RemainingKeys(QuotaUsageSet{}, resultMap))
+		if err != nil {
+			return err
+		}
+
+		err = json.Unmarshal(tmpb, &r.Extra)
+		if err != nil {
+			return err
+		}
+	}
+
+	return err
 }
 
 // QuotaUsage is a set of details about a single operational limit that allows
diff --git a/openstack/blockstorage/v3/quotasets/results.go b/openstack/blockstorage/v3/quotasets/results.go
index e75aea5b5..179427f4b 100644
--- a/openstack/blockstorage/v3/quotasets/results.go
+++ b/openstack/blockstorage/v3/quotasets/results.go
@@ -111,6 +111,47 @@ type QuotaUsageSet struct {
 	// Note: allocated attribute is available only when nested quota is
 	// enabled.
 	Groups QuotaUsage `json:"groups"`
+
+	// Extra is a collection of key/values that has the size (GB) usage information
+	// per volume_type. Note: allocated attribute is available only when nested
+	// quota is enabled.
+	Extra map[string]QuotaUsage `json:"-"`
+}
+
+// UnmarshalJSON is used on QuotaUsageSet to unmarshal extra keys that are
+// used to represent QuotaUsage per volume_type.
+func (r *QuotaUsageSet) UnmarshalJSON(b []byte) error {
+	type tmp QuotaUsageSet
+	var s struct {
+		tmp
+		Extra map[string]QuotaUsage `json:"extra"`
+	}
+	err := json.Unmarshal(b, &s)
+	if err != nil {
+		return err
+	}
+	*r = QuotaUsageSet(s.tmp)
+
+	var result any
+	err = json.Unmarshal(b, &result)
+	if err != nil {
+		return err
+	}
+
+	// process remaining items as separate QuotaUsage objects.
+	if resultMap, ok := result.(map[string]any); ok {
+		tmpb, err := json.Marshal(gophercloud.RemainingKeys(QuotaUsageSet{}, resultMap))
+		if err != nil {
+			return err
+		}
+
+		err = json.Unmarshal(tmpb, &r.Extra)
+		if err != nil {
+			return err
+		}
+	}
+
+	return err
 }
 
 // QuotaUsage is a set of details about a single operational limit that allows
diff --git a/openstack/compute/v2/servers/requests.go b/openstack/compute/v2/servers/requests.go
index f9c3bf065..b74b98768 100644
--- a/openstack/compute/v2/servers/requests.go
+++ b/openstack/compute/v2/servers/requests.go
@@ -511,6 +511,9 @@ type CreateOpts struct {
 
 	// KeyName is the name of the key pair.
 	KeyName string `json:"key_name,omitempty"`
+
+	// HypervisorHostname is the name of the hypervisor to which the server is scheduled.
+	HypervisorHostname string `json:"hypervisor_hostname,omitempty"`
 }
 
 // ToServerCreateMap assembles a request body based on the contents of a
diff --git a/openstack/dns/v2/zones/requests.go b/openstack/dns/v2/zones/requests.go
index fba4371ce..b8ac4cf8a 100644
--- a/openstack/dns/v2/zones/requests.go
+++ b/openstack/dns/v2/zones/requests.go
@@ -178,3 +178,45 @@ func Delete(ctx context.Context, client *gophercloud.ServiceClient, zoneID strin
 	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
 	return
 }
+
+// request body for sharing a zone.
+type ShareOptsBuilder interface {
+	ToShareMap() (map[string]interface{}, error)
+}
+
+// ShareZoneOpts specifies the target project for sharing a zone.
+type ShareZoneOpts struct {
+	// TargetProjectID is the ID of the project to share the zone with.
+	TargetProjectID string `json:"target_project_id" required:"true"`
+}
+
+// ToShareMap constructs a request body from a ShareZoneOpts.
+func (opts ShareZoneOpts) ToShareMap() (map[string]interface{}, error) {
+	return map[string]interface{}{
+		"target_project_id": opts.TargetProjectID,
+	}, nil
+}
+
+// Share shares a zone with another project.
+func Share(ctx context.Context, client *gophercloud.ServiceClient, zoneID string, opts ShareOptsBuilder) (r gophercloud.ErrResult) {
+	body, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		r.Err = err
+		return
+	}
+
+	resp, err := client.Post(ctx, zoneShareURL(client, zoneID), body, nil, &gophercloud.RequestOpts{
+		OkCodes: []int{201},
+	})
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
+// Unshare removes a share for a zone.
+func Unshare(ctx context.Context, client *gophercloud.ServiceClient, zoneID, shareID string) (r gophercloud.ErrResult) {
+	resp, err := client.Delete(ctx, zoneUnshareURL(client, zoneID, shareID), &gophercloud.RequestOpts{
+		OkCodes: []int{204},
+	})
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
diff --git a/openstack/dns/v2/zones/results.go b/openstack/dns/v2/zones/results.go
index ae2f27dbc..7ccf5ace9 100644
--- a/openstack/dns/v2/zones/results.go
+++ b/openstack/dns/v2/zones/results.go
@@ -50,6 +50,11 @@ type ZonePage struct {
 	pagination.LinkedPageBase
 }
 
+// ErrResult represents a generic error result.
+type ErrResult struct {
+	gophercloud.ErrResult
+}
+
 // IsEmpty returns true if the page contains no results.
 func (r ZonePage) IsEmpty() (bool, error) {
 	if r.StatusCode == 204 {
diff --git a/openstack/dns/v2/zones/urls.go b/openstack/dns/v2/zones/urls.go
index d157b30ef..ba156882f 100644
--- a/openstack/dns/v2/zones/urls.go
+++ b/openstack/dns/v2/zones/urls.go
@@ -2,10 +2,22 @@ package zones
 
 import "github.com/gophercloud/gophercloud/v2"
 
+// baseURL returns the base URL for zones.
 func baseURL(c *gophercloud.ServiceClient) string {
 	return c.ServiceURL("zones")
 }
 
+// zoneURL returns the URL for a specific zone.
 func zoneURL(c *gophercloud.ServiceClient, zoneID string) string {
 	return c.ServiceURL("zones", zoneID)
 }
+
+// zoneShareURL returns the URL for sharing a zone.
+func zoneShareURL(c *gophercloud.ServiceClient, zoneID string) string {
+	return c.ServiceURL("zones", zoneID, "shares")
+}
+
+// zoneUnshareURL returns the URL for unsharing a zone.
+func zoneUnshareURL(c *gophercloud.ServiceClient, zoneID, shareID string) string {
+	return c.ServiceURL("zones", zoneID, "shares", shareID)
+}
diff --git a/openstack/identity/v3/endpoints/requests.go b/openstack/identity/v3/endpoints/requests.go
index 35eb96607..4386bb9bc 100644
--- a/openstack/identity/v3/endpoints/requests.go
+++ b/openstack/identity/v3/endpoints/requests.go
@@ -90,6 +90,13 @@ func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pa
 	})
 }
 
+// Get retrieves details on a single endpoint, by ID.
+func Get(ctx context.Context, client *gophercloud.ServiceClient, id string) (r GetResult) {
+	resp, err := client.Get(ctx, endpointURL(client, id), &r.Body, nil)
+	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
+	return
+}
+
 // UpdateOptsBuilder allows extensions to add parameters to the Update request.
 type UpdateOptsBuilder interface {
 	ToEndpointUpdateMap() (map[string]any, error)
diff --git a/openstack/identity/v3/endpoints/results.go b/openstack/identity/v3/endpoints/results.go
index 9efec30af..19d279ebc 100644
--- a/openstack/identity/v3/endpoints/results.go
+++ b/openstack/identity/v3/endpoints/results.go
@@ -19,6 +19,12 @@ func (r commonResult) Extract() (*Endpoint, error) {
 	return s.Endpoint, err
 }
 
+// GetResult is the response from a Get operation. Call its Extract method
+// to interpret it as an Endpoint.
+type GetResult struct {
+	commonResult
+}
+
 // CreateResult is the response from a Create operation. Call its Extract
 // method to interpret it as an Endpoint.
 type CreateResult struct {
diff --git a/openstack/identity/v3/projects/doc.go b/openstack/identity/v3/projects/doc.go
index 6aea466a5..610032739 100644
--- a/openstack/identity/v3/projects/doc.go
+++ b/openstack/identity/v3/projects/doc.go
@@ -73,7 +73,7 @@ Example to List all tags of a Project
 		panic(err)
 	}
 
-Example to  modify all tags of a Project
+Example to modify all tags of a Project
 
 	projectID := "966b3c7d36a24facaf20b7e458bf2192"
 	tags := ["foo", "bar"]



Test patch:
diff --git a/internal/acceptance/openstack/baremetal/httpbasic/portgroups_test.go b/internal/acceptance/openstack/baremetal/httpbasic/portgroups_test.go
new file mode 100644
index 000000000..b3ed1948e
--- /dev/null
+++ b/internal/acceptance/openstack/baremetal/httpbasic/portgroups_test.go
@@ -0,0 +1,50 @@
+package httpbasic
+
+import (
+	"context"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/v2/internal/acceptance/clients"
+	v1 "github.com/gophercloud/gophercloud/v2/internal/acceptance/openstack/baremetal/v1"
+	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/portgroups"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+	th "github.com/gophercloud/gophercloud/v2/testhelper"
+)
+
+func TestPortGroupsCreateDestroy(t *testing.T) {
+	clients.RequireLong(t)
+	clients.RequireIronicHTTPBasic(t)
+
+	client, err := clients.NewBareMetalV1HTTPBasic()
+	th.AssertNoErr(t, err)
+
+	// NOTE(sharpz7) - increased due to create fake node requiring it.
+	client.Microversion = "1.50"
+
+	node, err := v1.CreateFakeNode(t, client)
+	th.AssertNoErr(t, err)
+	defer v1.DeleteNode(t, client, node)
+
+	portgroup, err := v1.CreatePortGroup(t, client, node)
+	th.AssertNoErr(t, err)
+	defer v1.DeletePortGroup(t, client, portgroup)
+
+	// Verify the portgroup exists by listing
+	err = portgroups.List(client, portgroups.ListOpts{
+		Node: node.UUID,
+	}).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) {
+		pg, err := portgroups.ExtractPortGroups(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, p := range pg {
+			if p.UUID == portgroup.UUID {
+				return true, nil
+			}
+		}
+
+		return false, nil
+	})
+	th.AssertNoErr(t, err)
+}
diff --git a/internal/acceptance/openstack/baremetal/noauth/portgroups_test.go b/internal/acceptance/openstack/baremetal/noauth/portgroups_test.go
new file mode 100644
index 000000000..c4b95bba6
--- /dev/null
+++ b/internal/acceptance/openstack/baremetal/noauth/portgroups_test.go
@@ -0,0 +1,49 @@
+package noauth
+
+import (
+	"context"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/v2/internal/acceptance/clients"
+	v1 "github.com/gophercloud/gophercloud/v2/internal/acceptance/openstack/baremetal/v1"
+	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/portgroups"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+	th "github.com/gophercloud/gophercloud/v2/testhelper"
+)
+
+func TestPortGroupsCreateDestroy(t *testing.T) {
+	clients.RequireLong(t)
+
+	client, err := clients.NewBareMetalV1Client()
+	th.AssertNoErr(t, err)
+
+	// NOTE(sharpz7) - increased due to create fake node requiring it.
+	client.Microversion = "1.50"
+
+	node, err := v1.CreateFakeNode(t, client)
+	th.AssertNoErr(t, err)
+	defer v1.DeleteNode(t, client, node)
+
+	portgroup, err := v1.CreatePortGroup(t, client, node)
+	th.AssertNoErr(t, err)
+	defer v1.DeletePortGroup(t, client, portgroup)
+
+	// Verify the portgroup exists by listing
+	err = portgroups.List(client, portgroups.ListOpts{
+		Node: node.UUID,
+	}).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) {
+		pg, err := portgroups.ExtractPortGroups(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, p := range pg {
+			if p.UUID == portgroup.UUID {
+				return true, nil
+			}
+		}
+
+		return false, nil
+	})
+	th.AssertNoErr(t, err)
+}
diff --git a/internal/acceptance/openstack/baremetal/v1/nodes_test.go b/internal/acceptance/openstack/baremetal/v1/nodes_test.go
index 669ea69cd..1151135e6 100644
--- a/internal/acceptance/openstack/baremetal/v1/nodes_test.go
+++ b/internal/acceptance/openstack/baremetal/v1/nodes_test.go
@@ -272,3 +272,51 @@ func TestNodesServicingHold(t *testing.T) {
 	}, nodes.Active)
 	th.AssertNoErr(t, err)
 }
+
+func TestNodesVirtualInterfaces(t *testing.T) {
+	clients.SkipReleasesBelow(t, "stable/2023.2") // Adjust based on when this feature was added
+	clients.RequireLong(t)
+
+	client, err := clients.NewBareMetalV1Client()
+	th.AssertNoErr(t, err)
+	// VIFs were added in API version 1.28, but at least 1.38 is needed for tests to pass
+	client.Microversion = "1.38"
+
+	node, err := CreateNode(t, client)
+	th.AssertNoErr(t, err)
+	defer DeleteNode(t, client, node)
+
+	// First, list VIFs (should be empty initially)
+	vifs, err := nodes.ListVirtualInterfaces(context.TODO(), client, node.UUID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 0, len(vifs))
+
+	// For a real test, we would need a valid VIF ID from the networking service
+	// Since this is difficult in a test environment, we can test the API call
+	// with a fake ID and expect it to fail with a specific error
+	fakeVifID := "1974dcfa-836f-41b2-b541-686c100900e5"
+
+	// Try to attach a VIF (this will likely fail with a 404 Not Found since the VIF doesn't exist)
+	err = nodes.AttachVirtualInterface(context.TODO(), client, node.UUID, nodes.VirtualInterfaceOpts{
+		ID: fakeVifID,
+	}).ExtractErr()
+
+	// We expect this to fail, but we're testing the API call itself
+	// In a real environment with valid VIFs, you would check for success instead
+	if err == nil {
+		t.Logf("Warning: Expected error when attaching non-existent VIF, but got success. This might indicate the test environment has a VIF with ID %s", fakeVifID)
+	}
+
+	// Try to detach a VIF (this will likely fail with a 404 Not Found)
+	err = nodes.DetachVirtualInterface(context.TODO(), client, node.UUID, fakeVifID).ExtractErr()
+
+	// Again, we expect this to fail in most test environments
+	if err == nil {
+		t.Logf("Warning: Expected error when detaching non-existent VIF, but got success. This might indicate the test environment has a VIF with ID %s", fakeVifID)
+	}
+
+	// List VIFs again to confirm state hasn't changed
+	vifs, err = nodes.ListVirtualInterfaces(context.TODO(), client, node.UUID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 0, len(vifs))
+}
diff --git a/internal/acceptance/openstack/baremetal/v1/portgroups_test.go b/internal/acceptance/openstack/baremetal/v1/portgroups_test.go
new file mode 100644
index 000000000..6f04223a0
--- /dev/null
+++ b/internal/acceptance/openstack/baremetal/v1/portgroups_test.go
@@ -0,0 +1,48 @@
+package v1
+
+import (
+	"context"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/v2/internal/acceptance/clients"
+	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/portgroups"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+	th "github.com/gophercloud/gophercloud/v2/testhelper"
+)
+
+func TestPortGroupsCreateDestroy(t *testing.T) {
+	clients.RequireLong(t)
+
+	client, err := clients.NewBareMetalV1Client()
+	th.AssertNoErr(t, err)
+
+	// NOTE(sharpz7) - increased due to create fake node requiring it.
+	client.Microversion = "1.50"
+
+	node, err := CreateFakeNode(t, client)
+	th.AssertNoErr(t, err)
+	defer DeleteNode(t, client, node)
+
+	portgroup, err := CreatePortGroup(t, client, node)
+	th.AssertNoErr(t, err)
+	defer DeletePortGroup(t, client, portgroup)
+
+	// Verify the portgroup exists by listing
+	err = portgroups.List(client, portgroups.ListOpts{
+		Node: node.UUID,
+	}).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) {
+		pg, err := portgroups.ExtractPortGroups(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, p := range pg {
+			if p.UUID == portgroup.UUID {
+				return true, nil
+			}
+		}
+
+		return false, nil
+	})
+	th.AssertNoErr(t, err)
+}
diff --git a/internal/acceptance/openstack/blockstorage/v3/quotaset_test.go b/internal/acceptance/openstack/blockstorage/v3/quotaset_test.go
index 327ea0d79..125d9f9d6 100644
--- a/internal/acceptance/openstack/blockstorage/v3/quotaset_test.go
+++ b/internal/acceptance/openstack/blockstorage/v3/quotaset_test.go
@@ -46,6 +46,7 @@ func TestQuotasetGetUsage(t *testing.T) {
 	th.AssertNoErr(t, err)
 
 	tools.PrintResource(t, quotaSetUsage)
+	tools.PrintResource(t, quotaSetUsage.Extra)
 }
 
 var UpdateQuotaOpts = quotasets.UpdateOpts{
diff --git a/internal/acceptance/openstack/identity/v3/endpoint_test.go b/internal/acceptance/openstack/identity/v3/endpoint_test.go
index ae2254102..8dd9e5bad 100644
--- a/internal/acceptance/openstack/identity/v3/endpoint_test.go
+++ b/internal/acceptance/openstack/identity/v3/endpoint_test.go
@@ -39,6 +39,29 @@ func TestEndpointsList(t *testing.T) {
 	th.AssertEquals(t, found, true)
 }
 
+func TestEndpointsGet(t *testing.T) {
+	clients.RequireAdmin(t)
+
+	client, err := clients.NewIdentityV3Client()
+	th.AssertNoErr(t, err)
+
+	allPages, err := endpoints.List(client, nil).AllPages(context.TODO())
+	th.AssertNoErr(t, err)
+
+	allEndpoints, err := endpoints.ExtractEndpoints(allPages)
+	th.AssertNoErr(t, err)
+
+	endpoint := allEndpoints[0]
+	e, err := endpoints.Get(context.TODO(), client, endpoint.ID).Extract()
+	if err != nil {
+		t.Fatalf("Unable to get endpoint: %v", err)
+	}
+
+	tools.PrintResource(t, e)
+
+	th.AssertEquals(t, e.Name, e.Name)
+}
+
 func TestEndpointsNavigateCatalog(t *testing.T) {
 	clients.RequireAdmin(t)
 
diff --git a/openstack/baremetal/v1/nodes/testing/fixtures_test.go b/openstack/baremetal/v1/nodes/testing/fixtures_test.go
index 88abebceb..282d5dbc7 100644
--- a/openstack/baremetal/v1/nodes/testing/fixtures_test.go
+++ b/openstack/baremetal/v1/nodes/testing/fixtures_test.go
@@ -1849,3 +1849,78 @@ func HandleDetachVirtualMediaSuccessfully(t *testing.T, withType bool) {
 		w.WriteHeader(http.StatusNoContent)
 	})
 }
+
+// HandleListVirtualInterfacesSuccessfully sets up the test server to respond to a ListVirtualInterfaces request
+func HandleListVirtualInterfacesSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/nodes/1234asdf/vifs",
+		func(w http.ResponseWriter, r *http.Request) {
+			th.TestMethod(t, r, "GET")
+			th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+			th.TestHeader(t, r, "Accept", "application/json")
+
+			w.Header().Add("Content-Type", "application/json")
+			w.WriteHeader(http.StatusOK)
+			fmt.Fprintf(w, `
+{
+  "vifs": [
+    {
+      "id": "1974dcfa-836f-41b2-b541-686c100900e5"
+    }
+  ]
+}`)
+		})
+}
+
+// HandleAttachVirtualInterfaceSuccessfully sets up the test server to respond to an AttachVirtualInterface request
+func HandleAttachVirtualInterfaceSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/nodes/1234asdf/vifs",
+		func(w http.ResponseWriter, r *http.Request) {
+			th.TestMethod(t, r, "POST")
+			th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+			th.TestHeader(t, r, "Content-Type", "application/json")
+			th.TestHeader(t, r, "Accept", "application/json")
+			th.TestJSONRequest(t, r, `{"id":"1974dcfa-836f-41b2-b541-686c100900e5"}`)
+
+			w.WriteHeader(http.StatusNoContent)
+		})
+}
+
+// HandleAttachVirtualInterfaceWithPortSuccessfully sets up the test server to respond to an AttachVirtualInterface request with port
+func HandleAttachVirtualInterfaceWithPortSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/nodes/1234asdf/vifs",
+		func(w http.ResponseWriter, r *http.Request) {
+			th.TestMethod(t, r, "POST")
+			th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+			th.TestHeader(t, r, "Content-Type", "application/json")
+			th.TestHeader(t, r, "Accept", "application/json")
+			th.TestJSONRequest(t, r, `{"id":"1974dcfa-836f-41b2-b541-686c100900e5","port_uuid":"b2f96298-5172-45e9-b174-8d1ba936ab47"}`)
+
+			w.WriteHeader(http.StatusNoContent)
+		})
+}
+
+// HandleAttachVirtualInterfaceWithPortgroupSuccessfully sets up the test server to respond to an AttachVirtualInterface request with portgroup
+func HandleAttachVirtualInterfaceWithPortgroupSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/nodes/1234asdf/vifs",
+		func(w http.ResponseWriter, r *http.Request) {
+			th.TestMethod(t, r, "POST")
+			th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+			th.TestHeader(t, r, "Content-Type", "application/json")
+			th.TestHeader(t, r, "Accept", "application/json")
+			th.TestJSONRequest(t, r, `{"id":"1974dcfa-836f-41b2-b541-686c100900e5","portgroup_uuid":"c24944b5-a52e-4c5c-9c0a-52a0235a08a2"}`)
+
+			w.WriteHeader(http.StatusNoContent)
+		})
+}
+
+// HandleDetachVirtualInterfaceSuccessfully sets up the test server to respond to a DetachVirtualInterface request
+func HandleDetachVirtualInterfaceSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/nodes/1234asdf/vifs/1974dcfa-836f-41b2-b541-686c100900e5",
+		func(w http.ResponseWriter, r *http.Request) {
+			th.TestMethod(t, r, "DELETE")
+			th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+			th.TestHeader(t, r, "Accept", "application/json")
+
+			w.WriteHeader(http.StatusNoContent)
+		})
+}
diff --git a/openstack/baremetal/v1/nodes/testing/requests_test.go b/openstack/baremetal/v1/nodes/testing/requests_test.go
index 5529e795a..487c35017 100644
--- a/openstack/baremetal/v1/nodes/testing/requests_test.go
+++ b/openstack/baremetal/v1/nodes/testing/requests_test.go
@@ -829,3 +829,83 @@ func TestVirtualMediaDetachWithTypes(t *testing.T) {
 	err := nodes.DetachVirtualMedia(context.TODO(), c, "1234asdf", opts).ExtractErr()
 	th.AssertNoErr(t, err)
 }
+
+func TestListVirtualInterfaces(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListVirtualInterfacesSuccessfully(t)
+
+	c := client.ServiceClient()
+	actual, err := nodes.ListVirtualInterfaces(context.TODO(), c, "1234asdf").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := []nodes.VIF{
+		{
+			ID: "1974dcfa-836f-41b2-b541-686c100900e5",
+		},
+	}
+
+	th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestAttachVirtualInterface(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleAttachVirtualInterfaceSuccessfully(t)
+
+	c := client.ServiceClient()
+	opts := nodes.VirtualInterfaceOpts{
+		ID: "1974dcfa-836f-41b2-b541-686c100900e5",
+	}
+	err := nodes.AttachVirtualInterface(context.TODO(), c, "1234asdf", opts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestAttachVirtualInterfaceWithPort(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleAttachVirtualInterfaceWithPortSuccessfully(t)
+
+	c := client.ServiceClient()
+	opts := nodes.VirtualInterfaceOpts{
+		ID:       "1974dcfa-836f-41b2-b541-686c100900e5",
+		PortUUID: "b2f96298-5172-45e9-b174-8d1ba936ab47",
+	}
+	err := nodes.AttachVirtualInterface(context.TODO(), c, "1234asdf", opts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestAttachVirtualInterfaceWithPortgroup(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleAttachVirtualInterfaceWithPortgroupSuccessfully(t)
+
+	c := client.ServiceClient()
+	opts := nodes.VirtualInterfaceOpts{
+		ID:            "1974dcfa-836f-41b2-b541-686c100900e5",
+		PortgroupUUID: "c24944b5-a52e-4c5c-9c0a-52a0235a08a2",
+	}
+	err := nodes.AttachVirtualInterface(context.TODO(), c, "1234asdf", opts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestDetachVirtualInterface(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDetachVirtualInterfaceSuccessfully(t)
+
+	c := client.ServiceClient()
+	err := nodes.DetachVirtualInterface(context.TODO(), c, "1234asdf", "1974dcfa-836f-41b2-b541-686c100900e5").ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestVirtualInterfaceOptsValidation(t *testing.T) {
+	opts := nodes.VirtualInterfaceOpts{
+		ID:            "1974dcfa-836f-41b2-b541-686c100900e5",
+		PortUUID:      "b2f96298-5172-45e9-b174-8d1ba936ab47",
+		PortgroupUUID: "c24944b5-a52e-4c5c-9c0a-52a0235a08a2",
+	}
+
+	_, err := opts.ToVirtualInterfaceMap()
+	th.AssertEquals(t, err.Error(), "cannot specify both port_uuid and portgroup_uuid")
+}
diff --git a/openstack/baremetal/v1/portgroups/testing/fixtures.go b/openstack/baremetal/v1/portgroups/testing/fixtures.go
new file mode 100644
index 000000000..9a8773fd8
--- /dev/null
+++ b/openstack/baremetal/v1/portgroups/testing/fixtures.go
@@ -0,0 +1,271 @@
+package testing
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/portgroups"
+	th "github.com/gophercloud/gophercloud/v2/testhelper"
+	"github.com/gophercloud/gophercloud/v2/testhelper/client"
+)
+
+// PortGroupsListBody is the JSON response for listing all portgroups.
+var PortGroupsListBody = `
+{
+  "portgroups": [
+    {
+      "address": "00:1a:2b:3c:4d:5e",
+      "created_at": "2019-02-20T09:43:58Z",
+      "extra": {
+        "description": "Primary network bond",
+        "location": "rack-3-unit-12"
+      },
+      "internal_info": {
+        "fault_count": 0,
+        "last_check": "2024-03-15T10:30:00Z"
+      },
+      "links": [
+        {
+          "href": "http://ironic.example.com/v1/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+          "rel": "self"
+        },
+        {
+          "href": "http://ironic.example.com/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+          "rel": "bookmark"
+        }
+      ],
+      "mode": "active-backup",
+      "name": "bond0",
+      "node_uuid": "f9c9a846-c53f-4b17-9f0c-dd9f459d35c8",
+      "ports": [
+        {
+          "href": "http://ironic.example.com/v1/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796/ports",
+          "rel": "self"
+        }
+      ],
+      "properties": {
+        "miimon": "100",
+        "updelay": "1000",
+        "downdelay": "1000",
+        "xmit_hash_policy": "layer2"
+      },
+      "standalone_ports_supported": true,
+      "updated_at": "2019-02-20T09:43:58Z",
+      "uuid": "d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796"
+    },
+    {
+      "address": "11:22:33:44:55:66",
+      "created_at": "2019-02-20T09:43:58Z",
+      "extra": {
+        "description": "Secondary bond",
+        "location": "rack-1-unit-4"
+      },
+      "internal_info": {
+        "fault_count": 1,
+        "last_check": "2024-04-01T09:00:00Z"
+      },
+      "links": [
+        {
+          "href": "http://ironic.example.com/v1/portgroups/a1b2c3d4-e5f6-7890-1234-56789abcdef0",
+          "rel": "self"
+        },
+        {
+          "href": "http://ironic.example.com/portgroups/a1b2c3d4-e5f6-7890-1234-56789abcdef0",
+          "rel": "bookmark"
+        }
+      ],
+      "mode": "active-backup",
+      "name": "bond1",
+      "node_uuid": "aabbcc00-1122-3344-5566-778899aabbcc",
+      "ports": [
+        {
+          "href": "http://ironic.example.com/v1/portgroups/a1b2c3d4-e5f6-7890-1234-56789abcdef0/ports",
+          "rel": "self"
+        }
+      ],
+      "properties": {
+        "miimon": "200",
+        "updelay": "500",
+        "downdelay": "500",
+        "xmit_hash_policy": "layer3+4"
+      },
+      "standalone_ports_supported": true,
+      "updated_at": "2019-02-20T09:43:58Z",
+      "uuid": "a1b2c3d4-e5f6-7890-1234-56789abcdef0"
+    }
+  ]
+}
+`
+
+// SinglePortGroupBody returns JSON for a single portgroup.
+// Here we use PortGroup1 as the example.
+var SinglePortGroupBody = `
+{
+  "address": "00:1a:2b:3c:4d:5e",
+  "created_at": "2019-02-20T09:43:58Z",
+  "extra": {
+    "description": "Primary network bond",
+    "location": "rack-3-unit-12"
+  },
+  "internal_info": {
+    "fault_count": 0,
+    "last_check": "2024-03-15T10:30:00Z"
+  },
+  "links": [
+    {
+      "href": "http://ironic.example.com/v1/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+      "rel": "self"
+    },
+    {
+      "href": "http://ironic.example.com/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+      "rel": "bookmark"
+    }
+  ],
+  "mode": "active-backup",
+  "name": "bond0",
+  "node_uuid": "f9c9a846-c53f-4b17-9f0c-dd9f459d35c8",
+  "ports": [
+    {
+      "href": "http://ironic.example.com/v1/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796/ports",
+      "rel": "self"
+    }
+  ],
+  "properties": {
+    "miimon": "100",
+    "updelay": "1000",
+    "downdelay": "1000",
+    "xmit_hash_policy": "layer2"
+  },
+  "standalone_ports_supported": true,
+  "updated_at": "2019-02-20T09:43:58Z",
+  "uuid": "d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796"
+}
+`
+
+var (
+	createdAt, _ = time.Parse(time.RFC3339, "2019-02-20T09:43:58Z")
+
+	// PortGroup1 is the first portgroup.
+	PortGroup1 = portgroups.PortGroup{
+		Name:                     "bond0",
+		UUID:                     "d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+		Address:                  "00:1a:2b:3c:4d:5e",
+		NodeUUID:                 "f9c9a846-c53f-4b17-9f0c-dd9f459d35c8",
+		StandalonePortsSupported: true,
+		InternalInfo: map[string]any{
+			"fault_count": float64(0),
+			"last_check":  "2024-03-15T10:30:00Z",
+		},
+		Extra: map[string]any{
+			"description": "Primary network bond",
+			"location":    "rack-3-unit-12",
+		},
+		Mode: "active-backup",
+		Properties: map[string]any{
+			"miimon":           "100",
+			"updelay":          "1000",
+			"downdelay":        "1000",
+			"xmit_hash_policy": "layer2",
+		},
+		CreatedAt: createdAt,
+		UpdatedAt: createdAt,
+		Links: []portgroups.ResourceLink{
+			{
+				Href: "http://ironic.example.com/v1/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+				Rel:  "self",
+			},
+			{
+				Href: "http://ironic.example.com/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+				Rel:  "bookmark",
+			},
+		},
+		Ports: []portgroups.ResourceLink{
+			{
+				Href: "http://ironic.example.com/v1/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796/ports",
+				Rel:  "self",
+			},
+		},
+	}
+)
+
+// HandlePortGroupListSuccessfully sets up the test server to respond to a
+// portgroup List request.
+func HandlePortGroupListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/portgroups", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		w.Header().Add("Content-Type", "application/json")
+		if err := r.ParseForm(); err != nil {
+			t.Errorf("Failed to parse request form: %v", err)
+		}
+
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			// Return both portgroups.
+			fmt.Fprintf(w, PortGroupsListBody)
+		case "d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796":
+			// No portgroups remain.
+			fmt.Fprintf(w, `{ "portgroups": [] }`)
+		default:
+			t.Fatalf("/portgroups invoked with unexpected marker=[%s]", marker)
+		}
+	})
+}
+
+// HandlePortGroupCreationSuccessfully sets up the test server to respond to a PortGroup creation request
+// with a given response.
+func HandlePortGroupCreationSuccessfully(t *testing.T, response string) {
+	th.Mux.HandleFunc("/portgroups", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{
+      "node_uuid": "f9c9a846-c53f-4b17-9f0c-dd9f459d35c8",
+      "address": "00:1a:2b:3c:4d:5e",
+      "name": "bond0",
+      "mode": "active-backup",
+      "standalone_ports_supported": true,
+      "properties": {
+          "miimon": "100",
+          "updelay": "1000",
+          "downdelay": "1000",
+          "xmit_hash_policy": "layer2"
+      },
+      "extra": {
+          "description": "Primary network bond",
+          "location": "rack-3-unit-12"
+      }
+  }`)
+
+		w.WriteHeader(http.StatusAccepted)
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, response)
+	})
+}
+
+// HandlePortGroupDeletionSuccessfully sets up the test server to respond to a
+// portgroup Deletion (DELETE) request for PortGroup2.
+func HandlePortGroupDeletionSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+		func(w http.ResponseWriter, r *http.Request) {
+			th.TestMethod(t, r, "DELETE")
+			th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+			w.WriteHeader(http.StatusNoContent)
+		})
+}
+
+// HandlePortGroupGetSuccessfully sets up the test server to respond to a
+// portgroup Get request for PortGroup1.
+func HandlePortGroupGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/portgroups/d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796",
+		func(w http.ResponseWriter, r *http.Request) {
+			th.TestMethod(t, r, "GET")
+			th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+			th.TestHeader(t, r, "Accept", "application/json")
+
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprintf(w, SinglePortGroupBody)
+		})
+}
diff --git a/openstack/baremetal/v1/portgroups/testing/requests_test.go b/openstack/baremetal/v1/portgroups/testing/requests_test.go
new file mode 100644
index 000000000..7a818060d
--- /dev/null
+++ b/openstack/baremetal/v1/portgroups/testing/requests_test.go
@@ -0,0 +1,94 @@
+package testing
+
+import (
+	"context"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/v2/openstack/baremetal/v1/portgroups"
+	"github.com/gophercloud/gophercloud/v2/pagination"
+	"github.com/gophercloud/gophercloud/v2/testhelper/client"
+
+	th "github.com/gophercloud/gophercloud/v2/testhelper"
+)
+
+func TestListPortGroups(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandlePortGroupListSuccessfully(t)
+
+	pages := 0
+	err := portgroups.List(client.ServiceClient(), portgroups.ListOpts{}).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := portgroups.ExtractPortGroups(page)
+		if err != nil {
+			return false, err
+		}
+
+		if len(actual) != 2 {
+			t.Fatalf("Expected 2 portgroups, got %d", len(actual))
+		}
+		th.AssertEquals(t, "d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796", actual[0].UUID)
+		th.AssertEquals(t, "a1b2c3d4-e5f6-7890-1234-56789abcdef0", actual[1].UUID)
+		th.AssertEquals(t, "bond0", actual[0].Name)
+		th.AssertEquals(t, "bond1", actual[1].Name)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+
+	if pages != 1 {
+		t.Errorf("Expected 1 page, saw %d", pages)
+	}
+}
+
+func TestCreatePortGroup(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandlePortGroupCreationSuccessfully(t, SinglePortGroupBody)
+
+	actual, err := portgroups.Create(context.TODO(), client.ServiceClient(), portgroups.CreateOpts{
+		Name:                     "bond0",
+		NodeUUID:                 "f9c9a846-c53f-4b17-9f0c-dd9f459d35c8",
+		Address:                  "00:1a:2b:3c:4d:5e",
+		Mode:                     "active-backup",
+		StandalonePortsSupported: true,
+		Properties: map[string]interface{}{
+			"miimon":           "100",
+			"updelay":          "1000",
+			"downdelay":        "1000",
+			"xmit_hash_policy": "layer2",
+		},
+		Extra: map[string]string{
+			"description": "Primary network bond",
+			"location":    "rack-3-unit-12",
+		},
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	th.CheckDeepEquals(t, PortGroup1, *actual)
+}
+
+func TestDeletePortGroup(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandlePortGroupDeletionSuccessfully(t)
+
+	res := portgroups.Delete(context.TODO(), client.ServiceClient(), "d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestGetPortGroup(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandlePortGroupGetSuccessfully(t)
+
+	c := client.ServiceClient()
+	actual, err := portgroups.Get(context.TODO(), c, "d2b42f0d-c7e6-4f08-b9bc-e8b23a6ee796").Extract()
+	if err != nil {
+		t.Fatalf("Unexpected Get error: %v", err)
+	}
+
+	th.CheckDeepEquals(t, PortGroup1, *actual)
+}
diff --git a/openstack/blockstorage/v2/quotasets/testing/fixtures_test.go b/openstack/blockstorage/v2/quotasets/testing/fixtures_test.go
index 854ff08b6..1e26bec78 100644
--- a/openstack/blockstorage/v2/quotasets/testing/fixtures_test.go
+++ b/openstack/blockstorage/v2/quotasets/testing/fixtures_test.go
@@ -75,8 +75,23 @@ var getUsageExpectedJSONBody = `
             "in_use": 40,
             "limit": 41,
             "reserved": 42
+        },
+        "gigabytes_hdd" : {
+            "in_use": 50,
+            "limit": 51,
+            "reserved": 52
+        },
+        "volumes_hdd" : {
+            "in_use": 53,
+            "limit": 54,
+            "reserved": 55
+        },
+        "snapshots_hdd": {
+            "in_use": 56,
+            "limit": 57,
+            "reserved": 58
         }
-	}
+    }
 }`
 
 var getUsageExpectedQuotaSet = quotasets.QuotaUsageSet{
@@ -88,6 +103,11 @@ var getUsageExpectedQuotaSet = quotasets.QuotaUsageSet{
 	Backups:            quotasets.QuotaUsage{InUse: 27, Limit: 28, Reserved: 29},
 	BackupGigabytes:    quotasets.QuotaUsage{InUse: 30, Limit: 31, Reserved: 32},
 	Groups:             quotasets.QuotaUsage{InUse: 40, Limit: 41, Reserved: 42},
+	Extra: map[string]quotasets.QuotaUsage{
+		"gigabytes_hdd": {InUse: 50, Limit: 51, Reserved: 52},
+		"volumes_hdd":   {InUse: 53, Limit: 54, Reserved: 55},
+		"snapshots_hdd": {InUse: 56, Limit: 57, Reserved: 58},
+	},
 }
 
 var fullUpdateExpectedJSONBody = `
@@ -147,7 +167,7 @@ var partialUpdateOpts = quotasets.UpdateOpts{
 	Extra:              make(map[string]any),
 }
 
-var partiualUpdateExpectedQuotaSet = quotasets.QuotaSet{
+var partialUpdateExpectedQuotaSet = quotasets.QuotaSet{
 	Volumes: 200,
 	Extra:   make(map[string]any),
 }
diff --git a/openstack/blockstorage/v2/quotasets/testing/requests_test.go b/openstack/blockstorage/v2/quotasets/testing/requests_test.go
index 563b2d1d3..025d824f2 100644
--- a/openstack/blockstorage/v2/quotasets/testing/requests_test.go
+++ b/openstack/blockstorage/v2/quotasets/testing/requests_test.go
@@ -51,7 +51,7 @@ func TestPartialUpdate(t *testing.T) {
 	HandleSuccessfulRequest(t, "PUT", "/os-quota-sets/"+FirstTenantID, partialUpdateExpectedJSONBody, uriQueryParms)
 	actual, err := quotasets.Update(context.TODO(), client.ServiceClient(), FirstTenantID, partialUpdateOpts).Extract()
 	th.AssertNoErr(t, err)
-	th.CheckDeepEquals(t, &partiualUpdateExpectedQuotaSet, actual)
+	th.CheckDeepEquals(t, &partialUpdateExpectedQuotaSet, actual)
 }
 
 type ErrorUpdateOpts quotasets.UpdateOpts
diff --git a/openstack/blockstorage/v3/quotasets/testing/fixtures_test.go b/openstack/blockstorage/v3/quotasets/testing/fixtures_test.go
index 103afef6e..d78b7d012 100644
--- a/openstack/blockstorage/v3/quotasets/testing/fixtures_test.go
+++ b/openstack/blockstorage/v3/quotasets/testing/fixtures_test.go
@@ -75,8 +75,23 @@ var getUsageExpectedJSONBody = `
             "in_use": 40,
             "limit": 41,
             "reserved": 42
+        },
+        "gigabytes_hdd" : {
+            "in_use": 50,
+            "limit": 51,
+            "reserved": 52
+        },
+        "volumes_hdd" : {
+            "in_use": 53,
+            "limit": 54,
+            "reserved": 55
+        },
+        "snapshots_hdd": {
+            "in_use": 56,
+            "limit": 57,
+            "reserved": 58
         }
-	}
+    }
 }`
 
 var getUsageExpectedQuotaSet = quotasets.QuotaUsageSet{
@@ -88,6 +103,11 @@ var getUsageExpectedQuotaSet = quotasets.QuotaUsageSet{
 	Backups:            quotasets.QuotaUsage{InUse: 27, Limit: 28, Reserved: 29},
 	BackupGigabytes:    quotasets.QuotaUsage{InUse: 30, Limit: 31, Reserved: 32},
 	Groups:             quotasets.QuotaUsage{InUse: 40, Limit: 41, Reserved: 42},
+	Extra: map[string]quotasets.QuotaUsage{
+		"gigabytes_hdd": {InUse: 50, Limit: 51, Reserved: 52},
+		"volumes_hdd":   {InUse: 53, Limit: 54, Reserved: 55},
+		"snapshots_hdd": {InUse: 56, Limit: 57, Reserved: 58},
+	},
 }
 
 var fullUpdateExpectedJSONBody = `
@@ -147,7 +167,7 @@ var partialUpdateOpts = quotasets.UpdateOpts{
 	Extra:              make(map[string]any),
 }
 
-var partiualUpdateExpectedQuotaSet = quotasets.QuotaSet{
+var partialUpdateExpectedQuotaSet = quotasets.QuotaSet{
 	Volumes: 200,
 	Extra:   make(map[string]any),
 }
diff --git a/openstack/blockstorage/v3/quotasets/testing/requests_test.go b/openstack/blockstorage/v3/quotasets/testing/requests_test.go
index ca17ef10b..ba2019fa5 100644
--- a/openstack/blockstorage/v3/quotasets/testing/requests_test.go
+++ b/openstack/blockstorage/v3/quotasets/testing/requests_test.go
@@ -51,7 +51,7 @@ func TestPartialUpdate(t *testing.T) {
 	HandleSuccessfulRequest(t, "PUT", "/os-quota-sets/"+FirstTenantID, partialUpdateExpectedJSONBody, uriQueryParms)
 	actual, err := quotasets.Update(context.TODO(), client.ServiceClient(), FirstTenantID, partialUpdateOpts).Extract()
 	th.AssertNoErr(t, err)
-	th.CheckDeepEquals(t, &partiualUpdateExpectedQuotaSet, actual)
+	th.CheckDeepEquals(t, &partialUpdateExpectedQuotaSet, actual)
 }
 
 type ErrorUpdateOpts quotasets.UpdateOpts
diff --git a/openstack/compute/v2/servers/testing/requests_test.go b/openstack/compute/v2/servers/testing/requests_test.go
index 83a7d3fe4..831ddb2ec 100644
--- a/openstack/compute/v2/servers/testing/requests_test.go
+++ b/openstack/compute/v2/servers/testing/requests_test.go
@@ -1161,3 +1161,25 @@ func TestCreateServerWithTags(t *testing.T) {
 	th.AssertNoErr(t, err)
 	th.CheckDeepEquals(t, ServerDerpTags, *actualServer)
 }
+
+func TestCreateServerWithHypervisorHostname(t *testing.T) {
+	opts := servers.CreateOpts{
+		Name:               "createdserver",
+		FlavorRef:          "performance1-1",
+		ImageRef:           "asdfasdfasdf",
+		HypervisorHostname: "test-hypervisor",
+	}
+	expected := `
+    {
+        "server": {
+            "name":"createdserver",
+            "flavorRef":"performance1-1",
+            "imageRef":"asdfasdfasdf",
+            "hypervisor_hostname":"test-hypervisor"
+        }
+    }
+    `
+	actual, err := opts.ToServerCreateMap()
+	th.AssertNoErr(t, err)
+	th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/openstack/dns/v2/zones/testing/requests_test.go b/openstack/dns/v2/zones/testing/requests_test.go
index 0b64e81ec..b75b99e96 100644
--- a/openstack/dns/v2/zones/testing/requests_test.go
+++ b/openstack/dns/v2/zones/testing/requests_test.go
@@ -2,6 +2,9 @@ package testing
 
 import (
 	"context"
+	"encoding/json"
+	"io"
+	"net/http"
 	"testing"
 
 	"github.com/gophercloud/gophercloud/v2/openstack/dns/v2/zones"
@@ -105,3 +108,41 @@ func TestDelete(t *testing.T) {
 	th.AssertNoErr(t, err)
 	th.CheckDeepEquals(t, &DeletedZone, actual)
 }
+
+func TestShare(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/zones/zone-id/shares", func(w http.ResponseWriter, r *http.Request) {
+		th.AssertEquals(t, r.Method, "POST")
+
+		body, err := io.ReadAll(r.Body)
+		defer r.Body.Close()
+		th.AssertNoErr(t, err)
+
+		var reqBody map[string]string
+		err = json.Unmarshal(body, &reqBody)
+		th.AssertNoErr(t, err)
+		expectedBody := map[string]string{"target_project_id": "project-id"}
+		th.CheckDeepEquals(t, expectedBody, reqBody)
+
+		w.WriteHeader(http.StatusCreated)
+	})
+
+	opts := zones.ShareZoneOpts{TargetProjectID: "project-id"}
+	err := zones.Share(context.TODO(), client.ServiceClient(), "zone-id", opts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestUnshare(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/zones/zone-id/shares/share-id", func(w http.ResponseWriter, r *http.Request) {
+		th.AssertEquals(t, r.Method, "DELETE")
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	err := zones.Unshare(context.TODO(), client.ServiceClient(), "zone-id", "share-id").ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v3/endpoints/testing/requests_test.go b/openstack/identity/v3/endpoints/testing/requests_test.go
index 348a1f1c6..ad138acd4 100644
--- a/openstack/identity/v3/endpoints/testing/requests_test.go
+++ b/openstack/identity/v3/endpoints/testing/requests_test.go
@@ -20,35 +20,31 @@ func TestCreateSuccessful(t *testing.T) {
 	th.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
 		th.TestMethod(t, r, "POST")
 		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
-		th.TestJSONRequest(t, r, `
-      {
-        "endpoint": {
-          "interface": "public",
-          "name": "the-endiest-of-points",
-          "region": "underground",
-          "url": "https://1.2.3.4:9000/",
-          "service_id": "asdfasdfasdfasdf"
-        }
-      }
-    `)
+		th.TestJSONRequest(t, r, `{
+			"endpoint": {
+				"interface": "public",
+				"name": "the-endiest-of-points",
+				"region": "underground",
+				"url": "https://1.2.3.4:9000/",
+				"service_id": "asdfasdfasdfasdf"
+			}
+		}`)
 
 		w.WriteHeader(http.StatusCreated)
-		fmt.Fprint(w, `
-      {
-        "endpoint": {
-          "id": "12",
-          "interface": "public",
-		  "enabled": true,
-		  "links": {
-            "self": "https://localhost:5000/v3/endpoints/12"
-          },
-          "name": "the-endiest-of-points",
-          "region": "underground",
-          "service_id": "asdfasdfasdfasdf",
-          "url": "https://1.2.3.4:9000/"
-        }
-      }
-    `)
+		fmt.Fprint(w, `{
+			"endpoint": {
+				"id": "12",
+				"interface": "public",
+				"enabled": true,
+				"links": {
+					"self": "https://localhost:5000/v3/endpoints/12"
+				},
+				"name": "the-endiest-of-points",
+				"region": "underground",
+				"service_id": "asdfasdfasdfasdf",
+				"url": "https://1.2.3.4:9000/"
+			}
+		}`)
 	})
 
 	actual, err := endpoints.Create(context.TODO(), client.ServiceClient(), endpoints.CreateOpts{
@@ -82,40 +78,38 @@ func TestListEndpoints(t *testing.T) {
 		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
 
 		w.Header().Add("Content-Type", "application/json")
-		fmt.Fprint(w, `
-			{
-				"endpoints": [
-					{
-						"id": "12",
-						"interface": "public",
-						"enabled": true,
-						"links": {
-							"self": "https://localhost:5000/v3/endpoints/12"
-						},
-						"name": "the-endiest-of-points",
-						"region": "underground",
-						"service_id": "asdfasdfasdfasdf",
-						"url": "https://1.2.3.4:9000/"
+		fmt.Fprint(w, `{
+			"endpoints": [
+				{
+					"id": "12",
+					"interface": "public",
+					"enabled": true,
+					"links": {
+						"self": "https://localhost:5000/v3/endpoints/12"
 					},
-					{
-						"id": "13",
-						"interface": "internal",
-						"enabled": false,
-						"links": {
-							"self": "https://localhost:5000/v3/endpoints/13"
-						},
-						"name": "shhhh",
-						"region": "underground",
-						"service_id": "asdfasdfasdfasdf",
-						"url": "https://1.2.3.4:9001/"
-					}
-				],
-				"links": {
-					"next": null,
-					"previous": null
+					"name": "the-endiest-of-points",
+					"region": "underground",
+					"service_id": "asdfasdfasdfasdf",
+					"url": "https://1.2.3.4:9000/"
+				},
+				{
+					"id": "13",
+					"interface": "internal",
+					"enabled": false,
+					"links": {
+						"self": "https://localhost:5000/v3/endpoints/13"
+					},
+					"name": "shhhh",
+					"region": "underground",
+					"service_id": "asdfasdfasdfasdf",
+					"url": "https://1.2.3.4:9001/"
 				}
+			],
+			"links": {
+				"next": null,
+				"previous": null
 			}
-		`)
+		}`)
 	})
 
 	count := 0
@@ -154,6 +148,47 @@ func TestListEndpoints(t *testing.T) {
 	th.AssertEquals(t, 1, count)
 }
 
+func TestGetEndpoint(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/endpoints/12", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		fmt.Fprint(w, `{
+			"endpoint": {
+				"id": "12",
+				"interface": "public",
+				"enabled": true,
+				"links": {
+					"self": "https://localhost:5000/v3/endpoints/12"
+				},
+				"name": "the-endiest-of-points",
+				"region": "underground",
+				"service_id": "asdfasdfasdfasdf",
+				"url": "https://1.2.3.4:9000/"
+			}
+		}`)
+	})
+
+	actual, err := endpoints.Get(context.TODO(), client.ServiceClient(), "12").Extract()
+	if err != nil {
+		t.Fatalf("Unexpected error from Get: %v", err)
+	}
+
+	expected := &endpoints.Endpoint{
+		ID:           "12",
+		Availability: gophercloud.AvailabilityPublic,
+		Enabled:      true,
+		Name:         "the-endiest-of-points",
+		Region:       "underground",
+		ServiceID:    "asdfasdfasdfasdf",
+		URL:          "https://1.2.3.4:9000/",
+	}
+	th.AssertDeepEquals(t, expected, actual)
+}
+
 func TestUpdateEndpoint(t *testing.T) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
@@ -161,17 +196,14 @@ func TestUpdateEndpoint(t *testing.T) {
 	th.Mux.HandleFunc("/endpoints/12", func(w http.ResponseWriter, r *http.Request) {
 		th.TestMethod(t, r, "PATCH")
 		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
-		th.TestJSONRequest(t, r, `
-		{
-	    "endpoint": {
-	      "name": "renamed",
+		th.TestJSONRequest(t, r, `{
+			"endpoint": {
+				"name": "renamed",
 				"region": "somewhere-else"
-	    }
-		}
-	`)
+			}
+		}`)
 
-		fmt.Fprint(w, `
-		{
+		fmt.Fprint(w, `{
 			"endpoint": {
 				"id": "12",
 				"interface": "public",
@@ -184,8 +216,7 @@ func TestUpdateEndpoint(t *testing.T) {
 				"service_id": "asdfasdfasdfasdf",
 				"url": "https://1.2.3.4:9000/"
 			}
-		}
-	`)
+		}`)
 	})
 
 	actual, err := endpoints.Update(context.TODO(), client.ServiceClient(), "12", endpoints.UpdateOpts{