package convert import ( "context" "fmt" "log/slog" "os/exec" "strings" "sync" "sync/atomic" "time" ) // Capabilities is the snapshot the convert-health endpoint reports // and the convert entry points consult before exec'ing. // // In the runtime-image model, "Ready" means both binaries // (pandoc + chromium) are present on PATH. Sandboxing + resource // limits live in the wrapper scripts that PATH resolves to — out // of zddc-server's concern. The probe doesn't try to validate // those; if the wrapper is broken, the first conversion surfaces // the failure as a ConvertError with the wrapper's stderr. type Capabilities struct { PandocBinary string // resolved path, e.g. /usr/local/bin/pandoc PandocVersion string // first line of "pandoc --version" ChromiumBinary string // resolved path, e.g. /usr/local/bin/chromium-browser ChromiumVersion string // first line of "chromium-browser --version" ProbedAt time.Time Err error } // Ready reports whether conversions can be attempted. func (c Capabilities) Ready() bool { return c.PandocBinary != "" && c.ChromiumBinary != "" && c.Err == nil } // Reason returns a short human-friendly explanation when Ready() is // false. Used as the body of a 503. func (c Capabilities) Reason() string { if c.Err != nil { return c.Err.Error() } var missing []string if c.PandocBinary == "" { missing = append(missing, "pandoc") } if c.ChromiumBinary == "" { missing = append(missing, "chromium-browser") } if len(missing) > 0 { return fmt.Sprintf("conversion binary not found on PATH: %s — runtime image is missing the conversion toolchain (see zddc/runtime.Containerfile)", strings.Join(missing, ", ")) } return "unavailable" } var ( caps atomic.Pointer[Capabilities] probeCool sync.Mutex ) // Available returns the current Capabilities snapshot and whether // conversions can proceed. func Available() (Capabilities, bool) { p := caps.Load() if p == nil { return Capabilities{}, false } return *p, p.Ready() } // Probe resolves the conversion binaries on PATH and installs the // localRunner. Call once at server startup. Returns the captured // Capabilities for logging. // // Image responsibility: the binaries on PATH should be the wrapper // scripts at /usr/local/bin/{pandoc,chromium-browser} (shipped by // zddc/runtime.Containerfile). Each wrapper handles cgroup setup // + bwrap sandbox + exec of the real binary at /usr/bin/. // If an operator runs zddc-server outside the runtime image with // raw pandoc / chromium on PATH, the conversion still works but // without the per-call sandbox + resource caps. // // Failure here is non-fatal: the server still starts, conversion // endpoints just return 503. func Probe(ctx context.Context) Capabilities { probeCool.Lock() defer probeCool.Unlock() c := Capabilities{ProbedAt: time.Now()} pandocBin := currentPandocBinary() chromiumBin := currentChromiumBinary() if p, err := exec.LookPath(pandocBin); err == nil { c.PandocBinary = p if v, err := probeVersion(ctx, p); err == nil { c.PandocVersion = v } } if p, err := exec.LookPath(chromiumBin); err == nil { c.ChromiumBinary = p if v, err := probeVersion(ctx, p); err == nil { c.ChromiumVersion = v } } if c.PandocBinary == "" || c.ChromiumBinary == "" { c.Err = fmt.Errorf("%s", c.Reason()) caps.Store(&c) slog.Warn("convert: probe failed", "reason", c.Err.Error()) return c } InstallRunner(newLocalRunner()) caps.Store(&c) slog.Info("convert: ready", "pandoc_binary", c.PandocBinary, "pandoc_version", c.PandocVersion, "chromium_binary", c.ChromiumBinary, "chromium_version", c.ChromiumVersion) return c } // Reprobe re-runs Probe with the existing configuration. Used by // the handler when a request hits a not-Ready state — gives the // operator a way to recover (e.g. installed pandoc after server // start) without a server restart. Cooldown of 60 s between probes // to keep error-path requests cheap. func Reprobe(ctx context.Context) Capabilities { if p := caps.Load(); p != nil { if time.Since(p.ProbedAt) < 60*time.Second { return *p } } return Probe(ctx) } func probeVersion(ctx context.Context, binary string) (string, error) { c := exec.CommandContext(ctx, binary, "--version") out, err := c.CombinedOutput() if err != nil { return "", err } line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] return line, nil }