carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// `carlos accounts plan`: a deployment operator reads or sets an account's
4// plan, packs and sponsored limits through the console's plan door
5// (internal/console/apiaccountplan.go; plans-caps-packs spec §4, §8.7).
6
7import (
8 "bytes"
9 "context"
10 "encoding/json"
11 "errors"
12 "flag"
13 "fmt"
14 "io"
15 "log"
16 "net/http"
17 "net/url"
18 "os"
19 "slices"
20 "strconv"
21 "strings"
22 "time"
23
24 "github.com/carlosframework/platform/internal/plans"
25)
26
27// accountPlanReply and setAccountPlanRequest mirror the console door's wire
28// shapes.
29type accountPlanReply struct {
30 Account string `json:"account"`
31 CatalogID string `json:"catalog_id"`
32 Plan string `json:"plan"`
33 Packs map[string]int `json:"packs"`
34 Source string `json:"source"`
35 Sponsored *plans.Limits `json:"sponsored,omitempty"`
36 Reason string `json:"reason,omitempty"`
37 UpdatedBy string `json:"updated_by,omitempty"`
38 Revision int64 `json:"revision"`
39 PublishedRevision int64 `json:"published_revision"`
40 Limits *plans.Limits `json:"limits,omitempty"`
41 LimitsError string `json:"limits_error,omitempty"`
42}
43
44type setAccountPlanRequest struct {
45 Plan string `json:"plan"`
46 Packs map[string]int `json:"packs,omitempty"`
47 Sponsored *plans.Limits `json:"sponsored,omitempty"`
48 Reason string `json:"reason"`
49}
50
51// packFlags collects repeated --pack name=quantity values.
52type packFlags map[string]int
53
54func (p *packFlags) String() string {
55 if p == nil {
56 return ""
57 }
58 return formatPacks(*p)
59}
60
61func (p *packFlags) Set(v string) error {
62 name, qty, ok := strings.Cut(v, "=")
63 n, err := strconv.Atoi(qty)
64 if !ok || name == "" || err != nil {
65 return fmt.Errorf(cliPlanCopyPackFormat, v)
66 }
67 if *p == nil {
68 *p = packFlags{}
69 }
70 (*p)[name] = n
71 return nil
72}
73
74func formatPacks(p map[string]int) string {
75 names := make([]string, 0, len(p))
76 for name := range p {
77 names = append(names, name)
78 }
79 slices.Sort(names)
80 parts := make([]string, 0, len(names))
81 for _, name := range names {
82 parts = append(parts, fmt.Sprintf("%s %d", name, p[name]))
83 }
84 return strings.Join(parts, ", ")
85}
86
87func runAccountsPlan(args []string) {
88 if err := runAccountsPlanWithSession(context.Background(), nil, args, stdout); err != nil {
89 if errors.Is(err, flag.ErrHelp) {
90 return
91 }
92 log.Fatalf("accounts plan: %v", err)
93 }
94}
95
96func runAccountsPlanWithSession(ctx context.Context, sess *apiSession, args []string, out io.Writer) error {
97 fs := flag.NewFlagSet("accounts plan", flag.ContinueOnError)
98 fs.SetOutput(out)
99 account := fs.String("account", "", cliPlanCopyFlagAccount)
100 set := fs.String("set", "", cliPlanCopyFlagSet)
101 var packs packFlags
102 fs.Var(&packs, "pack", cliPlanCopyFlagPack)
103 sponsored := fs.String("sponsored", "", cliPlanCopyFlagSponsored)
104 reason := fs.String("reason", "", cliPlanCopyFlagReason)
105 consoleFlag := consoleOption(fs, "console origin to use")
106 fs.Usage = flagUsage(fs, "")
107 if err := fs.Parse(args); err != nil {
108 return err
109 }
110 if fs.NArg() != 0 {
111 return fmt.Errorf("unexpected argument %q", fs.Arg(0))
112 }
113 acct, err := explicitStoreAccount(*account)
114 if err != nil {
115 return err
116 }
117 var req *setAccountPlanRequest
118 if *set == "" {
119 if len(packs) > 0 || *sponsored != "" || *reason != "" {
120 return errors.New(cliPlanCopyNeedsSet)
121 }
122 } else {
123 if strings.TrimSpace(*reason) == "" {
124 return errors.New(cliPlanCopyReasonRequired)
125 }
126 req = &setAccountPlanRequest{Plan: *set, Packs: packs, Reason: *reason}
127 if *sponsored != "" {
128 b, err := os.ReadFile(*sponsored)
129 if err == nil {
130 // Strict: a misspelt limit would otherwise vanish and lower
131 // the account's limits.
132 req.Sponsored = &plans.Limits{}
133 dec := json.NewDecoder(bytes.NewReader(b))
134 dec.DisallowUnknownFields()
135 err = dec.Decode(req.Sponsored)
136 }
137 if err != nil {
138 return fmt.Errorf(cliPlanCopySponsoredFile, *sponsored, err)
139 }
140 }
141 }
142 if sess == nil {
143 sess = requirePlanAPISession(*consoleFlag)
144 printUsing(out, sess)
145 }
146 var res accountPlanReply
147 if req == nil {
148 res, err = sess.accountPlan(ctx, acct)
149 } else {
150 res, err = sess.setAccountPlan(ctx, acct, *req)
151 }
152 if err != nil {
153 return err
154 }
155 printAccountPlan(out, res)
156 return nil
157}
158
159func (s *apiSession) accountPlan(ctx context.Context, account string) (accountPlanReply, error) {
160 status, body, err := s.getJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/plan")
161 return decodeAccountPlanReply(status, body, err)
162}
163
164func (s *apiSession) setAccountPlan(ctx context.Context, account string, req setAccountPlanRequest) (accountPlanReply, error) {
165 status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/plan", req)
166 return decodeAccountPlanReply(status, body, err)
167}
168
169func decodeAccountPlanReply(status int, body []byte, err error) (accountPlanReply, error) {
170 if err != nil {
171 return accountPlanReply{}, err
172 }
173 if status != http.StatusOK {
174 return accountPlanReply{}, apiErrorFromBody(status, body)
175 }
176 var out accountPlanReply
177 if err := json.Unmarshal(body, &out); err != nil {
178 return accountPlanReply{}, fmt.Errorf("accounts plan: unreadable response: %w", err)
179 }
180 return out, nil
181}
182
183func printAccountPlan(w io.Writer, r accountPlanReply) {
184 fmt.Fprintln(w, colorFor(w).ok(fmt.Sprintf(cliPlanCopyHeading, r.Account, r.Plan)))
185 if len(r.Packs) > 0 {
186 fmt.Fprintf(w, cliPlanCopyPacks+"\n", formatPacks(r.Packs))
187 }
188 switch r.Source {
189 case "operator":
190 fmt.Fprintf(w, cliPlanCopySourceOperator+"\n", r.UpdatedBy, r.Reason)
191 case "sponsored":
192 fmt.Fprintf(w, cliPlanCopySourceSponsored+"\n", r.UpdatedBy, r.Reason)
193 case "billing":
194 fmt.Fprintln(w, cliPlanCopySourceBilling)
195 default:
196 fmt.Fprintln(w, cliPlanCopySourceDefault)
197 }
198 if r.Revision > 0 && r.PublishedRevision < r.Revision {
199 mutedf(w, "%s", cliPlanCopyUnpublished)
200 }
201 if r.Limits == nil {
202 fmt.Fprintf(w, cliPlanCopyLimitsError+"\n", r.LimitsError)
203 return
204 }
205 l := r.Limits
206 fmt.Fprintln(w)
207 fmt.Fprintln(w, cliPlanCopyLimitsHeading)
208 storage := planBytes(l.StorageBytes)
209 if !l.StorageSelfServe {
210 storage += ", " + cliPlanCopyStorageOperator
211 }
212 history := cliPlanCopyLatestOnly
213 if l.HistoryDays > 0 {
214 history = plural(l.HistoryDays, "day")
215 }
216 rows := [][2]string{
217 {cliPlanCopyLimitRunning, strconv.Itoa(l.RunningThings)},
218 {cliPlanCopyLimitSidecars, strconv.Itoa(l.SidecarsPerApp)},
219 {cliPlanCopyLimitIdle, humanSeconds(l.IdleSeconds)},
220 {cliPlanCopyLimitMemory, l.MemoryTier},
221 {cliPlanCopyLimitAwake, strconv.Itoa(l.AwakeHoursMonth)},
222 {cliPlanCopyLimitDisk, planBytes(l.DiskBytes)},
223 {cliPlanCopyLimitStorage, storage},
224 {cliPlanCopyLimitEmail, strconv.Itoa(l.EmailPerDay)},
225 {cliPlanCopyLimitBandwidth, planBytes(l.BandwidthBytesMonth)},
226 {cliPlanCopyLimitDomains, allowed(l.CustomDomains)},
227 {cliPlanCopyLimitFleets, allowed(l.Fleets)},
228 {cliPlanCopyLimitSchedule, humanSeconds(l.MinScheduleSeconds)},
229 {cliPlanCopyLimitHistory, history},
230 }
231 width := 0
232 for _, row := range rows {
233 width = max(width, len(row[0]))
234 }
235 for _, row := range rows {
236 fmt.Fprintf(w, " %-*s %s\n", width, row[0], row[1])
237 }
238}
239
240func allowed(b bool) string {
241 if b {
242 return cliPlanCopyAllowed
243 }
244 return cliPlanCopyNotAllowed
245}
246
247func plural(n int, unit string) string {
248 if n == 1 {
249 return "1 " + unit
250 }
251 return strconv.Itoa(n) + " " + unit + "s"
252}
253
254// humanSeconds renders a duration in the largest whole unit.
255func humanSeconds(s int) string {
256 d := time.Duration(s) * time.Second
257 switch {
258 case s == 0:
259 return "0 seconds"
260 case d%time.Hour == 0:
261 return plural(int(d/time.Hour), "hour")
262 case d%time.Minute == 0:
263 return plural(int(d/time.Minute), "minute")
264 default:
265 return plural(s, "second")
266 }
267}
268
269// planBytes renders a size in GB, or MB below one GB (binary units, as
270// the catalog's numbers are).
271func planBytes(b int64) string {
272 const mb, gb = 1 << 20, 1 << 30
273 if b >= gb {
274 if b%gb == 0 {
275 return strconv.FormatInt(b/gb, 10) + " GB"
276 }
277 return strconv.FormatFloat(float64(b)/gb, 'f', 1, 64) + " GB"
278 }
279 return strconv.FormatInt(b/mb, 10) + " MB"
280}
281
282// requirePlanAPISession is the console session the plan door needs. Plans
283// live in the console's database, so direct bucket mode has no equivalent.
284func requirePlanAPISession(consoleFlag string) *apiSession {
285 sess, err := resolveMode(consoleFlag)
286 if err != nil {
287 log.Fatal(err)
288 }
289 if sess == nil {
290 log.Fatal(cliPlanCopyNeedsConsole)
291 }
292 return sess
293}
294