359 lines
13 KiB
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
|
|
}
|