Backend image rendering as plugin (#11966)
* rendering: headless chrome progress * renderer: minor change * grpc: version hell * updated grpc libs * wip: minor progess * rendering: new image rendering plugin is starting to work * feat: now phantomjs works as well and updated alerting to use new rendering service * refactor: renamed renderer package and service to rendering to make renderer name less confusing (rendering is internal service that handles the renderer plugin now) * rendering: now render key is passed and render auth is working in plugin mode * removed unneeded lines from gitignore * rendering: now plugin mode supports waiting for all panels to complete rendering * fix: LastSeenAt fix for render calls, was not set which causes a lot of updates to Last Seen at during rendering, this should fix sqlite db locked issues in seen in previous releases * change: changed render tz url parameter to use proper timezone name as chrome does not handle UTC offset TZ values * fix: another update to tz param generation * renderer: added http mode to renderer service, new ini setting [rendering] server_url
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var netTransport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
Dial: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).Dial,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*RenderResult, error) {
|
||||
filePath := rs.getFilePathForNewImage()
|
||||
|
||||
var netClient = &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: netTransport,
|
||||
}
|
||||
|
||||
rendererUrl, err := url.Parse(rs.Cfg.RendererUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queryParams := rendererUrl.Query()
|
||||
queryParams.Add("url", rs.getURL(opts.Path))
|
||||
queryParams.Add("renderKey", rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole))
|
||||
queryParams.Add("width", strconv.Itoa(opts.Width))
|
||||
queryParams.Add("height", strconv.Itoa(opts.Height))
|
||||
queryParams.Add("domain", rs.getLocalDomain())
|
||||
queryParams.Add("timezone", isoTimeOffsetToPosixTz(opts.Timezone))
|
||||
queryParams.Add("encoding", opts.Encoding)
|
||||
queryParams.Add("timeout", strconv.Itoa(int(opts.Timeout.Seconds())))
|
||||
rendererUrl.RawQuery = queryParams.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", rendererUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// make request to renderer server
|
||||
resp, err := netClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// save response to file
|
||||
defer resp.Body.Close()
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer out.Close()
|
||||
io.Copy(out, resp.Body)
|
||||
|
||||
return &RenderResult{FilePath: filePath}, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter")
|
||||
var ErrNoRenderer = errors.New("No renderer plugin found nor is an external render server configured")
|
||||
|
||||
type Opts struct {
|
||||
Width int
|
||||
Height int
|
||||
Timeout time.Duration
|
||||
OrgId int64
|
||||
UserId int64
|
||||
OrgRole models.RoleType
|
||||
Path string
|
||||
Encoding string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type RenderResult struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type renderFunc func(ctx context.Context, options Opts) (*RenderResult, error)
|
||||
|
||||
type Service interface {
|
||||
Render(ctx context.Context, opts Opts) (*RenderResult, error)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
)
|
||||
|
||||
func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (*RenderResult, error) {
|
||||
rs.log.Info("Rendering", "path", opts.Path)
|
||||
|
||||
var executable = "phantomjs"
|
||||
if runtime.GOOS == "windows" {
|
||||
executable = executable + ".exe"
|
||||
}
|
||||
|
||||
url := rs.getURL(opts.Path)
|
||||
binPath, _ := filepath.Abs(filepath.Join(rs.Cfg.PhantomDir, executable))
|
||||
scriptPath, _ := filepath.Abs(filepath.Join(rs.Cfg.PhantomDir, "render.js"))
|
||||
pngPath := rs.getFilePathForNewImage()
|
||||
|
||||
renderKey := middleware.AddRenderAuthKey(opts.OrgId, opts.UserId, opts.OrgRole)
|
||||
defer middleware.RemoveRenderAuthKey(renderKey)
|
||||
|
||||
phantomDebugArg := "--debug=false"
|
||||
if log.GetLogLevelFor("renderer") >= log.LvlDebug {
|
||||
phantomDebugArg = "--debug=true"
|
||||
}
|
||||
|
||||
cmdArgs := []string{
|
||||
"--ignore-ssl-errors=true",
|
||||
"--web-security=false",
|
||||
phantomDebugArg,
|
||||
scriptPath,
|
||||
fmt.Sprintf("url=%v", url),
|
||||
fmt.Sprintf("width=%v", opts.Width),
|
||||
fmt.Sprintf("height=%v", opts.Height),
|
||||
fmt.Sprintf("png=%v", pngPath),
|
||||
fmt.Sprintf("domain=%v", rs.getLocalDomain()),
|
||||
fmt.Sprintf("timeout=%v", opts.Timeout.Seconds()),
|
||||
fmt.Sprintf("renderKey=%v", renderKey),
|
||||
}
|
||||
|
||||
if opts.Encoding != "" {
|
||||
cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", opts.Encoding)}, cmdArgs...)
|
||||
}
|
||||
|
||||
commandCtx, _ := context.WithTimeout(ctx, opts.Timeout+time.Second*2)
|
||||
cmd := exec.CommandContext(commandCtx, binPath, cmdArgs...)
|
||||
cmd.Stderr = cmd.Stdout
|
||||
|
||||
if opts.Timezone != "" {
|
||||
baseEnviron := os.Environ()
|
||||
cmd.Env = appendEnviron(baseEnviron, "TZ", isoTimeOffsetToPosixTz(opts.Timezone))
|
||||
}
|
||||
|
||||
out, err := cmd.Output()
|
||||
|
||||
// check for timeout first
|
||||
if commandCtx.Err() == context.DeadlineExceeded {
|
||||
rs.log.Info("Rendering timed out")
|
||||
return nil, ErrTimeout
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rs.log.Error("Phantomjs exited with non zero exit code", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rs.log.Debug("Phantomjs output", "out", string(out))
|
||||
|
||||
rs.log.Debug("Image rendered", "path", pngPath)
|
||||
return &RenderResult{FilePath: pngPath}, nil
|
||||
}
|
||||
|
||||
func isoTimeOffsetToPosixTz(isoOffset string) string {
|
||||
// invert offset
|
||||
if strings.HasPrefix(isoOffset, "UTC+") {
|
||||
return strings.Replace(isoOffset, "UTC+", "UTC-", 1)
|
||||
}
|
||||
if strings.HasPrefix(isoOffset, "UTC-") {
|
||||
return strings.Replace(isoOffset, "UTC-", "UTC+", 1)
|
||||
}
|
||||
return isoOffset
|
||||
}
|
||||
|
||||
func appendEnviron(baseEnviron []string, name string, value string) []string {
|
||||
results := make([]string, 0)
|
||||
prefix := fmt.Sprintf("%s=", name)
|
||||
for _, v := range baseEnviron {
|
||||
if !strings.HasPrefix(v, prefix) {
|
||||
results = append(results, v)
|
||||
}
|
||||
}
|
||||
return append(results, fmt.Sprintf("%s=%s", name, value))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
pluginModel "github.com/grafana/grafana-plugin-model/go/renderer"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
plugin "github.com/hashicorp/go-plugin"
|
||||
)
|
||||
|
||||
func (rs *RenderingService) startPlugin(ctx context.Context) error {
|
||||
cmd := plugins.ComposePluginStartCommmand("plugin_start")
|
||||
fullpath := path.Join(rs.pluginInfo.PluginDir, cmd)
|
||||
|
||||
var handshakeConfig = plugin.HandshakeConfig{
|
||||
ProtocolVersion: 1,
|
||||
MagicCookieKey: "grafana_plugin_type",
|
||||
MagicCookieValue: "renderer",
|
||||
}
|
||||
|
||||
rs.log.Info("Renderer plugin found, starting", "cmd", cmd)
|
||||
|
||||
rs.pluginClient = plugin.NewClient(&plugin.ClientConfig{
|
||||
HandshakeConfig: handshakeConfig,
|
||||
Plugins: map[string]plugin.Plugin{
|
||||
plugins.Renderer.Id: &pluginModel.RendererPluginImpl{},
|
||||
},
|
||||
Cmd: exec.Command(fullpath),
|
||||
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
|
||||
Logger: plugins.LogWrapper{Logger: rs.log},
|
||||
})
|
||||
|
||||
rpcClient, err := rs.pluginClient.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := rpcClient.Dispense(rs.pluginInfo.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rs.grpcPlugin = raw.(pluginModel.RendererPlugin)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RenderingService) watchAndRestartPlugin(ctx context.Context) error {
|
||||
ticker := time.NewTicker(time.Second * 1)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if rs.pluginClient.Exited() {
|
||||
err := rs.startPlugin(ctx)
|
||||
rs.log.Debug("Render plugin existed, restarting...")
|
||||
if err != nil {
|
||||
rs.log.Error("Failed to start render plugin", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *RenderingService) renderViaPlugin(ctx context.Context, opts Opts) (*RenderResult, error) {
|
||||
pngPath := rs.getFilePathForNewImage()
|
||||
|
||||
rsp, err := rs.grpcPlugin.Render(ctx, &pluginModel.RenderRequest{
|
||||
Url: rs.getURL(opts.Path),
|
||||
Width: int32(opts.Width),
|
||||
Height: int32(opts.Height),
|
||||
FilePath: pngPath,
|
||||
Timeout: int32(opts.Timeout.Seconds()),
|
||||
RenderKey: rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole),
|
||||
Encoding: opts.Encoding,
|
||||
Timezone: isoTimeOffsetToPosixTz(opts.Timezone),
|
||||
Domain: rs.getLocalDomain(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rsp.Error != "" {
|
||||
return nil, fmt.Errorf("Rendering failed: %v", rsp.Error)
|
||||
}
|
||||
|
||||
return &RenderResult{FilePath: pngPath}, err
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
plugin "github.com/hashicorp/go-plugin"
|
||||
|
||||
pluginModel "github.com/grafana/grafana-plugin-model/go/renderer"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.RegisterService(&RenderingService{})
|
||||
}
|
||||
|
||||
type RenderingService struct {
|
||||
log log.Logger
|
||||
pluginClient *plugin.Client
|
||||
grpcPlugin pluginModel.RendererPlugin
|
||||
pluginInfo *plugins.RendererPlugin
|
||||
renderAction renderFunc
|
||||
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
}
|
||||
|
||||
func (rs *RenderingService) Init() error {
|
||||
rs.log = log.New("rendering")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RenderingService) Run(ctx context.Context) error {
|
||||
if rs.Cfg.RendererUrl != "" {
|
||||
rs.log.Info("Backend rendering via external http server")
|
||||
rs.renderAction = rs.renderViaHttp
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
if plugins.Renderer == nil {
|
||||
rs.renderAction = rs.renderViaPhantomJS
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
rs.pluginInfo = plugins.Renderer
|
||||
|
||||
if err := rs.startPlugin(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rs.renderAction = rs.renderViaPlugin
|
||||
|
||||
err := rs.watchAndRestartPlugin(ctx)
|
||||
|
||||
if rs.pluginClient != nil {
|
||||
rs.log.Debug("Killing renderer plugin process")
|
||||
rs.pluginClient.Kill()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (rs *RenderingService) Render(ctx context.Context, opts Opts) (*RenderResult, error) {
|
||||
if rs.renderAction != nil {
|
||||
return rs.renderAction(ctx, opts)
|
||||
} else {
|
||||
return nil, fmt.Errorf("No renderer found")
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *RenderingService) getFilePathForNewImage() string {
|
||||
pngPath, _ := filepath.Abs(filepath.Join(rs.Cfg.ImagesDir, util.GetRandomString(20)))
|
||||
return pngPath + ".png"
|
||||
}
|
||||
|
||||
func (rs *RenderingService) getURL(path string) string {
|
||||
// &render=1 signals to the legacy redirect layer to
|
||||
return fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, rs.getLocalDomain(), setting.HttpPort, path)
|
||||
}
|
||||
|
||||
func (rs *RenderingService) getLocalDomain() string {
|
||||
if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR {
|
||||
return setting.HttpAddr
|
||||
}
|
||||
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
func (rs *RenderingService) getRenderKey(orgId, userId int64, orgRole models.RoleType) string {
|
||||
return middleware.AddRenderAuthKey(orgId, userId, orgRole)
|
||||
}
|
||||
Reference in New Issue
Block a user