~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
963 lines · 28.0 KB Raw History
package clusterprovider

import (
	"bytes"
	"cmp"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/fsnotify/fsnotify"
	coordinationv1 "k8s.io/api/coordination/v1"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/apiserverfront"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/clusterlink"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/clusterstack"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/clock"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/fswatch"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/fsx"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/sha256sum"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/materialiser"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/portallocator"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/procgroup"
)

const (
	RollRequestFile = "roll-request.json"
	RollsDirName    = "rolls"
	BackupsDirName  = "backups"

	RollKindCluster = "cluster"
	RollKindLink    = "link"

	DefaultIdleBound = 30 * time.Minute
	DefaultRollWatch = 60 * time.Second
	drainWait        = 5 * time.Second
	kineSchemaMark   = "tables and indexes are up to date"
)

type RollRequest struct {
	ID          string    `json:"id"`
	Kind        string    `json:"kind"`
	K3s         string    `json:"k3s,omitempty"`
	Kine        string    `json:"kine,omitempty"`
	Dumbpipe    string    `json:"dumbpipe,omitempty"`
	Service     string    `json:"service,omitempty"`
	IdleBound   string    `json:"idleBound,omitempty"`
	RequestedAt time.Time `json:"requestedAt"`
}

type LeaseHandover struct {
	Lease   string  `json:"lease"`
	From    string  `json:"from"`
	To      string  `json:"to,omitempty"`
	Seconds float64 `json:"seconds,omitempty"`
}

type RollReport struct {
	ID      string `json:"id"`
	Kind    string `json:"kind"`
	Cluster string `json:"cluster"`
	From    string `json:"from"`
	To      string `json:"to"`

	FromVersion string   `json:"fromVersion,omitempty"`
	ToVersion   string   `json:"toVersion,omitempty"`
	Backup      string   `json:"backup,omitempty"`
	Relet       []string `json:"relet,omitempty"`

	Nodes        int     `json:"nodes"`
	IdleSeconds  float64 `json:"idleSeconds"`
	IdleDeferred bool    `json:"idleDeferred,omitempty"`

	StartedAt     time.Time `json:"startedAt"`
	GreenReadyAt  time.Time `json:"greenReadyAt,omitzero"`
	PersistedAt   time.Time `json:"persistedAt,omitzero"`
	FlippedAt     time.Time `json:"flippedAt,omitzero"`
	OldStoppedAt  time.Time `json:"oldStoppedAt,omitzero"`
	DoneAt        time.Time `json:"doneAt,omitzero"`
	StartToReady  float64   `json:"startToReadySeconds,omitempty"`
	OldStopSecs   float64   `json:"oldStopSeconds,omitempty"`
	OpenOnOld     int       `json:"openOnOldAtStop"`
	Drained       int       `json:"drained"`
	FrontBackend  string    `json:"frontBackend,omitempty"`
	Endpoints     []string  `json:"endpoints,omitempty"`
	EndpointsLost int       `json:"endpointsNotAdvertisedPolls"`

	Leases          []LeaseHandover `json:"leases,omitempty"`
	NodeTransitions []string        `json:"nodeReadyTransitions,omitempty"`

	Ticket       string `json:"ticket,omitempty"`
	TicketBefore string `json:"ticketBefore,omitempty"`
	OldListener  string `json:"oldListener,omitempty"`
	NewListener  string `json:"newListener,omitempty"`

	Error string `json:"error,omitempty"`
}

func RollRequestPath(stateDir, cluster string) (string, error) {
	dir, err := ClusterDataDir(stateDir, cluster)
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, RollRequestFile), nil
}

func RollReportPath(stateDir, cluster, id string) (string, error) {
	dir, err := ClusterDataDir(stateDir, cluster)
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, RollsDirName, id+".json"), nil
}

func WriteRollRequest(stateDir, cluster string, r RollRequest) (string, error) {
	p, err := RollRequestPath(stateDir, cluster)
	if err != nil {
		return "", err
	}
	if _, err := os.Stat(filepath.Dir(p)); err != nil {
		return "", fmt.Errorf("clusterprovider: cluster %s has no data dir under %s: %w", cluster, stateDir, err)
	}
	if _, err := os.Stat(p); err == nil {
		return "", fmt.Errorf("clusterprovider: %s already holds a roll request; the provider has not taken it yet", p)
	}
	return p, fsx.WriteJSON(p, r, 0o600)
}

