~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
304 lines · 9.0 KB Raw History
package hostprocess

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"strconv"
	"strings"
	"syscall"
	"time"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/watch"
	"k8s.io/client-go/rest"

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/host"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/controllers/runtime/kube"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/hostsvc"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kcpserver"
)

const RemoteTimeout = 2 * time.Minute

type RemoteConfig struct {
	Kubeconfig string
	Workspace  string
	Reader     hostsvc.Manager
	Timeout    time.Duration
	Log        *slog.Logger
}

var _ host.Units = (*Remote)(nil)

type Remote struct {
	hostsvc.Manager
	c      RemoteConfig
	client kube.Resource
}

func NewRemote(c RemoteConfig) (*Remote, error) {
	if c.Reader == nil {
		return nil, fmt.Errorf("hostprocess: a remote placement needs the backend it reads units from")
	}
	restCfg, err := kcpserver.FrontRestConfig(c.Kubeconfig)
	if err != nil {
		return nil, err
	}
	cfg := rest.CopyConfig(restCfg)
	cfg.Host = strings.TrimSuffix(cfg.Host, "/") + "/clusters/" + c.Workspace
	kc, err := kube.New(cfg)
	if err != nil {
		return nil, err
	}
	return &Remote{Manager: c.Reader, c: c, client: kc.Resource(GVR)}, nil
}

func (r *Remote) log() *slog.Logger {
	if r.c.Log == nil {
		return slog.New(slog.DiscardHandler)
	}
	return r.c.Log.With("component", "hostprocess-remote", "workspace", r.c.Workspace)
}

func (r *Remote) timeout() time.Duration {
	if r.c.Timeout == 0 {
		return RemoteTimeout
	}
	return r.c.Timeout
}

func ObjectName(unit string) string {
	n := strings.TrimSuffix(strings.TrimPrefix(unit, hostsvc.NamePrefix), hostsvc.ServiceSuffix)
	return strings.ToLower(strings.NewReplacer("@", "-", "_", "-", ".", "-").Replace(n))
}

func restartPolicy(r hostsvc.Restart) v1alpha1.HostProcessRestartPolicy {
	switch r {
	case hostsvc.RestartAlways:
		return v1alpha1.HostProcessRestartAlways
	case hostsvc.RestartOnFailure:
		return v1alpha1.HostProcessRestartOnFailure
	}
	return v1alpha1.HostProcessRestartNever
}

func signalName(s syscall.Signal) string {
	switch s {
	case syscall.SIGINT:
		return "SIGINT"
	case syscall.SIGKILL:
		return "SIGKILL"
	case syscall.SIGQUIT:
		return "SIGQUIT"
	}
	return "SIGTERM"
}

func SpecOf(u hostsvc.UnitSpec) v1alpha1.HostProcessSpec {
	s := v1alpha1.HostProcessSpec{Unit: u.Name, WorkDir: u.WorkDir, RestartPolicy: restartPolicy(u.Restart), After: u.After,
		Wants: u.Wants, Slice: u.Slice, PrivateHostname: u.PrivateHostname, FdStore: int32(u.FDStore), EnvFiles: u.EnvFiles,
		Placement: v1alpha1.HostProcessPlacementSupervisor}
	if len(u.Exec) > 0 {
		s.Exec, s.Argv = u.Exec[0], u.Exec[1:]
	}
	for _, e := range u.Env {
		k, v, _ := strings.Cut(e, "=")
		s.Env = append(s.Env, v1alpha1.HostProcessEnv{Name: k, Value: v})
	}
	if u.Output.StdoutFile != "" || u.Output.StderrFile != "" {
		s.Output = &v1alpha1.HostProcessOutput{Stdout: u.Output.StdoutFile, Stderr: u.Output.StderrFile}
	}
	if u.KillSignal != 0 || u.StopTimeout > 0 {
		s.Stop = &v1alpha1.HostProcessStop{Signal: signalName(u.KillSignal), GraceSeconds: int32((u.StopTimeout + time.Second - 1) / time.Second)}
	}
	l := u.Limits
	if l.MemoryMax > 0 || l.CPUWeight > 0 || l.CPUQuotaPercent > 0 || l.TasksMax > 0 {
		s.Limits = &v1alpha1.HostProcessLimits{CpuWeight: int64(l.CPUWeight), CpuQuotaPercent: int32(l.CPUQuotaPercent), TasksMax: int64(l.TasksMax)}
		if l.MemoryMax > 0 {
			s.Limits.MemoryMax = strconv.FormatUint(l.MemoryMax, 10)
		}
	}
	return s
}

func (r *Remote) Start(ctx context.Context, spec hostsvc.UnitSpec) (hostsvc.UnitStatus, error) {
	if err := hostsvc.ValidateSpec(spec); err != nil {
		return hostsvc.UnitStatus{}, err
	}
	name := ObjectName(spec.Name)
	_, err := kube.Get[v1alpha1.HostProcess](ctx, r.client, name)
	switch {
	case apierrors.IsNotFound(err):
	case err != nil:
		return hostsvc.UnitStatus{}, fmt.Errorf("hostprocess: reading %s in %s: %w", name, r.c.Workspace, err)
	default:
		if st, err := r.Manager.Inspect(ctx, spec.Name); err == nil && (st.State == hostsvc.Active || st.State == hostsvc.Activating) {
			st.Adopted = true
			return st, nil
		}
		if err := r.Stop(ctx, spec.Name); err != nil {
			return hostsvc.UnitStatus{}, err
		}
	}
	o := v1alpha1.HostProcess{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{
		MirrorLabel: string(v1alpha1.HostProcessPlacementSupervisor)}},
		Spec: SpecOf(spec)}
	if _, err := kube.Create[v1alpha1.HostProcess](ctx, r.client, &o, metav1.CreateOptions{}); err != nil {
		return hostsvc.UnitStatus{}, fmt.Errorf("hostprocess: writing %s into %s: %w", name, r.c.Workspace, err)
	}
	r.log().Info("declared", "hostprocess", name, "unit", spec.Name)
	return r.waitPlaced(ctx, name, spec.Name)
}

