~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
181 lines · 5.3 KB Raw History
package clusterroll

import (
	"context"
	"errors"
	"fmt"
	"reflect"
	"strings"
	"sync"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	ctlruntime "github.com/johnandersen777/socialweb-computer-kcp/internal/controllers/runtime"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/conds"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/lease/local"
)

const (
	Name = "clusterroll"

	Export = "applications"

	ConditionReady = "Ready"

	ConditionRolling = "Rolling"
)

type Options struct {
	Rolls local.Rolls

	Changes <-chan struct{}

	Scope func(workspace string) bool
}

type Controller struct {
	rolls local.Rolls

	mu sync.Mutex

	known map[ctlruntime.Request]bool
}

func Register(m *ctlruntime.Manager, opts Options) (*Controller, error) {
	if opts.Rolls == nil {
		return nil, errors.New("clusterroll: a kcp slot reader is required")
	}
	if err := m.AddToScheme(v1alpha1.AddToScheme); err != nil {
		return nil, err
	}
	ctl := &Controller{rolls: opts.Rolls, known: map[ctlruntime.Request]bool{}}
	options := []ctlruntime.Option{ctlruntime.Export(Export), ctlruntime.Gone(ctl.gone)}
	if opts.Scope != nil {
		options = append(options, ctlruntime.InWorkspaces(opts.Scope))
	}
	if opts.Changes != nil {
		events, err := ctlruntime.Forward(m, Name+": changes", opts.Changes, ctl.changed)
		if err != nil {
			return nil, fmt.Errorf("clusterroll: forwarding changes: %w", err)
		}
		options = append(options, ctlruntime.Events(events))
	}
	if err := ctlruntime.For[*v1alpha1.ClusterRoll](m, Name, ctl, options...); err != nil {
		return nil, fmt.Errorf("clusterroll: registering the ClusterRoll controller: %w", err)
	}
	return ctl, nil
}

type outcome struct {
	phase v1alpha1.ClusterRollPhase

	started time.Time

	completed time.Time

	ready bool

	rolling bool

	reason string

	message string
}

func (c *Controller) Reconcile(ctx context.Context, req ctlruntime.Request, object *v1alpha1.ClusterRoll) (ctlruntime.Result, error) {
	self := ctlruntime.Request{Cluster: req.Cluster, Namespace: req.Namespace, Name: req.Name}
	if !object.DeletionTimestamp.IsZero() {
		c.forget(self)
		return ctlruntime.Result{}, nil
	}
	c.remember(self)
	o, rollErr := c.observe(strings.TrimSpace(object.Spec.ClusterRef.Name))
	gen := object.Generation
	status := *object.Status.DeepCopy()
	status.ObservedGeneration = gen
	status.Phase = o.phase
	status.StartedAt = formatTime(o.started)
	status.CompletedAt = formatTime(o.completed)
	conds.Set(&status.Conditions, gen, ConditionReady, o.ready, o.reason, o.message)
	conds.Set(&status.Conditions, gen, ConditionRolling, o.rolling, o.reason, o.message)
	return ctlruntime.Result{}, errors.Join(rollErr, c.apply(ctx, req, object, status))
}

func (c *Controller) observe(cluster string) (outcome, error) {
	pending := func(reason, message string) outcome {
		return outcome{phase: v1alpha1.ClusterRollPhasePending, reason: reason, message: message}
	}
	if cluster == "" {
		return pending("InvalidSpec", "the ClusterRoll names no cluster"), nil
	}
	slots, err := c.rolls.Slots()
	if err != nil {
		return pending("RollStateUnavailable", err.Error()),
			fmt.Errorf("clusterroll: reading the kcp roll state for cluster %s: %w", cluster, err)
	}
	active := strings.TrimSpace(slots.Active)
	if active == "" {
		return pending("AwaitingRoll", fmt.Sprintf("the kcpserver records no active slot for cluster %s", cluster)), nil
	}
	record, ok := slots.Slots[active]
	if !ok {
		return outcome{phase: v1alpha1.ClusterRollPhaseFailed, reason: "RollStateInconsistent",
			message: fmt.Sprintf("the kcpserver records active slot %q for cluster %s but no record for it", active, cluster)}, nil
	}
	started := record.StartedAt
	if started.IsZero() {
		started = slots.Updated
	}
	if record.ReadyAt.IsZero() {
		return outcome{phase: v1alpha1.ClusterRollPhaseRunning, started: started, rolling: true, reason: "RollInProgress",
			message: fmt.Sprintf("the kcpserver is bringing slot %s of cluster %s up on port %d", active, cluster, record.Port)}, nil
	}
	return outcome{phase: v1alpha1.ClusterRollPhaseCompleted, started: started, completed: record.ReadyAt, ready: true,
		reason: "RollCompleted", message: fmt.Sprintf("the kcpserver records slot %s of cluster %s ready on port %d", active, cluster, record.Port)}, nil
}

func (c *Controller) apply(ctx context.Context, req ctlruntime.Request, object *v1alpha1.ClusterRoll, status v1alpha1.ClusterRollStatus) error {
	if reflect.DeepEqual(object.Status, status) {
		return nil
	}
	next := object.DeepCopy()
	next.Status = status
	return req.ApplyStatus(ctx, next)
}

func (c *Controller) gone(_ context.Context, req ctlruntime.Request) {
	c.forget(ctlruntime.Request{Cluster: req.Cluster, Namespace: req.Namespace, Name: req.Name})
}

func (c *Controller) remember(r ctlruntime.Request) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.known[r] = true
}

func (c *Controller) forget(r ctlruntime.Request) {
	c.mu.Lock()
	defer c.mu.Unlock()
	delete(c.known, r)
}

func (c *Controller) all() []ctlruntime.Request {
	c.mu.Lock()
	defer c.mu.Unlock()
	out := make([]ctlruntime.Request, 0, len(c.known))
	for r := range c.known {
		out = append(out, r)
	}
	return out
}

func (c *Controller) changed(struct{}) []ctlruntime.Request {
	return c.all()
}

func formatTime(t time.Time) string {
	if t.IsZero() {
		return ""
	}
	return t.UTC().Format(time.RFC3339)
}