Skip to content

Commit f54e020

Browse files
authored
feat: Add service wait resource (#104)
1 parent 624e727 commit f54e020

File tree

7 files changed

+365
-0
lines changed

7 files changed

+365
-0
lines changed

docs/resources/service_wait.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "plural_service_wait Resource - terraform-provider-plural"
4+
subcategory: ""
5+
description: |-
6+
7+
---
8+
9+
# plural_service_wait (Resource)
10+
11+
12+
13+
14+
15+
<!-- schema generated by tfplugindocs -->
16+
## Schema
17+
18+
### Required
19+
20+
- `cluster` (String) Handle of the cluster where the service is deployed.
21+
- `service` (String) Name the service deployment that should be checked.
22+
23+
### Optional
24+
25+
- `duration` (String) Maximum duration to wait for the service deployment to become healthy. Minimum 1 minute. Defaults to 10 minutes.
26+
- `warmup` (String) Initial delay before checking the service deployment health. Defaults to 5 minutes.

example/servicewait/main.tf

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
terraform {
2+
required_providers {
3+
plural = {
4+
source = "pluralsh/plural"
5+
version = "0.2.28"
6+
}
7+
}
8+
}
9+
10+
provider "plural" {
11+
use_cli = true
12+
}
13+
14+
resource "plural_service_wait" "test" {
15+
cluster = "mgmt"
16+
service = "console"
17+
warmup = "5s"
18+
duration = "1m"
19+
}
20+

internal/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ func (p *PluralProvider) Resources(_ context.Context) []func() resource.Resource
212212
r.NewObservabilityWebhookResource,
213213
r.NewCloudConnectionResource,
214214
r.NewServiceAccountResource,
215+
r.NewServiceWaitResource,
215216
}
216217
}
217218

