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/openstack/baremetal/v1/nodes/requests.go b/openstack/baremetal/v1/nodes/requests.go
index 8b2eb4b21..6268f5037 100644
--- a/openstack/baremetal/v1/nodes/requests.go
+++ b/openstack/baremetal/v1/nodes/requests.go
@@ -1002,3 +1002,73 @@ func DetachVirtualMedia(ctx context.Context, client *gophercloud.ServiceClient,
 	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
 	return
 }
+
+// Request the list of virtual media devices attached to the Node.
+// Requires microversion 1.93 or later.
+func GetVirtualMedia(ctx context.Context, client *gophercloud.ServiceClient, id string) (r VirtualMediaGetResult) {
+
+	resp, err := client.Get(ctx, virtualMediaURL(client, id), &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+
+	_, 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..b33517a38 100644
--- a/openstack/baremetal/v1/nodes/results.go
+++ b/openstack/baremetal/v1/nodes/results.go
@@ -710,3 +710,45 @@ type VirtualMediaAttachResult struct {
 type VirtualMediaDetachResult struct {
 	gophercloud.ErrResult
 }
+
+// Requires microversion 1.93 or later.
+type VirtualMediaGetResult struct {
+	gophercloud.Result
+}
+
+// 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/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/v1/nodes_test.go b/internal/acceptance/openstack/baremetal/v1/nodes_test.go
index 669ea69cd..be48ee919 100644
--- a/internal/acceptance/openstack/baremetal/v1/nodes_test.go
+++ b/internal/acceptance/openstack/baremetal/v1/nodes_test.go
@@ -219,7 +219,7 @@ func TestNodesVirtualMedia(t *testing.T) {
 
 	client, err := clients.NewBareMetalV1Client()
 	th.AssertNoErr(t, err)
-	client.Microversion = "1.89"
+	client.Microversion = "1.93"
 
 	node, err := CreateNode(t, client)
 	th.AssertNoErr(t, err)
@@ -241,6 +241,15 @@ func TestNodesVirtualMedia(t *testing.T) {
 
 	err = nodes.DetachVirtualMedia(context.TODO(), client, node.UUID, nodes.DetachVirtualMediaOpts{}).ExtractErr()
 	th.AssertNoErr(t, err)
+
+	err = nodes.GetVirtualMedia(context.TODO(), client, node.UUID).Err
+	// Since Virtual Media GET api call is synchronous, we get a HTTP 400
+	// response as CreateNode has ipmi driver hardcoded, but the api is
+	// only supported by the redfish driver
+	// (TODO: hroyrh) fix this once redfish driver is used in the tests
+	if node.Driver == "redfish" {
+		th.AssertNoErr(t, err)
+	}
 }
 
 func TestNodesServicingHold(t *testing.T) {
@@ -272,3 +281,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/openstack/baremetal/v1/nodes/testing/fixtures_test.go b/openstack/baremetal/v1/nodes/testing/fixtures_test.go
index 88abebceb..371a4f00a 100644
--- a/openstack/baremetal/v1/nodes/testing/fixtures_test.go
+++ b/openstack/baremetal/v1/nodes/testing/fixtures_test.go
@@ -900,6 +900,28 @@ const NodeVirtualMediaAttachBodyWithSource = `
 }
 `
 
+const NodeVirtualMediaGetBodyAttached = `
+{
+    "image": "https://example.com/image",
+    "inserted": true,
+    "media_types": [
+      "CD",
+      "DVD"
+    ]
+}
+`
+
+const NodeVirtualMediaGetBodyNotAttached = `
+{
+    "image": "",
+    "inserted": false,
+    "media_types": [
+      "CD",
+      "DVD"
+    ]
+}
+`
+
 var (
 	createdAtFoo, _      = time.Parse(time.RFC3339, "2019-01-31T19:59:28+00:00")
 	createdAtBar, _      = time.Parse(time.RFC3339, "2019-01-31T19:59:29+00:00")
@@ -1849,3 +1871,91 @@ func HandleDetachVirtualMediaSuccessfully(t *testing.T, withType bool) {
 		w.WriteHeader(http.StatusNoContent)
 	})
 }
+
+func HandleGetVirtualMediaSuccessfully(t *testing.T, attached bool) {
+	th.Mux.HandleFunc("/nodes/1234asdf/vmedia", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		w.WriteHeader(http.StatusOK)
+		if attached {
+			fmt.Fprint(w, NodeVirtualMediaGetBodyAttached)
+		} else {
+			fmt.Fprint(w, NodeVirtualMediaGetBodyNotAttached)
+		}
+	})
+}
+
+// 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..24452be0c 100644
--- a/openstack/baremetal/v1/nodes/testing/requests_test.go
+++ b/openstack/baremetal/v1/nodes/testing/requests_test.go
@@ -829,3 +829,103 @@ func TestVirtualMediaDetachWithTypes(t *testing.T) {
 	err := nodes.DetachVirtualMedia(context.TODO(), c, "1234asdf", opts).ExtractErr()
 	th.AssertNoErr(t, err)
 }
+
+func TestVirtualMediaGetAttached(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetVirtualMediaSuccessfully(t, true)
+
+	c := client.ServiceClient()
+	err := nodes.GetVirtualMedia(context.TODO(), c, "1234asdf").Err
+	th.AssertNoErr(t, err)
+}
+
+func TestVirtualMediaGetNotAttached(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetVirtualMediaSuccessfully(t, false)
+
+	c := client.ServiceClient()
+	err := nodes.GetVirtualMedia(context.TODO(), c, "1234asdf").Err
+	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")
+}