textmachine/platform/internal/books/render.go

244 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}