Skip to content

Commit 1e0753c

Browse files
imtiazPabelImtiaz Ahmed
andauthored
Feat/dedicated server installation import (#313)
* fix(dockerfile): resolve corepack issue in node image * refactor(reportError): make reportError public for external use * feat(import, timeout): add dedicated_server_installation import and applied timeout to 1 hour --------- Co-authored-by: Imtiaz Ahmed <i.ahmed@global.leaseweb.com>
1 parent e33d478 commit 1e0753c

File tree

9 files changed

+171
-78
lines changed

9 files changed

+171
-78
lines changed

docker/node/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:jod-slim@sha256:35531c52ce27b6575d69755c73e65d4468dba93a25644eed56dc12879cae9213
1+
FROM node:jod-slim@sha256:91be66fb4214c9449836550cf4c3524489816fcc29455bf42d968e8e87cfa5f2
22

33
RUN corepack enable \
44
&& corepack prepare pnpm@latest-9 --activate \

docs/resources/dedicated_server_installation.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,11 @@ Optional:
8585
- *HW*
8686
- *SW*
8787
- *NONE*
88+
89+
## Import
90+
91+
Import is supported using the following syntax:
92+
93+
```shell
94+
terraform import leaseweb_dedicated_server_installation.test 12345678
95+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import leaseweb_dedicated_server_installation.test 12345678

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ require (
99
github.com/hashicorp/terraform-plugin-go v0.26.0
1010
github.com/hashicorp/terraform-plugin-log v0.9.0
1111
github.com/hashicorp/terraform-plugin-testing v1.11.0
12-
github.com/leaseweb/leaseweb-go-sdk/dedicatedserver/v2 v2.0.3
12+
github.com/leaseweb/leaseweb-go-sdk/dedicatedserver/v2 v2.0.4
1313
github.com/leaseweb/leaseweb-go-sdk/dns v1.1.0
1414
github.com/leaseweb/leaseweb-go-sdk/ipmgmt v1.0.0
1515
github.com/leaseweb/leaseweb-go-sdk/publiccloud v0.0.2

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,12 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
112112
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
113113
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
114114
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
115+
github.com/leaseweb/leaseweb-go-sdk/dedicatedserver/v2 v2.0.2 h1:WYJMnr0UNeb8v5wVfDqVNQXcfpTGbUMXVufARi/MV90=
116+
github.com/leaseweb/leaseweb-go-sdk/dedicatedserver/v2 v2.0.2/go.mod h1:D/dX8az1mr8VKxIRL1jrg6wR+vReaF45kp06R2VnvmA=
115117
github.com/leaseweb/leaseweb-go-sdk/dedicatedserver/v2 v2.0.3 h1:2gQyn0DkGn9iQaxcYCvIrXogogV2RgSfY33w7qq1tog=
116118
github.com/leaseweb/leaseweb-go-sdk/dedicatedserver/v2 v2.0.3/go.mod h1:D/dX8az1mr8VKxIRL1jrg6wR+vReaF45kp06R2VnvmA=
119+
github.com/leaseweb/leaseweb-go-sdk/dedicatedserver/v2 v2.0.4 h1:1xeAgn/yAtF2Z34FUExI0FmR9SzRIkXTeYSDuyZc3iU=
120+
github.com/leaseweb/leaseweb-go-sdk/dedicatedserver/v2 v2.0.4/go.mod h1:D/dX8az1mr8VKxIRL1jrg6wR+vReaF45kp06R2VnvmA=
117121
github.com/leaseweb/leaseweb-go-sdk/dns v1.1.0 h1:Ev5xjXlYunGV+cjY2eem5TuC3OXCFujY8Efk/n0YcME=
118122
github.com/leaseweb/leaseweb-go-sdk/dns v1.1.0/go.mod h1:ntxqh2fT7XDwjPLNsWtq6tTjwjL/75llxnbzEZvvzIo=
119123
github.com/leaseweb/leaseweb-go-sdk/ipmgmt v1.0.0 h1:R+ZC7t3vtAoeRxSH3KcsR8vNF5NgaXy606vobn++cdU=

internal/provider/dedicatedserver/installation_resource.go

Lines changed: 139 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ package dedicatedserver
33
import (
44
"context"
55
"encoding/base64"
6+
"errors"
7+
"fmt"
68
"strings"
9+
"time"
710

811
"github.com/cenkalti/backoff/v5"
912
"github.com/hashicorp/terraform-plugin-framework-validators/int32validator"
1013
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
1114
"github.com/hashicorp/terraform-plugin-framework/attr"
15+
"github.com/hashicorp/terraform-plugin-framework/diag"
16+
"github.com/hashicorp/terraform-plugin-framework/path"
1217
"github.com/hashicorp/terraform-plugin-framework/resource"
1318
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
1419
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
@@ -24,8 +29,8 @@ import (
2429
)
2530

2631
var (
27-
_ resource.Resource = &installationResource{}
28-
_ resource.ResourceWithConfigure = &installationResource{}
32+
_ resource.ResourceWithConfigure = &installationResource{}
33+
_ resource.ResourceWithImportState = &installationResource{}
2934
)
3035

3136
func NewInstallationResource() resource.Resource {
@@ -326,94 +331,72 @@ func (i *installationResource) Create(
326331
}
327332

328333
serverID := plan.DedicatedServerID.ValueString()
329-
result, response, err := i.DedicatedserverAPI.InstallOperatingSystem(ctx, serverID).
334+
job, response, err := i.DedicatedserverAPI.InstallOperatingSystem(ctx, serverID).
330335
InstallOperatingSystemOpts(*opts).Execute()
331336
if err != nil {
332337
utils.SdkError(ctx, &resp.Diagnostics, err, response)
333338
return
334339
}
335340

336-
payload := result.GetPayload()
337-
plan.ID = types.StringValue(result.GetUuid())
338-
plan.Device = types.StringValue(payload.GetDevice())
339-
plan.Timezone = types.StringValue(payload.GetTimezone())
340-
plan.PowerCycle = types.BoolValue(payload.GetPowerCycle())
341-
342-
partitionAttributeTypes := map[string]attr.Type{
343-
"filesystem": types.StringType,
344-
"mountpoint": types.StringType,
345-
"size": types.StringType,
346-
}
347-
348-
// Preparing and converting partitions into types.Object to store in the state
349-
var partitionsObjects []attr.Value
350-
for _, p := range payload.GetPartitions() {
351-
partition := partitionsResourceModel{
352-
Filesystem: types.StringValue(p.GetFilesystem()),
353-
Mountpoint: types.StringValue(p.GetMountpoint()),
354-
Size: types.StringValue(p.GetSize()),
355-
}
356-
357-
partitionObj, diags := types.ObjectValueFrom(
358-
ctx,
359-
partitionAttributeTypes,
360-
partition,
361-
)
362-
if diags.HasError() {
363-
resp.Diagnostics.Append(diags...)
364-
return
365-
}
366-
367-
partitionsObjects = append(partitionsObjects, partitionObj)
341+
err = i.waitForJobCompletion(serverID, job.GetUuid(), ctx, resp)
342+
if err != nil {
343+
utils.ReportError(err.Error(), &resp.Diagnostics)
344+
return
368345
}
369346

370-
// Convert the slice of partition objects to a types.List and store it in the plan
371-
partitionsList, diags := types.ListValueFrom(
372-
ctx,
373-
types.ObjectType{
374-
AttrTypes: partitionAttributeTypes,
375-
},
376-
partitionsObjects,
377-
)
378-
if diags.HasError() {
379-
resp.Diagnostics.Append(diags...)
347+
diags := i.syncResourceModelWithSDK(&plan, *job, ctx)
348+
resp.Diagnostics.Append(diags...)
349+
if resp.Diagnostics.HasError() {
380350
return
381351
}
382-
plan.Partitions = partitionsList
383352

384-
pollJobStatus := func() (string, error) {
385-
return getJobStatus(serverID, result.GetUuid(), i, ctx, resp)
386-
}
353+
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
354+
}
387355

388-
_, err = backoff.Retry(context.TODO(), pollJobStatus, backoff.WithBackOff(backoff.NewExponentialBackOff()))
389-
if err != nil {
390-
utils.GeneralError(&resp.Diagnostics, ctx, err)
356+
func (i *installationResource) ImportState(
357+
ctx context.Context,
358+
req resource.ImportStateRequest,
359+
resp *resource.ImportStateResponse,
360+
) {
361+
// Retrieve import ID and save to id attribute
362+
resource.ImportStatePassthroughID(ctx, path.Root("dedicated_server_id"), req, resp)
363+
}
364+
365+
func (i *installationResource) Read(
366+
ctx context.Context,
367+
req resource.ReadRequest,
368+
resp *resource.ReadResponse,
369+
) {
370+
var state installationResourceModel
371+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
372+
if resp.Diagnostics.HasError() {
373+
return
391374
}
392375

393-
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
394-
}
376+
serverID := state.DedicatedServerID.ValueString()
395377

396-
func getJobStatus(serverID string, jobID string, i *installationResource, ctx context.Context, resp *resource.CreateResponse) (string, error) {
397-
request := i.DedicatedserverAPI.GetJob(ctx, serverID, jobID)
378+
result, response, err := i.DedicatedserverAPI.GetJobList(ctx, serverID).
379+
Offset(0).Limit(1).Type_("install").Status("FINISHED").Execute()
398380

399-
result, response, err := request.Execute()
400381
if err != nil {
401382
utils.SdkError(ctx, &resp.Diagnostics, err, response)
383+
return
402384
}
403385

404-
status := result.GetStatus()
405-
if status != "FINISHED" {
406-
return "", backoff.RetryAfter(30)
386+
jobs := result.GetJobs()
387+
388+
if len(jobs) == 0 {
389+
utils.ReportError(fmt.Sprintf("No installation jobs found for server %s", serverID), &resp.Diagnostics)
390+
return
407391
}
408392

409-
return status, nil
410-
}
393+
diags := i.syncResourceModelWithSDK(&state, jobs[0], ctx)
394+
resp.Diagnostics.Append(diags...)
395+
if resp.Diagnostics.HasError() {
396+
return
397+
}
411398

412-
func (i *installationResource) Read(
413-
_ context.Context,
414-
_ resource.ReadRequest,
415-
_ *resource.ReadResponse,
416-
) {
399+
resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
417400
}
418401

419402
func (i *installationResource) Update(
@@ -429,3 +412,92 @@ func (i *installationResource) Delete(
429412
_ *resource.DeleteResponse,
430413
) {
431414
}
415+
416+
// isJobFinished checks if the job status is "FINISHED".
417+
func (i *installationResource) isJobFinished(serverID, jobID string, ctx context.Context, resp *resource.CreateResponse) bool {
418+
// Fetch the job status
419+
result, response, err := i.DedicatedserverAPI.GetJob(ctx, serverID, jobID).Execute()
420+
if err != nil {
421+
utils.SdkError(ctx, &resp.Diagnostics, err, response)
422+
return false // Return false indicating the status couldn't be fetched
423+
}
424+
425+
// Return true if the job status is finished
426+
return result.GetStatus() == "FINISHED"
427+
}
428+
429+
// waitForJobCompletion handles polling with retry and timeout.
430+
func (i *installationResource) waitForJobCompletion(serverID, jobID string, ctx context.Context, resp *resource.CreateResponse) error {
431+
// Create a constant backoff with a 30-second retry interval
432+
bo := backoff.NewConstantBackOff(30 * time.Second)
433+
434+
// Set the retry limit to 120 retries (60 minutes total)
435+
retryCount := 0
436+
maxRetries := 120
437+
438+
// Start polling and retrying
439+
for {
440+
if retryCount >= maxRetries {
441+
return errors.New("timed out waiting for job to finish after 60 minutes")
442+
}
443+
444+
// Call the function to check if the job is finished
445+
if i.isJobFinished(serverID, jobID, ctx, resp) {
446+
// Job is finished, exit the loop
447+
return nil
448+
}
449+
450+
// Sleep for the backoff interval before retrying
451+
time.Sleep(bo.NextBackOff())
452+
retryCount++
453+
}
454+
}
455+
456+
func (i *installationResource) syncResourceModelWithSDK(
457+
state *installationResourceModel,
458+
job dedicatedserver.ServerJob,
459+
ctx context.Context,
460+
) diag.Diagnostics {
461+
var diags diag.Diagnostics
462+
payload := job.GetPayload()
463+
state.ID = types.StringValue(job.GetUuid())
464+
state.Device = types.StringValue(payload.GetDevice())
465+
state.OperatingSystemID = types.StringValue(payload.GetOperatingSystemId())
466+
state.PowerCycle = types.BoolValue(payload.GetPowerCycle())
467+
state.Timezone = types.StringValue(payload.GetTimezone())
468+
469+
partitionAttributeTypes := map[string]attr.Type{
470+
"filesystem": types.StringType,
471+
"mountpoint": types.StringType,
472+
"size": types.StringType,
473+
}
474+
475+
// Preparing and converting partitions into types.Object to store in the state
476+
var partitionsObjects []attr.Value
477+
for _, p := range payload.GetPartitions() {
478+
partition := partitionsResourceModel{
479+
Filesystem: types.StringValue(p.GetFilesystem()),
480+
Mountpoint: types.StringValue(p.GetMountpoint()),
481+
Size: types.StringValue(p.GetSize()),
482+
}
483+
484+
partitionObj, partitionDiags := types.ObjectValueFrom(ctx, partitionAttributeTypes, partition)
485+
diags.Append(partitionDiags...)
486+
partitionsObjects = append(partitionsObjects, partitionObj)
487+
}
488+
489+
// Convert the slice of partition objects to a types.List and store it in the plan
490+
partitionsList, listDiags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: partitionAttributeTypes}, partitionsObjects)
491+
diags.Append(listDiags...)
492+
state.Partitions = partitionsList
493+
494+
if state.Raid.IsNull() || state.Raid.IsUnknown() {
495+
state.Raid = types.ObjectNull(map[string]attr.Type{
496+
"level": types.Int64Type,
497+
"number_of_disks": types.Int64Type,
498+
"type": types.StringType,
499+
})
500+
}
501+
502+
return diags
503+
}

internal/provider/provider_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,14 @@ func TestAccDedicatedServerInstallationResource(t *testing.T) {
14651465
),
14661466
),
14671467
},
1468+
{
1469+
ResourceName: "leaseweb_dedicated_server_installation.test",
1470+
ImportState: true,
1471+
ImportStateVerify: true,
1472+
ImportStateId: "12345",
1473+
ImportStateVerifyIdentifierAttribute: "dedicated_server_id",
1474+
ImportStateVerifyIgnore: []string{"callback_url", "control_panel_id", "hostname", "password", "ssh_keys", "post_install_script", "raid"},
1475+
},
14681476
},
14691477
})
14701478
})

internal/utils/error.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func GeneralError(diags *diag.Diagnostics, ctx context.Context, err error) {
2222
if err != nil {
2323
logDebug(err.Error(), ctx)
2424
}
25-
reportError(defaultErrMsg, diags)
25+
ReportError(defaultErrMsg, diags)
2626
}
2727

2828
// ImportOnlyError should be used in resource Read() functions for resources that can only be imported.
@@ -57,13 +57,13 @@ func SdkError(
5757

5858
if err == nil {
5959
logDebug("No error detail found.", ctx)
60-
reportError(defaultErrMsg, diags)
60+
ReportError(defaultErrMsg, diags)
6161
return
6262
}
6363

6464
// Without a response we only need to handle the error.
6565
if resp == nil {
66-
reportError(err.Error(), diags)
66+
ReportError(err.Error(), diags)
6767
return
6868
}
6969

@@ -81,12 +81,12 @@ func SdkError(
8181
// For certain http responses we don't need to analyze the response body.
8282
if resp.StatusCode == 504 {
8383
logDebug(fmt.Sprintf("server response: %v", resp.Body), ctx)
84-
reportError("The server took too long to respond.", diags)
84+
ReportError("The server took too long to respond.", diags)
8585
return
8686
}
8787
if resp.StatusCode == 404 {
8888
logDebug(fmt.Sprintf("server response: %v", resp.Body), ctx)
89-
reportError("Resource not found.", diags)
89+
ReportError("Resource not found.", diags)
9090
return
9191
}
9292

@@ -103,7 +103,7 @@ func SdkError(
103103
fmt.Sprintf("error decoding HTTP response body: %v", err),
104104
ctx,
105105
)
106-
reportError(defaultErrMsg, diags)
106+
ReportError(defaultErrMsg, diags)
107107
return
108108
}
109109

@@ -116,7 +116,7 @@ func SdkError(
116116
}
117117

118118
if len(errorResponse.ErrorMessage) > 0 {
119-
reportError(errorResponse.ErrorMessage, diags)
119+
ReportError(errorResponse.ErrorMessage, diags)
120120
return
121121
}
122122

@@ -190,7 +190,7 @@ func logDebug(details string, ctx context.Context) {
190190
tflog.Debug(ctx, fmt.Sprintf("Details: %v", details))
191191
}
192192

193-
func reportError(details string, diags *diag.Diagnostics) {
193+
func ReportError(details string, diags *diag.Diagnostics) {
194194
diags.AddError(errTitle, details)
195195
}
196196

internal/utils/error_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ func ExampleUnexpectedImportIdentifierError() {
653653
func Test_writeSDKOutput(t *testing.T) {
654654
diags := diag.Diagnostics{}
655655

656-
reportError("tralala", &diags)
656+
ReportError("tralala", &diags)
657657

658658
assert.Len(t, diags.Errors(), 1)
659659
assert.Equal(t, errTitle, diags.Errors()[0].Summary())

0 commit comments

Comments
 (0)