Files

263 lines
9.0 KiB
Go

// Package publish takes a finished build to wherever the operator serves the
// site from. hotdog-cms doesn't decide that: a target is a folder on this machine,
// a server reached over SSH, a container image, or a command of the operator's
// own, and an operator can run any of them from CI, from a deploy script or by
// hand. (The pull agent, for servers that fetch their own updates, is in
// pull.go.)
//
// Targets are read from a file the operator owns, by default publish.yaml in
// the site's folder. Values may name environment variables (${DEPLOY_HOST}),
// so host names and registries can stay out of a public repository.
// Credentials never go in the file: SSH uses the operator's keys and agent,
// registries use the container tool's own login.
package publish
import (
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build"
)
// Target is one place a site can be published to.
type Target struct {
Type string `yaml:"type"` // dir, rsync, container or command
// dir: a local folder, typically a web server's root.
// rsync: the folder on the remote server.
Path string `yaml:"path"`
// rsync: [user@]host, and an SSH port if it isn't 22.
Host string `yaml:"host"`
Port int `yaml:"port"`
// container: the image to build, its web server, and whether to push it.
Image string `yaml:"image"`
Server string `yaml:"server"` // nginx (default) or caddy
Push bool `yaml:"push"` // push to the registry named in image
Tool string `yaml:"tool"` // docker or podman; default whichever is installed
Context string `yaml:"context"` // write the build context here and stop, for a pipeline that builds images itself
CSP bool `yaml:"csp"` // send the Content-Security-Policy the pages need (as hotdog-cms check -csp prints it)
// command: argv, run without a shell. {out} is replaced with the build folder.
Command []string `yaml:"command"`
// Before and After are the operator's own steps around publishing: argv,
// run without a shell, with {out} replaced by the build folder. Before runs
// once the site is built and can change the build (a search index, a
// minifier); a failure stops the publish. After runs once it's live (purge
// a cache, tell a monitor). They run with the operator's rights, so they
// live only here, in a file the operator owns, never in site.yaml, which
// anyone who can edit the site can change.
Before [][]string `yaml:"before"`
After [][]string `yaml:"after"`
// IndexNow tells search engines which pages changed, after each publish
// here (indexnow.go). For the live site's target, not a staging one.
IndexNow bool `yaml:"indexnow"`
}
// Load reads a targets file.
func Load(file string) (map[string]Target, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var doc struct {
Targets map[string]Target `yaml:"targets"`
}
dec := yaml.NewDecoder(strings.NewReader(os.ExpandEnv(string(raw))))
dec.KnownFields(true)
if err := dec.Decode(&doc); err != nil {
return nil, fmt.Errorf("%s: %w", file, err)
}
if len(doc.Targets) == 0 {
return nil, fmt.Errorf("%s: no targets", file)
}
for name, t := range doc.Targets {
if err := t.validate(); err != nil {
return nil, fmt.Errorf("%s: target %s: %w", file, name, err)
}
}
return doc.Targets, nil
}
// Names returns target names in order.
func Names(ts map[string]Target) []string {
out := make([]string, 0, len(ts))
for n := range ts {
out = append(out, n)
}
sort.Strings(out)
return out
}
var hostRe = regexp.MustCompile(`^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.-]+$|^(?:[A-Za-z0-9._-]+@)?\[[0-9A-Fa-f:]+\]$`)
func (t Target) validate() error {
switch t.Type {
case "dir":
if t.Path == "" {
return fmt.Errorf("dir needs path")
}
case "rsync":
if !hostRe.MatchString(t.Host) {
return fmt.Errorf("rsync needs host as [user@]host, got %q", t.Host)
}
if err := safeRemotePath(t.Path); err != nil {
return err
}
case "container":
if t.Image == "" && t.Context == "" {
return fmt.Errorf("container needs image, or context to only write the build context")
}
if t.Server != "" && t.Server != "nginx" && t.Server != "caddy" {
return fmt.Errorf("server must be nginx or caddy")
}
if t.Tool != "" && t.Tool != "docker" && t.Tool != "podman" {
return fmt.Errorf("tool must be docker or podman")
}
case "command":
if len(t.Command) == 0 {
return fmt.Errorf("command needs a command")
}
default:
return fmt.Errorf("type %q is not dir, rsync, container or command", t.Type)
}
for _, steps := range [][][]string{t.Before, t.After} {
for _, argv := range steps {
if len(argv) == 0 || argv[0] == "" {
return fmt.Errorf("before and after steps are commands: [program, arg, ...]")
}
}
}
return nil
}
// safeRemotePath keeps a typo from aiming a delete-and-replace at / or /var.
func safeRemotePath(p string) error {
if !path.IsAbs(p) || path.Clean(p) != p {
return fmt.Errorf("path %q must be an absolute, clean path", p)
}
if strings.Count(p, "/") < 2 {
return fmt.Errorf("path %q is too close to / to replace", p)
}
if strings.ContainsAny(p, "\x00\n") {
return fmt.Errorf("path %q has control characters", p)
}
return nil
}
// Run publishes the build in out to one target.
func Run(name string, t Target, out string, log io.Writer) error {
if _, err := os.Stat(filepath.Join(out, build.Marker)); err != nil {
return fmt.Errorf("%s is not a hotdog-cms build; run hotdog-cms build first", out)
}
for _, argv := range t.Before {
if err := step(name, argv, out, log); err != nil {
return fmt.Errorf("before publishing to %s: %w", name, err)
}
}
fmt.Fprintf(log, "publishing to %s (%s)\n", name, t.Type)
if err := run(t, out, log); err != nil {
return err
}
if t.IndexNow {
indexNow(name, t, out, log)
}
for _, argv := range t.After {
if err := step(name, argv, out, log); err != nil {
return fmt.Errorf("published to %s, but an after step failed: %w", name, err)
}
}
return nil
}
// step runs one of the operator's before or after commands.
func step(target string, argv []string, out string, log io.Writer) error {
args := make([]string, len(argv))
for i, a := range argv {
args[i] = strings.ReplaceAll(a, "{out}", out)
}
fmt.Fprintf(log, "running %s\n", strings.Join(args, " "))
cmd := exec.Command(args[0], args[1:]...)
cmd.Env = append(os.Environ(), "HOTDOG_OUT="+out, "HOTDOG_TARGET="+target)
cmd.Stdout, cmd.Stderr = log, log
return cmd.Run()
}
func run(t Target, out string, log io.Writer) error {
switch t.Type {
case "dir":
return build.Install(out, t.Path)
case "rsync":
return rsync(t, out, log)
case "container":
return container(t, out, log)
case "command":
args := make([]string, len(t.Command))
for i, a := range t.Command {
args[i] = strings.ReplaceAll(a, "{out}", out)
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Env = append(os.Environ(), "HOTDOG_OUT="+out)
cmd.Stdout, cmd.Stderr = log, log
return cmd.Run()
}
return fmt.Errorf("unknown target type %q", t.Type)
}
// shq quotes a string for the remote shell.
func shq(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
// rsync uploads into a staging folder beside the live one, hard-linking every
// unchanged file from the live copy so only changes cross the network, then
// swaps the two on the server with renames. Visitors see the old site or the
// new one, never a half-uploaded mix, and the live folder is only replaced if
// it is empty, missing, or an earlier hotdog-cms build.
func rsync(t Target, out string, log io.Writer) error {
for _, tool := range []string{"rsync", "ssh"} {
if _, err := exec.LookPath(tool); err != nil {
return fmt.Errorf("rsync target needs %s installed", tool)
}
}
sshArgs := []string{"-o", "BatchMode=yes"}
if t.Port != 0 {
sshArgs = append(sshArgs, "-p", strconv.Itoa(t.Port))
}
remote := func(script string) error {
cmd := exec.Command("ssh", append(append([]string{}, sshArgs...), t.Host, script)...)
cmd.Stdout, cmd.Stderr = log, log
return cmd.Run()
}
live, staging, old := t.Path, t.Path+".hotdog-cms-staging", t.Path+".hotdog-cms-old"
check := fmt.Sprintf(`if [ -e %[1]s ] && [ ! -e %[1]s/%[2]s ] && [ -n "$(ls -A %[1]s 2>/dev/null)" ]; then echo "%[1]s has files hotdog-cms did not write; refusing to replace it" >&2; exit 3; fi; mkdir -p %[3]s`,
shq(live), build.Marker, shq(staging))
if err := remote(check); err != nil {
return fmt.Errorf("checking %s:%s: %w", t.Host, live, err)
}
rs := []string{"-a", "--delete", "--link-dest=" + live + "/", "-e", "ssh " + strings.Join(sshArgs, " "),
strings.TrimRight(out, "/") + "/", t.Host + ":" + staging + "/"}
cmd := exec.Command("rsync", rs...)
cmd.Stdout, cmd.Stderr = log, log
if err := cmd.Run(); err != nil {
return fmt.Errorf("rsync: %w", err)
}
swap := fmt.Sprintf(`set -e; rm -rf %[3]s; if [ -e %[1]s ]; then mv %[1]s %[3]s; fi; mv %[2]s %[1]s; rm -rf %[3]s`,
shq(live), shq(staging), shq(old))
if err := remote(swap); err != nil {
return fmt.Errorf("swapping on %s: %w", t.Host, err)
}
return nil
}