diff --git a/docs/resources/server.md b/docs/resources/server.md index cb798aaf..5246dfb3 100644 --- a/docs/resources/server.md +++ b/docs/resources/server.md @@ -384,6 +384,7 @@ resource "stackit_server" "user-data-from-file" { - `affinity_group` (String) The affinity group the server is assigned to. - `availability_zone` (String) The availability zone of the server. - `boot_volume` (Attributes) The boot volume for the server (see [below for nested schema](#nestedatt--boot_volume)) +- `desired_status` (String) The desired status of the server resource. Defaults to 'active' Supported values are: `active`, `inactive`, `deallocated`. - `image_id` (String) The image ID to be used for an ephemeral disk on the server. - `keypair_name` (String) The name of the keypair used during server creation. - `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container diff --git a/stackit/internal/services/iaas/server/resource.go b/stackit/internal/services/iaas/server/resource.go index e4c071a7..38d3d85f 100644 --- a/stackit/internal/services/iaas/server/resource.go +++ b/stackit/internal/services/iaas/server/resource.go @@ -47,6 +47,13 @@ var ( _ resource.ResourceWithImportState = &serverResource{} supportedSourceTypes = []string{"volume", "image"} + desiredStatusOptions = []string{modelStateActive, modelStateInactive, modelStateDeallocated} +) + +const ( + modelStateActive = "active" + modelStateInactive = "inactive" + modelStateDeallocated = "deallocated" ) type Model struct { @@ -65,6 +72,7 @@ type Model struct { CreatedAt types.String `tfsdk:"created_at"` LaunchedAt types.String `tfsdk:"launched_at"` UpdatedAt types.String `tfsdk:"updated_at"` + DesiredStatus types.String `tfsdk:"desired_status"` } // Struct corresponding to Model.BootVolume @@ -333,10 +341,58 @@ func (r *serverResource) Schema(_ context.Context, _ resource.SchemaRequest, res Description: "Date-time when the server was updated", Computed: true, }, + "desired_status": schema.StringAttribute{ + Description: "The desired status of the server resource." + utils.SupportedValuesDocumentation(desiredStatusOptions), + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf(desiredStatusOptions...), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + desiredStateModifier{}, + }, + }, }, } } +var _ planmodifier.String = desiredStateModifier{} + +type desiredStateModifier struct { +} + +// Description implements planmodifier.String. +func (d desiredStateModifier) Description(context.Context) string { + return "validates desired state transition" +} + +// MarkdownDescription implements planmodifier.String. +func (d desiredStateModifier) MarkdownDescription(ctx context.Context) string { + return d.Description(ctx) +} + +// PlanModifyString implements planmodifier.String. +func (d desiredStateModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { //nolint: gocritic //signature is defined by terraform api + // Retrieve values from plan + var ( + planState types.String + currentState types.String + ) + resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("desired_status"), &planState)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("desired_status"), ¤tState)...) + if resp.Diagnostics.HasError() { + return + } + + if currentState.ValueString() == modelStateDeallocated && planState.ValueString() == modelStateInactive { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error changing server state", "Server state change from deallocated to inactive is not possible") + } +} + // Create creates the resource and sets the initial Terraform state. func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan @@ -371,7 +427,6 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("server creation waiting: %v", err)) return } - ctx = tflog.SetField(ctx, "server_id", serverId) // Map response body to schema @@ -380,6 +435,12 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Processing API payload: %v", err)) return } + + if err := updateServerStatus(ctx, r.client, server.Status, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creting server", fmt.Sprintf("update server state: %v", err)) + return + } + // Set state to fully populated data diags = resp.State.Set(ctx, model) resp.Diagnostics.Append(diags...) @@ -389,6 +450,117 @@ func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, tflog.Info(ctx, "Server created") } +// serverControlClient provides a mockable interface for the necessary +// client operations in [updateServerStatus] +type serverControlClient interface { + wait.APIClientInterface + StartServerExecute(ctx context.Context, projectId string, serverId string) error + StopServerExecute(ctx context.Context, projectId string, serverId string) error + DeallocateServerExecute(ctx context.Context, projectId string, serverId string) error +} + +func startServer(ctx context.Context, client serverControlClient, projectId, serverId string) error { + tflog.Debug(ctx, "starting server to enter active state") + if err := client.StartServerExecute(ctx, projectId, serverId); err != nil { + return fmt.Errorf("cannot start server: %w", err) + } + _, err := wait.StartServerWaitHandler(ctx, client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("cannot check started server: %w", err) + } + return nil +} + +func stopServer(ctx context.Context, client serverControlClient, projectId, serverId string) error { + tflog.Debug(ctx, "stopping server to enter inactive state") + if err := client.StopServerExecute(ctx, projectId, serverId); err != nil { + return fmt.Errorf("cannot stop server: %w", err) + } + _, err := wait.StopServerWaitHandler(ctx, client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("cannot check stopped server: %w", err) + } + return nil +} + +func deallocatServer(ctx context.Context, client serverControlClient, projectId, serverId string) error { + tflog.Debug(ctx, "deallocating server to enter shelved state") + if err := client.DeallocateServerExecute(ctx, projectId, serverId); err != nil { + return fmt.Errorf("cannot deallocate server: %w", err) + } + _, err := wait.DeallocateServerWaitHandler(ctx, client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("cannot check deallocated server: %w", err) + } + return nil +} + +// updateServerStatus applies the appropriate server state changes for the actual current and the intended state +func updateServerStatus(ctx context.Context, client serverControlClient, currentState *string, model *Model) error { + if currentState == nil { + tflog.Warn(ctx, "no current state available, not updating server state") + return nil + } + switch *currentState { + case wait.ServerActiveStatus: + switch strings.ToUpper(model.DesiredStatus.ValueString()) { + case wait.ServerInactiveStatus: + if err := stopServer(ctx, client, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + + case wait.ServerDeallocatedStatus: + + if err := deallocatServer(ctx, client, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + default: + tflog.Debug(ctx, fmt.Sprintf("nothing to do for status value %q", model.DesiredStatus.ValueString())) + if _, err := client.GetServerExecute(ctx, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + } + case wait.ServerInactiveStatus: + switch strings.ToUpper(model.DesiredStatus.ValueString()) { + case wait.ServerActiveStatus: + if err := startServer(ctx, client, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + case wait.ServerDeallocatedStatus: + if err := deallocatServer(ctx, client, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + + default: + tflog.Debug(ctx, fmt.Sprintf("nothing to do for status value %q", model.DesiredStatus.ValueString())) + if _, err := client.GetServerExecute(ctx, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + } + case wait.ServerDeallocatedStatus: + switch strings.ToUpper(model.DesiredStatus.ValueString()) { + case wait.ServerActiveStatus: + if err := startServer(ctx, client, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + + case wait.ServerInactiveStatus: + if err := stopServer(ctx, client, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + default: + tflog.Debug(ctx, fmt.Sprintf("nothing to do for status value %q", model.DesiredStatus.ValueString())) + if _, err := client.GetServerExecute(ctx, model.ProjectId.ValueString(), model.ServerId.ValueString()); err != nil { + return err + } + } + default: + tflog.Debug(ctx, "not updating server state") + } + + return nil +} + // // Read refreshes the Terraform state with the latest data. func (r *serverResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform var model Model @@ -428,6 +600,43 @@ func (r *serverResource) Read(ctx context.Context, req resource.ReadRequest, res tflog.Info(ctx, "server read") } +func (r *serverResource) updateServerAttributes(ctx context.Context, model, stateModel *Model) (*iaas.Server, error) { + // Generate API request body from model + payload, err := toUpdatePayload(ctx, model, stateModel.Labels) + if err != nil { + return nil, fmt.Errorf("Creating API payload: %w", err) + } + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + + var updatedServer *iaas.Server + // Update existing server + updatedServer, err = r.client.UpdateServer(ctx, projectId, serverId).UpdateServerPayload(*payload).Execute() + if err != nil { + return nil, fmt.Errorf("Calling API: %w", err) + } + + // Update machine type + modelMachineType := conversion.StringValueToPointer(model.MachineType) + if modelMachineType != nil && updatedServer.MachineType != nil && *modelMachineType != *updatedServer.MachineType { + payload := iaas.ResizeServerPayload{ + MachineType: modelMachineType, + } + err := r.client.ResizeServer(ctx, projectId, serverId).ResizeServerPayload(payload).Execute() + if err != nil { + return nil, fmt.Errorf("Resizing the server, calling API: %w", err) + } + + _, err = wait.ResizeServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + return nil, fmt.Errorf("server resize waiting: %w", err) + } + // Update server model because the API doesn't return a server object as response + updatedServer.MachineType = modelMachineType + } + return updatedServer, nil +} + // Update updates the resource and sets the updated Terraform state on success. func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform // Retrieve values from plan @@ -450,37 +659,40 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, return } - // Generate API request body from model - payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Creating API payload: %v", err)) - return - } - // Update existing server - updatedServer, err := r.client.UpdateServer(ctx, projectId, serverId).UpdateServerPayload(*payload).Execute() - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Calling API: %v", err)) - return + var ( + server *iaas.Server + err error + ) + if server, err = r.client.GetServer(ctx, model.ProjectId.ValueString(), model.ServerId.ValueString()).Execute(); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error retrieving server state", fmt.Sprintf("Getting server state: %v", err)) } - // Update machine type - modelMachineType := conversion.StringValueToPointer(model.MachineType) - if modelMachineType != nil && updatedServer.MachineType != nil && *modelMachineType != *updatedServer.MachineType { - payload := iaas.ResizeServerPayload{ - MachineType: modelMachineType, - } - err := r.client.ResizeServer(ctx, projectId, serverId).ResizeServerPayload(payload).Execute() + var updatedServer *iaas.Server + if model.DesiredStatus.ValueString() == modelStateDeallocated { + // if the target state is "deallocated", we have to perform the server update first + // and then shelve it afterwards. A shelved server cannot be updated + updatedServer, err = r.updateServerAttributes(ctx, &model, &stateModel) if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Resizing the server, calling API: %v", err)) - } - - _, err = wait.ResizeServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx) - if err != nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("server resize waiting: %v", err)) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", err.Error()) + return + } + + if err := updateServerStatus(ctx, r.client, server.Status, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", err.Error()) + return + } + } else { + // potentially unfreeze first and update afterwards + if err := updateServerStatus(ctx, r.client, server.Status, &model); err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", err.Error()) + return + } + + updatedServer, err = r.updateServerAttributes(ctx, &model, &stateModel) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", err.Error()) return } - // Update server model because the API doesn't return a server object as response - updatedServer.MachineType = modelMachineType } err = mapFields(ctx, updatedServer, &model) @@ -488,6 +700,7 @@ func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Processing API payload: %v", err)) return } + diags = resp.State.Set(ctx, model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -605,7 +818,15 @@ func mapFields(ctx context.Context, serverResp *iaas.Server, model *Model) error model.ServerId = types.StringValue(serverId) model.MachineType = types.StringPointerValue(serverResp.MachineType) - model.AvailabilityZone = types.StringPointerValue(serverResp.AvailabilityZone) + + // Proposed fix: If the server is deallocated, it has no availability zone anymore + // reactivation will then _change_ the availability zone again, causing terraform + // to destroy and recreate the resource, which is not intended. So we skip the zone + // when the server is deallocated to retain the original zone until the server + // is activated again + if serverResp.Status != nil && *serverResp.Status != wait.ServerDeallocatedStatus { + model.AvailabilityZone = types.StringPointerValue(serverResp.AvailabilityZone) + } model.Name = types.StringPointerValue(serverResp.Name) model.Labels = labels model.ImageId = types.StringPointerValue(serverResp.ImageId) @@ -614,6 +835,7 @@ func mapFields(ctx context.Context, serverResp *iaas.Server, model *Model) error model.CreatedAt = createdAt model.UpdatedAt = updatedAt model.LaunchedAt = launchedAt + return nil } diff --git a/stackit/internal/services/iaas/server/resource_test.go b/stackit/internal/services/iaas/server/resource_test.go index 3a87a092..1debdfa9 100644 --- a/stackit/internal/services/iaas/server/resource_test.go +++ b/stackit/internal/services/iaas/server/resource_test.go @@ -8,8 +8,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" ) const ( @@ -76,6 +78,7 @@ func TestMapFields(t *testing.T) { CreatedAt: utils.Ptr(testTimestamp()), UpdatedAt: utils.Ptr(testTimestamp()), LaunchedAt: utils.Ptr(testTimestamp()), + Status: utils.Ptr("active"), }, Model{ Id: types.StringValue("pid,sid"), @@ -267,3 +270,266 @@ func TestToUpdatePayload(t *testing.T) { }) } } + +var _ serverControlClient = (*mockServerControlClient)(nil) + +// mockServerControlClient mocks the [serverControlClient] interface with +// pluggable functions +type mockServerControlClient struct { + wait.APIClientInterface + startServerCalled int + startServerExecute func(callNo int, ctx context.Context, projectId, serverId string) error + + stopServerCalled int + stopServerExecute func(callNo int, ctx context.Context, projectId, serverId string) error + + deallocateServerCalled int + deallocateServerExecute func(callNo int, ctx context.Context, projectId, serverId string) error + + getServerCalled int + getServerExecute func(callNo int, ctx context.Context, projectId, serverId string) (*iaas.Server, error) +} + +// DeallocateServerExecute implements serverControlClient. +func (t *mockServerControlClient) DeallocateServerExecute(ctx context.Context, projectId, serverId string) error { + t.deallocateServerCalled++ + return t.deallocateServerExecute(t.deallocateServerCalled, ctx, projectId, serverId) +} + +// GetServerExecute implements serverControlClient. +func (t *mockServerControlClient) GetServerExecute(ctx context.Context, projectId, serverId string) (*iaas.Server, error) { + t.getServerCalled++ + return t.getServerExecute(t.getServerCalled, ctx, projectId, serverId) +} + +// StartServerExecute implements serverControlClient. +func (t *mockServerControlClient) StartServerExecute(ctx context.Context, projectId, serverId string) error { + t.startServerCalled++ + return t.startServerExecute(t.startServerCalled, ctx, projectId, serverId) +} + +// StopServerExecute implements serverControlClient. +func (t *mockServerControlClient) StopServerExecute(ctx context.Context, projectId, serverId string) error { + t.stopServerCalled++ + return t.stopServerExecute(t.stopServerCalled, ctx, projectId, serverId) +} + +func Test_serverResource_updateServerStatus(t *testing.T) { + projectId := basetypes.NewStringValue("projectId") + serverId := basetypes.NewStringValue("serverId") + type fields struct { + client *mockServerControlClient + } + type args struct { + currentState *string + model Model + } + type want struct { + err bool + status types.String + getServerCount int + stopCount int + startCount int + deallocatedCount int + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "no desired status", + fields: fields{ + client: &mockServerControlClient{ + getServerExecute: func(_ int, _ context.Context, _, _ string) (*iaas.Server, error) { + return &iaas.Server{ + Id: utils.Ptr(serverId.ValueString()), + Status: utils.Ptr(wait.ServerActiveStatus), + }, nil + }, + }, + }, + args: args{ + currentState: utils.Ptr(wait.ServerActiveStatus), + model: Model{ + ProjectId: projectId, + ServerId: serverId, + }, + }, + want: want{ + getServerCount: 1, + }, + }, + + { + name: "desired inactive state", + fields: fields{ + client: &mockServerControlClient{ + getServerExecute: func(no int, _ context.Context, _, _ string) (*iaas.Server, error) { + var state string + if no <= 1 { + state = wait.ServerActiveStatus + } else { + state = wait.ServerInactiveStatus + } + return &iaas.Server{ + Id: utils.Ptr(serverId.ValueString()), + Status: &state, + }, nil + }, + stopServerExecute: func(_ int, _ context.Context, _, _ string) error { return nil }, + }, + }, + args: args{ + currentState: utils.Ptr(wait.ServerActiveStatus), + model: Model{ + ProjectId: projectId, + ServerId: serverId, + DesiredStatus: basetypes.NewStringValue("inactive"), + }, + }, + want: want{ + getServerCount: 2, + stopCount: 1, + status: basetypes.NewStringValue("inactive"), + }, + }, + { + name: "desired deallocated state", + fields: fields{ + client: &mockServerControlClient{ + getServerExecute: func(no int, _ context.Context, _, _ string) (*iaas.Server, error) { + var state string + switch no { + case 1: + state = wait.ServerActiveStatus + case 2: + state = wait.ServerInactiveStatus + default: + state = wait.ServerDeallocatedStatus + } + return &iaas.Server{ + Id: utils.Ptr(serverId.ValueString()), + Status: &state, + }, nil + }, + deallocateServerExecute: func(_ int, _ context.Context, _, _ string) error { return nil }, + }, + }, + args: args{ + currentState: utils.Ptr(wait.ServerActiveStatus), + model: Model{ + ProjectId: projectId, + ServerId: serverId, + DesiredStatus: basetypes.NewStringValue("deallocated"), + }, + }, + want: want{ + getServerCount: 3, + deallocatedCount: 1, + status: basetypes.NewStringValue("deallocated"), + }, + }, + { + name: "don't call start if active", + fields: fields{ + client: &mockServerControlClient{ + getServerExecute: func(_ int, _ context.Context, _, _ string) (*iaas.Server, error) { + return &iaas.Server{ + Id: utils.Ptr(serverId.ValueString()), + Status: utils.Ptr(wait.ServerActiveStatus), + }, nil + }, + }, + }, + args: args{ + currentState: utils.Ptr(wait.ServerActiveStatus), + model: Model{ + ProjectId: projectId, + ServerId: serverId, + DesiredStatus: basetypes.NewStringValue("active"), + }, + }, + want: want{ + status: basetypes.NewStringValue("active"), + getServerCount: 1, + }, + }, + { + name: "don't call stop if inactive", + fields: fields{ + client: &mockServerControlClient{ + getServerExecute: func(_ int, _ context.Context, _, _ string) (*iaas.Server, error) { + return &iaas.Server{ + Id: utils.Ptr(serverId.ValueString()), + Status: utils.Ptr(wait.ServerInactiveStatus), + }, nil + }, + }, + }, + args: args{ + currentState: utils.Ptr(wait.ServerInactiveStatus), + model: Model{ + ProjectId: projectId, + ServerId: serverId, + DesiredStatus: basetypes.NewStringValue("inactive"), + }, + }, + want: want{ + status: basetypes.NewStringValue("inactive"), + getServerCount: 1, + }, + }, + { + name: "don't call dealloacate if deallocated", + fields: fields{ + client: &mockServerControlClient{ + getServerExecute: func(_ int, _ context.Context, _, _ string) (*iaas.Server, error) { + return &iaas.Server{ + Id: utils.Ptr(serverId.ValueString()), + Status: utils.Ptr(wait.ServerDeallocatedStatus), + }, nil + }, + }, + }, + args: args{ + currentState: utils.Ptr(wait.ServerDeallocatedStatus), + model: Model{ + ProjectId: projectId, + ServerId: serverId, + DesiredStatus: basetypes.NewStringValue("deallocated"), + }, + }, + want: want{ + status: basetypes.NewStringValue("deallocated"), + getServerCount: 1, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := updateServerStatus(context.Background(), tt.fields.client, tt.args.currentState, &tt.args.model) + if (err != nil) != tt.want.err { + t.Errorf("inconsistent error, want %v and got %v", tt.want.err, err) + } + if expected, actual := tt.want.status, tt.args.model.DesiredStatus; expected != actual { + t.Errorf("wanted status %s but got %s", expected, actual) + } + + if expected, actual := tt.want.getServerCount, tt.fields.client.getServerCalled; expected != actual { + t.Errorf("wrong number of get server calls: Expected %d but got %d", expected, actual) + } + if expected, actual := tt.want.startCount, tt.fields.client.startServerCalled; expected != actual { + t.Errorf("wrong number of start server calls: Expected %d but got %d", expected, actual) + } + if expected, actual := tt.want.stopCount, tt.fields.client.stopServerCalled; expected != actual { + t.Errorf("wrong number of stop server calls: Expected %d but got %d", expected, actual) + } + if expected, actual := tt.want.deallocatedCount, tt.fields.client.deallocateServerCalled; expected != actual { + t.Errorf("wrong number of deallocate server calls: Expected %d but got %d", expected, actual) + } + }) + } +}