var errWatchEnded = errors.New("hostprocess: a watch ended")

func (r *Remote) follow(ctx context.Context, name, unit string, done func(o *v1alpha1.HostProcess) (bool, error)) error {
	for {
		err := r.followOnce(ctx, name, unit, done)
		if !errors.Is(err, errWatchEnded) {
			return err
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(RetryAfter):
		}
	}
}

func (r *Remote) followOnce(ctx context.Context, name, unit string, done func(o *v1alpha1.HostProcess) (bool, error)) error {
	wctx, cancel := context.WithCancel(ctx)
	defer cancel()
	units, err := r.Manager.Watch(wctx, unit)
	if errors.Is(err, hostsvc.ErrUnreachable) {
		return fmt.Errorf("%w: %w", errWatchEnded, err)
	}
	if err != nil {
		return err
	}
	events, stop, err := kube.Watch[v1alpha1.HostProcess](wctx, r.client, metav1.ListOptions{FieldSelector: "metadata.name=" + name})
	if err != nil {
		return fmt.Errorf("%w: %w", errWatchEnded, err)
	}
	defer stop()
	var o *v1alpha1.HostProcess
	if cur, err := kube.Get[v1alpha1.HostProcess](ctx, r.client, name); err == nil {
		o = cur
	}
	for {
		if ok, err := done(o); ok || err != nil {
			return err
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case ev, ok := <-events:
			if !ok || ev.Type == watch.Error {
				return errWatchEnded
			}
			if ev.Type == watch.Deleted {
				o = nil
				continue
			}
			if ev.Object != nil {
				o = ev.Object
			}
		case _, ok := <-units:
			if !ok {
				return errWatchEnded
			}
		}
	}
}

func (r *Remote) waitPlaced(ctx context.Context, name, unit string) (hostsvc.UnitStatus, error) {
	tctx, cancel := context.WithTimeout(ctx, r.timeout())
	defer cancel()
	var st hostsvc.UnitStatus
	err := r.follow(tctx, name, unit, func(o *v1alpha1.HostProcess) (bool, error) {
		if o == nil {
			return false, nil
		}
		if o.Status.Phase == v1alpha1.HostProcessPhaseFailed && o.Status.Runtime == nil {
			return true, fmt.Errorf("hostprocess: %s in %s failed to place: %s", name, r.c.Workspace, o.Status.Message)
		}
		rt := o.Status.Runtime
		if rt == nil || rt.InvocationID == "" {
			return false, nil
		}
		cur, err := r.Manager.Inspect(tctx, unit)
		if err != nil {
			return false, nil
		}
		if cur.InvocationID == rt.InvocationID && cur.State == hostsvc.Active {
			st = cur
			return true, nil
		}
		if cur.State == hostsvc.Failed {
			st = cur
			return true, fmt.Errorf("hostprocess: %s failed after placement (result %s, status %d)", unit, cur.Result, cur.ExecMainStatus)
		}
		return false, nil
	})
	switch {
	case err == nil:
		return st, nil
	case ctx.Err() != nil:
		return hostsvc.UnitStatus{}, ctx.Err()
	case tctx.Err() != nil:
		return hostsvc.UnitStatus{}, fmt.Errorf("hostprocess: the supervisor of %s did not place %s within %s", r.c.Workspace, unit, r.timeout())
	}
	return st, err
}

func (r *Remote) Stop(ctx context.Context, unit string) error {
	name := ObjectName(unit)
	err := r.client.Delete(ctx, name, metav1.DeleteOptions{})
	if err != nil && !apierrors.IsNotFound(err) {
		return fmt.Errorf("hostprocess: deleting %s in %s: %w", name, r.c.Workspace, err)
	}
	tctx, cancel := context.WithTimeout(ctx, r.timeout())
	defer cancel()
	var st hostsvc.UnitStatus
	err = r.follow(tctx, name, unit, func(*v1alpha1.HostProcess) (bool, error) {
		cur, err := r.Manager.Inspect(tctx, unit)
		st = cur
		return err == nil && !hostsvc.Live(cur), nil
	})
	switch {
	case err == nil:
		r.log().Info("stopped", "hostprocess", name, "unit", unit, "state", string(st.State))
		return nil
	case ctx.Err() != nil:
		return ctx.Err()
	case tctx.Err() != nil:
		return fmt.Errorf("hostprocess: the supervisor of %s did not stop %s within %s", r.c.Workspace, unit, r.timeout())
	}
	return err
}

func (r *Remote) ResetFailed(ctx context.Context, unit string) error { return nil }

func (r *Remote) StopSlice(ctx context.Context, slice string) error {
	if err := hostsvc.ValidateSlice(slice); err != nil {
		return err
	}
	list, err := kube.List[v1alpha1.HostProcess](ctx, r.client, metav1.ListOptions{})
	if err != nil {
		return err
	}
	for i := len(list) - 1; i >= 0; i-- {
		o := list[i]
		if !hostsvc.SliceContains(slice, o.Spec.Slice) {
			continue
		}
		if err := r.Stop(ctx, o.Spec.Unit); err != nil {
			return err
		}
	}
	return nil
}