package provider import ( "context" "encoding/json" "fmt" "regexp" "strings" "github.com/glueops/autoglue-sdk" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) var _ resource.Resource = &ServerResource{} var _ resource.ResourceWithConfigure = &ServerResource{} var _ resource.ResourceWithImportState = &ServerResource{} type ServerResource struct{ client *Client } func NewServerResource() resource.Resource { return &ServerResource{} } type serverResModel struct { // Identity ID types.String `tfsdk:"id"` OrganizationID types.String `tfsdk:"organization_id"` CreatedAt types.String `tfsdk:"created_at"` UpdatedAt types.String `tfsdk:"updated_at"` // DTO fields Hostname types.String `tfsdk:"hostname"` PrivateIPAddress types.String `tfsdk:"private_ip_address"` PublicIPAddress types.String `tfsdk:"public_ip_address"` Role types.String `tfsdk:"role"` SSHKeyID types.String `tfsdk:"ssh_key_id"` SSHUser types.String `tfsdk:"ssh_user"` Status types.String `tfsdk:"status"` // Raw JSON for debugging Raw types.String `tfsdk:"raw"` } func (r *ServerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_server" } var uuidRx = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`) func (r *ServerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = rschema.Schema{ Description: "Create and manage a server (org-scoped). Mirrors API validation for role/status/ssh_key_id.", Attributes: map[string]rschema.Attribute{ "id": rschema.StringAttribute{ Computed: true, Description: "Server ID (UUID).", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "organization_id": rschema.StringAttribute{Computed: true}, "created_at": rschema.StringAttribute{Computed: true}, "updated_at": rschema.StringAttribute{Computed: true}, "hostname": rschema.StringAttribute{ Required: true, Description: "Hostname.", }, "private_ip_address": rschema.StringAttribute{ Required: true, // API requires on create Description: "Private IP address (required).", }, "public_ip_address": rschema.StringAttribute{ Optional: true, // required only if role=bastion Description: "Public IP address (required when role = bastion).", }, "role": rschema.StringAttribute{ Required: true, // API requires on create Description: "Server role (e.g., agent/manager/bastion). Lowercased by the provider.", }, "ssh_key_id": rschema.StringAttribute{ Required: true, // API requires on create Description: "SSH key ID (UUID) that belongs to the org.", Validators: []validator.String{ stringvalidator.RegexMatches(uuidRx, "must be a valid UUID"), }, }, "ssh_user": rschema.StringAttribute{ Required: true, // API requires on create Description: "SSH username (required).", }, "status": rschema.StringAttribute{ Optional: true, // patchable; if omitted, server sets/returns it Computed: true, Description: "Status (pending|provisioning|ready|failed). Lowercased by the provider.", Validators: []validator.String{ stringvalidator.OneOf("", "pending", "provisioning", "ready", "failed"), }, }, "raw": rschema.StringAttribute{ Computed: true, Description: "Full server JSON from API.", }, }, } } func (r *ServerResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { if req.ProviderData == nil { return } r.client = req.ProviderData.(*Client) } func (r *ServerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { if r.client == nil || r.client.SDK == nil { resp.Diagnostics.AddError("Client not configured", "Provider configuration missing") return } var plan serverResModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } // Normalize + validate against backend rules role := strings.ToLower(strings.TrimSpace(plan.Role.ValueString())) status := strings.ToLower(strings.TrimSpace(plan.Status.ValueString())) pub := strings.TrimSpace(plan.PublicIPAddress.ValueString()) if role == "bastion" && pub == "" { resp.Diagnostics.AddAttributeError( path.Root("public_ip_address"), "Public IP required for bastion", "public_ip_address must be set when role is 'bastion'.", ) return } body := autoglue.DtoCreateServerRequest{ Hostname: stringPtrFromAttr(plan.Hostname), PrivateIpAddress: stringPtrFromAttr(plan.PrivateIPAddress), PublicIpAddress: nil, Role: &role, SshKeyId: stringPtrFromAttr(plan.SSHKeyID), SshUser: stringPtrFromAttr(plan.SSHUser), } if pub != "" { body.PublicIpAddress = &pub } if status != "" { body.Status = &status // validator already checked allowed values } created, httpResp, err := r.client.SDK. ServersAPI. CreateServer(ctx). Body(body). Execute() if err != nil { resp.Diagnostics.AddError("Create server failed", fmt.Sprintf("%v", httpErr(err, httpResp))) return } var state serverResModel r.mapRespToState(created, &state) raw, _ := json.Marshal(created) state.Raw = types.StringValue(string(raw)) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } func (r *ServerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { if r.client == nil || r.client.SDK == nil { resp.Diagnostics.AddError("Client not configured", "Provider configuration missing") return } var state serverResModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } got, httpResp, err := r.client.SDK. ServersAPI. GetServer(ctx, state.ID.ValueString()). Execute() if err != nil { if isNotFound(httpResp) { resp.State.RemoveResource(ctx) return } resp.Diagnostics.AddError("Read server failed", fmt.Sprintf("%v", httpErr(err, httpResp))) return } r.mapRespToState(got, &state) raw, _ := json.Marshal(got) state.Raw = types.StringValue(string(raw)) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } func (r *ServerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { if r.client == nil || r.client.SDK == nil { resp.Diagnostics.AddError("Client not configured", "Provider configuration missing") return } var plan, state serverResModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } patch := autoglue.DtoUpdateServerRequest{} // helpers to set changed fields setIfChanged := func(p types.String, s types.String, setter func(string)) { if p.IsUnknown() || p.IsNull() { return } if s.IsNull() || s.IsUnknown() || p.ValueString() != s.ValueString() { setter(p.ValueString()) } } setIfChanged(plan.Hostname, state.Hostname, func(v string) { patch.Hostname = strPtr(v) }) setIfChanged(plan.PrivateIPAddress, state.PrivateIPAddress, func(v string) { patch.PrivateIpAddress = strPtr(v) }) setIfChanged(plan.PublicIPAddress, state.PublicIPAddress, func(v string) { patch.PublicIpAddress = strPtr(strings.TrimSpace(v)) }) setIfChanged(plan.SSHUser, state.SSHUser, func(v string) { patch.SshUser = strPtr(v) }) // Normalize role/status and enforce rules if !plan.Role.IsNull() && !plan.Role.IsUnknown() { role := strings.ToLower(strings.TrimSpace(plan.Role.ValueString())) if state.Role.IsNull() || state.Role.IsUnknown() || role != strings.ToLower(state.Role.ValueString()) { patch.Role = &role } } if !plan.Status.IsNull() && !plan.Status.IsUnknown() { status := strings.ToLower(strings.TrimSpace(plan.Status.ValueString())) patch.Status = &status } // ssh_key_id: validate UUID via regex at runtime too (gives precise attribute error) if !plan.SSHKeyID.IsNull() && !plan.SSHKeyID.IsUnknown() { if !uuidRx.MatchString(plan.SSHKeyID.ValueString()) { resp.Diagnostics.AddAttributeError( path.Root("ssh_key_id"), "Invalid ssh_key_id", "ssh_key_id must be a valid UUID.", ) return } if state.SSHKeyID.IsNull() || state.SSHKeyID.IsUnknown() || plan.SSHKeyID.ValueString() != state.SSHKeyID.ValueString() { patch.SshKeyId = strPtr(plan.SSHKeyID.ValueString()) } } // Bastion rule: if resulting role == "bastion" ensure resulting public IP is non-empty resultRole := firstNonEmptyLower(plan.Role, state.Role) resultPub := firstNonEmptyTrim(plan.PublicIPAddress, state.PublicIPAddress) if resultRole == "bastion" && resultPub == "" { resp.Diagnostics.AddAttributeError( path.Root("public_ip_address"), "Public IP required for bastion", "public_ip_address must be set when role is 'bastion'.", ) return } if isEmptyUpdateServerRequest(patch) { // Nothing to do; persist state unchanged. resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) return } updated, httpResp, err := r.client.SDK. ServersAPI. UpdateServer(ctx, state.ID.ValueString()). Body(patch). Execute() if err != nil { resp.Diagnostics.AddError("Update server failed", fmt.Sprintf("%v", httpErr(err, httpResp))) return } r.mapRespToState(updated, &state) raw, _ := json.Marshal(updated) state.Raw = types.StringValue(string(raw)) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } func (r *ServerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { if r.client == nil || r.client.SDK == nil { resp.Diagnostics.AddError("Client not configured", "Provider configuration missing") return } var state serverResModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } _, httpResp, err := r.client.SDK. ServersAPI. DeleteServer(ctx, state.ID.ValueString()). Execute() if err != nil && !isNotFound(httpResp) { resp.Diagnostics.AddError("Delete server failed", fmt.Sprintf("%v", httpErr(err, httpResp))) } } func (r *ServerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) } // --- helpers --- func (r *ServerResource) mapRespToState(s *autoglue.DtoServerResponse, out *serverResModel) { out.ID = types.StringPointerValue(s.Id) out.OrganizationID = types.StringPointerValue(s.OrganizationId) out.Hostname = types.StringPointerValue(s.Hostname) out.PrivateIPAddress = types.StringPointerValue(s.PrivateIpAddress) out.PublicIPAddress = types.StringPointerValue(s.PublicIpAddress) out.Role = types.StringPointerValue(s.Role) out.SSHKeyID = types.StringPointerValue(s.SshKeyId) out.SSHUser = types.StringPointerValue(s.SshUser) out.Status = types.StringPointerValue(s.Status) out.CreatedAt = types.StringPointerValue(s.CreatedAt) out.UpdatedAt = types.StringPointerValue(s.UpdatedAt) } func stringPtrFromAttr(a types.String) *string { if a.IsNull() || a.IsUnknown() { return nil } v := a.ValueString() return &v } func strPtr(v string) *string { return &v } func isEmptyUpdateServerRequest(u autoglue.DtoUpdateServerRequest) bool { return u.Hostname == nil && u.PrivateIpAddress == nil && u.PublicIpAddress == nil && u.Role == nil && u.SshKeyId == nil && u.SshUser == nil && u.Status == nil } func firstNonEmptyLower(a, b types.String) string { if !a.IsNull() && !a.IsUnknown() && strings.TrimSpace(a.ValueString()) != "" { return strings.ToLower(strings.TrimSpace(a.ValueString())) } if !b.IsNull() && !b.IsUnknown() { return strings.ToLower(strings.TrimSpace(b.ValueString())) } return "" } func firstNonEmptyTrim(a, b types.String) string { if !a.IsNull() && !a.IsUnknown() && strings.TrimSpace(a.ValueString()) != "" { return strings.TrimSpace(a.ValueString()) } if !b.IsNull() && !b.IsUnknown() { return strings.TrimSpace(b.ValueString()) } return "" }