Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,7 @@ $tf/
# Custom
*.zip
jwt.txt

# CLI
cmd/usage/usage
cmd/usage/*.json
47 changes: 47 additions & 0 deletions cmd/usage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Billing and Usage CLI

## Building the CLI

```shell
go build
```

## Using the CLI

To see the CLI options:

```shell
./usage -h
```

To produce a JSON report of usage:

1. Create an SSH tunnel to the billing database
1. Create a `.env` with the database connection information and other necessary variables

```env
# fill with your tunnel's connection parameters
export PGHOST=localhost
export PGPORT=
export PGDATABASE=
export PGUSER=
export PGPASSWORD=

# blank values are fine
export CF_API_URL=
export CF_CLIENT_ID=
export CF_CLIENT_SECRET=
export OIDC_ISSUER=
```

1. Set the environment variables in your shell:

```shell
source .env
```

1. Run the usage CLI and stream output to a JSON file:

```shell
./usage -cname 'some customer' | jq | tee report.json
```
39 changes: 29 additions & 10 deletions cmd/usage/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main

import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
Expand Down Expand Up @@ -114,9 +115,10 @@ func run(ctx context.Context, out io.Writer) error {
logger.Debug("run: got usage", "usage", nodes)

logger.Debug("run: making report")
slices.Reverse(nodes)
report := NewReporter()
var link ReportLinker

slices.Reverse(nodes)
for i, n := range nodes {
uCreds, err := n.TotalMicrocredits.Int64Value()
if err != nil {
Expand All @@ -131,10 +133,20 @@ func run(ctx context.Context, out io.Writer) error {

p := nodes[i-1]

paths := []string{}
levels := []pgtype.Text{n.L1, n.L2, n.L3, n.L4}
for _, l := range levels {
if !l.Valid {
break
}
paths = append(paths, l.String)
}
path := strings.Join(paths, ".")

// org
if n.L1.Valid && !n.L2.Valid {
// org is always linked to root
link, err = report.SetNode(report, uCredsInt, Org, n.L1.String, "")
link, err = report.SetNode(report, uCredsInt, Org, n.L1.String, path)
if err != nil {
return fmtErr(ErrCreatingReport, err)
}
Expand All @@ -150,7 +162,7 @@ func run(ctx context.Context, out io.Writer) error {
} else if p.L2.Valid { // go space/g > org
link = link.getParent()
}
link, err = report.SetNode(link, uCredsInt, Space, n.L2.String, "")
link, err = report.SetNode(link, uCredsInt, Space, n.L2.String, path)
if err != nil {
return fmtErr(ErrCreatingReport, err)
}
Expand All @@ -164,37 +176,44 @@ func run(ctx context.Context, out io.Writer) error {
} else if p.L3.Valid { // go space/s > space/g
link = link.getParent()
}
link, err = report.SetNode(link, uCredsInt, Space, n.L3.String, "")
link, err = report.SetNode(link, uCredsInt, Space, n.L3.String, path)
if err != nil {
return fmtErr(ErrCreatingReport, err)
}
continue
}

// TODO: we don't currently have the individual meters attached here
// - There aren't currently more than one meter per resource anyway
// - See cloud-gov/cg-interface/cg-billing#89
if n.L4.Valid {
if p.L4.Valid { // go from leaf > space/s
link = link.getParent()
}

var k Kind
var kind Kind
if isApp(n.L4) {
k = CfApp
kind = CfApp
} else if isService(n.L4) {
k = CfSvc
kind = CfSvc
}

link, err = report.SetNode(link, uCredsInt, k, n.L4.String, "")
link, err = report.SetNode(link, uCredsInt, kind, n.L4.String, path)
if err != nil {
return fmtErr(ErrCreatingReport, err)
}
continue
}

logger.Debug("weirds gotten in report", "node", n)

}
logger.Debug("run: got report", "report", report)

enc := json.NewEncoder(out)
if err := enc.Encode(report); err != nil {
return fmt.Errorf("error encoding report into JSON: %w", err)
}

return err
}

Expand Down Expand Up @@ -240,7 +259,7 @@ func buildQuery() string {
func getCustomerID(ctx context.Context, q dbx.Querier) (id pgtype.UUID, err error) {
r := getRawCID()
if r != "" {
return dbx.UtilUUID(r), nil
return dbx.ToUUID(r), nil
}

cs, e := q.GetCustomersByName(ctx, cname)
Expand Down
15 changes: 11 additions & 4 deletions cmd/usage/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,28 @@ type Kind string
const (
Org Kind = "cf_org"
Space Kind = "cf_space"
CfApp Kind = "meter::cfapps"
CfSvc Kind = "meter::cfservices"
CfApp Kind = "cf_app"
CfSvc Kind = "cf_service"
)

var meterReg = regexp.MustCompile(`^meter::(\w+)`)

func (k Kind) String() string {
if k.isMeter() {
return k.meterName()
}
return string(k)
}

func (k Kind) StringRaw() string {
return string(k)
}

func (k Kind) isMeter() bool {
res := meterReg.MatchString(k.String())
res := meterReg.MatchString(k.StringRaw())
return res
}

func (k Kind) meterName() string {
return meterReg.FindStringSubmatch(k.String())[0]
return meterReg.FindStringSubmatch(k.StringRaw())[0]
}
55 changes: 32 additions & 23 deletions cmd/usage/report_detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,29 @@ import "fmt"

type (
Report struct {
UCreditSum int
UCreditSum int `json:"microcredit_sum"`
ReportLink
ReportWriter
ReportWriter `json:"-"`
}
ReportNode struct {
Path string
Slug string
Kind string
UCreditSum int
Meters []*ReportLeaf
UCreditSum int `json:"microcredit_sum"`
Meters []*ReportLeaf `json:"meters,omitempty"`
ReportLink
}
ReportLeaf struct {
Kind string
UCreditUse int
UCreditUse int `json:"microcredit_use"`
ReportLink
}
ReportLink struct {
parent ReportLinker
root *Report
Nodes []*ReportNode
ReportLinker
ReportLinker `json:"-"`

root *Report `json:"-"`
parent ReportLinker `json:"-"`

Slug string `json:"slug,omitempty"`
Path string `json:"path,omitempty"`
Kind string `json:"kind,omitempty"`
Nodes []*ReportNode `json:"nodes,omitempty"`
}
)

Expand Down Expand Up @@ -64,24 +65,32 @@ func (rl *ReportLink) setParent(r ReportLinker) { rl.parent = r }

func (r *Report) SetNode(link ReportLinker, uCredits int, kind any, name, path string) (ReportLinker, error) {
var rp rootParenter
var stKind string

if kk, ok := kind.(Kind); ok && kk.isMeter() { // this could also be implicit by excluding a name & path?
rp = &ReportLeaf{Kind: kk.meterName(), UCreditUse: uCredits}
} else if ok {
rp = &ReportNode{Kind: kk.String(), UCreditSum: uCredits, Slug: name, Path: path}
} else if ks, ok := kind.(string); ok {
rp = &ReportNode{Kind: ks, UCreditSum: uCredits, Slug: name, Path: path}
} else {
// TODO: meters/leaves are not currently attached
// - These are really just the branch tips
// - See cloud-gov/cg-interface/cg-billing#89
switch k := kind.(type) {
case Kind:
stKind = k.String()
case string:
stKind = k
default:
return nil, fmt.Errorf("Report SetNode: kind must be stringable, got: %T", kind)
}

rl := rp.(ReportLinker)
link.addChild(rl)
rp = &ReportNode{
UCreditSum: uCredits,
ReportLink: ReportLink{Kind: stKind, Slug: name, Path: path},
}

linker := rp.(ReportLinker)
link.addChild(linker)

rp.setRoot(r)
rp.setParent(link)

return rl, nil
return linker, nil
}

func NewReporter() *Report {
Expand Down
2 changes: 1 addition & 1 deletion internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func handleCreateAppUsageJob(logger *slog.Logger, cf *client.Client, q db.Querie
NaturalID: app.GUID,
Meter: "oneoff",
KindNaturalID: "",
CFOrgID: dbx.UtilUUID(space.Relationships.Organization.Data.GUID),
CFOrgID: dbx.ToUUID(space.Relationships.Organization.Data.GUID),
})
if err != nil {
http.Error(w, "upserting resource: "+err.Error(), http.StatusInternalServerError)
Expand Down
28 changes: 20 additions & 8 deletions internal/db/cf_org.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/db/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 25 additions & 5 deletions internal/dbx/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)

func UtilUUID(s any) pgtype.UUID {
func TimeToTimestamp(t time.Time) pgtype.Timestamp {
return pgtype.Timestamp{
Time: t,
Valid: true,
}
}

func ToUUID(s any) pgtype.UUID {
switch s := s.(type) {
case pgtype.UUID:
return s
Expand All @@ -25,9 +32,22 @@ func UtilUUID(s any) pgtype.UUID {
return u
}

func UtilTimestamp(t time.Time) pgtype.Timestamp {
return pgtype.Timestamp{
Time: t,
Valid: true,
func ToBlankableUUID(u any) pgtype.UUID {
uu := ToUUID(u)
if !uu.Valid {
if err := uu.Scan("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"); err != nil {
panic(err)
}
}
return uu
}

func UUIDishString(u any) string {
switch ud := u.(type) {
case string:
return ud
case pgtype.UUID:
return ud.String()
}
return ""
}
Loading
Loading