diff --git a/internal/acceptance/openstack/dns/v2/dns.go b/internal/acceptance/openstack/dns/v2/dns.go index c31f238a6e..9adb61882d 100644 --- a/internal/acceptance/openstack/dns/v2/dns.go +++ b/internal/acceptance/openstack/dns/v2/dns.go @@ -186,6 +186,41 @@ func DeleteTransferRequest(t *testing.T, client *gophercloud.ServiceClient, tr * t.Logf("Deleted zone transfer request: %s", tr.ID) } +// CreateShare will create a zone share. An error will be returned if the +// zone share was unable to be created. +func CreateShare(t *testing.T, client *gophercloud.ServiceClient, zone *zones.Zone, targetProjectID string) (*zones.ZoneShare, error) { + t.Logf("Attempting to share zone %s with project %s", zone.ID, targetProjectID) + + createOpts := zones.ShareZoneOpts{ + TargetProjectID: targetProjectID, + } + + share, err := zones.Share(context.TODO(), client, zone.ID, createOpts).Extract() + if err != nil { + return share, err + } + + t.Logf("Created share for zone: %s", zone.ID) + + th.AssertEquals(t, share.ZoneID, zone.ID) + th.AssertEquals(t, share.TargetProjectID, targetProjectID) + + return share, nil +} + +// UnshareZone will unshare a zone. An error will be returned if the +// zone unshare was unable to be created. +func UnshareZone(t *testing.T, client *gophercloud.ServiceClient, share *zones.ZoneShare) { + t.Logf("Attempting to unshare zone %s with project %s", share.ZoneID, share.TargetProjectID) + + err := zones.Unshare(context.TODO(), client, share.ZoneID, share.ID).ExtractErr() + if err != nil { + t.Fatalf("Unable to unshare zone %s: %v", share.ZoneID, err) + } + + t.Logf("Unshared zone: %s", share.ZoneID) +} + // DeleteRecordSet will delete a specified record set. A fatal error will occur if // the record set failed to be deleted. This works best when used as a deferred // function. diff --git a/internal/acceptance/openstack/dns/v2/shares_test.go b/internal/acceptance/openstack/dns/v2/shares_test.go new file mode 100644 index 0000000000..496ee8f1b4 --- /dev/null +++ b/internal/acceptance/openstack/dns/v2/shares_test.go @@ -0,0 +1,66 @@ +//go:build acceptance || dns || zone_shares + +package v2 + +import ( + "context" + "testing" + + "github.com/gophercloud/gophercloud/v2/internal/acceptance/clients" + identity "github.com/gophercloud/gophercloud/v2/internal/acceptance/openstack/identity/v3" + "github.com/gophercloud/gophercloud/v2/internal/acceptance/tools" + "github.com/gophercloud/gophercloud/v2/openstack/dns/v2/zones" + th "github.com/gophercloud/gophercloud/v2/testhelper" +) + +func TestShareCRD(t *testing.T) { + // Create new project + identityClient, err := clients.NewIdentityV3Client() + th.AssertNoErr(t, err) + + project, err := identity.CreateProject(t, identityClient, nil) + th.AssertNoErr(t, err) + defer identity.DeleteProject(t, identityClient, project.ID) + + // Create new Zone + client, err := clients.NewDNSV2Client() + th.AssertNoErr(t, err) + + zone, err := CreateZone(t, client) + th.AssertNoErr(t, err) + defer DeleteZone(t, client, zone) + + // Create a zone share to new tenant + share, err := CreateShare(t, client, zone, project.ID) + th.AssertNoErr(t, err) + tools.PrintResource(t, share) + defer UnshareZone(t, client, share) + + // Get the share + getShare, err := zones.GetShare(context.TODO(), client, share.ZoneID, share.ID).Extract() + th.AssertNoErr(t, err) + tools.PrintResource(t, getShare) + th.AssertDeepEquals(t, *share, *getShare) + + // List shares + allPages, err := zones.ListShares(client, share.ZoneID, nil).AllPages(context.TODO()) + th.AssertNoErr(t, err) + + allShares, err := zones.ExtractZoneShares(allPages) + th.AssertNoErr(t, err) + tools.PrintResource(t, allShares) + + foundShare := -1 + for i, s := range allShares { + tools.PrintResource(t, &s) + if share.ID == s.ID { + foundShare = i + break + } + } + if foundShare == -1 { + t.Fatalf("Share %s not found in list", share.ID) + } + + th.AssertDeepEquals(t, *share, allShares[foundShare]) +} diff --git a/openstack/dns/v2/zones/requests.go b/openstack/dns/v2/zones/requests.go index b8ac4cf8a5..b0049a9148 100644 --- a/openstack/dns/v2/zones/requests.go +++ b/openstack/dns/v2/zones/requests.go @@ -179,6 +179,48 @@ func Delete(ctx context.Context, client *gophercloud.ServiceClient, zoneID strin return } +// ListSharesOptsBuilder allows extensions to add additional parameters to the List +// request. +type ListSharesOptsBuilder interface { + ToZoneListSharesHeadersMap() (map[string]string, error) +} + +// ListSharesOpts is a structure that holds parameters for listing zone shares. +type ListSharesOpts struct { + AllProjects bool `h:"X-Auth-All-Projects"` +} + +// ToZoneListSharesHeadersMap formats a ListSharesOpts into header parameters. +func (opts ListSharesOpts) ToZoneListSharesHeadersMap() (map[string]string, error) { + return gophercloud.BuildHeaders(opts) +} + +// ListShares implements a zone list shares request. +func ListShares(client *gophercloud.ServiceClient, zoneID string, opts ListSharesOptsBuilder) pagination.Pager { + var h map[string]string + var err error + + if opts != nil { + h, err = opts.ToZoneListSharesHeadersMap() + if err != nil { + return pagination.Pager{Err: err} + } + } + + pager := pagination.NewPager(client, sharesBaseURL(client, zoneID), func(r pagination.PageResult) pagination.Page { + return ZoneSharePage{pagination.LinkedPageBase{PageResult: r}} + }) + pager.Headers = h + return pager +} + +// GetShare returns information about a shared zone, given its ID. +func GetShare(ctx context.Context, client *gophercloud.ServiceClient, zoneID, shareID string) (r ZoneShareResult) { + resp, err := client.Get(ctx, shareURL(client, zoneID, shareID), &r.Body, nil) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} + // request body for sharing a zone. type ShareOptsBuilder interface { ToShareMap() (map[string]interface{}, error) @@ -198,14 +240,14 @@ func (opts ShareZoneOpts) ToShareMap() (map[string]interface{}, error) { } // Share shares a zone with another project. -func Share(ctx context.Context, client *gophercloud.ServiceClient, zoneID string, opts ShareOptsBuilder) (r gophercloud.ErrResult) { +func Share(ctx context.Context, client *gophercloud.ServiceClient, zoneID string, opts ShareOptsBuilder) (r ZoneShareResult) { body, err := gophercloud.BuildRequestBody(opts, "") if err != nil { r.Err = err return } - resp, err := client.Post(ctx, zoneShareURL(client, zoneID), body, nil, &gophercloud.RequestOpts{ + resp, err := client.Post(ctx, sharesBaseURL(client, zoneID), body, &r.Body, &gophercloud.RequestOpts{ OkCodes: []int{201}, }) _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) @@ -214,7 +256,7 @@ func Share(ctx context.Context, client *gophercloud.ServiceClient, zoneID string // Unshare removes a share for a zone. func Unshare(ctx context.Context, client *gophercloud.ServiceClient, zoneID, shareID string) (r gophercloud.ErrResult) { - resp, err := client.Delete(ctx, zoneUnshareURL(client, zoneID, shareID), &gophercloud.RequestOpts{ + resp, err := client.Delete(ctx, shareURL(client, zoneID, shareID), &gophercloud.RequestOpts{ OkCodes: []int{204}, }) _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) diff --git a/openstack/dns/v2/zones/results.go b/openstack/dns/v2/zones/results.go index 7ccf5ace9b..9b93e4f4b0 100644 --- a/openstack/dns/v2/zones/results.go +++ b/openstack/dns/v2/zones/results.go @@ -173,3 +173,81 @@ func (r *Zone) UnmarshalJSON(b []byte) error { return err } + +// ZoneShare represents a shared zone. +type ZoneShare struct { + // ID uniquely identifies this zone share. + ID string `json:"id"` + + // ZoneID is the ID of the zone being shared. + ZoneID string `json:"zone_id"` + + // ProjectID is the ID of the project with which the zone is shared. + ProjectID string `json:"project_id"` + + // TargetProjectID is the ID of the project with which the zone is shared. + TargetProjectID string `json:"target_project_id"` + + // CreatedAt is the date when the zone share was created. + CreatedAt time.Time `json:"-"` + + // UpdatedAt is the date when the zone share was last updated. + UpdatedAt time.Time `json:"-"` +} + +func (r *ZoneShare) UnmarshalJSON(b []byte) error { + type tmp ZoneShare + var s struct { + tmp + CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at"` + UpdatedAt gophercloud.JSONRFC3339MilliNoZ `json:"updated_at"` + } + + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + *r = ZoneShare(s.tmp) + + r.CreatedAt = time.Time(s.CreatedAt) + r.UpdatedAt = time.Time(s.UpdatedAt) + + return nil +} + +// ZoneShareResult is the result of a GetZoneShare request. +type ZoneShareResult struct { + gophercloud.Result +} + +// Extract interprets a GetResult, CreateResult or UpdateResult as a Zone. +// An error is returned if the original call or the extraction failed. +func (r ZoneShareResult) Extract() (*ZoneShare, error) { + var s *ZoneShare + err := r.ExtractInto(&s) + return s, err +} + +// ZoneSharePage is a single page of ZoneShare results. +type ZoneSharePage struct { + pagination.LinkedPageBase +} + +// IsEmpty returns true if the page contains no results. +func (r ZoneSharePage) IsEmpty() (bool, error) { + if r.StatusCode == 204 { + return true, nil + } + + s, err := ExtractZoneShares(r) + return len(s) == 0, err +} + +// ExtractZoneShares extracts a slice of ZoneShares from a List result. +func ExtractZoneShares(r pagination.Page) ([]ZoneShare, error) { + var s struct { + ZoneShares []ZoneShare `json:"shared_zones"` + } + err := (r.(ZoneSharePage)).ExtractInto(&s) + return s.ZoneShares, err +} diff --git a/openstack/dns/v2/zones/testing/fixtures_test.go b/openstack/dns/v2/zones/testing/fixtures_test.go index d1c60adb82..8a7b12a09f 100644 --- a/openstack/dns/v2/zones/testing/fixtures_test.go +++ b/openstack/dns/v2/zones/testing/fixtures_test.go @@ -300,3 +300,57 @@ func HandleDeleteSuccessfully(t *testing.T, fakeServer th.FakeServer) { fmt.Fprint(w, DeleteZoneResponse) }) } + +// ShareZoneResponse is a sample response to share a zone. +const ShareZoneResponse = ` +{ + "id": "fd40b017-bf97-461c-8d30-d4e922b28edd", + "zone_id": "a3365b47-ee93-43ad-9a60-2b2ca96b1898", + "project_id": "16ade46c85a1435bb86d9138d37da57e", + "target_project_id": "232e37df46af42089710e2ae39111c2f", + "created_at": "2022-11-30T22:20:27.000000", + "updated_at": null, + "links": { + "self": "http://127.0.0.1:60053/v2/zones/a3365b47-ee93-43ad-9a60-2b2ca96b1898/shares/fd40b017-bf97-461c-8d30-d4e922b28edd", + "zone": "http://127.0.0.1:60053/v2/zones/a3365b47-ee93-43ad-9a60-2b2ca96b1898" + } +} +` + +// ShareZoneCreatedAt is the expected created at time for the shared zone +var ShareZoneCreatedAt, _ = time.Parse(gophercloud.RFC3339MilliNoZ, "2022-11-30T22:20:27.000000") + +// ShareZone is the expected shared zone +var ShareZone = zones.ZoneShare{ + ID: "fd40b017-bf97-461c-8d30-d4e922b28edd", + ZoneID: "a3365b47-ee93-43ad-9a60-2b2ca96b1898", + ProjectID: "16ade46c85a1435bb86d9138d37da57e", + TargetProjectID: "232e37df46af42089710e2ae39111c2f", + CreatedAt: ShareZoneCreatedAt, +} + +// ListSharesResponse is a sample response to list zone shares. +const ListSharesResponse = ` +{ + "shared_zones": [ + { + "id": "fd40b017-bf97-461c-8d30-d4e922b28edd", + "zone_id": "a3365b47-ee93-43ad-9a60-2b2ca96b1898", + "project_id": "16ade46c85a1435bb86d9138d37da57e", + "target_project_id": "232e37df46af42089710e2ae39111c2f", + "created_at": "2022-11-30T22:20:27.000000", + "updated_at": null, + "links": { + "self": "http://127.0.0.1:60053/v2/zones/a3365b47-ee93-43ad-9a60-2b2ca96b1898/shares/fd40b017-bf97-461c-8d30-d4e922b28edd", + "zone": "http://127.0.0.1:60053/v2/zones/a3365b47-ee93-43ad-9a60-2b2ca96b1898" + } + } + ], + "links": { + "self": "http://127.0.0.1:60053/v2/zones/a3365b47-ee93-43ad-9a60-2b2ca96b1898/shares" + } +} +` + +// ListZoneShares is the expected list of shared zones +var ListZoneShares = []zones.ZoneShare{ShareZone} diff --git a/openstack/dns/v2/zones/testing/requests_test.go b/openstack/dns/v2/zones/testing/requests_test.go index 4cf9067be8..fcc1838ce3 100644 --- a/openstack/dns/v2/zones/testing/requests_test.go +++ b/openstack/dns/v2/zones/testing/requests_test.go @@ -3,6 +3,7 @@ package testing import ( "context" "encoding/json" + "fmt" "io" "net/http" "testing" @@ -127,11 +128,14 @@ func TestShare(t *testing.T) { th.CheckDeepEquals(t, expectedBody, reqBody) w.WriteHeader(http.StatusCreated) + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, ShareZoneResponse) }) opts := zones.ShareZoneOpts{TargetProjectID: "project-id"} - err := zones.Share(context.TODO(), client.ServiceClient(fakeServer), "zone-id", opts).ExtractErr() + zone, err := zones.Share(context.TODO(), client.ServiceClient(fakeServer), "zone-id", opts).Extract() th.AssertNoErr(t, err) + th.CheckDeepEquals(t, ShareZone, *zone) } func TestUnshare(t *testing.T) { @@ -146,3 +150,25 @@ func TestUnshare(t *testing.T) { err := zones.Unshare(context.TODO(), client.ServiceClient(fakeServer), "zone-id", "share-id").ExtractErr() th.AssertNoErr(t, err) } + +func TestListShares(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + fakeServer.Mux.HandleFunc("/zones/zone-id/shares", func(w http.ResponseWriter, r *http.Request) { + th.AssertEquals(t, r.Method, "GET") + th.AssertEquals(t, "true", r.Header.Get("X-Auth-All-Projects")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, ListSharesResponse) + }) + + opts := zones.ListSharesOpts{ + AllProjects: true, + } + pages, err := zones.ListShares(client.ServiceClient(fakeServer), "zone-id", opts).AllPages(context.TODO()) + th.AssertNoErr(t, err) + actual, err := zones.ExtractZoneShares(pages) + th.AssertNoErr(t, err) + th.CheckDeepEquals(t, ListZoneShares, actual) +} diff --git a/openstack/dns/v2/zones/urls.go b/openstack/dns/v2/zones/urls.go index ba156882f8..99416a3e19 100644 --- a/openstack/dns/v2/zones/urls.go +++ b/openstack/dns/v2/zones/urls.go @@ -12,12 +12,12 @@ func zoneURL(c *gophercloud.ServiceClient, zoneID string) string { return c.ServiceURL("zones", zoneID) } -// zoneShareURL returns the URL for sharing a zone. -func zoneShareURL(c *gophercloud.ServiceClient, zoneID string) string { +// sharesBaseURL returns the URL for shared zones. +func sharesBaseURL(c *gophercloud.ServiceClient, zoneID string) string { return c.ServiceURL("zones", zoneID, "shares") } -// zoneUnshareURL returns the URL for unsharing a zone. -func zoneUnshareURL(c *gophercloud.ServiceClient, zoneID, shareID string) string { - return c.ServiceURL("zones", zoneID, "shares", shareID) +// shareURL returns the URL for a shared zone. +func shareURL(c *gophercloud.ServiceClient, zoneID, sharedZoneID string) string { + return c.ServiceURL("zones", zoneID, "shares", sharedZoneID) }