diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 0a5a9c0f..bb0a5101 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -293,6 +293,13 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst Message: err.Error(), }, nil } + restartPolicy, err := toDomainRestartPolicy(request.Body.RestartPolicy) + if err != nil { + return oapi.CreateInstance400JSONResponse{ + Code: "invalid_restart_policy", + Message: err.Error(), + }, nil + } domainReq := instances.CreateInstanceRequest{ Name: request.Body.Name, @@ -319,6 +326,7 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst SkipGuestAgent: request.Body.SkipGuestAgent != nil && *request.Body.SkipGuestAgent, AutoStandby: autoStandby, HealthCheck: healthCheck, + RestartPolicy: restartPolicy, } if request.Body.SnapshotPolicy != nil { snapshotPolicy, err := toInstanceSnapshotPolicy(*request.Body.SnapshotPolicy) @@ -1044,11 +1052,20 @@ func (s *ApiService) UpdateInstance(ctx context.Context, request oapi.UpdateInst Message: err.Error(), }, nil } + restartPolicy, err := toDomainRestartPolicy(request.Body.RestartPolicy) + if err != nil { + return oapi.UpdateInstance400JSONResponse{ + Code: "invalid_restart_policy", + Message: err.Error(), + }, nil + } result, err := s.InstanceManager.UpdateInstance(ctx, inst.Id, instances.UpdateInstanceRequest{ - Env: env, - AutoStandby: autoStandby, - HealthCheck: healthCheck, + Env: env, + AutoStandby: autoStandby, + HealthCheck: healthCheck, + RestartPolicy: restartPolicy, + RestartPolicySet: request.Body.RestartPolicy != nil, }) if err != nil { switch { @@ -1182,6 +1199,8 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance { oapiInst.AutoStandby = toOAPIAutoStandbyPolicy(inst.AutoStandby) oapiInst.HealthCheck = toOAPIHealthCheck(inst.HealthCheck) oapiInst.HealthStatus = toOAPIHealthStatus(healthcheck.Snapshot(inst.HealthCheck, string(inst.State), inst.HealthCheckRuntime)) + oapiInst.RestartPolicy = toOAPIRestartPolicy(inst.RestartPolicy) + oapiInst.RestartStatus = toOAPIRestartStatus(inst.RestartStatus) // Convert volume attachments if len(inst.Volumes) > 0 { diff --git a/cmd/api/api/instances_test.go b/cmd/api/api/instances_test.go index b28fbc58..4fadadee 100644 --- a/cmd/api/api/instances_test.go +++ b/cmd/api/api/instances_test.go @@ -16,6 +16,7 @@ import ( mw "github.com/kernel/hypeman/lib/middleware" "github.com/kernel/hypeman/lib/oapi" "github.com/kernel/hypeman/lib/paths" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" "github.com/kernel/hypeman/lib/system" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -281,6 +282,7 @@ func (m *captureUpdateManager) UpdateInstance(ctx context.Context, id string, re Env: req.Env, AutoStandby: req.AutoStandby, HealthCheck: req.HealthCheck, + RestartPolicy: req.RestartPolicy, CreatedAt: now, HypervisorType: hypervisor.TypeCloudHypervisor, }, @@ -304,6 +306,7 @@ func (m *captureCreateManager) CreateInstance(ctx context.Context, req instances Vcpus: req.Vcpus, AutoStandby: req.AutoStandby, HealthCheck: req.HealthCheck, + RestartPolicy: req.RestartPolicy, CreatedAt: now, HypervisorType: hypervisor.TypeCloudHypervisor, }, @@ -705,6 +708,48 @@ func TestCreateInstance_MapsHealthCheckPolicy(t *testing.T) { assert.Equal(t, oapi.InstanceHealthStatusStatusStarting, instance.HealthStatus.Status) } +func TestCreateInstance_MapsRestartPolicy(t *testing.T) { + t.Parallel() + + svc := newTestService(t) + origMgr := svc.InstanceManager + mockMgr := &captureCreateManager{Manager: origMgr} + svc.InstanceManager = mockMgr + + policy := oapi.OnFailure + backoff := "7s" + stableAfter := "2m" + maxAttempts := 4 + + resp, err := svc.CreateInstance(ctx(), oapi.CreateInstanceRequestObject{ + Body: &oapi.CreateInstanceRequest{ + Name: "test-restart-policy", + Image: "docker.io/library/alpine:latest", + RestartPolicy: &oapi.RestartPolicy{ + Policy: &policy, + Backoff: &backoff, + StableAfter: &stableAfter, + MaxAttempts: &maxAttempts, + }, + }, + }) + require.NoError(t, err) + + created, ok := resp.(oapi.CreateInstance201JSONResponse) + require.True(t, ok, "expected 201 response") + require.NotNil(t, mockMgr.lastReq) + require.NotNil(t, mockMgr.lastReq.RestartPolicy) + assert.Equal(t, restartpolicy.PolicyOnFailure, mockMgr.lastReq.RestartPolicy.Policy) + assert.Equal(t, "7s", mockMgr.lastReq.RestartPolicy.Backoff) + assert.Equal(t, "2m", mockMgr.lastReq.RestartPolicy.StableAfter) + assert.Equal(t, 4, mockMgr.lastReq.RestartPolicy.MaxAttempts) + + instance := oapi.Instance(created) + require.NotNil(t, instance.RestartPolicy) + require.NotNil(t, instance.RestartPolicy.Policy) + assert.Equal(t, oapi.OnFailure, *instance.RestartPolicy.Policy) +} + func TestUpdateInstance_MapsEnvPatch(t *testing.T) { t.Parallel() svc := newTestService(t) @@ -883,6 +928,108 @@ func TestUpdateInstance_MapsHealthCheckPatch(t *testing.T) { assert.Equal(t, oapi.InstanceHealthStatusStatusUnknown, instance.HealthStatus.Status) } +func TestUpdateInstance_MapsRestartPolicyPatch(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + origMgr := svc.InstanceManager + now := time.Now() + mockMgr := &captureUpdateManager{ + Manager: origMgr, + result: &instances.Instance{ + StoredMetadata: instances.StoredMetadata{ + Id: "inst-update-restart-policy", + Name: "inst-update-restart-policy", + Image: "docker.io/library/alpine:latest", + CreatedAt: now, + HypervisorType: hypervisor.TypeCloudHypervisor, + RestartPolicy: &restartpolicy.Policy{ + Policy: restartpolicy.PolicyAlways, + Backoff: "5s", + StableAfter: "10m0s", + }, + RestartStatus: restartpolicy.Status{ + BlockedReason: restartpolicy.BlockedReasonManualStop, + }, + }, + State: instances.StateStopped, + }, + } + svc.InstanceManager = mockMgr + + policy := oapi.Always + resolved := &instances.Instance{ + StoredMetadata: instances.StoredMetadata{ + Id: "inst-update-restart-policy", + Name: "inst-update-restart-policy", + Image: "docker.io/library/alpine:latest", + CreatedAt: now, + HypervisorType: hypervisor.TypeCloudHypervisor, + }, + State: instances.StateStopped, + } + + resp, err := svc.UpdateInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.UpdateInstanceRequestObject{ + Id: resolved.Id, + Body: &oapi.UpdateInstanceRequest{ + RestartPolicy: &oapi.RestartPolicy{Policy: &policy}, + }, + }) + require.NoError(t, err) + updated, ok := resp.(oapi.UpdateInstance200JSONResponse) + require.True(t, ok, "expected 200 response") + + require.NotNil(t, mockMgr.lastReq) + assert.True(t, mockMgr.lastReq.RestartPolicySet) + require.NotNil(t, mockMgr.lastReq.RestartPolicy) + assert.Equal(t, restartpolicy.PolicyAlways, mockMgr.lastReq.RestartPolicy.Policy) + + instance := oapi.Instance(updated) + require.NotNil(t, instance.RestartPolicy) + require.NotNil(t, instance.RestartStatus) + require.NotNil(t, instance.RestartStatus.BlockedReason) + assert.Equal(t, oapi.ManualStop, *instance.RestartStatus.BlockedReason) +} + +func TestUpdateInstance_RejectsInvalidRestartPolicy(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + origMgr := svc.InstanceManager + mockMgr := &captureUpdateManager{Manager: origMgr} + svc.InstanceManager = mockMgr + + now := time.Now() + resolved := &instances.Instance{ + StoredMetadata: instances.StoredMetadata{ + Id: "inst-update-restart-policy", + Name: "inst-update-restart-policy", + Image: "docker.io/library/alpine:latest", + CreatedAt: now, + HypervisorType: hypervisor.TypeCloudHypervisor, + }, + State: instances.StateStopped, + } + policy := oapi.OnFailure + backoff := "0s" + + resp, err := svc.UpdateInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.UpdateInstanceRequestObject{ + Id: resolved.Id, + Body: &oapi.UpdateInstanceRequest{ + RestartPolicy: &oapi.RestartPolicy{ + Policy: &policy, + Backoff: &backoff, + }, + }, + }) + require.NoError(t, err) + + badReq, ok := resp.(oapi.UpdateInstance400JSONResponse) + require.True(t, ok, "expected 400 response") + assert.Equal(t, "invalid_restart_policy", badReq.Code) + assert.Nil(t, mockMgr.lastReq) +} + func TestUpdateInstance_RejectsZeroAutoStandbyIgnoreDestinationPort(t *testing.T) { t.Parallel() svc := newTestService(t) diff --git a/cmd/api/api/restart_policy.go b/cmd/api/api/restart_policy.go new file mode 100644 index 00000000..f50ce244 --- /dev/null +++ b/cmd/api/api/restart_policy.go @@ -0,0 +1,79 @@ +package api + +import ( + "github.com/kernel/hypeman/lib/oapi" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" + "github.com/samber/lo" +) + +func toDomainRestartPolicy(policy *oapi.RestartPolicy) (*restartpolicy.Policy, error) { + if policy == nil { + return nil, nil + } + + out := &restartpolicy.Policy{} + if policy.Policy != nil { + out.Policy = restartpolicy.PolicyMode(*policy.Policy) + } + if policy.Backoff != nil { + out.Backoff = *policy.Backoff + } + if policy.MaxAttempts != nil { + out.MaxAttempts = *policy.MaxAttempts + } + if policy.StableAfter != nil { + out.StableAfter = *policy.StableAfter + } + if _, err := restartpolicy.NormalizePolicy(out); err != nil { + return nil, err + } + return out, nil +} + +func toOAPIRestartPolicy(policy *restartpolicy.Policy) *oapi.RestartPolicy { + if policy == nil { + return nil + } + + mode := oapi.RestartPolicyPolicy(policy.Policy) + out := &oapi.RestartPolicy{ + Policy: &mode, + } + if policy.Backoff != "" { + out.Backoff = lo.ToPtr(policy.Backoff) + } + if policy.MaxAttempts > 0 { + out.MaxAttempts = lo.ToPtr(policy.MaxAttempts) + } + if policy.StableAfter != "" { + out.StableAfter = lo.ToPtr(policy.StableAfter) + } + return out +} + +func toOAPIRestartStatus(status restartpolicy.Status) *oapi.RestartStatus { + if status.IsZero() { + return nil + } + + out := &oapi.RestartStatus{ + Attempts: lo.ToPtr(status.Attempts), + } + if status.BlockedReason != "" { + reason := oapi.RestartStatusBlockedReason(status.BlockedReason) + out.BlockedReason = &reason + } + if status.LastAttemptAt != nil { + lastAttemptAt := status.LastAttemptAt.UTC() + out.LastAttemptAt = &lastAttemptAt + } + if status.NextAttemptAt != nil { + nextAttemptAt := status.NextAttemptAt.UTC() + out.NextAttemptAt = &nextAttemptAt + } + if status.LastReason != "" { + reason := oapi.RestartStatusLastReason(status.LastReason) + out.LastReason = &reason + } + return out +} diff --git a/cmd/api/main.go b/cmd/api/main.go index f7e24c4e..da2a2326 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -571,6 +571,14 @@ func run() error { return app.HealthCheckController.Run(gctx) }) } + if restartController, ok := app.InstanceManager.(interface { + StartRestartPolicyController(context.Context) error + }); ok { + grp.Go(func() error { + logger.Info("starting restart policy controller") + return restartController.StartRestartPolicyController(gctx) + }) + } // Run the server grp.Go(func() error { diff --git a/lib/healthcheck/README.md b/lib/healthcheck/README.md index dba2ba0d..f11a9c9b 100644 --- a/lib/healthcheck/README.md +++ b/lib/healthcheck/README.md @@ -75,6 +75,10 @@ Stopping, deleting, standing by, or restoring an instance stops active checks. S ## Restart Policy -Health checks only report health. They do not restart instances. +Health checks do not restart instances by themselves. -If Hypeman later adds restart-on-unhealthy behavior, it should consume `health_status=unhealthy` explicitly rather than making health checks mutate lifecycle state. +When an instance also has `restart_policy.policy=on_failure` or `restart_policy.policy=always`, an `unhealthy` health status becomes a restart-policy failure signal. The restart policy applies its normal backoff, max attempts, manual-stop suppression, and stable-window reset before Hypeman restarts the whole instance. + +With `restart_policy.policy=never` or no restart policy, health checks only report status. + +Health checks still do not mutate lifecycle state directly. The instance remains `Running` while unhealthy until restart policy chooses to stop and start it. diff --git a/lib/healthcheck/controller.go b/lib/healthcheck/controller.go index 2a01e67f..8e0e040c 100644 --- a/lib/healthcheck/controller.go +++ b/lib/healthcheck/controller.go @@ -188,6 +188,13 @@ func (c *Controller) runCheck(ctx context.Context, id string) { if err := c.store.SetRuntime(ctx, id, runtime); err != nil { c.log.Warn("failed to persist health check status", "instance_id", id, "error", err) } + if runtime.Status == StatusUnhealthy { + if handler, ok := c.store.(UnhealthyHandler); ok { + if err := handler.HandleUnhealthy(ctx, inst, runtime); err != nil { + c.log.Warn("failed to handle unhealthy instance", "instance_id", id, "error", err) + } + } + } interval, _, _, err := DurationConfig(policy) if err != nil { diff --git a/lib/healthcheck/controller_test.go b/lib/healthcheck/controller_test.go index 74d98cd1..704ae3af 100644 --- a/lib/healthcheck/controller_test.go +++ b/lib/healthcheck/controller_test.go @@ -13,6 +13,7 @@ type controllerTestStore struct { instances []Instance events chan InstanceEvent runtimes chan *Runtime + unhealthy chan Instance } func (s *controllerTestStore) ListInstances(context.Context) ([]Instance, error) { @@ -24,6 +25,13 @@ func (s *controllerTestStore) SetRuntime(_ context.Context, _ string, runtime *R return nil } +func (s *controllerTestStore) HandleUnhealthy(_ context.Context, inst Instance, _ *Runtime) error { + if s.unhealthy != nil { + s.unhealthy <- inst + } + return nil +} + func (s *controllerTestStore) SubscribeInstanceEvents() (<-chan InstanceEvent, func(), error) { return s.events, func() {}, nil } @@ -148,3 +156,44 @@ func TestControllerResetsRuntimeOnStartEvent(t *testing.T) { cancel() require.NoError(t, <-done) } + +func TestControllerReportsUnhealthyInstance(t *testing.T) { + policy, err := NormalizePolicy(&Policy{ + Type: TypeExec, + Interval: "1h", + StartPeriod: "0s", + FailureThreshold: 1, + Exec: &ExecCheck{Command: []string{"false"}}, + }) + require.NoError(t, err) + + store := &controllerTestStore{ + instances: []Instance{{ + ID: "inst-1", + State: StateRunning, + GuestAgentReady: true, + HealthCheck: policy, + }}, + events: make(chan InstanceEvent), + runtimes: make(chan *Runtime, 4), + unhealthy: make(chan Instance, 1), + } + controller := NewController(store, controllerTestRunner{result: ProbeResult{Success: false, Error: "exit status 1"}}, ControllerOptions{}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- controller.Run(ctx) + }() + + select { + case inst := <-store.unhealthy: + assert.Equal(t, "inst-1", inst.ID) + case <-time.After(time.Second): + t.Fatal("timed out waiting for unhealthy handler") + } + + cancel() + require.NoError(t, <-done) +} diff --git a/lib/healthcheck/types.go b/lib/healthcheck/types.go index 9ebc62e8..6710b26a 100644 --- a/lib/healthcheck/types.go +++ b/lib/healthcheck/types.go @@ -112,3 +112,7 @@ type InstanceStore interface { SetRuntime(ctx context.Context, id string, runtime *Runtime) error SubscribeInstanceEvents() (<-chan InstanceEvent, func(), error) } + +type UnhealthyHandler interface { + HandleUnhealthy(ctx context.Context, inst Instance, runtime *Runtime) error +} diff --git a/lib/instances/create.go b/lib/instances/create.go index dc2f57b0..df991674 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -375,6 +375,7 @@ func (m *manager) createInstance( SnapshotPolicy: cloneSnapshotPolicy(req.SnapshotPolicy), AutoStandby: cloneAutoStandbyPolicy(req.AutoStandby), HealthCheck: cloneHealthCheckPolicy(req.HealthCheck), + RestartPolicy: cloneRestartPolicy(req.RestartPolicy), } // 12. Ensure directories @@ -638,6 +639,11 @@ func validateCreateRequest(req *CreateInstanceRequest) error { if err := validateHealthCheckCompatibility(req.HealthCheck, req.NetworkEnabled, req.SkipGuestAgent); err != nil { return err } + normalizedRestartPolicy, err := normalizeRestartPolicy(req.RestartPolicy) + if err != nil { + return err + } + req.RestartPolicy = normalizedRestartPolicy // Validate volume attachments if err := validateVolumeAttachments(req.Volumes); err != nil { diff --git a/lib/instances/fork.go b/lib/instances/fork.go index d515b051..1bad2605 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -16,6 +16,7 @@ import ( "github.com/kernel/hypeman/lib/instances/phasetracking" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" "github.com/nrednav/cuid2" "go.opentelemetry.io/otel/attribute" "gvisor.dev/gvisor/pkg/cleanup" @@ -286,6 +287,7 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin forkMeta.VsockSocket = m.paths.InstanceSocket(forkID, hypervisor.VsockSocketNameForType(forkMeta.HypervisorType)) forkMeta.ExitCode = nil forkMeta.ExitMessage = "" + forkMeta.RestartStatus = restartpolicy.Status{} // Forks are new instances; phase accounting must not inherit the source's // cumulative durations. The first transition into the fork's runtime // phase (Standby for snapshot forks, Stopped for stopped forks) will be @@ -504,6 +506,9 @@ func cloneStoredMetadata(src StoredMetadata) StoredMetadata { if src.HealthCheck != nil { dst.HealthCheck = cloneHealthCheckPolicy(src.HealthCheck) } + if src.RestartPolicy != nil { + dst.RestartPolicy = cloneRestartPolicy(src.RestartPolicy) + } if src.SnapshotPolicy != nil { dst.SnapshotPolicy = cloneSnapshotPolicy(src.SnapshotPolicy) } diff --git a/lib/instances/lifecycle_events.go b/lib/instances/lifecycle_events.go index 4bba4851..9eb15b75 100644 --- a/lib/instances/lifecycle_events.go +++ b/lib/instances/lifecycle_events.go @@ -12,15 +12,17 @@ const defaultLifecycleEventBufferSize = 256 type LifecycleEventConsumer string const ( - LifecycleEventConsumerWaitForState LifecycleEventConsumer = "wait_for_state" - LifecycleEventConsumerAutoStandby LifecycleEventConsumer = "auto_standby" - LifecycleEventConsumerHealthCheck LifecycleEventConsumer = "health_check" + LifecycleEventConsumerWaitForState LifecycleEventConsumer = "wait_for_state" + LifecycleEventConsumerAutoStandby LifecycleEventConsumer = "auto_standby" + LifecycleEventConsumerHealthCheck LifecycleEventConsumer = "health_check" + LifecycleEventConsumerRestartPolicy LifecycleEventConsumer = "restart_policy" ) var allLifecycleEventConsumers = []LifecycleEventConsumer{ LifecycleEventConsumerWaitForState, LifecycleEventConsumerAutoStandby, LifecycleEventConsumerHealthCheck, + LifecycleEventConsumerRestartPolicy, } // LifecycleEventAction identifies which instance lifecycle action occurred. diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 94bc1e71..5371c995 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -546,7 +546,17 @@ func (m *manager) StopInstance(ctx context.Context, id string) (*Instance, error return nil, err } if current.State == StateStopped { - return current, nil + if err := m.markRestartManualStopLocked(ctx, id); err != nil { + return nil, err + } + updated, err := m.currentInstanceWithoutHydration(ctx, id) + if err != nil { + return nil, err + } + return updated, nil + } + if err := m.markRestartManualStopLocked(ctx, id); err != nil { + return nil, err } inst, err := m.stopInstance(ctx, id) if err == nil { @@ -569,6 +579,9 @@ func (m *manager) StartInstance(ctx context.Context, id string, req StartInstanc return current, nil } } + if err := m.clearRestartStatusLocked(ctx, id); err != nil { + return nil, err + } inst, err := m.startInstance(ctx, id, req) if err == nil { m.notifyLifecycleEvent(ctx, LifecycleEventStart, inst) diff --git a/lib/instances/restart_policy.go b/lib/instances/restart_policy.go new file mode 100644 index 00000000..8594f149 --- /dev/null +++ b/lib/instances/restart_policy.go @@ -0,0 +1,216 @@ +package instances + +import ( + "context" + "fmt" + "time" + + "github.com/kernel/hypeman/lib/logger" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" +) + +type restartPolicyStore struct { + manager *manager +} + +func cloneRestartPolicy(policy *restartpolicy.Policy) *restartpolicy.Policy { + if policy == nil { + return nil + } + return &restartpolicy.Policy{ + Policy: policy.Policy, + Backoff: policy.Backoff, + MaxAttempts: policy.MaxAttempts, + StableAfter: policy.StableAfter, + } +} + +func normalizeRestartPolicy(policy *restartpolicy.Policy) (*restartpolicy.Policy, error) { + normalized, err := restartpolicy.NormalizePolicy(policy) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) + } + return normalized, nil +} + +func restartStatusAfterPolicyUpdate(status restartpolicy.Status) restartpolicy.Status { + if status.BlockedReason == restartpolicy.BlockedReasonManualStop { + return restartpolicy.Status{BlockedReason: restartpolicy.BlockedReasonManualStop} + } + return restartpolicy.Status{} +} + +func (m *manager) markRestartManualStopLocked(ctx context.Context, id string) error { + if err := m.updateRestartStatusLocked(id, restartpolicy.Status{BlockedReason: restartpolicy.BlockedReasonManualStop}); err != nil { + logger.FromContext(ctx).WarnContext(ctx, "failed to mark restart policy manual stop", "instance_id", id, "error", err) + return err + } + return nil +} + +func (m *manager) clearRestartStatusLocked(ctx context.Context, id string) error { + if err := m.updateRestartStatusLocked(id, restartpolicy.Status{}); err != nil { + logger.FromContext(ctx).WarnContext(ctx, "failed to clear restart policy status", "instance_id", id, "error", err) + return err + } + return nil +} + +func (m *manager) updateRestartStatusLocked(id string, status restartpolicy.Status) error { + meta, err := m.loadMetadata(id) + if err != nil { + return err + } + meta.RestartStatus = status + return m.saveMetadata(meta) +} + +func (m *manager) RestartInstance(ctx context.Context, id string) (*Instance, error) { + lock := m.getInstanceLock(id) + lock.Lock() + defer lock.Unlock() + + inst, err := m.startInstance(ctx, id, StartInstanceRequest{}) + if err == nil { + m.notifyLifecycleEvent(ctx, LifecycleEventStart, inst) + } + return inst, err +} + +func (m *manager) HandleHealthCheckUnhealthy(ctx context.Context, id string) error { + lock := m.getInstanceLock(id) + lock.Lock() + defer lock.Unlock() + + current, err := m.currentInstanceWithoutHydration(ctx, id) + if err != nil { + return err + } + if current.State != StateRunning { + return nil + } + + policy, err := restartpolicy.NormalizePolicy(current.RestartPolicy) + if err != nil { + return err + } + if !restartpolicy.ShouldRestartHealthCheck(policy) { + return nil + } + if current.RestartStatus.BlockedReason != "" { + return nil + } + + now := time.Now().UTC() + status := current.RestartStatus + status.LastReason = restartpolicy.RestartReasonHealthCheckFailed + nextStatus, shouldAttempt := restartpolicy.PrepareAttempt(policy, status, now) + if !shouldAttempt { + if !restartpolicy.EqualStatus(current.RestartStatus, nextStatus) { + return m.updateRestartStatusLocked(id, nextStatus) + } + return nil + } + + reason := nextStatus.LastReason + nextStatus.LastReason = "" + if err := m.updateRestartStatusLocked(id, nextStatus); err != nil { + return err + } + + stopped, err := m.stopInstance(ctx, id) + if err != nil { + _ = m.updateRestartStatusLocked(id, restartStatusAfterFailedHealthAttempt(policy, nextStatus, reason, now)) + return err + } + m.notifyLifecycleEvent(ctx, LifecycleEventStop, stopped) + + started, err := m.startInstance(ctx, id, StartInstanceRequest{}) + if err != nil { + _ = m.updateRestartStatusLocked(id, restartStatusAfterFailedHealthAttempt(policy, nextStatus, reason, now)) + return err + } + m.notifyLifecycleEvent(ctx, LifecycleEventStart, started) + return nil +} + +func restartStatusAfterFailedHealthAttempt(policy *restartpolicy.Policy, status restartpolicy.Status, reason restartpolicy.RestartReason, now time.Time) restartpolicy.Status { + status = restartpolicy.AfterFailedAttempt(policy, status, now) + status.LastReason = reason + return status +} + +func (m *manager) StartRestartPolicyController(ctx context.Context) error { + controller := restartpolicy.NewController( + restartPolicyStore{manager: m}, + restartpolicy.ControllerOptions{ + Log: logger.FromContext(ctx).With("controller", "restart_policy"), + }, + ) + return controller.Run(ctx) +} + +func (s restartPolicyStore) ListInstances(ctx context.Context) ([]restartpolicy.Instance, error) { + insts, err := s.manager.ListInstances(ctx, nil) + if err != nil { + return nil, err + } + out := make([]restartpolicy.Instance, 0, len(insts)) + for _, inst := range insts { + out = append(out, *toRestartPolicyInstance(&inst)) + } + return out, nil +} + +func (s restartPolicyStore) RestartInstance(ctx context.Context, id string) error { + _, err := s.manager.RestartInstance(ctx, id) + return err +} + +func (s restartPolicyStore) SetRestartStatus(ctx context.Context, id string, status restartpolicy.Status) error { + lock := s.manager.getInstanceLock(id) + lock.Lock() + defer lock.Unlock() + return s.manager.updateRestartStatusLocked(id, status) +} + +func (s restartPolicyStore) SubscribeInstanceEvents() (<-chan restartpolicy.InstanceEvent, func(), error) { + src, unsub := s.manager.SubscribeLifecycleEvents(LifecycleEventConsumerRestartPolicy) + dst := make(chan restartpolicy.InstanceEvent, 32) + go func() { + defer close(dst) + for event := range src { + dst <- restartpolicy.InstanceEvent{ + Action: restartpolicy.InstanceEventAction(event.Action), + InstanceID: event.InstanceID, + Instance: toRestartPolicyInstance(event.Instance), + } + } + }() + return dst, unsub, nil +} + +func toRestartPolicyInstance(inst *Instance) *restartpolicy.Instance { + if inst == nil { + return nil + } + return &restartpolicy.Instance{ + ID: inst.Id, + State: string(inst.State), + StartedAt: inst.StartedAt, + ExitCode: inst.ExitCode, + RestartPolicy: inst.RestartPolicy, + RestartStatus: inst.RestartStatus, + } +} + +var _ restartpolicy.Store = restartPolicyStore{} +var _ interface { + StartRestartPolicyController(context.Context) error +} = (*manager)(nil) +var _ interface { + RestartInstance(context.Context, string) (*Instance, error) +} = (*manager)(nil) +var _ interface { + HandleHealthCheckUnhealthy(context.Context, string) error +} = (*manager)(nil) diff --git a/lib/instances/restart_policy_test.go b/lib/instances/restart_policy_test.go new file mode 100644 index 00000000..d500f578 --- /dev/null +++ b/lib/instances/restart_policy_test.go @@ -0,0 +1,48 @@ +package instances + +import ( + "errors" + "testing" + + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateUpdateInstanceRequestAllowsRestartPolicyOnly(t *testing.T) { + err := validateUpdateInstanceRequest(&metadata{}, UpdateInstanceRequest{ + RestartPolicy: &restartpolicy.Policy{Policy: restartpolicy.PolicyAlways}, + RestartPolicySet: true, + }) + + require.NoError(t, err) +} + +func TestNormalizeRestartPolicyWrapsInvalidRequest(t *testing.T) { + _, err := normalizeRestartPolicy(&restartpolicy.Policy{ + Policy: restartpolicy.PolicyOnFailure, + Backoff: "0s", + }) + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidRequest)) +} + +func TestRestartStatusAfterPolicyUpdatePreservesManualStop(t *testing.T) { + status := restartStatusAfterPolicyUpdate(restartpolicy.Status{ + Attempts: 3, + BlockedReason: restartpolicy.BlockedReasonManualStop, + }) + + assert.Equal(t, restartpolicy.BlockedReasonManualStop, status.BlockedReason) + assert.Zero(t, status.Attempts) +} + +func TestRestartStatusAfterPolicyUpdateClearsRetryState(t *testing.T) { + status := restartStatusAfterPolicyUpdate(restartpolicy.Status{ + Attempts: 3, + BlockedReason: restartpolicy.BlockedReasonMaxAttemptsExceeded, + }) + + assert.True(t, status.IsZero()) +} diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 908583cd..54d18c4f 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -11,6 +11,7 @@ import ( "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" snapshotstore "github.com/kernel/hypeman/lib/snapshot" "github.com/kernel/hypeman/lib/tags" "github.com/nrednav/cuid2" @@ -451,6 +452,7 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS forkMeta.VsockSocket = m.paths.InstanceSocket(forkID, hypervisor.VsockSocketNameForType(targetHypervisor)) forkMeta.ExitCode = nil forkMeta.ExitMessage = "" + forkMeta.RestartStatus = restartpolicy.Status{} if rec.Snapshot.Kind == SnapshotKindStandby { forkMeta.VsockCID = rec.StoredMetadata.VsockCID } else { diff --git a/lib/instances/types.go b/lib/instances/types.go index 0fe2fd04..71a18b3c 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -7,6 +7,7 @@ import ( "github.com/kernel/hypeman/lib/healthcheck" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/instances/phasetracking" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" "github.com/kernel/hypeman/lib/snapshot" "github.com/kernel/hypeman/lib/tags" ) @@ -168,6 +169,10 @@ type StoredMetadata struct { // Workload health check policy. Health is reported separately from lifecycle state. HealthCheck *healthcheck.Policy + // Whole-instance restart supervision policy and runtime status. + RestartPolicy *restartpolicy.Policy + RestartStatus restartpolicy.Status + // Shutdown configuration StopTimeout int // Grace period in seconds for graceful stop (0 = use default 5s) @@ -259,6 +264,7 @@ type CreateInstanceRequest struct { SnapshotPolicy *SnapshotPolicy // Optional snapshot policy defaults for this instance AutoStandby *autostandby.Policy // Optional automatic standby policy HealthCheck *healthcheck.Policy // Optional workload health check policy + RestartPolicy *restartpolicy.Policy // Optional whole-instance restart policy } // StartInstanceRequest is the domain request for starting a stopped instance @@ -269,9 +275,11 @@ type StartInstanceRequest struct { // UpdateInstanceRequest is the domain request for updating mutable instance properties. type UpdateInstanceRequest struct { - Env map[string]string // Updated environment variables (merged with existing) - AutoStandby *autostandby.Policy // Replaces the persisted auto-standby policy when non-nil - HealthCheck *healthcheck.Policy // Replaces the persisted health check policy when non-nil + Env map[string]string // Updated environment variables (merged with existing) + AutoStandby *autostandby.Policy // Replaces the persisted auto-standby policy when non-nil + HealthCheck *healthcheck.Policy // Replaces the persisted health check policy when non-nil + RestartPolicy *restartpolicy.Policy // Replaces the persisted restart policy when non-nil + RestartPolicySet bool // True when restart policy was present in the update request } // ForkInstanceRequest is the domain request for forking an instance. diff --git a/lib/instances/update.go b/lib/instances/update.go index d8f97833..cdea9e4c 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -41,6 +41,13 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta return nil, err } req.HealthCheck = normalizedHealthCheck + if req.RestartPolicySet { + normalizedRestartPolicy, err := normalizeRestartPolicy(req.RestartPolicy) + if err != nil { + return nil, err + } + req.RestartPolicy = normalizedRestartPolicy + } if err := validateUpdateInstanceRequest(meta, req); err != nil { return nil, err @@ -56,6 +63,10 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta nextMeta.HealthCheck = cloneHealthCheckPolicy(req.HealthCheck) nextMeta.HealthCheckRuntime = nil } + if req.RestartPolicySet { + nextMeta.RestartPolicy = cloneRestartPolicy(req.RestartPolicy) + nextMeta.RestartStatus = restartStatusAfterPolicyUpdate(nextMeta.RestartStatus) + } if len(req.Env) == 0 { if err := m.saveMetadata(nextMeta); err != nil { return nil, fmt.Errorf("save metadata: %w", err) @@ -103,8 +114,8 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta } func validateUpdateInstanceRequest(meta *metadata, req UpdateInstanceRequest) error { - if len(req.Env) == 0 && req.AutoStandby == nil && req.HealthCheck == nil { - return fmt.Errorf("%w: request must include env, auto_standby, and/or health_check", ErrInvalidRequest) + if len(req.Env) == 0 && req.AutoStandby == nil && req.HealthCheck == nil && !req.RestartPolicySet { + return fmt.Errorf("%w: request must include env, auto_standby, health_check, and/or restart_policy", ErrInvalidRequest) } if req.HealthCheck != nil { if meta == nil { diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index bd645a3b..8257a67b 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -186,6 +186,24 @@ const ( MemoryReclaimResponseHostPressureStatePressure MemoryReclaimResponseHostPressureState = "pressure" ) +// Defines values for RestartPolicyPolicy. +const ( + Always RestartPolicyPolicy = "always" + Never RestartPolicyPolicy = "never" + OnFailure RestartPolicyPolicy = "on_failure" +) + +// Defines values for RestartStatusBlockedReason. +const ( + ManualStop RestartStatusBlockedReason = "manual_stop" + MaxAttemptsExceeded RestartStatusBlockedReason = "max_attempts_exceeded" +) + +// Defines values for RestartStatusLastReason. +const ( + HealthCheckFailed RestartStatusLastReason = "health_check_failed" +) + // Defines values for RestoreSnapshotRequestTargetHypervisor. const ( RestoreSnapshotRequestTargetHypervisorCloudHypervisor RestoreSnapshotRequestTargetHypervisor = "cloud-hypervisor" @@ -507,6 +525,9 @@ type CreateInstanceRequest struct { // OverlaySize Writable overlay disk size (human-readable format like "10GB", "50G") OverlaySize *string `json:"overlay_size,omitempty"` + // RestartPolicy Whole-instance restart supervision policy. + RestartPolicy *RestartPolicy `json:"restart_policy,omitempty"` + // Size Base memory size (human-readable format like "1GB", "512MB", "2G") Size *string `json:"size,omitempty"` @@ -1026,6 +1047,12 @@ type Instance struct { // billable. PhaseDurationsMs *map[string]int64 `json:"phase_durations_ms,omitempty"` + // RestartPolicy Whole-instance restart supervision policy. + RestartPolicy *RestartPolicy `json:"restart_policy,omitempty"` + + // RestartStatus Runtime status for restart policy decisions. + RestartStatus *RestartStatus `json:"restart_status,omitempty"` + // Size Base memory size (human-readable) Size *string `json:"size,omitempty"` SnapshotPolicy *SnapshotPolicy `json:"snapshot_policy,omitempty"` @@ -1299,6 +1326,54 @@ type Resources struct { Network ResourceStatus `json:"network"` } +// RestartPolicy Whole-instance restart supervision policy. +type RestartPolicy struct { + // Backoff Delay before each restart attempt, expressed as a Go duration like "5s" or "1m". + Backoff *string `json:"backoff,omitempty"` + + // MaxAttempts Consecutive automatic restart attempts before blocking retries. 0 means unlimited. + MaxAttempts *int `json:"max_attempts,omitempty"` + + // Policy Restart behavior when the guest program exits: + // - never: do not automatically restart + // - always: restart after any guest exit + // - on_failure: restart only for nonzero, signaled, OOM, or unknown exits + Policy *RestartPolicyPolicy `json:"policy,omitempty"` + + // StableAfter Running this long resets the consecutive restart attempt count. + StableAfter *string `json:"stable_after,omitempty"` +} + +// RestartPolicyPolicy Restart behavior when the guest program exits: +// - never: do not automatically restart +// - always: restart after any guest exit +// - on_failure: restart only for nonzero, signaled, OOM, or unknown exits +type RestartPolicyPolicy string + +// RestartStatus Runtime status for restart policy decisions. +type RestartStatus struct { + // Attempts Consecutive automatic restart attempts in the current failure window. + Attempts *int `json:"attempts,omitempty"` + + // BlockedReason Reason automatic restarts are currently blocked. + BlockedReason *RestartStatusBlockedReason `json:"blocked_reason"` + + // LastAttemptAt Last time Hypeman attempted an automatic restart. + LastAttemptAt *time.Time `json:"last_attempt_at"` + + // LastReason Most recent non-exit failure signal that entered restart policy. + LastReason *RestartStatusLastReason `json:"last_reason"` + + // NextAttemptAt Next scheduled automatic restart attempt after backoff. + NextAttemptAt *time.Time `json:"next_attempt_at"` +} + +// RestartStatusBlockedReason Reason automatic restarts are currently blocked. +type RestartStatusBlockedReason string + +// RestartStatusLastReason Most recent non-exit failure signal that entered restart policy. +type RestartStatusLastReason string + // RestoreSnapshotRequest defines model for RestoreSnapshotRequest. type RestoreSnapshotRequest struct { // TargetHypervisor Optional hypervisor override. Allowed only when restoring from a Stopped snapshot. @@ -1475,6 +1550,9 @@ type UpdateInstanceRequest struct { // HealthCheck Workload health check policy. Health is reported separately from instance lifecycle state. HealthCheck *HealthCheck `json:"health_check,omitempty"` + + // RestartPolicy Whole-instance restart supervision policy. + RestartPolicy *RestartPolicy `json:"restart_policy,omitempty"` } // Volume defines model for Volume. @@ -16291,295 +16369,302 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "cmBIDRaNAQA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/providers/health_check.go b/lib/providers/health_check.go index 81cc21e7..9c14ec6d 100644 --- a/lib/providers/health_check.go +++ b/lib/providers/health_check.go @@ -17,6 +17,10 @@ type healthCheckRuntimeManager interface { SubscribeLifecycleEvents(consumer instances.LifecycleEventConsumer) (<-chan instances.LifecycleEvent, func()) } +type healthCheckUnhealthyManager interface { + HandleHealthCheckUnhealthy(ctx context.Context, id string) error +} + type healthCheckInstanceStore struct { manager instances.Manager runtimeManager healthCheckRuntimeManager @@ -43,6 +47,14 @@ func (s healthCheckInstanceStore) SetRuntime(ctx context.Context, id string, run return s.runtimeManager.SetHealthCheckRuntime(ctx, id, runtime) } +func (s healthCheckInstanceStore) HandleUnhealthy(ctx context.Context, inst healthcheck.Instance, _ *healthcheck.Runtime) error { + manager, ok := s.manager.(healthCheckUnhealthyManager) + if !ok { + return nil + } + return manager.HandleHealthCheckUnhealthy(ctx, inst.ID) +} + func (s healthCheckInstanceStore) SubscribeInstanceEvents() (<-chan healthcheck.InstanceEvent, func(), error) { src, unsub := s.runtimeManager.SubscribeLifecycleEvents(instances.LifecycleEventConsumerHealthCheck) dst := make(chan healthcheck.InstanceEvent, 32) diff --git a/lib/restart-policy/README.md b/lib/restart-policy/README.md new file mode 100644 index 00000000..f8dd76bb --- /dev/null +++ b/lib/restart-policy/README.md @@ -0,0 +1,88 @@ +# Restart Policy + +Restart policy lets Hypeman keep an instance running after the guest program exits, or after a configured health check marks a running workload unhealthy. + +This supervises the whole instance, not an individual in-guest process. If the image runs systemd or multiple processes, a restart boots the instance again using the same persisted instance configuration. + +## Policies + +`never` is the default. Hypeman records exit information, but never restarts the instance. + +`on_failure` restarts when the last run failed. + +Failure means: + +- exit code is nonzero +- the guest was killed by signal or OOM +- the instance stopped unexpectedly and no clean exit code is available +- a configured health check reached `unhealthy` + +Exit code `0` does not restart under `on_failure`. + +`always` restarts after any guest exit, including exit code `0`. + +## Manual Stops + +Manual stop suppresses restart policy. + +When an instance is stopped through the API, Hypeman records an internal suppression marker before shutdown begins. The public instance `state` remains the single lifecycle state; the suppression marker is exposed only as `restart_status.blocked_reason=manual_stop`. + +Calling `start` clears manual suppression and retry status. + +Deleting an instance removes it entirely and no restart is attempted. + +Unexpected guest exit does not set manual suppression. If the instance has a restart policy, the controller may restart it. + +## Attempts And Backoff + +Each automatic restart waits for `backoff` before another restart attempt is allowed. + +`max_attempts` limits consecutive automatic restart attempts. `0` means unlimited attempts. + +If `max_attempts` is exceeded, restart policy is blocked for that failure window and `restart_status.blocked_reason` is set to `max_attempts_exceeded`. + +If an instance runs for `stable_after`, the consecutive attempt count resets. This prevents old transient failures from permanently consuming the retry budget. + +Manual `start` clears blocked restart status and starts a new failure window. + +Updating the restart policy clears retry status unless the instance was manually stopped. Changing the policy on a manually stopped instance does not start it; the user must call `start`. + +## Health Checks + +Health checks are a separate feature. They report workload health without changing instance lifecycle state. + +When health status reaches `unhealthy`, restart policy treats that as a failure signal for `on_failure` and `always`. `never` still reports health without restarting. + +Health-triggered restart uses the same attempt counter, backoff, max-attempt limit, manual-stop suppression, and stable-window reset as exit-triggered restart. + +## Lifecycle Behavior + +Exit-triggered restart only starts instances from `Stopped`. + +Health-triggered restart acts on `Running` instances by stopping the instance and starting it again. The health check itself does not keep the lifecycle state in `Initializing` or otherwise hide the distinction between boot readiness and workload health. + +It does not restore `Standby` instances, wake templates, or act on `Unknown` state. + +Automatic restart uses the normal instance start path. Resource validation, network allocation, config disk generation, volumes, GPU attachment, egress policy, and command/env metadata behave the same as a manual start. + +The restart keeps the same image, overlay disk, volumes, env, tags, entrypoint, and cmd stored on the instance. + +## Status + +Instance responses include the configured `restart_policy` and current `restart_status`. + +`restart_status.next_attempt_at` is set while waiting for backoff. + +`restart_status.attempts` counts consecutive automatic restart attempts in the current failure window. + +`restart_status.blocked_reason` explains why no more retries will happen despite a restart policy being configured. + +`restart_status.last_reason=health_check_failed` means the current retry window was entered because health checks marked the workload unhealthy rather than because the guest process exited on its own. + +## Non-goals + +Restart policy is not a health check. + +It does not supervise individual processes inside the guest. + +It does not replace systemd for images that want in-guest service supervision. diff --git a/lib/restart-policy/controller.go b/lib/restart-policy/controller.go new file mode 100644 index 00000000..2bd31f34 --- /dev/null +++ b/lib/restart-policy/controller.go @@ -0,0 +1,200 @@ +package restartpolicy + +import ( + "context" + "log/slog" + "time" +) + +const ( + StateInitializing = "Initializing" + StateRunning = "Running" + StateStopped = "Stopped" + + DefaultReconcileInterval = 5 * time.Second +) + +type InstanceEventAction string + +const ( + InstanceEventCreate InstanceEventAction = "create" + InstanceEventUpdate InstanceEventAction = "update" + InstanceEventStart InstanceEventAction = "start" + InstanceEventStop InstanceEventAction = "stop" + InstanceEventDelete InstanceEventAction = "delete" +) + +type InstanceEvent struct { + Action InstanceEventAction + InstanceID string + Instance *Instance +} + +type Instance struct { + ID string + State string + StartedAt *time.Time + ExitCode *int + RestartPolicy *Policy + RestartStatus Status +} + +type Store interface { + ListInstances(ctx context.Context) ([]Instance, error) + RestartInstance(ctx context.Context, id string) error + SetRestartStatus(ctx context.Context, id string, status Status) error + SubscribeInstanceEvents() (<-chan InstanceEvent, func(), error) +} + +type ControllerOptions struct { + Log *slog.Logger + Now func() time.Time + ReconcileInterval time.Duration +} + +type Controller struct { + store Store + log *slog.Logger + now func() time.Time + reconcileInterval time.Duration +} + +func NewController(store Store, opts ControllerOptions) *Controller { + log := opts.Log + if log == nil { + log = slog.Default() + } + now := opts.Now + if now == nil { + now = time.Now + } + interval := opts.ReconcileInterval + if interval <= 0 { + interval = DefaultReconcileInterval + } + return &Controller{ + store: store, + log: log, + now: now, + reconcileInterval: interval, + } +} + +func (c *Controller) Run(ctx context.Context) error { + c.log.Info("restart policy controller started", "reconcile_interval", c.reconcileInterval) + if err := c.Reconcile(ctx); err != nil { + c.log.Warn("restart policy startup reconcile failed", "error", err) + } + + events, unsubscribe, err := c.store.SubscribeInstanceEvents() + if err != nil { + return err + } + defer unsubscribe() + + ticker := time.NewTicker(c.reconcileInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case event, ok := <-events: + if !ok { + return nil + } + if event.Action == InstanceEventDelete { + continue + } + if event.Instance == nil { + if err := c.Reconcile(ctx); err != nil { + c.log.Warn("restart policy event reconcile failed", "instance_id", event.InstanceID, "error", err) + } + continue + } + if err := c.reconcileInstance(ctx, *event.Instance); err != nil { + c.log.Warn("restart policy event handling failed", "instance_id", event.InstanceID, "error", err) + } + case <-ticker.C: + if err := c.Reconcile(ctx); err != nil { + c.log.Warn("restart policy reconcile failed", "error", err) + } + } + } +} + +func (c *Controller) Reconcile(ctx context.Context) error { + instances, err := c.store.ListInstances(ctx) + if err != nil { + return err + } + for _, inst := range instances { + if err := c.reconcileInstance(ctx, inst); err != nil { + c.log.Warn("restart policy reconcile failed for instance", "instance_id", inst.ID, "error", err) + } + } + return nil +} + +func (c *Controller) reconcileInstance(ctx context.Context, inst Instance) error { + policy, err := NormalizePolicy(inst.RestartPolicy) + if err != nil { + return err + } + if policy == nil { + return nil + } + + status := inst.RestartStatus + now := c.now().UTC() + + if shouldResetStableAttempts(policy, status, inst, now) { + c.log.Info("restart policy stable window reached", "instance_id", inst.ID, "attempts", status.Attempts) + return c.store.SetRestartStatus(ctx, inst.ID, Status{}) + } + + if inst.State != StateStopped { + return nil + } + if status.BlockedReason != "" { + return nil + } + if !ShouldRestartInstance(policy, inst.ExitCode, status) { + return nil + } + + nextStatus, shouldAttempt := PrepareAttempt(policy, status, now) + if !shouldAttempt { + if !EqualStatus(status, nextStatus) { + return c.store.SetRestartStatus(ctx, inst.ID, nextStatus) + } + return nil + } + + reason := nextStatus.LastReason + nextStatus.LastReason = "" + if err := c.store.SetRestartStatus(ctx, inst.ID, nextStatus); err != nil { + return err + } + + c.log.Info("restart policy starting instance", "instance_id", inst.ID, "attempt", nextStatus.Attempts) + if err := c.store.RestartInstance(ctx, inst.ID); err != nil { + nextStatus = AfterFailedAttempt(policy, nextStatus, now) + nextStatus.LastReason = reason + if statusErr := c.store.SetRestartStatus(ctx, inst.ID, nextStatus); statusErr != nil { + c.log.Warn("failed to persist restart status after restart failure", "instance_id", inst.ID, "error", statusErr) + } + return err + } + return nil +} + +func shouldResetStableAttempts(policy *Policy, status Status, inst Instance, now time.Time) bool { + if status.Attempts == 0 || inst.StartedAt == nil { + return false + } + if inst.State != StateRunning && inst.State != StateInitializing { + return false + } + return !now.Before(inst.StartedAt.UTC().Add(StableAfter(policy))) +} diff --git a/lib/restart-policy/controller_test.go b/lib/restart-policy/controller_test.go new file mode 100644 index 00000000..47c37a2c --- /dev/null +++ b/lib/restart-policy/controller_test.go @@ -0,0 +1,136 @@ +package restartpolicy + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeStore struct { + instances []Instance + started []string + statuses map[string]Status + startErr error +} + +func (s *fakeStore) ListInstances(context.Context) ([]Instance, error) { + out := make([]Instance, len(s.instances)) + copy(out, s.instances) + return out, nil +} + +func (s *fakeStore) RestartInstance(_ context.Context, id string) error { + s.started = append(s.started, id) + return s.startErr +} + +func (s *fakeStore) SetRestartStatus(_ context.Context, id string, status Status) error { + if s.statuses == nil { + s.statuses = make(map[string]Status) + } + s.statuses[id] = status + return nil +} + +func (s *fakeStore) SubscribeInstanceEvents() (<-chan InstanceEvent, func(), error) { + ch := make(chan InstanceEvent) + close(ch) + return ch, func() {}, nil +} + +func TestReconcileRestartsFailedStoppedInstance(t *testing.T) { + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + exitCode := 1 + store := &fakeStore{ + instances: []Instance{{ + ID: "inst-1", + State: StateStopped, + ExitCode: &exitCode, + RestartPolicy: &Policy{Policy: PolicyOnFailure}, + }}, + } + controller := NewController(store, ControllerOptions{Now: func() time.Time { return now }}) + + require.NoError(t, controller.Reconcile(context.Background())) + + assert.Equal(t, []string{"inst-1"}, store.started) + status := store.statuses["inst-1"] + assert.Equal(t, 1, status.Attempts) + require.NotNil(t, status.LastAttemptAt) + assert.Equal(t, now, *status.LastAttemptAt) +} + +func TestReconcileSkipsManualStop(t *testing.T) { + store := &fakeStore{ + instances: []Instance{{ + ID: "inst-1", + State: StateStopped, + RestartPolicy: &Policy{Policy: PolicyAlways}, + RestartStatus: Status{BlockedReason: BlockedReasonManualStop}, + }}, + } + controller := NewController(store, ControllerOptions{}) + + require.NoError(t, controller.Reconcile(context.Background())) + + assert.Empty(t, store.started) + assert.Nil(t, store.statuses) +} + +func TestReconcileBlocksAfterMaxAttempts(t *testing.T) { + store := &fakeStore{ + instances: []Instance{{ + ID: "inst-1", + State: StateStopped, + RestartPolicy: &Policy{Policy: PolicyAlways, MaxAttempts: 2}, + RestartStatus: Status{Attempts: 2}, + }}, + } + controller := NewController(store, ControllerOptions{}) + + require.NoError(t, controller.Reconcile(context.Background())) + + assert.Empty(t, store.started) + assert.Equal(t, BlockedReasonMaxAttemptsExceeded, store.statuses["inst-1"].BlockedReason) +} + +func TestReconcileSkipsCleanOnFailureExit(t *testing.T) { + exitCode := 0 + store := &fakeStore{ + instances: []Instance{{ + ID: "inst-1", + State: StateStopped, + ExitCode: &exitCode, + RestartPolicy: &Policy{Policy: PolicyOnFailure}, + }}, + } + controller := NewController(store, ControllerOptions{}) + + require.NoError(t, controller.Reconcile(context.Background())) + + assert.Empty(t, store.started) + assert.Nil(t, store.statuses) +} + +func TestReconcileRestartsStoppedHealthFailure(t *testing.T) { + exitCode := 0 + store := &fakeStore{ + instances: []Instance{{ + ID: "inst-1", + State: StateStopped, + ExitCode: &exitCode, + RestartPolicy: &Policy{Policy: PolicyOnFailure}, + RestartStatus: Status{LastReason: RestartReasonHealthCheckFailed}, + }}, + } + controller := NewController(store, ControllerOptions{}) + + require.NoError(t, controller.Reconcile(context.Background())) + + assert.Equal(t, []string{"inst-1"}, store.started) + assert.Equal(t, 1, store.statuses["inst-1"].Attempts) + assert.Empty(t, store.statuses["inst-1"].LastReason) +} diff --git a/lib/restart-policy/policy.go b/lib/restart-policy/policy.go new file mode 100644 index 00000000..d012b239 --- /dev/null +++ b/lib/restart-policy/policy.go @@ -0,0 +1,223 @@ +package restartpolicy + +import ( + "fmt" + "strings" + "time" +) + +const ( + DefaultBackoff = 5 * time.Second + DefaultStableAfter = 10 * time.Minute +) + +type PolicyMode string + +const ( + PolicyNever PolicyMode = "never" + PolicyAlways PolicyMode = "always" + PolicyOnFailure PolicyMode = "on_failure" +) + +type BlockedReason string + +const ( + BlockedReasonManualStop BlockedReason = "manual_stop" + BlockedReasonMaxAttemptsExceeded BlockedReason = "max_attempts_exceeded" +) + +type RestartReason string + +const ( + RestartReasonHealthCheckFailed RestartReason = "health_check_failed" +) + +type Policy struct { + Policy PolicyMode `json:"policy"` + Backoff string `json:"backoff,omitempty"` + MaxAttempts int `json:"max_attempts,omitempty"` + StableAfter string `json:"stable_after,omitempty"` +} + +type Status struct { + Attempts int `json:"attempts,omitempty"` + LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` + NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"` + BlockedReason BlockedReason `json:"blocked_reason,omitempty"` + LastReason RestartReason `json:"last_reason,omitempty"` +} + +func NormalizePolicy(policy *Policy) (*Policy, error) { + if policy == nil { + return nil, nil + } + + mode := policy.Policy + if mode == "" { + mode = PolicyNever + } + + switch mode { + case PolicyNever: + return nil, nil + case PolicyAlways, PolicyOnFailure: + default: + return nil, fmt.Errorf("restart_policy.policy must be one of never, always, on_failure") + } + + backoff, err := normalizeDuration(policy.Backoff, DefaultBackoff, "restart_policy.backoff") + if err != nil { + return nil, err + } + stableAfter, err := normalizeDuration(policy.StableAfter, DefaultStableAfter, "restart_policy.stable_after") + if err != nil { + return nil, err + } + if policy.MaxAttempts < 0 { + return nil, fmt.Errorf("restart_policy.max_attempts must be >= 0") + } + + return &Policy{ + Policy: mode, + Backoff: backoff.String(), + MaxAttempts: policy.MaxAttempts, + StableAfter: stableAfter.String(), + }, nil +} + +func Backoff(policy *Policy) time.Duration { + d, err := durationOrDefault(policy, func(p *Policy) string { return p.Backoff }, DefaultBackoff) + if err != nil { + return DefaultBackoff + } + return d +} + +func StableAfter(policy *Policy) time.Duration { + d, err := durationOrDefault(policy, func(p *Policy) string { return p.StableAfter }, DefaultStableAfter) + if err != nil { + return DefaultStableAfter + } + return d +} + +func Failure(exitCode *int) bool { + return exitCode == nil || *exitCode != 0 +} + +func ShouldRestart(policy *Policy, exitCode *int) bool { + if policy == nil { + return false + } + switch policy.Policy { + case PolicyAlways: + return true + case PolicyOnFailure: + return Failure(exitCode) + default: + return false + } +} + +func ShouldRestartHealthCheck(policy *Policy) bool { + if policy == nil { + return false + } + switch policy.Policy { + case PolicyAlways, PolicyOnFailure: + return true + default: + return false + } +} + +func ShouldRestartInstance(policy *Policy, exitCode *int, status Status) bool { + if status.LastReason == RestartReasonHealthCheckFailed { + return ShouldRestartHealthCheck(policy) + } + return ShouldRestart(policy, exitCode) +} + +func PrepareAttempt(policy *Policy, status Status, now time.Time) (Status, bool) { + now = now.UTC() + if status.BlockedReason != "" { + return status, false + } + if status.NextAttemptAt != nil && now.Before(status.NextAttemptAt.UTC()) { + return status, false + } + if status.LastAttemptAt != nil { + nextAttemptAt := status.LastAttemptAt.UTC().Add(Backoff(policy)) + if now.Before(nextAttemptAt) { + status.NextAttemptAt = &nextAttemptAt + return status, false + } + } + if policy.MaxAttempts > 0 && status.Attempts >= policy.MaxAttempts { + status.NextAttemptAt = nil + status.BlockedReason = BlockedReasonMaxAttemptsExceeded + return status, false + } + + status.Attempts++ + status.LastAttemptAt = &now + status.NextAttemptAt = nil + return status, true +} + +func AfterFailedAttempt(policy *Policy, status Status, now time.Time) Status { + now = now.UTC() + if policy.MaxAttempts > 0 && status.Attempts >= policy.MaxAttempts { + status.BlockedReason = BlockedReasonMaxAttemptsExceeded + status.NextAttemptAt = nil + return status + } + nextAttemptAt := now.Add(Backoff(policy)) + status.NextAttemptAt = &nextAttemptAt + return status +} + +func EqualStatus(a, b Status) bool { + return a.Attempts == b.Attempts && + equalTime(a.LastAttemptAt, b.LastAttemptAt) && + equalTime(a.NextAttemptAt, b.NextAttemptAt) && + a.BlockedReason == b.BlockedReason && + a.LastReason == b.LastReason +} + +func (s Status) IsZero() bool { + return s.Attempts == 0 && + s.LastAttemptAt == nil && + s.NextAttemptAt == nil && + s.BlockedReason == "" && + s.LastReason == "" +} + +func equalTime(a, b *time.Time) bool { + if a == nil || b == nil { + return a == b + } + return a.UTC().Equal(b.UTC()) +} + +func normalizeDuration(raw string, fallback time.Duration, field string) (time.Duration, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("%s must be a valid duration: %w", field, err) + } + if parsed <= 0 { + return 0, fmt.Errorf("%s must be positive", field) + } + return parsed, nil +} + +func durationOrDefault(policy *Policy, selectValue func(*Policy) string, fallback time.Duration) (time.Duration, error) { + if policy == nil { + return fallback, nil + } + return normalizeDuration(selectValue(policy), fallback, "duration") +} diff --git a/lib/restart-policy/policy_test.go b/lib/restart-policy/policy_test.go new file mode 100644 index 00000000..1c0efe6c --- /dev/null +++ b/lib/restart-policy/policy_test.go @@ -0,0 +1,57 @@ +package restartpolicy + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizePolicyDefaults(t *testing.T) { + policy, err := NormalizePolicy(&Policy{Policy: PolicyOnFailure}) + require.NoError(t, err) + + assert.Equal(t, PolicyOnFailure, policy.Policy) + assert.Equal(t, "5s", policy.Backoff) + assert.Equal(t, "10m0s", policy.StableAfter) + assert.Equal(t, 0, policy.MaxAttempts) +} + +func TestNormalizePolicyNeverBecomesNil(t *testing.T) { + policy, err := NormalizePolicy(&Policy{Policy: PolicyNever}) + require.NoError(t, err) + + assert.Nil(t, policy) +} + +func TestNormalizePolicyRejectsInvalidDuration(t *testing.T) { + _, err := NormalizePolicy(&Policy{Policy: PolicyAlways, Backoff: "0s"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "restart_policy.backoff") +} + +func TestShouldRestart(t *testing.T) { + exitZero := 0 + exitOne := 1 + + assert.False(t, ShouldRestart(nil, &exitOne)) + assert.True(t, ShouldRestart(&Policy{Policy: PolicyAlways}, &exitZero)) + assert.False(t, ShouldRestart(&Policy{Policy: PolicyOnFailure}, &exitZero)) + assert.True(t, ShouldRestart(&Policy{Policy: PolicyOnFailure}, &exitOne)) + assert.True(t, ShouldRestart(&Policy{Policy: PolicyOnFailure}, nil)) +} + +func TestStableAttemptReset(t *testing.T) { + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + startedAt := now.Add(-11 * time.Minute) + + reset := shouldResetStableAttempts( + &Policy{Policy: PolicyAlways, StableAfter: "10m"}, + Status{Attempts: 2}, + Instance{State: StateRunning, StartedAt: &startedAt}, + now, + ) + + assert.True(t, reset) +} diff --git a/openapi.yaml b/openapi.yaml index 3b2ce03c..89e0e3b4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -376,6 +376,70 @@ components: description: Truncated error from the most recent failed check. example: "connection refused" + RestartPolicy: + type: object + description: Whole-instance restart supervision policy. + properties: + policy: + type: string + enum: [never, always, on_failure] + default: never + description: | + Restart behavior when the guest program exits: + - never: do not automatically restart + - always: restart after any guest exit + - on_failure: restart only for nonzero, signaled, OOM, or unknown exits + example: on_failure + backoff: + type: string + description: Delay before each restart attempt, expressed as a Go duration like "5s" or "1m". + default: "5s" + example: "5s" + max_attempts: + type: integer + minimum: 0 + default: 0 + description: Consecutive automatic restart attempts before blocking retries. 0 means unlimited. + example: 10 + stable_after: + type: string + description: Running this long resets the consecutive restart attempt count. + default: "10m" + example: "10m" + + RestartStatus: + type: object + description: Runtime status for restart policy decisions. + properties: + attempts: + type: integer + description: Consecutive automatic restart attempts in the current failure window. + example: 3 + last_attempt_at: + type: string + format: date-time + nullable: true + description: Last time Hypeman attempted an automatic restart. + example: "2025-01-15T12:30:00Z" + next_attempt_at: + type: string + format: date-time + nullable: true + description: Next scheduled automatic restart attempt after backoff. + example: "2025-01-15T12:30:05Z" + blocked_reason: + type: string + enum: [manual_stop, max_attempts_exceeded] + nullable: true + description: Reason automatic restarts are currently blocked. + example: max_attempts_exceeded + last_reason: + type: string + enum: [health_check_failed] + nullable: true + description: Most recent non-exit failure signal that entered restart policy. + example: health_check_failed + AutoStandbyStatus: type: object required: [supported, configured, enabled, eligible, status, reason, active_inbound_connections, tracking_mode] @@ -459,6 +523,8 @@ components: $ref: "#/components/schemas/AutoStandbyPolicy" health_check: $ref: "#/components/schemas/HealthCheck" + restart_policy: + $ref: "#/components/schemas/RestartPolicy" CreateInstanceRequest: type: object @@ -569,6 +635,8 @@ components: $ref: "#/components/schemas/AutoStandbyPolicy" health_check: $ref: "#/components/schemas/HealthCheck" + restart_policy: + $ref: "#/components/schemas/RestartPolicy" skip_kernel_headers: type: boolean description: | @@ -1027,6 +1095,10 @@ components: $ref: "#/components/schemas/HealthCheck" health_status: $ref: "#/components/schemas/InstanceHealthStatus" + restart_policy: + $ref: "#/components/schemas/RestartPolicy" + restart_status: + $ref: "#/components/schemas/RestartStatus" phase_durations_ms: type: object description: | diff --git a/stainless.yaml b/stainless.yaml index e247d289..cceb702b 100644 --- a/stainless.yaml +++ b/stainless.yaml @@ -74,6 +74,8 @@ resources: models: auto_standby_policy: "#/components/schemas/AutoStandbyPolicy" auto_standby_status: "#/components/schemas/AutoStandbyStatus" + restart_policy: "#/components/schemas/RestartPolicy" + restart_status: "#/components/schemas/RestartStatus" snapshot_policy: "#/components/schemas/SnapshotPolicy" standby_instance_request: "#/components/schemas/StandbyInstanceRequest" snapshot_schedule: "#/components/schemas/SnapshotSchedule"