244 lines
11 KiB
Go
244 lines
11 KiB
Go
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)
|
||
}
|