internal/resource/service_wait.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package resource
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"terraform-provider-plural/internal/client"
9+
"terraform-provider-plural/internal/common"
10+
customvalidator "terraform-provider-plural/internal/validator"
11+
12+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
13+
"github.com/hashicorp/terraform-plugin-framework/path"
14+
"github.com/hashicorp/terraform-plugin-framework/resource"
15+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
16+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
17+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
18+
"github.com/hashicorp/terraform-plugin-framework/types"
19+
"github.com/hashicorp/terraform-plugin-log/tflog"
20+
console "github.com/pluralsh/console/go/client"
21+
"k8s.io/apimachinery/pkg/util/wait"
22+
)
23+
24+
type serviceWait struct {
25+
Cluster types.String `tfsdk:"cluster"`
26+
Service types.String `tfsdk:"service"`
27+
Warmup types.String `tfsdk:"warmup"`
28+
Duration types.String `tfsdk:"duration"`
29+
}
30+
31+
func (in *serviceWait) ParseWarmup() (time.Duration, error) {
32+
return time.ParseDuration(in.Warmup.ValueString())
33+
}
34+
35+
func (in *serviceWait) ParseDuration() (time.Duration, error) {
36+
return time.ParseDuration(in.Duration.ValueString())
37+
}
38+
39+
var _ resource.ResourceWithConfigure = &serviceWaitResource{}
40+
41+
func NewServiceWaitResource() resource.Resource {
42+
return &serviceWaitResource{}
43+
}
44+
45+
type serviceWaitResource struct {
46+
client *client.Client
47+
}
48+
49+
func (in *serviceWaitResource) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) {
50+
response.TypeName = request.ProviderTypeName + "_service_wait"
51+
}
52+
53+
func (in *serviceWaitResource) Schema(_ context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) {
54+
response.Schema = schema.Schema{
55+
Attributes: map[string]schema.Attribute{
56+
"cluster": schema.StringAttribute{
57+
Description: "Handle of the cluster where the service is deployed.",
58+
MarkdownDescription: "Handle of the cluster where the service is deployed.",
59+
Required: true,
60+
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
61+
},
62+
"service": schema.StringAttribute{
63+
Description: "Name the service deployment that should be checked.",
64+
MarkdownDescription: "Name the service deployment that should be checked.",
65+
Required: true,
66+
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
67+
},
68+
"warmup": schema.StringAttribute{
69+
Description: "Initial delay before checking the service deployment health. Defaults to 5 minutes.",
70+
MarkdownDescription: "Initial delay before checking the service deployment health. Defaults to 5 minutes.",
71+
Optional: true,
72+
Computed: true,
73+
Default: stringdefault.StaticString("5m"),
74+
Validators: []validator.String{customvalidator.Duration()},
75+
},
76+
"duration": schema.StringAttribute{
77+
Description: "Maximum duration to wait for the service deployment to become healthy. Minimum 1 minute. Defaults to 10 minutes.",
78+
MarkdownDescription: "Maximum duration to wait for the service deployment to become healthy. Minimum 1 minute. Defaults to 10 minutes.",
79+
Optional: true,
80+
Computed: true,
81+
Default: stringdefault.StaticString("10m"),
82+
Validators: []validator.String{customvalidator.MinDuration(time.Minute)},
83+
},
84+
},
85+
}
86+
}
87+
88+
func (in *serviceWaitResource) Configure(_ context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) {
89+
if request.ProviderData == nil {
90+
return
91+
}
92+
93+
data, ok := request.ProviderData.(*common.ProviderData)
94+
if !ok {
95+
response.Diagnostics.AddError(
96+
"Unexpected Project Resource Configure Type",
97+
fmt.Sprintf("Expected *common.ProviderData, got: %T. Please report this issue to the provider developers.", request.ProviderData),
98+
)
99+
return
100+
}
101+
102+
in.client = data.Client
103+
}
104+
105+
func (in *serviceWaitResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) {
106+
data := new(serviceWait)
107+
response.Diagnostics.Append(request.Plan.Get(ctx, data)...)
108+
if response.Diagnostics.HasError() {
109+
return
110+
}
111+
112+
if err := in.Wait(ctx, data); err != nil {
113+
response.Diagnostics.AddError("Client Error", fmt.Sprintf("Got error while waiting for service: %s", err))
114+
return
115+
}
116+
117+
response.Diagnostics.Append(response.State.Set(ctx, &data)...)
118+
}
119+
120+
func (in *serviceWaitResource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) {
121+
// Ignore.
122+
}
123+
124+
func (in *serviceWaitResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
125+
var data serviceWait
126+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
127+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
128+
}
129+
130+
func (in *serviceWaitResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {
131+
// Ignore.
132+
}
133+
134+
func (in *serviceWaitResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
135+
resource.ImportStatePassthroughID(ctx, path.Root("service_id"), req, resp)
136+
}
137+
138+
func (in *serviceWaitResource) Wait(ctx context.Context, data *serviceWait) error {
139+
warmup, err := data.ParseWarmup()
140+
if err != nil {
141+
return fmt.Errorf("unable to parse warmup duration, got error: %s", err.Error())
142+
}
143+
144+
duration, err := data.ParseDuration()
145+
if err != nil {
146+
return fmt.Errorf("unable to parse duration, got error: %s", err.Error())
147+
}
148+
149+
tflog.Info(ctx, fmt.Sprintf("waiting for warmup period of %s before starting health checks...", warmup))
150+
time.Sleep(warmup)
151+
tflog.Info(ctx, "warmup period completed, starting health checks")
152+
153+
if err = wait.PollUntilContextTimeout(context.Background(), 30*time.Second, duration, true, func(pollCtx context.Context) (done bool, err error) {
154+
service, err := in.client.GetServiceDeploymentByHandle(pollCtx, data.Cluster.ValueString(), data.Service.ValueString())
155+
if err != nil {
156+
tflog.Warn(ctx, fmt.Sprintf("failed to get service %s, got error: %s", data.Service.ValueString(), err.Error()))
157+
return false, nil
158+
}
159+
160+
tflog.Debug(ctx, fmt.Sprintf("service %s is %s", service.ServiceDeployment.ID, service.ServiceDeployment.Status))
161+
return service.ServiceDeployment.Status == console.ServiceDeploymentStatusHealthy, nil
162+
}); err != nil {
163+
tflog.Warn(ctx, fmt.Sprintf("service %s did not become healthy within %s, got error: %s", data.Service.ValueString(), duration, err.Error()))
164+
return fmt.Errorf("service %s did not become healthy within %s, got error: %s", data.Service.ValueString(), duration, err.Error())
165+
}
166+
167+
tflog.Info(ctx, fmt.Sprintf("service %s health check completed successfully", data.Service.ValueString()))
168+
return nil
169+
}

internal/validator/duration.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package validator
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
9+
)
10+
11+
var _ validator.String = durationValidator{}
12+
13+
// durationValidator validates that a string is a valid time.Duration.
14+
type durationValidator struct{}
15+
16+
func (v durationValidator) Description(_ context.Context) string {
17+
return "Value must be a valid duration string (e.g., '5m', '1h30m', '500ms')."
18+
}
19+
20+
func (v durationValidator) MarkdownDescription(ctx context.Context) string {
21+
return v.Description(ctx)
22+
}
23+
24+
func (v durationValidator) ValidateString(_ context.Context, request validator.StringRequest, response *validator.StringResponse) {
25+
if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
26+
return
27+
}
28+
29+
value := request.ConfigValue.ValueString()
30+
31+
if _, err := time.ParseDuration(value); err != nil {
32+
response.Diagnostics.AddAttributeError(
33+
request.Path,
34+
"Invalid Duration",
35+
fmt.Sprintf("Value %q is not a valid duration string: %s. Valid examples: '5m', '1h30m', '500ms'", value, err.Error()),
36+
)
37+
}
38+
}
39+
40+
// Duration returns a validator which ensures that the configured string value
41+
// is a valid time.Duration.
42+
func Duration() validator.String {
43+
return durationValidator{}
44+
}

