Skip to content

Commit fe5b7f5

Browse files
authored
fix(cli): reduce number of api calls for suga build (#112)
1 parent 1dc2ca6 commit fe5b7f5

File tree

4 files changed

+308
-7
lines changed

4 files changed

+308
-7
lines changed

cli/internal/api/build.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/url"
8+
9+
"github.com/nitrictech/suga/cli/internal/version"
10+
"github.com/nitrictech/suga/engines/terraform"
11+
)
12+
13+
// GetBuildManifest fetches the platform and all its plugins in a single call
14+
func (c *SugaApiClient) GetBuildManifest(team, platform string, revision int) (*terraform.PlatformSpec, map[string]map[string]any, error) {
15+
response, err := c.get(fmt.Sprintf("/api/teams/%s/platforms/%s/revisions/%d/build-manifest", url.PathEscape(team), url.PathEscape(platform), revision), true)
16+
if err != nil {
17+
return nil, nil, err
18+
}
19+
defer response.Body.Close()
20+
21+
if response.StatusCode != 200 {
22+
if response.StatusCode == 404 {
23+
return nil, nil, ErrNotFound
24+
}
25+
26+
if response.StatusCode == 401 {
27+
return nil, nil, ErrUnauthenticated
28+
}
29+
30+
return nil, nil, fmt.Errorf("received non 200 response from %s build manifest endpoint: %d", version.ProductName, response.StatusCode)
31+
}
32+
33+
body, err := io.ReadAll(response.Body)
34+
if err != nil {
35+
return nil, nil, fmt.Errorf("failed to read response from %s build manifest endpoint: %v", version.ProductName, err)
36+
}
37+
38+
var buildManifest GetBuildManifestResponse
39+
err = json.Unmarshal(body, &buildManifest)
40+
if err != nil {
41+
return nil, nil, fmt.Errorf("unexpected response from %s build manifest endpoint: %v", version.ProductName, err)
42+
}
43+
44+
return buildManifest.Platform, buildManifest.Plugins, nil
45+
}
46+
47+
// GetPublicBuildManifest fetches the platform and all its plugins for a public platform
48+
func (c *SugaApiClient) GetPublicBuildManifest(team, platform string, revision int) (*terraform.PlatformSpec, map[string]map[string]any, error) {
49+
response, err := c.get(fmt.Sprintf("/api/public/platforms/%s/%s/revisions/%d/build-manifest", url.PathEscape(team), url.PathEscape(platform), revision), true)
50+
if err != nil {
51+
return nil, nil, err
52+
}
53+
defer response.Body.Close()
54+
55+
if response.StatusCode != 200 {
56+
if response.StatusCode == 404 {
57+
return nil, nil, ErrNotFound
58+
}
59+
60+
return nil, nil, fmt.Errorf("received non 200 response from %s public build manifest endpoint: %d", version.ProductName, response.StatusCode)
61+
}
62+
63+
body, err := io.ReadAll(response.Body)
64+
if err != nil {
65+
return nil, nil, fmt.Errorf("failed to read response from %s public build manifest endpoint: %v", version.ProductName, err)
66+
}
67+
68+
var buildManifest GetBuildManifestResponse
69+
err = json.Unmarshal(body, &buildManifest)
70+
if err != nil {
71+
return nil, nil, fmt.Errorf("unexpected response from %s public build manifest endpoint: %v", version.ProductName, err)
72+
}
73+
74+
return buildManifest.Platform, buildManifest.Plugins, nil
75+
}

cli/internal/api/models.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ type GetPluginManifestResponse struct {
195195
Manifest map[string]interface{} `json:"manifest"`
196196
}
197197

