Skip to content
Merged
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,16 @@ Glassity UI and pass it through the environment:

```sh
export GLASSITY_TOKEN=<pat>
export GLASSITY_ACCOUNT_ID=<account-prefix-id> # from `glassity auth whoami`
export GLASSITY_AWS_ACCOUNT_ID=<aws-account-id> # scopes cost reads
glassity --output json opp list
```

The token alone is not enough: tenant-scoped commands need `GLASSITY_ACCOUNT_ID`
(the `account_…` prefix id, printed by `glassity auth whoami` and by `auth
set-token`), and cost commands scope to `GLASSITY_AWS_ACCOUNT_ID` unless
`--aws-account-id` is passed explicitly.

`GLASSITY_TOKEN` takes precedence over the keychain, so nothing is written to disk on the
runner. Where a keychain exists, `glassity auth set-token` stores a PAT persistently.

Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ Profile selection precedence: `--profile` beats `GLASSITY_PROFILE`, which beats
| `GLASSITY_API_URL` | Fallback alias for `GLASSITY_API_BASE_URL`, consulted only when that variable is unset. |
| `GLASSITY_PROFILE` | Selects the active profile. `--profile` wins over it. |
| `GLASSITY_CONFIG_HOME` | Directory holding `profile.yaml`. Wins over `XDG_CONFIG_HOME`. |
| `GLASSITY_ACCOUNT_ID` | Overrides `account_prefix_id`. |
| `GLASSITY_AWS_ACCOUNT_ID` | Overrides `aws_account_id`. |
| `GLASSITY_ACCOUNT_ID` | Overrides `account_prefix_id`. Required for tenant-scoped commands when running headless with only `GLASSITY_TOKEN`. |
| `GLASSITY_AWS_ACCOUNT_ID` | Overrides `aws_account_id`. Cost commands fall back to it when `--aws-account-id` is not passed. |
| `XDG_CONFIG_HOME` | Base directory for the config file. |
| `XDG_CACHE_HOME` | Base directory for the filesystem cache and, on Linux without `XDG_RUNTIME_DIR`, the confirm-token secrets. |
| `XDG_STATE_HOME` | Base directory for filesystem mount state. |
Expand Down
14 changes: 13 additions & 1 deletion internal/cli/cost/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func newCostCmd(use, short string, fetch costFetcher) *cobra.Command {
AccountPrefixID: rt.Profile.AccountPrefixID,
})
fmt.Fprintln(rt.Stderr, output.TenantBanner(rt.Profile.TenantLabel(), rt.Profile.AccountPrefixID, rt.Profile.AwsAccountsLabel()))
applyProfileAccountScope(&f, rt)
rows, err := fetch(cmd.Context(), client, f)
if err != nil {
return err
Expand All @@ -83,12 +84,23 @@ func newCostCmd(use, short string, fetch costFetcher) *cobra.Command {
flags.StringVar(&f.StartDate, "start-date", "", "window start (YYYY-MM-DD)")
flags.StringVar(&f.EndDate, "end-date", "", "window end (YYYY-MM-DD)")
flags.StringVar(&f.Granularity, "granularity", "", "daily|monthly")
flags.StringVar(&f.AwsAccountID, "aws-account-id", "", "scope to a single AWS account")
flags.StringVar(&f.AwsAccountID, "aws-account-id", "", "scope to a single AWS account (default: the profile's account, e.g. GLASSITY_AWS_ACCOUNT_ID)")
flags.IntVar(&f.Limit, "limit", 0, "page size (1..100)")
flags.IntVar(&f.Page, "page", 0, "page number (1-indexed)")
return cmd
}

// applyProfileAccountScope falls back to the profile's AWS account (which
// GLASSITY_AWS_ACCOUNT_ID overrides) when --aws-account-id is not given.
// Without this, an unflagged query silently scopes to whatever account the
// server picks as default — which can be a different account than the one
// the caller's environment names.
func applyProfileAccountScope(f *apiclient.CostCommon, rt *cli.Runtime) {
if f.AwsAccountID == "" {
f.AwsAccountID = rt.Profile.AwsAccountID
}
}

// RenderRows is the shared table/json renderer for cost and rec commands.
// Exposed so the rec package can reuse it without duplicating the logic.
func RenderRows(rt *cli.Runtime, rows []apiclient.CostRow) error {
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/cost/summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func newCostSummaryCmd() *cobra.Command {
AccountPrefixID: rt.Profile.AccountPrefixID,
})
fmt.Fprintln(rt.Stderr, output.TenantBanner(rt.Profile.TenantLabel(), rt.Profile.AccountPrefixID, rt.Profile.AwsAccountsLabel()))
applyProfileAccountScope(&f, rt)
result, err := client.CostSummary(cmd.Context(), f)
if err != nil {
return err
Expand All @@ -44,7 +45,7 @@ func newCostSummaryCmd() *cobra.Command {
flags.StringVar(&f.StartDate, "start-date", "", "window start (YYYY-MM-DD)")
flags.StringVar(&f.EndDate, "end-date", "", "window end (YYYY-MM-DD)")
flags.StringVar(&f.Granularity, "granularity", "", "daily|monthly")
flags.StringVar(&f.AwsAccountID, "aws-account-id", "", "scope to a single AWS account")
flags.StringVar(&f.AwsAccountID, "aws-account-id", "", "scope to a single AWS account (default: the profile's account, e.g. GLASSITY_AWS_ACCOUNT_ID)")
flags.IntVar(&f.Limit, "limit", 0, "page size (1..100)")
flags.IntVar(&f.Page, "page", 0, "page number (1-indexed)")
return cmd
Expand Down
49 changes: 49 additions & 0 deletions internal/cli/cost/summary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,52 @@ func TestCostSummary_NotAuthenticatedFromServer_PropagatesAsAPIError(t *testing.
t.Errorf("Status = %d, want 404", apiErr.Status)
}
}

// The profile's AWS account (which GLASSITY_AWS_ACCOUNT_ID overrides) must
// scope unflagged cost queries; before this fallback the server silently
// substituted its own default account.
func TestCostSummary_ProfileAwsAccount_ScopesUnflaggedQuery(t *testing.T) {
var gotAccountID string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAccountID = r.URL.Query().Get("aws_account_id")
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}})
}))
t.Cleanup(srv.Close)

var stdout, stderr bytes.Buffer
rt := newCostTestRuntime(t, srv.URL, output.ModeJSON, &stdout, &stderr)
rt.Profile.AwsAccountID = "111122223333"

if err := runCostSummary(t, rt); err != nil {
t.Fatalf("runCostSummary: %v (stderr=%s)", err, stderr.String())
}
if gotAccountID != "111122223333" {
t.Errorf("aws_account_id = %q, want the profile fallback 111122223333", gotAccountID)
}
}

func TestCostSummary_AwsAccountFlag_WinsOverProfile(t *testing.T) {
var gotAccountID string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAccountID = r.URL.Query().Get("aws_account_id")
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}})
}))
t.Cleanup(srv.Close)

var stdout, stderr bytes.Buffer
rt := newCostTestRuntime(t, srv.URL, output.ModeJSON, &stdout, &stderr)
rt.Profile.AwsAccountID = "111122223333"

cmd := newCostSummaryCmd()
cmd.SetArgs([]string{"--aws-account-id", "444455556666"})
cmd.SetOut(rt.Stdout)
cmd.SetErr(rt.Stderr)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
if err := cmd.ExecuteContext(cli.WithRuntime(context.Background(), rt)); err != nil {
t.Fatalf("execute: %v (stderr=%s)", err, stderr.String())
}
if gotAccountID != "444455556666" {
t.Errorf("aws_account_id = %q, want the explicit flag value", gotAccountID)
}
}