292 lines
10 KiB
Go
292 lines
10 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>] (development intake)
|
|
books [--migratable] [--abandoned] (books, which are safe to migrate, which lost their surface)
|
|
book refresh --book <id> (ask again for a reading surface that was given up on)
|
|
runs [--stalled] (live runs and what the reconciler cannot finish)
|
|
run abandon --run <id> --reason <text> [--release-hold] (operator's terminal verdict on a stalled run)
|
|
seed [--url <base>] [--subject <id>] [--usd <amount>] [--source <file>]
|
|
(development stand: account, credit, a book)
|
|
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 {
|
|
return fmt.Errorf("book takes a subcommand, add or refresh: %w", errUsage)
|
|
}
|
|
switch rest[0] {
|
|
case "add":
|
|
return addBook(ctx, store, rest[1:], out)
|
|
case "refresh":
|
|
return refreshBook(ctx, store, rest[1:], out)
|
|
}
|
|
return fmt.Errorf("book takes a subcommand, add or refresh: %w", errUsage)
|
|
case "books":
|
|
return listBooks(ctx, store, rest, out)
|
|
case "runs":
|
|
return listRuns(ctx, store, rest, out)
|
|
case "run":
|
|
if len(rest) == 0 || rest[0] != "abandon" {
|
|
return fmt.Errorf("run takes one subcommand, abandon: %w", errUsage)
|
|
}
|
|
return abandonRun(ctx, store, rest[1:], out)
|
|
case "seed":
|
|
return seed(ctx, store, rest, 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
|
|
}
|