diff --git a/openstack/client.go b/openstack/client.go index cd95e599e5..805abce427 100644 --- a/openstack/client.go +++ b/openstack/client.go @@ -508,3 +508,9 @@ func NewWorkflowV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOp func NewPlacementV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) { return initClientOpts(client, eo, "placement", 1) } + +// NewVirtualPrivateCloudV1 creates a ServiceClient that may be used with the v1 +// Virtual Private Cloud (Orion) lifecycle management package. +func NewVirtualPrivateCloudV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) { + return initClientOpts(client, eo, "vpc", 1) +} diff --git a/openstack/networking/v2/extensions/layer3/routers/requests.go b/openstack/networking/v2/extensions/layer3/routers/requests.go index 3e49a30f10..318c98ae69 100644 --- a/openstack/networking/v2/extensions/layer3/routers/requests.go +++ b/openstack/networking/v2/extensions/layer3/routers/requests.go @@ -37,6 +37,7 @@ type ListOpts struct { NotTags string `q:"not-tags"` NotTagsAny string `q:"not-tags-any"` RevisionNumber *int `q:"revision_number"` + VpcID string `q:"vpc_id"` } // ToRouterListQuery formats a ListOpts into a query string. @@ -85,6 +86,7 @@ type CreateOpts struct { ProjectID string `json:"project_id,omitempty"` GatewayInfo *GatewayInfo `json:"external_gateway_info,omitempty"` AvailabilityZoneHints []string `json:"availability_zone_hints,omitempty"` + VpcID string `json:"vpc_id,omitempty"` } // ToRouterCreateMap builds a create request body from CreateOpts. diff --git a/openstack/networking/v2/extensions/layer3/routers/results.go b/openstack/networking/v2/extensions/layer3/routers/results.go index 38b3a565da..6045e2848a 100644 --- a/openstack/networking/v2/extensions/layer3/routers/results.go +++ b/openstack/networking/v2/extensions/layer3/routers/results.go @@ -81,6 +81,9 @@ type Router struct { // RevisionNumber optionally set via extensions/standard-attr-revisions RevisionNumber int `json:"revision_number"` + // VpcID is the VPC identifier. 1-to-1 relation with a router. + VpcID string `json:"vpc_id"` + // Timestamp when the router was created CreatedAt time.Time `json:"-"` diff --git a/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go b/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go index 1125aa12c4..a8b42a41ee 100644 --- a/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go +++ b/openstack/networking/v2/extensions/layer3/routers/testing/requests_test.go @@ -897,3 +897,98 @@ func TestRemoveExternalGateways(t *testing.T) { th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c") th.AssertEquals(t, n.GatewayInfo.NetworkID, "") } + +func TestCreateWithVpcID(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/routers", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "router": { + "name": "vpc-router", + "admin_state_up": true, + "vpc_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } +}`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + fmt.Fprint(w, ` +{ + "router": { + "status": "ACTIVE", + "external_gateway_info": null, + "name": "vpc-router", + "admin_state_up": true, + "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3", + "distributed": false, + "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e", + "vpc_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } +}`) + }) + + asu := true + options := routers.CreateOpts{ + Name: "vpc-router", + AdminStateUp: &asu, + VpcID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + } + r, err := routers.Create(context.TODO(), fake.ServiceClient(fakeServer), options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "vpc-router", r.Name) + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", r.VpcID) + th.AssertEquals(t, "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e", r.ID) +} + +func TestListWithVpcID(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/routers", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + th.AssertEquals(t, r.URL.Query().Get("vpc_id"), "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1") + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "routers": [ + { + "status": "ACTIVE", + "external_gateway_info": null, + "name": "vpc-router", + "admin_state_up": true, + "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3", + "distributed": false, + "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e", + "vpc_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } + ] +}`) + }) + + listOpts := routers.ListOpts{ + VpcID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + } + + allPages, err := routers.List(fake.ServiceClient(fakeServer), listOpts).AllPages(context.TODO()) + th.AssertNoErr(t, err) + + allRouters, err := routers.ExtractRouters(allPages) + th.AssertNoErr(t, err) + + th.AssertEquals(t, 1, len(allRouters)) + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", allRouters[0].VpcID) + th.AssertEquals(t, "vpc-router", allRouters[0].Name) +} diff --git a/openstack/networking/v2/extensions/layer3/routetables/doc.go b/openstack/networking/v2/extensions/layer3/routetables/doc.go new file mode 100644 index 0000000000..278c3497e6 --- /dev/null +++ b/openstack/networking/v2/extensions/layer3/routetables/doc.go @@ -0,0 +1,8 @@ +// Package routetables provides information and interaction with the route +// tables extension for the OpenStack Networking service. +// +// A route table is associated with a router and contains routes and subnet +// associations. Each router has a default route table that cannot be deleted. +// Additional custom route tables can be created and subnets can be moved +// between them. +package routetables diff --git a/openstack/networking/v2/extensions/layer3/routetables/requests.go b/openstack/networking/v2/extensions/layer3/routetables/requests.go new file mode 100644 index 0000000000..81ee689e40 --- /dev/null +++ b/openstack/networking/v2/extensions/layer3/routetables/requests.go @@ -0,0 +1,273 @@ +package routetables + +import ( + "context" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/pagination" +) + +// ListOptsBuilder allows extensions to add additional parameters to the +// List request. +type ListOptsBuilder interface { + ToRouteTableListQuery() (string, error) +} + +// ListOpts allows the filtering of paginated collections through the API. +type ListOpts struct { + RouterID string `q:"router_id"` + IsDefault *bool `q:"is_default"` + Limit int `q:"limit"` + Marker string `q:"marker"` +} + +// ToRouteTableListQuery formats a ListOpts into a query string. +func (opts ListOpts) ToRouteTableListQuery() (string, error) { + q, err := gophercloud.BuildQueryString(opts) + if err != nil { + return "", err + } + return q.String(), nil +} + +// List returns a Pager which allows you to iterate over a collection of +// route tables. +func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager { + url := rootURL(c) + if opts != nil { + query, err := opts.ToRouteTableListQuery() + if err != nil { + return pagination.Pager{Err: err} + } + url += query + } + return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page { + return RouteTablePage{pagination.LinkedPageBase{PageResult: r}} + }) +} + +// Get retrieves a specific route table based on its unique ID. +func Get(ctx context.Context, c *gophercloud.ServiceClient, id string) (r GetResult) { + resp, err := c.Get(ctx, resourceURL(c, id), &r.Body, nil) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// CreateOptsBuilder allows extensions to add additional parameters to the +// Create request. +type CreateOptsBuilder interface { + ToRouteTableCreateMap() (map[string]any, error) +} + +// CreateOpts represents options used to create a route table. +type CreateOpts struct { + // Name is the human-readable name of the route table. + Name string `json:"name,omitempty"` + + // Description is a human-readable description of the route table. + Description string `json:"description,omitempty"` + + // RouterID is the ID of the router this route table belongs to. Required. + RouterID string `json:"router_id" required:"true"` + + // IsDefault indicates whether this is the default route table. + IsDefault *bool `json:"is_default,omitempty"` +} + +// ToRouteTableCreateMap builds a request body from CreateOpts. +func (opts CreateOpts) ToRouteTableCreateMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "route_table") +} + +// Create accepts a CreateOpts struct and creates a new route table. +func Create(ctx context.Context, c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) { + b, err := opts.ToRouteTableCreateMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Post(ctx, rootURL(c), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{201}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// UpdateOptsBuilder allows extensions to add additional parameters to the +// Update request. +type UpdateOptsBuilder interface { + ToRouteTableUpdateMap() (map[string]any, error) +} + +// UpdateOpts represents options used to update a route table. +type UpdateOpts struct { + // Name is the human-readable name of the route table. + Name *string `json:"name,omitempty"` + + // Description is a human-readable description of the route table. + Description *string `json:"description,omitempty"` +} + +// ToRouteTableUpdateMap builds a request body from UpdateOpts. +func (opts UpdateOpts) ToRouteTableUpdateMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "route_table") +} + +// Update accepts an UpdateOpts struct and updates a route table. +func Update(ctx context.Context, c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) { + b, err := opts.ToRouteTableUpdateMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Put(ctx, resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// Delete accepts a route table ID and deletes it. +func Delete(ctx context.Context, c *gophercloud.ServiceClient, id string) (r DeleteResult) { + resp, err := c.Delete(ctx, resourceURL(c, id), nil) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// AddRoutesOptsBuilder allows extensions to add additional parameters to the +// AddRoutes request. +type AddRoutesOptsBuilder interface { + ToAddRoutesMap() (map[string]any, error) +} + +// RouteOpts represents a route to add to a route table. +type RouteOpts struct { + // Destination is the destination CIDR of the route. Required. + Destination string `json:"destination" required:"true"` + + // Nexthop is the IP address of the next hop. + Nexthop string `json:"nexthop,omitempty"` + + // NexthopType is the type of the nexthop. Required. + NexthopType string `json:"nexthop_type" required:"true"` +} + +// AddRoutesOpts represents options for adding routes to a route table. +type AddRoutesOpts struct { + Routes []RouteOpts `json:"routes" required:"true"` +} + +// ToAddRoutesMap builds a request body from AddRoutesOpts. +func (opts AddRoutesOpts) ToAddRoutesMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "") +} + +// AddRoutes adds routes to a route table. +func AddRoutes(ctx context.Context, c *gophercloud.ServiceClient, id string, opts AddRoutesOptsBuilder) (r AddRoutesResult) { + b, err := opts.ToAddRoutesMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Put(ctx, addRoutesURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// RemoveRoutesOptsBuilder allows extensions to add additional parameters to the +// RemoveRoutes request. +type RemoveRoutesOptsBuilder interface { + ToRemoveRoutesMap() (map[string]any, error) +} + +// RemoveRouteOpts represents a route to remove from a route table. +type RemoveRouteOpts struct { + // Destination is the destination CIDR of the route to remove. Required. + Destination string `json:"destination" required:"true"` +} + +// RemoveRoutesOpts represents options for removing routes from a route table. +type RemoveRoutesOpts struct { + Routes []RemoveRouteOpts `json:"routes" required:"true"` +} + +// ToRemoveRoutesMap builds a request body from RemoveRoutesOpts. +func (opts RemoveRoutesOpts) ToRemoveRoutesMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "") +} + +// RemoveRoutes removes routes from a route table. +func RemoveRoutes(ctx context.Context, c *gophercloud.ServiceClient, id string, opts RemoveRoutesOptsBuilder) (r RemoveRoutesResult) { + b, err := opts.ToRemoveRoutesMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Put(ctx, removeRoutesURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// AddSubnetsOptsBuilder allows extensions to add additional parameters to the +// AddSubnets request. +type AddSubnetsOptsBuilder interface { + ToAddSubnetsMap() (map[string]any, error) +} + +// AddSubnetsOpts represents options for adding subnets to a route table. +type AddSubnetsOpts struct { + Subnets []string `json:"subnets" required:"true"` +} + +// ToAddSubnetsMap builds a request body from AddSubnetsOpts. +func (opts AddSubnetsOpts) ToAddSubnetsMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "") +} + +// AddSubnets adds subnet associations to a route table. +func AddSubnets(ctx context.Context, c *gophercloud.ServiceClient, id string, opts AddSubnetsOptsBuilder) (r AddSubnetsResult) { + b, err := opts.ToAddSubnetsMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Put(ctx, addSubnetsURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// RemoveSubnetsOptsBuilder allows extensions to add additional parameters to the +// RemoveSubnets request. +type RemoveSubnetsOptsBuilder interface { + ToRemoveSubnetsMap() (map[string]any, error) +} + +// RemoveSubnetsOpts represents options for removing subnets from a route table. +type RemoveSubnetsOpts struct { + Subnets []string `json:"subnets" required:"true"` +} + +// ToRemoveSubnetsMap builds a request body from RemoveSubnetsOpts. +func (opts RemoveSubnetsOpts) ToRemoveSubnetsMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "") +} + +// RemoveSubnets removes subnet associations from a route table. +func RemoveSubnets(ctx context.Context, c *gophercloud.ServiceClient, id string, opts RemoveSubnetsOptsBuilder) (r RemoveSubnetsResult) { + b, err := opts.ToRemoveSubnetsMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Put(ctx, removeSubnetsURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} diff --git a/openstack/networking/v2/extensions/layer3/routetables/results.go b/openstack/networking/v2/extensions/layer3/routetables/results.go new file mode 100644 index 0000000000..a8b53c8263 --- /dev/null +++ b/openstack/networking/v2/extensions/layer3/routetables/results.go @@ -0,0 +1,135 @@ +package routetables + +import ( + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/pagination" +) + +// Route represents a route entry in a route table. +type Route struct { + // Destination is the destination CIDR of the route. + Destination string `json:"destination"` + + // Nexthop is the IP address of the next hop. Can be empty for non-IP nexthop types. + Nexthop string `json:"nexthop"` + + // NexthopType is the type of the nexthop: local, ip, blackhole, unreachable, prohibit. + NexthopType string `json:"nexthop_type"` +} + +// RouteTable represents a route table resource. +type RouteTable struct { + // ID is the unique identifier of the route table. + ID string `json:"id"` + + // Name is the human-readable name of the route table. + Name string `json:"name"` + + // Description is a human-readable description of the route table. + Description string `json:"description"` + + // RouterID is the ID of the router this route table belongs to. + RouterID string `json:"router_id"` + + // IsDefault indicates whether this is the default route table for the router. + IsDefault bool `json:"is_default"` + + // Routes is the list of routes in this route table. + Routes []Route `json:"routes"` + + // Subnets is the list of subnet IDs associated with this route table. + Subnets []string `json:"subnets"` + + // ProjectID is the project owner of the route table. + ProjectID string `json:"project_id"` +} + +type commonResult struct { + gophercloud.Result +} + +// Extract interprets the result as a RouteTable. +func (r commonResult) Extract() (*RouteTable, error) { + var s struct { + RouteTable *RouteTable `json:"route_table"` + } + err := r.ExtractInto(&s) + return s.RouteTable, err +} + +// CreateResult represents the result of a create operation. +type CreateResult struct { + commonResult +} + +// GetResult represents the result of a get operation. +type GetResult struct { + commonResult +} + +// UpdateResult represents the result of an update operation. +type UpdateResult struct { + commonResult +} + +// DeleteResult represents the result of a delete operation. +type DeleteResult struct { + gophercloud.ErrResult +} + +// AddRoutesResult represents the result of an add routes operation. +type AddRoutesResult struct { + commonResult +} + +// RemoveRoutesResult represents the result of a remove routes operation. +type RemoveRoutesResult struct { + commonResult +} + +// AddSubnetsResult represents the result of an add subnets operation. +type AddSubnetsResult struct { + commonResult +} + +// RemoveSubnetsResult represents the result of a remove subnets operation. +type RemoveSubnetsResult struct { + commonResult +} + +// RouteTablePage is the page returned by a pager when traversing over a +// collection of route tables. +type RouteTablePage struct { + pagination.LinkedPageBase +} + +// NextPageURL is invoked when a paginated collection of route tables has +// reached the end of a page and the pager seeks to traverse over a new one. +func (r RouteTablePage) NextPageURL() (string, error) { + var s struct { + Links []gophercloud.Link `json:"route_tables_links"` + } + err := r.ExtractInto(&s) + if err != nil { + return "", err + } + return gophercloud.ExtractNextURL(s.Links) +} + +// IsEmpty checks whether a RouteTablePage struct is empty. +func (r RouteTablePage) IsEmpty() (bool, error) { + if r.StatusCode == 204 { + return true, nil + } + + is, err := ExtractRouteTables(r) + return len(is) == 0, err +} + +// ExtractRouteTables accepts a Page struct, specifically a RouteTablePage, +// and extracts the elements into a slice of RouteTable structs. +func ExtractRouteTables(r pagination.Page) ([]RouteTable, error) { + var s []RouteTable + err := r.(RouteTablePage).ExtractIntoSlicePtr(&s, "route_tables") + return s, err +} diff --git a/openstack/networking/v2/extensions/layer3/routetables/testing/doc.go b/openstack/networking/v2/extensions/layer3/routetables/testing/doc.go new file mode 100644 index 0000000000..2659f409e7 --- /dev/null +++ b/openstack/networking/v2/extensions/layer3/routetables/testing/doc.go @@ -0,0 +1,2 @@ +// routetables unit tests +package testing diff --git a/openstack/networking/v2/extensions/layer3/routetables/testing/requests_test.go b/openstack/networking/v2/extensions/layer3/routetables/testing/requests_test.go new file mode 100644 index 0000000000..49e49732d7 --- /dev/null +++ b/openstack/networking/v2/extensions/layer3/routetables/testing/requests_test.go @@ -0,0 +1,505 @@ +package testing + +import ( + "context" + "fmt" + "net/http" + "testing" + + fake "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/common" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/routetables" + "github.com/gophercloud/gophercloud/v2/pagination" + th "github.com/gophercloud/gophercloud/v2/testhelper" +) + +func TestList(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "route_tables": [ + { + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "name": "default", + "description": "Default route table", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": true, + "routes": [ + {"destination": "10.0.0.0/8", "nexthop": "", "nexthop_type": "local"} + ], + "subnets": ["08eae331-0402-425a-923c-34f7cfe39c1b"], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + }, + { + "id": "b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f", + "name": "custom", + "description": "Custom route table", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": false, + "routes": [ + {"destination": "192.168.1.0/24", "nexthop": "10.0.0.1", "nexthop_type": "ip"} + ], + "subnets": [], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } + ] +}`) + }) + + count := 0 + + err := routetables.List(fake.ServiceClient(fakeServer), routetables.ListOpts{}).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) { + count++ + actual, err := routetables.ExtractRouteTables(page) + if err != nil { + t.Errorf("Failed to extract route tables: %v", err) + return false, err + } + + expected := []routetables.RouteTable{ + { + ID: "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + Name: "default", + Description: "Default route table", + RouterID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + IsDefault: true, + Routes: []routetables.Route{ + {Destination: "10.0.0.0/8", Nexthop: "", NexthopType: "local"}, + }, + Subnets: []string{"08eae331-0402-425a-923c-34f7cfe39c1b"}, + ProjectID: "4fd44f30292945e481c7b8a0c8908869", + }, + { + ID: "b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f", + Name: "custom", + Description: "Custom route table", + RouterID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + IsDefault: false, + Routes: []routetables.Route{ + {Destination: "192.168.1.0/24", Nexthop: "10.0.0.1", NexthopType: "ip"}, + }, + Subnets: []string{}, + ProjectID: "4fd44f30292945e481c7b8a0c8908869", + }, + } + + th.CheckDeepEquals(t, expected, actual) + + return true, nil + }) + th.AssertNoErr(t, err) + + if count != 1 { + t.Errorf("Expected 1 page, got %d", count) + } +} + +func TestListWithFilter(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", r.URL.Query().Get("router_id")) + th.AssertEquals(t, "true", r.URL.Query().Get("is_default")) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "route_tables": [ + { + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "name": "default", + "description": "", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": true, + "routes": [], + "subnets": [], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } + ] +}`) + }) + + isDefault := true + listOpts := routetables.ListOpts{ + RouterID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + IsDefault: &isDefault, + } + + allPages, err := routetables.List(fake.ServiceClient(fakeServer), listOpts).AllPages(context.TODO()) + th.AssertNoErr(t, err) + + allRouteTables, err := routetables.ExtractRouteTables(allPages) + th.AssertNoErr(t, err) + + th.AssertEquals(t, 1, len(allRouteTables)) + th.AssertEquals(t, true, allRouteTables[0].IsDefault) +} + +func TestGet(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables/e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "route_table": { + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "name": "default", + "description": "Default route table", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": true, + "routes": [ + {"destination": "10.0.0.0/8", "nexthop": "", "nexthop_type": "local"}, + {"destination": "0.0.0.0/0", "nexthop": "10.0.0.1", "nexthop_type": "ip"} + ], + "subnets": ["08eae331-0402-425a-923c-34f7cfe39c1b", "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } +}`) + }) + + rt, err := routetables.Get(context.TODO(), fake.ServiceClient(fakeServer), "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a").Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", rt.ID) + th.AssertEquals(t, "default", rt.Name) + th.AssertEquals(t, "Default route table", rt.Description) + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", rt.RouterID) + th.AssertEquals(t, true, rt.IsDefault) + th.AssertEquals(t, 2, len(rt.Routes)) + th.AssertEquals(t, "10.0.0.0/8", rt.Routes[0].Destination) + th.AssertEquals(t, "local", rt.Routes[0].NexthopType) + th.AssertEquals(t, "0.0.0.0/0", rt.Routes[1].Destination) + th.AssertEquals(t, "10.0.0.1", rt.Routes[1].Nexthop) + th.AssertEquals(t, "ip", rt.Routes[1].NexthopType) + th.AssertEquals(t, 2, len(rt.Subnets)) + th.AssertEquals(t, "4fd44f30292945e481c7b8a0c8908869", rt.ProjectID) +} + +func TestCreate(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "route_table": { + "name": "custom-rt", + "description": "Custom route table for web tier", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } +}`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + fmt.Fprint(w, ` +{ + "route_table": { + "id": "b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f", + "name": "custom-rt", + "description": "Custom route table for web tier", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": false, + "routes": [], + "subnets": [], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } +}`) + }) + + options := routetables.CreateOpts{ + Name: "custom-rt", + Description: "Custom route table for web tier", + RouterID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + } + rt, err := routetables.Create(context.TODO(), fake.ServiceClient(fakeServer), options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f", rt.ID) + th.AssertEquals(t, "custom-rt", rt.Name) + th.AssertEquals(t, "Custom route table for web tier", rt.Description) + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", rt.RouterID) + th.AssertEquals(t, false, rt.IsDefault) + th.AssertEquals(t, 0, len(rt.Routes)) + th.AssertEquals(t, 0, len(rt.Subnets)) +} + +func TestUpdate(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables/b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "route_table": { + "name": "renamed-rt", + "description": "Updated description" + } +}`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "route_table": { + "id": "b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f", + "name": "renamed-rt", + "description": "Updated description", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": false, + "routes": [], + "subnets": [], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } +}`) + }) + + name := "renamed-rt" + description := "Updated description" + options := routetables.UpdateOpts{ + Name: &name, + Description: &description, + } + rt, err := routetables.Update(context.TODO(), fake.ServiceClient(fakeServer), "b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f", options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "renamed-rt", rt.Name) + th.AssertEquals(t, "Updated description", rt.Description) +} + +func TestDelete(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables/b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "DELETE") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + w.WriteHeader(http.StatusNoContent) + }) + + res := routetables.Delete(context.TODO(), fake.ServiceClient(fakeServer), "b2f3c4d5-1a2b-3c4d-5e6f-7a8b9c0d1e2f") + th.AssertNoErr(t, res.Err) +} + +func TestAddRoutes(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables/e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a/add_routes", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestJSONRequest(t, r, ` +{ + "routes": [ + {"destination": "192.168.1.0/24", "nexthop": "10.0.0.1", "nexthop_type": "ip"}, + {"destination": "172.16.0.0/12", "nexthop_type": "blackhole"} + ] +}`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "route_table": { + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "name": "default", + "description": "", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": true, + "routes": [ + {"destination": "10.0.0.0/8", "nexthop": "", "nexthop_type": "local"}, + {"destination": "192.168.1.0/24", "nexthop": "10.0.0.1", "nexthop_type": "ip"}, + {"destination": "172.16.0.0/12", "nexthop": "", "nexthop_type": "blackhole"} + ], + "subnets": [], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } +}`) + }) + + opts := routetables.AddRoutesOpts{ + Routes: []routetables.RouteOpts{ + {Destination: "192.168.1.0/24", Nexthop: "10.0.0.1", NexthopType: "ip"}, + {Destination: "172.16.0.0/12", NexthopType: "blackhole"}, + }, + } + rt, err := routetables.AddRoutes(context.TODO(), fake.ServiceClient(fakeServer), "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", opts).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, 3, len(rt.Routes)) + th.AssertEquals(t, "192.168.1.0/24", rt.Routes[1].Destination) + th.AssertEquals(t, "10.0.0.1", rt.Routes[1].Nexthop) + th.AssertEquals(t, "ip", rt.Routes[1].NexthopType) + th.AssertEquals(t, "172.16.0.0/12", rt.Routes[2].Destination) + th.AssertEquals(t, "blackhole", rt.Routes[2].NexthopType) +} + +func TestRemoveRoutes(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables/e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a/remove_routes", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestJSONRequest(t, r, ` +{ + "routes": [ + {"destination": "192.168.1.0/24"} + ] +}`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "route_table": { + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "name": "default", + "description": "", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": true, + "routes": [ + {"destination": "10.0.0.0/8", "nexthop": "", "nexthop_type": "local"} + ], + "subnets": [], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } +}`) + }) + + opts := routetables.RemoveRoutesOpts{ + Routes: []routetables.RemoveRouteOpts{ + {Destination: "192.168.1.0/24"}, + }, + } + rt, err := routetables.RemoveRoutes(context.TODO(), fake.ServiceClient(fakeServer), "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", opts).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, 1, len(rt.Routes)) + th.AssertEquals(t, "10.0.0.0/8", rt.Routes[0].Destination) +} + +func TestAddSubnets(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables/e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a/add_subnets", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestJSONRequest(t, r, ` +{ + "subnets": [ + "08eae331-0402-425a-923c-34f7cfe39c1b", + "54d6f61d-db07-451c-9ab3-b9609b6b6f0b" + ] +}`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "route_table": { + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "name": "default", + "description": "", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": true, + "routes": [], + "subnets": ["08eae331-0402-425a-923c-34f7cfe39c1b", "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } +}`) + }) + + opts := routetables.AddSubnetsOpts{ + Subnets: []string{ + "08eae331-0402-425a-923c-34f7cfe39c1b", + "54d6f61d-db07-451c-9ab3-b9609b6b6f0b", + }, + } + rt, err := routetables.AddSubnets(context.TODO(), fake.ServiceClient(fakeServer), "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", opts).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, 2, len(rt.Subnets)) + th.AssertEquals(t, "08eae331-0402-425a-923c-34f7cfe39c1b", rt.Subnets[0]) + th.AssertEquals(t, "54d6f61d-db07-451c-9ab3-b9609b6b6f0b", rt.Subnets[1]) +} + +func TestRemoveSubnets(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/route_tables/e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a/remove_subnets", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestJSONRequest(t, r, ` +{ + "subnets": [ + "54d6f61d-db07-451c-9ab3-b9609b6b6f0b" + ] +}`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "route_table": { + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "name": "default", + "description": "", + "router_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + "is_default": true, + "routes": [], + "subnets": ["08eae331-0402-425a-923c-34f7cfe39c1b"], + "project_id": "4fd44f30292945e481c7b8a0c8908869" + } +}`) + }) + + opts := routetables.RemoveSubnetsOpts{ + Subnets: []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"}, + } + rt, err := routetables.RemoveSubnets(context.TODO(), fake.ServiceClient(fakeServer), "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", opts).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, 1, len(rt.Subnets)) + th.AssertEquals(t, "08eae331-0402-425a-923c-34f7cfe39c1b", rt.Subnets[0]) +} diff --git a/openstack/networking/v2/extensions/layer3/routetables/urls.go b/openstack/networking/v2/extensions/layer3/routetables/urls.go new file mode 100644 index 0000000000..32dc534ae5 --- /dev/null +++ b/openstack/networking/v2/extensions/layer3/routetables/urls.go @@ -0,0 +1,29 @@ +package routetables + +import "github.com/gophercloud/gophercloud/v2" + +const resourcePath = "route_tables" + +func rootURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL(resourcePath) +} + +func resourceURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL(resourcePath, id) +} + +func addRoutesURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL(resourcePath, id, "add_routes") +} + +func removeRoutesURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL(resourcePath, id, "remove_routes") +} + +func addSubnetsURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL(resourcePath, id, "add_subnets") +} + +func removeSubnetsURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL(resourcePath, id, "remove_subnets") +} diff --git a/openstack/networking/v2/networks/requests.go b/openstack/networking/v2/networks/requests.go index d3013b7f76..1501439048 100644 --- a/openstack/networking/v2/networks/requests.go +++ b/openstack/networking/v2/networks/requests.go @@ -39,6 +39,7 @@ type ListOpts struct { RevisionNumber *int `q:"revision_number"` NetworkType string `q:"provider:network_type"` SegmentationID *int `q:"provider:segmentation_id"` + VpcID string `q:"vpc_id"` } // ToNetworkListQuery formats a ListOpts into a query string. @@ -88,6 +89,7 @@ type CreateOpts struct { AvailabilityZoneHints []string `json:"availability_zone_hints,omitempty"` NetworkType string `json:"provider:network_type,omitempty"` SegmentationID *int `json:"provider:segmentation_id,omitempty"` + VpcID string `json:"vpc_id,omitempty"` } // ToNetworkCreateMap builds a request body from CreateOpts. diff --git a/openstack/networking/v2/networks/results.go b/openstack/networking/v2/networks/results.go index d4b97c1a20..edf279f011 100644 --- a/openstack/networking/v2/networks/results.go +++ b/openstack/networking/v2/networks/results.go @@ -93,6 +93,9 @@ type Network struct { // RevisionNumber optionally set via extensions/standard-attr-revisions RevisionNumber int `json:"revision_number"` + + // VpcID is the VPC identifier. Set at creation, cannot be updated. + VpcID string `json:"vpc_id"` } func (r *Network) UnmarshalJSON(b []byte) error { diff --git a/openstack/networking/v2/networks/testing/requests_test.go b/openstack/networking/v2/networks/testing/requests_test.go index bd87758699..98e75b8532 100644 --- a/openstack/networking/v2/networks/testing/requests_test.go +++ b/openstack/networking/v2/networks/testing/requests_test.go @@ -354,3 +354,98 @@ func TestUpdatePortSecurity(t *testing.T) { th.AssertEquals(t, networkWithExtensions.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c") th.AssertEquals(t, networkWithExtensions.PortSecurityEnabled, false) } + +func TestCreateWithVpcID(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "network": { + "name": "vpc-network", + "admin_state_up": true, + "vpc_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } +}`) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + fmt.Fprint(w, ` +{ + "network": { + "status": "ACTIVE", + "subnets": [], + "name": "vpc-network", + "admin_state_up": true, + "tenant_id": "4fd44f30292945e481c7b8a0c8908869", + "shared": false, + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "vpc_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } +}`) + }) + + iTrue := true + options := networks.CreateOpts{ + Name: "vpc-network", + AdminStateUp: &iTrue, + VpcID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + } + n, err := networks.Create(context.TODO(), fake.ServiceClient(fakeServer), options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "vpc-network", n.Name) + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", n.VpcID) + th.AssertEquals(t, "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", n.ID) +} + +func TestListWithVpcID(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + th.AssertEquals(t, r.URL.Query().Get("vpc_id"), "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1") + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "networks": [ + { + "status": "ACTIVE", + "subnets": ["54d6f61d-db07-451c-9ab3-b9609b6b6f0b"], + "name": "vpc-network", + "admin_state_up": true, + "tenant_id": "4fd44f30292945e481c7b8a0c8908869", + "shared": false, + "id": "e3f5f925-6c6c-4b1e-9c1e-7c6d4d7c3f2a", + "vpc_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } + ] +}`) + }) + + listOpts := networks.ListOpts{ + VpcID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + } + + allPages, err := networks.List(fake.ServiceClient(fakeServer), listOpts).AllPages(context.TODO()) + th.AssertNoErr(t, err) + + allNetworks, err := networks.ExtractNetworks(allPages) + th.AssertNoErr(t, err) + + th.AssertEquals(t, 1, len(allNetworks)) + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", allNetworks[0].VpcID) + th.AssertEquals(t, "vpc-network", allNetworks[0].Name) +} diff --git a/openstack/networking/v2/subnets/requests.go b/openstack/networking/v2/subnets/requests.go index 85c5d2b402..753502966c 100644 --- a/openstack/networking/v2/subnets/requests.go +++ b/openstack/networking/v2/subnets/requests.go @@ -44,6 +44,7 @@ type ListOpts struct { NotTagsAny string `q:"not-tags-any"` RevisionNumber *int `q:"revision_number"` SegmentID string `q:"segment_id"` + VpcID string `q:"vpc_id"` } // ToSubnetListQuery formats a ListOpts into a query string. diff --git a/openstack/networking/v2/subnets/results.go b/openstack/networking/v2/subnets/results.go index 4f0aa8408d..41402f2d31 100644 --- a/openstack/networking/v2/subnets/results.go +++ b/openstack/networking/v2/subnets/results.go @@ -129,6 +129,9 @@ type Subnet struct { // available when segment extension is enabled. SegmentID string `json:"segment_id"` + // VpcID is the VPC identifier. Inherited from the parent network (read-only). + VpcID string `json:"vpc_id"` + // Timestamp when the subnet was created CreatedAt time.Time `json:"-"` diff --git a/openstack/networking/v2/subnets/testing/requests_test.go b/openstack/networking/v2/subnets/testing/requests_test.go index fd8c810db3..61ade58b5f 100644 --- a/openstack/networking/v2/subnets/testing/requests_test.go +++ b/openstack/networking/v2/subnets/testing/requests_test.go @@ -676,3 +676,89 @@ func TestDelete(t *testing.T) { res := subnets.Delete(context.TODO(), fake.ServiceClient(fakeServer), "08eae331-0402-425a-923c-34f7cfe39c1b") th.AssertNoErr(t, res.Err) } + +func TestListWithVpcID(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/subnets", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + th.AssertEquals(t, r.URL.Query().Get("vpc_id"), "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1") + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "subnets": [ + { + "name": "vpc-subnet", + "enable_dhcp": true, + "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324", + "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e", + "dns_nameservers": [], + "allocation_pools": [{"start": "10.0.0.2", "end": "10.0.0.254"}], + "host_routes": [], + "ip_version": 4, + "gateway_ip": "10.0.0.1", + "cidr": "10.0.0.0/24", + "id": "08eae331-0402-425a-923c-34f7cfe39c1b", + "vpc_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } + ] +}`) + }) + + listOpts := subnets.ListOpts{ + VpcID: "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", + } + + allPages, err := subnets.List(fake.ServiceClient(fakeServer), listOpts).AllPages(context.TODO()) + th.AssertNoErr(t, err) + + allSubnets, err := subnets.ExtractSubnets(allPages) + th.AssertNoErr(t, err) + + th.AssertEquals(t, 1, len(allSubnets)) + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", allSubnets[0].VpcID) + th.AssertEquals(t, "vpc-subnet", allSubnets[0].Name) +} + +func TestGetWithVpcID(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v2.0/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ` +{ + "subnet": { + "name": "vpc-subnet", + "enable_dhcp": true, + "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324", + "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e", + "dns_nameservers": [], + "allocation_pools": [{"start": "10.0.0.2", "end": "10.0.0.254"}], + "host_routes": [], + "ip_version": 4, + "gateway_ip": "10.0.0.1", + "cidr": "10.0.0.0/24", + "id": "08eae331-0402-425a-923c-34f7cfe39c1b", + "vpc_id": "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1" + } +}`) + }) + + s, err := subnets.Get(context.TODO(), fake.ServiceClient(fakeServer), "08eae331-0402-425a-923c-34f7cfe39c1b").Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "a5c3a4d4-5e0f-4c2a-bb02-e2723de5b8f1", s.VpcID) + th.AssertEquals(t, "vpc-subnet", s.Name) +} diff --git a/openstack/virtualprivatecloud/v1/common/common_tests.go b/openstack/virtualprivatecloud/v1/common/common_tests.go new file mode 100644 index 0000000000..eaf46d7ccb --- /dev/null +++ b/openstack/virtualprivatecloud/v1/common/common_tests.go @@ -0,0 +1,13 @@ +package common + +import ( + "github.com/gophercloud/gophercloud/v2" + th "github.com/gophercloud/gophercloud/v2/testhelper" + "github.com/gophercloud/gophercloud/v2/testhelper/client" +) + +const TokenID = client.TokenID + +func ServiceClient(fakeServer th.FakeServer) *gophercloud.ServiceClient { + return client.ServiceClient(fakeServer) +} diff --git a/openstack/virtualprivatecloud/v1/quotas/doc.go b/openstack/virtualprivatecloud/v1/quotas/doc.go new file mode 100644 index 0000000000..fc5932e402 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/quotas/doc.go @@ -0,0 +1,35 @@ +/* +Package quotas provides the ability to retrieve and manage VPC quotas through +the Orion API. + +Example to Get a Quota + + projectID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + quota, err := quotas.Get(context.TODO(), orionClient, projectID).Extract() + if err != nil { + panic(err) + } + + fmt.Printf("Max VPCs: %d (source: %s)\n", quota.MaxVpcs, quota.Source) + +Example to Set a Quota (admin only) + + projectID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + setOpts := quotas.SetOpts{ + MaxVpcs: 10, + } + + result, err := quotas.Set(context.TODO(), orionClient, projectID, setOpts).Extract() + if err != nil { + panic(err) + } + +Example to Delete a Quota Override (admin only) + + projectID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + err := quotas.Delete(context.TODO(), orionClient, projectID).ExtractErr() + if err != nil { + panic(err) + } +*/ +package quotas diff --git a/openstack/virtualprivatecloud/v1/quotas/requests.go b/openstack/virtualprivatecloud/v1/quotas/requests.go new file mode 100644 index 0000000000..98c4eedb19 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/quotas/requests.go @@ -0,0 +1,53 @@ +package quotas + +import ( + "context" + + "github.com/gophercloud/gophercloud/v2" +) + +// Get returns the VPC quota for a given project ID. +func Get(ctx context.Context, client *gophercloud.ServiceClient, projectID string) (r GetResult) { + resp, err := client.Get(ctx, resourceURL(client, projectID), &r.Body, nil) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// SetOptsBuilder allows extensions to add additional parameters to the +// Set request. +type SetOptsBuilder interface { + ToQuotaSetMap() (map[string]any, error) +} + +// SetOpts represents options used to set VPC quotas for a project. +type SetOpts struct { + // Maximum number of VPCs for this project. + MaxVpcs int `json:"max_vpcs"` +} + +// ToQuotaSetMap builds a request body from SetOpts. +func (opts SetOpts) ToQuotaSetMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "quota") +} + +// Set creates or updates a per-project VPC quota override. Requires admin role. +func Set(ctx context.Context, c *gophercloud.ServiceClient, projectID string, opts SetOptsBuilder) (r SetResult) { + b, err := opts.ToQuotaSetMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Put(ctx, resourceURL(c, projectID), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// Delete removes the per-project quota override. The project reverts to the +// config default. Requires admin role. +func Delete(ctx context.Context, c *gophercloud.ServiceClient, projectID string) (r DeleteResult) { + resp, err := c.Delete(ctx, resourceURL(c, projectID), nil) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} diff --git a/openstack/virtualprivatecloud/v1/quotas/results.go b/openstack/virtualprivatecloud/v1/quotas/results.go new file mode 100644 index 0000000000..d1d9ac0060 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/quotas/results.go @@ -0,0 +1,56 @@ +package quotas + +import ( + "github.com/gophercloud/gophercloud/v2" +) + +// Quota represents VPC quota information for a project. +type Quota struct { + // OpenStack project ID. + ProjectID string `json:"project_id"` + + // Maximum number of VPCs for this project. + MaxVpcs int `json:"max_vpcs"` + + // Whether this is the global default or a per-project override. + // Possible values: "config_default", "project_override". + Source string `json:"source"` +} + +// QuotaResult represents the response from a Set operation (no source field). +type QuotaResult struct { + // OpenStack project ID. + ProjectID string `json:"project_id"` + + // Maximum number of VPCs for this project. + MaxVpcs int `json:"max_vpcs"` +} + +// GetResult represents the result of a Get operation. +type GetResult struct { + gophercloud.Result +} + +// Extract interprets a GetResult as a Quota. +func (r GetResult) Extract() (*Quota, error) { + var s Quota + err := r.ExtractIntoStructPtr(&s, "quota") + return &s, err +} + +// SetResult represents the result of a Set operation. +type SetResult struct { + gophercloud.Result +} + +// Extract interprets a SetResult as a QuotaResult. +func (r SetResult) Extract() (*QuotaResult, error) { + var s QuotaResult + err := r.ExtractIntoStructPtr(&s, "quota") + return &s, err +} + +// DeleteResult represents the result of a Delete operation. +type DeleteResult struct { + gophercloud.ErrResult +} diff --git a/openstack/virtualprivatecloud/v1/quotas/testing/doc.go b/openstack/virtualprivatecloud/v1/quotas/testing/doc.go new file mode 100644 index 0000000000..404d517542 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/quotas/testing/doc.go @@ -0,0 +1,2 @@ +// quotas unit tests +package testing diff --git a/openstack/virtualprivatecloud/v1/quotas/testing/fixtures.go b/openstack/virtualprivatecloud/v1/quotas/testing/fixtures.go new file mode 100644 index 0000000000..d609788ebd --- /dev/null +++ b/openstack/virtualprivatecloud/v1/quotas/testing/fixtures.go @@ -0,0 +1,34 @@ +package testing + +const GetResponseDefault = ` +{ + "quota": { + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "max_vpcs": 5, + "source": "config_default" + } +}` + +const GetResponseOverride = ` +{ + "quota": { + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "max_vpcs": 10, + "source": "project_override" + } +}` + +const SetRequest = ` +{ + "quota": { + "max_vpcs": 10 + } +}` + +const SetResponse = ` +{ + "quota": { + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "max_vpcs": 10 + } +}` diff --git a/openstack/virtualprivatecloud/v1/quotas/testing/requests_test.go b/openstack/virtualprivatecloud/v1/quotas/testing/requests_test.go new file mode 100644 index 0000000000..a1a59fb38b --- /dev/null +++ b/openstack/virtualprivatecloud/v1/quotas/testing/requests_test.go @@ -0,0 +1,96 @@ +package testing + +import ( + "context" + "fmt" + "net/http" + "testing" + + fake "github.com/gophercloud/gophercloud/v2/openstack/virtualprivatecloud/v1/common" + "github.com/gophercloud/gophercloud/v2/openstack/virtualprivatecloud/v1/quotas" + th "github.com/gophercloud/gophercloud/v2/testhelper" +) + +func TestGetDefault(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/quotas/a1b2c3d4-e5f6-7890-abcd-ef1234567890", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, GetResponseDefault) + }) + + q, err := quotas.Get(context.TODO(), fake.ServiceClient(fakeServer), "a1b2c3d4-e5f6-7890-abcd-ef1234567890").Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "a1b2c3d4-e5f6-7890-abcd-ef1234567890", q.ProjectID) + th.AssertEquals(t, 5, q.MaxVpcs) + th.AssertEquals(t, "config_default", q.Source) +} + +func TestGetOverride(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/quotas/a1b2c3d4-e5f6-7890-abcd-ef1234567890", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, GetResponseOverride) + }) + + q, err := quotas.Get(context.TODO(), fake.ServiceClient(fakeServer), "a1b2c3d4-e5f6-7890-abcd-ef1234567890").Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "a1b2c3d4-e5f6-7890-abcd-ef1234567890", q.ProjectID) + th.AssertEquals(t, 10, q.MaxVpcs) + th.AssertEquals(t, "project_override", q.Source) +} + +func TestSet(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/quotas/a1b2c3d4-e5f6-7890-abcd-ef1234567890", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, SetRequest) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, SetResponse) + }) + + opts := quotas.SetOpts{MaxVpcs: 10} + q, err := quotas.Set(context.TODO(), fake.ServiceClient(fakeServer), "a1b2c3d4-e5f6-7890-abcd-ef1234567890", opts).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "a1b2c3d4-e5f6-7890-abcd-ef1234567890", q.ProjectID) + th.AssertEquals(t, 10, q.MaxVpcs) +} + +func TestDelete(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/quotas/a1b2c3d4-e5f6-7890-abcd-ef1234567890", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "DELETE") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.WriteHeader(http.StatusNoContent) + }) + + err := quotas.Delete(context.TODO(), fake.ServiceClient(fakeServer), "a1b2c3d4-e5f6-7890-abcd-ef1234567890").ExtractErr() + th.AssertNoErr(t, err) +} diff --git a/openstack/virtualprivatecloud/v1/quotas/urls.go b/openstack/virtualprivatecloud/v1/quotas/urls.go new file mode 100644 index 0000000000..f9ce4f0bd1 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/quotas/urls.go @@ -0,0 +1,7 @@ +package quotas + +import "github.com/gophercloud/gophercloud/v2" + +func resourceURL(c *gophercloud.ServiceClient, projectID string) string { + return c.ServiceURL("v1", "quotas", projectID) +} diff --git a/openstack/virtualprivatecloud/v1/vpcs/doc.go b/openstack/virtualprivatecloud/v1/vpcs/doc.go new file mode 100644 index 0000000000..f338c161c0 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/vpcs/doc.go @@ -0,0 +1,66 @@ +/* +Package vpcs provides functionality for managing Orion VPC resources. + +A VPC (Virtual Private Cloud) is an isolated virtual network with an IPv4 CIDR +block. VPCs are managed by the Orion service. CREATE and DELETE are asynchronous +(return 202), while UPDATE is synchronous (return 200). + +Example to List VPCs + + listOpts := vpcs.ListOpts{ + Status: "READY", + } + + allPages, err := vpcs.List(orionClient, listOpts).AllPages(context.TODO()) + if err != nil { + panic(err) + } + + allVpcs, err := vpcs.ExtractVpcs(allPages) + if err != nil { + panic(err) + } + + for _, vpc := range allVpcs { + fmt.Printf("%+v\n", vpc) + } + +Example to Create a VPC + + createOpts := vpcs.CreateOpts{ + Name: "my-vpc", + CIDRBlock: "10.0.0.0/16", + } + + vpc, err := vpcs.Create(context.TODO(), orionClient, createOpts).Extract() + if err != nil { + panic(err) + } + +Example to Get a VPC + + vpc, err := vpcs.Get(context.TODO(), orionClient, "f47ac10b-58cc-4372-a567-0e02b2c3d479").Extract() + if err != nil { + panic(err) + } + +Example to Update a VPC + + name := "my-vpc-renamed" + updateOpts := vpcs.UpdateOpts{ + Name: &name, + } + + vpc, err := vpcs.Update(context.TODO(), orionClient, "f47ac10b-58cc-4372-a567-0e02b2c3d479", updateOpts).Extract() + if err != nil { + panic(err) + } + +Example to Delete a VPC + + vpc, err := vpcs.Delete(context.TODO(), orionClient, "f47ac10b-58cc-4372-a567-0e02b2c3d479").Extract() + if err != nil { + panic(err) + } +*/ +package vpcs diff --git a/openstack/virtualprivatecloud/v1/vpcs/requests.go b/openstack/virtualprivatecloud/v1/vpcs/requests.go new file mode 100644 index 0000000000..b145007f4b --- /dev/null +++ b/openstack/virtualprivatecloud/v1/vpcs/requests.go @@ -0,0 +1,140 @@ +package vpcs + +import ( + "context" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/pagination" +) + +// ListOptsBuilder allows extensions to add additional parameters to the +// List request. +type ListOptsBuilder interface { + ToVpcListQuery() (string, error) +} + +// ListOpts allows the filtering of paginated collections through the API. +// Filtering is achieved by passing in struct field values that map to the VPC +// attributes you want to see returned. +type ListOpts struct { + // Filter by VPC name (exact match). + Name string `q:"name"` + + // Filter by VPC status. + Status string `q:"status"` +} + +// ToVpcListQuery formats a ListOpts into a query string. +func (opts ListOpts) ToVpcListQuery() (string, error) { + q, err := gophercloud.BuildQueryString(opts) + return q.String(), err +} + +// List returns a Pager which allows you to iterate over a collection of +// VPCs. It accepts a ListOpts struct, which allows you to filter the +// returned collection for greater efficiency. +func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager { + url := listURL(c) + if opts != nil { + query, err := opts.ToVpcListQuery() + if err != nil { + return pagination.Pager{Err: err} + } + url += query + } + return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page { + return VpcPage{pagination.SinglePageBase(r)} + }) +} + +// Get retrieves a specific VPC based on its unique ID. +func Get(ctx context.Context, c *gophercloud.ServiceClient, id string) (r GetResult) { + resp, err := c.Get(ctx, getURL(c, id), &r.Body, nil) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// CreateOptsBuilder allows extensions to add additional parameters to the +// Create request. +type CreateOptsBuilder interface { + ToVpcCreateMap() (map[string]any, error) +} + +// CreateOpts represents options used to create a VPC. +type CreateOpts struct { + // Human-readable VPC name. Required. + Name string `json:"name"` + + // IPv4 CIDR block (RFC 1918, /16 to /28). Required. + CIDRBlock string `json:"cidr_block"` + + // Optional description. + Description string `json:"description,omitempty"` +} + +// ToVpcCreateMap builds a request body from CreateOpts. +func (opts CreateOpts) ToVpcCreateMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "vpc") +} + +// Create accepts a CreateOpts struct and creates a new VPC using the values +// provided. This is an asynchronous operation that returns 202. +func Create(ctx context.Context, c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) { + b, err := opts.ToVpcCreateMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Post(ctx, createURL(c), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{202}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// UpdateOptsBuilder allows extensions to add additional parameters to the +// Update request. +type UpdateOptsBuilder interface { + ToVpcUpdateMap() (map[string]any, error) +} + +// UpdateOpts represents options used to update a VPC. +type UpdateOpts struct { + // New VPC name. + Name *string `json:"name,omitempty"` + + // New description. + Description *string `json:"description,omitempty"` +} + +// ToVpcUpdateMap builds a request body from UpdateOpts. +func (opts UpdateOpts) ToVpcUpdateMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "vpc") +} + +// Update accepts an UpdateOpts struct and updates an existing VPC using the +// values provided. Only allowed when the VPC is in READY state. +func Update(ctx context.Context, c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) { + b, err := opts.ToVpcUpdateMap() + if err != nil { + r.Err = err + return + } + resp, err := c.Put(ctx, updateURL(c, id), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + +// Delete accepts a unique ID and deletes the VPC associated with it. +// This is an asynchronous operation that returns 202 with the VPC in +// DELETING state. +func Delete(ctx context.Context, c *gophercloud.ServiceClient, id string) (r DeleteResult) { + resp, err := c.Delete(ctx, deleteURL(c, id), &gophercloud.RequestOpts{ + OkCodes: []int{202}, + JSONResponse: &r.Body, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} diff --git a/openstack/virtualprivatecloud/v1/vpcs/results.go b/openstack/virtualprivatecloud/v1/vpcs/results.go new file mode 100644 index 0000000000..d215775874 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/vpcs/results.go @@ -0,0 +1,143 @@ +package vpcs + +import ( + "encoding/json" + "time" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/pagination" +) + +type commonResult struct { + gophercloud.Result +} + +// Extract is a function that accepts a result and extracts a VPC resource. +func (r commonResult) Extract() (*Vpc, error) { + var s Vpc + err := r.ExtractInto(&s) + return &s, err +} + +func (r commonResult) ExtractInto(v any) error { + return r.ExtractIntoStructPtr(v, "vpc") +} + +// CreateResult represents the result of a create operation. Call its Extract +// method to interpret it as a Vpc. +type CreateResult struct { + commonResult +} + +// GetResult represents the result of a get operation. Call its Extract +// method to interpret it as a Vpc. +type GetResult struct { + commonResult +} + +// UpdateResult represents the result of an update operation. Call its Extract +// method to interpret it as a Vpc. +type UpdateResult struct { + commonResult +} + +// DeleteResult represents the result of a delete operation. Call its Extract +// method to interpret it as a Vpc (Orion returns 202 with the VPC in DELETING +// state). +type DeleteResult struct { + commonResult +} + +// Vpc represents an Orion Virtual Private Cloud resource. +type Vpc struct { + // UUID for the VPC. + ID string `json:"id"` + + // OpenStack project ID (from auth token). + ProjectID string `json:"project_id"` + + // Human-readable VPC name. + Name string `json:"name"` + + // Optional description. + Description string `json:"description"` + + // IPv4 CIDR block. + CIDRBlock string `json:"cidr_block"` + + // VPC status: CREATING, READY, UPDATING, ERROR, DELETING, DELETED. + Status string `json:"status"` + + // Creation timestamp. + CreatedAt time.Time `json:"-"` + + // Last update timestamp. + UpdatedAt time.Time `json:"-"` +} + +func (r *Vpc) UnmarshalJSON(b []byte) error { + type tmp Vpc + + // Support for time format without timezone suffix + var s1 struct { + tmp + CreatedAt gophercloud.JSONRFC3339NoZ `json:"created_at"` + UpdatedAt gophercloud.JSONRFC3339NoZ `json:"updated_at"` + } + + err := json.Unmarshal(b, &s1) + if err == nil { + *r = Vpc(s1.tmp) + r.CreatedAt = time.Time(s1.CreatedAt) + r.UpdatedAt = time.Time(s1.UpdatedAt) + + return nil + } + + // Support for standard RFC3339 time format + var s2 struct { + tmp + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + + err = json.Unmarshal(b, &s2) + if err != nil { + return err + } + + *r = Vpc(s2.tmp) + r.CreatedAt = time.Time(s2.CreatedAt) + r.UpdatedAt = time.Time(s2.UpdatedAt) + + return nil +} + +// VpcPage is the page returned by a pager when traversing over a collection +// of VPCs. Orion does not paginate, so this is always a single page. +type VpcPage struct { + pagination.SinglePageBase +} + +// IsEmpty checks whether a VpcPage struct is empty. +func (r VpcPage) IsEmpty() (bool, error) { + if r.StatusCode == 204 { + return true, nil + } + + is, err := ExtractVpcs(r) + return len(is) == 0, err +} + +// ExtractVpcs accepts a Page struct, specifically a VpcPage struct, +// and extracts the elements into a slice of Vpc structs. +func ExtractVpcs(r pagination.Page) ([]Vpc, error) { + var s []Vpc + err := ExtractVpcsInto(r, &s) + return s, err +} + +// ExtractVpcsInto extracts VPCs from a page into the provided slice. +func ExtractVpcsInto(r pagination.Page, v any) error { + return r.(VpcPage).ExtractIntoSlicePtr(v, "vpcs") +} diff --git a/openstack/virtualprivatecloud/v1/vpcs/testing/doc.go b/openstack/virtualprivatecloud/v1/vpcs/testing/doc.go new file mode 100644 index 0000000000..77f41573c7 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/vpcs/testing/doc.go @@ -0,0 +1,2 @@ +// vpcs unit tests +package testing diff --git a/openstack/virtualprivatecloud/v1/vpcs/testing/fixtures.go b/openstack/virtualprivatecloud/v1/vpcs/testing/fixtures.go new file mode 100644 index 0000000000..6a78a3cb2d --- /dev/null +++ b/openstack/virtualprivatecloud/v1/vpcs/testing/fixtures.go @@ -0,0 +1,138 @@ +package testing + +import ( + "time" + + "github.com/gophercloud/gophercloud/v2/openstack/virtualprivatecloud/v1/vpcs" +) + +const ListResponse = ` +{ + "vpcs": [ + { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-vpc", + "description": "Production VPC", + "cidr_block": "10.0.0.0/16", + "status": "ACTIVE", + "created_at": "2026-03-04T12:00:00Z", + "updated_at": "2026-03-04T12:05:00Z" + }, + { + "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "dev-vpc", + "description": "", + "cidr_block": "172.16.0.0/20", + "status": "CREATING", + "created_at": "2026-03-04T13:00:00Z", + "updated_at": "2026-03-04T13:00:00Z" + } + ] +}` + +const GetResponse = ` +{ + "vpc": { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-vpc", + "description": "Production VPC", + "cidr_block": "10.0.0.0/16", + "status": "ACTIVE", + "created_at": "2026-03-04T12:00:00Z", + "updated_at": "2026-03-04T12:05:00Z" + } +}` + +const CreateRequest = ` +{ + "vpc": { + "name": "my-vpc", + "cidr_block": "10.0.0.0/16", + "description": "Production VPC" + } +}` + +const CreateResponse = ` +{ + "vpc": { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-vpc", + "description": "Production VPC", + "cidr_block": "10.0.0.0/16", + "status": "CREATING", + "created_at": "2026-03-04T12:00:00Z", + "updated_at": "2026-03-04T12:00:00Z" + } +}` + +const UpdateRequest = ` +{ + "vpc": { + "name": "my-vpc-renamed" + } +}` + +const UpdateResponse = ` +{ + "vpc": { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-vpc-renamed", + "description": "Production VPC", + "cidr_block": "10.0.0.0/16", + "status": "ACTIVE", + "created_at": "2026-03-04T12:00:00Z", + "updated_at": "2026-03-04T12:10:00Z" + } +}` + +const DeleteResponse = ` +{ + "vpc": { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-vpc", + "description": "Production VPC", + "cidr_block": "10.0.0.0/16", + "status": "DELETING", + "created_at": "2026-03-04T12:00:00Z", + "updated_at": "2026-03-04T12:15:00Z" + } +}` + +var ( + createdTime1, _ = time.Parse(time.RFC3339, "2026-03-04T12:00:00Z") + updatedTime1, _ = time.Parse(time.RFC3339, "2026-03-04T12:05:00Z") + createdTime2, _ = time.Parse(time.RFC3339, "2026-03-04T13:00:00Z") + updatedTime2, _ = time.Parse(time.RFC3339, "2026-03-04T13:00:00Z") + updatedTime3, _ = time.Parse(time.RFC3339, "2026-03-04T12:10:00Z") + updatedTime4, _ = time.Parse(time.RFC3339, "2026-03-04T12:15:00Z") +) + +var Vpc1 = vpcs.Vpc{ + ID: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ProjectID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Name: "my-vpc", + Description: "Production VPC", + CIDRBlock: "10.0.0.0/16", + Status: "ACTIVE", + CreatedAt: createdTime1, + UpdatedAt: updatedTime1, +} + +var Vpc2 = vpcs.Vpc{ + ID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + ProjectID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Name: "dev-vpc", + Description: "", + CIDRBlock: "172.16.0.0/20", + Status: "CREATING", + CreatedAt: createdTime2, + UpdatedAt: updatedTime2, +} + +var ExpectedVpcSlice = []vpcs.Vpc{Vpc1, Vpc2} diff --git a/openstack/virtualprivatecloud/v1/vpcs/testing/requests_test.go b/openstack/virtualprivatecloud/v1/vpcs/testing/requests_test.go new file mode 100644 index 0000000000..6b50ba3f83 --- /dev/null +++ b/openstack/virtualprivatecloud/v1/vpcs/testing/requests_test.go @@ -0,0 +1,183 @@ +package testing + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + fake "github.com/gophercloud/gophercloud/v2/openstack/virtualprivatecloud/v1/common" + "github.com/gophercloud/gophercloud/v2/openstack/virtualprivatecloud/v1/vpcs" + "github.com/gophercloud/gophercloud/v2/pagination" + th "github.com/gophercloud/gophercloud/v2/testhelper" +) + +func TestList(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/vpcs", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ListResponse) + }) + + client := fake.ServiceClient(fakeServer) + count := 0 + + err := vpcs.List(client, vpcs.ListOpts{}).EachPage(context.TODO(), func(_ context.Context, page pagination.Page) (bool, error) { + count++ + actual, err := vpcs.ExtractVpcs(page) + if err != nil { + t.Errorf("Failed to extract vpcs: %v", err) + return false, err + } + + th.CheckDeepEquals(t, ExpectedVpcSlice, actual) + + return true, nil + }) + + th.AssertNoErr(t, err) + + if count != 1 { + t.Errorf("Expected 1 page, got %d", count) + } +} + +func TestListWithFilter(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/vpcs", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + th.AssertEquals(t, "ACTIVE", r.URL.Query().Get("status")) + th.AssertEquals(t, "my-vpc", r.URL.Query().Get("name")) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, ListResponse) + }) + + client := fake.ServiceClient(fakeServer) + + allPages, err := vpcs.List(client, vpcs.ListOpts{ + Name: "my-vpc", + Status: "ACTIVE", + }).AllPages(context.TODO()) + th.AssertNoErr(t, err) + + allVpcs, err := vpcs.ExtractVpcs(allPages) + th.AssertNoErr(t, err) + th.AssertEquals(t, 2, len(allVpcs)) +} + +func TestGet(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/vpcs/f47ac10b-58cc-4372-a567-0e02b2c3d479", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, GetResponse) + }) + + n, err := vpcs.Get(context.TODO(), fake.ServiceClient(fakeServer), "f47ac10b-58cc-4372-a567-0e02b2c3d479").Extract() + th.AssertNoErr(t, err) + th.CheckDeepEquals(t, &Vpc1, n) + th.AssertEquals(t, n.CreatedAt.Format(time.RFC3339), "2026-03-04T12:00:00Z") + th.AssertEquals(t, n.UpdatedAt.Format(time.RFC3339), "2026-03-04T12:05:00Z") +} + +func TestCreate(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/vpcs", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, CreateRequest) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + + fmt.Fprint(w, CreateResponse) + }) + + options := vpcs.CreateOpts{ + Name: "my-vpc", + CIDRBlock: "10.0.0.0/16", + Description: "Production VPC", + } + n, err := vpcs.Create(context.TODO(), fake.ServiceClient(fakeServer), options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "f47ac10b-58cc-4372-a567-0e02b2c3d479", n.ID) + th.AssertEquals(t, "CREATING", n.Status) + th.AssertEquals(t, "my-vpc", n.Name) + th.AssertEquals(t, "10.0.0.0/16", n.CIDRBlock) + th.AssertEquals(t, n.CreatedAt.Format(time.RFC3339), "2026-03-04T12:00:00Z") +} + +func TestUpdate(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/vpcs/f47ac10b-58cc-4372-a567-0e02b2c3d479", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, UpdateRequest) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprint(w, UpdateResponse) + }) + + name := "my-vpc-renamed" + options := vpcs.UpdateOpts{Name: &name} + n, err := vpcs.Update(context.TODO(), fake.ServiceClient(fakeServer), "f47ac10b-58cc-4372-a567-0e02b2c3d479", options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "my-vpc-renamed", n.Name) + th.AssertEquals(t, "ACTIVE", n.Status) + th.AssertEquals(t, n.UpdatedAt.Format(time.RFC3339), "2026-03-04T12:10:00Z") +} + +func TestDelete(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/v1/vpcs/f47ac10b-58cc-4372-a567-0e02b2c3d479", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "DELETE") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + + fmt.Fprint(w, DeleteResponse) + }) + + n, err := vpcs.Delete(context.TODO(), fake.ServiceClient(fakeServer), "f47ac10b-58cc-4372-a567-0e02b2c3d479").Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, "DELETING", n.Status) + th.AssertEquals(t, "f47ac10b-58cc-4372-a567-0e02b2c3d479", n.ID) + th.AssertEquals(t, n.UpdatedAt.Format(time.RFC3339), "2026-03-04T12:15:00Z") +} diff --git a/openstack/virtualprivatecloud/v1/vpcs/urls.go b/openstack/virtualprivatecloud/v1/vpcs/urls.go new file mode 100644 index 0000000000..b87f1e3eea --- /dev/null +++ b/openstack/virtualprivatecloud/v1/vpcs/urls.go @@ -0,0 +1,31 @@ +package vpcs + +import "github.com/gophercloud/gophercloud/v2" + +func resourceURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL("v1", "vpcs", id) +} + +func rootURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("v1", "vpcs") +} + +func listURL(c *gophercloud.ServiceClient) string { + return rootURL(c) +} + +func createURL(c *gophercloud.ServiceClient) string { + return rootURL(c) +} + +func getURL(c *gophercloud.ServiceClient, id string) string { + return resourceURL(c, id) +} + +func updateURL(c *gophercloud.ServiceClient, id string) string { + return resourceURL(c, id) +} + +func deleteURL(c *gophercloud.ServiceClient, id string) string { + return resourceURL(c, id) +}