Files
Felis/internal/offsite/writer.go
T

264 lines
8.1 KiB
Go

package offsite
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"unicode"
)
// writerMark names the host that writes the bucket. A rehearsal on a spare
// machine restores the production host's /etc/felis, [offsite] and its key
// included: without the record the spare would copy its bundles into the
// production prefix and prune the production host's by DBKeep. The writer
// rewrites it on every run; a host restored from its backup finds another
// host named there and stands by, writing nothing, until `felis offsite
// take-over`.
const writerMark = "felis-writer"
// WriterLive is how recently the writer must have run for a standby host to
// count it as alive. Both run hourly, so a writer seen within it is one that
// ran in the last two hours.
const WriterLive = 3 * time.Hour
// HostIDFile is the file, next to the status file, holding this host's id. It
// is written when the host first writes the bucket and never leaves the host
// (a bundle carries /etc/felis only), so a host restored from a bundle has
// none.
const HostIDFile = "host-id"
var (
// ErrStandby is a run refused because another host writes the bucket and
// this one never has.
ErrStandby = errors.New("offsite: another host writes this bucket")
// ErrDisplaced is a run refused because another host took the bucket
// over from this one.
ErrDisplaced = errors.New("offsite: another host took this bucket over")
)
const takeOverHint = "sudo felis offsite take-over -yes (docs/troubleshooting.md §16)"
// Writer is the felis-writer record.
type Writer struct {
HostID string `json:"host_id"`
Host string `json:"host"`
At time.Time `json:"at"`
}
func (w *Writer) String() string {
return fmt.Sprintf("host %s (id %s)", w.Host, w.HostID)
}
// WriterError is a run refused because another host writes the bucket.
type WriterError struct {
// Kind is ErrStandby or ErrDisplaced.
Kind error
// Writer is the host named in the bucket; nil for a bucket that holds
// another host's copies and names no writer.
Writer *Writer
}
func (e *WriterError) Error() string {
switch {
case e.Kind == ErrDisplaced:
return fmt.Sprintf("%v: %s writes it now (last at %s), and this host copies nothing there any more; if that host is a rehearsal machine, take the bucket back here: %s",
e.Kind, e.Writer, e.Writer.At.UTC().Format(time.RFC3339), takeOverHint)
case e.Writer != nil:
return fmt.Sprintf("%v: %s writes it (last at %s); this host was built from its backup and copies nothing there, until it replaces that host for good: %s",
e.Kind, e.Writer, e.Writer.At.UTC().Format(time.RFC3339), takeOverHint)
default:
return fmt.Sprintf("%v: the bucket holds copies this host did not write and names no host writing it; this host copies nothing there, until it replaces that host for good: %s",
e.Kind, takeOverHint)
}
}
func (e *WriterError) Unwrap() error { return e.Kind }
// Role is what a host's next run does with the bucket.
type Role int
const (
// RoleWrites: the bucket names this host.
RoleWrites Role = iota + 1
// RoleClaims: the bucket names no host, and this one records itself.
RoleClaims
// RoleStandby: another host writes the bucket, and this one never has.
RoleStandby
// RoleDisplaced: another host took the bucket over from this one.
RoleDisplaced
)
// Lease is this host's side of felis-writer.
type Lease struct {
// IDFile is this host's id (HostIDFile next to the status file).
IDFile string
// Host is this host's name, shown to the others.
Host string
// Inherited is a host that copied to the bucket before writers were
// recorded: a status file an older release wrote. It claims a bucket
// that names no writer.
Inherited bool
}
// BucketWriter reads the felis-writer record, nil when there is none.
func BucketWriter(ctx context.Context, b Bucket) (*Writer, error) {
rc, err := b.Get(ctx, writerMark)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read %s: %w", writerMark, err)
}
defer rc.Close()
raw, err := io.ReadAll(io.LimitReader(rc, 1024))
if err != nil {
return nil, fmt.Errorf("read %s: %w", writerMark, err)
}
var w Writer
if json.Unmarshal(raw, &w) != nil || !keyIDPattern.MatchString(w.HostID) {
return nil, fmt.Errorf("offsite: %s in the bucket is not a record Felis wrote", writerMark)
}
w.Host = printable(w.Host)
return &w, nil
}
// Plan says what this host's next run does with the bucket, and the host the
// bucket names (nil for none). empty is a bucket holding no sealed object
// (CheckKey's KeyUnused), which any host may claim.
func (l Lease) Plan(ctx context.Context, b Bucket, empty bool) (Role, *Writer, error) {
w, err := BucketWriter(ctx, b)
if err != nil {
return 0, nil, err
}
mine, err := l.ID()
if err != nil {
return 0, nil, err
}
switch {
case w != nil && w.HostID == mine:
return RoleWrites, w, nil
case w != nil && mine != "":
return RoleDisplaced, w, nil
case w != nil:
return RoleStandby, w, nil
case mine != "" || l.Inherited || empty:
return RoleClaims, nil, nil
default:
return RoleStandby, nil, nil
}
}
// Acquire is Plan before a run writes anything: a host that writes or claims
// the bucket records itself there at now; one that stands by or was displaced
// gets a *WriterError and writes nothing.
func (l Lease) Acquire(ctx context.Context, b Bucket, empty bool, now time.Time) error {
role, w, err := l.Plan(ctx, b, empty)
if err != nil {
return err
}
switch role {
case RoleStandby:
return &WriterError{Kind: ErrStandby, Writer: w}
case RoleDisplaced:
return &WriterError{Kind: ErrDisplaced, Writer: w}
}
return l.record(ctx, b, now)
}
// TakeOver records this host as the bucket's writer whatever the bucket named,
// and returns the writer it replaced (nil for none). That host's next run is
// refused with ErrDisplaced.
func (l Lease) TakeOver(ctx context.Context, b Bucket, now time.Time) (*Writer, error) {
prev, err := BucketWriter(ctx, b)
if err != nil {
return nil, err
}
return prev, l.record(ctx, b, now)
}
// record writes this host into felis-writer, creating its id first: a host
// whose id is on disk but not in the bucket claims the bucket on its next run,
// where one named in the bucket without an id on disk would stand by for
// itself.
func (l Lease) record(ctx context.Context, b Bucket, now time.Time) error {
id, err := l.ID()
if err != nil {
return err
}
if id == "" {
if id, err = l.newID(); err != nil {
return err
}
}
raw, err := json.Marshal(Writer{HostID: id, Host: printable(l.Host), At: now.UTC()})
if err != nil {
return err
}
raw = append(raw, '\n')
if err := b.Put(ctx, writerMark, strings.NewReader(string(raw)), int64(len(raw))); err != nil {
return fmt.Errorf("record this host in %s: %w", writerMark, err)
}
return nil
}
// ID is this host's id, "" when it has never written a bucket.
func (l Lease) ID() (string, error) {
raw, err := os.ReadFile(l.IDFile)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read this host's id: %w", err)
}
id := strings.TrimSpace(string(raw))
if !keyIDPattern.MatchString(id) {
return "", fmt.Errorf("offsite: %s is not a host id Felis wrote; remove it and run sudo felis offsite take-over -yes", l.IDFile)
}
return id, nil
}
func (l Lease) newID() (string, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
id := hex.EncodeToString(b[:])
if err := os.MkdirAll(filepath.Dir(l.IDFile), 0o700); err != nil {
return "", fmt.Errorf("record this host's id: %w", err)
}
tmp := l.IDFile + ".tmp"
if err := os.WriteFile(tmp, []byte(id+"\n"), 0o600); err != nil {
return "", fmt.Errorf("record this host's id: %w", err)
}
if err := os.Rename(tmp, l.IDFile); err != nil {
return "", fmt.Errorf("record this host's id: %w", err)
}
return id, nil
}
// printable keeps a host name fit for a message: at most 64 printable runes,
// "unknown" for none.
func printable(s string) string {
s = strings.Map(func(r rune) rune {
if unicode.IsPrint(r) {
return r
}
return -1
}, s)
if r := []rune(s); len(r) > 64 {
s = string(r[:64])
}
if strings.TrimSpace(s) == "" {
return "unknown"
}
return s
}