Skip to content

Commit

Permalink
Add billing plan info during build or failure to build/launch satelli…
Browse files Browse the repository at this point in the history
…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
idodod authored Nov 17, 2023
1 parent 87185f2 commit 2928bb8
Show file tree
Hide file tree
Showing 18 changed files with 407 additions and 4 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to [Earthly](https://github.com/earthly/earthly) will be doc

### Added
- A new experimental `earthly --exec-stats` flag, which displays per-target execution stats such as total CPU and memory usage.
- A new experimental `earthly billing view` command to get information about the organization billing plan.
- Messages informing used build minutes during a build.

### Fixed
- Remove redundant verbose error messages that were not different from messages that were already being printed.
Expand All @@ -20,6 +22,8 @@ All notable changes to [Earthly](https://github.com/earthly/earthly) will be doc
- A successful authentication with an auth token will display a warning with time left before token expires if it's 14 days or under.
- The command `earthly registry` will attempt to use the selected org if no org is specified.
- Clarify error messages when failing to pass secrets to a build.
- provide information on how to get more build minutes when a build fails due to missing minutes.
- Provide information on how to increase the max number of allowed satellites when failing to launch a satellite.

## v0.7.21 - 2023-10-24

Expand Down
2 changes: 1 addition & 1 deletion Earthfile
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ code:
RUN go mod download
END
COPY ./ast/parser+parser/*.go ./ast/parser/
COPY --dir analytics autocomplete buildcontext builder logbus cleanup cloud cmd config conslogging debugger \
COPY --dir analytics autocomplete billing buildcontext builder logbus cleanup cloud cmd config conslogging debugger \
dockertar docker2earthly domain features internal outmon slog states util variables regproxy ./
COPY --dir buildkitd/buildkitd.go buildkitd/settings.go buildkitd/certificates.go buildkitd/
COPY --dir earthfile2llb/*.go earthfile2llb/
Expand Down
37 changes: 37 additions & 0 deletions billing/billing.go
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)
}
18 changes: 18 additions & 0 deletions billing/billing_tracker.go
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
}
18 changes: 18 additions & 0 deletions cloud/billing.go
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
}
3 changes: 3 additions & 0 deletions cloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/earthly/cloud-api/analytics"
"github.com/earthly/cloud-api/askv"
"github.com/earthly/cloud-api/billing"
"github.com/earthly/cloud-api/compute"
"github.com/earthly/cloud-api/logstream"
"github.com/earthly/cloud-api/pipelines"
Expand Down Expand Up @@ -65,6 +66,7 @@ type Client struct {
logstreamBackoff time.Duration
analytics analytics.AnalyticsClient
askv askv.AskvClient
billing billing.BillingClient
requestID string
installationName string
logstreamAddressOverride string
Expand Down Expand Up @@ -141,6 +143,7 @@ func NewClient(httpAddr, grpcAddr string, useInsecure bool, agentSockPath, authC
c.compute = compute.NewComputeClient(conn)
c.analytics = analytics.NewAnalyticsClient(conn)
c.askv = askv.NewAskvClient(conn)
c.billing = billing.NewBillingClient(conn)

logstreamAddr := grpcAddr
if c.logstreamAddressOverride != "" {
Expand Down
61 changes: 58 additions & 3 deletions cmd/earthly/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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") {
Expand Down
23 changes: 23 additions & 0 deletions cmd/earthly/base/billing.go
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
}
108 changes: 108 additions & 0 deletions cmd/earthly/subcmd/billing_cmds.go
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"
}
Loading

0 comments on commit 2928bb8

Please sign in to comment.