init
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
// Package webroot maintains the $WEBROOT/~project symlinks.
|
||||
//
|
||||
// The server serves its own content, so these links are not on any request
|
||||
// path. They exist for everything else: an nginx that would rather serve the
|
||||
// files itself, a backup job, an operator running ls. That makes every
|
||||
// operation here best-effort — a failure is logged and reconciled later, never
|
||||
// a reason to fail a deployment that the database already committed.
|
||||
package webroot
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/config"
|
||||
)
|
||||
|
||||
// prefix is what marks an entry as ours. Project names cannot contain a slash
|
||||
// or start with a dot — config.ProjectNamePattern sees to that — so "~" + name
|
||||
// is always a single entry directly inside the webroot and can never escape it.
|
||||
const prefix = "~"
|
||||
|
||||
// tmpMarker separates a half-built link from a live one. Point never renames a
|
||||
// name containing it into place, and Reconcile sweeps any that a crash left.
|
||||
const tmpMarker = ".tmp."
|
||||
|
||||
// Webroot is a directory of symlinks pointing into the deployments directory.
|
||||
type Webroot struct {
|
||||
dir string
|
||||
deployDir string
|
||||
}
|
||||
|
||||
// Open prepares the webroot directory. deployDir bounds what Reconcile is
|
||||
// willing to delete: an entry that does not point inside it belongs to the
|
||||
// operator, not to us.
|
||||
func Open(dir, deployDir string) (*Webroot, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("webroot: no directory configured")
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deployAbs, err := filepath.Abs(deployDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Webroot{dir: abs, deployDir: deployAbs}, nil
|
||||
}
|
||||
|
||||
// Dir is the directory being maintained.
|
||||
func (w *Webroot) Dir() string { return w.dir }
|
||||
|
||||
// Point makes ~project refer to target.
|
||||
//
|
||||
// The link is created under a temporary name and renamed into place. rename(2)
|
||||
// within one directory replaces an existing symlink atomically, so an external
|
||||
// reader never observes the link missing, dangling or half-written — which is
|
||||
// the same guarantee, at a coarser grain, that the in-process pointer swap
|
||||
// gives HTTP clients.
|
||||
func (w *Webroot) Point(project, target string) error {
|
||||
link, err := w.linkPath(project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cur, err := os.Readlink(link); err == nil && cur == target {
|
||||
return nil
|
||||
}
|
||||
|
||||
tmp, err := w.tempLink(project, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, link); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("webroot: point %s: %w", project, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unpoint removes ~project, if it is one of ours.
|
||||
func (w *Webroot) Unpoint(project string) error {
|
||||
link, err := w.linkPath(project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !w.ours(link) {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(link); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("webroot: unpoint %s: %w", project, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconcile makes the directory match want, a project name to target directory
|
||||
// map, and reports the first thing that went wrong after attempting all of it.
|
||||
//
|
||||
// It only ever deletes an entry that is itself a symlink pointing inside the
|
||||
// deployments directory. An operator's unrelated file, directory or link that
|
||||
// happens to share the webroot is left strictly alone: this program owns the
|
||||
// names it created, not the directory.
|
||||
func (w *Webroot) Reconcile(want map[string]string) error {
|
||||
entries, err := os.ReadDir(w.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, prefix) || e.Type()&fs.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(w.dir, name)
|
||||
if !w.pointsIntoDeployments(full) {
|
||||
continue
|
||||
}
|
||||
project := name[len(prefix):]
|
||||
if strings.Contains(project, tmpMarker) {
|
||||
// A Point that died between symlink and rename.
|
||||
if err := os.Remove(full); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, keep := want[project]; keep {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
for project, target := range want {
|
||||
if err := w.Point(project, target); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// linkPath validates the project name and returns the path of its link.
|
||||
func (w *Webroot) linkPath(project string) (string, error) {
|
||||
if !config.ProjectNamePattern.MatchString(project) {
|
||||
return "", fmt.Errorf("webroot: refusing to touch %q: not a project name", project)
|
||||
}
|
||||
return filepath.Join(w.dir, prefix+project), nil
|
||||
}
|
||||
|
||||
// tempLink creates a symlink under a name that Reconcile will recognise as
|
||||
// abandoned if this process dies before the rename.
|
||||
func (w *Webroot) tempLink(project, target string) (string, error) {
|
||||
base, err := w.linkPath(project)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf [6]byte
|
||||
for range 8 {
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := base + tmpMarker + hex.EncodeToString(buf[:])
|
||||
err := os.Symlink(target, tmp)
|
||||
if err == nil {
|
||||
return tmp, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return "", fmt.Errorf("webroot: link %s: %w", project, err)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("webroot: could not create a temporary link for %s", project)
|
||||
}
|
||||
|
||||
// ours reports whether path is a symlink this package would have created.
|
||||
func (w *Webroot) ours(path string) bool {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil || fi.Mode()&fs.ModeSymlink == 0 {
|
||||
return false
|
||||
}
|
||||
return w.pointsIntoDeployments(path)
|
||||
}
|
||||
|
||||
// pointsIntoDeployments reports whether a symlink's target is inside the
|
||||
// deployments directory. The target is compared lexically after cleaning: it is
|
||||
// the string this program wrote, and resolving it would answer a different
|
||||
// question about a link that may well dangle.
|
||||
func (w *Webroot) pointsIntoDeployments(path string) bool {
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(w.dir, target)
|
||||
}
|
||||
target = filepath.Clean(target)
|
||||
if target == w.deployDir {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(target, w.deployDir+string(filepath.Separator))
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package webroot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// env is a webroot plus the deployments directory it is allowed to point at.
|
||||
type env struct {
|
||||
*Webroot
|
||||
dir string
|
||||
deployDir string
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
dir := filepath.Join(base, "www")
|
||||
deployDir := filepath.Join(base, "deployments")
|
||||
if err := os.MkdirAll(deployDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := Open(dir, deployDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{Webroot: w, dir: dir, deployDir: deployDir}
|
||||
}
|
||||
|
||||
// deployment creates a deployment directory and returns its path.
|
||||
func (e *env) deployment(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(e.deployDir, rel)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (e *env) readlink(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
target, err := os.Readlink(filepath.Join(e.dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("readlink %s: %v", name, err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func (e *env) names(t *testing.T) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(e.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
out = append(out, entry.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestOpenCreatesTheDirectory(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
dir := filepath.Join(base, "a", "b", "www")
|
||||
w, err := Open(dir, filepath.Join(base, "deployments"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
|
||||
t.Fatalf("Open did not create %s: %v", dir, err)
|
||||
}
|
||||
if !filepath.IsAbs(w.Dir()) {
|
||||
t.Errorf("Dir() = %q, want an absolute path", w.Dir())
|
||||
}
|
||||
if _, err := Open("", base); err == nil {
|
||||
t.Error("Open accepted an empty directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoint(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first := e.deployment(t, "1/dpl_aaaa")
|
||||
|
||||
if err := e.Point("demo", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.readlink(t, "~demo"); got != first {
|
||||
t.Errorf("~demo -> %q, want %q", got, first)
|
||||
}
|
||||
|
||||
// Pointing at the same target again is a no-op, not an error.
|
||||
if err := e.Point("demo", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second := e.deployment(t, "1/dpl_bbbb")
|
||||
if err := e.Point("demo", second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.readlink(t, "~demo"); got != second {
|
||||
t.Errorf("after repoint ~demo -> %q, want %q", got, second)
|
||||
}
|
||||
// The rename must not leave the temporary link behind.
|
||||
if names := e.names(t); len(names) != 1 || names[0] != "~demo" {
|
||||
t.Errorf("webroot contains %v, want just [~demo]", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Point replaces the link with rename(2), which is atomic within a directory:
|
||||
// an external reader either sees the old target or the new one, never a missing
|
||||
// or half-written link. Observing that from a test means checking that the name
|
||||
// resolves to a valid deployment at every moment, which the atomicity suite
|
||||
// does concurrently; here we pin the mechanism it relies on — the link is never
|
||||
// unlinked first.
|
||||
func TestPointNeverUnlinksBeforeRenaming(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first := e.deployment(t, "1/dpl_aaaa")
|
||||
second := e.deployment(t, "1/dpl_bbbb")
|
||||
if err := e.Point("demo", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(e.dir, "~demo")
|
||||
before, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Point("demo", second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.SameFile(before, after) {
|
||||
t.Error("Point reused the same inode; it must create a new link and rename over")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointRefusesANameThatIsNotAProject(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
target := e.deployment(t, "1/dpl_aaaa")
|
||||
// Anything that could escape the webroot, plus the merely invalid.
|
||||
for _, name := range []string{"", ".", "..", "../evil", "a/b", "/abs", "UPPER", ".hidden", "~demo", strings.Repeat("a", 64)} {
|
||||
if err := e.Point(name, target); err == nil {
|
||||
t.Errorf("Point(%q) was accepted", name)
|
||||
}
|
||||
if err := e.Unpoint(name); err == nil {
|
||||
t.Errorf("Unpoint(%q) was accepted", name)
|
||||
}
|
||||
}
|
||||
if names := e.names(t); len(names) != 0 {
|
||||
t.Errorf("a refused name still created %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpoint(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if err := e.Unpoint("demo"); err != nil {
|
||||
t.Fatalf("Unpoint on a missing link = %v, want nil", err)
|
||||
}
|
||||
|
||||
if err := e.Point("demo", e.deployment(t, "1/dpl_aaaa")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Unpoint("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(e.dir, "~demo")); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("~demo survived Unpoint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The webroot is very likely a directory the operator also keeps other things
|
||||
// in. Unpoint must not remove a name that this package did not create, even
|
||||
// though the name matches the pattern it uses.
|
||||
func TestUnpointLeavesForeignEntriesAlone(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
regular := filepath.Join(e.dir, "~demo")
|
||||
if err := os.WriteFile(regular, []byte("operator's file"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Unpoint("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(regular); err != nil {
|
||||
t.Fatalf("Unpoint deleted a regular file: %v", err)
|
||||
}
|
||||
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign := filepath.Join(e.dir, "~other")
|
||||
if err := os.Symlink(outside, foreign); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Unpoint("other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(foreign); err != nil {
|
||||
t.Fatalf("Unpoint deleted a symlink pointing outside the deployments dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
a := e.deployment(t, "1/dpl_a")
|
||||
b := e.deployment(t, "2/dpl_b")
|
||||
c := e.deployment(t, "3/dpl_c")
|
||||
|
||||
if err := e.Point("stale", a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Point("moved", a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := map[string]string{"moved": b, "fresh": c}
|
||||
if err := e.Reconcile(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := os.Lstat(filepath.Join(e.dir, "~stale")); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("~stale survived Reconcile: %v", err)
|
||||
}
|
||||
if got := e.readlink(t, "~moved"); got != b {
|
||||
t.Errorf("~moved -> %q, want %q", got, b)
|
||||
}
|
||||
if got := e.readlink(t, "~fresh"); got != c {
|
||||
t.Errorf("~fresh -> %q, want %q", got, c)
|
||||
}
|
||||
}
|
||||
|
||||
// The guarantee that makes Reconcile safe to run on a shared webroot: it
|
||||
// deletes only symlinks of its own that point inside the deployments directory.
|
||||
func TestReconcileLeavesForeignEntriesAlone(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
elsewhere := t.TempDir()
|
||||
|
||||
keep := []string{
|
||||
"index.html", // a file the operator serves directly
|
||||
"~notes.txt", // a file whose name happens to start with ~
|
||||
"~static", // a directory under a name we would use
|
||||
"~elsewhere", // a symlink out of our control
|
||||
"~dangling", // a symlink to nothing
|
||||
"other-thing", // anything else
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(e.dir, "index.html"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(e.dir, "~notes.txt"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(e.dir, "~static"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(elsewhere, filepath.Join(e.dir, "~elsewhere")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(elsewhere, "nope"), filepath.Join(e.dir, "~dangling")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(e.dir, "other-thing"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// An empty want set is the harshest case: every project is gone, so
|
||||
// anything Reconcile is willing to delete, it deletes now.
|
||||
if err := e.Reconcile(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range keep {
|
||||
if _, err := os.Lstat(filepath.Join(e.dir, name)); err != nil {
|
||||
t.Errorf("Reconcile removed %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A symlink pointing at the deployments directory itself is not one of ours:
|
||||
// nothing here ever creates one, so removing it would be removing an
|
||||
// operator's entry.
|
||||
func TestReconcileIgnoresALinkToTheDeploymentsRoot(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
link := filepath.Join(e.dir, "~all")
|
||||
if err := os.Symlink(e.deployDir, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Reconcile(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(link); err != nil {
|
||||
t.Errorf("Reconcile removed a link to the deployments root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix match on the string would treat /deployments-old as inside
|
||||
// /deployments. It is a sibling directory and must be left alone.
|
||||
func TestReconcileIgnoresASiblingDirectoryWithASharedPrefix(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
sibling := e.deployDir + "-old"
|
||||
if err := os.MkdirAll(filepath.Join(sibling, "dpl_x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(e.dir, "~archived")
|
||||
if err := os.Symlink(filepath.Join(sibling, "dpl_x"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Reconcile(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(link); err != nil {
|
||||
t.Errorf("Reconcile removed a link into a sibling directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A crash between symlink() and rename() leaves ~name.tmp.<rand> behind. It is
|
||||
// ours by construction, so Reconcile sweeps it.
|
||||
func TestReconcileSweepsAbandonedTemporaryLinks(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
target := e.deployment(t, "1/dpl_a")
|
||||
leftover := filepath.Join(e.dir, "~demo"+tmpMarker+"0123456789ab")
|
||||
if err := os.Symlink(target, leftover); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := e.Reconcile(map[string]string{"demo": target}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(leftover); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("an abandoned temporary link survived Reconcile: %v", err)
|
||||
}
|
||||
if got := e.readlink(t, "~demo"); got != target {
|
||||
t.Errorf("~demo -> %q, want %q", got, target)
|
||||
}
|
||||
}
|
||||
|
||||
// A relative target is still ours if it lands inside the deployments directory.
|
||||
// Reconcile resolves it against the webroot before deciding.
|
||||
func TestReconcileUnderstandsRelativeTargets(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.deployment(t, "1/dpl_a")
|
||||
rel, err := filepath.Rel(e.dir, filepath.Join(e.deployDir, "1", "dpl_a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(e.dir, "~gone")
|
||||
if err := os.Symlink(rel, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.Reconcile(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(link); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Error("a relative link into the deployments dir survived Reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile reports what failed but keeps going: one bad project must not stop
|
||||
// the rest of the webroot from converging.
|
||||
func TestReconcileContinuesAfterAFailure(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
good := e.deployment(t, "1/dpl_a")
|
||||
if err := e.Reconcile(map[string]string{"good": good, "Bad Name": good}); err == nil {
|
||||
t.Fatal("Reconcile hid a failure")
|
||||
}
|
||||
if got := e.readlink(t, "~good"); got != good {
|
||||
t.Errorf("~good -> %q, want %q", got, good)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileIsIdempotent(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
want := map[string]string{
|
||||
"a": e.deployment(t, "1/dpl_a"),
|
||||
"b": e.deployment(t, "2/dpl_b"),
|
||||
}
|
||||
for range 3 {
|
||||
if err := e.Reconcile(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
names := e.names(t)
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("webroot contains %v, want two links", names)
|
||||
}
|
||||
for project, target := range want {
|
||||
if got := e.readlink(t, "~"+project); got != target {
|
||||
t.Errorf("~%s -> %q, want %q", project, got, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user