internal/validator/min_duration.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package validator
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
9+
)
10+
11+
var _ validator.String = minDurationValidator{}
12+
13+
// minDurationValidator validates that a string is a valid time.Duration and meets a minimum value.
14+
type minDurationValidator struct {
15+
minDuration time.Duration
16+
}
17+
18+
func (v minDurationValidator) Description(_ context.Context) string {
19+
return fmt.Sprintf("Value must be a valid duration string and at least %s.", v.minDuration)
20+
}
21+
22+
func (v minDurationValidator) MarkdownDescription(ctx context.Context) string {
23+
return v.Description(ctx)
24+
}
25+
26+
func (v minDurationValidator) ValidateString(_ context.Context, request validator.StringRequest, response *validator.StringResponse) {
27+
if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
28+
return
29+
}
30+
31+
value := request.ConfigValue.ValueString()
32+
33+
duration, err := time.ParseDuration(value)
34+
if err != nil {
35+
response.Diagnostics.AddAttributeError(
36+
request.Path,
37+
"Invalid Duration",
38+
fmt.Sprintf("Value %q is not a valid duration string: %s. Valid examples: '5m', '1h30m', '500ms'", value, err.Error()),
39+
)
40+
return
41+
}
42+
43+
if duration < v.minDuration {
44+
response.Diagnostics.AddAttributeError(
45+
request.Path,
46+
"Duration Too Short",
47+
fmt.Sprintf("Value %q (%s) is less than the minimum allowed duration of %s", value, duration, v.minDuration),
48+
)
49+
}
50+
}
51+
52+
// MinDuration returns a validator which ensures that the configured string value
53+
// is a valid time.Duration and is at least the specified minimum duration.
54+
func MinDuration(minDuration time.Duration) validator.String {
55+
return minDurationValidator{
56+
minDuration: minDuration,
57+
}
58+
}

internal/validator/uuid.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package validator
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"regexp"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
9+
)
10+
11+
var _ validator.String = uuidValidator{}
12+
13+
// uuidValidator validates that a string is a valid UUID (v4 format).
14+
type uuidValidator struct{}
15+
16+
// UUID regex pattern that matches standard UUID format (8-4-4-4-12 hex digits)
17+
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
18+
19+
func (v uuidValidator) Description(_ context.Context) string {
20+
return "Value must be a valid UUID."
21+
}
22+
23+
func (v uuidValidator) MarkdownDescription(ctx context.Context) string {
24+
return v.Description(ctx)
25+
}
26+
27+
func (v uuidValidator) ValidateString(_ context.Context, request validator.StringRequest, response *validator.StringResponse) {
28+
if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
29+
return
30+
}
31+
32+
value := request.ConfigValue.ValueString()
33+
34+
if !uuidRegex.MatchString(value) {
35+
response.Diagnostics.AddAttributeError(
36+
request.Path,
37+
"Invalid UUID",
38+
fmt.Sprintf("Value %q is not a valid UUID. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", value),
39+
)
40+
}
41+
}
42+
43+
// UUID returns a validator which ensures that the configured string value
44+
// is a valid UUID.
45+
func UUID() validator.String {
46+
return uuidValidator{}
47+
}

0 commit comments

Comments
 (0)