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

extract ancestry discovery logic into an interface #82

Merged
merged 2 commits into from Oct 4, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
87 changes: 87 additions & 0 deletions ancestrymanager/ancestrymanager.go
@@ -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
@@ -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
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
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