Allow forced server deletion
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-04
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Server deletion is currently a password-confirmed soft delete, but active states (`running` and `installing`) are always rejected. That protects against orphaning live runtime work, but it also traps operators when the platform state is stale, the run endpoint is gone, or a lifecycle job cannot complete.
|
||||||
|
|
||||||
|
The platform/run boundary matters here: platform can mark metadata deleted, but it must not invent game-specific stop behavior or directly manage host processes outside plugin-owned lifecycle jobs.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Let an owner or platform administrator force-delete a running or installing instance when they explicitly accept the operational risk.
|
||||||
|
- Keep password confirmation mandatory for normal and forced deletion.
|
||||||
|
- Preserve soft-delete history and current list filtering behavior.
|
||||||
|
- Make forced deletion visible and deliberate in the UI.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Hard-deleting server metadata or historical records.
|
||||||
|
- Killing, stopping, or cleaning up a remote process as part of delete.
|
||||||
|
- Adding run-side force-kill behavior, game-specific cleanup, or deployment target changes.
|
||||||
|
- Allowing non-owners or non-admins to delete servers.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Extend the existing `DELETE /api/v1/server-instances/{id}` JSON body with `force` and `confirmation` fields. The existing route keeps one destructive API surface, while older clients still get the default safe rejection for running/installing instances.
|
||||||
|
- Require `force: true` plus a fixed confirmation phrase for running or installing instances. This separates accidental password-only deletion from intentional cleanup of stuck active instances.
|
||||||
|
- Keep backend enforcement in `DeleteServerInstanceForSession`. The frontend can guide the operator, but the service must remain the source of truth.
|
||||||
|
- Treat forced deletion as metadata-only. The server state becomes `deleted`; run jobs, logs, artifacts, and audit history remain visible through existing historical paths where supported.
|
||||||
|
- Show force confirmation only when the selected instance is running or installing, and keep the delete entry in the existing compact server-card action menu.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] A forced delete can hide a server whose process is still alive. -> Mitigation: confirmation copy states that delete is metadata-only and does not stop the process.
|
||||||
|
- [Risk] Operators may use force instead of stopping cleanly. -> Mitigation: normal delete remains the default for stopped/ready/failed servers; active states require explicit force confirmation.
|
||||||
|
- [Risk] Existing clients may omit the new fields. -> Mitigation: the backend keeps the current rejection unless force confirmation is present.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Operators need a way to remove server instances that are stuck in `running` or `installing` state when the runtime can no longer be stopped cleanly. The current delete safety rule blocks those instances forever, which leaves stale servers in the active management console.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add an explicit forced delete path to the existing server deletion flow.
|
||||||
|
- Preserve owner/platform-admin authorization and current-password confirmation for all server deletion.
|
||||||
|
- Require an additional force confirmation when deleting a running or installing instance.
|
||||||
|
- Keep deletion as a soft delete that marks the server instance `deleted` and preserves history.
|
||||||
|
- Make the UI expose forced deletion only through the existing destructive password confirmation dialog.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `server-deletion`: server instance soft deletion, including authorization, password confirmation, and explicit forced deletion of active/stuck instances.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `platform/` delete DTO, API handler docs, service validation, and tests.
|
||||||
|
- `platform_web/` delete request types, client-side delete dialog, error/confirmation copy, and tests.
|
||||||
|
- No changes to run executor ownership, plugin lifecycle execution, or hard-delete persistence.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Authorized server deletion
|
||||||
|
The system SHALL allow a server instance to be deleted only when the authenticated user is the instance owner or a platform administrator.
|
||||||
|
|
||||||
|
#### Scenario: Owner deletes a server
|
||||||
|
- **WHEN** the instance owner submits a delete request for their server
|
||||||
|
- **THEN** the system SHALL accept the request if all other delete checks pass
|
||||||
|
|
||||||
|
#### Scenario: Non-owner cannot delete
|
||||||
|
- **WHEN** an authenticated user who is neither the owner nor a platform administrator submits a delete request
|
||||||
|
- **THEN** the system SHALL reject the request with forbidden access
|
||||||
|
|
||||||
|
### Requirement: Password confirmation for deletion
|
||||||
|
The system SHALL require the authenticated user to provide their current account password with every server delete request and SHALL reject the request if the password is missing or does not match the current session user.
|
||||||
|
|
||||||
|
#### Scenario: Password mismatch
|
||||||
|
- **WHEN** the authenticated user submits the delete request with an incorrect password
|
||||||
|
- **THEN** the system SHALL reject the request with forbidden access
|
||||||
|
|
||||||
|
#### Scenario: Password required
|
||||||
|
- **WHEN** the authenticated user submits the delete request without a password
|
||||||
|
- **THEN** the system SHALL reject the request as invalid input or forbidden access
|
||||||
|
|
||||||
|
### Requirement: Forced active server deletion
|
||||||
|
The system SHALL reject deletion for a running or installing server instance by default, but SHALL allow the same soft deletion when the authenticated owner or platform administrator also submits an explicit forced-delete confirmation.
|
||||||
|
|
||||||
|
#### Scenario: Running server delete without force is rejected
|
||||||
|
- **WHEN** a delete request targets a running server instance without forced-delete confirmation
|
||||||
|
- **THEN** the system SHALL reject the request and keep the server instance intact
|
||||||
|
|
||||||
|
#### Scenario: Installing server delete without force is rejected
|
||||||
|
- **WHEN** a delete request targets an installing server instance without forced-delete confirmation
|
||||||
|
- **THEN** the system SHALL reject the request and keep the server instance intact
|
||||||
|
|
||||||
|
#### Scenario: Running server force delete marks deleted state
|
||||||
|
- **WHEN** a valid delete request targets a running server instance with forced-delete confirmation
|
||||||
|
- **THEN** the system SHALL mark the server instance as deleted while preserving historical records
|
||||||
|
|
||||||
|
#### Scenario: Installing server force delete marks deleted state
|
||||||
|
- **WHEN** a valid delete request targets an installing server instance with forced-delete confirmation
|
||||||
|
- **THEN** the system SHALL mark the server instance as deleted while preserving historical records
|
||||||
|
|
||||||
|
### Requirement: Soft server removal state
|
||||||
|
The system SHALL mark deleted server instances with the deleted state and preserve historical records rather than hard-deleting metadata.
|
||||||
|
|
||||||
|
#### Scenario: Successful deletion marks deleted state
|
||||||
|
- **WHEN** a valid delete request targets a stopped, ready, failed, or force-confirmed active server instance
|
||||||
|
- **THEN** the system SHALL mark the server instance as deleted and preserve history
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## 1. Backend Force Delete
|
||||||
|
|
||||||
|
- [x] 1.1 Add force-delete fields to server deletion DTO/domain request and route handling.
|
||||||
|
- [x] 1.2 Update service deletion validation so running/installing servers require explicit force confirmation.
|
||||||
|
- [x] 1.3 Add backend service/API tests for forced running and installing deletion plus default rejection.
|
||||||
|
|
||||||
|
## 2. Frontend Confirmation
|
||||||
|
|
||||||
|
- [x] 2.1 Extend the server deletion request type and API usage with force confirmation fields.
|
||||||
|
- [x] 2.2 Update the server list delete dialog to show active-state force warning and confirmation input.
|
||||||
|
- [x] 2.3 Update frontend tests for forced delete payloads and copy.
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
- [x] 3.1 Run OpenSpec strict validation, structure check, and focused backend/frontend tests.
|
||||||
@@ -1305,12 +1305,12 @@ func (h *coreHandlers) serverInstances(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// serverInstanceDetail godoc
|
// serverInstanceDetail godoc
|
||||||
// @Summary Get, update, or delete server instance
|
// @Summary Get, update, or delete server instance
|
||||||
// @Description Returns one server instance by ID, updates safe metadata, or deletes it by marking the instance deleted after safety validation and password confirmation. Delete requests send a JSON body with the current password.
|
// @Description Returns one server instance by ID, updates safe metadata, or deletes it by marking the instance deleted after safety validation and password confirmation. Delete requests send a JSON body with the current password and may include explicit forced-delete confirmation for running or installing instances.
|
||||||
// @Tags server-instances
|
// @Tags server-instances
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Server instance ID"
|
// @Param id path string true "Server instance ID"
|
||||||
// @Param body body dto.ServerInstanceUpdateRequest false "Server metadata update request"
|
// @Param body body dto.ServerDeletionRequest false "Server metadata update or deletion request"
|
||||||
// @Success 204
|
// @Success 204
|
||||||
// @Success 200 {object} dto.ServerInstanceResponse
|
// @Success 200 {object} dto.ServerInstanceResponse
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
@@ -1347,7 +1347,7 @@ func (h *coreHandlers) serverInstanceDetail(w http.ResponseWriter, r *http.Reque
|
|||||||
writeDecodeError(w, err)
|
writeDecodeError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = h.core.DeleteServerInstanceForSession(bearerToken(r), r.PathValue("id"), request.Password)
|
_, err = h.core.DeleteServerInstanceForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -909,6 +909,12 @@ func TestServerInstanceManagementAPI(t *testing.T) {
|
|||||||
if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted {
|
if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted {
|
||||||
t.Fatalf("expected explicit deleted filter to return deleted server, got %+v", deletedList)
|
t.Fatalf("expected explicit deleted filter to return deleted server, got %+v", deletedList)
|
||||||
}
|
}
|
||||||
|
forcedDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local", Force: true, Confirmation: service.ServerDeletionForceConfirmation}), adminSession)
|
||||||
|
assertStatus(t, forcedDelete, http.StatusNoContent)
|
||||||
|
forcedDeleted := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/"+running.ID, adminSession)
|
||||||
|
if forcedDeleted.State != domain.ServerInstanceStateDeleted {
|
||||||
|
t.Fatalf("expected forced deleted running server, got %+v", forcedDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
blank := ""
|
blank := ""
|
||||||
invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-delete", dto.ServerInstanceUpdateRequest{Name: &blank}, adminSession)
|
invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-delete", dto.ServerInstanceUpdateRequest{Name: &blank}, adminSession)
|
||||||
|
|||||||
@@ -816,6 +816,12 @@ type ServerInstanceUpdate struct {
|
|||||||
Name *string
|
Name *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServerDeletionRequest struct {
|
||||||
|
Password string
|
||||||
|
Force bool
|
||||||
|
Confirmation string
|
||||||
|
}
|
||||||
|
|
||||||
type PlatformResourceUsage struct {
|
type PlatformResourceUsage struct {
|
||||||
CPUPercent float64
|
CPUPercent float64
|
||||||
MemoryPercent float64
|
MemoryPercent float64
|
||||||
|
|||||||
@@ -554,7 +554,9 @@ type ServerInstanceUpdateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerDeletionRequest struct {
|
type ServerDeletionRequest struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Force bool `json:"force,omitempty"`
|
||||||
|
Confirmation string `json:"confirmation,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerMemberRequest struct {
|
type ServerMemberRequest struct {
|
||||||
@@ -1242,6 +1244,10 @@ func (request ServerInstanceUpdateRequest) ToDomain() domain.ServerInstanceUpdat
|
|||||||
return domain.ServerInstanceUpdate{Name: request.Name}
|
return domain.ServerInstanceUpdate{Name: request.Name}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (request ServerDeletionRequest) ToDomain() domain.ServerDeletionRequest {
|
||||||
|
return domain.ServerDeletionRequest{Password: request.Password, Force: request.Force, Confirmation: request.Confirmation}
|
||||||
|
}
|
||||||
|
|
||||||
func (request ServerConfigDiffPreviewRequest) ToDomain(serverInstanceID string) domain.ServerConfigDiffRequest {
|
func (request ServerConfigDiffPreviewRequest) ToDomain(serverInstanceID string) domain.ServerConfigDiffRequest {
|
||||||
return domain.ServerConfigDiffRequest{
|
return domain.ServerConfigDiffRequest{
|
||||||
ServerInstanceID: serverInstanceID,
|
ServerInstanceID: serverInstanceID,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ var (
|
|||||||
ErrForbidden = errors.New("forbidden")
|
ErrForbidden = errors.New("forbidden")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ServerDeletionForceConfirmation = "FORCE DELETE"
|
||||||
|
|
||||||
type ForbiddenError struct {
|
type ForbiddenError struct {
|
||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,7 @@ type Core interface {
|
|||||||
ListServerAdministratorCandidates(string, string) ([]domain.User, error)
|
ListServerAdministratorCandidates(string, string) ([]domain.User, error)
|
||||||
AddServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
AddServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
||||||
RemoveServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
RemoveServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
||||||
DeleteServerInstanceForSession(string, string, string) (domain.ServerInstance, error)
|
DeleteServerInstanceForSession(string, string, domain.ServerDeletionRequest) (domain.ServerInstance, error)
|
||||||
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
|
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
|
||||||
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
||||||
GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error)
|
GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error)
|
||||||
@@ -2367,7 +2369,7 @@ func (svc *CoreService) RemoveServerAdministrator(sessionID string, serverInstan
|
|||||||
return domain.CopyServerInstance(instance), nil
|
return domain.CopyServerInstance(instance), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) DeleteServerInstanceForSession(sessionID string, serverInstanceID string, password string) (domain.ServerInstance, error) {
|
func (svc *CoreService) DeleteServerInstanceForSession(sessionID string, serverInstanceID string, request domain.ServerDeletionRequest) (domain.ServerInstance, error) {
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerInstance{}, err
|
return domain.ServerInstance{}, err
|
||||||
@@ -2379,14 +2381,16 @@ func (svc *CoreService) DeleteServerInstanceForSession(sessionID string, serverI
|
|||||||
if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID {
|
if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID {
|
||||||
return domain.ServerInstance{}, ErrForbidden
|
return domain.ServerInstance{}, ErrForbidden
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(password) == "" {
|
if strings.TrimSpace(request.Password) == "" {
|
||||||
return domain.ServerInstance{}, validationError("password is required")
|
return domain.ServerInstance{}, validationError("password is required")
|
||||||
}
|
}
|
||||||
if !verifyPassword(user.PasswordHash, password) {
|
if !verifyPassword(user.PasswordHash, request.Password) {
|
||||||
return domain.ServerInstance{}, forbiddenError("password confirmation failed")
|
return domain.ServerInstance{}, forbiddenError("password confirmation failed")
|
||||||
}
|
}
|
||||||
if instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateInstalling {
|
if instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateInstalling {
|
||||||
return domain.ServerInstance{}, validationError("running or installing server instances must be stopped before delete")
|
if !request.Force || strings.TrimSpace(request.Confirmation) != ServerDeletionForceConfirmation {
|
||||||
|
return domain.ServerInstance{}, validationError("running or installing server instances require forced-delete confirmation")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if instance.State == domain.ServerInstanceStateDeleted {
|
if instance.State == domain.ServerInstanceStateDeleted {
|
||||||
return domain.CopyServerInstance(instance), nil
|
return domain.CopyServerInstance(instance), nil
|
||||||
|
|||||||
@@ -580,11 +580,21 @@ func TestCoreServiceDeletesServerInstancesWithPasswordConfirmation(t *testing.T)
|
|||||||
if err := svc.store.ServerInstances().Update(runningTarget); err != nil {
|
if err := svc.store.ServerInstances().Update(runningTarget); err != nil {
|
||||||
t.Fatalf("set running target state: %v", err)
|
t.Fatalf("set running target state: %v", err)
|
||||||
}
|
}
|
||||||
|
installingTarget, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
||||||
|
ID: "server-delete-installing",
|
||||||
|
PluginID: plugin.ID,
|
||||||
|
RunEndpointID: endpoint.ID,
|
||||||
|
Name: "Delete Installing Target",
|
||||||
|
State: domain.ServerInstanceStateInstalling,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create installing target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := svc.DeleteServerInstanceForSession(otherSession, ownerInstance.ID, "secret-password"); !errors.Is(err, ErrForbidden) {
|
if _, err := svc.DeleteServerInstanceForSession(otherSession, ownerInstance.ID, domain.ServerDeletionRequest{Password: "secret-password"}); !errors.Is(err, ErrForbidden) {
|
||||||
t.Fatalf("expected non-owner delete to be forbidden, got %v", err)
|
t.Fatalf("expected non-owner delete to be forbidden, got %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, ""); err == nil {
|
if _, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, domain.ServerDeletionRequest{}); err == nil {
|
||||||
t.Fatalf("expected missing password to fail")
|
t.Fatalf("expected missing password to fail")
|
||||||
} else {
|
} else {
|
||||||
var validationErr validator.ValidationError
|
var validationErr validator.ValidationError
|
||||||
@@ -592,25 +602,25 @@ func TestCoreServiceDeletesServerInstancesWithPasswordConfirmation(t *testing.T)
|
|||||||
t.Fatalf("expected missing password to be validation error, got %v", err)
|
t.Fatalf("expected missing password to be validation error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, "wrong-password"); !errors.Is(err, ErrForbidden) {
|
if _, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, domain.ServerDeletionRequest{Password: "wrong-password"}); !errors.Is(err, ErrForbidden) {
|
||||||
t.Fatalf("expected wrong password to be forbidden, got %v", err)
|
t.Fatalf("expected wrong password to be forbidden, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deletedOwner, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, "secret-password")
|
deletedOwner, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, domain.ServerDeletionRequest{Password: "secret-password"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("delete owner instance: %v", err)
|
t.Fatalf("delete owner instance: %v", err)
|
||||||
}
|
}
|
||||||
if deletedOwner.State != domain.ServerInstanceStateDeleted {
|
if deletedOwner.State != domain.ServerInstanceStateDeleted {
|
||||||
t.Fatalf("expected deleted owner state, got %+v", deletedOwner)
|
t.Fatalf("expected deleted owner state, got %+v", deletedOwner)
|
||||||
}
|
}
|
||||||
deletedAdmin, err := svc.DeleteServerInstanceForSession(adminSession, adminTarget.ID, "secret-password")
|
deletedAdmin, err := svc.DeleteServerInstanceForSession(adminSession, adminTarget.ID, domain.ServerDeletionRequest{Password: "secret-password"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("delete admin target: %v", err)
|
t.Fatalf("delete admin target: %v", err)
|
||||||
}
|
}
|
||||||
if deletedAdmin.State != domain.ServerInstanceStateDeleted {
|
if deletedAdmin.State != domain.ServerInstanceStateDeleted {
|
||||||
t.Fatalf("expected deleted admin state, got %+v", deletedAdmin)
|
t.Fatalf("expected deleted admin state, got %+v", deletedAdmin)
|
||||||
}
|
}
|
||||||
if _, err := svc.DeleteServerInstanceForSession(ownerSession, runningTarget.ID, "secret-password"); err == nil {
|
if _, err := svc.DeleteServerInstanceForSession(ownerSession, runningTarget.ID, domain.ServerDeletionRequest{Password: "secret-password"}); err == nil {
|
||||||
t.Fatalf("expected running instance delete to fail")
|
t.Fatalf("expected running instance delete to fail")
|
||||||
} else {
|
} else {
|
||||||
var validationErr validator.ValidationError
|
var validationErr validator.ValidationError
|
||||||
@@ -618,6 +628,23 @@ func TestCoreServiceDeletesServerInstancesWithPasswordConfirmation(t *testing.T)
|
|||||||
t.Fatalf("expected running delete to be validation error, got %v", err)
|
t.Fatalf("expected running delete to be validation error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := svc.DeleteServerInstanceForSession(ownerSession, installingTarget.ID, domain.ServerDeletionRequest{Password: "secret-password", Force: true, Confirmation: "not enough"}); err == nil {
|
||||||
|
t.Fatalf("expected installing force delete without exact confirmation to fail")
|
||||||
|
}
|
||||||
|
forcedRunning, err := svc.DeleteServerInstanceForSession(ownerSession, runningTarget.ID, domain.ServerDeletionRequest{Password: "secret-password", Force: true, Confirmation: ServerDeletionForceConfirmation})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("force delete running target: %v", err)
|
||||||
|
}
|
||||||
|
if forcedRunning.State != domain.ServerInstanceStateDeleted {
|
||||||
|
t.Fatalf("expected forced running target deleted, got %+v", forcedRunning)
|
||||||
|
}
|
||||||
|
forcedInstalling, err := svc.DeleteServerInstanceForSession(ownerSession, installingTarget.ID, domain.ServerDeletionRequest{Password: "secret-password", Force: true, Confirmation: ServerDeletionForceConfirmation})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("force delete installing target: %v", err)
|
||||||
|
}
|
||||||
|
if forcedInstalling.State != domain.ServerInstanceStateDeleted {
|
||||||
|
t.Fatalf("expected forced installing target deleted, got %+v", forcedInstalling)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreServiceMetricsAndConfigReadAreRoleScoped(t *testing.T) {
|
func TestCoreServiceMetricsAndConfigReadAreRoleScoped(t *testing.T) {
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
return jsonResponse({ ...server, name: "Example Survival Renamed" });
|
return jsonResponse({ ...server, name: "Example Survival Renamed" });
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "DELETE") {
|
if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "DELETE") {
|
||||||
expect(JSON.parse(String(init.body))).toEqual({ password: "secret-password" });
|
expect(JSON.parse(String(init.body))).toEqual({ password: "secret-password", force: true, confirmation: "FORCE DELETE" });
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/v1/metrics/platform")) {
|
if (url.endsWith("/api/v1/metrics/platform")) {
|
||||||
@@ -601,7 +601,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
|
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
|
||||||
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
|
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
|
||||||
await expect(client.updateServerInstance(server.id, { name: "Example Survival Renamed" })).resolves.toMatchObject({ name: "Example Survival Renamed" });
|
await expect(client.updateServerInstance(server.id, { name: "Example Survival Renamed" })).resolves.toMatchObject({ name: "Example Survival Renamed" });
|
||||||
await expect(client.deleteServerInstance(server.id, { password: "secret-password" })).resolves.toBeUndefined();
|
await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined();
|
||||||
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
|
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
|
||||||
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
|
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
|
||||||
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
|
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
|
||||||
@@ -815,7 +815,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
code: "validation_failed",
|
code: "validation_failed",
|
||||||
message: "validation failed",
|
message: "validation failed",
|
||||||
details: [
|
details: [
|
||||||
"running or installing server instances must be stopped before delete",
|
"running or installing server instances require forced-delete confirmation",
|
||||||
"config path /Users/operator/private/server.ini is unavailable"
|
"config path /Users/operator/private/server.ini is unavailable"
|
||||||
]
|
]
|
||||||
}), { status: 400, headers: { "Content-Type": "application/json" } })));
|
}), { status: 400, headers: { "Content-Type": "application/json" } })));
|
||||||
@@ -824,7 +824,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
await expect(client.deleteServerInstance("running-server", { password: "secret-password" })).rejects.toMatchObject({
|
await expect(client.deleteServerInstance("running-server", { password: "secret-password" })).rejects.toMatchObject({
|
||||||
status: 400,
|
status: 400,
|
||||||
code: "validation_failed",
|
code: "validation_failed",
|
||||||
message: "运行中或安装中的服务器必须先停止再删除。;config path [host-path] is unavailable"
|
message: "运行中或安装中的服务器需要强制删除确认。;config path [host-path] is unavailable"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -783,6 +783,8 @@ function safeValidationDetail(detail: string): string | undefined {
|
|||||||
return "请输入当前登录密码。";
|
return "请输入当前登录密码。";
|
||||||
case "running or installing server instances must be stopped before delete":
|
case "running or installing server instances must be stopped before delete":
|
||||||
return "运行中或安装中的服务器必须先停止再删除。";
|
return "运行中或安装中的服务器必须先停止再删除。";
|
||||||
|
case "running or installing server instances require forced-delete confirmation":
|
||||||
|
return "运行中或安装中的服务器需要强制删除确认。";
|
||||||
default:
|
default:
|
||||||
return sanitized || undefined;
|
return sanitized || undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -493,6 +493,8 @@ export interface ServerInstanceUpdateRequest {
|
|||||||
|
|
||||||
export interface ServerDeletionRequest {
|
export interface ServerDeletionRequest {
|
||||||
password: string;
|
password: string;
|
||||||
|
force?: boolean;
|
||||||
|
confirmation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInstanceListResponse {
|
export interface ServerInstanceListResponse {
|
||||||
|
|||||||
@@ -163,5 +163,5 @@ export function serverMetadataFormFromInstance(instance: ServerInstanceResponse)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canDeleteServer(state: ServerInstanceState): boolean {
|
export function canDeleteServer(state: ServerInstanceState): boolean {
|
||||||
return state !== "running" && state !== "installing" && state !== "deleted";
|
return state !== "deleted";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,8 +253,12 @@ describe("first-party console pages", () => {
|
|||||||
const deleteHandlerSource = serversPageSource.split("async function handleDeleteServer")[1]?.split("function openRunTargetSelection")[0] ?? "";
|
const deleteHandlerSource = serversPageSource.split("async function handleDeleteServer")[1]?.split("function openRunTargetSelection")[0] ?? "";
|
||||||
expect(deleteHandlerSource).toContain("deleteServerInstance(deleteConfirmation.serverInstanceId, {");
|
expect(deleteHandlerSource).toContain("deleteServerInstance(deleteConfirmation.serverInstanceId, {");
|
||||||
expect(deleteHandlerSource).toContain("password: deletePassword");
|
expect(deleteHandlerSource).toContain("password: deletePassword");
|
||||||
|
expect(deleteHandlerSource).toContain("force: forceDelete");
|
||||||
|
expect(deleteHandlerSource).toContain("confirmation: forceDelete ? deleteForceConfirmation : undefined");
|
||||||
expect(serversPageSource).toContain("serverDeleteConfirmation(card.instance)");
|
expect(serversPageSource).toContain("serverDeleteConfirmation(card.instance)");
|
||||||
expect(serversPageSource).toContain("serverDeleteDisabledReason(session, card.instance)");
|
expect(serversPageSource).toContain("serverDeleteDisabledReason(session, card.instance)");
|
||||||
|
expect(serversPageSource).toContain("FORCE DELETE");
|
||||||
|
expect(serversPageSource).toContain("不会停止远端进程");
|
||||||
expect(serversPageSource).toContain("危险操作");
|
expect(serversPageSource).toContain("危险操作");
|
||||||
expect(serversPageSource).toContain("删除服务器");
|
expect(serversPageSource).toContain("删除服务器");
|
||||||
expect(serversPageSource).toContain("请输入当前登录密码");
|
expect(serversPageSource).toContain("请输入当前登录密码");
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [
|
|||||||
|
|
||||||
const serverListRefreshMs = 5000;
|
const serverListRefreshMs = 5000;
|
||||||
const serverMetricFreshMs = 30000;
|
const serverMetricFreshMs = 30000;
|
||||||
|
const serverForceDeleteConfirmation = "FORCE DELETE";
|
||||||
|
|
||||||
export function ServersPage({ session, operations, onNavigate }: PageComponentProps) {
|
export function ServersPage({ session, operations, onNavigate }: PageComponentProps) {
|
||||||
const [listState, setListState] = useState<ListState>("loading");
|
const [listState, setListState] = useState<ListState>("loading");
|
||||||
@@ -79,6 +80,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||||||
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
|
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
|
||||||
const [deletePassword, setDeletePassword] = useState("");
|
const [deletePassword, setDeletePassword] = useState("");
|
||||||
|
const [deleteForceConfirmation, setDeleteForceConfirmation] = useState("");
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
|
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
|
||||||
|
|
||||||
@@ -210,13 +212,19 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
if (!deleteConfirmation) {
|
if (!deleteConfirmation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const forceDelete = deleteConfirmation.state === "running" || deleteConfirmation.state === "installing";
|
||||||
setDeleteBusy(true);
|
setDeleteBusy(true);
|
||||||
const operationId = operations.begin({ intent: "删除服务器", targetKind: "server", targetId: deleteConfirmation.serverInstanceId, requester: session.displayName });
|
const operationId = operations.begin({ intent: "删除服务器", targetKind: "server", targetId: deleteConfirmation.serverInstanceId, requester: session.displayName });
|
||||||
try {
|
try {
|
||||||
await platformApiClient.deleteServerInstance(deleteConfirmation.serverInstanceId, { password: deletePassword });
|
await platformApiClient.deleteServerInstance(deleteConfirmation.serverInstanceId, {
|
||||||
|
password: deletePassword,
|
||||||
|
force: forceDelete,
|
||||||
|
confirmation: forceDelete ? deleteForceConfirmation : undefined
|
||||||
|
});
|
||||||
operations.succeed(operationId, `服务器已删除:${deleteConfirmation.serverInstanceId}`);
|
operations.succeed(operationId, `服务器已删除:${deleteConfirmation.serverInstanceId}`);
|
||||||
setDeleteConfirmation(null);
|
setDeleteConfirmation(null);
|
||||||
setDeletePassword("");
|
setDeletePassword("");
|
||||||
|
setDeleteForceConfirmation("");
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
operations.fail(operationId, error instanceof Error ? error.message : "服务器删除失败", operationId);
|
operations.fail(operationId, error instanceof Error ? error.message : "服务器删除失败", operationId);
|
||||||
@@ -611,6 +619,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setDeletePassword("");
|
setDeletePassword("");
|
||||||
|
setDeleteForceConfirmation("");
|
||||||
setDeleteConfirmation(serverDeleteConfirmation(card.instance));
|
setDeleteConfirmation(serverDeleteConfirmation(card.instance));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -620,14 +629,15 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteConfirmation !== null}
|
open={deleteConfirmation !== null}
|
||||||
title="删除服务器"
|
title="删除服务器"
|
||||||
description={`确认删除 ${deleteConfirmation?.name ?? ""}(${deleteConfirmation?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
|
description={deleteDialogDescription(deleteConfirmation)}
|
||||||
confirmLabel="确认删除"
|
confirmLabel="确认删除"
|
||||||
danger
|
danger
|
||||||
busy={deleteBusy}
|
busy={deleteBusy}
|
||||||
confirmDisabled={deletePassword.trim() === ""}
|
confirmDisabled={deletePassword.trim() === "" || (deleteRequiresForceConfirmation(deleteConfirmation) && deleteForceConfirmation.trim() !== serverForceDeleteConfirmation)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setDeleteConfirmation(null);
|
setDeleteConfirmation(null);
|
||||||
setDeletePassword("");
|
setDeletePassword("");
|
||||||
|
setDeleteForceConfirmation("");
|
||||||
}}
|
}}
|
||||||
onConfirm={() => void handleDeleteServer()}
|
onConfirm={() => void handleDeleteServer()}
|
||||||
>
|
>
|
||||||
@@ -645,6 +655,15 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
onChange={(event) => setDeletePassword(event.target.value)}
|
onChange={(event) => setDeletePassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{deleteRequiresForceConfirmation(deleteConfirmation) && (
|
||||||
|
<label>
|
||||||
|
运行中/安装中的服务器将被强制软删除;这不会停止远端进程。请输入 {serverForceDeleteConfirmation}
|
||||||
|
<input
|
||||||
|
value={deleteForceConfirmation}
|
||||||
|
onChange={(event) => setDeleteForceConfirmation(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
|
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
|
||||||
</section>
|
</section>
|
||||||
@@ -677,11 +696,24 @@ function serverDeleteDisabledReason(session: PageComponentProps["session"], inst
|
|||||||
return "仅创建人或平台管理员可删除";
|
return "仅创建人或平台管理员可删除";
|
||||||
}
|
}
|
||||||
if (!canDeleteServer(instance.state)) {
|
if (!canDeleteServer(instance.state)) {
|
||||||
return "运行中、安装中或已删除的服务器不能直接删除";
|
return "已删除的服务器不能再次删除";
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteRequiresForceConfirmation(confirmation: ReturnType<typeof serverDeleteConfirmation> | null): boolean {
|
||||||
|
return confirmation?.state === "running" || confirmation?.state === "installing";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDialogDescription(confirmation: ReturnType<typeof serverDeleteConfirmation> | null): string {
|
||||||
|
const name = confirmation?.name ?? "";
|
||||||
|
const id = confirmation?.serverInstanceId ?? "";
|
||||||
|
if (deleteRequiresForceConfirmation(confirmation)) {
|
||||||
|
return `确认强制删除 ${name}(${id})?平台只会把实例标记为已删除并保留历史记录,不会停止可能仍在远端运行的进程。`;
|
||||||
|
}
|
||||||
|
return `确认删除 ${name}(${id})?历史记录会保留。`;
|
||||||
|
}
|
||||||
|
|
||||||
interface ServerCardProps {
|
interface ServerCardProps {
|
||||||
card: ServerCardView;
|
card: ServerCardView;
|
||||||
metricsPending: boolean;
|
metricsPending: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user