Files

284 lines
8.3 KiB
Go

// Package preview builds every branch of a site's repository the way
// production would build it, and serves each at its own address, so a change
// can be looked at for real before it is published.
//
// A preview is the production build with one difference, the site's address,
// so links stay on the preview. Nothing is injected into the pages. Every
// response says noindex and no-store, robots.txt turns crawlers away, and the
// server listens on loopback unless it is given credentials to ask for.
package preview
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/check"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/gitx"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/isolate"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// Options configure the preview builder.
type Options struct {
Repo string // anything git can clone
Subdir string // the site's folder inside the repository
Refs []string // branch patterns to preview (path.Match); default every branch
Root string // where previews are built
Domain string // previews are served at <slug>.<Domain>; "localhost" by default
Port string // the port browsers use, for the preview's own links; "" for 80/443
Scheme string // http or https
Drafts bool // include draft pages
Log io.Writer
Git gitx.Auth // for private HTTPS repositories
Forge *forge.Client // posts each preview's address as a commit status; nil for none
HookSecret string // the webhook secret; empty turns the hook off
// FrameAncestors are origins allowed to show previews in a frame, such as
// the editor (http://127.0.0.1:8190). Anyone else is refused, as before.
FrameAncestors []string
}
// Preview is one built branch.
type Preview struct {
Branch string
Slug string
Commit string
Built time.Time
Pages int
Error string // the last build's error; the previous good build is still served
Errors int // check errors in the current build: publishing it would be refused
Warnings int // check warnings in the current build
URL string
dir string
pending string // commit of a failed build, so it isn't retried until the branch moves
}
// Builder keeps the set of previews current.
type Builder struct {
o Options
mu sync.RWMutex
previews map[string]*Preview // by slug
syncMu sync.Mutex // one sync at a time
kick chan struct{} // a webhook asking for a sync now
}
// NewBuilder prepares a builder; call Sync to build.
func NewBuilder(o Options) (*Builder, error) {
if err := gitx.Available(); err != nil {
return nil, fmt.Errorf("previews need git: %w", err)
}
if o.Domain == "" {
o.Domain = "localhost"
}
if o.Scheme == "" {
o.Scheme = "http"
}
if o.Log == nil {
o.Log = io.Discard
}
if err := os.MkdirAll(o.Root, 0o755); err != nil {
return nil, err
}
return &Builder{o: o, previews: map[string]*Preview{}, kick: make(chan struct{}, 1)}, nil
}
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
// Slug turns a branch name into a DNS label: readable, and unique per branch
// through a short hash, so feature/x and feature-x never share a preview.
func Slug(branch string) string {
s := strings.Trim(slugRe.ReplaceAllString(strings.ToLower(branch), "-"), "-")
if len(s) > 48 {
s = strings.Trim(s[:48], "-")
}
sum := sha256.Sum256([]byte(branch))
if s == "" {
s = "branch"
}
return s + "-" + hex.EncodeToString(sum[:])[:6]
}
// heads lists the wanted branches and their commits.
func (b *Builder) heads() (map[string]string, error) {
all, err := gitx.Heads(b.o.Git, b.o.Repo)
if err != nil {
return nil, err
}
heads := map[string]string{}
for br, c := range all {
if b.wanted(br) {
heads[br] = c
}
}
return heads, nil
}
func (b *Builder) wanted(branch string) bool {
if !gitx.ValidBranch(branch) {
return false // a name git could read as an option, or that git itself would refuse
}
if len(b.o.Refs) == 0 {
return true
}
for _, p := range b.o.Refs {
if ok, _ := path.Match(p, branch); ok {
return true
}
}
return false
}
// Sync builds every wanted branch whose commit has moved and removes the
// previews of branches that are gone.
func (b *Builder) Sync() error {
b.syncMu.Lock()
defer b.syncMu.Unlock()
heads, err := b.heads()
if err != nil {
return err
}
branches := make([]string, 0, len(heads))
for br := range heads {
branches = append(branches, br)
}
sort.Strings(branches)
live := map[string]bool{}
for _, br := range branches {
slug := Slug(br)
live[slug] = true
b.mu.RLock()
p := b.previews[slug]
b.mu.RUnlock()
if p != nil && (p.Commit == heads[br] || p.pending == heads[br]) {
continue
}
b.buildBranch(br, slug, heads[br])
}
b.mu.Lock()
for slug, p := range b.previews {
if !live[slug] {
fmt.Fprintf(b.o.Log, "preview %s: branch %s is gone, removing\n", slug, p.Branch)
_ = os.RemoveAll(p.dir)
_ = os.RemoveAll(filepath.Join(b.o.Root, ".src", slug))
delete(b.previews, slug)
}
}
b.mu.Unlock()
return nil
}
func (b *Builder) buildBranch(branch, slug, commit string) {
src := filepath.Join(b.o.Root, ".src", slug)
out := filepath.Join(b.o.Root, slug)
host := slug + "." + b.o.Domain
if b.o.Port != "" {
host += ":" + b.o.Port
}
url := b.o.Scheme + "://" + host
b.status(commit, forge.Pending, url+"/", "Building the preview")
err := b.checkout(src, branch)
var res *isolate.Summary
var cfg *site.Config
if err == nil {
res, err = isolate.Build(build.Options{SiteDir: filepath.Join(src, b.o.Subdir), Out: out, Drafts: b.o.Drafts, URL: url})
}
if err == nil {
cfg, err = site.LoadConfig(filepath.Join(src, b.o.Subdir))
}
b.mu.Lock()
defer b.mu.Unlock()
p := b.previews[slug]
if p == nil {
p = &Preview{Branch: branch, Slug: slug, URL: url + "/", dir: out}
b.previews[slug] = p
}
if err != nil {
p.Error, p.pending = err.Error(), commit
fmt.Fprintf(b.o.Log, "preview %s (%s @ %s) failed: %v\n", slug, branch, short(commit), err)
go b.status(commit, forge.Failure, "", "Preview build failed: "+err.Error())
return
}
p.Commit, p.Built, p.Pages, p.Error, p.pending = commit, time.Now(), res.Pages, "", ""
p.Errors, p.Warnings = 0, 0
if rep, err := check.Run(filepath.Join(src, b.o.Subdir), out, cfg); err == nil {
p.Errors = rep.Errors()
p.Warnings = len(rep.Problems) - p.Errors
}
fmt.Fprintf(b.o.Log, "preview %s (%s @ %s): %d pages at %s/\n", slug, branch, short(commit), res.Pages, url)
go b.status(commit, forge.Success, url+"/", fmt.Sprintf("Preview ready, %d pages", res.Pages))
}
// status tells the platform how a commit's preview went, so the pull request
// shows it with a link. Failures to post are logged, never fatal.
func (b *Builder) status(commit string, st forge.State, url, desc string) {
if b.o.Forge == nil || !b.o.Forge.Supported() || commit == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := b.o.Forge.SetStatus(ctx, commit, forge.Status{State: st, URL: url, Description: desc}); err != nil {
fmt.Fprintf(b.o.Log, "commit status for %s: %v\n", short(commit), err)
}
}
// Kick asks for a sync as soon as possible; extra kicks while one is
// pending fold into it.
func (b *Builder) Kick() {
select {
case b.kick <- struct{}{}:
default:
}
}
func (b *Builder) checkout(src, branch string) error {
if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil {
return err
}
_, err := gitx.Checkout(b.o.Git, b.o.Repo, branch, src)
return err
}
// List returns the previews, by branch name.
func (b *Builder) List() []Preview {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]Preview, 0, len(b.previews))
for _, p := range b.previews {
out = append(out, *p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Branch < out[j].Branch })
return out
}
// dirFor returns the build folder for a slug, if it has a good build.
func (b *Builder) dirFor(slug string) (string, bool) {
b.mu.RLock()
defer b.mu.RUnlock()
p, ok := b.previews[slug]
if !ok || p.Commit == "" {
return "", false
}
return p.dir, true
}
func short(h string) string {
if len(h) > 10 {
return h[:10]
}
return h
}