198+
// Build manifest DTOs
199+
type GetBuildManifestResponse struct {
200+
Platform *terraform.PlatformSpec `json:"platform"`
201+
Plugins map[string]map[string]any `json:"plugins"` // key format: "team/library/version/name"
202+
}
203+
198204
// Team DTOs from backend
199205
type CreateTeamRequest struct {
200206
Name string `json:"name"`

cli/internal/build/build.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import (
66
"os"
77
"path/filepath"
88
"regexp"
9+
"strings"
910
"time"
1011

1112
"github.com/nitrictech/suga/cli/internal/api"
12-
"github.com/nitrictech/suga/cli/internal/platforms"
1313
"github.com/nitrictech/suga/cli/internal/plugins"
1414
"github.com/nitrictech/suga/cli/pkg/schema"
1515
"github.com/nitrictech/suga/engines/terraform"
@@ -30,21 +30,29 @@ func sanitizeForFilename(input string) string {
3030
return re.ReplaceAllString(input, "_")
3131
}
3232

33-
3433
func (b *BuilderService) BuildProject(appSpec *schema.Application, currentTeam string) (string, error) {
35-
platformRepository := platforms.NewPlatformRepository(b.apiClient, currentTeam)
36-
3734
if appSpec.Target == "" {
3835
return "", fmt.Errorf("no target specified in project %s", appSpec.Name)
3936
}
4037

41-
platform, err := terraform.PlatformFromId(b.fs, appSpec.Target, platformRepository)
38+
var pluginRepo terraform.PluginRepository = plugins.NewPluginRepository(b.apiClient, currentTeam)
39+
var platformRepo terraform.PlatformRepository = nil
40+
if !strings.HasPrefix(appSpec.Target, terraform.PlatformReferencePrefix_File) {
41+
repo, err := NewRepository(b.apiClient, currentTeam, appSpec.Target)
42+
if err != nil {
43+
return "", err
44+
}
45+
// Use the original repositories
46+
pluginRepo = repo
47+
platformRepo = repo
48+
}
49+
50+
platform, err := terraform.PlatformFromId(b.fs, appSpec.Target, platformRepo)
4251
if err != nil {
4352
return "", err
4453
}
4554

46-
repo := plugins.NewPluginRepository(b.apiClient, currentTeam)
47-
engine := terraform.New(platform, terraform.WithRepository(repo))
55+
engine := terraform.New(platform, terraform.WithRepository(pluginRepo))
4856

4957
stackPath, err := engine.Apply(appSpec)
5058
if err != nil {

cli/internal/build/repository.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package build
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"regexp"
8+
"strconv"
9+
10+
"github.com/nitrictech/suga/cli/internal/api"
11+
"github.com/nitrictech/suga/cli/internal/version"
12+
"github.com/nitrictech/suga/engines/terraform"
13+
)
14+
15+
// Repository fetches both platform and plugins in a single API call at construction time
16+
type Repository struct {
17+
apiClient *api.SugaApiClient
18+
currentTeam string
19+
// The platform reference this repository was created for
20+
platformRef string
21+
// Cached platform spec and plugin manifests
22+
platformSpec *terraform.PlatformSpec
23+
pluginManifests map[string]map[string]any
24+
}
25+
26+
var _ terraform.PlatformRepository = (*Repository)(nil)
27+
var _ terraform.PluginRepository = (*Repository)(nil)
28+
29+
// NewRepository creates a repository and fetches the platform and all its plugins upfront
30+
func NewRepository(apiClient *api.SugaApiClient, currentTeam, platformRef string) (*Repository, error) {
31+
repo := &Repository{
32+
apiClient: apiClient,
33+
currentTeam: currentTeam,
34+
platformRef: platformRef,
35+
}
36+
37+
// Fetch platform and plugins immediately
38+
if err := repo.fetchPlatformAndPlugins(platformRef); err != nil {
39+
return nil, err
40+
}
41+
42+
return repo, nil
43+
}
44+
45+
// GetPlatform validates that the requested platform matches the one this repository was created for
46+
func (r *Repository) GetPlatform(name string) (*terraform.PlatformSpec, error) {
47+
// Validate that the requested platform matches what we fetched
48+
if name != r.platformRef {
49+
return nil, fmt.Errorf("repository was created for platform %s but %s was requested", r.platformRef, name)
50+
}
51+
52+
return r.platformSpec, nil
53+
}
54+
55+
// fetchPlatformAndPlugins fetches the platform and all its plugins in a single API call
56+
func (r *Repository) fetchPlatformAndPlugins(name string) error {
57+
// Parse the platform name <team>/<platform>@<revision>
58+
re := regexp.MustCompile(`^(?P<team>[a-z][a-z0-9-]*)/(?P<platform>[a-z][a-z0-9-]*)@(?P<revision>\d+)$`)
59+
matches := re.FindStringSubmatch(name)
60+
61+
if matches == nil {
62+
return fmt.Errorf("invalid platform name format: %s. Expected format: <team>/<platform>@<revision> e.g. %s/aws@1", name, version.CommandName)
63+
}
64+
65+
team := matches[re.SubexpIndex("team")]
66+
platform := matches[re.SubexpIndex("platform")]
67+
revisionStr := matches[re.SubexpIndex("revision")]
68+
69+
revision, err := strconv.Atoi(revisionStr)
70+
if err != nil {
71+
return fmt.Errorf("invalid revision format: %s. Expected integer", revisionStr)
72+
}
73+
74+
// Smart ordering: try public first if the platform team doesn't match current user's team
75+
var platformSpec *terraform.PlatformSpec
76+
var plugins map[string]map[string]any
77+
78+
if team != r.currentTeam {
79+
// Try public access first
80+
platformSpec, plugins, err = r.apiClient.GetPublicBuildManifest(team, platform, revision)
81+
if err == nil {
82+
r.platformSpec = platformSpec
83+
r.pluginManifests = plugins
84+
return nil
85+
}
86+
87+
// If public fails with 404, it's definitely not found
88+
if errors.Is(err, api.ErrNotFound) {
89+
return terraform.ErrPlatformNotFound
90+
}
91+
92+
// If public fails for other reasons, try authenticated access
93+
platformSpec, plugins, err = r.apiClient.GetBuildManifest(team, platform, revision)
94+
if err != nil {
95+
if errors.Is(err, api.ErrNotFound) {
96+
return terraform.ErrPlatformNotFound
97+
}
98+
if errors.Is(err, api.ErrUnauthenticated) {
99+
return terraform.ErrUnauthenticated
100+
}
101+
return err
102+
}
103+
r.platformSpec = platformSpec
104+
r.pluginManifests = plugins
105+
return nil
106+
}
107+
108+
// Try authenticated access first
109+
platformSpec, plugins, err = r.apiClient.GetBuildManifest(team, platform, revision)
110+
if err != nil {
111+
// If authentication failed, try public platform access
112+
if errors.Is(err, api.ErrUnauthenticated) || errors.Is(err, api.ErrNotFound) {
113+
platformSpec, plugins, err = r.apiClient.GetPublicBuildManifest(team, platform, revision)
114+
if err != nil {
115+
// If public access also fails with 404, return platform not found
116+
if errors.Is(err, api.ErrNotFound) {
117+
return terraform.ErrPlatformNotFound
118+
}
119+
// Return the original authentication error for other public access failures
120+
return terraform.ErrUnauthenticated
121+
}
122+
r.platformSpec = platformSpec
123+
r.pluginManifests = plugins
124+
return nil
125+
}
126+
127+
// If its a 404, then return platform not found error
128+
if errors.Is(err, api.ErrNotFound) {
129+
return terraform.ErrPlatformNotFound
130+
}
131+
132+
// return the original error to the engine
133+
return err
134+
}
135+
136+
r.platformSpec = platformSpec
137+
r.pluginManifests = plugins
138+
return nil
139+
}
140+
141+
// GetResourcePlugin retrieves a cached plugin manifest
142+
func (r *Repository) GetResourcePlugin(team, libname, version, name string) (*terraform.ResourcePluginManifest, error) {
143+
pluginManifest, err := r.getPluginManifest(team, libname, version, name)
144+
if err != nil {
145+
return nil, err
146+
}
147+
148+
resourcePluginManifest, ok := pluginManifest.(*terraform.ResourcePluginManifest)
149+
if !ok {
150+
return nil, fmt.Errorf("encountered malformed manifest for plugin %s/%s/%s@%s", team, libname, name, version)
151+
}
152+
153+
return resourcePluginManifest, nil
154+
}
155+
156+
// GetIdentityPlugin retrieves a cached plugin manifest
157+
func (r *Repository) GetIdentityPlugin(team, libname, version, name string) (*terraform.IdentityPluginManifest, error) {
158+
pluginManifest, err := r.getPluginManifest(team, libname, version, name)
159+
if err != nil {
160+
return nil, err
161+
}
162+
163+
identityPluginManifest, ok := pluginManifest.(*terraform.IdentityPluginManifest)
164+
if !ok {
165+
return nil, fmt.Errorf("encountered malformed manifest for plugin %s/%s/%s@%s", team, libname, name, version)
166+
}
167+
168+
return identityPluginManifest, nil
169+
}
170+
171+
// getPluginManifest retrieves a plugin manifest from the cache
172+
func (r *Repository) getPluginManifest(team, libname, version, name string) (any, error) {
173+
if r.pluginManifests == nil {
174+
return nil, fmt.Errorf("no plugin manifests cached. GetPlatform must be called first")
175+
}
176+
177+
key := fmt.Sprintf("%s/%s/%s/%s", team, libname, version, name)
178+
manifestData, ok := r.pluginManifests[key]
179+
if !ok {
180+
return nil, fmt.Errorf("plugin %s not found in build manifest", key)
181+
}
182+
183+
// Check the type field to determine which manifest type to use
184+
pluginType, ok := manifestData["type"].(string)
185+
if !ok {
186+
return nil, fmt.Errorf("plugin manifest missing type field for %s", key)
187+
}
188+
189+
if pluginType == "identity" {
190+
var identityManifest terraform.IdentityPluginManifest
191+
if err := remapToStruct(manifestData, &identityManifest); err != nil {
192+
return nil, fmt.Errorf("failed to parse identity plugin manifest for %s: %w", key, err)
193+
}
194+
return &identityManifest, nil
195+
}
196+
197+
var resourceManifest terraform.ResourcePluginManifest
198+
if err := remapToStruct(manifestData, &resourceManifest); err != nil {
199+
return nil, fmt.Errorf("failed to parse resource plugin manifest for %s: %w", key, err)
200+
}
201+
return &resourceManifest, nil
202+
}
203+
204+
// remapToStruct is a helper to convert map[string]any to a struct
205+
// This is a simple implementation using JSON marshaling
206+
func remapToStruct(m map[string]any, target any) error {
207+
bytes, err := json.Marshal(m)
208+
if err != nil {
209+
return err
210+
}
211+
return json.Unmarshal(bytes, target)
212+
}

0 commit comments

Comments
 (0)