package convert import ( "context" "fmt" "log/slog" "os/exec" "strings" "sync" "sync/atomic" "time" ) // remoteURL is set by Probe from cfg.ConvertPodmanSocket. Empty means // local mode. var remoteURL atomic.Pointer[string] // Capabilities is the snapshot of "can we convert right now?". The // only hard requirement is a container runtime reachable from // zddc-server — image presence is left to `--pull=missing` at // conversion time, so a missing image surfaces as a normal // ConvertError (not a probe failure). // // Mode applies to OCI engines (podman/docker): "local" when the // engine creates containers in the same process as zddc-server, // "remote" when zddc-server is the client of a podman-system-service // sidecar. The bwrap engine has no mode (always direct exec). type Capabilities struct { Engine string // "bwrap" | "podman" | "docker" | "" EngineVer string // first line of " --version" Mode string // "local" or "remote" (OCI engines only) RemoteURL string // populated in remote mode (OCI engines only) PandocImage string // resolved pandoc image ref (OCI engines) ChromiumImage string // resolved chromium image ref (OCI engines) ProbedAt time.Time Err error } // Ready reports whether conversions can be attempted. The first // conversion may still fail if the configured binary or image isn't // actually present (the runner will surface a clear error from the // child process's 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 conversion sandbox found (looked for bwrap, podman, docker on PATH)" } if c.Err != nil { if c.Mode == "remote" { return fmt.Sprintf("podman remote socket unreachable (%s): %s", c.RemoteURL, c.Err.Error()) } 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() } // SetRemoteURL installs the podman remote socket URL for subsequent // Probe / Reprobe calls. Empty means "local mode" (the engine binary // creates containers in the same process). Called from // cmd/zddc-server/main.go after flag parsing, before Probe. func SetRemoteURL(url string) { s := url remoteURL.Store(&s) } func currentRemoteURL() string { if p := remoteURL.Load(); p != nil { return *p } return "" } // 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. // // In remote mode (SetRemoteURL with non-empty URL), the probe also // invokes ` --remote --url= version` to confirm the // sidecar's socket is reachable. A reachable-engine-but-unreachable- // socket state surfaces as Ready=false so conversion requests serve // 503 until the sidecar comes up. // // Any failure here is non-fatal: the server still starts, conversion // endpoints just return 503. func Probe(ctx context.Context, engineOverride string) Capabilities { probeCool.Lock() defer probeCool.Unlock() now := time.Now() rURL := currentRemoteURL() c := Capabilities{ PandocImage: currentPandocImage(), ChromiumImage: currentChromiumImage(), Mode: "local", RemoteURL: rURL, ProbedAt: now, } if rURL != "" { c.Mode = "remote" } enginePath := resolveEngine(engineOverride) if enginePath == "" { c.Err = fmt.Errorf("no conversion sandbox found (tried: %s)", strings.Join(enginesTried(engineOverride), ", ")) caps.Store(&c) slog.Warn("convert: probe failed", "reason", c.Err.Error()) return c } kind := engineKind(enginePath) c.Engine = kind if v, err := probeVersion(ctx, enginePath); err == nil { c.EngineVer = v } // bwrap engine: no remote-mode concept, just install the runner. // The bwrap binary IS the sandbox; conversion binaries (pandoc, // chromium) are resolved separately from PATH at call time and // reported by the convert-health endpoint when ready. if kind == "bwrap" { InstallRunner(newBwrapRunner(enginePath)) caps.Store(&c) slog.Info("convert: ready", "engine", kind, "engine_path", enginePath, "engine_version", c.EngineVer, "pandoc_binary", currentPandocBinary(), "chromium_binary", currentChromiumBinary()) return c } // Legacy OCI engine (podman/docker). Optional remote-socket // connectivity check, then install containerRunner. if rURL != "" { if err := probeRemoteSocket(ctx, enginePath, rURL); err != nil { c.Err = err caps.Store(&c) slog.Warn("convert: remote socket probe failed", "engine", kind, "remote_url", rURL, "err", err) return c } } InstallRunner(newContainerRunner(enginePath, rURL)) caps.Store(&c) slog.Info("convert: ready", "engine", kind, "engine_path", enginePath, "engine_version", c.EngineVer, "mode", c.Mode, "remote_url", c.RemoteURL, "pandoc_image", c.PandocImage, "chromium_image", c.ChromiumImage) return c } // probeRemoteSocket runs ` --remote --url= version` with // a short timeout. Returns nil on success; a wrapped error otherwise. // The remote URL is typically a Unix socket path // (unix:///var/run/podman/podman.sock) in the sidecar pattern but a // TCP form (tcp://host:port) is accepted too. func probeRemoteSocket(ctx context.Context, engine, url string) error { c := exec.CommandContext(ctx, engine, "--remote", "--url="+url, "version", "--format={{.Client.Version}}") out, err := c.CombinedOutput() if err != nil { return fmt.Errorf("podman --remote version: %w (output: %s)", err, strings.TrimSpace(string(out))) } return nil } // 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 "" } // Probe order: bwrap (production default — lightest sandbox, no // daemon, no OCI engine), then podman / docker as legacy fallbacks // for hosts that already have a container engine and want OCI-image // isolation per conversion. for _, name := range []string{"bwrap", "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{"bwrap", "podman", "docker"} } // engineKind returns the engine-family label for a resolved binary // path. "bwrap" is its own engine; "podman" and "docker" are the // OCI-container engines handled by containerRunner. Used by Probe to // pick the right Runner implementation. func engineKind(resolved string) string { base := resolved if i := strings.LastIndex(base, "/"); i >= 0 { base = base[i+1:] } switch base { case "bwrap": return "bwrap" case "podman", "podman-remote": return "podman" case "docker": return "docker" } return base } 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 }