forgejo-pdf/services/pdfexport/runner.go
codex 1ce1370983
Some checks failed
pdfexport / pdfexport-worker-fixtures (push) Has been cancelled
Add server-side Markdown→PDF export (v0.1)
2025-12-16 17:52:53 +00:00

148 lines
3.5 KiB
Go

// SPDX-License-Identifier: MIT
package pdfexport
import (
"bytes"
gocontext "context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
const (
defaultWorkerTimeout = 60 * time.Second
maxWorkerStderrBytes = 256 * 1024
)
func runWorker(input workerInput) ([]byte, userError) {
if !setting.PDF.Enabled {
return nil, errBadRequest("ERR_PDF_DISABLED", "PDF export is disabled.")
}
jobDir, err := os.MkdirTemp("", "forgejo-pdfexport-job-*")
if err != nil {
return nil, errInternal("ERR_PDF_INTERNAL", "Failed to export PDF.")
}
defer func() {
if err := os.RemoveAll(jobDir); err != nil {
log.Error("pdfexport: remove job dir: %v", err)
}
}()
inPath := filepath.Join(jobDir, "input.json")
outPath := filepath.Join(jobDir, "output.pdf")
inBytes, err := json.Marshal(input)
if err != nil {
return nil, errInternal("ERR_PDF_INTERNAL", "Failed to export PDF.")
}
if err := os.WriteFile(inPath, inBytes, 0o600); err != nil {
return nil, errInternal("ERR_PDF_INTERNAL", "Failed to export PDF.")
}
runtime := setting.PDF.ContainerRuntime
if runtime == "" {
runtime = "podman"
}
image := setting.PDF.WorkerImage
if image == "" {
image = "localhost/forgejo/pdf-worker:v0.1"
}
ctx, cancel := gocontext.WithTimeout(gocontext.Background(), defaultWorkerTimeout)
defer cancel()
args := []string{
"run", "--rm",
"--network=none",
"--read-only",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--tmpfs", "/tmp:rw,noexec,nosuid,size=1024m",
"--volume", fmt.Sprintf("%s:/job:rw", jobDir),
"--memory", "2g",
"--cpus", "2",
image,
"node", "src/index.js",
"--in", "/job/input.json",
"--out", "/job/output.pdf",
}
if strings.Contains(runtime, "podman") {
// Proxmox/LXC deployments often confine AppArmor and can block the default container profile load.
args = append(args[:6], append([]string{"--security-opt=apparmor=unconfined"}, args[6:]...)...)
}
cmd := exec.CommandContext(ctx, runtime, args...)
cmd.Stdout = io.Discard
var stderr bytes.Buffer
cmd.Stderr = &limitedWriter{W: &stderr, N: maxWorkerStderrBytes}
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), gocontext.DeadlineExceeded) {
return nil, errInternal("ERR_PDF_TIMEOUT", "PDF export timed out.")
}
// Best-effort parse of worker error envelope from stderr JSONL.
if ue, ok := parseWorkerError(stderr.String()); ok {
return nil, ue
}
log.Error("pdfexport: worker failed: %v", err)
return nil, errInternal("ERR_PDF_WORKER_FAILED", "Failed to export PDF.")
}
pdf, err := os.ReadFile(outPath)
if err != nil || len(pdf) == 0 {
return nil, errInternal("ERR_PDF_WORKER_NO_OUTPUT", "Failed to export PDF.")
}
return pdf, userError{}
}
type limitedWriter struct {
W io.Writer
N int
}
func (w *limitedWriter) Write(p []byte) (int, error) {
if w.N <= 0 {
return len(p), nil
}
if len(p) > w.N {
p = p[:w.N]
}
n, err := w.W.Write(p)
w.N -= n
return n, err
}
func parseWorkerError(stderr string) (userError, bool) {
type workerErr struct {
ErrorID string `json:"error_id"`
Message string `json:"message"`
}
lines := strings.Split(stderr, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
var we workerErr
if err := json.Unmarshal([]byte(line), &we); err != nil {
continue
}
if we.ErrorID == "" || we.Message == "" {
continue
}
return errInternal(we.ErrorID, we.Message), true
}
return userError{}, false
}