A quota could be enforced and reported, and set nowhere but a shell. Nothing in the browser showed one at all — not what a team may spend, not what it has spent, not that it had no limit. In practice that means quotas nobody sets. /admin/quotas lists the whole tree, whether or not a group has a limit: the one without a quota is the one most likely to need one, so it cannot be the row the page leaves out. Spend over the last thirty days sits beside the budget, with the fraction used. Setting a quota here is a change of position. It was kept to the shell because raising a budget is a financial act and a stolen admin session should not perform one. That argument holds for exactly one row on this page: resolution takes the most restrictive value along the chain, so raising a team's or a project's limit cannot exceed what the organization allows. Only an organization's own limit is bounded by nothing. Every change is logged with who made it and what it was before. Per-key limits stay on the CLI. A key belongs to a person and is already bounded by its project; letting an admin session reach individual keys would put every caller's throughput one click away, and the page refuses a key scope rather than rendering it and failing later. A limit an ancestor already caps lower is marked as written-but-unreachable. Resolution means a project given more than its team is not a bigger budget, it is a number nobody will reach — and without saying so the page would show it as though it applied, with the first sign being a refusal that disagrees with the screen. The group is chosen, not typed. Ids became random in the previous commit so two organizations could both have a "platform" team, which also made them impossible to remember and a typo impossible to notice: it would write a quota onto nothing and report success. A blank field is no limit of that kind, not zero — zero is a limit of nothing, which refuses every request. Clearing removes the row rather than writing nulls across it: both resolve the same, only one tells a reader nobody has set a limit here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
5.9 KiB
Go
181 lines
5.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base32"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ErrDuplicateName means a sibling of that name is already there.
|
|
var ErrDuplicateName = errors.New("something of that name is already here")
|
|
|
|
// NewID mints an identifier with a readable prefix.
|
|
//
|
|
// Random rather than a slug of the name, which is what the tenancy tree used
|
|
// and what made two organizations impossible to have: team ids were slugs in
|
|
// one flat namespace, PutTeam upserts on id, so creating "platform" under a
|
|
// second organization moved the first one's team into it. Nothing failed. The
|
|
// team simply changed owner.
|
|
func NewID(prefix string) string {
|
|
var buf [10]byte
|
|
_, _ = rand.Read(buf[:])
|
|
return prefix + strings.ToLower(
|
|
base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
|
|
}
|
|
|
|
// TeamPath is a team with the organization it belongs to, for a chooser.
|
|
type TeamPath struct {
|
|
ID, Name, OrgID, OrgName string
|
|
}
|
|
|
|
// AllOrgs lists the organizations, by name.
|
|
func (s *DB) AllOrgs(ctx context.Context) ([]Org, error) {
|
|
return queryAll(ctx, s.db, `SELECT id, name FROM organizations ORDER BY name`,
|
|
nil, func(rows *sql.Rows) (Org, error) {
|
|
var o Org
|
|
return o, rows.Scan(&o.ID, &o.Name)
|
|
})
|
|
}
|
|
|
|
// AllTeams lists the teams with their organization.
|
|
func (s *DB) AllTeams(ctx context.Context) ([]TeamPath, error) {
|
|
return queryAll(ctx, s.db, `
|
|
SELECT t.id, t.name, o.id, o.name
|
|
FROM teams t JOIN organizations o ON o.id = t.org_id
|
|
ORDER BY o.name, t.name`, nil, func(rows *sql.Rows) (TeamPath, error) {
|
|
var t TeamPath
|
|
return t, rows.Scan(&t.ID, &t.Name, &t.OrgID, &t.OrgName)
|
|
})
|
|
}
|
|
|
|
// CreateOrg adds an organization and returns its id.
|
|
//
|
|
// Create rather than Put. The Put* calls upsert by id so `keys init` can be run
|
|
// again without duplicating, which is right for provisioning and wrong for a
|
|
// person filling in a form: there, a name that already exists is a mistake
|
|
// being made, not a line being re-run.
|
|
func (s *DB) CreateOrg(ctx context.Context, name string) (string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("an organization needs a name")
|
|
}
|
|
if err := s.nameIsFree(ctx,
|
|
`SELECT COUNT(*) FROM organizations WHERE name = ?`, name); err != nil {
|
|
return "", err
|
|
}
|
|
id := NewID("org-")
|
|
return id, s.PutOrg(ctx, Org{ID: id, Name: name})
|
|
}
|
|
|
|
// CreateTeam adds a team to an organization.
|
|
//
|
|
// Unique within its organization, not globally: two companies both having a
|
|
// "platform" team is ordinary, and refusing the second would be the flat
|
|
// namespace back again wearing a better error message.
|
|
func (s *DB) CreateTeam(ctx context.Context, orgID, name string) (string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if orgID == "" || name == "" {
|
|
return "", fmt.Errorf("a team needs an organization and a name")
|
|
}
|
|
if err := s.nameIsFree(ctx,
|
|
`SELECT COUNT(*) FROM teams WHERE org_id = ? AND name = ?`, orgID, name); err != nil {
|
|
return "", err
|
|
}
|
|
id := NewID("team-")
|
|
return id, s.PutTeam(ctx, Team{ID: id, OrgID: orgID, Name: name})
|
|
}
|
|
|
|
// CreateProject adds a project to a team.
|
|
func (s *DB) CreateProject(ctx context.Context, teamID, name string) (string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if teamID == "" || name == "" {
|
|
return "", fmt.Errorf("a project needs a team and a name")
|
|
}
|
|
if err := s.nameIsFree(ctx,
|
|
`SELECT COUNT(*) FROM projects WHERE team_id = ? AND name = ?`, teamID, name); err != nil {
|
|
return "", err
|
|
}
|
|
id := NewID("proj-")
|
|
return id, s.PutProject(ctx, Project{ID: id, TeamID: teamID, Name: name})
|
|
}
|
|
|
|
// nameIsFree refuses a duplicate among siblings.
|
|
//
|
|
// Checked rather than left to a constraint because there is no unique index to
|
|
// lean on: names were never unique, the ids were, and the ids were slugs of the
|
|
// names. A query and a race is the honest state of it — two admins creating the
|
|
// same team in the same second get two teams, which is visible and fixable,
|
|
// where the old behaviour silently reparented somebody else's.
|
|
func (s *DB) nameIsFree(ctx context.Context, q string, args ...any) error {
|
|
var n int
|
|
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return ErrDuplicateName
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AllLimits reads every limit that has been set, keyed by scope and id.
|
|
//
|
|
// All of them in one read rather than one query per group: the quota page shows
|
|
// the whole tree, and a row for each of an organization's projects would
|
|
// otherwise be a query each.
|
|
func (s *DB) AllLimits(ctx context.Context) (map[string]Limits, error) {
|
|
rows, err := queryAll(ctx, s.db, `
|
|
SELECT scope_type, scope_id, rpm, tpm, budget_micros, budget_period, allowed_models
|
|
FROM limits`, nil, func(rows *sql.Rows) (Limits, error) {
|
|
var l Limits
|
|
var scope string
|
|
var models sql.NullString
|
|
var rpm, tpm sql.NullInt64
|
|
var budget sql.NullInt64
|
|
var period sql.NullString
|
|
if err := rows.Scan(&scope, &l.ScopeID, &rpm, &tpm, &budget, &period, &models); err != nil {
|
|
return l, err
|
|
}
|
|
l.Scope = Scope(scope)
|
|
if rpm.Valid {
|
|
v := int(rpm.Int64)
|
|
l.RPM = &v
|
|
}
|
|
if tpm.Valid {
|
|
v := int(tpm.Int64)
|
|
l.TPM = &v
|
|
}
|
|
if budget.Valid {
|
|
v := budget.Int64
|
|
l.Budget = &v
|
|
}
|
|
l.BudgetPeriod = Period(period.String)
|
|
am, err := decodeModels(models)
|
|
l.AllowedModels = am
|
|
return l, err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]Limits, len(rows))
|
|
for _, l := range rows {
|
|
out[LimitOwner{Scope: l.Scope, ID: l.ScopeID}.CounterKey()] = l
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ClearLimits removes every limit at one scope.
|
|
//
|
|
// Distinct from setting them all to nothing, which is what an empty form would
|
|
// otherwise write: a row of NULLs and no row mean the same to resolution, but
|
|
// only one of them says "nobody has set a limit here" when somebody reads the
|
|
// table directly.
|
|
func (s *DB) ClearLimits(ctx context.Context, scope Scope, id string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM limits WHERE scope_type = ? AND scope_id = ?`, string(scope), id)
|
|
return err
|
|
}
|