feat: Allow managing server state in stackit_server resource (#623)

* feat: implement state switching in resource

* chore: fix linter issues

* feat: fix testcases

* chore: update documentation

* feat: replace backoff implementation with canonical wait functionality

* feat: refactor update method to correctly handle state changes of shelved servers

* chore: reverted documentation changes

* feat: updated server documentation

* feat: configured desired_state as "write-only" attribute

* feat: update to command help
This commit is contained in:
Rüdiger Schmitz 2025-01-15 11:28:50 +01:00 committed by GitHub
parent e2635b5a64
commit f04ced9981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 517 additions and 28 deletions

View file

@ -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

View file

@ -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"), &currentState)...)
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
}

View file

@ -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)
}
})
}
}