package main // `carlos accounts plan`: a deployment operator reads or sets an account's // plan, packs and sponsored limits through the console's plan door // (internal/console/apiaccountplan.go; plans-caps-packs spec §4, §8.7). import ( "bytes" "context" "encoding/json" "errors" "flag" "fmt" "io" "log" "net/http" "net/url" "os" "slices" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/plans" ) // accountPlanReply and setAccountPlanRequest mirror the console door's wire // shapes. type accountPlanReply struct { Account string `json:"account"` CatalogID string `json:"catalog_id"` Plan string `json:"plan"` Packs map[string]int `json:"packs"` Source string `json:"source"` Sponsored *plans.Limits `json:"sponsored,omitempty"` Reason string `json:"reason,omitempty"` UpdatedBy string `json:"updated_by,omitempty"` Revision int64 `json:"revision"` PublishedRevision int64 `json:"published_revision"` Limits *plans.Limits `json:"limits,omitempty"` LimitsError string `json:"limits_error,omitempty"` } type setAccountPlanRequest struct { Plan string `json:"plan"` Packs map[string]int `json:"packs,omitempty"` Sponsored *plans.Limits `json:"sponsored,omitempty"` Reason string `json:"reason"` } // packFlags collects repeated --pack name=quantity values. type packFlags map[string]int func (p *packFlags) String() string { if p == nil { return "" } return formatPacks(*p) } func (p *packFlags) Set(v string) error { name, qty, ok := strings.Cut(v, "=") n, err := strconv.Atoi(qty) if !ok || name == "" || err != nil { return fmt.Errorf(cliPlanCopyPackFormat, v) } if *p == nil { *p = packFlags{} } (*p)[name] = n return nil } func formatPacks(p map[string]int) string { names := make([]string, 0, len(p)) for name := range p { names = append(names, name) } slices.Sort(names) parts := make([]string, 0, len(names)) for _, name := range names { parts = append(parts, fmt.Sprintf("%s %d", name, p[name])) } return strings.Join(parts, ", ") } func runAccountsPlan(args []string) { if err := runAccountsPlanWithSession(context.Background(), nil, args, stdout); err != nil { if errors.Is(err, flag.ErrHelp) { return } log.Fatalf("accounts plan: %v", err) } } func runAccountsPlanWithSession(ctx context.Context, sess *apiSession, args []string, out io.Writer) error { fs := flag.NewFlagSet("accounts plan", flag.ContinueOnError) fs.SetOutput(out) account := fs.String("account", "", cliPlanCopyFlagAccount) set := fs.String("set", "", cliPlanCopyFlagSet) var packs packFlags fs.Var(&packs, "pack", cliPlanCopyFlagPack) sponsored := fs.String("sponsored", "", cliPlanCopyFlagSponsored) reason := fs.String("reason", "", cliPlanCopyFlagReason) consoleFlag := consoleOption(fs, "console origin to use") fs.Usage = flagUsage(fs, "") if err := fs.Parse(args); err != nil { return err } if fs.NArg() != 0 { return fmt.Errorf("unexpected argument %q", fs.Arg(0)) } acct, err := explicitStoreAccount(*account) if err != nil { return err } var req *setAccountPlanRequest if *set == "" { if len(packs) > 0 || *sponsored != "" || *reason != "" { return errors.New(cliPlanCopyNeedsSet) } } else { if strings.TrimSpace(*reason) == "" { return errors.New(cliPlanCopyReasonRequired) } req = &setAccountPlanRequest{Plan: *set, Packs: packs, Reason: *reason} if *sponsored != "" { b, err := os.ReadFile(*sponsored) if err == nil { // Strict: a misspelt limit would otherwise vanish and lower // the account's limits. req.Sponsored = &plans.Limits{} dec := json.NewDecoder(bytes.NewReader(b)) dec.DisallowUnknownFields() err = dec.Decode(req.Sponsored) } if err != nil { return fmt.Errorf(cliPlanCopySponsoredFile, *sponsored, err) } } } if sess == nil { sess = requirePlanAPISession(*consoleFlag) printUsing(out, sess) } var res accountPlanReply if req == nil { res, err = sess.accountPlan(ctx, acct) } else { res, err = sess.setAccountPlan(ctx, acct, *req) } if err != nil { return err } printAccountPlan(out, res) return nil } func (s *apiSession) accountPlan(ctx context.Context, account string) (accountPlanReply, error) { status, body, err := s.getJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/plan") return decodeAccountPlanReply(status, body, err) } func (s *apiSession) setAccountPlan(ctx context.Context, account string, req setAccountPlanRequest) (accountPlanReply, error) { status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/plan", req) return decodeAccountPlanReply(status, body, err) } func decodeAccountPlanReply(status int, body []byte, err error) (accountPlanReply, error) { if err != nil { return accountPlanReply{}, err } if status != http.StatusOK { return accountPlanReply{}, apiErrorFromBody(status, body) } var out accountPlanReply if err := json.Unmarshal(body, &out); err != nil { return accountPlanReply{}, fmt.Errorf("accounts plan: unreadable response: %w", err) } return out, nil } func printAccountPlan(w io.Writer, r accountPlanReply) { fmt.Fprintln(w, colorFor(w).ok(fmt.Sprintf(cliPlanCopyHeading, r.Account, r.Plan))) if len(r.Packs) > 0 { fmt.Fprintf(w, cliPlanCopyPacks+"\n", formatPacks(r.Packs)) } switch r.Source { case "operator": fmt.Fprintf(w, cliPlanCopySourceOperator+"\n", r.UpdatedBy, r.Reason) case "sponsored": fmt.Fprintf(w, cliPlanCopySourceSponsored+"\n", r.UpdatedBy, r.Reason) case "billing": fmt.Fprintln(w, cliPlanCopySourceBilling) default: fmt.Fprintln(w, cliPlanCopySourceDefault) } if r.Revision > 0 && r.PublishedRevision < r.Revision { mutedf(w, "%s", cliPlanCopyUnpublished) } if r.Limits == nil { fmt.Fprintf(w, cliPlanCopyLimitsError+"\n", r.LimitsError) return } l := r.Limits fmt.Fprintln(w) fmt.Fprintln(w, cliPlanCopyLimitsHeading) storage := planBytes(l.StorageBytes) if !l.StorageSelfServe { storage += ", " + cliPlanCopyStorageOperator } history := cliPlanCopyLatestOnly if l.HistoryDays > 0 { history = plural(l.HistoryDays, "day") } rows := [][2]string{ {cliPlanCopyLimitRunning, strconv.Itoa(l.RunningThings)}, {cliPlanCopyLimitSidecars, strconv.Itoa(l.SidecarsPerApp)}, {cliPlanCopyLimitIdle, humanSeconds(l.IdleSeconds)}, {cliPlanCopyLimitMemory, l.MemoryTier}, {cliPlanCopyLimitAwake, strconv.Itoa(l.AwakeHoursMonth)}, {cliPlanCopyLimitDisk, planBytes(l.DiskBytes)}, {cliPlanCopyLimitStorage, storage}, {cliPlanCopyLimitEmail, strconv.Itoa(l.EmailPerDay)}, {cliPlanCopyLimitBandwidth, planBytes(l.BandwidthBytesMonth)}, {cliPlanCopyLimitDomains, allowed(l.CustomDomains)}, {cliPlanCopyLimitFleets, allowed(l.Fleets)}, {cliPlanCopyLimitSchedule, humanSeconds(l.MinScheduleSeconds)}, {cliPlanCopyLimitHistory, history}, } width := 0 for _, row := range rows { width = max(width, len(row[0])) } for _, row := range rows { fmt.Fprintf(w, " %-*s %s\n", width, row[0], row[1]) } } func allowed(b bool) string { if b { return cliPlanCopyAllowed } return cliPlanCopyNotAllowed } func plural(n int, unit string) string { if n == 1 { return "1 " + unit } return strconv.Itoa(n) + " " + unit + "s" } // humanSeconds renders a duration in the largest whole unit. func humanSeconds(s int) string { d := time.Duration(s) * time.Second switch { case s == 0: return "0 seconds" case d%time.Hour == 0: return plural(int(d/time.Hour), "hour") case d%time.Minute == 0: return plural(int(d/time.Minute), "minute") default: return plural(s, "second") } } // planBytes renders a size in GB, or MB below one GB (binary units, as // the catalog's numbers are). func planBytes(b int64) string { const mb, gb = 1 << 20, 1 << 30 if b >= gb { if b%gb == 0 { return strconv.FormatInt(b/gb, 10) + " GB" } return strconv.FormatFloat(float64(b)/gb, 'f', 1, 64) + " GB" } return strconv.FormatInt(b/mb, 10) + " MB" } // requirePlanAPISession is the console session the plan door needs. Plans // live in the console's database, so direct bucket mode has no equivalent. func requirePlanAPISession(consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal(cliPlanCopyNeedsConsole) } return sess }