func ReadRollReport(stateDir, cluster, id string) (RollReport, bool, error) {
	p, err := RollReportPath(stateDir, cluster, id)
	if err != nil {
		return RollReport{}, false, err
	}
	b, err := os.ReadFile(p)
	if errors.Is(err, os.ErrNotExist) {
		return RollReport{}, false, nil
	}
	if err != nil {
		return RollReport{}, false, err
	}
	var r RollReport
	return r, true, json.Unmarshal(b, &r)
}

type Roller struct {
	Units *UnitsHost

	Stack StackConfig

	Clusters func(ctx context.Context) ([]string, error)

	Ready func(ctx context.Context, cluster string) (bool, error)

	Leases func(cluster string) (materialiser.Leases, error)

	Relet func(ctx context.Context, cluster string, p portallocator.Purpose) (portallocator.Lease, bool, error)

	Name string

	Log *slog.Logger

	status RollerStatus

	once sync.Once

	mu sync.Mutex

	pending map[string]bool

	wake chan struct{}
}

func (r *Roller) log() *slog.Logger {
	if r.Log != nil {
		return r.Log
	}
	return slog.Default()
}

func (r *Roller) init() {
	r.once.Do(func() {
		r.wake = make(chan struct{}, 1)
		r.pending = map[string]bool{}
	})
}

func (r *Roller) Note(key string) {
	if key == "" {
		return
	}
	r.init()
	r.mu.Lock()
	r.pending[key] = true
	r.mu.Unlock()
	select {
	case r.wake <- struct{}{}:
	default:
	}
}

func (r *Roller) take() []string {
	r.mu.Lock()
	defer r.mu.Unlock()
	keys := make([]string, 0, len(r.pending))
	for k := range r.pending {
		keys = append(keys, k)
	}
	clear(r.pending)
	slices.Sort(keys)
	return keys
}

func (r *Roller) Run(ctx context.Context) {
	r.init()
	w, err := fswatch.Shared().Subscribe()
	if err != nil {
		r.log().Error("cluster provider: the roller cannot watch for roll requests", "err", err.Error())
		r.noteStatus(nil, err)
		return
	}
	defer w.Close()
	if keys, err := r.Clusters(ctx); err != nil {
		r.noteStatus(nil, err)
	} else {
		for _, key := range keys {
			r.Note(key)
		}
	}
	dirs := map[string]string{}
	for {
		select {
		case <-ctx.Done():
			return
		case ev, ok := <-w.Events():
			if !ok {
				return
			}
			if filepath.Base(ev.Name) != RollRequestFile || ev.Op == fsnotify.Chmod || ev.Has(fsnotify.Remove) {
				continue
			}
			if key, known := dirs[filepath.Dir(ev.Name)]; known {
				r.Note(key)
			}
			continue
		case _, ok := <-w.Errors():
			if !ok {
				return
			}
			continue
		case <-r.wake:
		}
		var err error
		for _, key := range r.take() {
			if dir, derr := ClusterDataDir(r.Stack.StateDir, key); derr == nil {
				if _, watched := dirs[dir]; !watched && w.Add(dir) == nil {
					dirs[dir] = key
				}
			}
			if perr := r.pass(ctx, key); perr != nil && err == nil {
				err = perr
			}
		}
		keys := make([]string, 0, len(dirs))
		for _, key := range dirs {
			keys = append(keys, key)
		}
		slices.Sort(keys)
		r.noteStatus(keys, err)
	}
}

