Skip to content
This repository has been archived by the owner on May 15, 2023. It is now read-only.

Commit

Permalink
Merge pull request #82 from yukinying/interface
Browse files Browse the repository at this point in the history
extract ancestry discovery logic into an interface
  • Loading branch information
morgante committed Oct 4, 2019
2 parents d94b867 + fb44920 commit 1206372
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 111 deletions.
87 changes: 87 additions & 0 deletions ancestrymanager/ancestrymanager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Package ancestrymanager provides an interface to query the ancestry information for a project.
package ancestrymanager

import (
"context"
"fmt"
"strings"

"github.com/pkg/errors"
"google.golang.org/api/cloudresourcemanager/v1"
"google.golang.org/api/option"
)

// AncestryManager is the interface that wraps the GetAncestry method.
type AncestryManager interface {
// GetAncestry takes a project name and return a ancestry path
GetAncestry(project string) (string, error)
}

type onlineAncestryManager struct {
// Talk to GCP resource manager. This field would be nil in offline mode.
resourceManager *cloudresourcemanager.Service
// Cache to prevent multiple network calls for looking up the same project's ancestry
// map[project]ancestryPath
ancestryCache map[string]string
}

// GetAncestry uses the resource manager API to get ancestry paths for
// projects. It implements a cache because many resources share the same
// project.
func (m *onlineAncestryManager) GetAncestry(project string) (string, error) {
if path, ok := m.ancestryCache[project]; ok {
return path, nil
}
ancestry, err := m.resourceManager.Projects.GetAncestry(project, &cloudresourcemanager.GetAncestryRequest{}).Do()
if err != nil {
return "", err
}
path := ancestryPath(ancestry.Ancestor)
m.store(project, path)
return path, nil
}

func (m *onlineAncestryManager) store(project, ancestry string) {
if project != "" && ancestry != "" {
m.ancestryCache[project] = ancestry
}
}

type offlineAncestryManager struct {
project string
ancestry string
}

// GetAncestry returns the ancestry for the project. It returns an error if
// the project does not equal to the one provided during initialization.
func (m *offlineAncestryManager) GetAncestry(project string) (string, error) {
if project != m.project {
return "", fmt.Errorf("cannot fetch ancestry in offline mode")
}
return m.ancestry, nil
}

// New returns AncestryManager that can be used to fetch ancestry information for a project.
func New(ctx context.Context, project, ancestry string, offline bool, opts ...option.ClientOption) (AncestryManager, error) {
if offline {
return &offlineAncestryManager{project: project, ancestry: ancestry}, nil
}
am := &onlineAncestryManager{ancestryCache: map[string]string{}}
am.store(project, ancestry)
rm, err := cloudresourcemanager.NewService(ctx, opts...)
if err != nil {
return nil, errors.Wrap(err, "constructing resource manager client")
}
am.resourceManager = rm
return am, nil
}

// ancestryPath composes a path containing organization/folder/project
// (i.e. "organization/my-org/folder/my-folder/project/my-prj").
func ancestryPath(as []*cloudresourcemanager.Ancestor) string {
var path []string
for i := len(as) - 1; i >= 0; i-- {
path = append(path, as[i].ResourceId.Type, as[i].ResourceId.Id)
}
return strings.Join(path, "/")
}
133 changes: 133 additions & 0 deletions ancestrymanager/ancestrymanager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package ancestrymanager

import (
"context"
"net/http"
"net/http/httptest"
"regexp"
"testing"

cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1"
"google.golang.org/api/option"
)

func TestAncestryPath(t *testing.T) {
cases := []struct {
name string
input []*cloudresourcemanager.Ancestor
expectedOutput string
}{
{
name: "Empty",
input: []*cloudresourcemanager.Ancestor{},
expectedOutput: "",
},
{
name: "ProjectOrganization",
input: []*cloudresourcemanager.Ancestor{
{
ResourceId: &cloudresourcemanager.ResourceId{
Id: "my-prj",
Type: "project",
},
},
{
ResourceId: &cloudresourcemanager.ResourceId{
Id: "my-org",
Type: "organization",
},
},
},
expectedOutput: "organization/my-org/project/my-prj",
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
output := ancestryPath(c.input)
if output != c.expectedOutput {
t.Errorf("expected output %q, got %q", c.expectedOutput, output)
}
})
}
}

