-
Notifications
You must be signed in to change notification settings - Fork 221
/
state.go
238 lines (200 loc) · 6.78 KB
/
state.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
package launch
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/samber/lo"
fly "github.com/superfly/fly-go"
"github.com/superfly/flyctl/gql"
extensions_core "github.com/superfly/flyctl/internal/command/extensions/core"
"github.com/superfly/flyctl/internal/command/launch/plan"
"github.com/superfly/flyctl/internal/flag"
"github.com/superfly/flyctl/internal/prompt"
"github.com/superfly/flyctl/iostreams"
)
// Let's *try* to keep this struct backwards-compatible as we change it
type launchPlanSource struct {
appNameSource string
regionSource string
orgSource string
computeSource string
postgresSource string
redisSource string
sentrySource string
}
type LaunchManifest struct {
Plan *plan.LaunchPlan
PlanSource *launchPlanSource
}
type launchState struct {
workingDir string
configPath string
LaunchManifest
env map[string]string
planBuildCache
cache map[string]interface{}
}
func cacheGrab[T any](cache map[string]interface{}, key string, cb func() (T, error)) (T, error) {
if val, ok := cache[key]; ok {
return val.(T), nil
}
val, err := cb()
if err != nil {
return val, err
}
cache[key] = val
return val, nil
}
func (state *launchState) Org(ctx context.Context) (*fly.Organization, error) {
apiClient := fly.ClientFromContext(ctx)
return cacheGrab(state.cache, "org,"+state.Plan.OrgSlug, func() (*fly.Organization, error) {
return apiClient.GetOrganizationBySlug(ctx, state.Plan.OrgSlug)
})
}
func (state *launchState) Region(ctx context.Context) (fly.Region, error) {
apiClient := fly.ClientFromContext(ctx)
regions, err := cacheGrab(state.cache, "regions", func() ([]fly.Region, error) {
regions, _, err := apiClient.PlatformRegions(ctx)
if err != nil {
return nil, err
}
return regions, nil
})
if err != nil {
return fly.Region{}, err
}
region, ok := lo.Find(regions, func(r fly.Region) bool {
return r.Code == state.Plan.RegionCode
})
if !ok {
return region, fmt.Errorf("region %s not found. Is this a valid region according to `fly platform regions`?", state.Plan.RegionCode)
}
return region, nil
}
// PlanSummary returns a human-readable summary of the launch plan.
// Used to confirm the plan before executing it.
func (state *launchState) PlanSummary(ctx context.Context) (string, error) {
// It feels wrong to modify the appConfig here, but in well-formed states these should be identical anyway.
state.appConfig.Compute = state.Plan.Compute
// Expensive but should accurately simulate the whole machine building path, meaning we end up with the same
// guest description that will be deployed down the road :)
fakeMachine, err := state.appConfig.ToMachineConfig(state.appConfig.DefaultProcessName(), nil)
if err != nil {
return "", fmt.Errorf("failed to resolve machine guest config: %w", err)
}
guestStr := fakeMachine.Guest.String()
if len(state.appConfig.Compute) > 1 {
guestStr += fmt.Sprintf(", %d more", len(state.appConfig.Compute)-1)
}
org, err := state.Org(ctx)
if err != nil {
return "", err
}
region, err := state.Region(ctx)
if err != nil {
return "", err
}
postgresStr, err := describePostgresPlan(state.Plan)
if err != nil {
return "", err
}
redisStr, err := describeRedisPlan(ctx, state.Plan.Redis, org)
if err != nil {
return "", err
}
rows := [][]string{
{"Organization", org.Name, state.PlanSource.orgSource},
{"Name", state.Plan.AppName, state.PlanSource.appNameSource},
{"Region", region.Name, state.PlanSource.regionSource},
{"App Machines", guestStr, state.PlanSource.computeSource},
{"Postgres", postgresStr, state.PlanSource.postgresSource},
{"Redis", redisStr, state.PlanSource.redisSource},
{"Sentry", strconv.FormatBool(state.Plan.Sentry), state.PlanSource.sentrySource},
}
for _, row := range rows {
// TODO: This is a hack. It'd be nice to not require a special sentinel value for the description,
// but it works OK for now. I'd special-case on value=="" instead, but that isn't *necessarily*
// a failure case for every field.
if row[2] == recoverableSpecifyInUi {
row[1] = "<unspecified>"
}
}
colLengths := []int{0, 0, 0}
for _, row := range rows {
for i, col := range row {
if len(col) > colLengths[i] {
colLengths[i] = len(col)
}
}
}
ret := ""
for _, row := range rows {
label := row[0]
value := row[1]
source := row[2]
labelSpaces := strings.Repeat(" ", colLengths[0]-len(label))
valueSpaces := strings.Repeat(" ", colLengths[1]-len(value))
ret += fmt.Sprintf("%s: %s%s %s(%s)\n", label, labelSpaces, value, valueSpaces, source)
}
return ret, nil
}
func (state *launchState) validateExtensions(ctx context.Context) error {
// This is written a little awkwardly with the expectation
// that we'll probably need more validation in the future.
// When that happens we can just errors.Join(a(), b(), c()...)
io := iostreams.FromContext(ctx)
noConfirm := !io.IsInteractive() || flag.GetBool(ctx, "now")
org, err := state.Org(ctx)
if err != nil {
return err
}
validateSupabase := func() error {
supabase := state.Plan.Postgres.SupabasePostgres
if supabase == nil {
return nil
}
// We're using Supabase. Ensure that we're within plan limits.
client := fly.ClientFromContext(ctx).GenqClient
response, err := gql.ListAddOns(ctx, client, "supabase")
if err != nil {
return fmt.Errorf("failed to list Supabase databases: %w", err)
}
// TODO: We'd like to be able to query the user's plan to see if they're on a paid plan.
// For now, we'll just nag when they create their second database, every time.
if len(response.AddOns.Nodes) != 1 {
// If we're at zero databases, we're within the free plan.
// If we're at >=2 databases, we know we're on a paid plan.
// It's only 1 existing database where we need to validate the plan.
return nil
}
if noConfirm {
// We can't validate this any further until we can query the plan info.
// Assume it's okay, and let the launch fail if it's not.
// TODO: Once we can query whether or not the user is on a paid plan,
// we'll be able to early-exit in non-interactive mode and prevent a failed launch.
return nil
}
fmt.Fprintf(io.Out, "You're about to create a second Supabase database. This requires a paid plan.\n")
fmt.Fprintf(io.Out, "Please check to ensure that your plan supports this, otherwise your launch may fail.\n")
openDashboard, err := prompt.Confirm(ctx, "Open the dashboard to check your plan?")
if err != nil {
return err
}
if openDashboard {
if err = extensions_core.OpenOrgDashboard(ctx, org.Slug, "supabase"); err != nil {
return err
}
}
confirm, err := prompt.Confirm(ctx, fmt.Sprintf("Continue launching %s?", state.Plan.AppName))
if err != nil {
return err
}
if !confirm {
return errors.New("aborted by user")
}
return nil
}
return validateSupabase()
}