package convert import ( "context" "fmt" "log/slog" "os/exec" "strings" "sync" "sync/atomic" "time" ) // Capabilities is the snapshot of "can we convert right now?". The // only hard requirement is a container runtime on PATH — image presence // is left to `--pull=missing` at conversion time, so a missing image // surfaces as a normal ConvertError (not a probe failure). type Capabilities struct { Engine string // "podman" | "docker" | "" EngineVer string // first line of " --version" PandocImage string // resolved pandoc image ref ChromiumImage string // resolved chromium image ref ProbedAt time.Time Err error } // Ready reports whether conversions can be attempted. The first // conversion may still fail if the configured image isn't reachable // from the host's registry (the runner will surface a clear error // from podman/docker stderr). func (c Capabilities) Ready() bool { return c.Engine != "" && 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.Engine == "" { return "no container runtime (podman or docker) found on PATH" } if c.Err != nil { return c.Err.Error() } 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 locates the container engine and installs a containerRunner // as the package default. Call once at server startup. Returns the // captured Capabilities for logging. // // Engine order: engineOverride (if non-empty) → podman → docker. First // hit wins. Image presence is NOT probed: the runner uses // `--pull=missing` so the first conversion request will pull whichever // image it needs. // // Any failure here is non-fatal: the server still starts, conversion // endpoints just return 503. This matches the user's locked-in // requirement that no-container-runtime ⇒ "can't do conversions". func Probe(ctx context.Context, engineOverride string) Capabilities { probeCool.Lock() defer probeCool.Unlock() now := time.Now() c := Capabilities{ PandocImage: currentPandocImage(), ChromiumImage: currentChromiumImage(), ProbedAt: now, } engine := resolveEngine(engineOverride) if engine == "" { c.Err = fmt.Errorf("no container runtime found (tried: %s)", strings.Join(enginesTried(engineOverride), ", ")) caps.Store(&c) slog.Warn("convert: probe failed", "reason", c.Err.Error()) return c } c.Engine = engine if v, err := probeVersion(ctx, engine); err == nil { c.EngineVer = v } InstallRunner(newContainerRunner(engine)) caps.Store(&c) slog.Info("convert: ready", "engine", engine, "engine_version", c.EngineVer, "pandoc_image", c.PandocImage, "chromium_image", c.ChromiumImage) 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 podman after the server started) // without a server restart. Cooldown of 60 s between probes to keep // error-path requests cheap. func Reprobe(ctx context.Context, engineOverride string) Capabilities { if p := caps.Load(); p != nil { if time.Since(p.ProbedAt) < 60*time.Second { return *p } } return Probe(ctx, engineOverride) } func resolveEngine(override string) string { if override != "" { if p, err := exec.LookPath(override); err == nil { return p } return "" } for _, name := range []string{"podman", "docker"} { if p, err := exec.LookPath(name); err == nil { return p } } return "" } func enginesTried(override string) []string { if override != "" { return []string{override} } return []string{"podman", "docker"} } func probeVersion(ctx context.Context, engine string) (string, error) { c := exec.CommandContext(ctx, engine, "--version") out, err := c.CombinedOutput() if err != nil { return "", err } line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] return line, nil }