func (r *Roller) pass(ctx context.Context, key string) error {
	p, err := RollRequestPath(r.Stack.StateDir, key)
	if err != nil {
		return nil
	}
	b, err := os.ReadFile(p)
	if err != nil {
		return nil
	}
	var req RollRequest
	if err := json.Unmarshal(b, &req); err != nil || req.ID == "" {
		r.log().Error("cluster provider: an unreadable roll request is dropped", "cluster", key, "path", p)
		os.Remove(p)
		return nil
	}
	ready, rerr := r.Ready(ctx, key)
	if !ready {
		r.noteObserved(key, req, RollStateWaiting, rerr)
		if rerr != nil {
			r.log().Warn("cluster provider: a roll waits; the cluster's readiness could not be read", "cluster", key, "roll", req.ID, "err", rerr.Error())
			return rerr
		}
		r.log().Info("cluster provider: a roll waits for its cluster to be Ready", "cluster", key, "roll", req.ID)
		return nil
	}
	r.noteObserved(key, req, RollStateStarted, nil)
	r.log().Info("cluster provider: roll started", "cluster", key, "roll", req.ID, "kind", req.Kind)
	var rep RollReport
	switch req.Kind {
	case RollKindCluster:
		rep = r.RollCluster(ctx, key, req)
	case RollKindLink:
		rep = r.RollLink(ctx, key, req)
	default:
		rep = RollReport{ID: req.ID, Kind: req.Kind, Cluster: key, Error: "unknown roll kind " + req.Kind}
	}
	rep.DoneAt = time.Now().UTC()
	if rp, err := RollReportPath(r.Stack.StateDir, key, req.ID); err == nil {
		if err := fsx.WriteJSON(rp, rep, 0o600); err != nil {
			r.log().Error("cluster provider: writing the roll report", "cluster", key, "err", err.Error())
		}
	}
	os.Remove(p)
	if op, err := RollObservedPath(r.Stack.StateDir, key, req.ID); err == nil {
		os.Remove(op)
	}
	if rep.Error != "" {
		r.log().Error("cluster provider: roll failed", "cluster", key, "roll", req.ID, "err", rep.Error)
		return nil
	}
	r.log().Info("cluster provider: roll done", "cluster", key, "roll", req.ID, "from", rep.From, "to", rep.To)
	return nil
}

var k3sVersionLine = regexp.MustCompile(`v(\d+)\.(\d+)\.(\d+)\S*`)

func ExecutableVersion(ctx context.Context, exe string) (string, error) {
	out, err := exec.CommandContext(ctx, exe, "--version").CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("clusterprovider: %s --version: %w", exe, err)
	}
	m := k3sVersionLine.FindString(string(out))
	if m == "" {
		return "", fmt.Errorf("clusterprovider: %s --version printed no version: %q", exe, strings.TrimSpace(string(out)))
	}
	return m, nil
}

func minorOf(v string) (int, error) {
	m := k3sVersionLine.FindStringSubmatch(v)
	if m == nil {
		return 0, fmt.Errorf("clusterprovider: %q is no Kubernetes version", v)
	}
	return strconv.Atoi(m[2])
}

func CheckSkew(active, target string, guests []string) error {
	am, err := minorOf(active)
	if err != nil {
		return err
	}
	tm, err := minorOf(target)
	if err != nil {
		return err
	}
	if tm != am && tm != am+1 {
		return fmt.Errorf("clusterprovider: the target k3s %s is not the active minor %d or the next one; minors move one at a time and a rollback stays within its minor", target, am)
	}
	for _, g := range guests {
		gm, err := minorOf(g)
		if err != nil {
			return err
		}
		if tm < gm || tm > gm+3 {
			return fmt.Errorf("clusterprovider: the target k3s %s is older than a joined guest's %s or more than three minors newer", target, g)
		}
	}
	return nil
}

var sharedCAs = []string{"server-ca.crt", "client-ca.crt", "request-header-ca.crt"}

func SameCAs(blueTLS, greenTLS string) error {
	for _, n := range sharedCAs {
		a, err := sha256sum.File(filepath.Join(blueTLS, n))
		if err != nil {
			return err
		}
		b, err := sha256sum.File(filepath.Join(greenTLS, n))
		if err != nil {
			return err
		}
		if a != b {
			return fmt.Errorf("clusterprovider: the new slot serves another %s than the active one (%s, %s)", n, a[:12], b[:12])
		}
	}
	return nil
}

func workingPods(ctx context.Context, core Tenant) (nodes, running int, guests []string, err error) {
	nl, err := core.Nodes(ctx)
	if err != nil {
		return 0, 0, nil, err
	}
	for _, n := range nl {
		guests = append(guests, n.Status.NodeInfo.KubeletVersion)
	}
	if len(nl) == 0 {
		return 0, 0, guests, nil
	}
	pl, err := core.RunningPods(ctx)
	if err != nil {
		return 0, 0, nil, err
	}
	pods := make([]*corev1.Pod, 0, len(pl))
	for i := range pl {
		pods = append(pods, &pl[i])
	}
	running = working(pods)
	return len(nl), running, guests, nil
}

func working(pods []*corev1.Pod) int {
	n := 0
	for _, p := range pods {
		if p.Namespace != metav1.NamespaceSystem && p.Spec.NodeName != "" {
			n++
		}
	}
	return n
}

func nodeReadySince(ctx context.Context, core Tenant) map[string]string {
	out := map[string]string{}
	nl, err := core.Nodes(ctx)
	if err != nil {
		return out
	}
	for _, n := range nl {
		for _, c := range n.Status.Conditions {
			if c.Type == corev1.NodeReady {
				out[n.Name] = string(c.Status) + "@" + c.LastTransitionTime.UTC().Format(time.RFC3339)
			}
		}
	}
	return out
}

func leaseHolders(ctx context.Context, core Tenant) map[string]string {
	out := map[string]string{}
	ll, err := core.SystemLeases(ctx)
	if err != nil {
		return out
	}
	for _, l := range ll {
		if strings.HasPrefix(l.Name, "apiserver-") || l.Spec.HolderIdentity == nil {
			continue
		}
		out[l.Name] = *l.Spec.HolderIdentity
	}
	return out
}

func backupStore(ctx context.Context, cfg StackConfig, s Store, dir string) error {
	pgDump, err := cfg.resolveExe("", cfg.PgDumpExec, "pg_dump")
	if err != nil {
		return fmt.Errorf("clusterprovider: a minor bump takes a pg_dump first and none is on this host: %w", err)
	}
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return err
	}
	for _, db := range []string{PostgresDatabase, PostgresVaultDatabase} {
		out := filepath.Join(dir, db+".sql")
		var stderr bytes.Buffer
		cmd := exec.CommandContext(ctx, pgDump, "-h", s.SocketDir, "-U", s.User, "-f", out, db)
		cmd.Stderr = &stderr
		if err := cmd.Run(); err != nil {
			return fmt.Errorf("clusterprovider: pg_dump %s: %w: %s", db, err, strings.TrimSpace(stderr.String()))
		}
		if st, err := os.Stat(out); err != nil || st.Size() == 0 {
			return fmt.Errorf("clusterprovider: pg_dump %s wrote nothing", db)
		}
	}
	return nil
}

func (h *UnitsHost) placeGated(ctx context.Context, key string, st procgroup.Step, gate func(context.Context, procgroup.Member) error) (*v1alpha1.HostProcess, error) {
	member := StepMember(st)
	cur, err := h.member(ctx, key, member)
	if err != nil {
		return nil, err
	}
	if cur != nil {
		if err := h.remove(ctx, ObjectName(key, member)); err != nil {
			return nil, err
		}
	}
	want, err := h.memberSpec(key, st, st.Spec)
	if err != nil {
		return nil, err
	}
	o, err := h.place(ctx, key, member, nil, want)
	if err != nil {
		return nil, err
	}
	gctx, cancel := context.WithTimeout(ctx, GateTimeout)
	defer cancel()
	if err := h.gateAlive(gctx, key, member, o.Status.Runtime.InvocationID, func(ctx context.Context) error {
		return gate(ctx, unitMember{path: st.Spec.Log})
	}); err != nil {
		return nil, fmt.Errorf("clusterprovider: %s of %s never became ready: %w", member, key, err)
	}
	inv := o.Status.Runtime.InvocationID
	if err := h.verify(ctx, key, member, inv); err != nil {
		return nil, err
	}
	return o, nil
}

func waitOutput(ctx context.Context, m procgroup.Member, mark, what string) error {
	return untilOutput(ctx, m, func(out string) (bool, error) {
		return strings.Contains(strings.ToLower(out), mark), nil
	}, func() error { return &errNotReady{what: what, last: tail(m.Output())} })
}

func slotSteps(spec clusterstack.Spec) (kine, k3s procgroup.Step, err error) {
	kargv, err := spec.KineArgv()
	if err != nil {
		return kine, k3s, err
	}
	sargv, err := spec.ServerArgv()
	if err != nil {
		return kine, k3s, err
	}
	klog, err := componentLog(spec.DataDir, procgroup.ComponentKine)
	if err != nil {
		return kine, k3s, err
	}
	k3sLog, err := componentLog(spec.DataDir, procgroup.ComponentK3s)
	if err != nil {
		return kine, k3s, err
	}
	if spec.Slot != ActiveSlot {
		klog = strings.TrimSuffix(klog, ".log") + "@" + spec.Slot + ".log"
		k3sLog = strings.TrimSuffix(k3sLog, ".log") + "@" + spec.Slot + ".log"
	}
	rotateLog(klog)
	rotateLog(k3sLog)
	kine = procgroup.Step{Name: procgroup.ComponentKine, Slot: spec.Slot, Spec: procgroup.Spec{Argv: kargv, Log: klog}}
	k3s = procgroup.Step{Name: procgroup.ComponentK3s, Slot: spec.Slot, Spec: procgroup.Spec{Argv: sargv, Log: k3sLog}}
	return kine, k3s, nil
}

func otherSlot(s string) string {
	if s == "b" {
		return "a"
	}
	return "b"
}

func (r *Roller) RollCluster(ctx context.Context, key string, req RollRequest) (rep RollReport) {
	h := r.Units
	rep = RollReport{ID: req.ID, Kind: RollKindCluster, Cluster: key, StartedAt: time.Now().UTC()}
	fail := func(err error) RollReport {
		rep.Error = err.Error()
		return rep
	}
	unlock := h.lock(key)
	defer unlock()
	cp, err := h.readControlPlane(key)
	if err != nil {
		return fail(err)
	}
	blue := slotOr(cp.ActiveSlot)
	green := otherSlot(blue)
	rep.From, rep.To = blue, green
	blueRec := cp.Slots[blue]
	l, err := r.Leases(key)
	if err != nil {
		return fail(err)
	}
	o := materialiser.Observed{Cluster: v1alpha1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: key}}}
	blueSpec, err := r.Stack.SlotSpec(o, l, SlotState{Slot: blue, K3s: blueRec.K3s, Kine: blueRec.Kine})
	if err != nil {
		return fail(err)
	}
	gk3s, gkine := req.K3s, req.Kine
	if gk3s == "" {
		gk3s = blueSpec.K3sExecutable()
	}
	if gkine == "" {
		gkine = blueSpec.KineExecutable()
	}
	if r.Relet != nil {
		gp, err := portallocator.PurposesOfSlot(green)
		if err != nil {
			return fail(err)
		}
		for _, p := range gp.All() {
			lease, changed, err := r.Relet(ctx, key, p)
			if err != nil {
				return fail(fmt.Errorf("clusterprovider: leasing %s for slot %s: %w", p, green, err))
			}
			if changed {
				rep.Relet = append(rep.Relet, fmt.Sprintf("%s %d -> %d", p, l[p].Port, lease.Port))
			}
			l[p] = lease
		}
	}
	spec, err := r.Stack.SlotSpec(o, l, SlotState{Slot: green, K3s: gk3s, Kine: gkine})
	if err != nil {
		return fail(err)
	}
	front := apiserverfront.Client{Path: spec.FrontControl}
	core, err := r.Stack.Admin(ctx, key, APIServerURL(spec.AdvertisePort))
	if err != nil {
		return fail(err)
	}
	if rep.FromVersion, err = ExecutableVersion(ctx, blueSpec.K3sExecutable()); err != nil {
		return fail(err)
	}
	if rep.ToVersion, err = ExecutableVersion(ctx, spec.K3sExecutable()); err != nil {
		return fail(err)
	}
	want := fmt.Sprintf("%s:%d", spec.AdvertiseAddress, spec.AdvertisePort)
	if eps, err := AdvertisedEndpoints(ctx, core); err != nil || len(eps) != 1 || eps[0] != want {
		return fail(fmt.Errorf("clusterprovider: the kubernetes EndpointSlice is %v (%v), not only %s; refusing to swap", eps, err, want))
	}
	bound := DefaultIdleBound
	if req.IdleBound != "" {
		if bound, err = time.ParseDuration(req.IdleBound); err != nil {
			return fail(err)
		}
	}
	idleStart := time.Now()
	nodes, running, guests, err := workingPods(ctx, core)
	if err != nil {
		return fail(err)
	}
	rep.Nodes = nodes
	if err := CheckSkew(rep.FromVersion, rep.ToVersion, guests); err != nil {
		return fail(err)
	}
	if running > 0 {
		r.log().Info("cluster provider: a swap waits for the market nodes to be idle", "cluster", key, "running", running, "bound", bound)
		err := core.AwaitRunningPods(ctx, bound, func(pods []*corev1.Pod) (bool, error) {
			return working(pods) == 0, nil
		})
		switch {
		case timedOut(err):
			rep.IdleDeferred = true
		case err != nil:
			return fail(err)
		}
		if nodes, _, guests, err = workingPods(ctx, core); err != nil {
			return fail(err)
		}
		rep.Nodes = nodes
		if err := CheckSkew(rep.FromVersion, rep.ToVersion, guests); err != nil {
			return fail(err)
		}
	}
	rep.IdleSeconds = clock.SecondsBetween(idleStart, time.Now())
	if am, _ := minorOf(rep.FromVersion); func() bool { tm, _ := minorOf(rep.ToVersion); return tm != am }() {
		store, ok := h.Store(key)
		if !ok {
			return fail(errors.New("clusterprovider: no store recorded for a minor bump's backup"))
		}
		dir := filepath.Join(spec.DataDir, BackupsDirName, time.Now().UTC().Format("20060102T150405Z"))
		if err := backupStore(ctx, r.Stack, store, dir); err != nil {
			return fail(err)
		}
		rep.Backup = dir
	}
	kineStep, k3sStep, err := slotSteps(spec)
	if err != nil {
		return fail(err)
	}
	abort := func(err error) RollReport {
		sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
		defer cancel()
		h.remove(sctx, ObjectName(key, StepMember(k3sStep)))
		h.remove(sctx, ObjectName(key, StepMember(kineStep)))
		return fail(err)
	}
	ko, err := h.placeGated(ctx, key, kineStep, func(ctx context.Context, m procgroup.Member) error {
		if err := waitDial(ctx, spec.KineListen); err != nil {
			return err
		}
		return waitOutput(ctx, m, kineSchemaMark, "kine@"+green+"'s schema check")
	})
	if err != nil {
		return abort(err)
	}
	so, err := h.placeGated(ctx, key, k3sStep, func(ctx context.Context, m procgroup.Member) error {
		if err := r.Stack.WaitSlot(ctx, spec); err != nil {
			return err
		}
		return SameCAs(blueSpec.ServerTLSDir(), spec.ServerTLSDir())
	})
	if err != nil {
		return abort(err)
	}
	rep.GreenReadyAt = time.Now().UTC()
	rep.StartToReady = clock.SecondsBetween(rep.StartedAt, rep.GreenReadyAt)
	beforeNodes := nodeReadySince(ctx, core)
	beforeLeases := leaseHolders(ctx, core)

	next := cp
	next.ActiveSlot = green
	next.LastRoll = req.ID
	next.Members = nil
	for _, m := range cp.Members {
		switch m.Name {
		case "kine@" + blue:
			m = ControlPlaneMember{Name: StepMember(kineStep), Unit: ko.Spec.Unit, Object: ObjectName(key, StepMember(kineStep)), InvocationID: ko.Status.Runtime.InvocationID}
		case "k3s@" + blue:
			m = ControlPlaneMember{Name: StepMember(k3sStep), Unit: so.Spec.Unit, Object: ObjectName(key, StepMember(k3sStep)), InvocationID: so.Status.Runtime.InvocationID}
		}
		next.Members = append(next.Members, m)
	}
	if next.Slots == nil {
		next.Slots = map[string]SlotRecord{}
	}
	if _, ok := next.Slots[blue]; !ok {
		next.Slots[blue] = SlotRecord{K3s: blueSpec.K3sExecutable(), Kine: blueSpec.KineExecutable(), K3sVersion: rep.FromVersion,
			Port: blueSpec.K3sSecurePort, DataDir: blueSpec.ServerDataDir, Hostname: blueSpec.Hostname,
			K3sUnit: h.Unit(key, "k3s@"+blue), KineUnit: h.Unit(key, "kine@"+blue)}
	}
	next.Slots[green] = SlotRecord{K3s: spec.K3sExecutable(), Kine: spec.KineExecutable(), K3sVersion: rep.ToVersion,
		Port: spec.K3sSecurePort, KinePort: slotKinePort(l, green), DataDir: spec.ServerDataDir, Hostname: spec.Hostname,
		K3sUnit: so.Spec.Unit, KineUnit: ko.Spec.Unit, Invocation: so.Status.Runtime.InvocationID, StartedAt: rep.StartedAt.Format(time.RFC3339Nano)}
	if err := h.writeControlPlane(key, next); err != nil {
		return abort(err)
	}
	rep.PersistedAt = time.Now().UTC()
	fst, err := front.Set(ctx, SlotBackend(spec))
	if err != nil {
		h.writeControlPlane(key, cp)
		return abort(fmt.Errorf("clusterprovider: switching the front to slot %s: %w", green, err))
	}
	rep.FlippedAt = time.Now().UTC()
	rep.FrontBackend = fst.Backend
	r.log().Info("cluster provider: front switched", "cluster", key, "from", SlotBackend(blueSpec), "to", fst.Backend)
	if err := h.remove(ctx, ObjectName(key, "k3s@"+blue)); err != nil {
		return fail(err)
	}
	rep.OldStoppedAt = time.Now().UTC()
	rep.OldStopSecs = clock.SecondsBetween(rep.FlippedAt, rep.OldStoppedAt)
	if err := h.remove(ctx, ObjectName(key, "kine@"+blue)); err != nil {
		return fail(err)
	}
	dctx, cancelDrain := context.WithTimeout(ctx, drainWait)
	_ = poll(dctx, func(ctx context.Context) (bool, error) {
		st, err := front.Status(ctx)
		if err != nil {
			return true, nil
		}
		rep.OpenOnOld = st.Open[SlotBackend(blueSpec)]
		return rep.OpenOnOld == 0, nil
	}, func() error { return nil })
	cancelDrain()
	if n, err := front.Drain(ctx, SlotBackend(blueSpec)); err == nil {
		rep.Drained = n
	}
	if f, err := h.frontRecord(ctx, key); err == nil {
		next.Front = f
		h.writeControlPlane(key, next)
	}
	r.watch(ctx, core, spec, &rep, beforeNodes, beforeLeases)
	return rep
}

func slotKinePort(l materialiser.Leases, slot string) int {
	sp, err := portallocator.PurposesOfSlot(slot)
	if err != nil {
		return 0
	}
	return l[sp.Kine].Port
}

func linkComponent(service v1alpha1.ClusterLinkService) (procgroup.Component, error) {
	switch service {
	case v1alpha1.ClusterLinkApiserver:
		return procgroup.ComponentApiserverLink, nil
	case v1alpha1.ClusterLinkOpenBao:
		return procgroup.ComponentOpenBaoLink, nil
	}
	return "", fmt.Errorf("clusterprovider: %q is no cluster link; the links are %v", service, clusterlink.Services)
}

func printedTicket(log string) string {
	t, _ := clusterlink.TicketIn(unitMember{path: log}.Output())
	return t
}

func (r *Roller) RollLink(ctx context.Context, key string, req RollRequest) (rep RollReport) {
	h := r.Units
	rep = RollReport{ID: req.ID, Kind: RollKindLink, Cluster: key, StartedAt: time.Now().UTC()}
	fail := func(err error) RollReport {
		rep.Error = err.Error()
		return rep
	}
	if r.Stack.Links == nil {
		return fail(errors.New("clusterprovider: this provider runs no cluster links"))
	}
	service := v1alpha1.ClusterLinkService(req.Service)
	comp, err := linkComponent(service)
	if err != nil {
		return fail(err)
	}
	unlock := h.lock(key)
	defer unlock()
	cp, err := h.readControlPlane(key)
	if err != nil {
		return fail(err)
	}
	cur := h.LinkState(key, service)
	from := slotOr(cur.Slot)
	to := otherSlot(from)
	rep.From, rep.To = from, to
	l, err := r.Leases(key)
	if err != nil {
		return fail(err)
	}
	dumbpipe := req.Dumbpipe
	dumbpipe = cmp.Or(dumbpipe, cur.Dumbpipe)
	next := LinkRecord{Slot: to, Dumbpipe: dumbpipe}
	cfg := r.Stack
	prev := cfg.LinkSlot
	cfg.LinkSlot = func(cluster string, s v1alpha1.ClusterLinkService) LinkRecord {
		if s == service {
			return next
		}
		if prev != nil {
			return prev(cluster, s)
		}
		return LinkRecord{Slot: ActiveSlot}
	}
	cfg.Slot = func(string) SlotState { return h.SlotState(key) }
	steps, err := StepsFor(cfg)(materialiser.Observed{Cluster: v1alpha1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: key}}}, l)
	if err != nil {
		return fail(err)
	}
	var st procgroup.Step
	var oldLog string
	for _, s := range steps {
		if s.Name == comp {
			st = s
		}
	}
	if st.Name == "" {
		return fail(fmt.Errorf("clusterprovider: no %s step for %s", comp, key))
	}
	oldMember := SlotMember(comp, linkStepSlot(from))
	newMember := StepMember(st)
	if o, err := h.member(ctx, key, oldMember); err == nil && o != nil && o.Spec.Output != nil {
		oldLog = o.Spec.Output.Stdout
		rep.OldListener = o.Spec.Unit
	}
	rep.TicketBefore = printedTicket(oldLog)
	rotateLog(st.Spec.Log)
	spec, err := st.Prepare(ctx, st.Spec)
	if err != nil {
		return fail(err)
	}
	st.Spec = spec
	o, err := h.placeGated(ctx, key, st, st.Ready)
	if err != nil {
		sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
		defer cancel()
		h.remove(sctx, ObjectName(key, newMember))
		return fail(err)
	}
	rep.GreenReadyAt = time.Now().UTC()
	rep.StartToReady = clock.SecondsBetween(rep.StartedAt, rep.GreenReadyAt)
	rep.NewListener = o.Spec.Unit
	rep.Ticket = printedTicket(st.Spec.Log)
	updated := cp
	updated.Members = nil
	for _, m := range cp.Members {
		if m.Name == oldMember {
			m = ControlPlaneMember{Name: newMember, Unit: o.Spec.Unit, Object: ObjectName(key, newMember), InvocationID: o.Status.Runtime.InvocationID}
		}
		updated.Members = append(updated.Members, m)
	}
	if updated.Links == nil {
		updated.Links = map[string]LinkRecord{}
	}
	updated.Links[string(service)] = next
	updated.LastRoll = req.ID
	if err := h.writeControlPlane(key, updated); err != nil {
		return fail(err)
	}
	rep.PersistedAt = time.Now().UTC()
	if err := h.remove(ctx, ObjectName(key, oldMember)); err != nil {
		return fail(err)
	}
	rep.OldStoppedAt = time.Now().UTC()
	rep.OldStopSecs = clock.SecondsBetween(rep.PersistedAt, rep.OldStoppedAt)
	return rep
}

