Skip to content

Commit a890343

Browse files
omerdemirokactions-user
authored andcommitted
Eng 582 support search in dynamic adapters (#1877)
# Support SEARCH in dynamic adapters We currently have all the necessary meta data for creating a searchable adapter. We only need to: * Create a searchable adapters * Initiate them during source startup It is important to note that, in the SEARCH method we need to check the query to see if it is actually for a terraform mappings and targeting a single resource rather than multiple. Simply, if the query contains and `/`, then it is for a single query, we should use the `externalCallSingle` and return the single response within a slice of `items` to comply with the `Search` method signature. Relevant: [ENG-580](https://linear.app/overmind/issue/ENG-580/handle-terraform-mappings-in-search-method) GitOrigin-RevId: 13ee3ef6b5e51acc20b787ff9a728523d5a2e96c
1 parent 7903ca3 commit a890343

File tree

9 files changed

+395
-19
lines changed

9 files changed

+395
-19
lines changed

sources/gcp/dynamic/adapter-listable.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ func (g ListableAdapter) Metadata() *sdp.AdapterMetadata {
4646
DescriptiveName: g.sdpAssetType.Readable(),
4747
SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{
4848
Get: true,
49-
GetDescription: fmt.Sprintf("Get a %s by its name i.e: zones/<zone>/instances/<instance-name>", g.sdpAssetType),
49+
GetDescription: getDescription(g.sdpAssetType, g.scope, g.uniqueAttributeKeys),
5050
List: true,
51-
ListDescription: fmt.Sprintf("List all %s within its scopes: %v", g.sdpAssetType, g.Scopes()),
51+
ListDescription: listDescription(g.sdpAssetType, g.scope),
5252
},
5353
TerraformMappings: g.terraformMappings,
5454
PotentialLinks: g.potentialLinks,
@@ -67,7 +67,7 @@ func (g ListableAdapter) List(ctx context.Context, scope string, ignoreCache boo
6767
itemsSelector := g.uniqueAttributeKeys[len(g.uniqueAttributeKeys)-1] // Use the last key as the item selector
6868
multiResp, err := externalCallMulti(ctx, itemsSelector, g.httpCli, g.httpHeaders, g.listEndpoint)
6969
if err != nil {
70-
return nil, fmt.Errorf("failed to list items for %s: %w", g.listEndpoint, err)
70+
return nil, fmt.Errorf("failed to retrieve items for %s: %w", g.listEndpoint, err)
7171
}
7272

7373
for _, resp := range multiResp {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package dynamic
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/overmindtech/cli/discovery"
10+
"github.com/overmindtech/cli/sdp-go"
11+
gcpshared "github.com/overmindtech/cli/sources/gcp/shared"
12+
)
13+
14+
// SearchableAdapter implements discovery.SearchableAdapter for GCP dynamic adapters.
15+
type SearchableAdapter struct {
16+
searchURLFunc gcpshared.EndpointFunc
17+
Adapter
18+
}
19+
20+
// NewSearchableAdapter creates a new GCP dynamic adapter.
21+
func NewSearchableAdapter(searchURLFunc gcpshared.EndpointFunc, config *AdapterConfig) discovery.SearchableAdapter {
22+
23+
return SearchableAdapter{
24+
searchURLFunc: searchURLFunc,
25+
Adapter: Adapter{
26+
projectID: config.ProjectID,
27+
scope: config.Scope,
28+
httpCli: config.HTTPClient,
29+
getURLFunc: config.GetURLFunc,
30+
httpHeaders: http.Header{
31+
"Authorization": []string{"Bearer " + config.Token},
32+
},
33+
sdpAssetType: config.SDPAssetType,
34+
sdpAdapterCategory: config.SDPAdapterCategory,
35+
terraformMappings: config.TerraformMappings,
36+
linker: config.Linker,
37+
potentialLinks: potentialLinksFromBlasts(config.SDPAssetType, gcpshared.BlastPropagations),
38+
uniqueAttributeKeys: config.UniqueAttributeKeys,
39+
},
40+
}
41+
}
42+
43+
func (g SearchableAdapter) Metadata() *sdp.AdapterMetadata {
44+
return &sdp.AdapterMetadata{
45+
Type: g.sdpAssetType.String(),
46+
Category: g.sdpAdapterCategory,
47+
DescriptiveName: g.sdpAssetType.Readable(),
48+
SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{
49+
Get: true,
50+
GetDescription: getDescription(g.sdpAssetType, g.scope, g.uniqueAttributeKeys),
51+
Search: true,
52+
SearchDescription: searchDescription(g.sdpAssetType, g.scope, g.uniqueAttributeKeys),
53+
},
54+
TerraformMappings: g.terraformMappings,
55+
PotentialLinks: g.potentialLinks,
56+
}
57+
}
58+
59+
func (g SearchableAdapter) Search(ctx context.Context, scope, query string, ignoreCache bool) ([]*sdp.Item, error) {
60+
if scope != g.scope {
61+
return nil, &sdp.QueryError{
62+
ErrorType: sdp.QueryError_NOSCOPE,
63+
ErrorString: fmt.Sprintf("requested scope %v does not match any adapter scope %v", scope, g.Scopes()),
64+
}
65+
}
66+
searchEndpoint := g.searchURLFunc(query)
67+
var items []*sdp.Item
68+
itemsSelector := g.uniqueAttributeKeys[len(g.uniqueAttributeKeys)-1] // Use the last key as the item selector
69+
70+
if strings.HasPrefix(query, "projects/") {
71+
// This is a single item query for terraform search method mappings.
72+
// See: https://linear.app/overmind/issue/ENG-580/handle-terraform-mappings-in-search-method
73+
resp, err := externalCallSingle(ctx, g.httpCli, g.httpHeaders, searchEndpoint)
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
item, err := externalToSDP(ctx, g.projectID, g.scope, g.uniqueAttributeKeys, resp, g.sdpAssetType, g.linker)
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
return append(items, item), nil
84+
}
85+
86+
multiResp, err := externalCallMulti(ctx, itemsSelector, g.httpCli, g.httpHeaders, searchEndpoint)
87+
if err != nil {
88+
return nil, fmt.Errorf("failed to retrieve items for %s: %w", searchEndpoint, err)
89+
}
90+
91+
for _, resp := range multiResp {
92+
item, err := externalToSDP(ctx, g.projectID, g.scope, g.uniqueAttributeKeys, resp, g.sdpAssetType, g.linker)
93+
if err != nil {
94+
return nil, err
95+
}
96+
97+
items = append(items, item)
98+
}
99+
100+
return items, nil
101+
}

sources/gcp/dynamic/adapter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func (g Adapter) Metadata() *sdp.AdapterMetadata {
7575
DescriptiveName: g.sdpAssetType.Readable(),
7676
SupportedQueryMethods: &sdp.AdapterSupportedQueryMethods{
7777
Get: true,
78-
GetDescription: fmt.Sprintf("Get a %s by its unique name within its scope: %s", g.sdpAssetType, g.scope),
78+
GetDescription: getDescription(g.sdpAssetType, g.scope, g.uniqueAttributeKeys),
7979
},
8080
TerraformMappings: g.terraformMappings,
8181
PotentialLinks: g.potentialLinks,

sources/gcp/dynamic/adapters.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ func Adapters(projectID string, token string, regions []string, zones []string,
6363
continue
6464
}
6565

66+
if meta.SearchEndpointFunc != nil {
67+
searchEndpointFunc, err := meta.SearchEndpointFunc(projectID)
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
adapters = append(adapters, NewSearchableAdapter(searchEndpointFunc, cfg))
73+
74+
continue
75+
}
76+
6677
adapters = append(adapters, NewAdapter(cfg))
6778
}
6879

@@ -104,6 +115,17 @@ func Adapters(projectID string, token string, regions []string, zones []string,
104115
continue
105116
}
106117

118+
if meta.SearchEndpointFunc != nil {
119+
searchEndpointFunc, err := meta.SearchEndpointFunc(projectID, region)
120+
if err != nil {
121+
return nil, err
122+
}
123+
124+
adapters = append(adapters, NewSearchableAdapter(searchEndpointFunc, cfg))
125+
126+
continue
127+
}
128+
107129
adapters = append(adapters, NewAdapter(cfg))
108130
}
109131
}
@@ -135,6 +157,7 @@ func Adapters(projectID string, token string, regions []string, zones []string,
135157
HTTPClient: otelhttp.DefaultClient,
136158
UniqueAttributeKeys: meta.UniqueAttributeKeys,
137159
}
160+
138161
if meta.ListEndpointFunc != nil {
139162
listEndpoint, err := meta.ListEndpointFunc(projectID, zone)
140163
if err != nil {
@@ -145,6 +168,17 @@ func Adapters(projectID string, token string, regions []string, zones []string,
145168
continue
146169
}
147170

171+
if meta.SearchEndpointFunc != nil {
172+
searchEndpointFunc, err := meta.SearchEndpointFunc(projectID, zone)
173+
if err != nil {
174+
return nil, err
175+
}
176+
177+
adapters = append(adapters, NewSearchableAdapter(searchEndpointFunc, cfg))
178+
179+
continue
180+
}
181+
148182
adapters = append(adapters, NewAdapter(cfg))
149183
}
150184
}

sources/gcp/dynamic/shared.go

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,35 @@ import (
1515
"github.com/overmindtech/cli/sources/shared"
1616
)
1717

18+
var (
19+
getDescription = func(sdpAssetType shared.ItemType, scope string, uniqueAttributeKeys []string) string {
20+
selector := "{name}"
21+
if len(uniqueAttributeKeys) > 1 {
22+
// i.e.: {datasets|tables} for bigquery tables
23+
selector = "{" + strings.Join(uniqueAttributeKeys, shared.QuerySeparator) + "}"
24+
}
25+
26+
return fmt.Sprintf("Get a %s by its %s within its scope: %s", sdpAssetType, selector, scope)
27+
}
28+
29+
listDescription = func(sdpAssetType shared.ItemType, scope string) string {
30+
return fmt.Sprintf("List all %s within its scope: %s", sdpAssetType, scope)
31+
}
32+
33+
searchDescription = func(sdpAssetType shared.ItemType, scope string, uniqueAttributeKeys []string) string {
34+
if len(uniqueAttributeKeys) < 2 {
35+
panic("searchDescription requires at least two unique attribute keys")
36+
}
37+
// For service directory endpoint adapter, the uniqueAttributeKeys is: []string{"locations", "namespaces", "services", "endpoints"}
38+
// We want to create a selector like:
39+
// {locations|namespaces|services}
40+
// We remove the last key, because it defines the actual item selector
41+
selector := "{" + strings.Join(uniqueAttributeKeys[:len(uniqueAttributeKeys)-1], shared.QuerySeparator) + "}"
42+
43+
return fmt.Sprintf("Search for %s by its %s within its scope: %s", sdpAssetType, selector, scope)
44+
}
45+
)
46+
1847
func linkItem(ctx context.Context, projectID string, sdpItem *sdp.Item, sdpAssetType shared.ItemType, linker *gcpshared.Linker, resp any, keys []string) {
1948
if value, ok := resp.(string); ok {
2049
linker.AutoLink(ctx, projectID, sdpItem, sdpAssetType, value, keys)
@@ -62,6 +91,7 @@ func externalToSDP(ctx context.Context, projectID string, scope string, uniqueAt
6291
}
6392

6493
// We need to keep an eye on this.
94+
// Name might not exist in the response for all APIs.
6595
if name, ok := resp["name"].(string); ok {
6696
attrValues := gcpshared.ExtractPathParams(name, uniqueAttrKeys...)
6797
uniqueAttrValue := strings.Join(attrValues, shared.QuerySeparator)
@@ -70,7 +100,7 @@ func externalToSDP(ctx context.Context, projectID string, scope string, uniqueAt
70100
return nil, err
71101
}
72102
} else {
73-
return nil, fmt.Errorf("unable to determine self link")
103+
return nil, fmt.Errorf("unable to determine the name")
74104
}
75105

76106
for k, v := range resp {
@@ -96,6 +126,15 @@ func externalCallSingle(ctx context.Context, httpCli *http.Client, httpHeaders h
96126
defer resp.Body.Close()
97127

98128
if resp.StatusCode != http.StatusOK {
129+
body, err := io.ReadAll(resp.Body)
130+
if err == nil {
131+
return nil, fmt.Errorf("failed to make a GET call: %s, HTTP Status: %s, HTTP Body: %s", url, resp.Status, string(body))
132+
}
133+
134+
log.WithContext(ctx).WithFields(log.Fields{
135+
"ovm.gcp.dynamic.http.get.url": url,
136+
"ovm.gcp.dynamic.http.get.responseStatus": resp.Status,
137+
}).Warnf("failed to read the response body: %v", err)
99138
return nil, fmt.Errorf("failed to make call: %s", resp.Status)
100139
}
101140

@@ -127,7 +166,17 @@ func externalCallMulti(ctx context.Context, itemsSelector string, httpCli *http.
127166
defer resp.Body.Close()
128167

129168
if resp.StatusCode != http.StatusOK {
130-
return nil, fmt.Errorf("failed to make the GET call for the %s URL. HTTP Status: %s", url, resp.Status)
169+
// Read the body to provide more context in the error message
170+
body, err := io.ReadAll(resp.Body)
171+
if err == nil {
172+
return nil, fmt.Errorf("failed to make the GET call. HTTP Status: %s, HTTP Body: %s", resp.Status, string(body))
173+
}
174+
175+
log.WithContext(ctx).WithFields(log.Fields{
176+
"ovm.gcp.dynamic.http.get.url": url,
177+
"ovm.gcp.dynamic.http.get.responseStatus": resp.Status,
178+
}).Warnf("failed to read the response body: %v", err)
179+
return nil, fmt.Errorf("failed to make the GET callL. HTTP Status: %s", resp.Status)
131180
}
132181

133182
data, err := io.ReadAll(resp.Body)
@@ -142,12 +191,13 @@ func externalCallMulti(ctx context.Context, itemsSelector string, httpCli *http.
142191

143192
items, ok := result[itemsSelector].([]any)
144193
if !ok {
145-
// fallback to a generic "items" key if the itemsSelector is not found
146-
items, ok = result["items"].([]any)
194+
itemsSelector = "items" // Fallback to a generic "items" key
195+
items, ok = result[itemsSelector].([]any)
147196
if !ok {
148197
log.WithContext(ctx).WithFields(log.Fields{
149-
"url": url,
150-
}).Warnf("failed to cast resp as a list of items: %v", result)
198+
"ovm.gcp.dynamic.http.get.url": url,
199+
"ovm.gcp.dynamic.http.get.itemsSelector": itemsSelector,
200+
}).Warnf("failed to cast resp as a list of %s: %v", itemsSelector, result)
151201
return nil, nil
152202
}
153203
}

0 commit comments

Comments
 (0)