Compare commits
No commits in common. "52a6f139bbf0689a46cbbb4f3ba853e9347831b5" and "f37b55ddd5e21488e80aaa91893cd05cdfc71918" have entirely different histories.
52a6f139bb
...
f37b55ddd5
13 changed files with 55 additions and 206 deletions
|
|
@ -88,19 +88,13 @@ func main() {
|
||||||
"embedded_apps", embeddedVersionsForLog(embedded))
|
"embedded_apps", embeddedVersionsForLog(embedded))
|
||||||
|
|
||||||
// Probe the container runtime for the MD→{docx,html,pdf} endpoint.
|
// Probe the container runtime for the MD→{docx,html,pdf} endpoint.
|
||||||
// Non-fatal: if the host has no podman/docker (or the remote
|
// Non-fatal: if the host has no podman/docker, conversion requests
|
||||||
// socket is unreachable in sidecar mode), conversion requests
|
|
||||||
// return 503 and everything else keeps working. The probe installs
|
// return 503 and everything else keeps working. The probe installs
|
||||||
// the package-level Runner when an engine is found; the configured
|
// the package-level Runner when an engine is found; the configured
|
||||||
// image refs are pulled lazily on first conversion via
|
// image refs are pulled lazily on first conversion via
|
||||||
// `--pull=missing` so there's no manual setup beyond installing
|
// `--pull=missing` so there's no manual setup beyond installing
|
||||||
// podman or docker.
|
// podman or docker.
|
||||||
//
|
|
||||||
// SetRemoteURL + SetScratchDir must run BEFORE Probe so the probe
|
|
||||||
// can hit the sidecar socket when one is configured.
|
|
||||||
convert.SetImages(cfg.ConvertPandocImage, cfg.ConvertChromiumImage)
|
convert.SetImages(cfg.ConvertPandocImage, cfg.ConvertChromiumImage)
|
||||||
convert.SetRemoteURL(cfg.ConvertPodmanSocket)
|
|
||||||
convert.SetScratchDir(cfg.ConvertScratchDir)
|
|
||||||
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
convert.Probe(probeCtx, cfg.ConvertEngine)
|
convert.Probe(probeCtx, cfg.ConvertEngine)
|
||||||
probeCancel()
|
probeCancel()
|
||||||
|
|
|
||||||
|
|
@ -2470,7 +2470,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:17:48 · 7aec631</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1657,7 +1657,7 @@ html, body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:17:49 · 7aec631</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1681,7 +1681,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:17:48 · 7aec631</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1424,7 +1424,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:17:49 · 7aec631</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -2523,7 +2523,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:17:48 · 7aec631</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:06:57 · dfdd767</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.17-beta · 2026-05-13 17:17:48 · 7aec631
|
archive=v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767
|
||||||
transmittal=v0.0.17-beta · 2026-05-13 17:17:48 · 7aec631
|
transmittal=v0.0.17-beta · 2026-05-13 17:06:57 · dfdd767
|
||||||
classifier=v0.0.17-beta · 2026-05-13 17:17:48 · 7aec631
|
classifier=v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767
|
||||||
landing=v0.0.17-beta · 2026-05-13 17:17:49 · 7aec631
|
landing=v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767
|
||||||
form=v0.0.17-beta · 2026-05-13 17:17:49 · 7aec631
|
form=v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767
|
||||||
tables=v0.0.17-beta · 2026-05-13 17:17:49 · 7aec631
|
tables=v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767
|
||||||
browse=v0.0.17-beta · 2026-05-13 17:17:49 · 7aec631
|
browse=v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,6 @@ type Config struct {
|
||||||
ConvertPandocImage string // --convert-pandoc-image / ZDDC_CONVERT_PANDOC_IMAGE — image for MD→DOCX/HTML. Default docker.io/pandoc/latex:latest.
|
ConvertPandocImage string // --convert-pandoc-image / ZDDC_CONVERT_PANDOC_IMAGE — image for MD→DOCX/HTML. Default docker.io/pandoc/latex:latest.
|
||||||
ConvertChromiumImage string // --convert-chromium-image / ZDDC_CONVERT_CHROMIUM_IMAGE — image for HTML→PDF. Default docker.io/zenika/alpine-chrome:latest.
|
ConvertChromiumImage string // --convert-chromium-image / ZDDC_CONVERT_CHROMIUM_IMAGE — image for HTML→PDF. Default docker.io/zenika/alpine-chrome:latest.
|
||||||
ConvertEngine string // --convert-engine / ZDDC_CONVERT_ENGINE — override engine binary (default: probe for podman, then docker).
|
ConvertEngine string // --convert-engine / ZDDC_CONVERT_ENGINE — override engine binary (default: probe for podman, then docker).
|
||||||
ConvertPodmanSocket string // --convert-podman-socket / ZDDC_CONVERT_PODMAN_SOCKET — when non-empty, run podman in remote mode against this Unix socket (e.g. unix:///var/run/podman/podman.sock). Used with the Kubernetes sidecar pattern so zddc-server's own pod stays unprivileged.
|
|
||||||
ConvertScratchDir string // --convert-scratch-dir / ZDDC_CONVERT_SCRATCH_DIR — directory used for per-conversion scratch (template + HTML/PDF intermediates). Must be a path the remote podman can see at the same path. Empty = use $TMPDIR (local-mode default).
|
|
||||||
ConvertMemMiB int // --convert-mem-mib / ZDDC_CONVERT_MEM_MIB — per-container memory cap in MiB. Default 512.
|
ConvertMemMiB int // --convert-mem-mib / ZDDC_CONVERT_MEM_MIB — per-container memory cap in MiB. Default 512.
|
||||||
ConvertCPUs string // --convert-cpus / ZDDC_CONVERT_CPUS — per-container CPU limit. Default "2".
|
ConvertCPUs string // --convert-cpus / ZDDC_CONVERT_CPUS — per-container CPU limit. Default "2".
|
||||||
ConvertPIDs int // --convert-pids / ZDDC_CONVERT_PIDS — per-container PID limit. Default 100.
|
ConvertPIDs int // --convert-pids / ZDDC_CONVERT_PIDS — per-container PID limit. Default 100.
|
||||||
|
|
@ -149,10 +147,6 @@ func Load(args []string) (Config, error) {
|
||||||
"Headless Chromium container image for HTML→PDF. Pulled on first use via --pull=missing.")
|
"Headless Chromium container image for HTML→PDF. Pulled on first use via --pull=missing.")
|
||||||
convertEngineFlag := fs.String("convert-engine", os.Getenv("ZDDC_CONVERT_ENGINE"),
|
convertEngineFlag := fs.String("convert-engine", os.Getenv("ZDDC_CONVERT_ENGINE"),
|
||||||
"Container engine override (default: probe for podman, then docker).")
|
"Container engine override (default: probe for podman, then docker).")
|
||||||
convertPodmanSocketFlag := fs.String("convert-podman-socket", os.Getenv("ZDDC_CONVERT_PODMAN_SOCKET"),
|
|
||||||
"Run podman in remote mode against this Unix socket URL (e.g. unix:///var/run/podman/podman.sock). When set, the engine binary is invoked as `podman --remote --url=<this> run …`; the actual container creation happens in whatever process owns the socket (typically a podman-system-service sidecar). Empty = local mode.")
|
|
||||||
convertScratchDirFlag := fs.String("convert-scratch-dir", os.Getenv("ZDDC_CONVERT_SCRATCH_DIR"),
|
|
||||||
"Scratch directory for per-conversion intermediates (template, HTML, PDF). In remote mode this MUST be a path that the podman-service side can see at the same path — typically a shared emptyDir mounted at the same mountPath in both containers. Empty = use $TMPDIR (local mode).")
|
|
||||||
convertMemMiBFlag := fs.Int("convert-mem-mib", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_MEM_MIB"), 512),
|
convertMemMiBFlag := fs.Int("convert-mem-mib", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_MEM_MIB"), 512),
|
||||||
"Per-conversion container memory limit in MiB. Default 512.")
|
"Per-conversion container memory limit in MiB. Default 512.")
|
||||||
convertCPUsFlag := fs.String("convert-cpus", getEnv("ZDDC_CONVERT_CPUS", "2"),
|
convertCPUsFlag := fs.String("convert-cpus", getEnv("ZDDC_CONVERT_CPUS", "2"),
|
||||||
|
|
@ -236,8 +230,6 @@ func Load(args []string) (Config, error) {
|
||||||
ConvertPandocImage: *convertPandocImageFlag,
|
ConvertPandocImage: *convertPandocImageFlag,
|
||||||
ConvertChromiumImage: *convertChromiumImageFlag,
|
ConvertChromiumImage: *convertChromiumImageFlag,
|
||||||
ConvertEngine: *convertEngineFlag,
|
ConvertEngine: *convertEngineFlag,
|
||||||
ConvertPodmanSocket: *convertPodmanSocketFlag,
|
|
||||||
ConvertScratchDir: *convertScratchDirFlag,
|
|
||||||
ConvertMemMiB: *convertMemMiBFlag,
|
ConvertMemMiB: *convertMemMiBFlag,
|
||||||
ConvertCPUs: *convertCPUsFlag,
|
ConvertCPUs: *convertCPUsFlag,
|
||||||
ConvertPIDs: *convertPIDsFlag,
|
ConvertPIDs: *convertPIDsFlag,
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,6 @@ const (
|
||||||
var (
|
var (
|
||||||
pandocImage atomic.Pointer[string]
|
pandocImage atomic.Pointer[string]
|
||||||
chromiumImage atomic.Pointer[string]
|
chromiumImage atomic.Pointer[string]
|
||||||
scratchDir atomic.Pointer[string]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetImages installs the image refs used for subsequent ToDocx/ToHTML/
|
// SetImages installs the image refs used for subsequent ToDocx/ToHTML/
|
||||||
|
|
@ -85,24 +84,6 @@ func SetImages(pandoc, chromium string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetScratchDir installs the host-side scratch root used for per-call
|
|
||||||
// intermediates (template, HTML, PDF). Empty means "use $TMPDIR" — the
|
|
||||||
// local-mode default. In remote mode this MUST be a path the podman-
|
|
||||||
// service sidecar can see at the same mountpoint, typically a shared
|
|
||||||
// emptyDir mounted at /work in both containers. Called from
|
|
||||||
// cmd/zddc-server/main.go after flag parsing.
|
|
||||||
func SetScratchDir(dir string) {
|
|
||||||
s := dir
|
|
||||||
scratchDir.Store(&s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentScratchDir() string {
|
|
||||||
if p := scratchDir.Load(); p != nil {
|
|
||||||
return *p
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentPandocImage() string {
|
func currentPandocImage() string {
|
||||||
if p := pandocImage.Load(); p != nil && *p != "" {
|
if p := pandocImage.Load(); p != nil && *p != "" {
|
||||||
return *p
|
return *p
|
||||||
|
|
@ -144,7 +125,7 @@ func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
scratch, err := writeAssetsToScratch(currentScratchDir())
|
scratch, err := writeAssetsToScratch()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scratch: %w", err)
|
return nil, fmt.Errorf("scratch: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +172,7 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
scratch, err := os.MkdirTemp(currentScratchDir(), "zddc-pdf-")
|
scratch, err := os.MkdirTemp("", "zddc-pdf-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scratch: %w", err)
|
return nil, fmt.Errorf("scratch: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,27 +157,6 @@ func (r *recordingRunner) Run(_ context.Context, image string, _ []byte, mounts
|
||||||
return out, e
|
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) {
|
func TestToPDF_TwoStagePipeline(t *testing.T) {
|
||||||
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
|
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
|
||||||
// the bind mount and writes /pdf/out.pdf. The fake runner can't
|
// the bind mount and writes /pdf/out.pdf. The fake runner can't
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,13 @@ import (
|
||||||
"time"
|
"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
|
// Capabilities is the snapshot of "can we convert right now?". The
|
||||||
// only hard requirement is a container runtime reachable from
|
// only hard requirement is a container runtime on PATH — image presence
|
||||||
// zddc-server — image presence is left to `--pull=missing` at
|
// is left to `--pull=missing` at conversion time, so a missing image
|
||||||
// conversion time, so a missing image surfaces as a normal
|
// surfaces as a normal ConvertError (not a probe failure).
|
||||||
// ConvertError (not a probe failure).
|
|
||||||
//
|
|
||||||
// Mode is "local" when the engine creates containers in the same
|
|
||||||
// process as zddc-server, or "remote" when zddc-server is the client
|
|
||||||
// of a podman-system-service sidecar (see ContainerRunner doc).
|
|
||||||
type Capabilities struct {
|
type Capabilities struct {
|
||||||
Engine string // "podman" | "docker" | ""
|
Engine string // "podman" | "docker" | ""
|
||||||
EngineVer string // first line of "<engine> --version"
|
EngineVer string // first line of "<engine> --version"
|
||||||
Mode string // "local" or "remote"
|
|
||||||
RemoteURL string // populated in remote mode
|
|
||||||
PandocImage string // resolved pandoc image ref
|
PandocImage string // resolved pandoc image ref
|
||||||
ChromiumImage string // resolved chromium image ref
|
ChromiumImage string // resolved chromium image ref
|
||||||
ProbedAt time.Time
|
ProbedAt time.Time
|
||||||
|
|
@ -50,9 +39,6 @@ func (c Capabilities) Reason() string {
|
||||||
return "no container runtime (podman or docker) found on PATH"
|
return "no container runtime (podman or docker) found on PATH"
|
||||||
}
|
}
|
||||||
if c.Err != nil {
|
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 c.Err.Error()
|
||||||
}
|
}
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
|
@ -73,22 +59,6 @@ func Available() (Capabilities, bool) {
|
||||||
return *p, p.Ready()
|
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
|
// Probe locates the container engine and installs a containerRunner
|
||||||
// as the package default. Call once at server startup. Returns the
|
// as the package default. Call once at server startup. Returns the
|
||||||
// captured Capabilities for logging.
|
// captured Capabilities for logging.
|
||||||
|
|
@ -98,30 +68,19 @@ func currentRemoteURL() string {
|
||||||
// `--pull=missing` so the first conversion request will pull whichever
|
// `--pull=missing` so the first conversion request will pull whichever
|
||||||
// image it needs.
|
// image it needs.
|
||||||
//
|
//
|
||||||
// In remote mode (SetRemoteURL with non-empty URL), the probe also
|
|
||||||
// invokes `<engine> --remote --url=<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
|
// Any failure here is non-fatal: the server still starts, conversion
|
||||||
// endpoints just return 503.
|
// 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 {
|
func Probe(ctx context.Context, engineOverride string) Capabilities {
|
||||||
probeCool.Lock()
|
probeCool.Lock()
|
||||||
defer probeCool.Unlock()
|
defer probeCool.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
rURL := currentRemoteURL()
|
|
||||||
c := Capabilities{
|
c := Capabilities{
|
||||||
PandocImage: currentPandocImage(),
|
PandocImage: currentPandocImage(),
|
||||||
ChromiumImage: currentChromiumImage(),
|
ChromiumImage: currentChromiumImage(),
|
||||||
Mode: "local",
|
|
||||||
RemoteURL: rURL,
|
|
||||||
ProbedAt: now,
|
ProbedAt: now,
|
||||||
}
|
}
|
||||||
if rURL != "" {
|
|
||||||
c.Mode = "remote"
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := resolveEngine(engineOverride)
|
engine := resolveEngine(engineOverride)
|
||||||
if engine == "" {
|
if engine == "" {
|
||||||
|
|
@ -136,42 +95,16 @@ func Probe(ctx context.Context, engineOverride string) Capabilities {
|
||||||
c.EngineVer = v
|
c.EngineVer = v
|
||||||
}
|
}
|
||||||
|
|
||||||
if rURL != "" {
|
InstallRunner(newContainerRunner(engine))
|
||||||
if err := probeRemoteSocket(ctx, engine, rURL); err != nil {
|
|
||||||
c.Err = err
|
|
||||||
caps.Store(&c)
|
|
||||||
slog.Warn("convert: remote socket probe failed",
|
|
||||||
"engine", engine, "remote_url", rURL, "err", err)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InstallRunner(newContainerRunner(engine, rURL))
|
|
||||||
caps.Store(&c)
|
caps.Store(&c)
|
||||||
slog.Info("convert: ready",
|
slog.Info("convert: ready",
|
||||||
"engine", engine,
|
"engine", engine,
|
||||||
"engine_version", c.EngineVer,
|
"engine_version", c.EngineVer,
|
||||||
"mode", c.Mode,
|
|
||||||
"remote_url", c.RemoteURL,
|
|
||||||
"pandoc_image", c.PandocImage,
|
"pandoc_image", c.PandocImage,
|
||||||
"chromium_image", c.ChromiumImage)
|
"chromium_image", c.ChromiumImage)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeRemoteSocket runs `<engine> --remote --url=<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
|
// Reprobe re-runs Probe with the existing configuration. Used by the
|
||||||
// handler when a request hits a not-Ready state — gives the operator
|
// handler when a request hits a not-Ready state — gives the operator
|
||||||
// a way to recover (e.g. installed podman after the server started)
|
// a way to recover (e.g. installed podman after the server started)
|
||||||
|
|
|
||||||
|
|
@ -67,21 +67,6 @@ func (e *ConvertError) Unwrap() error { return e.Cause }
|
||||||
// per call so the same runner handles both pandoc and chromium
|
// per call so the same runner handles both pandoc and chromium
|
||||||
// invocations.
|
// invocations.
|
||||||
//
|
//
|
||||||
// Two modes:
|
|
||||||
//
|
|
||||||
// - **local** (remoteURL=""): the engine binary creates containers
|
|
||||||
// directly on the host that runs zddc-server. Used for bare-metal
|
|
||||||
// and host-podman deployments. Requires podman or docker on PATH.
|
|
||||||
//
|
|
||||||
// - **remote** (remoteURL="unix:///var/run/podman/podman.sock" or
|
|
||||||
// similar): the engine binary is the local podman CLIENT, invoked
|
|
||||||
// as `podman --remote --url=<remoteURL> run …`; the actual
|
|
||||||
// container creation happens in whatever process owns the socket
|
|
||||||
// (typically a `podman system service` sidecar in the same pod).
|
|
||||||
// Used for the Kubernetes sidecar pattern so zddc-server's own
|
|
||||||
// pod stays unprivileged. Bind-mount paths must resolve identically
|
|
||||||
// on both sides — see scratchDir.
|
|
||||||
//
|
|
||||||
// The runner relies on `--pull=missing` so the operator never has to
|
// The runner relies on `--pull=missing` so the operator never has to
|
||||||
// pre-pull images: the first request that needs an image pulls it,
|
// pre-pull images: the first request that needs an image pulls it,
|
||||||
// subsequent requests use the local cache. Both podman and docker
|
// subsequent requests use the local cache. Both podman and docker
|
||||||
|
|
@ -89,7 +74,6 @@ func (e *ConvertError) Unwrap() error { return e.Cause }
|
||||||
type containerRunner struct {
|
type containerRunner struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
engine string
|
engine string
|
||||||
remoteURL string
|
|
||||||
memMiB int
|
memMiB int
|
||||||
cpus string
|
cpus string
|
||||||
pids int
|
pids int
|
||||||
|
|
@ -155,10 +139,9 @@ func (cr *containerRunner) SetLimits(memMiB int, cpus string, pids int, timeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newContainerRunner(engine, remoteURL string) *containerRunner {
|
func newContainerRunner(engine string) *containerRunner {
|
||||||
return &containerRunner{
|
return &containerRunner{
|
||||||
engine: engine,
|
engine: engine,
|
||||||
remoteURL: remoteURL,
|
|
||||||
memMiB: 512,
|
memMiB: 512,
|
||||||
cpus: "2",
|
cpus: "2",
|
||||||
pids: 100,
|
pids: 100,
|
||||||
|
|
@ -199,7 +182,6 @@ func newContainerRunner(engine, remoteURL string) *containerRunner {
|
||||||
func (cr *containerRunner) Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
|
func (cr *containerRunner) Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
|
||||||
cr.mu.RLock()
|
cr.mu.RLock()
|
||||||
engine := cr.engine
|
engine := cr.engine
|
||||||
remoteURL := cr.remoteURL
|
|
||||||
memMiB := cr.memMiB
|
memMiB := cr.memMiB
|
||||||
cpus := cr.cpus
|
cpus := cr.cpus
|
||||||
pids := cr.pids
|
pids := cr.pids
|
||||||
|
|
@ -216,32 +198,25 @@ func (cr *containerRunner) Run(ctx context.Context, image string, stdin []byte,
|
||||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Client args. In remote mode, prepend --remote and --url so the
|
args := []string{
|
||||||
// podman CLI dispatches the request to the sidecar's
|
|
||||||
// `podman system service` instead of creating a container locally.
|
|
||||||
// The remaining flags (--rm, --pull=missing, etc.) apply to the
|
|
||||||
// container that the remote daemon will create — same wire format
|
|
||||||
// as local mode.
|
|
||||||
var args []string
|
|
||||||
if remoteURL != "" {
|
|
||||||
args = append(args, "--remote", "--url="+remoteURL)
|
|
||||||
}
|
|
||||||
args = append(args,
|
|
||||||
"run",
|
"run",
|
||||||
"--rm",
|
"--rm",
|
||||||
"--pull=missing",
|
"--pull=missing",
|
||||||
"-i",
|
"-i",
|
||||||
)
|
// --userns=host: reuse the calling process's user namespace
|
||||||
// --userns=host only in local mode: needed when zddc-server itself
|
// instead of creating a new one. Required for the nested-
|
||||||
// is the one running podman inside a Kubernetes pod, because the
|
// podman case (zddc-server runs inside a Kubernetes pod and
|
||||||
// kernel won't let an inner rootless podman set up its own userns
|
// invokes podman from there): the kernel won't let the inner
|
||||||
// via newuidmap. In remote (sidecar) mode the sidecar runs as root
|
// podman set up its own userns via newuidmap when /etc/subuid
|
||||||
// and creates the inner container in its own (rootful) namespace,
|
// mappings don't resolve through the pod's namespace, even
|
||||||
// so --userns=host is unnecessary and potentially noisy.
|
// with CAP_SETUID via privileged: true. The chart already
|
||||||
if remoteURL == "" {
|
// runs the pod privileged, so reusing its userns adds no new
|
||||||
args = append(args, "--userns=host")
|
// privilege escalation. On a bare-metal host invocation the
|
||||||
}
|
// outer userns is the host's, so --userns=host means "no
|
||||||
args = append(args,
|
// userns remapping" — also fine; --cap-drop=ALL +
|
||||||
|
// --network=none + --read-only continue to isolate the
|
||||||
|
// inner container's process.
|
||||||
|
"--userns=host",
|
||||||
"--network=none",
|
"--network=none",
|
||||||
"--read-only",
|
"--read-only",
|
||||||
"--tmpfs=/tmp:size=128m,exec",
|
"--tmpfs=/tmp:size=128m,exec",
|
||||||
|
|
@ -253,7 +228,7 @@ func (cr *containerRunner) Run(ctx context.Context, image string, stdin []byte,
|
||||||
"--security-opt=no-new-privileges",
|
"--security-opt=no-new-privileges",
|
||||||
"--env=HOME=/tmp",
|
"--env=HOME=/tmp",
|
||||||
"--workdir=/tmp",
|
"--workdir=/tmp",
|
||||||
)
|
}
|
||||||
for _, m := range mounts {
|
for _, m := range mounts {
|
||||||
if !strings.Contains(m, ":ro") && !strings.Contains(m, ":rw") {
|
if !strings.Contains(m, ":ro") && !strings.Contains(m, ":rw") {
|
||||||
m += ":ro"
|
m += ":ro"
|
||||||
|
|
@ -385,20 +360,15 @@ func (r *ringWriter) String() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeAssetsToScratch materialises the embedded viewer-template.html
|
// writeAssetsToScratch materialises the embedded viewer-template.html
|
||||||
// and custom.css into a fresh scratch dir and returns the host path.
|
// and custom.css into a fresh scratch dir under TMPDIR and returns the
|
||||||
// Caller is responsible for os.RemoveAll(dir) when done. Used by
|
// host path. Caller is responsible for os.RemoveAll(dir) when done.
|
||||||
// ToHTML which needs the template visible inside the container.
|
// Used by ToHTML which needs the template visible inside the container.
|
||||||
//
|
|
||||||
// scratchRoot controls where the temp dir lands. Empty means "use
|
|
||||||
// $TMPDIR" (local mode default). In remote/sidecar mode the caller
|
|
||||||
// passes the shared mount path (e.g. "/work") so the podman-service
|
|
||||||
// sidecar sees the bind-mount source at the same path.
|
|
||||||
//
|
//
|
||||||
// Files are written world-readable so the container's default user
|
// Files are written world-readable so the container's default user
|
||||||
// (root for pandoc/latex, uid 1000 for alpine-chrome) can read them
|
// (root for pandoc/latex, uid 1000 for alpine-chrome) can read them
|
||||||
// through the read-only bind mount regardless of the host's umask.
|
// through the read-only bind mount regardless of the host's umask.
|
||||||
func writeAssetsToScratch(scratchRoot string) (string, error) {
|
func writeAssetsToScratch() (string, error) {
|
||||||
dir, err := os.MkdirTemp(scratchRoot, "zddc-convert-")
|
dir, err := os.MkdirTemp("", "zddc-convert-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("scratch dir: %w", err)
|
return "", fmt.Errorf("scratch dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:17:49 · 7aec631</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 17:06:58 · dfdd767</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue