mirror of
https://github.com/cirruslabs/orchard.git
synced 2026-09-30 03:51:43 +02:00
Share one Stop completion per run and retain Stopping until both the stop command and VM cleanup finish. Expose transition conditions to reconciliation and return after requesting Stop so it cannot apply a generation or restart the VM early. Cover both completion orders, repeated and concurrent calls, restart, and generation acknowledgement with bounded fake-command regressions. Allow process startup under parallel test load while preserving explicit shutdown ordering.
890 lines
23 KiB
Go
890 lines
23 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"slices"
|
|
"time"
|
|
|
|
goruntime "runtime"
|
|
|
|
"github.com/cirruslabs/orchard/internal/dialer"
|
|
"github.com/cirruslabs/orchard/internal/opentelemetry"
|
|
"github.com/cirruslabs/orchard/internal/worker/dhcpleasetime"
|
|
"github.com/cirruslabs/orchard/internal/worker/ondiskname"
|
|
"github.com/cirruslabs/orchard/internal/worker/platform"
|
|
"github.com/cirruslabs/orchard/internal/worker/runtime"
|
|
"github.com/cirruslabs/orchard/internal/worker/vmmanager"
|
|
"github.com/cirruslabs/orchard/pkg/client"
|
|
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
|
|
"github.com/cirruslabs/orchard/rpc"
|
|
mapset "github.com/deckarep/golang-set/v2"
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/samber/lo"
|
|
"github.com/samber/mo"
|
|
"github.com/shirou/gopsutil/v4/cpu"
|
|
"github.com/shirou/gopsutil/v4/mem"
|
|
"go.opentelemetry.io/otel/metric"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/sync/errgroup"
|
|
"google.golang.org/grpc/metadata"
|
|
)
|
|
|
|
const (
|
|
pollInterval = 5 * time.Second
|
|
workerResourceUpdateInterval = 15 * time.Second
|
|
recoveredVMProtectionPeriod = 30 * time.Second
|
|
rpcWatchReconnectInterval = 100 * time.Millisecond
|
|
rpcWatchReconnectMaxInterval = 5 * time.Second
|
|
rpcWatchReconnectMultiplier = 2
|
|
rpcWatchHealthyInterval = time.Second
|
|
onDiskVMSyncTimeout = 30 * time.Second
|
|
)
|
|
|
|
var (
|
|
ErrPollFailed = errors.New("failed to poll controller")
|
|
errRPCWatchDisconnected = errors.New("RPC watch disconnected")
|
|
)
|
|
|
|
type Worker struct {
|
|
name string
|
|
nameSuffix string
|
|
syncRequested chan bool
|
|
vmm *vmmanager.VMManager
|
|
client *client.Client
|
|
pollTicker *time.Ticker
|
|
recoveredVMs map[ondiskname.OnDiskName]time.Time
|
|
resources v1.Resources
|
|
labels v1.Labels
|
|
|
|
defaultCPU uint64
|
|
defaultMemory uint64
|
|
|
|
runtime runtime.Runtime
|
|
|
|
vmPullTimeHistogram metric.Float64Histogram
|
|
|
|
dialer dialer.Dialer
|
|
|
|
logger *zap.SugaredLogger
|
|
}
|
|
|
|
func New(client *client.Client, opts ...Option) (*Worker, error) {
|
|
worker := &Worker{
|
|
client: client,
|
|
pollTicker: time.NewTicker(pollInterval),
|
|
recoveredVMs: make(map[ondiskname.OnDiskName]time.Time),
|
|
vmm: vmmanager.New(),
|
|
syncRequested: make(chan bool, 1),
|
|
}
|
|
|
|
// Apply options
|
|
for _, opt := range opts {
|
|
opt(worker)
|
|
}
|
|
|
|
// Apply defaults
|
|
if worker.name == "" {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
worker.name = hostname
|
|
}
|
|
|
|
if worker.nameSuffix != "" {
|
|
worker.name += worker.nameSuffix
|
|
}
|
|
|
|
if worker.runtime == nil {
|
|
if goruntime.GOOS == "linux" {
|
|
worker.runtime = runtime.NewVetu()
|
|
} else {
|
|
worker.runtime = runtime.NewTart()
|
|
}
|
|
}
|
|
|
|
defaultResources := v1.Resources{}
|
|
|
|
if worker.runtime.ID() == v1.RuntimeTart {
|
|
defaultResources[v1.ResourceTartVMs] = 2
|
|
}
|
|
|
|
// Determine the number of the host's logical CPU cores
|
|
numLogicalCPUs, err := cpu.Counts(true)
|
|
if err != nil {
|
|
worker.logger.Warnf("cannot determine the number of host's logical CPU cores, "+
|
|
"%s resource will not be available: %v", v1.ResourceLogicalCores, err)
|
|
} else {
|
|
defaultResources[v1.ResourceLogicalCores] = uint64(numLogicalCPUs)
|
|
}
|
|
|
|
// Determine the size of the host's memory
|
|
virtualMemoryStat, err := mem.VirtualMemory()
|
|
if err != nil {
|
|
worker.logger.Warnf("cannot determine the size of the host's memory, "+
|
|
"%s resource will not be available: %v", v1.ResourceMemoryMiB, err)
|
|
} else {
|
|
defaultResources[v1.ResourceMemoryMiB] = virtualMemoryStat.Total / humanize.MiByte
|
|
}
|
|
|
|
worker.resources = defaultResources.Merged(worker.resources)
|
|
|
|
// Worker, VMs and images-related metrics
|
|
worker.vmPullTimeHistogram, err = opentelemetry.DefaultMeter.Float64Histogram(
|
|
"org.cirruslabs.orchard.worker.vm.pull_time",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if worker.logger == nil {
|
|
worker.logger = zap.NewNop().Sugar()
|
|
}
|
|
|
|
return worker, nil
|
|
}
|
|
|
|
func (worker *Worker) Run(ctx context.Context) error {
|
|
if worker.runtime.ID() == v1.RuntimeTart {
|
|
if err := dhcpleasetime.Check(); err != nil {
|
|
worker.logger.Warnf("%v", err)
|
|
}
|
|
}
|
|
|
|
var reconnectBackoff rpcWatchReconnectBackoff
|
|
reconnectBackoff.reset()
|
|
|
|
for {
|
|
if err := worker.runNewSession(ctx, reconnectBackoff.reset); err != nil {
|
|
if errors.Is(err, errRPCWatchDisconnected) {
|
|
select {
|
|
case <-time.After(reconnectBackoff.next()):
|
|
continue
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
select {
|
|
case <-worker.pollTicker.C:
|
|
// continue
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
type rpcWatchReconnectBackoff struct {
|
|
nextInterval time.Duration
|
|
}
|
|
|
|
func (backoff *rpcWatchReconnectBackoff) next() time.Duration {
|
|
interval := backoff.nextInterval
|
|
if interval <= 0 {
|
|
interval = rpcWatchReconnectInterval
|
|
}
|
|
|
|
if interval >= rpcWatchReconnectMaxInterval/rpcWatchReconnectMultiplier {
|
|
backoff.nextInterval = rpcWatchReconnectMaxInterval
|
|
} else {
|
|
backoff.nextInterval = interval * rpcWatchReconnectMultiplier
|
|
}
|
|
|
|
return min(interval, rpcWatchReconnectMaxInterval)
|
|
}
|
|
|
|
func (backoff *rpcWatchReconnectBackoff) reset() {
|
|
backoff.nextInterval = rpcWatchReconnectInterval
|
|
}
|
|
|
|
func (worker *Worker) Close() error {
|
|
var result error
|
|
for _, vm := range worker.vmm.List() {
|
|
<-vm.Stop()
|
|
}
|
|
for _, vm := range worker.vmm.List() {
|
|
err := vm.Delete()
|
|
if err != nil {
|
|
result = multierror.Append(result, err)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (worker *Worker) runNewSession(ctx context.Context, onWatchHealthy func()) error {
|
|
subCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
// Check the runtime before advertising this worker, but do not touch local
|
|
// VMs until registration confirms that this worker belongs to this machine.
|
|
vmInfos, err := worker.listOnDiskVMs(subCtx)
|
|
if err != nil {
|
|
worker.logger.Errorf("failed to list on-disk VMs: %v", err)
|
|
|
|
return nil
|
|
}
|
|
|
|
if err := worker.registerWorker(subCtx); err != nil {
|
|
worker.logger.Warnf("failed to register worker: %v", err)
|
|
|
|
return nil
|
|
}
|
|
|
|
info, err := worker.client.Controller().Info(ctx)
|
|
if err != nil {
|
|
worker.logger.Warnf("failed to retrieve controller info: %v", err)
|
|
|
|
return nil
|
|
}
|
|
|
|
group, sessionCtx := errgroup.WithContext(subCtx)
|
|
worker.superviseRPCWatch(sessionCtx, ctx, group, info, onWatchHealthy)
|
|
|
|
// Sync on-disk VMs
|
|
if err := worker.syncOnDiskVMsWithInventory(sessionCtx, vmInfos); err != nil {
|
|
cancel()
|
|
watchErr := group.Wait()
|
|
|
|
worker.logger.Errorf("failed to sync on-disk VMs: %v", err)
|
|
|
|
if errors.Is(watchErr, errRPCWatchDisconnected) {
|
|
return watchErr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
recoveredVMs := worker.trackRecoveredVMs(time.Now())
|
|
|
|
// Backward compatibility with for older Orchard Controllers
|
|
updateFuncInner := worker.client.VMs().UpdateState
|
|
|
|
if !info.Capabilities.Has(v1.ControllerCapabilityVMStateEndpoint) {
|
|
updateFuncInner = worker.client.VMs().Update
|
|
}
|
|
|
|
// Ignore HTTP 404, because the VM might no longer exist while we're still processing it
|
|
updateFunc := func(ctx context.Context, vm v1.VM) error {
|
|
_, err = updateFuncInner(ctx, vm)
|
|
var apiError *client.APIError
|
|
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
group.Go(func() error {
|
|
for {
|
|
if err := worker.updateWorker(sessionCtx); err != nil {
|
|
return fmt.Errorf("failed to update worker resource: %w", err)
|
|
}
|
|
|
|
select {
|
|
case <-sessionCtx.Done():
|
|
return sessionCtx.Err()
|
|
case <-time.After(workerResourceUpdateInterval):
|
|
// Proceed
|
|
}
|
|
}
|
|
})
|
|
|
|
group.Go(func() error {
|
|
for {
|
|
if err := worker.syncVMs(sessionCtx, updateFunc, recoveredVMs); err != nil {
|
|
return fmt.Errorf("failed to sync VMs: %w", err)
|
|
}
|
|
|
|
select {
|
|
case <-sessionCtx.Done():
|
|
return sessionCtx.Err()
|
|
case <-worker.syncRequested:
|
|
case <-worker.pollTicker.C:
|
|
// Proceed
|
|
}
|
|
}
|
|
})
|
|
|
|
if err := group.Wait(); err != nil {
|
|
worker.logger.Errorf("%v", err)
|
|
|
|
if errors.Is(err, errRPCWatchDisconnected) {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (worker *Worker) superviseRPCWatch(
|
|
sessionCtx context.Context,
|
|
operationCtx context.Context,
|
|
group *errgroup.Group,
|
|
info v1.ControllerInfo,
|
|
onWatchHealthy func(),
|
|
) {
|
|
watchRPC := worker.watchRPC
|
|
rpcVersion := "v1"
|
|
|
|
if info.Capabilities.Has(v1.ControllerCapabilityRPCV2) {
|
|
worker.logger.Infof("using WebSocket-based v2 RPC")
|
|
watchRPC = worker.watchRPCV2
|
|
rpcVersion = "v2"
|
|
} else {
|
|
worker.logger.Infof("using gRPC-based v1 RPC")
|
|
}
|
|
|
|
watchEstablished := make(chan struct{})
|
|
|
|
group.Go(func() error {
|
|
if err := watchRPC(sessionCtx, operationCtx, func() { close(watchEstablished) }); err != nil {
|
|
if sessionCtx.Err() != nil {
|
|
return sessionCtx.Err()
|
|
}
|
|
|
|
return fmt.Errorf("%w: failed to watch RPC %s: %w", errRPCWatchDisconnected, rpcVersion, err)
|
|
}
|
|
|
|
return fmt.Errorf("%w: RPC %s watch closed unexpectedly", errRPCWatchDisconnected, rpcVersion)
|
|
})
|
|
|
|
group.Go(func() error {
|
|
return monitorRPCWatchHealth(sessionCtx, watchEstablished, rpcWatchHealthyInterval, onWatchHealthy)
|
|
})
|
|
}
|
|
|
|
func monitorRPCWatchHealth(
|
|
ctx context.Context,
|
|
established <-chan struct{},
|
|
healthyAfter time.Duration,
|
|
onHealthy func(),
|
|
) error {
|
|
select {
|
|
case <-established:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
|
|
healthTimer := time.NewTimer(healthyAfter)
|
|
defer healthTimer.Stop()
|
|
|
|
select {
|
|
case <-healthTimer.C:
|
|
onHealthy()
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (worker *Worker) trackRecoveredVMs(now time.Time) map[ondiskname.OnDiskName]time.Time {
|
|
if worker.recoveredVMs == nil {
|
|
worker.recoveredVMs = make(map[ondiskname.OnDiskName]time.Time)
|
|
}
|
|
|
|
for onDiskName := range worker.recoveredVMs {
|
|
if !worker.vmm.Exists(onDiskName) {
|
|
delete(worker.recoveredVMs, onDiskName)
|
|
}
|
|
}
|
|
|
|
for _, vm := range worker.vmm.List() {
|
|
status := vm.Status()
|
|
if status != v1.VMStatusPending && status != v1.VMStatusRunning {
|
|
continue
|
|
}
|
|
|
|
onDiskName := vm.OnDiskName()
|
|
if _, alreadyTracked := worker.recoveredVMs[onDiskName]; !alreadyTracked {
|
|
worker.recoveredVMs[onDiskName] = now.Add(recoveredVMProtectionPeriod)
|
|
}
|
|
}
|
|
|
|
return worker.recoveredVMs
|
|
}
|
|
|
|
func (worker *Worker) registerWorker(ctx context.Context) error {
|
|
platformUUID, err := platform.MachineID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = worker.client.Workers().Create(ctx, v1.Worker{
|
|
Meta: v1.Meta{
|
|
Name: worker.name,
|
|
},
|
|
Arch: v1.Architecture(goruntime.GOARCH),
|
|
Runtime: worker.runtime.ID(),
|
|
Resources: worker.resources,
|
|
Labels: worker.labels,
|
|
LastSeen: time.Now(),
|
|
MachineID: platformUUID,
|
|
DefaultCPU: worker.defaultCPU,
|
|
DefaultMemory: worker.defaultMemory,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
worker.logger.Infof("registered worker %s", worker.name)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (worker *Worker) updateWorker(ctx context.Context) error {
|
|
workerResource, err := worker.client.Workers().Get(ctx, worker.name)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to retrieve worker from the API: %v", ErrPollFailed, err)
|
|
}
|
|
|
|
worker.logger.Debugf("got worker from the API")
|
|
|
|
workerResource.LastSeen = time.Now()
|
|
|
|
if _, err := worker.client.Workers().Update(ctx, *workerResource); err != nil {
|
|
return fmt.Errorf("%w: failed to update worker in the API: %v", ErrPollFailed, err)
|
|
}
|
|
|
|
worker.logger.Debugf("updated worker in the API")
|
|
|
|
return nil
|
|
}
|
|
|
|
//nolint:gocognit // VM lifecycle branches are clearest in a single reconciliation loop.
|
|
func (worker *Worker) syncVMs(
|
|
ctx context.Context,
|
|
updateVM func(context.Context, v1.VM) error,
|
|
recoveredVMs map[ondiskname.OnDiskName]time.Time,
|
|
) error {
|
|
allKeys := mapset.NewSet[ondiskname.OnDiskName]()
|
|
|
|
remoteVMs, err := worker.client.VMs().FindForWorker(ctx, worker.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
remoteVMsIndex := map[ondiskname.OnDiskName]*v1.VM{}
|
|
for _, remoteVM := range remoteVMs {
|
|
onDiskName := ondiskname.NewFromResource(remoteVM)
|
|
allKeys.Add(onDiskName)
|
|
// Can't take an address of a loop variable
|
|
remoteVMCopy := remoteVM
|
|
remoteVMsIndex[onDiskName] = &remoteVMCopy
|
|
}
|
|
|
|
localVMsIndex := map[ondiskname.OnDiskName]vmmanager.VM{}
|
|
for _, vm := range worker.vmm.List() {
|
|
onDiskName := vm.OnDiskName()
|
|
allKeys.Add(onDiskName)
|
|
localVMsIndex[onDiskName] = vm
|
|
}
|
|
|
|
worker.logger.Infof("syncing %d local VMs against %d remote VMs...",
|
|
len(localVMsIndex), len(remoteVMsIndex))
|
|
|
|
var pairs []lo.Tuple3[ondiskname.OnDiskName, *v1.VM, vmmanager.VM]
|
|
|
|
for onDiskName := range allKeys.Iter() {
|
|
vmResource := remoteVMsIndex[onDiskName]
|
|
vm := localVMsIndex[onDiskName]
|
|
|
|
pairs = append(pairs, lo.T3(onDiskName, vmResource, vm))
|
|
}
|
|
|
|
// It's important to process the remote VMs in failed state
|
|
// and local VMs that ceased to exist remotely first, otherwise
|
|
// we risk violating the scheduler resource assumptions
|
|
sortNonExistentAndFailedFirst(pairs)
|
|
|
|
hasUnaccountedRecoveredVM := false
|
|
|
|
for _, tuple := range pairs {
|
|
onDiskName, vmResource, vm := lo.Unpack3(tuple)
|
|
|
|
remoteState := mo.None[v1.VMStatus]()
|
|
if vmResource != nil {
|
|
remoteState = mo.Some(vmResource.Status)
|
|
}
|
|
|
|
localState := mo.None[v1.VMStatus]()
|
|
var localConditions []v1.Condition
|
|
if vm != nil {
|
|
localState = mo.Some(vm.Status())
|
|
localConditions = vm.Conditions()
|
|
}
|
|
|
|
if shouldPreserveRecoveredVM(recoveredVMs, onDiskName, vmResource, localState, time.Now()) {
|
|
hasUnaccountedRecoveredVM = true
|
|
worker.logger.Warnf("preserving active VM %s missing from controller during worker session recovery",
|
|
onDiskName)
|
|
|
|
continue
|
|
}
|
|
|
|
action := transitions[remoteState][localState]
|
|
|
|
worker.logger.Debugf("processing VM: %s, remote state: %s, local state: %s, "+
|
|
"local conditions: [%s], action: %v", onDiskName, optionToString(remoteState),
|
|
optionToString(localState), v1.ConditionsHumanize(localConditions), action)
|
|
|
|
switch action {
|
|
case ActionCreate:
|
|
// Remote VM was created, but not the local VM
|
|
if hasUnaccountedRecoveredVM {
|
|
worker.logger.Warnf("deferring VM %s while recovered VMs are missing from controller inventory",
|
|
onDiskName)
|
|
|
|
continue
|
|
}
|
|
|
|
worker.createVM(onDiskName, *vmResource)
|
|
case ActionMonitorPending:
|
|
if vmResource.StatusMessage != vm.StatusMessage() {
|
|
vmResource.StatusMessage = vm.StatusMessage()
|
|
|
|
if err := updateVM(ctx, *vmResource); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case ActionReportRunning:
|
|
// Remote VM was created, and the local VM too,
|
|
// check if the local VM had already started
|
|
// and update the remote VM as accordingly
|
|
|
|
// Image FQN feature, see https://github.com/cirruslabs/orchard/issues/164
|
|
if imageFQN := vm.ImageFQN(); imageFQN != nil {
|
|
vmResource.ImageFQN = *imageFQN
|
|
}
|
|
|
|
// Mark the remote VM as started
|
|
vmResource.Status = v1.VMStatusRunning
|
|
vmResource.StatusMessage = vm.StatusMessage()
|
|
|
|
if err := updateVM(ctx, *vmResource); err != nil {
|
|
return err
|
|
}
|
|
case ActionMonitorRunning:
|
|
if err := worker.monitorRunningVM(ctx, vmResource, vm, updateVM); err != nil {
|
|
return err
|
|
}
|
|
case ActionStop:
|
|
// VM has failed on the remote side, stop it locally to prevent incorrect
|
|
// worker's resources calculation in the Controller's scheduler
|
|
if err := waitForVMStop(ctx, vm); err != nil {
|
|
return fmt.Errorf("failed to stop VM: %w", err)
|
|
}
|
|
case ActionFail, ActionLostTrack, ActionImpossible:
|
|
// VM has failed on the local side, stop it before reporting as failed to prevent incorrect
|
|
// worker's resources calculation in the Controller's scheduler
|
|
if vm != nil {
|
|
if err := waitForVMStop(ctx, vm); err != nil {
|
|
return fmt.Errorf("failed to stop VM: %w", err)
|
|
}
|
|
}
|
|
|
|
var statusMessage string
|
|
|
|
switch action {
|
|
case ActionFail:
|
|
statusMessage = vm.Err().Error()
|
|
case ActionLostTrack:
|
|
statusMessage = "Worker lost track of VM"
|
|
case ActionImpossible:
|
|
statusMessage = "Encountered an impossible transition"
|
|
}
|
|
|
|
vmResource.Status = v1.VMStatusFailed
|
|
vmResource.StatusMessage = statusMessage
|
|
if err := updateVM(ctx, *vmResource); err != nil {
|
|
return err
|
|
}
|
|
case ActionDelete:
|
|
// Remote VM was deleted, delete local VM
|
|
//
|
|
// Note: this check needs to run for each VM
|
|
// before we attempt to create any VMs below.
|
|
if err := worker.deleteVM(vm); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (worker *Worker) monitorRunningVM(
|
|
ctx context.Context,
|
|
vmResource *v1.VM,
|
|
vm vmmanager.VM,
|
|
updateVM func(context.Context, v1.VM) error,
|
|
) error {
|
|
worker.reconcileRunningVM(vmResource, vm) //nolint:contextcheck // Event streams outlive sync sessions.
|
|
|
|
var updateNeeded bool
|
|
|
|
if vmResource.StatusMessage != vm.StatusMessage() {
|
|
vmResource.StatusMessage = vm.StatusMessage()
|
|
|
|
updateNeeded = true
|
|
}
|
|
|
|
if vmResource.ObservedGeneration != vm.Resource().ObservedGeneration {
|
|
vmResource.ObservedGeneration = vm.Resource().ObservedGeneration
|
|
|
|
updateNeeded = true
|
|
}
|
|
|
|
// Propagate VM's conditions to the Orchard Controller
|
|
for _, condition := range vm.Conditions() {
|
|
if v1.ConditionsSet(&vmResource.Conditions, condition) {
|
|
updateNeeded = true
|
|
}
|
|
}
|
|
|
|
if updateNeeded {
|
|
return updateVM(ctx, *vmResource)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (worker *Worker) reconcileRunningVM(vmResource *v1.VM, vm vmmanager.VM) {
|
|
if vmResource.Generation == vm.Resource().Generation {
|
|
return
|
|
}
|
|
|
|
stoppingOrSuspending := v1.ConditionIsTrue(vm.Conditions(), v1.ConditionTypeStopping) ||
|
|
v1.ConditionIsTrue(vm.Conditions(), v1.ConditionTypeSuspending)
|
|
if stoppingOrSuspending {
|
|
return
|
|
}
|
|
|
|
if v1.ConditionIsTrue(vm.Conditions(), v1.ConditionTypeRunning) {
|
|
// VM is running, suspend or stop it first.
|
|
shouldStop := vmResource.PowerState == v1.PowerStateStopped || !vm.Resource().Suspendable
|
|
|
|
if shouldStop {
|
|
vm.Stop()
|
|
return
|
|
} else {
|
|
vm.Suspend()
|
|
}
|
|
}
|
|
|
|
if v1.ConditionIsFalse(vm.Conditions(), v1.ConditionTypeRunning) {
|
|
// VM stopped, update its specification.
|
|
vm.SetResource(*vmResource)
|
|
|
|
if vmResource.PowerState == v1.PowerStateRunning {
|
|
// Start the VM.
|
|
eventStreamer := worker.client.VMs().StreamEvents(vmResource.Name)
|
|
vm.Start(eventStreamer)
|
|
}
|
|
}
|
|
}
|
|
|
|
func shouldPreserveRecoveredVM(
|
|
recoveredVMs map[ondiskname.OnDiskName]time.Time,
|
|
onDiskName ondiskname.OnDiskName,
|
|
remoteVM *v1.VM,
|
|
localState mo.Option[v1.VMStatus],
|
|
now time.Time,
|
|
) bool {
|
|
deadline, recovered := recoveredVMs[onDiskName]
|
|
if !recovered {
|
|
return false
|
|
}
|
|
|
|
active := localState == mo.Some(v1.VMStatusPending) || localState == mo.Some(v1.VMStatusRunning)
|
|
if remoteVM == nil && active && now.Before(deadline) {
|
|
return true
|
|
}
|
|
|
|
// Once the controller recognizes a recovered VM, or the bounded recovery
|
|
// window expires, user-requested deletion follows the normal lifecycle.
|
|
delete(recoveredVMs, onDiskName)
|
|
|
|
return false
|
|
}
|
|
|
|
func (worker *Worker) listOnDiskVMs(ctx context.Context) ([]vmmanager.VMInfo, error) {
|
|
if worker.runtime.Synthetic() {
|
|
// There's no on-disk VMs when using synthetic VMs
|
|
return nil, nil
|
|
}
|
|
|
|
worker.logger.Infof("listing on-disk VMs...")
|
|
|
|
runtimeCtx, cancelRuntime := context.WithTimeout(ctx, onDiskVMSyncTimeout)
|
|
defer cancelRuntime()
|
|
|
|
vmInfos, err := worker.runtime.ListVMs(runtimeCtx, worker.logger)
|
|
if err != nil {
|
|
if errors.Is(runtimeCtx.Err(), context.DeadlineExceeded) {
|
|
return nil, fmt.Errorf("timed out listing on-disk VMs: %w", context.DeadlineExceeded)
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return vmInfos, nil
|
|
}
|
|
|
|
func (worker *Worker) syncOnDiskVMs(ctx context.Context) error {
|
|
vmInfos, err := worker.listOnDiskVMs(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return worker.syncOnDiskVMsWithInventory(ctx, vmInfos)
|
|
}
|
|
|
|
//nolint:nestif,gocognit // complexity is tolerable for now
|
|
func (worker *Worker) syncOnDiskVMsWithInventory(ctx context.Context, vmInfos []vmmanager.VMInfo) error {
|
|
if worker.runtime.Synthetic() {
|
|
return nil
|
|
}
|
|
|
|
remoteVMs, err := worker.client.VMs().FindForWorker(ctx, worker.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
remoteVMsIndex := map[ondiskname.OnDiskName]v1.VM{}
|
|
for _, remoteVM := range remoteVMs {
|
|
remoteVMsIndex[ondiskname.NewFromResource(remoteVM)] = remoteVM
|
|
}
|
|
|
|
worker.logger.Infof("syncing on-disk VMs...")
|
|
|
|
for _, vmInfo := range vmInfos {
|
|
onDiskName, err := ondiskname.Parse(vmInfo.Name)
|
|
if err != nil {
|
|
if errors.Is(err, ondiskname.ErrNotManagedByOrchard) {
|
|
continue
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// VMs that exist in the Worker's VM manager will be handled in the syncVMs()
|
|
if worker.vmm.Exists(onDiskName) {
|
|
continue
|
|
}
|
|
|
|
remoteVM, ok := remoteVMsIndex[onDiskName]
|
|
if !ok {
|
|
// On-disk VM doesn't exist on the controller nor in the Worker's VM manager,
|
|
// stop it (if applicable) and delete it
|
|
if vmInfo.Running {
|
|
_, _, err := worker.runtime.Cmd(ctx, worker.logger, "stop", vmInfo.Name)
|
|
if err != nil {
|
|
worker.logger.Warnf("failed to stop")
|
|
}
|
|
}
|
|
|
|
_, _, err := worker.runtime.Cmd(ctx, worker.logger, "delete", vmInfo.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if remoteVM.Status != v1.VMStatusPending {
|
|
// On-disk VM exists on the controller and was acted upon,
|
|
// but we've lost track of it, so shut it down (if applicable)
|
|
// and report the error (if not failed yet)
|
|
if vmInfo.Running {
|
|
_, _, err := worker.runtime.Cmd(ctx, worker.logger, "stop", vmInfo.Name)
|
|
if err != nil {
|
|
worker.logger.Warnf("failed to stop")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func waitForVMStop(ctx context.Context, vm vmmanager.VM) error {
|
|
select {
|
|
case err := <-vm.Stop():
|
|
return err
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (worker *Worker) deleteVM(vm vmmanager.VM) error {
|
|
<-vm.Stop()
|
|
|
|
if err := vm.Delete(); err != nil {
|
|
return err
|
|
}
|
|
|
|
worker.vmm.Delete(vm.OnDiskName())
|
|
|
|
return nil
|
|
}
|
|
|
|
func (worker *Worker) createVM(odn ondiskname.OnDiskName, vmResource v1.VM) {
|
|
eventStreamer := worker.client.VMs().StreamEvents(vmResource.Name)
|
|
|
|
vm := worker.runtime.NewVM(vmResource, eventStreamer, worker.vmPullTimeHistogram, worker.dialer, worker.logger)
|
|
|
|
worker.vmm.Put(odn, vm)
|
|
}
|
|
|
|
func (worker *Worker) grpcMetadata() metadata.MD {
|
|
return metadata.Join(
|
|
worker.client.GPRCMetadata(),
|
|
metadata.Pairs(rpc.MetadataWorkerNameKey, worker.name),
|
|
)
|
|
}
|
|
|
|
func (worker *Worker) requestVMSyncing() {
|
|
select {
|
|
case worker.syncRequested <- true:
|
|
worker.logger.Debugf("Successfully requested syncing")
|
|
default:
|
|
worker.logger.Debugf("There's already a syncing request in the queue, skipping")
|
|
}
|
|
}
|
|
|
|
func sortNonExistentAndFailedFirst(input []lo.Tuple3[ondiskname.OnDiskName, *v1.VM, vmmanager.VM]) {
|
|
slices.SortStableFunc(input, func(left, right lo.Tuple3[ondiskname.OnDiskName, *v1.VM, vmmanager.VM]) int {
|
|
_, leftVM, _ := lo.Unpack3(left)
|
|
_, rightVM, _ := lo.Unpack3(right)
|
|
|
|
leftNonExistent := leftVM == nil
|
|
rightNonExistent := rightVM == nil
|
|
|
|
switch {
|
|
case leftNonExistent && rightNonExistent:
|
|
return 0
|
|
case leftNonExistent:
|
|
return -1
|
|
case rightNonExistent:
|
|
return 1
|
|
}
|
|
|
|
leftFailed := leftVM != nil && leftVM.Status == v1.VMStatusFailed
|
|
rightFailed := rightVM != nil && rightVM.Status == v1.VMStatusFailed
|
|
|
|
switch {
|
|
case leftFailed && rightFailed:
|
|
return 0
|
|
case leftFailed:
|
|
return -1
|
|
case rightFailed:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
}
|