-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add billing plan info during build or failure to build/launch satelli…
…te (#3494) This PR will add the following: - During a build, the user will see the number of build mintues used thus far. - If a build command fails due to missing build minutes - it will display proper message to verify or upgrade the plan to get more minutes. - If a sat launch command fails due to number of satellites over the max, it will display a proper message to verify/upgrade/remove existing satellites/contact support (fix for earthly/earthly#3425) - Also adding a new (experimental) command to view billing plan info (`earthly billing --org <my-org> view`)
- Loading branch information
Showing
18 changed files
with
407 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
package billing | ||
|
||
import ( | ||
"fmt" | ||
"time" | ||
|
||
"github.com/earthly/cloud-api/billing" | ||
"google.golang.org/protobuf/proto" | ||
) | ||
|
||
// AddPlanInfo sets the billing plan to make it available to retrieve later with Plan() | ||
func AddPlanInfo(plan *billing.BillingPlan, usedBuildMinutes time.Duration) { | ||
billingTracker.AddPlanInfo(plan, usedBuildMinutes) | ||
} | ||
|
||
// Plan returns a copy of the billing plan which should have been set earlier by AddPlanInfo | ||
func Plan() *billing.BillingPlan { | ||
if billingTracker.plan != nil { | ||
return proto.Clone(billingTracker.plan).(*billing.BillingPlan) | ||
} | ||
return nil | ||
} | ||
|
||
// UsedBuildTime returns the used build time(duration) of org referenced by the earthly command | ||
func UsedBuildTime() time.Duration { | ||
return billingTracker.usedBuildTime | ||
} | ||
|
||
// GetBillingURL returns the billing url based on the given host and org names | ||
func GetBillingURL(hostName, orgName string) string { | ||
return fmt.Sprintf("%s/%s/settings", hostName, orgName) | ||
} | ||
|
||
// GetUpgradeURL returns the billing url based on the given host and org names | ||
func GetUpgradeURL(hostName, orgName string) string { | ||
return fmt.Sprintf("%s/%s/upgrade-now", hostName, orgName) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package billing | ||
|
||
import ( | ||
"github.com/earthly/cloud-api/billing" | ||
"time" | ||
) | ||
|
||
type tracker struct { | ||
plan *billing.BillingPlan | ||
usedBuildTime time.Duration | ||
} | ||
|
||
var billingTracker = tracker{} | ||
|
||
func (bt *tracker) AddPlanInfo(plan *billing.BillingPlan, usedBuildTime time.Duration) { | ||
bt.plan = plan | ||
bt.usedBuildTime = usedBuildTime | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package cloud | ||
|
||
import ( | ||
"context" | ||
"github.com/earthly/cloud-api/billing" | ||
|
||
"github.com/pkg/errors" | ||
) | ||
|
||
func (c *Client) GetBillingPlan(ctx context.Context, org string) (*billing.GetBillingPlanResponse, error) { | ||
response, err := c.billing.GetBillingPlan(c.withAuth(ctx), &billing.GetBillingPlanRequest{ | ||
OrgName: org, | ||
}) | ||
if err != nil { | ||
return nil, errors.Wrap(err, "failed get billing plan") | ||
} | ||
return response, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,7 @@ import ( | |
"strings" | ||
"time" | ||
|
||
billingpb "github.com/earthly/cloud-api/billing" | ||
"github.com/earthly/cloud-api/logstream" | ||
"github.com/fatih/color" | ||
"github.com/moby/buildkit/util/grpcerrors" | ||
|
@@ -18,6 +19,7 @@ import ( | |
|
||
"github.com/earthly/earthly/analytics" | ||
"github.com/earthly/earthly/ast/hint" | ||
"github.com/earthly/earthly/billing" | ||
"github.com/earthly/earthly/buildkitd" | ||
"github.com/earthly/earthly/cmd/earthly/common" | ||
"github.com/earthly/earthly/cmd/earthly/helper" | ||
|
@@ -27,12 +29,16 @@ import ( | |
"github.com/earthly/earthly/util/errutil" | ||
"github.com/earthly/earthly/util/params" | ||
"github.com/earthly/earthly/util/reflectutil" | ||
"github.com/earthly/earthly/util/stringutil" | ||
) | ||
|
||
var ( | ||
runExitCodeRegex = regexp.MustCompile(`did not complete successfully: exit code: [^0][0-9]*($|[\n\t]+in\s+.*?\+.+)`) | ||
notFoundRegex = regexp.MustCompile(`("[^"]*"): not found`) | ||
qemuExitCodeRegex = regexp.MustCompile(`process "/dev/.buildkit_qemu_emulator.*?did not complete successfully: exit code: 255$`) | ||
runExitCodeRegex = regexp.MustCompile(`did not complete successfully: exit code: [^0][0-9]*($|[\n\t]+in\s+.*?\+.+)`) | ||
notFoundRegex = regexp.MustCompile(`("[^"]*"): not found`) | ||
qemuExitCodeRegex = regexp.MustCompile(`process "/dev/.buildkit_qemu_emulator.*?did not complete successfully: exit code: 255$`) | ||
buildMinutesRegex = regexp.MustCompile(`(?P<msg>used \d+ of \d+ allowed minutes in current plan) {reqID: .*?}`) | ||
maxSatellitesRegex = regexp.MustCompile(`(?P<msg>plan only allows \d+ satellites in use at one time) {reqID: .*?}`) | ||
requestIDRegex = regexp.MustCompile(`(?P<msg>.*?) {reqID: .*?}`) | ||
) | ||
|
||
func (app *EarthlyApp) Run(ctx context.Context, console conslogging.ConsoleLogger, startTime time.Time, lastSignal os.Signal) int { | ||
|
@@ -244,6 +250,55 @@ func (app *EarthlyApp) run(ctx context.Context, args []string) int { | |
"You can login using the command:\n"+ | ||
" docker login%s", registryName, registryHost) | ||
return 1 | ||
case grpcErrOK && grpcErr.Code() == codes.PermissionDenied && buildMinutesRegex.MatchString(grpcErr.Message()): | ||
msg := grpcErr.Message() | ||
matches, _ := stringutil.NamedGroupMatches(msg, buildMinutesRegex) | ||
if len(matches["msg"]) > 0 { | ||
msg = matches["msg"][0] | ||
} | ||
tier := billing.Plan().GetTier() | ||
msg = fmt.Sprintf("%s (%s)", msg, stringutil.Title(tier)) | ||
app.BaseCLI.Console().VerboseWarnf(err.Error()) | ||
app.BaseCLI.Logbus().Run().SetGenericFatalError(time.Now(), logstream.FailureType_FAILURE_TYPE_OTHER, msg) | ||
switch tier { | ||
case billingpb.BillingPlan_TIER_UNKNOWN: | ||
app.BaseCLI.Console().DebugPrintf("failed to get billing plan tier\n") | ||
case billingpb.BillingPlan_TIER_LIMITED_FREE_TIER: | ||
app.BaseCLI.Console().HelpPrintf("Visit your organization settings to verify your account\nand get 6000 free build minutes per month: %s\n", billing.GetBillingURL(app.BaseCLI.CIHost(), app.BaseCLI.OrgName())) | ||
case billingpb.BillingPlan_TIER_FREE_TIER: | ||
app.BaseCLI.Console().HelpPrintf("Visit your organization settings to upgrade your account: %s\n", billing.GetUpgradeURL(app.BaseCLI.CIHost(), app.BaseCLI.OrgName())) | ||
} | ||
return 1 | ||
case grpcErrOK && grpcErr.Code() == codes.PermissionDenied && maxSatellitesRegex.MatchString(grpcErr.Message()): | ||
msg := grpcErr.Message() | ||
matches, _ := stringutil.NamedGroupMatches(msg, maxSatellitesRegex) | ||
if len(matches["msg"]) > 0 { | ||
msg = matches["msg"][0] | ||
} | ||
tier := billing.Plan().GetTier() | ||
msg = fmt.Sprintf("%s %s", stringutil.Title(tier), msg) | ||
app.BaseCLI.Console().VerboseWarnf(err.Error()) | ||
app.BaseCLI.Logbus().Run().SetGenericFatalError(time.Now(), logstream.FailureType_FAILURE_TYPE_OTHER, msg) | ||
switch tier { | ||
case billingpb.BillingPlan_TIER_UNKNOWN: | ||
app.BaseCLI.Console().DebugPrintf("failed to get billing plan tier\n") | ||
case billingpb.BillingPlan_TIER_LIMITED_FREE_TIER: | ||
app.BaseCLI.Console().HelpPrintf("Visit your organization settings to verify your account\nfor an option to launch more satellites: %s\nor consider removing one of your existing satellites (`earthly sat rm <satellite-name>`)", billing.GetBillingURL(app.BaseCLI.CIHost(), app.BaseCLI.OrgName())) | ||
case billingpb.BillingPlan_TIER_FREE_TIER: | ||
app.BaseCLI.Console().HelpPrintf("Visit your organization settings to upgrade your account for an option to launch more satellites: %s.\nAlternatively consider removing one of your existing satellites (`earthly sat rm <satellite-name>`)\nor contact support at [email protected] to potentially increase your satellites' limit", billing.GetUpgradeURL(app.BaseCLI.CIHost(), app.BaseCLI.OrgName())) | ||
default: | ||
app.BaseCLI.Console().HelpPrintf("Consider removing one of your existing satellites (`earthly sat rm <satellite-name>`)\nor contact support at [email protected] to potentially increase your satellites' limit") | ||
} | ||
return 1 | ||
case grpcErrOK && grpcErr.Code() == codes.PermissionDenied && requestIDRegex.MatchString(grpcErr.Message()): | ||
msg := grpcErr.Message() | ||
matches, _ := stringutil.NamedGroupMatches(msg, requestIDRegex) | ||
if len(matches["msg"]) > 0 { | ||
msg = matches["msg"][0] | ||
} | ||
app.BaseCLI.Console().VerboseWarnf(err.Error()) | ||
app.BaseCLI.Logbus().Run().SetGenericFatalError(time.Now(), logstream.FailureType_FAILURE_TYPE_OTHER, msg) | ||
return 1 | ||
case grpcErrOK && grpcErr.Code() != codes.Canceled: | ||
app.BaseCLI.Console().VerboseWarnf(errorWithPrefix(err.Error())) | ||
if !strings.Contains(grpcErr.Message(), "transport is closing") { | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package base | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"github.com/earthly/earthly/billing" | ||
"github.com/earthly/earthly/cloud" | ||
"time" | ||
) | ||
|
||
// CollectBillingInfo will collect billing plan info from billing service and make it available for other commands | ||
// to use later | ||
func (cli *CLI) CollectBillingInfo(ctx context.Context, cloudClient *cloud.Client, orgName string) error { | ||
if !cloudClient.IsLoggedIn(ctx) { | ||
return nil | ||
} | ||
resp, err := cloudClient.GetBillingPlan(ctx, orgName) | ||
if err != nil { | ||
return fmt.Errorf("failed to get billing plan: %w", err) | ||
} | ||
billing.AddPlanInfo(resp.GetPlan(), time.Second*time.Duration(resp.GetBillingCycleUsedBuildSeconds())) | ||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
package subcmd | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"strings" | ||
"text/tabwriter" | ||
|
||
"github.com/earthly/earthly/billing" | ||
"github.com/earthly/earthly/cmd/earthly/helper" | ||
"github.com/earthly/earthly/util/stringutil" | ||
|
||
"github.com/pkg/errors" | ||
"github.com/urfave/cli/v2" | ||
) | ||
|
||
type Billing struct { | ||
cli CLI | ||
} | ||
|
||
func NewBilling(cli CLI) *Billing { | ||
return &Billing{ | ||
cli: cli, | ||
} | ||
} | ||
|
||
func (a *Billing) Cmds() []*cli.Command { | ||
return []*cli.Command{ | ||
{ | ||
Name: "billing", | ||
Aliases: []string{"bill"}, | ||
Description: `*experimental* View Earthly billing info.`, | ||
Usage: `*experimental* View Earthly billing info`, | ||
UsageText: "earthly billing (view)", | ||
Flags: []cli.Flag{ | ||
&cli.StringFlag{ | ||
Name: "org", | ||
EnvVars: []string{"EARTHLY_ORG"}, | ||
Usage: "The name of the Earthly organization to to view billing info for", | ||
Required: false, | ||
Destination: &a.cli.Flags().OrgName, | ||
}, | ||
}, | ||
Subcommands: []*cli.Command{ | ||
{ | ||
Name: "view", | ||
Usage: "View billing information for the specified organization", | ||
Description: "View billing information for the specified organization.", | ||
UsageText: "earthly billing [--org <organization-name>] view", | ||
Action: a.actionView, | ||
}, | ||
}, | ||
}, | ||
} | ||
} | ||
|
||
func (a *Billing) actionView(cliCtx *cli.Context) error { | ||
a.cli.SetCommandName("billingView") | ||
|
||
cloudClient, err := helper.NewCloudClient(a.cli) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
if !cloudClient.IsLoggedIn(cliCtx.Context) { | ||
return errors.New("user must be logged in") | ||
} | ||
|
||
orgName := a.cli.OrgName() | ||
|
||
if orgName == "" { | ||
return errors.New("organization name must be specified") | ||
} | ||
if err := a.cli.CollectBillingInfo(cliCtx.Context, cloudClient, orgName); err != nil { | ||
return fmt.Errorf("failed to get billing info: %w", err) | ||
} | ||
|
||
plan := billing.Plan() | ||
allowedArches := strings.Join(stringutil.EnumToStringArray(plan.GetAllowedArchs(), stringutil.Lower), ",") | ||
allowedInstances := strings.Join(stringutil.EnumToStringArray(plan.GetAllowedInstances(), stringutil.Lower), ",") | ||
|
||
w := new(tabwriter.Writer) | ||
buf := new(bytes.Buffer) | ||
w.Init(buf, 0, 8, 0, '\t', 0) | ||
fmt.Fprintf(w, "Tier:\t%s\n", stringutil.Title(plan.GetTier())) | ||
fmt.Fprintf(w, "Plan Type:\t%s\n", stringutil.Title(plan.GetType())) | ||
fmt.Fprintf(w, "Started At:\t%s\n", plan.GetStartedAt().AsTime().UTC().Format("January 2, 2006")) | ||
fmt.Fprintf(w, "Used Build Time:\t%s (%d minutes)\n", billing.UsedBuildTime(), int(billing.UsedBuildTime().Minutes())) | ||
fmt.Fprintf(w, "Max Builds Minutes:\t%s\n", valueOrUnlimited(plan.GetMaxBuildMinutes())) | ||
fmt.Fprintf(w, "Max Minutes per Build:\t%d\n", plan.GetMaxMinutesPerBuild()) | ||
fmt.Fprintf(w, "Included Minutes:\t%d\n", plan.GetIncludedMinutes()) | ||
fmt.Fprintf(w, "Max Satellites:\t%d\n", plan.GetMaxSatellites()) | ||
fmt.Fprintf(w, "Max Hours Cache Retention:\t%s\n", valueOrUnlimited(plan.GetMaxHoursCacheRetention())) | ||
fmt.Fprintf(w, "Allowed Architectures:\t%s\n", allowedArches) | ||
fmt.Fprintf(w, "Allowed Instances:\t%s\n", allowedInstances) | ||
fmt.Fprintf(w, "Default Instance:\t%s\n", stringutil.Lower(plan.GetDefaultInstanceType())) | ||
w.Flush() | ||
a.cli.Console().Printf(buf.String()) | ||
|
||
return nil | ||
} | ||
|
||
func valueOrUnlimited(value int32) string { | ||
if value != 0 { | ||
return fmt.Sprintf("%d", value) | ||
} | ||
return "Unlimited" | ||
} |
Oops, something went wrong.