Files
hotdog-cms/internal/isolate/isolate.go
T

190 lines
5.1 KiB
Go

// Package isolate runs builds in a child process. A site's templates are
// files anyone who can write to the repository can change, and the editor,
// the preview server and the pull agent build them on a shared machine. A
// template can't read files or run commands, but it can loop. In a child
// process with a deadline and a memory limit, a loop costs one build, not
// the service: the child is killed and the build reported as failed.
//
// The child is this same program, started with ChildArg. It gets a minimal
// environment, so none of the parent's secrets (HOTDOG_EDITOR_SECRET, tokens,
// client secrets) are in reach of the build at all.
package isolate
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime/debug"
"strings"
"time"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build"
)
// ChildArg is the hidden first argument that makes the program a build child.
const ChildArg = "__hotdog-build-child"
// Enabled turns isolation on. The command line sets it for the services;
// tests and a person's own builds run in-process.
var Enabled = false
// Limits for one child.
var (
BuildTimeout = 5 * time.Minute
RenderTimeout = 30 * time.Second
MemoryLimit = int64(2 << 30)
)
// Summary is what a build reports back.
type Summary struct {
Out string
Pages, Files int
Feeds []string
NextScheduled time.Time
Duration time.Duration
}
type request struct {
Mode string // build or render
Opt build.Options
Source string
Content []byte
}
type response struct {
Summary *Summary
HTML []byte
Path string
Err string
}
// Build builds a site, in a child process when Enabled.
func Build(opt build.Options) (*Summary, error) {
if !Enabled {
return summarize(build.Run(opt))
}
res, err := run(request{Mode: "build", Opt: opt}, BuildTimeout)
if err != nil {
return nil, err
}
return res.Summary, nil
}
// RenderPage renders one page as edited, in a child process when Enabled.
func RenderPage(siteDir, source string, content []byte, opt build.Options) ([]byte, string, error) {
if !Enabled {
return build.RenderPage(siteDir, source, content, opt)
}
opt.SiteDir = siteDir
res, err := run(request{Mode: "render", Opt: opt, Source: source, Content: content}, RenderTimeout)
if err != nil {
return nil, "", err
}
return res.HTML, res.Path, nil
}
func summarize(res *build.Result, err error) (*Summary, error) {
if err != nil {
return nil, err
}
return &Summary{Out: res.Out, Pages: res.Pages, Files: res.Files, Feeds: res.Feeds, NextScheduled: res.Site.NextScheduled, Duration: res.Duration}, nil
}
func run(req request, timeout time.Duration) (*response, error) {
self, err := os.Executable()
if err != nil {
return nil, err
}
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, self, ChildArg)
cmd.Env = childEnv()
cmd.Stdin = bytes.NewReader(body)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &limitedWriter{w: &errb, n: 64 << 10}
err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("the build took longer than %s and was stopped; is a template looping?", timeout)
}
var res response
if jerr := json.Unmarshal(out.Bytes(), &res); jerr != nil {
if strings.Contains(errb.String(), "out of memory") || strings.Contains(errb.String(), "cannot allocate memory") {
return nil, fmt.Errorf("the build ran out of memory (over %d MB) and was stopped; is a template looping?", MemoryLimit>>20)
}
if err != nil {
return nil, fmt.Errorf("the build stopped: %v", err)
}
return nil, fmt.Errorf("the build's answer couldn't be read: %v", jerr)
}
if res.Err != "" {
return nil, errors.New(res.Err)
}
return &res, nil
}
// childEnv passes on only what a build needs: nothing secret.
func childEnv() []string {
var env []string
for _, k := range []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "TZ", "XDG_CACHE_HOME", "SYSTEMROOT"} {
if v, ok := os.LookupEnv(k); ok {
env = append(env, k+"="+v)
}
}
return env
}
// Child is the child's side: read one request, build, answer, exit.
func Child() {
debug.SetMemoryLimit(MemoryLimit * 3 / 4)
limitMemory(uint64(MemoryLimit))
var req request
res := response{}
if err := json.NewDecoder(io.LimitReader(os.Stdin, 64<<20)).Decode(&req); err != nil {
res.Err = "bad request: " + err.Error()
} else {
switch req.Mode {
case "build":
s, err := summarize(build.Run(req.Opt))
res.Summary = s
if err != nil {
res.Err = err.Error()
}
case "render":
html, p, err := build.RenderPage(req.Opt.SiteDir, req.Source, req.Content, req.Opt)
res.HTML, res.Path = html, p
if err != nil {
res.Err = err.Error()
}
default:
res.Err = "unknown mode"
}
}
_ = json.NewEncoder(os.Stdout).Encode(res)
os.Exit(0)
}
type limitedWriter struct {
w io.Writer
n int
}
func (l *limitedWriter) Write(p []byte) (int, error) {
if l.n <= 0 {
return len(p), nil
}
if len(p) > l.n {
p = p[:l.n]
}
l.n -= len(p)
return l.w.Write(p)
}