diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 66152a6ee..67af4a6f4 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -31,10 +31,13 @@ This document provides a comprehensive overview of every feature currently imple - **Networking**: Integrated with Open vSwitch (OVS) for true SDN. - **Backend Selection**: Set via `COMPUTE_BACKEND` environment variable (`docker` or `libvirt`). -- **Lifecycle**: The `InstanceService` manages the backend API to Create, Start, Stop, Resize, and Remove instances. +- **Lifecycle**: The `InstanceService` manages the backend API to Create, Start, Stop, Pause, Resume, Resize, and Remove instances. +- **Instance States**: Instances transition through states: `INITIALIZING` → `RUNNING` → `STOPPED` / `PAUSED` → `DELETED`. The `PAUSED` state freezes CPU while retaining memory and network connections. +- **Pause/Resume**: RUNNING instances can be paused (via `DomainSuspend` for Libvirt, `ContainerPause` for Docker) and later resumed. State transitions are validated to ensure proper ordering. - **Instance Metadata & Labels**: Support for arbitrary key-value pairs assigned to instances for organization and filtering. - **Cloud-Init (Docker Simulation)**: Simulates Cloud-Init configuration injection in containers (SSH keys, script execution). - **Self-Healing**: Automated background worker that detects instances in `ERROR` state and attempts recovery via restart. +- **Resilient Compute**: Backend operations are wrapped with circuit breaker and bulkhead patterns for fault tolerance. ### 2. Networking (VPC & Elastic IPs) **What it is**: Isolated virtual networks and static public IP addresses. diff --git a/docs/api-reference.md b/docs/api-reference.md index 96188b00c..5c269278e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -263,6 +263,40 @@ Get the VNC console URL for the instance. } ``` +### POST /instances/:id/pause +Pause a running instance (freezes CPU, retains memory/network). + +**Prerequisites:** Instance must be in `RUNNING` state. + +**Response:** +```json +{ + "message": "instance paused" +} +``` + +**Error Responses:** +- `400` — Instance not in RUNNING state (returned as `CONFLICT`) +- `404` — Instance not found +- `403` — Insufficient permissions + +### POST /instances/:id/resume +Resume a paused instance back to running state. + +**Prerequisites:** Instance must be in `PAUSED` state. + +**Response:** +```json +{ + "message": "instance resumed" +} +``` + +**Error Responses:** +- `400` — Instance not in PAUSED state (returned as `CONFLICT`) +- `404` — Instance not found +- `403` — Insufficient permissions + --- ## Images diff --git a/docs/guides/libvirt-backend.md b/docs/guides/libvirt-backend.md index 717590ceb..0f04d1e7c 100644 --- a/docs/guides/libvirt-backend.md +++ b/docs/guides/libvirt-backend.md @@ -442,6 +442,7 @@ Always use virtio for best I/O performance: | **Networking** | Bridge/overlay | NAT/bridge/macvtap | | **Storage** | Overlay2/volumes | QCOW2/raw images | | **Volume Attach/Detach** | Stop→recreate→start cycle | True hot-plug via DomainAttachDevice | +| **Pause/Resume** | ContainerPause/Unpause | DomainSuspend/DomainResume | | **Use Cases** | Microservices, CI/CD | Legacy apps, multi-OS, security | ## Best Practices diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index a1acaa04e..49a8d4108 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -3974,6 +3974,76 @@ const docTemplate = `{ } } }, + "/instances/{id}/pause": { + "post": { + "security": [ + { + "APIKeyAuth": [] + } + ], + "description": "Freezes a running instance (CPU halted, memory/network retained)", + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Pause an instance", + "parameters": [ + { + "type": "string", + "description": "Instance ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "409": { + "description": "Instance not in RUNNING state", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + } + } + } + }, "/instances/{id}/resize": { "post": { "security": [ @@ -4044,6 +4114,76 @@ const docTemplate = `{ } } }, + "/instances/{id}/resume": { + "post": { + "security": [ + { + "APIKeyAuth": [] + } + ], + "description": "Resumes a paused instance back to running state", + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Resume an instance", + "parameters": [ + { + "type": "string", + "description": "Instance ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "409": { + "description": "Instance not in PAUSED state", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + } + } + } + }, "/instances/{id}/stats": { "get": { "security": [ @@ -8934,6 +9074,7 @@ const docTemplate = `{ "RUNNING", "STOPPED", "ERROR", + "PAUSED", "DELETED" ], "x-enum-varnames": [ @@ -8941,6 +9082,7 @@ const docTemplate = `{ "StatusRunning", "StatusStopped", "StatusError", + "StatusPaused", "StatusDeleted" ] }, diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index c9564653b..a4e653353 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -3966,6 +3966,76 @@ } } }, + "/instances/{id}/pause": { + "post": { + "security": [ + { + "APIKeyAuth": [] + } + ], + "description": "Freezes a running instance (CPU halted, memory/network retained)", + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Pause an instance", + "parameters": [ + { + "type": "string", + "description": "Instance ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "409": { + "description": "Instance not in RUNNING state", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + } + } + } + }, "/instances/{id}/resize": { "post": { "security": [ @@ -4036,6 +4106,76 @@ } } }, + "/instances/{id}/resume": { + "post": { + "security": [ + { + "APIKeyAuth": [] + } + ], + "description": "Resumes a paused instance back to running state", + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Resume an instance", + "parameters": [ + { + "type": "string", + "description": "Instance ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "409": { + "description": "Instance not in PAUSED state", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httputil.Response" + } + } + } + } + }, "/instances/{id}/stats": { "get": { "security": [ @@ -8926,6 +9066,7 @@ "RUNNING", "STOPPED", "ERROR", + "PAUSED", "DELETED" ], "x-enum-varnames": [ @@ -8933,6 +9074,7 @@ "StatusRunning", "StatusStopped", "StatusError", + "StatusPaused", "StatusDeleted" ] }, diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 86a80d4f0..5dba3664b 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -676,6 +676,7 @@ definitions: - RUNNING - STOPPED - ERROR + - PAUSED - DELETED type: string x-enum-varnames: @@ -683,6 +684,7 @@ definitions: - StatusRunning - StatusStopped - StatusError + - StatusPaused - StatusDeleted domain.InstanceType: properties: @@ -5007,6 +5009,51 @@ paths: summary: Update instance metadata tags: - instances + /instances/{id}/pause: + post: + description: Freezes a running instance (CPU halted, memory/network retained) + parameters: + - description: Instance ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httputil.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httputil.Response' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httputil.Response' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httputil.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/httputil.Response' + "409": + description: Instance not in RUNNING state + schema: + $ref: '#/definitions/httputil.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httputil.Response' + security: + - APIKeyAuth: [] + summary: Pause an instance + tags: + - instances /instances/{id}/resize: post: consumes: @@ -5052,6 +5099,51 @@ paths: summary: Resize an instance tags: - instances + /instances/{id}/resume: + post: + description: Resumes a paused instance back to running state + parameters: + - description: Instance ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httputil.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httputil.Response' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httputil.Response' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httputil.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/httputil.Response' + "409": + description: Instance not in PAUSED state + schema: + $ref: '#/definitions/httputil.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httputil.Response' + security: + - APIKeyAuth: [] + summary: Resume an instance + tags: + - instances /instances/{id}/stats: get: description: Gets real-time CPU and Memory usage for a compute instance diff --git a/internal/api/setup/router.go b/internal/api/setup/router.go index 07e2fc479..f0af30e49 100644 --- a/internal/api/setup/router.go +++ b/internal/api/setup/router.go @@ -270,6 +270,8 @@ func registerComputeRoutes(r *gin.Engine, handlers *Handlers, svcs *Services) { instanceGroup.GET("", httputil.Permission(svcs.RBAC, domain.PermissionInstanceRead), handlers.Instance.List) instanceGroup.GET("/:id", httputil.Permission(svcs.RBAC, domain.PermissionInstanceRead), handlers.Instance.Get) instanceGroup.POST("/:id/stop", httputil.Permission(svcs.RBAC, domain.PermissionInstanceUpdate), handlers.Instance.Stop) + instanceGroup.POST("/:id/pause", httputil.Permission(svcs.RBAC, domain.PermissionInstanceUpdate), handlers.Instance.Pause) + instanceGroup.POST("/:id/resume", httputil.Permission(svcs.RBAC, domain.PermissionInstanceUpdate), handlers.Instance.Resume) instanceGroup.GET("/:id/logs", httputil.Permission(svcs.RBAC, domain.PermissionInstanceRead), handlers.Instance.GetLogs) instanceGroup.GET("/:id/stats", httputil.Permission(svcs.RBAC, domain.PermissionInstanceRead), handlers.Instance.GetStats) instanceGroup.GET("/:id/console", httputil.Permission(svcs.RBAC, domain.PermissionInstanceRead), handlers.Instance.GetConsole) diff --git a/internal/core/domain/instance.go b/internal/core/domain/instance.go index c87db6a40..e3f30bb1f 100644 --- a/internal/core/domain/instance.go +++ b/internal/core/domain/instance.go @@ -33,6 +33,9 @@ const ( // Check logs for details. Manual intervention may be required. StatusError InstanceStatus = "ERROR" + // StatusPaused indicates the instance is paused (frozen CPU, retained memory/network). + StatusPaused InstanceStatus = "PAUSED" + // StatusDeleted indicates the instance has been permanently removed. // All associated resources have been cleaned up. StatusDeleted InstanceStatus = "DELETED" diff --git a/internal/core/ports/compute.go b/internal/core/ports/compute.go index 981f5d808..5991898c3 100644 --- a/internal/core/ports/compute.go +++ b/internal/core/ports/compute.go @@ -18,6 +18,10 @@ type ComputeBackend interface { StartInstance(ctx context.Context, id string) error // StopInstance gracefully shuts down or forcibly terminates a running instance. StopInstance(ctx context.Context, id string) error + // PauseInstance freezes a running instance (CPU halted, memory/network retained). + PauseInstance(ctx context.Context, id string) error + // ResumeInstance resumes a paused instance back to running state. + ResumeInstance(ctx context.Context, id string) error // DeleteInstance removes an instance and its ephemeral resources. DeleteInstance(ctx context.Context, id string) error // GetInstanceLogs returns a stream of stdout/stderr from the instance. diff --git a/internal/core/ports/instance.go b/internal/core/ports/instance.go index 0b516e624..321040d1b 100644 --- a/internal/core/ports/instance.go +++ b/internal/core/ports/instance.go @@ -58,6 +58,10 @@ type InstanceService interface { StartInstance(ctx context.Context, idOrName string) error // StopInstance gracefully shuts down or halts a running compute resource. StopInstance(ctx context.Context, idOrName string) error + // PauseInstance freezes a running instance (CPU halted, memory/network retained). + PauseInstance(ctx context.Context, idOrName string) error + // ResumeInstance resumes a paused instance back to running state. + ResumeInstance(ctx context.Context, idOrName string) error // ListInstances returns a slice of all compute resources accessible to the caller. ListInstances(ctx context.Context) ([]*domain.Instance, error) // GetInstance retrieves detailed information about a specific compute resource. diff --git a/internal/core/services/function_internal_test.go b/internal/core/services/function_internal_test.go index d42b3f440..2692c8b6e 100644 --- a/internal/core/services/function_internal_test.go +++ b/internal/core/services/function_internal_test.go @@ -62,6 +62,8 @@ func (t *testComputeBackend) ResizeInstance(ctx context.Context, id string, cpu, func (t *testComputeBackend) CreateSnapshot(ctx context.Context, id, name string) error { return nil } func (t *testComputeBackend) RestoreSnapshot(ctx context.Context, id, name string) error { return nil } func (t *testComputeBackend) DeleteSnapshot(ctx context.Context, id, name string) error { return nil } +func (t *testComputeBackend) PauseInstance(ctx context.Context, id string) error { return nil } +func (t *testComputeBackend) ResumeInstance(ctx context.Context, id string) error { return nil } // compile-time check that testComputeBackend satisfies ports.ComputeBackend var _ ports.ComputeBackend = (*testComputeBackend)(nil) diff --git a/internal/core/services/instance.go b/internal/core/services/instance.go index 94f689e28..222aafe23 100644 --- a/internal/core/services/instance.go +++ b/internal/core/services/instance.go @@ -625,6 +625,135 @@ func (s *InstanceService) StopInstance(ctx context.Context, idOrName string) err return nil } +// PauseInstance freezes a running instance (CPU halted, memory/network retained). +func (s *InstanceService) PauseInstance(ctx context.Context, idOrName string) error { + userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) + + if err := s.rbacSvc.Authorize(ctx, userID, tenantID, domain.PermissionInstanceUpdate, idOrName); err != nil { + return err + } + + inst, err := s.GetInstance(ctx, idOrName) + if err != nil { + return err + } + + if inst.Status != domain.StatusRunning { + return errors.New(errors.Conflict, "instance must be RUNNING to pause, got: "+string(inst.Status)) + } + + target := inst.ContainerID + if target == "" { + target = s.formatContainerName(inst.ID) + } + + if err := s.compute.PauseInstance(ctx, target); err != nil { + platform.InstanceOperationsTotal.WithLabelValues("pause", "failure").Inc() + if errors.Is(err, errors.Conflict) { + s.logger.Warn("pause not possible in current state", "container_id", target, "error", err) + return errors.New(errors.Conflict, err.Error()) + } + s.logger.Error("failed to pause container", "container_id", target, "error", err) + return errors.Wrap(errors.Internal, "failed to pause container", err) + } + + oldStatus := inst.Status + inst.Status = domain.StatusPaused + if err := s.repo.Update(ctx, inst); err != nil { + // Best-effort rollback: undo the pause since DB update failed + // Call compensating backend first, then restore DB status + if resumeErr := s.compute.ResumeInstance(ctx, target); resumeErr != nil { + s.logger.Warn("failed to undo pause after repo error", + "instance_id", inst.ID, "resume_error", resumeErr) + } + inst.Status = oldStatus + if rollbackErr := s.repo.Update(ctx, inst); rollbackErr != nil { + s.logger.Warn("failed to rollback pause after repo error", + "instance_id", inst.ID, "pause_error", err, "rollback_error", rollbackErr) + } + return err + } + + if err := s.auditSvc.Log(ctx, inst.UserID, "instance.pause", "instance", inst.ID.String(), map[string]interface{}{ + "name": inst.Name, + }); err != nil { + s.logger.Warn("failed to log audit event", "action", "instance.pause", "instance_id", inst.ID, "error", err) + } + + platform.InstanceOperationsTotal.WithLabelValues("pause", "success").Inc() + s.logger.Info("instance paused", "instance_id", inst.ID) + return nil +} + +// ResumeInstance resumes a paused instance back to running state. +func (s *InstanceService) ResumeInstance(ctx context.Context, idOrName string) error { + userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) + + if err := s.rbacSvc.Authorize(ctx, userID, tenantID, domain.PermissionInstanceUpdate, idOrName); err != nil { + return err + } + + inst, err := s.GetInstance(ctx, idOrName) + if err != nil { + return err + } + + if inst.Status != domain.StatusPaused { + return errors.New(errors.Conflict, "instance must be PAUSED to resume, got: "+string(inst.Status)) + } + + target := inst.ContainerID + if target == "" { + target = s.formatContainerName(inst.ID) + } + + if err := s.compute.ResumeInstance(ctx, target); err != nil { + platform.InstanceOperationsTotal.WithLabelValues("resume", "failure").Inc() + oldStatus := inst.Status + if errors.Is(err, errors.Conflict) { + s.logger.Warn("resume not possible in current state", + "container_id", target, "instance_id", inst.ID, "error", err) + return errors.New(errors.Conflict, err.Error()) + } + s.logger.Error("failed to resume container, instance left in PAUSED state", + "container_id", target, "instance_id", inst.ID, "error", err) + inst.Status = oldStatus + if repoErr := s.repo.Update(ctx, inst); repoErr != nil { + s.logger.Error("failed to persist instance status after resume failure", + "instance_id", inst.ID, "resume_error", err, "persist_error", repoErr) + } + return errors.Wrap(errors.Internal, "failed to resume container", err) + } + + inst.Status = domain.StatusRunning + if err := s.repo.Update(ctx, inst); err != nil { + // Best-effort rollback: undo the resume since DB update failed + // Call compensating backend first, then restore DB status + if pauseErr := s.compute.PauseInstance(ctx, target); pauseErr != nil { + s.logger.Warn("failed to undo resume after repo error", + "instance_id", inst.ID, "pause_error", pauseErr) + } + inst.Status = domain.StatusPaused + if rollbackErr := s.repo.Update(ctx, inst); rollbackErr != nil { + s.logger.Warn("failed to rollback resume after repo error", + "instance_id", inst.ID, "resume_error", err, "rollback_error", rollbackErr) + } + return err + } + + if err := s.auditSvc.Log(ctx, inst.UserID, "instance.resume", "instance", inst.ID.String(), map[string]interface{}{ + "name": inst.Name, + }); err != nil { + s.logger.Warn("failed to log audit event", "action", "instance.resume", "instance_id", inst.ID, "error", err) + } + + platform.InstanceOperationsTotal.WithLabelValues("resume", "success").Inc() + s.logger.Info("instance resumed", "instance_id", inst.ID) + return nil +} + // ListInstances returns all instances owned by the current user. func (s *InstanceService) ListInstances(ctx context.Context) ([]*domain.Instance, error) { userID := appcontext.UserIDFromContext(ctx) diff --git a/internal/core/services/instance_unit_test.go b/internal/core/services/instance_unit_test.go index c91df85ea..45c92f187 100644 --- a/internal/core/services/instance_unit_test.go +++ b/internal/core/services/instance_unit_test.go @@ -81,6 +81,7 @@ func TestInstanceService_Unit(t *testing.T) { t.Run("ProvisionFinalize", testInstanceServiceProvisionFinalize) t.Run("Terminate", testInstanceServiceTerminateUnit) t.Run("VolumeRelease", testInstanceServiceVolumeReleaseUnit) + t.Run("PauseResume", testInstanceServicePauseResumeUnit) t.Run("RBACErrors", testInstanceServiceUnitRbacErrors) t.Run("RepoErrors", testInstanceServiceUnitRepoErrors) t.Run("ResizeInstance", testInstanceServiceResizeInstanceUnit) @@ -300,7 +301,7 @@ func testInstanceServiceLifecycleUnit(t *testing.T) { InstanceType: "t2.micro", } typeRepo.On("GetByID", mock.Anything, "t2.micro").Return(&domain.InstanceType{VCPUs: 1, MemoryMB: 1024}, nil).Maybe() - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceTerminate, instanceID.String()).Return(nil).Once() compute.On("DeleteInstance", mock.Anything, "cid-1").Return(nil).Once() @@ -341,7 +342,7 @@ func testInstanceServiceExecUnit(t *testing.T) { t.Run("NotRunning", func(t *testing.T) { inst := &domain.Instance{ID: instanceID, UserID: userID, TenantID: tenantID, Status: domain.StatusStopped, ContainerID: ""} - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() @@ -352,7 +353,7 @@ func testInstanceServiceExecUnit(t *testing.T) { t.Run("BackendError", func(t *testing.T) { inst := &domain.Instance{ID: instanceID, UserID: userID, TenantID: tenantID, Status: domain.StatusRunning, ContainerID: "cid-1"} - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() compute.On("Exec", mock.Anything, "cid-1", []string{"ls"}).Return("", errors.New("exec failed")).Once() @@ -672,7 +673,7 @@ func testInstanceServiceTerminateUnit(t *testing.T) { VpcID: &vpcID, } - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceTerminate, instanceID.String()).Return(nil).Once() compute.On("GetInstanceLogs", mock.Anything, "cid-1").Return(io.NopCloser(strings.NewReader("log line 1\nlog line 2\n")), nil).Once() @@ -727,7 +728,7 @@ func testInstanceServiceTerminateUnit(t *testing.T) { ContainerID: "cid-1", } - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceTerminate, instanceID.String()).Return(nil).Once() compute.On("DeleteInstance", mock.Anything, "cid-1").Return(fmt.Errorf("docker error")).Once() @@ -777,7 +778,7 @@ func testInstanceServiceTerminateUnit(t *testing.T) { InstanceType: "unknown-type", } - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceTerminate, instanceID.String()).Return(nil).Once() compute.On("DeleteInstance", mock.Anything, "cid-1").Return(nil).Once() @@ -832,7 +833,7 @@ func testInstanceServiceTerminateUnit(t *testing.T) { InstanceType: "t2.micro", } - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceTerminate, instanceID.String()).Return(nil).Once() compute.On("DeleteInstance", mock.Anything, "cid-1").Return(nil).Once() @@ -892,7 +893,7 @@ func testInstanceServiceVolumeReleaseUnit(t *testing.T) { InstanceType: "t2.micro", } - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceTerminate, instanceID.String()).Return(nil).Once() compute.On("DeleteInstance", mock.Anything, "cid-1").Return(nil).Once() @@ -952,7 +953,7 @@ func testInstanceServiceVolumeReleaseUnit(t *testing.T) { InstanceType: "t2.micro", } - repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Once() + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceTerminate, instanceID.String()).Return(nil).Once() compute.On("DeleteInstance", mock.Anything, "cid-1").Return(nil).Once() @@ -978,6 +979,250 @@ func testInstanceServiceVolumeReleaseUnit(t *testing.T) { }) } +func testInstanceServicePauseResumeUnit(t *testing.T) { + ctx := context.Background() + instanceID := uuid.New() + userID := uuid.New() + tenantID := uuid.New() + ctx = appcontext.WithUserID(ctx, userID) + ctx = appcontext.WithTenantID(ctx, tenantID) + + type pauseResumeCase struct { + name string + method func(*services.InstanceService) func(context.Context, string) error + setup func(*domain.Instance, *MockInstanceRepo, *MockComputeBackend, *MockRBACService, *MockAuditService) + assert func(*testing.T, error, *domain.Instance) + } + + pauseCases := []pauseResumeCase{ + { + name: "PauseInstance_Success", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.PauseInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusRunning + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + compute.On("PauseInstance", mock.Anything, "cid-1").Return(nil).Once() + compute.On("Type").Return("docker").Maybe() + repo.On("Update", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { return i.Status == domain.StatusPaused })).Return(nil).Once() + auditSvc.On("Log", mock.Anything, userID, "instance.pause", "instance", instanceID.String(), mock.Anything).Return(nil).Once() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.NoError(t, err) + assert.Equal(t, domain.StatusPaused, inst.Status) + }, + }, + { + name: "PauseInstance_WrongState", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.PauseInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusPaused + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), "must be RUNNING to pause") + }, + }, + { + name: "PauseInstance_ComputeError", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.PauseInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusRunning + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + compute.On("PauseInstance", mock.Anything, "cid-1").Return(fmt.Errorf("pause failed")).Once() + compute.On("Type").Return("docker").Maybe() + auditSvc.On("Log", mock.Anything, userID, "instance.pause", "instance", instanceID.String(), mock.Anything).Return(nil).Maybe() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), "pause failed") + }, + }, + { + name: "PauseInstance_RepoError_Rollback", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.PauseInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusRunning + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + compute.On("PauseInstance", mock.Anything, "cid-1").Return(nil).Once() + compute.On("Type").Return("docker").Maybe() + // First Update (to PAUSED) fails + repo.On("Update", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { return i.Status == domain.StatusPaused })).Return(fmt.Errorf("db error")).Once() + // Rollback: backend undo first, then repo status restore + compute.On("ResumeInstance", mock.Anything, "cid-1").Return(nil).Once() + repo.On("Update", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { return i.Status == domain.StatusRunning })).Return(nil).Once() + auditSvc.On("Log", mock.Anything, userID, "instance.pause", "instance", instanceID.String(), mock.Anything).Return(nil).Maybe() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), "db error") + assert.Equal(t, domain.StatusRunning, inst.Status) + }, + }, + } + + resumeCases := []pauseResumeCase{ + { + name: "ResumeInstance_Success", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.ResumeInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusPaused + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + compute.On("ResumeInstance", mock.Anything, "cid-1").Return(nil).Once() + compute.On("Type").Return("docker").Maybe() + repo.On("Update", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { return i.Status == domain.StatusRunning })).Return(nil).Once() + auditSvc.On("Log", mock.Anything, userID, "instance.resume", "instance", instanceID.String(), mock.Anything).Return(nil).Once() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.NoError(t, err) + assert.Equal(t, domain.StatusRunning, inst.Status) + }, + }, + { + name: "ResumeInstance_WrongState", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.ResumeInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusRunning + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), "must be PAUSED to resume") + }, + }, + { + name: "ResumeInstance_ComputeError", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.ResumeInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusPaused + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + compute.On("ResumeInstance", mock.Anything, "cid-1").Return(fmt.Errorf("resume failed")).Once() + compute.On("Type").Return("docker").Maybe() + repo.On("Update", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { return i.Status == domain.StatusPaused })).Return(nil).Once() + auditSvc.On("Log", mock.Anything, userID, "instance.resume", "instance", instanceID.String(), mock.Anything).Return(nil).Maybe() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), "resume failed") + }, + }, + { + name: "ResumeInstance_ConflictError", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.ResumeInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusPaused + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + compute.On("ResumeInstance", mock.Anything, "cid-1").Return(svcerrors.ErrInstanceNotResumable).Once() + compute.On("Type").Return("docker").Maybe() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), "instance cannot be resumed") + assert.True(t, svcerrors.Is(err, svcerrors.Conflict)) + }, + }, + { + name: "ResumeInstance_RepoError_Rollback", + method: func(svc *services.InstanceService) func(context.Context, string) error { return svc.ResumeInstance }, + setup: func(inst *domain.Instance, repo *MockInstanceRepo, compute *MockComputeBackend, rbacSvc *MockRBACService, auditSvc *MockAuditService) { + inst.Status = domain.StatusPaused + repo.On("GetByName", mock.Anything, instanceID.String()).Return(nil, fmt.Errorf("not found")).Maybe() + repo.On("GetByID", mock.Anything, instanceID).Return(inst, nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceUpdate, instanceID.String()).Return(nil).Once() + rbacSvc.On("Authorize", mock.Anything, userID, tenantID, domain.PermissionInstanceRead, instanceID.String()).Return(nil).Once() + compute.On("ResumeInstance", mock.Anything, "cid-1").Return(nil).Once() + compute.On("Type").Return("docker").Maybe() + // First Update (to RUNNING) fails + repo.On("Update", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { return i.Status == domain.StatusRunning })).Return(fmt.Errorf("db error")).Once() + // Rollback: backend undo first, then repo status restore + compute.On("PauseInstance", mock.Anything, "cid-1").Return(nil).Once() + repo.On("Update", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { return i.Status == domain.StatusPaused })).Return(nil).Once() + auditSvc.On("Log", mock.Anything, userID, "instance.resume", "instance", instanceID.String(), mock.Anything).Return(nil).Maybe() + }, + assert: func(t *testing.T, err error, inst *domain.Instance) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), "db error") + assert.Equal(t, domain.StatusPaused, inst.Status) + }, + }, + } + + for _, tc := range pauseCases { + t.Run(tc.name, func(t *testing.T) { + repo := new(MockInstanceRepo) + compute := new(MockComputeBackend) + rbacSvc := new(MockRBACService) + auditSvc := new(MockAuditService) + svc := services.NewInstanceService(services.InstanceServiceParams{ + Repo: repo, + Compute: compute, + RBAC: rbacSvc, + AuditSvc: auditSvc, + Logger: slog.Default(), + }) + inst := &domain.Instance{ID: instanceID, UserID: userID, TenantID: tenantID, Status: domain.StatusRunning, ContainerID: "cid-1"} + tc.setup(inst, repo, compute, rbacSvc, auditSvc) + err := tc.method(svc)(ctx, instanceID.String()) + tc.assert(t, err, inst) + mock.AssertExpectationsForObjects(t, repo, compute, rbacSvc, auditSvc) + }) + } + + for _, tc := range resumeCases { + t.Run(tc.name, func(t *testing.T) { + repo := new(MockInstanceRepo) + compute := new(MockComputeBackend) + rbacSvc := new(MockRBACService) + auditSvc := new(MockAuditService) + svc := services.NewInstanceService(services.InstanceServiceParams{ + Repo: repo, + Compute: compute, + RBAC: rbacSvc, + AuditSvc: auditSvc, + Logger: slog.Default(), + }) + inst := &domain.Instance{ID: instanceID, UserID: userID, TenantID: tenantID, Status: domain.StatusPaused, ContainerID: "cid-1"} + tc.setup(inst, repo, compute, rbacSvc, auditSvc) + err := tc.method(svc)(ctx, instanceID.String()) + tc.assert(t, err, inst) + mock.AssertExpectationsForObjects(t, repo, compute, rbacSvc, auditSvc) + }) + } +} + func testInstanceServiceUnitRbacErrors(t *testing.T) { repo := new(MockInstanceRepo) vpcRepo := new(MockVpcRepo) diff --git a/internal/core/services/mock_compute_test.go b/internal/core/services/mock_compute_test.go index c20a83a64..9beb72ca2 100644 --- a/internal/core/services/mock_compute_test.go +++ b/internal/core/services/mock_compute_test.go @@ -85,6 +85,12 @@ func (m *MockInstanceService) StartInstance(ctx context.Context, idOrName string func (m *MockInstanceService) StopInstance(ctx context.Context, idOrName string) error { return m.Called(ctx, idOrName).Error(0) } +func (m *MockInstanceService) PauseInstance(ctx context.Context, idOrName string) error { + return m.Called(ctx, idOrName).Error(0) +} +func (m *MockInstanceService) ResumeInstance(ctx context.Context, idOrName string) error { + return m.Called(ctx, idOrName).Error(0) +} func (m *MockInstanceService) ListInstances(ctx context.Context) ([]*domain.Instance, error) { args := m.Called(ctx) r0, _ := args.Get(0).([]*domain.Instance) @@ -143,6 +149,12 @@ func (m *MockComputeBackend) StartInstance(ctx context.Context, id string) error func (m *MockComputeBackend) StopInstance(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } +func (m *MockComputeBackend) PauseInstance(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} +func (m *MockComputeBackend) ResumeInstance(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} func (m *MockComputeBackend) GetInstanceLogs(ctx context.Context, id string) (io.ReadCloser, error) { args := m.Called(ctx, id) if args.Get(0) == nil { diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 2e6831002..3fbefedab 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -99,3 +99,9 @@ var ( ErrLBTargetExists = New(LBTargetExists, "target already registered") ErrLBCrossVPC = New(LBCrossVPC, "target must be in same VPC as LB") ) + +// Instance sentinel errors for state-based failures. +var ( + ErrInstanceNotPausable = New(Conflict, "instance cannot be paused in current state") + ErrInstanceNotResumable = New(Conflict, "instance cannot be resumed in current state") +) diff --git a/internal/handlers/instance_handler.go b/internal/handlers/instance_handler.go index 643bd6be2..5e8c5cf4d 100644 --- a/internal/handlers/instance_handler.go +++ b/internal/handlers/instance_handler.go @@ -244,6 +244,66 @@ func (h *InstanceHandler) Stop(c *gin.Context) { httputil.Success(c, http.StatusOK, gin.H{"message": "instance stop initiated"}) } +// Pause pauses a running instance +// @Summary Pause an instance +// @Description Freezes a running instance (CPU halted, memory/network retained) +// @Tags instances +// @Produce json +// @Security APIKeyAuth +// @Param id path string true "Instance ID" +// @Success 200 {object} httputil.Response +// @Failure 400 {object} httputil.Response +// @Failure 401 {object} httputil.Response +// @Failure 403 {object} httputil.Response +// @Failure 404 {object} httputil.Response +// @Failure 409 {object} httputil.Response "Instance not in RUNNING state" +// @Failure 500 {object} httputil.Response +// @Router /instances/{id}/pause [post] +func (h *InstanceHandler) Pause(c *gin.Context) { + id := c.Param("id") + if id == "" { + httputil.Error(c, errors.New(errors.InvalidInput, "id is required")) + return + } + + if err := h.svc.PauseInstance(c.Request.Context(), id); err != nil { + httputil.Error(c, err) + return + } + + httputil.Success(c, http.StatusOK, gin.H{"message": "instance paused"}) +} + +// Resume resumes a paused instance +// @Summary Resume an instance +// @Description Resumes a paused instance back to running state +// @Tags instances +// @Produce json +// @Security APIKeyAuth +// @Param id path string true "Instance ID" +// @Success 200 {object} httputil.Response +// @Failure 400 {object} httputil.Response +// @Failure 401 {object} httputil.Response +// @Failure 403 {object} httputil.Response +// @Failure 404 {object} httputil.Response +// @Failure 409 {object} httputil.Response "Instance not in PAUSED state" +// @Failure 500 {object} httputil.Response +// @Router /instances/{id}/resume [post] +func (h *InstanceHandler) Resume(c *gin.Context) { + id := c.Param("id") + if id == "" { + httputil.Error(c, errors.New(errors.InvalidInput, "id is required")) + return + } + + if err := h.svc.ResumeInstance(c.Request.Context(), id); err != nil { + httputil.Error(c, err) + return + } + + httputil.Success(c, http.StatusOK, gin.H{"message": "instance resumed"}) +} + // GetLogs returns instance logs // @Summary Get instance logs // @Description Gets the console output logs for a compute instance diff --git a/internal/handlers/instance_handler_test.go b/internal/handlers/instance_handler_test.go index f728ab698..1a0272e03 100644 --- a/internal/handlers/instance_handler_test.go +++ b/internal/handlers/instance_handler_test.go @@ -60,6 +60,12 @@ func (m *instanceServiceMock) StartInstance(ctx context.Context, idOrName string func (m *instanceServiceMock) StopInstance(ctx context.Context, idOrName string) error { return m.Called(ctx, idOrName).Error(0) } +func (m *instanceServiceMock) PauseInstance(ctx context.Context, idOrName string) error { + return m.Called(ctx, idOrName).Error(0) +} +func (m *instanceServiceMock) ResumeInstance(ctx context.Context, idOrName string) error { + return m.Called(ctx, idOrName).Error(0) +} func (m *instanceServiceMock) ListInstances(ctx context.Context) ([]*domain.Instance, error) { args := m.Called(ctx) @@ -324,6 +330,108 @@ func TestInstanceHandlerTerminateNotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) } +func TestInstanceHandlerPause(t *testing.T) { + t.Parallel() + mockSvc, handler, r := setupInstanceHandlerTest(t) + defer mockSvc.AssertExpectations(t) + r.POST(instancesPath+"/:id/pause", handler.Pause) + + id := uuid.New().String() + mockSvc.On("PauseInstance", mock.Anything, id).Return(nil) + + req := httptest.NewRequest(http.MethodPost, instancesPath+"/"+id+"/pause", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestInstanceHandlerPauseNotFound(t *testing.T) { + t.Parallel() + mockSvc, handler, r := setupInstanceHandlerTest(t) + defer mockSvc.AssertExpectations(t) + r.POST(instancesPath+"/:id/pause", handler.Pause) + + id := uuid.New().String() + mockSvc.On("PauseInstance", mock.Anything, id).Return(errors.New(errors.NotFound, "not found")) + + req := httptest.NewRequest(http.MethodPost, instancesPath+"/"+id+"/pause", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestInstanceHandlerPauseConflict(t *testing.T) { + t.Parallel() + mockSvc, handler, r := setupInstanceHandlerTest(t) + defer mockSvc.AssertExpectations(t) + r.POST(instancesPath+"/:id/pause", handler.Pause) + + id := uuid.New().String() + mockSvc.On("PauseInstance", mock.Anything, id).Return(errors.New(errors.Conflict, "instance not in RUNNING state")) + + req := httptest.NewRequest(http.MethodPost, instancesPath+"/"+id+"/pause", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestInstanceHandlerResume(t *testing.T) { + t.Parallel() + mockSvc, handler, r := setupInstanceHandlerTest(t) + defer mockSvc.AssertExpectations(t) + r.POST(instancesPath+"/:id/resume", handler.Resume) + + id := uuid.New().String() + mockSvc.On("ResumeInstance", mock.Anything, id).Return(nil) + + req := httptest.NewRequest(http.MethodPost, instancesPath+"/"+id+"/resume", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestInstanceHandlerResumeNotFound(t *testing.T) { + t.Parallel() + mockSvc, handler, r := setupInstanceHandlerTest(t) + defer mockSvc.AssertExpectations(t) + r.POST(instancesPath+"/:id/resume", handler.Resume) + + id := uuid.New().String() + mockSvc.On("ResumeInstance", mock.Anything, id).Return(errors.New(errors.NotFound, "not found")) + + req := httptest.NewRequest(http.MethodPost, instancesPath+"/"+id+"/resume", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestInstanceHandlerResumeConflict(t *testing.T) { + t.Parallel() + mockSvc, handler, r := setupInstanceHandlerTest(t) + defer mockSvc.AssertExpectations(t) + r.POST(instancesPath+"/:id/resume", handler.Resume) + + id := uuid.New().String() + mockSvc.On("ResumeInstance", mock.Anything, id).Return(errors.New(errors.Conflict, "instance not in PAUSED state")) + + req := httptest.NewRequest(http.MethodPost, instancesPath+"/"+id+"/resume", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + func TestInstanceHandlerGetLogs(t *testing.T) { t.Parallel() mockSvc, handler, r := setupInstanceHandlerTest(t) diff --git a/internal/platform/bulkhead.go b/internal/platform/bulkhead.go index 033f5ee8f..aff07d962 100644 --- a/internal/platform/bulkhead.go +++ b/internal/platform/bulkhead.go @@ -6,9 +6,10 @@ import ( "time" ) -// ErrBulkheadFull is returned when the bulkhead's concurrency limit is reached -// and the caller's timeout/context expires before a slot opens. -var ErrBulkheadFull = errors.New("bulkhead: concurrency limit reached") +// ErrBulkheadFull is returned only when the bulkhead's concurrency limit is reached +// and the caller's wait timeout expires before a slot opens. +// Context cancellation or expiry returns ctx.Err() instead of ErrBulkheadFull. +var ErrBulkheadFull = errors.New("bulkhead: concurrency limit reached, wait timeout") // Bulkhead limits concurrent access to a resource using a semaphore pattern. // It prevents one slow/failing component from consuming all available goroutines @@ -31,6 +32,9 @@ func NewBulkhead(opts BulkheadOpts) *Bulkhead { if opts.MaxConc <= 0 { opts.MaxConc = 10 } + if opts.WaitTimeout == 0 { + opts.WaitTimeout = 5 * time.Second + } return &Bulkhead{ name: opts.Name, sem: make(chan struct{}, opts.MaxConc), @@ -50,10 +54,8 @@ func (b *Bulkhead) Execute(ctx context.Context, fn func() error) error { } func (b *Bulkhead) acquire(ctx context.Context) error { - select { - case <-ctx.Done(): - return ErrBulkheadFull - default: + if ctx.Err() != nil { + return ctx.Err() } if b.timeout > 0 { @@ -65,7 +67,7 @@ func (b *Bulkhead) acquire(ctx context.Context) error { case <-timer.C: return ErrBulkheadFull case <-ctx.Done(): - return ErrBulkheadFull + return ctx.Err() } } // No explicit timeout — rely on context. @@ -73,7 +75,7 @@ func (b *Bulkhead) acquire(ctx context.Context) error { case b.sem <- struct{}{}: return nil case <-ctx.Done(): - return ErrBulkheadFull + return ctx.Err() } } diff --git a/internal/platform/bulkhead_test.go b/internal/platform/bulkhead_test.go index c7ccdfc5c..80c83378f 100644 --- a/internal/platform/bulkhead_test.go +++ b/internal/platform/bulkhead_test.go @@ -85,7 +85,7 @@ func TestBulkheadRespectsContext(t *testing.T) { defer cancel() err := bh.Execute(ctx, func() error { return nil }) - require.ErrorIs(t, err, ErrBulkheadFull) + require.ErrorIs(t, err, context.DeadlineExceeded) close(done) } diff --git a/internal/platform/resilient_compute.go b/internal/platform/resilient_compute.go index 1b014c02d..6cf33d829 100644 --- a/internal/platform/resilient_compute.go +++ b/internal/platform/resilient_compute.go @@ -302,6 +302,18 @@ func (r *ResilientCompute) Ping(ctx context.Context) error { }) } +func (r *ResilientCompute) PauseInstance(ctx context.Context, id string) error { + return r.callProtected(ctx, r.opts.CallTimeout, func(ctx context.Context) error { + return r.inner.PauseInstance(ctx, id) + }) +} + +func (r *ResilientCompute) ResumeInstance(ctx context.Context, id string) error { + return r.callProtected(ctx, r.opts.CallTimeout, func(ctx context.Context) error { + return r.inner.ResumeInstance(ctx, id) + }) +} + // Type delegates directly — no protection needed. func (r *ResilientCompute) Type() string { return r.inner.Type() diff --git a/internal/platform/resilient_compute_test.go b/internal/platform/resilient_compute_test.go index b976c83ae..18e1f7115 100644 --- a/internal/platform/resilient_compute_test.go +++ b/internal/platform/resilient_compute_test.go @@ -126,6 +126,8 @@ func (m *mockCompute) DeleteSnapshot(_ context.Context, _, _ string) error { return m.err } func (m *mockCompute) Type() string { return "mock" } +func (m *mockCompute) PauseInstance(_ context.Context, _ string) error { return nil } +func (m *mockCompute) ResumeInstance(_ context.Context, _ string) error { return nil } // ---------- tests ---------- diff --git a/internal/repositories/docker/adapter.go b/internal/repositories/docker/adapter.go index 50ef93f30..29c9c0092 100644 --- a/internal/repositories/docker/adapter.go +++ b/internal/repositories/docker/adapter.go @@ -71,6 +71,8 @@ type dockerClient interface { ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (container.CreateResponse, error) ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error ContainerStop(ctx context.Context, containerID string, options container.StopOptions) error + ContainerPause(ctx context.Context, containerID string) error + ContainerUnpause(ctx context.Context, containerID string) error ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error ContainerLogs(ctx context.Context, containerID string, options container.LogsOptions) (io.ReadCloser, error) ContainerStats(ctx context.Context, containerID string, stream bool) (container.StatsResponseReader, error) @@ -84,7 +86,7 @@ type dockerClient interface { ContainerExecStart(ctx context.Context, execID string, config container.ExecStartOptions) error ContainerExecAttach(ctx context.Context, execID string, config container.ExecStartOptions) (types.HijackedResponse, error) ContainerExecInspect(ctx context.Context, execID string) (container.ExecInspect, error) - ContainerRename(ctx context.Context, containerID string, newName string) error + ContainerRename(ctx context.Context, containerID string, newName string) error ContainerUpdate(ctx context.Context, containerID string, updateConfig container.UpdateConfig) (container.UpdateResponse, error) } @@ -408,6 +410,20 @@ func (a *DockerAdapter) StopInstance(ctx context.Context, name string) error { return nil } +func (a *DockerAdapter) PauseInstance(ctx context.Context, name string) error { + if err := a.cli.ContainerPause(ctx, name); err != nil { + return fmt.Errorf("failed to pause container %s: %w", name, err) + } + return nil +} + +func (a *DockerAdapter) ResumeInstance(ctx context.Context, name string) error { + if err := a.cli.ContainerUnpause(ctx, name); err != nil { + return fmt.Errorf("failed to resume container %s: %w", name, err) + } + return nil +} + func (a *DockerAdapter) DeleteInstance(ctx context.Context, containerID string) error { err := a.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}) if err != nil { diff --git a/internal/repositories/docker/fakes_test.go b/internal/repositories/docker/fakes_test.go index d0af77120..de7542470 100644 --- a/internal/repositories/docker/fakes_test.go +++ b/internal/repositories/docker/fakes_test.go @@ -98,6 +98,14 @@ func (f *fakeDockerClient) ContainerStop(ctx context.Context, containerID string return f.stopErr } +func (f *fakeDockerClient) ContainerPause(ctx context.Context, containerID string) error { + return nil +} + +func (f *fakeDockerClient) ContainerUnpause(ctx context.Context, containerID string) error { + return nil +} + func (f *fakeDockerClient) ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error { return f.removeErr } diff --git a/internal/repositories/firecracker/adapter.go b/internal/repositories/firecracker/adapter.go index 7e7bbf572..0aeda7537 100644 --- a/internal/repositories/firecracker/adapter.go +++ b/internal/repositories/firecracker/adapter.go @@ -149,6 +149,20 @@ func (a *FirecrackerAdapter) StopInstance(ctx context.Context, id string) error return m.Shutdown(ctx) } +func (a *FirecrackerAdapter) PauseInstance(ctx context.Context, id string) error { + if a.cfg.MockMode { + return nil + } + return nil // Firecracker does not support pause/resume +} + +func (a *FirecrackerAdapter) ResumeInstance(ctx context.Context, id string) error { + if a.cfg.MockMode { + return nil + } + return nil // Firecracker does not support pause/resume +} + func (a *FirecrackerAdapter) DeleteInstance(ctx context.Context, id string) error { if !idRegex.MatchString(id) { return fmt.Errorf("invalid instance ID format: %s", id) diff --git a/internal/repositories/firecracker/adapter_noop.go b/internal/repositories/firecracker/adapter_noop.go index 00d752028..80ca91595 100644 --- a/internal/repositories/firecracker/adapter_noop.go +++ b/internal/repositories/firecracker/adapter_noop.go @@ -42,6 +42,14 @@ func (a *FirecrackerAdapter) StopInstance(ctx context.Context, id string) error return fmt.Errorf("firecracker not supported on this platform") } +func (a *FirecrackerAdapter) PauseInstance(ctx context.Context, id string) error { + return fmt.Errorf("firecracker not supported on this platform") +} + +func (a *FirecrackerAdapter) ResumeInstance(ctx context.Context, id string) error { + return fmt.Errorf("firecracker not supported on this platform") +} + func (a *FirecrackerAdapter) DeleteInstance(ctx context.Context, id string) error { return nil } diff --git a/internal/repositories/k8s/kubeadm_provisioner_test.go b/internal/repositories/k8s/kubeadm_provisioner_test.go index 4538e1ba6..5bea80b70 100644 --- a/internal/repositories/k8s/kubeadm_provisioner_test.go +++ b/internal/repositories/k8s/kubeadm_provisioner_test.go @@ -49,6 +49,12 @@ func (m *MockInstanceService) StartInstance(ctx context.Context, id string) erro func (m *MockInstanceService) StopInstance(ctx context.Context, id string) error { return nil } +func (m *MockInstanceService) PauseInstance(ctx context.Context, id string) error { + return nil +} +func (m *MockInstanceService) ResumeInstance(ctx context.Context, id string) error { + return nil +} func (m *MockInstanceService) ListInstances(ctx context.Context) ([]*domain.Instance, error) { args := m.Called(ctx) if args.Get(0) == nil { diff --git a/internal/repositories/k8s/mocks_test.go b/internal/repositories/k8s/mocks_test.go index 316669f7f..fccade546 100644 --- a/internal/repositories/k8s/mocks_test.go +++ b/internal/repositories/k8s/mocks_test.go @@ -32,6 +32,8 @@ func (m *mockInstanceService) LaunchInstanceWithOptions(ctx context.Context, opt } func (m *mockInstanceService) StartInstance(ctx context.Context, idOrName string) error { return nil } func (m *mockInstanceService) StopInstance(ctx context.Context, idOrName string) error { return nil } +func (m *mockInstanceService) PauseInstance(ctx context.Context, idOrName string) error { return nil } +func (m *mockInstanceService) ResumeInstance(ctx context.Context, idOrName string) error { return nil } func (m *mockInstanceService) ListInstances(ctx context.Context) ([]*domain.Instance, error) { args := m.Called(ctx) if args.Get(0) == nil { diff --git a/internal/repositories/libvirt/adapter.go b/internal/repositories/libvirt/adapter.go index 89117a1d4..a320efc35 100644 --- a/internal/repositories/libvirt/adapter.go +++ b/internal/repositories/libvirt/adapter.go @@ -24,6 +24,7 @@ import ( "github.com/digitalocean/go-libvirt" "github.com/google/uuid" + apierrors "github.com/poyrazk/thecloud/internal/errors" "github.com/poyrazk/thecloud/internal/core/ports" ) @@ -39,6 +40,7 @@ const ( // Domain states domainStateRunning = 1 + domainStatePaused = 3 domainStateShutoff = 5 // Memory stat tags @@ -46,6 +48,20 @@ const ( memStatTagRSS = 6 ) +// domainStateName returns a human-readable name for a libvirt domain state. +func domainStateName(state int32) string { + switch state { + case domainStateRunning: + return "RUNNING" + case domainStatePaused: + return "PAUSED" + case domainStateShutoff: + return "SHUTOFF" + default: + return fmt.Sprintf("UNKNOWN(%d)", state) + } +} + // LibvirtAdapter implements compute backend operations using libvirt/KVM. type LibvirtAdapter struct { client LibvirtClient @@ -179,6 +195,50 @@ func (a *LibvirtAdapter) Type() string { return "libvirt" } +// PauseInstance suspends a running domain (freezes CPU, retains memory/network). +func (a *LibvirtAdapter) PauseInstance(ctx context.Context, id string) error { + dom, err := a.client.DomainLookupByName(ctx, id) + if err != nil { + return fmt.Errorf(errDomainNotFound, err) + } + + state, _, err := a.client.DomainGetState(ctx, dom, 0) + if err != nil { + return fmt.Errorf("failed to get domain state: %w", err) + } + if state != domainStateRunning { + return fmt.Errorf("%w: domain is %s, must be RUNNING", apierrors.ErrInstanceNotPausable, domainStateName(state)) + } + + if err := a.client.DomainSuspend(ctx, dom); err != nil { + return fmt.Errorf("failed to suspend domain: %w", err) + } + a.logger.Info("domain paused", "domain", id) + return nil +} + +// ResumeInstance resumes a paused domain back to running state. +func (a *LibvirtAdapter) ResumeInstance(ctx context.Context, id string) error { + dom, err := a.client.DomainLookupByName(ctx, id) + if err != nil { + return fmt.Errorf(errDomainNotFound, err) + } + + state, _, err := a.client.DomainGetState(ctx, dom, 0) + if err != nil { + return fmt.Errorf("failed to get domain state: %w", err) + } + if state != domainStatePaused { + return fmt.Errorf("%w: domain is %s, must be PAUSED", apierrors.ErrInstanceNotResumable, domainStateName(state)) + } + + if err := a.client.DomainResume(ctx, dom); err != nil { + return fmt.Errorf("failed to resume domain: %w", err) + } + a.logger.Info("domain resumed", "domain", id) + return nil +} + func (a *LibvirtAdapter) ResizeInstance(ctx context.Context, id string, cpu, memory int64) error { dom, err := a.client.DomainLookupByName(ctx, id) if err != nil { diff --git a/internal/repositories/libvirt/lb_proxy_test.go b/internal/repositories/libvirt/lb_proxy_test.go index a402ed86e..7cbbc599a 100644 --- a/internal/repositories/libvirt/lb_proxy_test.go +++ b/internal/repositories/libvirt/lb_proxy_test.go @@ -26,6 +26,8 @@ func (m *mockCompute) LaunchInstanceWithOptions(ctx context.Context, opts ports. } func (m *mockCompute) StartInstance(ctx context.Context, id string) error { return nil } func (m *mockCompute) StopInstance(ctx context.Context, id string) error { return nil } +func (m *mockCompute) PauseInstance(ctx context.Context, id string) error { return nil } +func (m *mockCompute) ResumeInstance(ctx context.Context, id string) error { return nil } func (m *mockCompute) DeleteInstance(ctx context.Context, id string) error { return nil } func (m *mockCompute) GetInstanceLogs(ctx context.Context, id string) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("")), nil diff --git a/internal/repositories/libvirt/libvirt_client.go b/internal/repositories/libvirt/libvirt_client.go index 3a08f37c0..4ff26d7a3 100644 --- a/internal/repositories/libvirt/libvirt_client.go +++ b/internal/repositories/libvirt/libvirt_client.go @@ -19,6 +19,8 @@ type LibvirtClient interface { DomainDefineXML(ctx context.Context, xml string) (libvirt.Domain, error) DomainCreate(ctx context.Context, dom libvirt.Domain) error DomainDestroy(ctx context.Context, dom libvirt.Domain) error + DomainSuspend(ctx context.Context, dom libvirt.Domain) error + DomainResume(ctx context.Context, dom libvirt.Domain) error DomainUndefine(ctx context.Context, dom libvirt.Domain) error DomainGetState(ctx context.Context, dom libvirt.Domain, flags uint32) (int32, int32, error) DomainGetXMLDesc(ctx context.Context, dom libvirt.Domain, flags libvirt.DomainXMLFlags) (string, error) diff --git a/internal/repositories/libvirt/mock_client_test.go b/internal/repositories/libvirt/mock_client_test.go index f14488790..947e53ea7 100644 --- a/internal/repositories/libvirt/mock_client_test.go +++ b/internal/repositories/libvirt/mock_client_test.go @@ -45,6 +45,14 @@ func (m *MockLibvirtClient) DomainDestroy(ctx context.Context, dom libvirt.Domai return m.Called(ctx, dom).Error(0) } +func (m *MockLibvirtClient) DomainSuspend(ctx context.Context, dom libvirt.Domain) error { + return m.Called(ctx, dom).Error(0) +} + +func (m *MockLibvirtClient) DomainResume(ctx context.Context, dom libvirt.Domain) error { + return m.Called(ctx, dom).Error(0) +} + func (m *MockLibvirtClient) DomainUndefine(ctx context.Context, dom libvirt.Domain) error { return m.Called(ctx, dom).Error(0) } diff --git a/internal/repositories/libvirt/real_client.go b/internal/repositories/libvirt/real_client.go index 7bee9fc0c..a34d3a51e 100644 --- a/internal/repositories/libvirt/real_client.go +++ b/internal/repositories/libvirt/real_client.go @@ -84,6 +84,24 @@ func (r *RealLibvirtClient) DomainDestroy(ctx context.Context, dom libvirt.Domai return r.conn.DomainDestroy(dom) } +func (r *RealLibvirtClient) DomainSuspend(ctx context.Context, dom libvirt.Domain) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + return r.conn.DomainSuspend(dom) +} + +func (r *RealLibvirtClient) DomainResume(ctx context.Context, dom libvirt.Domain) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + return r.conn.DomainResume(dom) +} + func (r *RealLibvirtClient) DomainUndefine(ctx context.Context, dom libvirt.Domain) error { select { case <-ctx.Done(): diff --git a/internal/repositories/noop/adapters.go b/internal/repositories/noop/adapters.go index e0689324b..af1f9cce1 100644 --- a/internal/repositories/noop/adapters.go +++ b/internal/repositories/noop/adapters.go @@ -147,8 +147,10 @@ func (b *NoopComputeBackend) AttachVolume(ctx context.Context, id string, volume func (b *NoopComputeBackend) DetachVolume(ctx context.Context, id string, volumePath string) (string, error) { return "", nil } -func (b *NoopComputeBackend) Ping(ctx context.Context) error { return nil } -func (b *NoopComputeBackend) Type() string { return "noop" } +func (b *NoopComputeBackend) Ping(ctx context.Context) error { return nil } +func (b *NoopComputeBackend) Type() string { return "noop" } +func (b *NoopComputeBackend) PauseInstance(ctx context.Context, id string) error { return nil } +func (b *NoopComputeBackend) ResumeInstance(ctx context.Context, id string) error { return nil } func (b *NoopComputeBackend) ResizeInstance(ctx context.Context, id string, cpu, memory int64) error { return nil } func (b *NoopComputeBackend) CreateSnapshot(ctx context.Context, id, name string) error { return nil } func (b *NoopComputeBackend) RestoreSnapshot(ctx context.Context, id, name string) error { return nil } diff --git a/internal/workers/database_failover_worker_test.go b/internal/workers/database_failover_worker_test.go index 149fd5b38..0a6728d14 100644 --- a/internal/workers/database_failover_worker_test.go +++ b/internal/workers/database_failover_worker_test.go @@ -207,12 +207,18 @@ func (m *mockComputeBackend) DetachVolume(ctx context.Context, id string, volume func (m *mockComputeBackend) Ping(ctx context.Context) error { return m.Called(ctx).Error(0) } -func (m *mockComputeBackend) Type() string { - return "mock" +func (m *mockComputeBackend) PauseInstance(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} +func (m *mockComputeBackend) ResumeInstance(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) } func (m *mockComputeBackend) ResizeInstance(ctx context.Context, id string, cpu, memory int64) error { return m.Called(ctx, id, cpu, memory).Error(0) } +func (m *mockComputeBackend) Type() string { + return "mock" +} func (m *mockComputeBackend) CreateSnapshot(ctx context.Context, id, name string) error { return m.Called(ctx, id, name).Error(0) } diff --git a/internal/workers/healing_worker_test.go b/internal/workers/healing_worker_test.go index b11beb0f9..dde147c02 100644 --- a/internal/workers/healing_worker_test.go +++ b/internal/workers/healing_worker_test.go @@ -140,6 +140,12 @@ func (m *mockInstanceSvc) UpdateInstanceMetadata(ctx context.Context, id uuid.UU func (m *mockInstanceSvc) ResizeInstance(ctx context.Context, idOrName, newInstanceType string) error { return m.Called(ctx, idOrName, newInstanceType).Error(0) } +func (m *mockInstanceSvc) PauseInstance(ctx context.Context, idOrName string) error { + return m.Called(ctx, idOrName).Error(0) +} +func (m *mockInstanceSvc) ResumeInstance(ctx context.Context, idOrName string) error { + return m.Called(ctx, idOrName).Error(0) +} func TestHealingWorker(t *testing.T) { t.Parallel() diff --git a/internal/workers/pipeline_worker_test.go b/internal/workers/pipeline_worker_test.go index 3b305c049..44acf2507 100644 --- a/internal/workers/pipeline_worker_test.go +++ b/internal/workers/pipeline_worker_test.go @@ -161,6 +161,12 @@ func (m *mockComputeBackendExtended) Ping(ctx context.Context) error { func (m *mockComputeBackendExtended) ResizeInstance(ctx context.Context, id string, cpu, memory int64) error { return m.Called(ctx, id, cpu, memory).Error(0) } +func (m *mockComputeBackendExtended) PauseInstance(ctx context.Context, id string) error { + return nil +} +func (m *mockComputeBackendExtended) ResumeInstance(ctx context.Context, id string) error { + return nil +} func (m *mockComputeBackendExtended) CreateSnapshot(ctx context.Context, id, name string) error { return m.Called(ctx, id, name).Error(0) }