func (r *Roller) watch(ctx context.Context, core Tenant, spec clusterstack.Spec, rep *RollReport, nodes, leases map[string]string) {
	want := fmt.Sprintf("%s:%d", spec.AdvertiseAddress, spec.AdvertisePort)
	pending := map[string]*LeaseHandover{}
	for name, holder := range leases {
		pending[name] = &LeaseHandover{Lease: name, From: holder}
	}
	wctx, cancel := context.WithTimeout(ctx, DefaultRollWatch)
	defer cancel()
	var mu sync.Mutex
	var wg sync.WaitGroup
	wg.Go(func() {
		core.FollowAPIServerSlices(wctx, func() {
			eps, err := AdvertisedEndpoints(wctx, core)
			mu.Lock()
			defer mu.Unlock()
			if err != nil || len(eps) != 1 || eps[0] != want {
				if wctx.Err() == nil {
					rep.EndpointsLost++
				}
				return
			}
			rep.Endpoints = eps
		})
	})
	wg.Go(func() {
		core.FollowSystemLeases(wctx, func(l *coordinationv1.Lease) {
			if l.Spec.HolderIdentity == nil {
				return
			}
			mu.Lock()
			defer mu.Unlock()
			p, ok := pending[l.Name]
			if !ok || p.To != "" {
				return
			}
			if holder := *l.Spec.HolderIdentity; holder != p.From {
				p.To = holder
				p.Seconds = clock.SecondsBetween(rep.OldStoppedAt, time.Now())
			}
		})
	})
	<-wctx.Done()
	wg.Wait()
	if ctx.Err() != nil {
		return
	}
	for _, p := range pending {
		rep.Leases = append(rep.Leases, *p)
	}
	after := nodeReadySince(ctx, core)
	for n, was := range nodes {
		if after[n] != was {
			rep.NodeTransitions = append(rep.NodeTransitions, n+": "+was+" -> "+after[n])
		}
	}
}