diff --git a/internal/acceptance/openstack/baremetal/v1/nodes_test.go b/internal/acceptance/openstack/baremetal/v1/nodes_test.go index 669ea69cdc..1151135e62 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/requests.go b/openstack/baremetal/v1/nodes/requests.go index 8cb0de9e05..9db04f3ba1 100644 --- a/openstack/baremetal/v1/nodes/requests.go +++ b/openstack/baremetal/v1/nodes/requests.go @@ -998,3 +998,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 5c6eb5c190..a6e8df231b 100644 --- a/openstack/baremetal/v1/nodes/results.go +++ b/openstack/baremetal/v1/nodes/results.go @@ -660,3 +660,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/testing/fixtures_test.go b/openstack/baremetal/v1/nodes/testing/fixtures_test.go index d47d70b13c..fe886131a5 100644 --- a/openstack/baremetal/v1/nodes/testing/fixtures_test.go +++ b/openstack/baremetal/v1/nodes/testing/fixtures_test.go @@ -1751,3 +1751,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 5529e795ad..487c350175 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/nodes/urls.go b/openstack/baremetal/v1/nodes/urls.go index 2948bb659e..b86e4820e5 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) +}