284 lines
8.3 KiB
Go
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
|
|
}
|