textmachine/platform/cmd/tmplatformctl/main.go

269 lines
9.3 KiB
Go

// Command tmplatformctl is the admin surface (P-8): credit an account, read a balance, look at
// sign-ins, end sessions.
//
// A CLI rather than a protected HTTP route, deliberately. An admin endpoint needs a second
// authorisation model — roles, an escalation path, a way to lose the admin cookie — for four
// operations. The trust boundary for these is already "can open a shell on the box and read the
// DSN", and that boundary is enforced by the machine rather than by code we would have to write
// and get right. If a browser-facing admin panel is ever wanted, it wraps these same store calls.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"text/tabwriter"
"time"
"textmachine/platform/internal/config"
"textmachine/platform/internal/money"
"textmachine/platform/internal/pgstore"
)
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
if errors.Is(err, errUsage) {
_, _ = fmt.Fprintln(os.Stderr, usage)
os.Exit(2)
}
_, _ = fmt.Fprintln(os.Stderr, "tmplatformctl:", err)
os.Exit(1)
}
}
// errUsage asks main to print the usage text; every other error is a message on its own.
var errUsage = errors.New("usage")
const usage = `usage: tmplatformctl <command> [flags]
grant --user <id> --usd <amount> [--note <text>] [--key <idempotency key>]
adjust --user <id> --usd <amount> --note <text> [--key <idempotency key>]
balance --user <id>
logins --user <id> [--limit <n>]
revoke --user <id>
book add --user <id> --workdir <dir> --title <text> --source-lang <code> --target-lang <code>
--chapters <n> [--characters <n>] [--genre <text>] (development intake)
exit-marker <path> <unit> (called by systemd, no DSN)
The DSN comes from TM_PLATFORM_DSN or TM_PLATFORM_DSN_FILE.`
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
// Before the DSN: this one runs INSIDE a run's transient unit, as ExecStopPost, where there is
// no database handle and no credential to read one with — and where failing would lose the only
// record of how the run ended.
if args[0] == "exit-marker" {
return exitMarker(args[1:])
}
dsn, err := config.Secret("TM_PLATFORM_DSN")
if err != nil {
return err
}
if dsn == "" {
return errors.New("TM_PLATFORM_DSN (or TM_PLATFORM_DSN_FILE) is not set")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
store, err := pgstore.Open(ctx, dsn)
if err != nil {
return err
}
defer store.Close()
cmd, rest := args[0], args[1:]
switch cmd {
case "grant":
return grant(ctx, store, rest, out)
case "adjust":
return adjust(ctx, store, rest, out)
case "balance":
return balance(ctx, store, rest, out)
case "logins":
return logins(ctx, store, rest, out)
case "revoke":
return revoke(ctx, store, rest, out)
case "book":
if len(rest) == 0 || rest[0] != "add" {
return fmt.Errorf("book takes one subcommand, add: %w", errUsage)
}
return addBook(ctx, store, rest[1:], out)
default:
return fmt.Errorf("unknown command %q: %w", cmd, errUsage)
}
}
// grant writes one ledger row: that is the whole of the free tier.
func grant(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("grant", flag.ContinueOnError)
user := fs.String("user", "", "account id")
amount := fs.String("usd", "", "amount in dollars, e.g. 5 or 2.50")
note := fs.String("note", "", "why")
// Idempotency is OPT-IN. A default key derived from the account and the day looked safe and was
// not: two legitimate grants on one day collapse into the first, and the second reports success
// while crediting nothing. Each invocation is its own intent unless the operator says otherwise.
key := fs.String("key", "", "idempotency key: repeating the command with the same one is a no-op")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" || *amount == "" {
return errors.New("grant needs --user and --usd")
}
micro, err := money.ParseUSD(*amount)
if err != nil {
return err
}
return write(ctx, store, out, *user, *key, func(id string, now time.Time) (bool, error) {
return store.Grant(ctx, *user, micro, "admin", id, *note, now)
}, "granted "+micro.USD()+" to "+*user)
}
// adjust corrects a balance with a second row: ledger rows are never edited.
func adjust(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("adjust", flag.ContinueOnError)
user := fs.String("user", "", "account id")
amount := fs.String("usd", "", "signed amount in dollars, e.g. -2.50")
note := fs.String("note", "", "why (required: an unexplained correction is unauditable)")
key := fs.String("key", "", "idempotency key")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" || *amount == "" || *note == "" {
return errors.New("adjust needs --user, --usd and --note")
}
micro, err := money.ParseUSD(*amount)
if err != nil {
return err
}
return write(ctx, store, out, *user, *key, func(id string, now time.Time) (bool, error) {
return store.Adjust(ctx, *user, micro, "admin", id, *note, now)
}, "adjusted "+*user+" by "+micro.USD())
}
// balanceReader is the read-back half of write. One method, and its only reason to exist is that the
// rule below — a committed write never reports failure — cannot be tested against a store that
// always works.
type balanceReader interface {
Balance(ctx context.Context, userID string) (money.MicroUSD, error)
}
// write runs one ledger operation and reports what actually happened. "Applied" and "the key was
// already spent" are different outcomes and the operator is told which one they got.
func write(ctx context.Context, store balanceReader, out io.Writer, user, key string,
op func(id string, now time.Time) (bool, error), what string) error {
now := time.Now().UTC()
id := key
if id == "" {
id = newKey()
}
applied, err := op(id, now)
if err != nil {
return err
}
// Past this point the write is committed and NOTHING may report failure. An operator who reads
// an error retries, and a retry without --key mints a fresh idempotency key, so the second run
// credits again — a failed BALANCE READ would have bought a double credit. The balance is a
// courtesy; its failure is a note on the same line. Found by review.
shown := "balance unavailable: " + user
if after, err := store.Balance(ctx, user); err == nil {
shown = "balance is " + after.USD()
} else {
_, _ = fmt.Fprintf(out, "warning: could not read the balance back: %v\n", err)
}
if !applied {
_, _ = fmt.Fprintf(out, "no-op: key %s was already used on %s; %s\n", id, user, shown)
return nil
}
_, _ = fmt.Fprintf(out, "%s (key %s); %s\n", what, id, shown)
return nil
}
// newKey mints a key for a one-off command, so that two deliberate grants on the same day are two
// grants.
func newKey() string {
var b [8]byte
rand.Read(b[:]) // never fails
return "cli-" + hex.EncodeToString(b[:])
}
func balance(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("balance", flag.ContinueOnError)
user := fs.String("user", "", "account id")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" {
return errors.New("balance needs --user")
}
// One snapshot: reading the cache and the ledger in two queries reports drift that a concurrent
// grant caused between them.
a, err := store.ReadAccount(ctx, *user)
if err != nil {
return err
}
_, _ = fmt.Fprintf(out, "balance %s\n", a.Balance.USD())
if a.Reserved != 0 {
_, _ = fmt.Fprintf(out, "reserved %s (open holds, already deducted)\n", a.Reserved.USD())
}
if a.Balance != a.LedgerSum {
_, _ = fmt.Fprintf(out, "⚠ ledger sums to %s: the cached balance has drifted\n", a.LedgerSum.USD())
}
open, err := store.OpenReservations(ctx, *user)
if err != nil {
return err
}
for _, r := range open {
_, _ = fmt.Fprintf(out, " hold %s on book %s since %s (run %s)\n",
r.Amount.USD(), r.BookID, r.OpenedAt.UTC().Format(time.RFC3339), r.EngineRunID)
}
return nil
}
func logins(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("logins", flag.ContinueOnError)
user := fs.String("user", "", "account id")
limit := fs.Int("limit", 20, "how many")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" {
return errors.New("logins needs --user")
}
entries, err := store.RecentLogins(ctx, *user, *limit)
if err != nil {
return err
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "WHEN\tPROVIDER\tOUTCOME\tCLIENT\tFROM\tREASON")
for _, e := range entries {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
e.At.UTC().Format(time.RFC3339), e.Provider, e.Outcome, e.Client, e.IPPrefix, e.Reason)
}
return w.Flush()
}
func revoke(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("revoke", flag.ContinueOnError)
user := fs.String("user", "", "account id")
if err := fs.Parse(args); err != nil {
return err
}
if *user == "" {
return errors.New("revoke needs --user")
}
n, err := store.RevokeUserSessions(ctx, *user, time.Now().UTC())
if err != nil {
return err
}
_, _ = fmt.Fprintf(out, "revoked %d sessions of %s\n", n, *user)
return nil
}