Repository: gophercloud/gophercloud

Issue:
Added support for baremetal virutal interfaces
See https://github.com/gophercloud/gophercloud/issues/1429

Adding virtual interfaces which is documented here: https://docs.openstack.org/api-ref/baremetal/#vifs-virtual-interfaces-of-nodes

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

Discussion:

Solution patch:
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)
+}



Test patch:
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/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")
+}