textmachine/platform/cmd/tmplatformctl/seed.go

359 lines
13 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"os"
"strings"
"time"
"textmachine/platform/internal/auth"
"textmachine/platform/internal/login"
"textmachine/platform/internal/money"
"textmachine/platform/internal/pgstore"
)
// seed.go: one command that makes a development stand usable — a test account with credit and a book
// that has been through the whole intake (П-16, owner 14.08).
//
// It exists so the frontend is built against the LIVE platform instead of against its own mocks, and
// the shape follows from that purpose: everything a USER does, this command does over HTTP through
// the same routes, with a real session. It signs in through the development sign-in and uploads
// through `POST /v0/books`. The one thing it does from the admin side is the credit, because that is
// what an operator does — there is no purchase path and there will not be one in the beta.
//
// Nothing is INSERTed into the read model directly, and that is the whole point of the exercise: a
// seed that wrote rows itself would produce a stand whose books never went through intake, so the
// first bug the frontend hit would be in a path the seed had quietly skipped.
// seedTimeout bounds the whole walk, including waiting for the engine to cut the demo book.
const seedTimeout = 5 * time.Minute
func seed(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("seed", flag.ContinueOnError)
base := fs.String("url", "http://127.0.0.1:8080", "base URL of the running tmplatformd")
subject := fs.String("subject", os.Getenv("TM_PLATFORM_DEV_LOGIN"),
"the development identity, as the daemon's TM_PLATFORM_DEV_LOGIN names it")
usd := fs.String("usd", "25", "credit to grant the test account, in dollars")
source := fs.String("source", "", "a book file to upload; empty uses the built-in demo fragment")
// Empty = whatever this deployment declares. A default pair here would be a second place the
// supported languages are written down, and the first one to go stale.
srcLang := fs.String("source-lang", "", "source language CODE; empty asks the deployment")
dstLang := fs.String("target-lang", "", "target language CODE; empty asks the deployment")
if err := fs.Parse(args); err != nil {
return err
}
// Trimmed, because the daemon trims (config.Load) and this side has to name the SAME identity.
// The two read one environment variable, and a value with a stray newline in it made them
// disagree about who had just signed in: the sign-in created the account and spent its one-time
// grant, and the lookup right below then found nothing and ended the walk.
sub := strings.TrimSpace(*subject)
if sub == "" {
return errors.New("seed needs --subject (or TM_PLATFORM_DEV_LOGIN in the environment): it is the identity the daemon's development sign-in issues")
}
grant, err := money.ParseUSD(*usd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, seedTimeout)
defer cancel()
c, err := newSeedClient(strings.TrimSuffix(*base, "/"))
if err != nil {
return err
}
// 1. The account, through the door a user walks. The daemon creates it — with its signup grant —
// on the first sign-in, so nothing here has to know how an account is made.
if err := c.devLogin(ctx); err != nil {
return err
}
userID, err := store.UserByIdentity(ctx, login.DevProvider, sub)
if err != nil {
return fmt.Errorf("the sign-in succeeded but no account was found for it: %w", err)
}
_, _ = fmt.Fprintf(out, "account %s (identity %s/%s)\n", userID, login.DevProvider, sub)
// 2. The credit, from the admin side — which is where credit comes from in the beta.
//
// Through the SAME helper the `grant` command uses, and not a second copy of it. That helper
// exists for two rules this call has to obey as much as an operator's does: "applied" and "the
// key was already spent" are different outcomes and the caller is told which one it got, and a
// balance read that fails AFTER a committed write is a note on the line rather than a failure of
// the write. The key is deterministic on the account, so seeding a stand twice tops it up once and
// says so out loud.
if err := write(ctx, store, out, userID, "seed-"+userID, func(id string, now time.Time) (bool, error) {
return store.Grant(ctx, userID, grant, "admin", id, "development stand", now)
}, "credit granted "+grant.USD()+" to "+userID); err != nil {
return err
}
// 3. The book, through the contract's own intake. The response says `parsing`, and the walk is
// only finished when the engine has cut it — so the command waits and reports the verdict
// rather than leaving a half-seeded stand that looks fine.
body, name, err := demoSource(*source)
if err != nil {
return err
}
if *srcLang == "" || *dstLang == "" {
pair, err := c.firstAvailablePair(ctx)
if err != nil {
return err
}
if *srcLang == "" {
*srcLang = pair.Source
}
if *dstLang == "" {
*dstLang = pair.Target
}
}
book, err := c.upload(ctx, uploadForm{
SourceLang: *srcLang, TargetLang: *dstLang, Filename: name, Body: body,
})
if err != nil {
return err
}
_, _ = fmt.Fprintf(out, "book %s (%s)\n", book.ID, book.Status)
final, err := c.awaitIntake(ctx, book.ID)
if err != nil {
return err
}
_, _ = fmt.Fprintf(out, "intake %s, %d chapters\n", final.Status, final.ChapterCount)
if final.Status != "not_started" {
return fmt.Errorf("the demo book ended intake as %q: the stand cannot parse books, and the daemon's log says why", final.Status)
}
_, _ = fmt.Fprintf(out, "\nThe stand is seeded. To use it from a browser on this host, open the app and run\n"+
" await fetch('/auth/dev-login', {method: 'POST'})\n"+
"in the console once — that is the same call this command made, and it sets the session cookie.\n")
return nil
}
// seedClient is a user of the platform, as far as the platform can tell: a cookie jar and nothing
// else.
type seedClient struct {
base string
http *http.Client
}
func newSeedClient(base string) (*seedClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &seedClient{base: base, http: &http.Client{Jar: jar}}, nil
}
// do sends one request as the app under development sends it.
//
// ⚠ The client header is not ceremony and it is not a workaround: an unsafe request presented by
// COOKIE has to carry it (auth.CSRF), because the stdlib's cross-origin check allows a request that
// bears neither Sec-Fetch-Site nor Origin — which a pre-2023 browser posting a cross-site form is —
// and `POST /books` takes multipart/form-data, a content type that triggers no preflight.
// running this command against a live stand, which is the point of running it: the seed walks the
// same doors the frontend does, so a door the frontend has to open correctly is one this command
// has to open correctly too.
func (c *seedClient) do(req *http.Request) (*http.Response, error) {
req.Header.Set(auth.ClientHeader, "tmplatformctl-seed")
req.Header.Set("User-Agent", "tmplatformctl/seed")
return c.http.Do(req)
}
func (c *seedClient) devLogin(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/auth/dev-login", nil)
if err != nil {
return err
}
resp, err := c.do(req)
if err != nil {
return fmt.Errorf("the daemon at %s could not be reached: %w", c.base, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("%s has no development sign-in: set TM_PLATFORM_DEV_LOGIN on the daemon (it is refused alongside an OIDC provider)", c.base)
}
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("the development sign-in answered %s", resp.Status)
}
return nil
}
// uploadForm is one intake request, in the order the wire requires.
type uploadForm struct {
// Title empty is the DECLARED way to ask for "name it from the file" (canon §BookIntake), which
// is what a seeded stand wants: the demo book's name is the demo file's.
Title, SourceLang, TargetLang, Filename string
Body io.Reader
}
type seedBook struct {
ID string `json:"id"`
Status string `json:"status"`
ChapterCount int `json:"chapter_count"`
}
// upload streams the multipart body, with the FILE LAST.
//
// Both halves matter and neither is decoration. The order is the route's own rule — a streaming
// reader takes the parts in wire order and cannot write the book's row before the languages that row
// requires — and streaming through a pipe rather than a buffer is what makes this command usable on
// a real book instead of only on the demo fragment.
func (c *seedClient) upload(ctx context.Context, in uploadForm) (seedBook, error) {
pr, pw := io.Pipe()
mw := multipart.NewWriter(pw)
go func() {
err := func() error {
for _, kv := range [][2]string{
{"title", in.Title}, {"source_lang", in.SourceLang}, {"target_lang", in.TargetLang},
} {
if err := mw.WriteField(kv[0], kv[1]); err != nil {
return err
}
}
part, err := mw.CreateFormFile("file", in.Filename)
if err != nil {
return err
}
if _, err := io.Copy(part, in.Body); err != nil {
return err
}
return mw.Close()
}()
_ = pw.CloseWithError(err)
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v0/books", pr)
if err != nil {
return seedBook{}, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := c.do(req)
if err != nil {
return seedBook{}, fmt.Errorf("the upload did not finish: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return seedBook{}, fmt.Errorf("the intake answered %s: %s", resp.Status, firstLine(resp.Body))
}
var book seedBook
if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
return seedBook{}, fmt.Errorf("the intake's answer did not decode: %w", err)
}
return book, nil
}
// awaitIntake polls the book card until the walk ends. Polling and not waiting on anything: intake is
// finished by a sweep or a queue worker, deliberately not by the request that started it.
func (c *seedClient) awaitIntake(ctx context.Context, id string) (seedBook, error) {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
card, err := c.book(ctx, id)
if err != nil {
return seedBook{}, err
}
if card.Status != "parsing" && card.Status != "uploading" {
return card, nil
}
select {
case <-ctx.Done():
return seedBook{}, fmt.Errorf("the demo book is still %q after %s; the daemon's log says what it is waiting for", card.Status, seedTimeout)
case <-t.C:
}
}
}
func (c *seedClient) book(ctx context.Context, id string) (seedBook, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/v0/books/"+id, nil)
if err != nil {
return seedBook{}, err
}
resp, err := c.do(req)
if err != nil {
return seedBook{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return seedBook{}, fmt.Errorf("reading the book answered %s", resp.Status)
}
var detail struct {
Book seedBook `json:"book"`
}
if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil {
return seedBook{}, err
}
return detail.Book, nil
}
// firstAvailablePair asks the deployment what it can translate, so the walk works wherever it runs.
func (c *seedClient) firstAvailablePair(ctx context.Context) (seedPair, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/v0/capabilities", nil)
if err != nil {
return seedPair{}, err
}
resp, err := c.do(req)
if err != nil {
return seedPair{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return seedPair{}, fmt.Errorf("reading the capabilities answered %s", resp.Status)
}
var caps struct {
Pairs []seedPair `json:"language_pairs"`
}
if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil {
return seedPair{}, err
}
for _, p := range caps.Pairs {
if p.State == "available" {
return p, nil
}
}
return seedPair{}, errors.New("this deployment declares no available language pair: set TM_PLATFORM_LANGUAGE_PAIRS, or name one with --source-lang/--target-lang")
}
type seedPair struct {
Source string `json:"source"`
Target string `json:"target"`
State string `json:"state"`
}
func firstLine(r io.Reader) string {
b, _ := io.ReadAll(io.LimitReader(r, 512))
if i := strings.IndexByte(string(b), '\n'); i >= 0 {
return string(b[:i])
}
return string(b)
}
// demoSource is the book the stand gets when the operator names none.
//
// ⚠ It reads as Chinese because the only prompt pack in this repository is zh-ru, so a demo book in
// any other pair would be one the engine legitimately refuses to translate — and a seeded stand that
// cannot run its own demo book teaches the wrong lesson. Nothing about the pair is decided here:
// --source, --source-lang and --target-lang are the whole of it, and the pack a book needs is the
// engine's data, not this command's.
func demoSource(path string) (io.Reader, string, error) {
if path != "" {
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
return f, f.Name(), nil
}
var b strings.Builder
for chapter := 1; chapter <= 3; chapter++ {
fmt.Fprintf(&b, "第%d章 演示\n", chapter)
for range 20 {
b.WriteString("这是一个用于开发环境的演示文本。")
}
b.WriteString("\n\n")
}
return strings.NewReader(b.String()), "demo.txt", nil
}