package books import ( "bytes" "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "gopkg.in/yaml.v3" "textmachine/platform/internal/runner" ) // render.go: the platform writes a book's STARTING engine configuration, once, from a template the // operator deploys (form Б, ratified D39.130). // // The shape follows from D39.110 §2b, which says the platform does not own `book.yaml`. It renders // ONE file at the moment a book's directory is created and never reads or edits it again: after that // the file belongs to the operator, and an operator who changes a ceiling or points the book at a // different pipeline finds their change still there on the next run. That is why the write is // O_EXCL — not as a race guard (though it is one), but because "the platform never overwrites this // file" is the whole of the interpretation this form rests on. // // What the platform fills in is exactly what only IT knows: the book's identity, the languages the // user declared at intake, and the name the intake wrote the source under. Everything else // — the pipeline and models paths, the ceilings, the langpack roots, whatever the engine's schema // grows next — comes from the template unread and unjudged. The platform does not know the engine's // configuration schema and must not learn it: a key it has never heard of has to survive this file // untouched, or every engine release would need a platform release. // // ⚠ Form В (`tmctl init`, unified backlog row 170) replaces this with a call to the engine. When it // lands, the ONE place to change is renderConfig below — the seam that decides "there is no // configuration here" is `provision`, and it does not care where the bytes come from. // ErrNoTemplate is a deployment that has no book template configured, or one whose template cannot // be used. It is deliberately the same CLASS as a book with no configuration at all — a deployment // question a human answers — so it can never end an intake or spend its budget (see defer_). var ErrNoTemplate = errors.New("books: the book template is not usable") // maxTemplate bounds what will be read as a template. A book configuration is a page of keys; a // megabyte of it means the path points at something else entirely. const maxTemplate = 1 << 20 // bookConfig is what the PLATFORM knows about a book and the engine needs told. Nothing else about // the engine's schema appears in this package. type bookConfig struct { // ID is the platform's book id, which becomes the engine's `book_id`: one identity for the book // on both sides of the seam, so the handshake's book_id and the row agree without a translation // table. ID string Title string SourceLang string TargetLang string // SourceFile is the name — not the path — the intake wrote the upload under. The engine resolves // it against the directory of book.yaml, which is the book's own directory. SourceFile string } // provision makes sure the book's directory carries an engine configuration, rendering the starting // one if it does not. // // It answers ErrNotProvisioned in every case where there is no usable configuration and no way to // make one, because the CALLER's handling of that is already right: a book with no configuration // waits for a human and never spends its attempt budget (defer_), which is the only safe answer to a // deployment fault that would otherwise apply to every book on the host at once. func (s *Service) provision(ctx context.Context, dir string, b bookConfig) error { path := filepath.Join(dir, runner.ConfigFile) switch _, err := os.Stat(path); { case err == nil: // The operator's file, or one this platform rendered earlier. Nothing here is read: where the // source is and what the ceilings are is what that file SAYS, and this platform does not get a // second opinion about its own book (D39.110 §2b). return nil case !errors.Is(err, fs.ErrNotExist): return fmt.Errorf("books: read book configuration: %w", err) } if s.Cfg.BookTemplate == "" { // A deployment where the operator drops the file in by hand is legitimate and is what every // deployment did before this existed. Nothing to do but wait for them. return ErrNotProvisioned } // Asked for only now, because only a render needs it: `source_file` is the one field whose value // is a fact about the directory rather than about the row. source, err := sourceFileIn(dir) if err != nil { // The directory is here and the upload is not. This platform does not produce that state — the // row is written before the bytes and removed with them — so it is read as a deployment fault // and waits, rather than as a verdict about a text nobody can see. s.log().ErrorContext(ctx, "a book directory has no source file to configure", "err", err) return ErrNotProvisioned } b.SourceFile = source rendered, err := s.renderConfig(b) if err != nil { // The engine is never asked, so the book learns nothing about itself here. Loud, because a // broken template stops EVERY intake on this host and the only other symptom is books that // quietly stay in `parsing`. s.log().ErrorContext(ctx, "the book template cannot be rendered; no uploaded book on this host can be parsed until it is fixed", "template", s.Cfg.BookTemplate, "err", err) return ErrNotProvisioned } // O_EXCL: this platform writes the file once and never again. Two passes of the intake can reach // here together — the queue worker and the backstop sweep — and the loser's EEXIST is the state it // wanted. f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) if errors.Is(err, fs.ErrExist) { return nil } if err != nil { return fmt.Errorf("books: create book configuration: %w", err) } defer f.Close() if _, err := f.Write(rendered); err != nil { return fmt.Errorf("books: write book configuration: %w", err) } if err := f.Close(); err != nil { return fmt.Errorf("books: write book configuration: %w", err) } // Counts and identities, never the book id: the same rule as everywhere else on this side of the // log (ENGINEERING_STANDARDS §Наблюдаемость). s.log().InfoContext(ctx, "a starting engine configuration was rendered for a new book", "bytes", len(rendered)) return nil } // renderConfig reads the operator's template and returns it with this book's own fields set. // // The template is read at RENDER time and not at boot, and that is operational rather than // stylistic: a template with a typo in it is discovered by the first upload, and an operator who // fixes the file must not also have to restart the control plane for the books already waiting on it. func (s *Service) renderConfig(b bookConfig) ([]byte, error) { raw, err := readTemplate(s.Cfg.BookTemplate) if err != nil { return nil, err } return renderBookConfig(raw, b) } func readTemplate(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("%w: %w", ErrNoTemplate, err) } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, maxTemplate+1)) if err != nil { return nil, fmt.Errorf("%w: %w", ErrNoTemplate, err) } if len(raw) > maxTemplate { return nil, fmt.Errorf("%w: it is over %d bytes, which no book configuration is", ErrNoTemplate, maxTemplate) } return raw, nil } // renderBookConfig sets this book's fields on the template's document and re-renders it. // // It works on the parsed NODE rather than on text, and that is what makes it safe to hand an // operator's file: their comments and their key order survive, a key they wrote for a field the // platform owns is REPLACED rather than duplicated (a duplicate key is a hard error to the engine's // strict decoder), and a value that happens to look like a number or a boolean is written back as // the string it is. Templating the file as text would get all three wrong, and the third silently: // a book titled "123" or a language code the operator quoted would decode into the engine's string // field as an int and fail the load. func renderBookConfig(template []byte, b bookConfig) ([]byte, error) { dec := yaml.NewDecoder(bytes.NewReader(template)) var doc yaml.Node if err := dec.Decode(&doc); err != nil { return nil, fmt.Errorf("%w: %w", ErrNoTemplate, err) } // A second document is REFUSED rather than dropped. The engine reads only the first, so a template // with two would load — and this render would silently emit half of what the operator wrote, which // is the kind of loss that is discovered by a book behaving strangely months later. if err := dec.Decode(new(yaml.Node)); !errors.Is(err, io.EOF) { return nil, fmt.Errorf("%w: it carries more than one YAML document", ErrNoTemplate) } if len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode { return nil, fmt.Errorf("%w: it is not a mapping of configuration keys", ErrNoTemplate) } root := doc.Content[0] // The whole of what this platform claims about the engine's schema. Everything else in the // template is carried through unread — `genre` INCLUDED, since 0.3.0: the field left the contract // and the intake form (Б-23, owner 16.08), so the platform no longer knows a book's genre and the // operator's own value in the template is what stands. The engine keeps the key until its own // removal lands (unified backlog row 184). for _, kv := range [][2]string{ {"book_id", b.ID}, {"title", b.Title}, {"source_lang", b.SourceLang}, {"target_lang", b.TargetLang}, {"source_file", b.SourceFile}, } { setString(root, kv[0], kv[1]) } out, err := yaml.Marshal(&doc) if err != nil { return nil, fmt.Errorf("books: render book configuration: %w", err) } return out, nil } // setString assigns a STRING value to a top-level key, replacing the value that was there. // // The !!str tag is load-bearing and not decoration: without it the encoder writes `title: 123` for a // book called "123", and the engine's decoder — which is strict — then refuses to put an int in a // string field. The tag makes the encoder quote whatever needs quoting and nothing that does not. func setString(mapping *yaml.Node, key, value string) { v := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} // A YAML mapping node is a flat list of alternating key and value nodes. for i := 0; i+1 < len(mapping.Content); i += 2 { if mapping.Content[i].Value == key { // The key node keeps its comments and its position; only what it points at changes. mapping.Content[i+1] = v return } } mapping.Content = append(mapping.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, v) } // sourceFileIn finds the name the intake wrote this book's upload under. // // The name is derived rather than stored because the intake is the only writer of this directory and // it writes exactly one source: `SourceName` plus the extension the user's filename carried, which // is kept because the engine dispatches its reader by it (extensionOf). Asking the directory keeps // that one fact in one place instead of two that can disagree. func sourceFileIn(dir string) (string, error) { entries, err := os.ReadDir(dir) if err != nil { return "", fmt.Errorf("books: read book directory: %w", err) } for _, e := range entries { name := e.Name() if e.IsDir() || name == runner.ConfigFile { continue } if base := name[:len(name)-len(filepath.Ext(name))]; base == SourceName { return name, nil } } return "", fmt.Errorf("books: no %s.* file in %s", SourceName, dir) }