func TestGetAncestry(t *testing.T) {
ctx := context.Background()
ownerProject := "foo"
ownerAncestry := "organization/qux/folder/bar/project/foo"
anotherProject := "foo2"

// Setup a simple test server to mock the response of resource manager.
cache := map[string][]*cloudresourcemanager.Ancestor{
ownerProject: []*cloudresourcemanager.Ancestor{
{ResourceId: &cloudresourcemanager.ResourceId{Id: "foo", Type: "project"}},
{ResourceId: &cloudresourcemanager.ResourceId{Id: "bar", Type: "folder"}},
{ResourceId: &cloudresourcemanager.ResourceId{Id: "qux", Type: "organization"}},
},
anotherProject: []*cloudresourcemanager.Ancestor{
{ResourceId: &cloudresourcemanager.ResourceId{Id: "foo2", Type: "project"}},
{ResourceId: &cloudresourcemanager.ResourceId{Id: "bar2", Type: "folder"}},
{ResourceId: &cloudresourcemanager.ResourceId{Id: "qux2", Type: "organization"}},
},
}
ts := newAncestryManagerMockServer(t, cache)
defer ts.Close()

amOnline, err := New(ctx, ownerProject, "", false, option.WithEndpoint(ts.URL), option.WithoutAuthentication())
if err != nil {
t.Fatalf("failed to create online ancestry manager: %s", err)
}
amOffline, err := New(ctx, ownerProject, ownerAncestry, true)
if err != nil {
t.Fatalf("failed to create offline ancestry manager: %s", err)
}

cases := []struct {
name string
target AncestryManager
query string
wantError bool
want string
}{
{name: "owner_project_online", target: amOnline, query: ownerProject, want: ownerAncestry},
{name: "owner_project_offline", target: amOffline, query: ownerProject, want: ownerAncestry},
{name: "another_project_online", target: amOnline, query: anotherProject, want: "organization/qux2/folder/bar2/project/foo2"},
{name: "another_project_offline", target: amOffline, query: anotherProject, wantError: true},
{name: "missed_project_online", target: amOnline, query: "notexist", wantError: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := c.target.GetAncestry(c.query)
if !c.wantError && err != nil {
t.Fatalf("GetAncestry(%s) returns error: %s", c.query, err)
}
if c.wantError && err == nil {
t.Fatalf("GetAncestry(%s) returns no error, want error", c.query)
}
if got != c.want {
t.Errorf("GetAncestry(%s): got=%s, want=%s", c.query, got, c.want)
}
})
}
}

func newAncestryManagerMockServer(t *testing.T, cache map[string][]*cloudresourcemanager.Ancestor) *httptest.Server {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
re := regexp.MustCompile(`([^/]*):getAncestry`)
path := re.FindStringSubmatch(r.URL.Path)
if path == nil || cache[path[1]] == nil {
w.WriteHeader(http.StatusForbidden)
return
}
payload, err := (&cloudresourcemanager.GetAncestryResponse{Ancestor: cache[path[1]]}).MarshalJSON()
if err != nil {
t.Errorf("failed to MarshalJSON: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write(payload)
}))
return ts
}
61 changes: 6 additions & 55 deletions converters/google/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@ package google
import (
"fmt"
"sort"
"strings"

"github.com/hashicorp/terraform/helper/schema"
"github.com/pkg/errors"
provider "github.com/terraform-providers/terraform-provider-google/google"
"google.golang.org/api/cloudresourcemanager/v1"

converter "github.com/GoogleCloudPlatform/terraform-google-conversion/google"
"github.com/GoogleCloudPlatform/terraform-validator/ancestrymanager"
)

var ErrDuplicateAsset = errors.New("duplicate asset")
Expand Down Expand Up @@ -86,29 +85,17 @@ type AssetResource struct {
}

