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

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