Repository: gophercloud/gophercloud

Issue:
vpnaas: add support for more ciphers (auth, encryption, pfs modes)
In Neutron VPNaaS IKE and IPSEC policies the lists of choices for encryption algorithms, auth algorithms and PFS modes was extended in neutron-lib v3.9.0 and v3.18.0.
Add the support for the new ciphers in gophercloud.

Encryption algorithms: Several variants for AES CCM and AES GCM modes were added (see https://opendev.org/openstack/neutron-lib/src/commit/bf21a6dcd48bdd15c28086f256319ac035b7fef0/neutron_lib/api/definitions/vpn.py#L47), AES CTR modes (see https://opendev.org/openstack/neutron-lib/src/branch/master/neutron_lib/api/definitions/vpn_aes_ctr.py)

Auth algorithms: aes-xcbc abd aes-cmac (see https://opendev.org/openstack/neutron-lib/src/commit/bf21a6dcd48bdd15c28086f256319ac035b7fef0/neutron_lib/api/definitions/vpn.py#L178)

PFS: Diffie Hellman groups 15 to 31 (see https://opendev.org/openstack/neutron-lib/src/branch/master/neutron_lib/api/definitions/vpn_aes_ctr.py)


Hints from issue comments:
@github-actions: Thank you for reporting your first issue! Be sure that we will be looking at it, but keep in mind
this sometimes takes a while.
Please let the maintainers know if your issue has not got enough attention after a few days.
If any doubt, please consult our issue [tutorial](https://github.com/gophercloud/gophercloud/blob/main/docs/contributor-tutorial/step-02-issues.md).

Solution patch:
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/networking/v2/extensions/vpnaas/ikepolicies/requests.go b/openstack/networking/v2/extensions/vpnaas/ikepolicies/requests.go
index 7d5c81e13..f0845d6a6 100644
--- a/openstack/networking/v2/extensions/vpnaas/ikepolicies/requests.go
+++ b/openstack/networking/v2/extensions/vpnaas/ikepolicies/requests.go
@@ -15,22 +15,62 @@ type IKEVersion string
 type Phase1NegotiationMode string
 
 const (
-	AuthAlgorithmSHA1         AuthAlgorithm         = "sha1"
-	AuthAlgorithmSHA256       AuthAlgorithm         = "sha256"
-	AuthAlgorithmSHA384       AuthAlgorithm         = "sha384"
-	AuthAlgorithmSHA512       AuthAlgorithm         = "sha512"
-	EncryptionAlgorithm3DES   EncryptionAlgorithm   = "3des"
-	EncryptionAlgorithmAES128 EncryptionAlgorithm   = "aes-128"
-	EncryptionAlgorithmAES256 EncryptionAlgorithm   = "aes-256"
-	EncryptionAlgorithmAES192 EncryptionAlgorithm   = "aes-192"
-	UnitSeconds               Unit                  = "seconds"
-	UnitKilobytes             Unit                  = "kilobytes"
-	PFSGroup2                 PFS                   = "group2"
-	PFSGroup5                 PFS                   = "group5"
-	PFSGroup14                PFS                   = "group14"
-	IKEVersionv1              IKEVersion            = "v1"
-	IKEVersionv2              IKEVersion            = "v2"
-	Phase1NegotiationModeMain Phase1NegotiationMode = "main"
+	AuthAlgorithmSHA1              AuthAlgorithm         = "sha1"
+	AuthAlgorithmSHA256            AuthAlgorithm         = "sha256"
+	AuthAlgorithmSHA384            AuthAlgorithm         = "sha384"
+	AuthAlgorithmSHA512            AuthAlgorithm         = "sha512"
+	AuthAlgorithmAESXCBC           AuthAlgorithm         = "aes-xcbc"
+	AuthAlgorithmAESCMAC           AuthAlgorithm         = "aes-cmac"
+	EncryptionAlgorithm3DES        EncryptionAlgorithm   = "3des"
+	EncryptionAlgorithmAES128      EncryptionAlgorithm   = "aes-128"
+	EncryptionAlgorithmAES192      EncryptionAlgorithm   = "aes-192"
+	EncryptionAlgorithmAES256      EncryptionAlgorithm   = "aes-256"
+	EncryptionAlgorithmAES128CTR   EncryptionAlgorithm   = "aes-128-ctr"
+	EncryptionAlgorithmAES192CTR   EncryptionAlgorithm   = "aes-192-ctr"
+	EncryptionAlgorithmAES256CTR   EncryptionAlgorithm   = "aes-256-ctr"
+	EncryptionAlgorithmAES128CCM8  EncryptionAlgorithm   = "aes-128-ccm-8"
+	EncryptionAlgorithmAES128CCM12 EncryptionAlgorithm   = "aes-128-ccm-12"
+	EncryptionAlgorithmAES128CCM16 EncryptionAlgorithm   = "aes-128-ccm-16"
+	EncryptionAlgorithmAES192CCM8  EncryptionAlgorithm   = "aes-192-ccm-8"
+	EncryptionAlgorithmAES192CCM12 EncryptionAlgorithm   = "aes-192-ccm-12"
+	EncryptionAlgorithmAES192CCM16 EncryptionAlgorithm   = "aes-192-ccm-16"
+	EncryptionAlgorithmAES256CCM8  EncryptionAlgorithm   = "aes-256-ccm-8"
+	EncryptionAlgorithmAES256CCM12 EncryptionAlgorithm   = "aes-256-ccm-12"
+	EncryptionAlgorithmAES256CCM16 EncryptionAlgorithm   = "aes-256-ccm-16"
+	EncryptionAlgorithmAES128GCM8  EncryptionAlgorithm   = "aes-128-gcm-8"
+	EncryptionAlgorithmAES128GCM12 EncryptionAlgorithm   = "aes-128-gcm-12"
+	EncryptionAlgorithmAES128GCM16 EncryptionAlgorithm   = "aes-128-gcm-16"
+	EncryptionAlgorithmAES192GCM8  EncryptionAlgorithm   = "aes-192-gcm-8"
+	EncryptionAlgorithmAES192GCM12 EncryptionAlgorithm   = "aes-192-gcm-12"
+	EncryptionAlgorithmAES192GCM16 EncryptionAlgorithm   = "aes-192-gcm-16"
+	EncryptionAlgorithmAES256GCM8  EncryptionAlgorithm   = "aes-256-gcm-8"
+	EncryptionAlgorithmAES256GCM12 EncryptionAlgorithm   = "aes-256-gcm-12"
+	EncryptionAlgorithmAES256GCM16 EncryptionAlgorithm   = "aes-256-gcm-16"
+	UnitSeconds                    Unit                  = "seconds"
+	UnitKilobytes                  Unit                  = "kilobytes"
+	PFSGroup2                      PFS                   = "group2"
+	PFSGroup5                      PFS                   = "group5"
+	PFSGroup14                     PFS                   = "group14"
+	PFSGroup15                     PFS                   = "group15"
+	PFSGroup16                     PFS                   = "group16"
+	PFSGroup17                     PFS                   = "group17"
+	PFSGroup18                     PFS                   = "group18"
+	PFSGroup19                     PFS                   = "group19"
+	PFSGroup20                     PFS                   = "group20"
+	PFSGroup21                     PFS                   = "group21"
+	PFSGroup22                     PFS                   = "group22"
+	PFSGroup23                     PFS                   = "group23"
+	PFSGroup24                     PFS                   = "group24"
+	PFSGroup25                     PFS                   = "group25"
+	PFSGroup26                     PFS                   = "group26"
+	PFSGroup27                     PFS                   = "group27"
+	PFSGroup28                     PFS                   = "group28"
+	PFSGroup29                     PFS                   = "group29"
+	PFSGroup30                     PFS                   = "group30"
+	PFSGroup31                     PFS                   = "group31"
+	IKEVersionv1                   IKEVersion            = "v1"
+	IKEVersionv2                   IKEVersion            = "v2"
+	Phase1NegotiationModeMain      Phase1NegotiationMode = "main"
 )
 
 // CreateOptsBuilder allows extensions to add additional parameters to the
diff --git a/openstack/networking/v2/extensions/vpnaas/ipsecpolicies/requests.go b/openstack/networking/v2/extensions/vpnaas/ipsecpolicies/requests.go
index 4397c0587..9f34895c1 100644
--- a/openstack/networking/v2/extensions/vpnaas/ipsecpolicies/requests.go
+++ b/openstack/networking/v2/extensions/vpnaas/ipsecpolicies/requests.go
@@ -15,24 +15,64 @@ type PFS string
 type Unit string
 
 const (
-	TransformProtocolESP       TransformProtocol   = "esp"
-	TransformProtocolAH        TransformProtocol   = "ah"
-	TransformProtocolAHESP     TransformProtocol   = "ah-esp"
-	AuthAlgorithmSHA1          AuthAlgorithm       = "sha1"
-	AuthAlgorithmSHA256        AuthAlgorithm       = "sha256"
-	AuthAlgorithmSHA384        AuthAlgorithm       = "sha384"
-	AuthAlgorithmSHA512        AuthAlgorithm       = "sha512"
-	EncryptionAlgorithm3DES    EncryptionAlgorithm = "3des"
-	EncryptionAlgorithmAES128  EncryptionAlgorithm = "aes-128"
-	EncryptionAlgorithmAES256  EncryptionAlgorithm = "aes-256"
-	EncryptionAlgorithmAES192  EncryptionAlgorithm = "aes-192"
-	EncapsulationModeTunnel    EncapsulationMode   = "tunnel"
-	EncapsulationModeTransport EncapsulationMode   = "transport"
-	UnitSeconds                Unit                = "seconds"
-	UnitKilobytes              Unit                = "kilobytes"
-	PFSGroup2                  PFS                 = "group2"
-	PFSGroup5                  PFS                 = "group5"
-	PFSGroup14                 PFS                 = "group14"
+	TransformProtocolESP           TransformProtocol   = "esp"
+	TransformProtocolAH            TransformProtocol   = "ah"
+	TransformProtocolAHESP         TransformProtocol   = "ah-esp"
+	AuthAlgorithmSHA1              AuthAlgorithm       = "sha1"
+	AuthAlgorithmSHA256            AuthAlgorithm       = "sha256"
+	AuthAlgorithmSHA384            AuthAlgorithm       = "sha384"
+	AuthAlgorithmSHA512            AuthAlgorithm       = "sha512"
+	AuthAlgorithmAESXCBC           AuthAlgorithm       = "aes-xcbc"
+	AuthAlgorithmAESCMAC           AuthAlgorithm       = "aes-cmac"
+	EncryptionAlgorithm3DES        EncryptionAlgorithm = "3des"
+	EncryptionAlgorithmAES128      EncryptionAlgorithm = "aes-128"
+	EncryptionAlgorithmAES192      EncryptionAlgorithm = "aes-192"
+	EncryptionAlgorithmAES256      EncryptionAlgorithm = "aes-256"
+	EncryptionAlgorithmAES128CTR   EncryptionAlgorithm = "aes-128-ctr"
+	EncryptionAlgorithmAES192CTR   EncryptionAlgorithm = "aes-192-ctr"
+	EncryptionAlgorithmAES256CTR   EncryptionAlgorithm = "aes-256-ctr"
+	EncryptionAlgorithmAES128CCM8  EncryptionAlgorithm = "aes-128-ccm-8"
+	EncryptionAlgorithmAES128CCM12 EncryptionAlgorithm = "aes-128-ccm-12"
+	EncryptionAlgorithmAES128CCM16 EncryptionAlgorithm = "aes-128-ccm-16"
+	EncryptionAlgorithmAES192CCM8  EncryptionAlgorithm = "aes-192-ccm-8"
+	EncryptionAlgorithmAES192CCM12 EncryptionAlgorithm = "aes-192-ccm-12"
+	EncryptionAlgorithmAES192CCM16 EncryptionAlgorithm = "aes-192-ccm-16"
+	EncryptionAlgorithmAES256CCM8  EncryptionAlgorithm = "aes-256-ccm-8"
+	EncryptionAlgorithmAES256CCM12 EncryptionAlgorithm = "aes-256-ccm-12"
+	EncryptionAlgorithmAES256CCM16 EncryptionAlgorithm = "aes-256-ccm-16"
+	EncryptionAlgorithmAES128GCM8  EncryptionAlgorithm = "aes-128-gcm-8"
+	EncryptionAlgorithmAES128GCM12 EncryptionAlgorithm = "aes-128-gcm-12"
+	EncryptionAlgorithmAES128GCM16 EncryptionAlgorithm = "aes-128-gcm-16"
+	EncryptionAlgorithmAES192GCM8  EncryptionAlgorithm = "aes-192-gcm-8"
+	EncryptionAlgorithmAES192GCM12 EncryptionAlgorithm = "aes-192-gcm-12"
+	EncryptionAlgorithmAES192GCM16 EncryptionAlgorithm = "aes-192-gcm-16"
+	EncryptionAlgorithmAES256GCM8  EncryptionAlgorithm = "aes-256-gcm-8"
+	EncryptionAlgorithmAES256GCM12 EncryptionAlgorithm = "aes-256-gcm-12"
+	EncryptionAlgorithmAES256GCM16 EncryptionAlgorithm = "aes-256-gcm-16"
+	EncapsulationModeTunnel        EncapsulationMode   = "tunnel"
+	EncapsulationModeTransport     EncapsulationMode   = "transport"
+	UnitSeconds                    Unit                = "seconds"
+	UnitKilobytes                  Unit                = "kilobytes"
+	PFSGroup2                      PFS                 = "group2"
+	PFSGroup5                      PFS                 = "group5"
+	PFSGroup14                     PFS                 = "group14"
+	PFSGroup15                     PFS                 = "group15"
+	PFSGroup16                     PFS                 = "group16"
+	PFSGroup17                     PFS                 = "group17"
+	PFSGroup18                     PFS                 = "group18"
+	PFSGroup19                     PFS                 = "group19"
+	PFSGroup20                     PFS                 = "group20"
+	PFSGroup21                     PFS                 = "group21"
+	PFSGroup22                     PFS                 = "group22"
+	PFSGroup23                     PFS                 = "group23"
+	PFSGroup24                     PFS                 = "group24"
+	PFSGroup25                     PFS                 = "group25"
+	PFSGroup26                     PFS                 = "group26"
+	PFSGroup27                     PFS                 = "group27"
+	PFSGroup28                     PFS                 = "group28"
+	PFSGroup29                     PFS                 = "group29"
+	PFSGroup30                     PFS                 = "group30"
+	PFSGroup31                     PFS                 = "group31"
 )
 
 // CreateOptsBuilder allows extensions to add additional parameters to the



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/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)
+}