// NewConverter is a factory function for Converter.
func NewConverter(resourceManager *cloudresourcemanager.Service, project, ancestry, credentials string, offline bool) (*Converter, error) {
func NewConverter(ancestryManager ancestrymanager.AncestryManager, project, credentials string) (*Converter, error) {
cfg := &converter.Config{
Project: project,
Credentials: credentials,
}
if !offline {
if err := cfg.LoadAndValidate(); err != nil {
return nil, errors.Wrap(err, "configuring")
}
}

ancestryCache := make(map[string]string)
if ancestry != "" {
ancestryCache[project] = fmt.Sprintf("%s/project/%s", ancestry, project)
}

p := provider.Provider().(*schema.Provider)
return &Converter{
schema: p,
mapperFuncs: mappers(),
cfg: cfg,
resourceManager: resourceManager,
ancestryCache: ancestryCache,
ancestryManager: ancestryManager,
assets: make(map[string]Asset),
}, nil
}
Expand All @@ -124,12 +111,8 @@ type Converter struct {

cfg *converter.Config

// Talk to GCP resource manager. This field would be nil in offline mode.
resourceManager *cloudresourcemanager.Service

// Cache to prevent multiple network calls for looking up the same project's ancestry
// map[project]ancestryPath
ancestryCache map[string]string
// ancestryManager provides a manager to find the ancestry information for a project.
ancestryManager ancestrymanager.AncestryManager

// Map of converted assets (key = asset.Type + asset.Name)
assets map[string]Asset
Expand Down Expand Up @@ -210,7 +193,7 @@ func (c *Converter) augmentAsset(tfData converter.TerraformResourceData, cfg *co
return Asset{}, err
}

ancestry, err := c.getAncestry(project)
ancestry, err := c.ancestryManager.GetAncestry(project)
if err != nil {
return Asset{}, errors.Wrapf(err, "getting resource ancestry: project %v", project)
}
Expand Down Expand Up @@ -247,35 +230,3 @@ func (c *Converter) augmentAsset(tfData converter.TerraformResourceData, cfg *co
converterAsset: cai,
}, nil
}

// getAncestry uses the resource manager API to get ancestry paths for
// projects. It implements a cache because many resources share the same
// project.
func (c *Converter) getAncestry(project string) (string, error) {
if path, ok := c.ancestryCache[project]; ok {
return path, nil
}
if c.resourceManager == nil {
return "", fmt.Errorf("cannot fetch ancestry in offline mode for project %s", project)
}

ancestry, err := c.resourceManager.Projects.GetAncestry(project, &cloudresourcemanager.GetAncestryRequest{}).Do()
if err != nil {
return "", err
}

path := ancestryPath(ancestry.Ancestor)
c.ancestryCache[project] = path

return path, nil
}

// ancestryPath composes a path containing organization/folder/project
// (i.e. "organization/my-org/folder/my-folder/project/my-prj").
func ancestryPath(as []*cloudresourcemanager.Ancestor) string {
var path []string
for i := len(as) - 1; i >= 0; i-- {
path = append(path, as[i].ResourceId.Type, as[i].ResourceId.Id)
}
return strings.Join(path, "/")
}
42 changes: 0 additions & 42 deletions converters/google/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,50 +19,8 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"google.golang.org/api/cloudresourcemanager/v1"
)

func TestAncestryPath(t *testing.T) {
cases := []struct {
name string
input []*cloudresourcemanager.Ancestor
expectedOutput string
}{
{
name: "Empty",
input: []*cloudresourcemanager.Ancestor{},
expectedOutput: "",
},
{
name: "ProjectOrganization",
input: []*cloudresourcemanager.Ancestor{
{
ResourceId: &cloudresourcemanager.ResourceId{
Id: "my-prj",
Type: "project",
},
},
{
ResourceId: &cloudresourcemanager.ResourceId{
Id: "my-org",
Type: "organization",
},
},
},
expectedOutput: "organization/my-org/project/my-prj",
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
output := ancestryPath(c.input)
if output != c.expectedOutput {
t.Errorf("expected output %q, got %q", c.expectedOutput, output)
}
})
}
}

func TestSortByName(t *testing.T) {
cases := []struct {
name string
Expand Down
Loading

0 comments on commit 1206372

Please sign in to comment.