190 lines
5.1 KiB
Go
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)
|
|
}
|