zddc-server can now invoke podman as a CLIENT against a remote socket
instead of creating containers in its own process. The sidecar pattern
in tnd-zddc-chart will use this so zddc-server's own pod stays
unprivileged (only the podman-system-service sidecar runs privileged).
New surface:
--convert-podman-socket / ZDDC_CONVERT_PODMAN_SOCKET
e.g. unix:///var/run/podman/podman.sock
Empty (default) → local mode (podman creates containers in
zddc-server's own filesystem namespace).
Non-empty → remote mode: `podman --remote --url=<this> run …`
dispatches each container request to whatever process owns the
socket. Typically a `podman system service` sidecar in the same
Kubernetes pod.
--convert-scratch-dir / ZDDC_CONVERT_SCRATCH_DIR
Host-side directory for per-conversion intermediates (template,
HTML, PDF). In remote mode this MUST be a path the sidecar sees
at the same mountpoint — typically a shared emptyDir at /work
in both containers. Empty = $TMPDIR (local-mode default).
Runner behaviour:
local mode → unchanged. `podman run --userns=host --rm --pull=missing
--network=none --read-only …`. `--userns=host` stays so nested-podman
on a privileged host (the previous chart shape) keeps working for
anyone still using it.
remote mode → `podman --remote --url=<sock> run --rm --pull=missing
--network=none --read-only …`. `--userns=host` is dropped because
the sidecar is rootful inside its own privileged container and
doesn't need userns juggling.
Health probe gains a Mode field ("local" | "remote") and, in remote
mode, runs `podman --remote --url=<sock> version` to confirm the
sidecar's socket is reachable. Unreachable-socket → 503 with a clear
reason (sidecar may still be starting up); reachable → ready.
Capabilities log now includes engine_version + mode + remote_url for
easier debugging of "which podman is actually doing the work".
No tests removed — the existing fake-runner table covers both modes
since the runner's args are uniform (remote prefix is the only thing
that differs).
307 lines
8.7 KiB
Go
307 lines
8.7 KiB
Go
package convert
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeRunner records the args it was invoked with and replays canned
|
|
// responses. Lets us assert the command lines + image refs without
|
|
// needing podman.
|
|
type fakeRunner struct {
|
|
mu sync.Mutex
|
|
calls [][]string
|
|
images []string
|
|
stdin [][]byte
|
|
mounts [][]string
|
|
resp []byte
|
|
err error
|
|
}
|
|
|
|
func (f *fakeRunner) Run(_ context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, append([]string(nil), cmd...))
|
|
f.images = append(f.images, image)
|
|
f.stdin = append(f.stdin, append([]byte(nil), stdin...))
|
|
f.mounts = append(f.mounts, append([]string(nil), mounts...))
|
|
return f.resp, f.err
|
|
}
|
|
|
|
func (f *fakeRunner) lastCall() (string, []string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if len(f.calls) == 0 {
|
|
return "", nil
|
|
}
|
|
return f.images[len(f.images)-1], f.calls[len(f.calls)-1]
|
|
}
|
|
|
|
func TestToDocx_UsesPandocImage(t *testing.T) {
|
|
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
|
|
InstallRunner(f)
|
|
t.Cleanup(func() { InstallRunner(nil) })
|
|
SetImages("docker.io/pandoc/latex:latest", "")
|
|
|
|
out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{
|
|
Title: "Hello",
|
|
Client: "Acme",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ToDocx: %v", err)
|
|
}
|
|
if string(out) != "FAKE-DOCX" {
|
|
t.Errorf("unexpected output: %q", out)
|
|
}
|
|
image, call := f.lastCall()
|
|
if image != "docker.io/pandoc/latex:latest" {
|
|
t.Errorf("expected pandoc image, got %q", image)
|
|
}
|
|
if !contains(call, "--to=docx") {
|
|
t.Errorf("missing --to=docx: %v", call)
|
|
}
|
|
if !contains(call, "title=Hello") {
|
|
t.Errorf("missing title metadata: %v", call)
|
|
}
|
|
if !contains(call, "client=Acme") {
|
|
t.Errorf("missing client metadata: %v", call)
|
|
}
|
|
// Last arg must be "-" so pandoc reads from stdin.
|
|
if call[len(call)-1] != "-" {
|
|
t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1])
|
|
}
|
|
}
|
|
|
|
func TestToHTML_UsesTemplateAndMountsScratch(t *testing.T) {
|
|
f := &fakeRunner{resp: []byte("<html>fake</html>")}
|
|
InstallRunner(f)
|
|
t.Cleanup(func() { InstallRunner(nil) })
|
|
SetImages("docker.io/pandoc/latex:latest", "")
|
|
|
|
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
|
|
if err != nil {
|
|
t.Fatalf("ToHTML: %v", err)
|
|
}
|
|
image, call := f.lastCall()
|
|
if image != "docker.io/pandoc/latex:latest" {
|
|
t.Errorf("expected pandoc image, got %q", image)
|
|
}
|
|
if !contains(call, "--template=/tpl/viewer-template.html") {
|
|
t.Errorf("template flag missing: %v", call)
|
|
}
|
|
if !contains(call, "--toc") {
|
|
t.Errorf("TOC flag missing (default NoTOC=false): %v", call)
|
|
}
|
|
if len(f.mounts) == 0 || len(f.mounts[0]) == 0 {
|
|
t.Fatalf("expected at least one bind mount for /tpl")
|
|
}
|
|
mount := f.mounts[0][0]
|
|
if !strings.Contains(mount, ":/tpl:") {
|
|
t.Errorf("mount missing /tpl: %q", mount)
|
|
}
|
|
}
|
|
|
|
func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
|
|
f := &fakeRunner{resp: []byte("<html/>")}
|
|
InstallRunner(f)
|
|
t.Cleanup(func() { InstallRunner(nil) })
|
|
|
|
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true})
|
|
_, call := f.lastCall()
|
|
if contains(call, "--toc") {
|
|
t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
|
|
}
|
|
if !contains(call, "no-toc=true") {
|
|
t.Errorf("no-toc metadata variable missing: %v", call)
|
|
}
|
|
}
|
|
|
|
// recordingRunner records every call and returns canned responses
|
|
// in sequence. Lets ToPDF tests assert the two-stage pipeline
|
|
// (pandoc image then chromium image).
|
|
type recordingRunner struct {
|
|
mu sync.Mutex
|
|
calls []recordedCall
|
|
resp [][]byte
|
|
err []error
|
|
cursor int
|
|
}
|
|
|
|
type recordedCall struct {
|
|
image string
|
|
cmd []string
|
|
mounts []string
|
|
}
|
|
|
|
func (r *recordingRunner) Run(_ context.Context, image string, _ []byte, mounts []string, cmd []string) ([]byte, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.calls = append(r.calls, recordedCall{
|
|
image: image,
|
|
cmd: append([]string(nil), cmd...),
|
|
mounts: append([]string(nil), mounts...),
|
|
})
|
|
if r.cursor >= len(r.resp) {
|
|
return nil, nil
|
|
}
|
|
out := r.resp[r.cursor]
|
|
var e error
|
|
if r.cursor < len(r.err) {
|
|
e = r.err[r.cursor]
|
|
}
|
|
r.cursor++
|
|
return out, e
|
|
}
|
|
|
|
func TestScratchDir_UsedByToHTML(t *testing.T) {
|
|
f := &fakeRunner{resp: []byte("<html/>")}
|
|
InstallRunner(f)
|
|
t.Cleanup(func() { InstallRunner(nil); SetScratchDir("") })
|
|
|
|
scratchRoot := t.TempDir()
|
|
SetScratchDir(scratchRoot)
|
|
|
|
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{})
|
|
if err != nil {
|
|
t.Fatalf("ToHTML: %v", err)
|
|
}
|
|
if len(f.mounts) == 0 || len(f.mounts[0]) == 0 {
|
|
t.Fatalf("expected at least one mount")
|
|
}
|
|
mount := f.mounts[0][0] // "<host>:/tpl:ro"
|
|
if !strings.HasPrefix(mount, scratchRoot+"/") {
|
|
t.Errorf("scratch dir not under configured root: %q (root=%q)", mount, scratchRoot)
|
|
}
|
|
}
|
|
|
|
func TestToPDF_TwoStagePipeline(t *testing.T) {
|
|
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
|
|
// the bind mount and writes /pdf/out.pdf. The fake runner can't
|
|
// actually write the PDF, so we expect ToPDF to fail at the
|
|
// read-back step — but we can still assert the two-stage call
|
|
// shape and the right image per stage.
|
|
r := &recordingRunner{
|
|
resp: [][]byte{
|
|
[]byte("<html><body>fake</body></html>"), // stage 1 stdout
|
|
nil, // stage 2 stdout (chromium writes PDF to bind mount)
|
|
},
|
|
}
|
|
InstallRunner(r)
|
|
t.Cleanup(func() { InstallRunner(nil) })
|
|
SetImages("docker.io/pandoc/latex:latest", "docker.io/zenika/alpine-chrome:latest")
|
|
|
|
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
|
|
// PDF read-back will fail (fake runner didn't write the file) —
|
|
// that's expected for this test which only inspects the call
|
|
// shape.
|
|
if err == nil {
|
|
t.Fatalf("expected error from PDF read-back; got nil")
|
|
}
|
|
if len(r.calls) != 2 {
|
|
t.Fatalf("expected 2 container calls (pandoc + chromium); got %d", len(r.calls))
|
|
}
|
|
if r.calls[0].image != "docker.io/pandoc/latex:latest" {
|
|
t.Errorf("stage 1 image: got %q want pandoc/latex", r.calls[0].image)
|
|
}
|
|
if r.calls[1].image != "docker.io/zenika/alpine-chrome:latest" {
|
|
t.Errorf("stage 2 image: got %q want alpine-chrome", r.calls[1].image)
|
|
}
|
|
// Stage 2 must include the --print-to-pdf flag pointing at /pdf.
|
|
if !contains(r.calls[1].cmd, "--print-to-pdf=/pdf/out.pdf") {
|
|
t.Errorf("chromium call missing --print-to-pdf flag: %v", r.calls[1].cmd)
|
|
}
|
|
if !contains(r.calls[1].cmd, "--no-sandbox") {
|
|
t.Errorf("chromium call missing --no-sandbox: %v", r.calls[1].cmd)
|
|
}
|
|
// Stage 2's bind mount must be writable (chromium writes the PDF).
|
|
if len(r.calls[1].mounts) == 0 || !strings.Contains(r.calls[1].mounts[0], ":rw") {
|
|
t.Errorf("chromium mount must be :rw, got %v", r.calls[1].mounts)
|
|
}
|
|
}
|
|
|
|
func TestErrUnavailable_WhenNoRunner(t *testing.T) {
|
|
InstallRunner(nil)
|
|
_, err := ToDocx(context.Background(), []byte("x"), Metadata{})
|
|
if !errors.Is(err, ErrUnavailable) {
|
|
t.Errorf("expected ErrUnavailable, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) {
|
|
args := metadataArgs(Metadata{
|
|
Title: "T",
|
|
Project: "P",
|
|
GenerationTime: time.Date(2026, 5, 13, 14, 30, 22, 0, time.UTC),
|
|
})
|
|
want := []string{
|
|
"-V", "title=T",
|
|
"-V", "project=P",
|
|
}
|
|
for i, w := range want {
|
|
if i >= len(args) || args[i] != w {
|
|
t.Fatalf("args[%d]: got %v want prefix %v", i, args, want)
|
|
}
|
|
}
|
|
joined := strings.Join(args, "|")
|
|
if !strings.Contains(joined, "generation_time=") || !strings.Contains(joined, "2026") {
|
|
t.Errorf("generation_time missing or malformed: %v", args)
|
|
}
|
|
if strings.Contains(joined, "client=") {
|
|
t.Errorf("empty client should not be passed: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestImageTag(t *testing.T) {
|
|
cases := map[string]string{
|
|
"docker.io/pandoc/latex:latest": "pandoc/latex",
|
|
"docker.io/zenika/alpine-chrome:latest": "zenika/alpine-chrome",
|
|
"pandoc/core": "pandoc/core",
|
|
"quay.io/example/foo:v1": "example/foo",
|
|
"alpine": "alpine",
|
|
}
|
|
for in, want := range cases {
|
|
if got := imageTag(in); got != want {
|
|
t.Errorf("imageTag(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSingleflight_Collapses(t *testing.T) {
|
|
var g singleflightGroup
|
|
const N = 50
|
|
var wg sync.WaitGroup
|
|
var hits int32
|
|
var mu sync.Mutex
|
|
|
|
wg.Add(N)
|
|
for i := 0; i < N; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = g.Do("k", func() (any, error) {
|
|
mu.Lock()
|
|
hits++
|
|
mu.Unlock()
|
|
time.Sleep(20 * time.Millisecond)
|
|
return "v", nil
|
|
})
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if hits != 1 {
|
|
t.Errorf("singleflight collapse: got %d invocations, want 1", hits)
|
|
}
|
|
}
|
|
|
|
// contains reports whether haystack has needle as any of its elements.
|
|
func contains(haystack []string, needle string) bool {
|
|
for _, s := range haystack {
|
|
if s == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|