Allow forced server deletion
This commit is contained in:
@@ -1305,12 +1305,12 @@ func (h *coreHandlers) serverInstances(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// serverInstanceDetail godoc
|
||||
// @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
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @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 200 {object} dto.ServerInstanceResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
@@ -1347,7 +1347,7 @@ func (h *coreHandlers) serverInstanceDetail(w http.ResponseWriter, r *http.Reque
|
||||
writeDecodeError(w, err)
|
||||
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 {
|
||||
writeServiceError(w, err)
|
||||
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 {
|
||||
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 := ""
|
||||
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
|
||||
}
|
||||
|
||||
type ServerDeletionRequest struct {
|
||||
Password string
|
||||
Force bool
|
||||
Confirmation string
|
||||
}
|
||||
|
||||
type PlatformResourceUsage struct {
|
||||
CPUPercent float64
|
||||
MemoryPercent float64
|
||||
|
||||
@@ -554,7 +554,9 @@ type ServerInstanceUpdateRequest 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 {
|
||||
@@ -1242,6 +1244,10 @@ func (request ServerInstanceUpdateRequest) ToDomain() domain.ServerInstanceUpdat
|
||||
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 {
|
||||
return domain.ServerConfigDiffRequest{
|
||||
ServerInstanceID: serverInstanceID,
|
||||
|
||||
@@ -23,6 +23,8 @@ var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
const ServerDeletionForceConfirmation = "FORCE DELETE"
|
||||
|
||||
type ForbiddenError struct {
|
||||
Reason string
|
||||
}
|
||||
@@ -102,7 +104,7 @@ type Core interface {
|
||||
ListServerAdministratorCandidates(string, string) ([]domain.User, error)
|
||||
AddServerAdministrator(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)
|
||||
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
||||
GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error)
|
||||
@@ -2367,7 +2369,7 @@ func (svc *CoreService) RemoveServerAdministrator(sessionID string, serverInstan
|
||||
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)
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
@@ -2379,14 +2381,16 @@ func (svc *CoreService) DeleteServerInstanceForSession(sessionID string, serverI
|
||||
if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID {
|
||||
return domain.ServerInstance{}, ErrForbidden
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
if strings.TrimSpace(request.Password) == "" {
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
return domain.CopyServerInstance(instance), nil
|
||||
|
||||
@@ -580,11 +580,21 @@ func TestCoreServiceDeletesServerInstancesWithPasswordConfirmation(t *testing.T)
|
||||
if err := svc.store.ServerInstances().Update(runningTarget); err != nil {
|
||||
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)
|
||||
}
|
||||
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")
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
deletedOwner, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, "secret-password")
|
||||
deletedOwner, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, domain.ServerDeletionRequest{Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("delete owner instance: %v", err)
|
||||
}
|
||||
if deletedOwner.State != domain.ServerInstanceStateDeleted {
|
||||
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 {
|
||||
t.Fatalf("delete admin target: %v", err)
|
||||
}
|
||||
if deletedAdmin.State != domain.ServerInstanceStateDeleted {
|
||||
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")
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user