mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-16 04:12:16 +01:00
process load balancing: broken but in progress
This commit is contained in:
1
internal/pkg/prinery/doc.go
Normal file
1
internal/pkg/prinery/doc.go
Normal file
@@ -0,0 +1 @@
|
||||
package prinery
|
||||
123
internal/pkg/prinery/prinery.go
Normal file
123
internal/pkg/prinery/prinery.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package prinery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/print"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/process"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
)
|
||||
|
||||
type request struct {
|
||||
ctx context.Context
|
||||
logger xlog.Logger
|
||||
print print.Print
|
||||
dest string
|
||||
result chan error
|
||||
}
|
||||
|
||||
type worker struct {
|
||||
work chan request
|
||||
proc process.Process
|
||||
}
|
||||
|
||||
func (w *worker) do(done chan *worker) {
|
||||
for {
|
||||
req := <-w.work
|
||||
req.result <- req.print.Print(req.ctx, req.dest, w.proc)
|
||||
done <- w
|
||||
}
|
||||
}
|
||||
|
||||
type Prinery struct {
|
||||
logger xlog.Logger
|
||||
manager process.Manager
|
||||
work chan request
|
||||
pool chan *worker
|
||||
done chan *worker
|
||||
}
|
||||
|
||||
func New(logger xlog.Logger, manager process.Manager, key process.Key) (*Prinery, error) {
|
||||
const op string = "prinery.New"
|
||||
processes := manager.Processes(key)
|
||||
nWorkers := len(processes)
|
||||
if nWorkers == 0 {
|
||||
err := fmt.Errorf("no processes found for key '%s'", string(key))
|
||||
return nil, xerror.New(op, err)
|
||||
}
|
||||
logger.DebugfOp(op, "found '%d' processes for key '%s'", nWorkers, string(key))
|
||||
work := make(chan request, 1)
|
||||
pool := make(chan *worker, nWorkers)
|
||||
done := make(chan *worker, nWorkers)
|
||||
for _, p := range processes {
|
||||
w := &worker{
|
||||
work: work,
|
||||
proc: p,
|
||||
}
|
||||
pool <- w
|
||||
go w.do(done)
|
||||
}
|
||||
return &Prinery{
|
||||
logger: logger,
|
||||
manager: manager,
|
||||
work: work,
|
||||
pool: pool,
|
||||
done: done,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Prinery) PrintRequest(ctx context.Context, logger xlog.Logger, prnt print.Print, dest string) error {
|
||||
const op string = "prinery.Prinery.PrintRequest"
|
||||
req := request{
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
print: prnt,
|
||||
dest: dest,
|
||||
result: make(chan error),
|
||||
}
|
||||
p.dispatch(req)
|
||||
err := <-req.result
|
||||
if err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Prinery) Start() {
|
||||
for {
|
||||
select {
|
||||
case req := <-p.work:
|
||||
p.dispatch(req)
|
||||
case w := <-p.done:
|
||||
p.completed(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Prinery) dispatch(req request) {
|
||||
const op string = "prinery.Prinery.dispatch"
|
||||
select {
|
||||
case w := <-p.pool:
|
||||
w.work <- req
|
||||
case <-req.ctx.Done():
|
||||
req.result <- xerror.New(op, req.ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Prinery) completed(w *worker) {
|
||||
const op string = "prinery.Prinerty.completed"
|
||||
go func() {
|
||||
// check process viability.
|
||||
isViable := p.manager.IsViable(w.proc)
|
||||
// check process memory usage.
|
||||
// TODO handle error.
|
||||
memory, _ := p.manager.Memory(w.proc)
|
||||
p.logger.DebugfOp(op, "%s: isViable = %t, memory = %d", w.proc.ID(), isViable, memory)
|
||||
// TODO manage viability and memory usage.
|
||||
// pushing back the worker.
|
||||
p.pool <- w
|
||||
}()
|
||||
|
||||
}
|
||||
265
internal/pkg/print/chrome.go
Normal file
265
internal/pkg/print/chrome.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
|
||||
"github.com/mafredri/cdp"
|
||||
"github.com/mafredri/cdp/devtool"
|
||||
"github.com/mafredri/cdp/protocol/network"
|
||||
"github.com/mafredri/cdp/protocol/page"
|
||||
"github.com/mafredri/cdp/protocol/target"
|
||||
"github.com/mafredri/cdp/rpcc"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/process"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xerrgroup"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
|
||||
)
|
||||
|
||||
type chromePrint struct {
|
||||
logger xlog.Logger
|
||||
url string
|
||||
opts ChromePrintOptions
|
||||
}
|
||||
|
||||
// ChromePrintOptions helps customizing the
|
||||
// Google Chrome Print result.
|
||||
type ChromePrintOptions struct {
|
||||
WaitDelay float64
|
||||
HeaderHTML string
|
||||
FooterHTML string
|
||||
PaperWidth float64
|
||||
PaperHeight float64
|
||||
MarginTop float64
|
||||
MarginBottom float64
|
||||
MarginLeft float64
|
||||
MarginRight float64
|
||||
Landscape bool
|
||||
}
|
||||
|
||||
// DefaultChromePrintOptions returns the default
|
||||
// Google Chrome Print options.
|
||||
func DefaultChromePrintOptions() ChromePrintOptions {
|
||||
const defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
|
||||
return ChromePrintOptions{
|
||||
WaitDelay: 0.0,
|
||||
HeaderHTML: defaultHeaderFooterHTML,
|
||||
FooterHTML: defaultHeaderFooterHTML,
|
||||
PaperWidth: 8.27,
|
||||
PaperHeight: 11.7,
|
||||
MarginTop: 1.0,
|
||||
MarginBottom: 1.0,
|
||||
MarginLeft: 1.0,
|
||||
MarginRight: 1.0,
|
||||
Landscape: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (p chromePrint) Print(ctx context.Context, dest string, proc process.Process) error {
|
||||
const op string = "print.chromePrint.Print"
|
||||
resolver := func() error {
|
||||
devtEndpoint := fmt.Sprintf("http://%s:%d", proc.Host(), proc.Port())
|
||||
devt, err := devtool.New(devtEndpoint).Version(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol.
|
||||
devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer devtConn.Close() // nolint: errcheck
|
||||
// create a new CDP Client that uses conn.
|
||||
devtClient := cdp.NewClient(devtConn)
|
||||
newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
/*
|
||||
close the browser context when done.
|
||||
we're not using the "default" context
|
||||
as it may timeout before actually closing
|
||||
the browser context.
|
||||
see: https://github.com/mafredri/cdp/issues/101#issuecomment-524533670
|
||||
*/
|
||||
disposeBrowserContextArgs := target.NewDisposeBrowserContextArgs(newContextTarget.BrowserContextID)
|
||||
defer devtClient.Target.DisposeBrowserContext(context.Background(), disposeBrowserContextArgs) // nolint: errcheck
|
||||
// create a new blank target with the new browser context.
|
||||
createTargetArgs := target.
|
||||
NewCreateTargetArgs("about:blank").
|
||||
SetBrowserContextID(newContextTarget.BrowserContextID)
|
||||
newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// connect the client to the new target.
|
||||
newTargetWsURL := fmt.Sprintf("ws://%s:%d/devtools/page/%s", proc.Host(), proc.Port(), newTarget.TargetID)
|
||||
newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer newContextConn.Close() // nolint: errcheck
|
||||
// create a new CDP Client that uses newContextConn.
|
||||
targetClient := cdp.NewClient(newContextConn)
|
||||
/*
|
||||
close the target when done.
|
||||
we're not using the "default" context
|
||||
as it may timeout before actually closing
|
||||
the target.
|
||||
see: https://github.com/mafredri/cdp/issues/101#issuecomment-524533670
|
||||
*/
|
||||
closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID)
|
||||
defer targetClient.Target.CloseTarget(context.Background(), closeTargetArgs) // nolint: errcheck
|
||||
// enable all events.
|
||||
if err := p.enableEvents(ctx, targetClient); err != nil {
|
||||
return err
|
||||
}
|
||||
// listen for all events.
|
||||
if err := p.listenEvents(ctx, targetClient); err != nil {
|
||||
return err
|
||||
}
|
||||
// apply a wait delay (if any).
|
||||
if p.opts.WaitDelay > 0.0 {
|
||||
// wait for a given amount of time (useful for javascript delay).
|
||||
p.logger.DebugfOp(op, "applying a wait delay of '%.2fs'...", p.opts.WaitDelay)
|
||||
time.Sleep(xtime.Duration(p.opts.WaitDelay))
|
||||
} else {
|
||||
p.logger.DebugOp(op, "no wait delay to apply, moving on...")
|
||||
}
|
||||
// print the page to PDF.
|
||||
print, err := targetClient.Page.PrintToPDF(
|
||||
ctx,
|
||||
page.NewPrintToPDFArgs().
|
||||
SetPaperWidth(p.opts.PaperWidth).
|
||||
SetPaperHeight(p.opts.PaperHeight).
|
||||
SetMarginTop(p.opts.MarginTop).
|
||||
SetMarginBottom(p.opts.MarginBottom).
|
||||
SetMarginLeft(p.opts.MarginLeft).
|
||||
SetMarginRight(p.opts.MarginRight).
|
||||
SetLandscape(p.opts.Landscape).
|
||||
SetDisplayHeaderFooter(true).
|
||||
SetHeaderTemplate(p.opts.HeaderHTML).
|
||||
SetFooterTemplate(p.opts.FooterHTML).
|
||||
SetPrintBackground(true),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ioutil.WriteFile(dest, print.Data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := resolver(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p chromePrint) enableEvents(ctx context.Context, client *cdp.Client) error {
|
||||
const op string = "print.chromePrint.enableEvents"
|
||||
// enable all the domain events that we're interested in.
|
||||
if err := xerrgroup.Run(
|
||||
func() error { return client.DOM.Enable(ctx) },
|
||||
func() error { return client.Network.Enable(ctx, network.NewEnableArgs()) },
|
||||
func() error { return client.Page.Enable(ctx) },
|
||||
func() error {
|
||||
return client.Page.SetLifecycleEventsEnabled(ctx, page.NewSetLifecycleEventsEnabledArgs(true))
|
||||
},
|
||||
func() error { return client.Runtime.Enable(ctx) },
|
||||
); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p chromePrint) listenEvents(ctx context.Context, client *cdp.Client) error {
|
||||
const op string = "print.chromePrint.listenEvents"
|
||||
resolver := func() error {
|
||||
// make sure Page events are enabled.
|
||||
if err := client.Page.Enable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// make sure Network events are enabled.
|
||||
if err := client.Network.Enable(ctx, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
// create all clients for events.
|
||||
domContentEventFired, err := client.Page.DOMContentEventFired(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer domContentEventFired.Close() // nolint: errcheck
|
||||
loadEventFired, err := client.Page.LoadEventFired(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer loadEventFired.Close() // nolint: errcheck
|
||||
lifecycleEvent, err := client.Page.LifecycleEvent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lifecycleEvent.Close() // nolint: errcheck
|
||||
loadingFinished, err := client.Network.LoadingFinished(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer loadingFinished.Close() // nolint: errcheck
|
||||
if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil {
|
||||
return err
|
||||
}
|
||||
// wait for all events.
|
||||
return xerrgroup.Run(
|
||||
func() error {
|
||||
_, err := domContentEventFired.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.logger.DebugOp(op, "event 'domContentEventFired' received")
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
_, err := loadEventFired.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.logger.DebugOp(op, "event 'loadEventFired' received")
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
const networkIdleEventName string = "networkIdle"
|
||||
for {
|
||||
ev, err := lifecycleEvent.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.logger.DebugfOp(op, "event '%s' received", ev.Name)
|
||||
if ev.Name == networkIdleEventName {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
_, err := loadingFinished.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.logger.DebugOp(op, "event 'loadingFinished' received")
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
if err := resolver(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Print(new(chromePrint))
|
||||
)
|
||||
1
internal/pkg/print/doc.go
Normal file
1
internal/pkg/print/doc.go
Normal file
@@ -0,0 +1 @@
|
||||
package print
|
||||
18
internal/pkg/print/html.go
Normal file
18
internal/pkg/print/html.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
)
|
||||
|
||||
// NewHTMLPrint returns a Print for
|
||||
// converting an HTML file to PDF.
|
||||
func NewHTMLPrint(logger xlog.Logger, fpath string, opts ChromePrintOptions) Print {
|
||||
URL := fmt.Sprintf("file://%s", fpath)
|
||||
return chromePrint{
|
||||
logger: logger,
|
||||
url: URL,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
70
internal/pkg/print/markdown.go
Normal file
70
internal/pkg/print/markdown.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/russross/blackfriday/v2"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
|
||||
)
|
||||
|
||||
// NewMarkdownPrint returns a Print for
|
||||
// converting a Markdown file to PDF.
|
||||
func NewMarkdownPrint(logger xlog.Logger, fpath string, opts ChromePrintOptions) (Print, error) {
|
||||
const op string = "print.NewMarkdownPrint"
|
||||
resolver := func() (string, error) {
|
||||
tmpl, err := template.
|
||||
New(filepath.Base(fpath)).
|
||||
Funcs(template.FuncMap{"toHTML": markdownToHTML}).
|
||||
ParseFiles(fpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dirPath := filepath.Dir(fpath)
|
||||
data := &templateData{DirPath: dirPath}
|
||||
logger.DebugOp(op, "converting Markdown files to HTML...")
|
||||
var buffer bytes.Buffer
|
||||
if err := tmpl.Execute(&buffer, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
baseFilename := xrand.Get()
|
||||
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
|
||||
logger.DebugOp(op, "writing the HTML from previous conversion(s) into new file...")
|
||||
if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("file://%s", dst), nil
|
||||
}
|
||||
URL, err := resolver()
|
||||
if err != nil {
|
||||
return chromePrint{}, xerror.New(op, err)
|
||||
}
|
||||
return chromePrint{
|
||||
logger: logger,
|
||||
url: URL,
|
||||
opts: opts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type templateData struct {
|
||||
DirPath string
|
||||
}
|
||||
|
||||
func markdownToHTML(dirPath, filename string) (template.HTML, error) {
|
||||
const op string = "print.markdownToHTML"
|
||||
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
|
||||
b, err := ioutil.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return "", xerror.New(op, err)
|
||||
}
|
||||
unsafe := blackfriday.Run(b)
|
||||
content := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
|
||||
/* #nosec */
|
||||
return template.HTML(content), nil
|
||||
}
|
||||
44
internal/pkg/print/merge.go
Normal file
44
internal/pkg/print/merge.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/process"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
)
|
||||
|
||||
type mergePrint struct {
|
||||
logger xlog.Logger
|
||||
fpaths []string
|
||||
}
|
||||
|
||||
// NewMergePrint returns a Print for
|
||||
// merging PDF files.
|
||||
func NewMergePrint(logger xlog.Logger, fpaths []string) Print {
|
||||
return mergePrint{
|
||||
logger: logger,
|
||||
fpaths: fpaths,
|
||||
}
|
||||
}
|
||||
|
||||
func (p mergePrint) Print(ctx context.Context, dest string, proc process.Process) error {
|
||||
const op string = "print.mergePrint.Print"
|
||||
p.logger.DebugfOp(op, "merging '%v'...", p.fpaths)
|
||||
resolver := func() error {
|
||||
var args []string
|
||||
args = append(args, p.fpaths...)
|
||||
args = append(args, "cat", "output", dest)
|
||||
cmd, err := xexec.CommandContext(ctx, p.logger, "pdftk", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
xexec.LogBeforeExecute(p.logger, cmd)
|
||||
return cmd.Run()
|
||||
}
|
||||
if err := resolver(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
109
internal/pkg/print/office.go
Normal file
109
internal/pkg/print/office.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/process"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
|
||||
)
|
||||
|
||||
type officePrint struct {
|
||||
logger xlog.Logger
|
||||
fpaths []string
|
||||
opts OfficePrintOptions
|
||||
}
|
||||
|
||||
// OfficePrintOptions helps customizing the
|
||||
// LibreOffice Print result.
|
||||
type OfficePrintOptions struct {
|
||||
Landscape bool
|
||||
}
|
||||
|
||||
// DefaultOfficePrinterOptions returns the default
|
||||
// LibreOffice Print options.
|
||||
func DefaultOfficePrinterOptions() OfficePrintOptions {
|
||||
return OfficePrintOptions{
|
||||
Landscape: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewOfficePrint returns a Print for
|
||||
// converting Office documents to PDF.
|
||||
func NewOfficePrint(logger xlog.Logger, fpaths []string, opts OfficePrintOptions) Print {
|
||||
return officePrint{
|
||||
logger: logger,
|
||||
fpaths: fpaths,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
func (p officePrint) Print(ctx context.Context, dest string, proc process.Process) error {
|
||||
const op string = "print.officePrint.Print"
|
||||
resolver := func() error {
|
||||
fpaths := make([]string, len(p.fpaths))
|
||||
dirPath := filepath.Dir(dest)
|
||||
for i, fpath := range p.fpaths {
|
||||
baseFilename := xrand.Get()
|
||||
tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename)
|
||||
p.logger.DebugfOp(op, "converting '%s' to PDF...", fpath)
|
||||
if err := unoconv(ctx, p.logger, fpath, tmpDest, p.opts); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logger.DebugfOp(op, "'%s.pdf' created", baseFilename)
|
||||
fpaths[i] = tmpDest
|
||||
}
|
||||
if len(fpaths) == 1 {
|
||||
p.logger.DebugOp(op, "only one PDF created, nothing to merge")
|
||||
if err := os.Rename(fpaths[0], dest); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
merger := NewMergePrint(p.logger, fpaths)
|
||||
return merger.Print(ctx, dest, nil)
|
||||
}
|
||||
if err := resolver(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unoconv(ctx context.Context, logger xlog.Logger, fpath, dest string, opts OfficePrintOptions) error {
|
||||
const op string = "print.unoconv"
|
||||
resolver := func() error {
|
||||
args := []string{
|
||||
"--format",
|
||||
"pdf",
|
||||
}
|
||||
if opts.Landscape {
|
||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||
}
|
||||
args = append(args, "--output", dest, fpath)
|
||||
cmd, err := xexec.CommandContext(
|
||||
ctx,
|
||||
logger,
|
||||
"unoconv",
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
xexec.LogBeforeExecute(logger, cmd)
|
||||
return cmd.Run()
|
||||
}
|
||||
if err := resolver(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Print(new(officePrint))
|
||||
)
|
||||
13
internal/pkg/print/print.go
Normal file
13
internal/pkg/print/print.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/process"
|
||||
)
|
||||
|
||||
// Print is a type that can create a PDF file from a source.
|
||||
// The source is defined in the underlying implementation.
|
||||
type Print interface {
|
||||
Print(ctx context.Context, dest string, proc process.Process) error
|
||||
}
|
||||
15
internal/pkg/print/url.go
Normal file
15
internal/pkg/print/url.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
)
|
||||
|
||||
// NewURLPrint returns a Print for
|
||||
// converting a URL to PDF.
|
||||
func NewURLPrint(logger xlog.Logger, url string, opts ChromePrintOptions) Print {
|
||||
return chromePrint{
|
||||
logger: logger,
|
||||
url: url,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
106
internal/pkg/process/chrome.go
Normal file
106
internal/pkg/process/chrome.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mafredri/cdp/devtool"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
)
|
||||
|
||||
const ChromeKey Key = "chrome"
|
||||
|
||||
type chromeProcess struct {
|
||||
id string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
// NewChromeProcess returns a Google Chrome
|
||||
// headless process.
|
||||
func NewChromeProcess(id, host string, port int) Process {
|
||||
return chromeProcess{
|
||||
id: id,
|
||||
host: host,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
func (p chromeProcess) ID() string {
|
||||
return p.id
|
||||
}
|
||||
|
||||
func (p chromeProcess) Host() string {
|
||||
return p.host
|
||||
}
|
||||
|
||||
func (p chromeProcess) Port() int {
|
||||
return p.port
|
||||
}
|
||||
|
||||
func (p chromeProcess) binary() string {
|
||||
return "google-chrome-stable"
|
||||
}
|
||||
|
||||
func (p chromeProcess) args() []string {
|
||||
return []string{
|
||||
"--no-sandbox",
|
||||
"--headless",
|
||||
// see https://github.com/GoogleChrome/puppeteer/issues/2410.
|
||||
"--font-render-hinting=medium",
|
||||
fmt.Sprintf("--remote-debugging-port=%d", p.port),
|
||||
"--disable-gpu",
|
||||
"--disable-translate",
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--safebrowsing-disable-auto-update",
|
||||
"--disable-sync",
|
||||
"--disable-default-apps",
|
||||
"--hide-scrollbars",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
}
|
||||
}
|
||||
|
||||
func (p chromeProcess) warmupTime() time.Duration {
|
||||
return 10 * time.Second
|
||||
}
|
||||
|
||||
func (p chromeProcess) viabilityFunc() func(logger xlog.Logger) bool {
|
||||
const op string = "process.chromeProcess.viabilityFunc"
|
||||
return func(logger xlog.Logger) bool {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
endpoint := fmt.Sprintf("http://%s:%d" /*p.host*/, "localhost", p.port)
|
||||
logger.DebugfOp(
|
||||
op,
|
||||
"checking '%s' viability via endpoint '%s/json/version'",
|
||||
p.ID(),
|
||||
endpoint,
|
||||
)
|
||||
v, err := devtool.New(endpoint).Version(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorfOp(
|
||||
op,
|
||||
"'%s' is not viable as endpoint returned '%v'",
|
||||
p.ID(),
|
||||
err,
|
||||
)
|
||||
return false
|
||||
}
|
||||
logger.DebugfOp(
|
||||
op,
|
||||
"'%s' is viable as endpoint returned '%v'",
|
||||
p.ID(),
|
||||
v,
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Process(new(chromeProcess))
|
||||
)
|
||||
1
internal/pkg/process/doc.go
Normal file
1
internal/pkg/process/doc.go
Normal file
@@ -0,0 +1 @@
|
||||
package process
|
||||
324
internal/pkg/process/pm2.go
Normal file
324
internal/pkg/process/pm2.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/conf"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
|
||||
)
|
||||
|
||||
type jlistItem struct {
|
||||
Name string `json:"name"`
|
||||
PM2Env struct {
|
||||
Status string `json:"status"`
|
||||
RestartTime int64 `json:"restart_time"`
|
||||
} `json:"pm2_env"`
|
||||
Monit struct {
|
||||
Memory int64 `json:"memory"`
|
||||
CPU float64 `json:"cpu"`
|
||||
} `json:"monit"`
|
||||
}
|
||||
|
||||
type jlist []jlistItem
|
||||
|
||||
func (list jlist) toList() List {
|
||||
var result List
|
||||
for _, current := range list {
|
||||
item := ListItem{
|
||||
Name: current.Name,
|
||||
Status: current.PM2Env.Status,
|
||||
Restart: current.PM2Env.RestartTime,
|
||||
Memory: current.Monit.Memory, // TODO humanize?
|
||||
CPU: current.Monit.CPU,
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (list jlist) isOnline(p Process) bool {
|
||||
const onlineStatus string = "online"
|
||||
for _, item := range list {
|
||||
if item.Name == p.ID() {
|
||||
return item.PM2Env.Status == onlineStatus
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (list jlist) memory(p Process) (int64, error) {
|
||||
const op string = "process.jlist.memory"
|
||||
for _, item := range list {
|
||||
if item.Name == p.ID() {
|
||||
return item.Monit.Memory, nil
|
||||
}
|
||||
}
|
||||
return 0, xerror.New(
|
||||
op,
|
||||
fmt.Errorf("'%s' does not exist in the list of PM2 processes", p.ID()),
|
||||
)
|
||||
}
|
||||
|
||||
type command string
|
||||
|
||||
const (
|
||||
startCommand command = "start"
|
||||
restartCommand command = "restart"
|
||||
stopCommand command = "stop"
|
||||
logsCommand command = "logs"
|
||||
jlistCommand command = "jlist"
|
||||
)
|
||||
|
||||
const maximumRestartAttempts uint = 3
|
||||
|
||||
type pm2Manager struct {
|
||||
logger xlog.Logger
|
||||
config conf.Config
|
||||
pool map[Key][]Process
|
||||
list *jlist
|
||||
listLock *sync.Mutex
|
||||
}
|
||||
|
||||
// NewPM2Manager returns a PM2 manager.
|
||||
func NewPM2Manager(logger xlog.Logger, config conf.Config) Manager {
|
||||
const op string = "process.NewPM2Manager"
|
||||
m := &pm2Manager{
|
||||
logger: logger,
|
||||
config: config,
|
||||
pool: make(map[Key][]Process),
|
||||
listLock: &sync.Mutex{},
|
||||
}
|
||||
if !config.DisableGoogleChrome() {
|
||||
processes := make([]Process, 2)
|
||||
availablePort := 9222
|
||||
// TODO from config
|
||||
for i := 0; i < 2; i++ {
|
||||
proc := chromeProcess{
|
||||
host: "127.0.0.1",
|
||||
port: availablePort,
|
||||
}
|
||||
proc.id = fmt.Sprintf("%s-%d", proc.binary(), proc.port)
|
||||
processes[i] = proc
|
||||
logger.DebugfOp(op, "added new process %v", proc)
|
||||
availablePort++
|
||||
}
|
||||
m.pool[ChromeKey] = processes
|
||||
}
|
||||
if !config.DisableUnoconv() {
|
||||
processes := make([]Process, 2)
|
||||
availablePort := 2002
|
||||
// TODO from config
|
||||
for i := 0; i < 2; i++ {
|
||||
proc := sofficeProcess{
|
||||
host: "127.0.0.1",
|
||||
port: availablePort,
|
||||
}
|
||||
proc.id = fmt.Sprintf("%s-%d", proc.binary(), proc.port)
|
||||
processes[i] = proc
|
||||
logger.DebugfOp(op, "added new process %v", proc)
|
||||
availablePort++
|
||||
}
|
||||
m.pool[SofficeKey] = processes
|
||||
}
|
||||
// update the manager processes list
|
||||
// only if there are processes.
|
||||
if !config.DisableGoogleChrome() || !config.DisableUnoconv() {
|
||||
go m.jlistTimer()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *pm2Manager) jlistTimer() {
|
||||
const op string = "process.pm2Manager.jlistTimer"
|
||||
duration := xtime.Duration(10)
|
||||
resolver := func() error {
|
||||
m.listLock.Lock()
|
||||
defer m.listLock.Unlock()
|
||||
out, err := exec.
|
||||
Command("pm2", string(jlistCommand)).
|
||||
Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := &jlist{}
|
||||
if err := json.Unmarshal(out, data); err != nil {
|
||||
return err
|
||||
}
|
||||
m.list = data
|
||||
return nil
|
||||
}
|
||||
// update every x seconds the
|
||||
// list from the manager.
|
||||
for range time.Tick(duration) {
|
||||
if err := resolver(); err != nil {
|
||||
m.logger.ErrorOp(op, xerror.New(op, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *pm2Manager) Start() error {
|
||||
const op string = "process.pm2Manager.Start"
|
||||
for _, processes := range m.pool {
|
||||
for _, proc := range processes {
|
||||
if err := m.start(proc); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *pm2Manager) start(p Process) error {
|
||||
const op string = "process.pm2Manager.start"
|
||||
resolver := func() error {
|
||||
// first, we try to start the process.
|
||||
if err := m.run(startCommand, p); err != nil {
|
||||
return err
|
||||
}
|
||||
// we wait the process to be ready.
|
||||
m.warmup(p)
|
||||
// if the process failed to start correctly,
|
||||
// we have to restart it.
|
||||
if !m.IsViable(p) && maximumRestartAttempts > 0 {
|
||||
return m.Restart(p)
|
||||
}
|
||||
// the process is viable, let's log its
|
||||
// output.
|
||||
return m.logs(p)
|
||||
}
|
||||
if err := resolver(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *pm2Manager) List() List {
|
||||
m.listLock.Lock()
|
||||
defer m.listLock.Unlock()
|
||||
return m.list.toList()
|
||||
}
|
||||
|
||||
func (m *pm2Manager) All() []Process {
|
||||
var result []Process
|
||||
for _, processes := range m.pool {
|
||||
result = append(result, processes...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *pm2Manager) Processes(key Key) []Process {
|
||||
return m.pool[key]
|
||||
}
|
||||
|
||||
func (m *pm2Manager) Restart(p Process) error {
|
||||
const op string = "process.pm2Manager.Restart"
|
||||
resolver := func() error {
|
||||
var attempts uint
|
||||
for attempts < maximumRestartAttempts {
|
||||
// we restart the process.
|
||||
if err := m.run(restartCommand, p); err != nil {
|
||||
return err
|
||||
}
|
||||
// we wait the process to be ready.
|
||||
m.warmup(p)
|
||||
attempts++
|
||||
// if the process is viable, we
|
||||
// leave.
|
||||
if m.IsViable(p) {
|
||||
return m.logs(p)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to start '%s'", p.ID())
|
||||
}
|
||||
if err := resolver(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *pm2Manager) Stop(p Process) error {
|
||||
const op string = "process.pm2Manager.Stop"
|
||||
if err := m.run(stopCommand, p); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *pm2Manager) IsViable(p Process) bool {
|
||||
if !p.viabilityFunc()(m.logger) {
|
||||
return false
|
||||
}
|
||||
m.listLock.Lock()
|
||||
defer m.listLock.Unlock()
|
||||
return m.list.isOnline(p)
|
||||
}
|
||||
|
||||
func (m *pm2Manager) Memory(p Process) (int64, error) {
|
||||
const op string = "process.pm2Manager.Memory"
|
||||
m.listLock.Lock()
|
||||
defer m.listLock.Unlock()
|
||||
memory, err := m.list.memory(p)
|
||||
if err != nil {
|
||||
return 0, xerror.New(op, err)
|
||||
}
|
||||
return memory, nil
|
||||
}
|
||||
|
||||
func (m *pm2Manager) warmup(p Process) {
|
||||
const op string = "process.pm2Manager.warmup"
|
||||
warmupTime := p.warmupTime()
|
||||
m.logger.DebugfOp(
|
||||
op,
|
||||
"waiting '%v' for allowing '%s' to warmup",
|
||||
warmupTime,
|
||||
p.ID(),
|
||||
)
|
||||
time.Sleep(warmupTime)
|
||||
}
|
||||
|
||||
func (m *pm2Manager) logs(p Process) error {
|
||||
const op string = "process.pm2Manager.logs"
|
||||
if m.config.LogLevel() == xlog.DebugLevel {
|
||||
if err := m.run(logsCommand, p); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *pm2Manager) run(pm2Cmd command, p Process) error {
|
||||
const op string = "process.pm2Manager.run"
|
||||
resolver := func() error {
|
||||
args := []string{
|
||||
string(pm2Cmd),
|
||||
p.binary(),
|
||||
}
|
||||
if pm2Cmd == startCommand {
|
||||
args = append(args, fmt.Sprintf("--name=%s", p.ID()))
|
||||
args = append(args, "--interpreter=none", "--")
|
||||
args = append(args, p.args()...)
|
||||
}
|
||||
cmd, err := xexec.Command(m.logger, "pm2", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
xexec.LogBeforeExecute(m.logger, cmd)
|
||||
return cmd.Start()
|
||||
}
|
||||
if err := resolver(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Manager(new(pm2Manager))
|
||||
)
|
||||
40
internal/pkg/process/process.go
Normal file
40
internal/pkg/process/process.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
)
|
||||
|
||||
type Process interface {
|
||||
ID() string
|
||||
Host() string
|
||||
Port() int
|
||||
binary() string
|
||||
args() []string
|
||||
warmupTime() time.Duration
|
||||
viabilityFunc() func(logger xlog.Logger) bool
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Restart int64 `json:"restart"`
|
||||
Memory int64 `json:"memory"`
|
||||
CPU float64 `json:"cpu"`
|
||||
}
|
||||
|
||||
type List []ListItem
|
||||
|
||||
type Key string
|
||||
|
||||
type Manager interface {
|
||||
Start() error
|
||||
List() List
|
||||
All() []Process
|
||||
Processes(key Key) []Process
|
||||
Restart(proc Process) error
|
||||
Stop(proc Process) error
|
||||
IsViable(proc Process) bool
|
||||
Memory(proc Process) (int64, error)
|
||||
}
|
||||
74
internal/pkg/process/soffice.go
Normal file
74
internal/pkg/process/soffice.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
|
||||
)
|
||||
|
||||
const SofficeKey Key = "soffice"
|
||||
|
||||
type sofficeProcess struct {
|
||||
id string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
// NewSofficeProcess returns a LibreOffice
|
||||
// headless process.
|
||||
func NewSofficeProcess(id, host string, port int) Process {
|
||||
return sofficeProcess{
|
||||
id: id,
|
||||
host: host,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
func (p sofficeProcess) ID() string {
|
||||
return p.id
|
||||
}
|
||||
|
||||
func (p sofficeProcess) Host() string {
|
||||
return p.host
|
||||
}
|
||||
|
||||
func (p sofficeProcess) Port() int {
|
||||
return p.port
|
||||
}
|
||||
|
||||
func (p sofficeProcess) binary() string {
|
||||
return "soffice"
|
||||
}
|
||||
|
||||
func (p sofficeProcess) args() []string {
|
||||
return []string{
|
||||
// see https://ask.libreoffice.org/en/question/42975/how-can-i-run-multiple-instances-of-sofficebin-at-a-time/.
|
||||
fmt.Sprintf("-env:UserInstallation=file:///tmp/%d", p.port),
|
||||
"--headless",
|
||||
"--invisible",
|
||||
"--nocrashreport",
|
||||
"--nodefault",
|
||||
"--nofirststartwizard",
|
||||
"--nologo",
|
||||
"--norestore",
|
||||
fmt.Sprintf("--accept=socket,host=%s,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", p.host, p.port),
|
||||
}
|
||||
}
|
||||
|
||||
func (p sofficeProcess) warmupTime() time.Duration {
|
||||
return 3 * time.Second
|
||||
}
|
||||
|
||||
func (p sofficeProcess) viabilityFunc() func(logger xlog.Logger) bool {
|
||||
const op string = "process.sofficeProcess.viabilityFunc"
|
||||
return func(logger xlog.Logger) bool {
|
||||
// TODO find a way to check.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Process(new(sofficeProcess))
|
||||
)
|
||||
6
internal/pkg/xerrgroup/doc.go
Normal file
6
internal/pkg/xerrgroup/doc.go
Normal file
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
Package xerrgroup helps running
|
||||
many functions simultaneously and wait until
|
||||
execution has completed or an error is encountered.
|
||||
*/
|
||||
package xerrgroup
|
||||
20
internal/pkg/xerrgroup/xerrgroup.go
Normal file
20
internal/pkg/xerrgroup/xerrgroup.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package xerrgroup
|
||||
|
||||
import (
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// Run runs all functions simultaneously and wait until
|
||||
// execution has completed or an error is encountered.
|
||||
func Run(fn ...func() error) error {
|
||||
const op string = "xerrgroup.Run"
|
||||
eg := errgroup.Group{}
|
||||
for _, f := range fn {
|
||||
eg.Go(f)
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
return xerror.New(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user