From 7f3fbd504161a8dc626b332b1e1ed18f5267f4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Tue, 30 Jun 2026 15:42:35 +0400 Subject: [PATCH] add: internal package (provider, core, registrykeys) --- internal/core/client.go | 1237 +++++++++++++++++ internal/core/instance_lookup.go | 178 +++ internal/core/instance_ops.go | 137 ++ internal/generated/bolvan_resource.go | 112 ++ .../generated/bolvan_resource_universal.go | 110 ++ .../bolvan_resource_universal_lifecycle.go | 192 +++ internal/provider/README.md | 132 ++ internal/provider/client_impl.go | 924 ++++++++++++ internal/provider/edge_data_source.go | 144 ++ internal/provider/edge_resource.go | 674 +++++++++ internal/provider/organization_resource.go | 517 +++++++ internal/provider/pgadmin_resource.go | 359 +++++ internal/provider/postgres_resource.go | 1046 ++++++++++++++ internal/provider/provider.go | 197 +++ internal/provider/quick_start_resource.go | 491 +++++++ internal/provider/s3bucket_resource.go | 255 ++++ .../provider/service_instance_data_source.go | 120 ++ internal/provider/tubulus_ai.go | 133 ++ internal/provider/tubulus_resource.go | 1049 ++++++++++++++ internal/provider/validators.go | 155 +++ internal/provider/vapp_data_source.go | 144 ++ internal/provider/vapp_resource.go | 639 +++++++++ internal/provider/vdc_data_source.go | 146 ++ internal/provider/vdc_resource.go | 766 ++++++++++ internal/provider/vm_resource.go | 939 +++++++++++++ 25 files changed, 10796 insertions(+) create mode 100644 internal/core/client.go create mode 100644 internal/core/instance_lookup.go create mode 100644 internal/core/instance_ops.go create mode 100644 internal/generated/bolvan_resource.go create mode 100644 internal/generated/bolvan_resource_universal.go create mode 100644 internal/generated/bolvan_resource_universal_lifecycle.go create mode 100644 internal/provider/README.md create mode 100644 internal/provider/client_impl.go create mode 100644 internal/provider/edge_data_source.go create mode 100644 internal/provider/edge_resource.go create mode 100644 internal/provider/organization_resource.go create mode 100644 internal/provider/pgadmin_resource.go create mode 100644 internal/provider/postgres_resource.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/quick_start_resource.go create mode 100644 internal/provider/s3bucket_resource.go create mode 100644 internal/provider/service_instance_data_source.go create mode 100644 internal/provider/tubulus_ai.go create mode 100644 internal/provider/tubulus_resource.go create mode 100644 internal/provider/validators.go create mode 100644 internal/provider/vapp_data_source.go create mode 100644 internal/provider/vapp_resource.go create mode 100644 internal/provider/vdc_data_source.go create mode 100644 internal/provider/vdc_resource.go create mode 100644 internal/provider/vm_resource.go diff --git a/internal/core/client.go b/internal/core/client.go new file mode 100644 index 0000000..ff9eccd --- /dev/null +++ b/internal/core/client.go @@ -0,0 +1,1237 @@ +package core + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +func debugLog(format string, args ...interface{}) { + f, err := os.OpenFile("/home/naeel/terra/debug_nubes.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, time.Now().Format(time.RFC3339)+": "+format+"\n", args...) +} + +// UniversalClient handles Nubes API logic +type UniversalClient struct { + HttpClient *http.Client + ApiEndpoint string + ApiToken string +} + +// Request models +type genericInstanceReq struct { + ServiceId int `json:"serviceId"` + DisplayName string `json:"displayName"` + Descr string `json:"descr"` +} + +type genericOpReq struct { + InstanceUid string `json:"instanceUid"` + Operation string `json:"operation"` +} + +type genericParamReq struct { + InstanceOperationUid string `json:"instanceOperationUid"` + SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` + ParamValue string `json:"paramValue"` +} + +// Deprecated: Use CreateGenericInstanceUniversalV5 instead. +// This method is kept for backward compatibility and will be removed in v3.0. +func (c *UniversalClient) CreateGenericInstance(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { + // 1. Create Placeholder (POST /instances) + tflog.Debug(ctx, fmt.Sprintf("Creating generic instance placeholder for service %d", serviceId)) + + instPayload := genericInstanceReq{ + ServiceId: serviceId, + DisplayName: displayName, + Descr: "Created via Terraform Universal Provider", + } + + instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) + if err != nil { + return "", err + } + debugLog("Step 1 Resp Body: %s", string(instResp)) + debugLog("Step 1 Resp Headers: %v", instHeaders) + + var instanceUid string + + // Try Location header first + if loc := instHeaders.Get("Location"); loc != "" { + // Location usually looks like "./3A021B21-..." + instanceUid = strings.TrimPrefix(loc, "./") + } + + // If header didn't give us ID, try body + if instanceUid == "" { + var instResult struct { + InstanceUid string `json:"instanceUid"` + } + if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { + instanceUid = instResult.InstanceUid + } else { + // Fallback: maybe the body IS the ID (string)? + var justId string + if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { + instanceUid = justId + } + } + } + + if instanceUid == "" { + return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) + } + + // 2. Init Operation (POST /instanceOperations) + tflog.Debug(ctx, fmt.Sprintf("Initializing create operation for %s", instanceUid)) + opPayload := genericOpReq{ + InstanceUid: instanceUid, + Operation: "create", + } + + opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) + if err != nil { + return "", err + } + debugLog("Step 2 Resp Body: %s", string(opResp)) + debugLog("Step 2 Resp Headers: %v", opHeaders) + + var opUid string + + if loc := opHeaders.Get("Location"); loc != "" { + opUid = strings.TrimPrefix(loc, "./") + } + + if opUid == "" { + var opResult struct { + InstanceOperationUid string `json:"instanceOperationUid"` + } + if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { + opUid = opResult.InstanceOperationUid + } else { + var justId string + if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { + opUid = justId + } + } + } + + if opUid == "" { + return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) + } + + // 3. Send Parameters (POST /instanceOperationCfsParams) + for paramId, value := range params { + tflog.Debug(ctx, fmt.Sprintf("Sending param %d = %s", paramId, value)) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + debugLog("Step 3 Failed for param %d. Error: %v", paramId, err) + return "", fmt.Errorf("failed to set param %d: %w", paramId, err) + } + debugLog("Step 3 OK for param %d", paramId) + } + + // 4. Validate (GET /validate-cfs) + tflog.Debug(ctx, fmt.Sprintf("Validating operation %s", opUid)) + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return "", fmt.Errorf("validation failed: %w", err) + } + + // 5. Execute (POST /run) + tflog.Debug(ctx, fmt.Sprintf("Executing operation %s", opUid)) + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return "", fmt.Errorf("execution failed: %w", err) + } + + // 6. Wait for completion (Simple Polling) + tflog.Info(ctx, "Waiting for resource creation...") + time.Sleep(5 * time.Second) + + return instanceUid, nil +} + +func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) { + var body io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + body = bytes.NewBuffer(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.ApiEndpoint+path, body) + if err != nil { + return nil, nil, err + } + + // Force close connection + req.Close = true + + req.Header.Set("Content-Type", "application/json") + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + + if resp.StatusCode >= 400 { + return nil, nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + } + + return respBody, resp.Header, nil +} + +// ===== UNIVERSAL FLOW (APPEND-ONLY) ===== + +type universalOpResponse struct { + InstanceOperation universalOperation `json:"instanceOperation"` +} + +type universalOperation struct { + CfsParams []universalCfsParam `json:"cfsParams"` +} + +type universalCfsParam struct { + SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` + ParamValue *string `json:"paramValue"` + DefaultValue *string `json:"defaultValue"` + DataType string `json:"dataType"` +} + +// Deprecated: Use CreateGenericInstanceUniversalV5 instead. +// This method is kept for backward compatibility and will be removed in v3.0. +// CreateGenericInstanceUniversal реализует универсальный Nubes Flow: +// instances -> instanceOperations -> get cfsParams -> submit params -> validate -> run +func (c *UniversalClient) CreateGenericInstanceUniversal(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { + // 1. Create Placeholder (POST /instances) + instPayload := genericInstanceReq{ + ServiceId: serviceId, + DisplayName: displayName, + Descr: "Created via Terraform Universal Provider", + } + + instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) + if err != nil { + return "", err + } + + var instanceUid string + if loc := instHeaders.Get("Location"); loc != "" { + instanceUid = strings.TrimPrefix(loc, "./") + } + if instanceUid == "" { + var instResult struct { + InstanceUid string `json:"instanceUid"` + } + if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { + instanceUid = instResult.InstanceUid + } else { + var justId string + if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { + instanceUid = justId + } + } + } + if instanceUid == "" { + return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) + } + + // 2. Init Operation (POST /instanceOperations) + opPayload := genericOpReq{ + InstanceUid: instanceUid, + Operation: "create", + } + + opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) + if err != nil { + return "", err + } + + var opUid string + if loc := opHeaders.Get("Location"); loc != "" { + opUid = strings.TrimPrefix(loc, "./") + } + if opUid == "" { + var opResult struct { + InstanceOperationUid string `json:"instanceOperationUid"` + } + if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { + opUid = opResult.InstanceOperationUid + } else { + var justId string + if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { + opUid = justId + } + } + } + if opUid == "" { + return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) + } + + // 3. Get operation details (cfsParams) + opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) + if err != nil { + return "", fmt.Errorf("failed to get operation details: %w", err) + } + var opDetails universalOpResponse + if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { + return "", fmt.Errorf("failed to parse operation details: %w", err) + } + + // 4. Submit explicit params + sent := make(map[int]bool) + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to set param %d: %w", paramId, err) + } + sent[paramId] = true + } + + // 5. Submit defaults for missing params + for _, param := range opDetails.InstanceOperation.CfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + + val := "" + if param.ParamValue != nil { + val = *param.ParamValue + } else if param.DefaultValue != nil { + val = *param.DefaultValue + } + val = normalizeUniversalValue(val, param.DataType) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: val, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) + } + } + + // 6. Validate (GET /validate-cfs) + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return "", fmt.Errorf("validation failed: %w", err) + } + + // 7. Execute (POST /run) + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return "", fmt.Errorf("execution failed: %w", err) + } + + // 8. Wait for completion (Simple Polling) + time.Sleep(5 * time.Second) + + return instanceUid, nil +} + +func normalizeUniversalValue(val string, dataType string) string { + if val != "" { + return val + } + + switch strings.ToLower(dataType) { + case "map", "json": + return "{}" + case "array", "list": + return "[]" + default: + return val + } +} + +// ===== UNIVERSAL FLOW V2 (APPEND-ONLY) ===== + +type universalOpResponseV2 struct { + InstanceOperation universalOperationV2 `json:"instanceOperation"` +} + +type universalOperationV2 struct { + CfsParams []universalCfsParamV2 `json:"cfsParams"` +} + +type universalCfsParamV2 struct { + SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` + ParamValue *string `json:"paramValue"` + DefaultValue *string `json:"defaultValue"` + DataType string `json:"dataType"` + Name string `json:"name"` + Code string `json:"code"` + SvcOperationCfsParam string `json:"svcOperationCfsParam"` +} + +// Deprecated: Use CreateGenericInstanceUniversalV5 instead. +// This method is kept for backward compatibility and will be removed in v3.0. +// CreateGenericInstanceUniversalV2 - расширенная нормализация значений +// (map/json/list/array) с учетом name/code/svcOperationCfsParam +func (c *UniversalClient) CreateGenericInstanceUniversalV2(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { + // 1. Create Placeholder (POST /instances) + instPayload := genericInstanceReq{ + ServiceId: serviceId, + DisplayName: displayName, + Descr: "Created via Terraform Universal Provider", + } + + instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) + if err != nil { + return "", err + } + + var instanceUid string + if loc := instHeaders.Get("Location"); loc != "" { + instanceUid = strings.TrimPrefix(loc, "./") + } + if instanceUid == "" { + var instResult struct { + InstanceUid string `json:"instanceUid"` + } + if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { + instanceUid = instResult.InstanceUid + } else { + var justId string + if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { + instanceUid = justId + } + } + } + if instanceUid == "" { + return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) + } + + // 2. Init Operation (POST /instanceOperations) + opPayload := genericOpReq{ + InstanceUid: instanceUid, + Operation: "create", + } + + opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) + if err != nil { + return "", err + } + + var opUid string + if loc := opHeaders.Get("Location"); loc != "" { + opUid = strings.TrimPrefix(loc, "./") + } + if opUid == "" { + var opResult struct { + InstanceOperationUid string `json:"instanceOperationUid"` + } + if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { + opUid = opResult.InstanceOperationUid + } else { + var justId string + if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { + opUid = justId + } + } + } + if opUid == "" { + return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) + } + + // 3. Get operation details (cfsParams) + opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) + if err != nil { + return "", fmt.Errorf("failed to get operation details: %w", err) + } + var opDetails universalOpResponseV2 + if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { + return "", fmt.Errorf("failed to parse operation details: %w", err) + } + + // 4. Submit explicit params + sent := make(map[int]bool) + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to set param %d: %w", paramId, err) + } + sent[paramId] = true + } + + // 5. Submit defaults for missing params + for _, param := range opDetails.InstanceOperation.CfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + + val := "" + if param.ParamValue != nil { + val = *param.ParamValue + } else if param.DefaultValue != nil { + val = *param.DefaultValue + } + val = normalizeUniversalValueV2(val, param) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: val, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) + } + } + + // 6. Validate (GET /validate-cfs) + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return "", fmt.Errorf("validation failed: %w", err) + } + + // 7. Execute (POST /run) + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return "", fmt.Errorf("execution failed: %w", err) + } + + // 8. Wait for completion (Simple Polling) + time.Sleep(5 * time.Second) + + return instanceUid, nil +} + +func normalizeUniversalValueV2(val string, param universalCfsParamV2) string { + if val != "" { + return val + } + + dataType := strings.ToLower(param.DataType) + nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.SvcOperationCfsParam) + + if dataType == "map" || dataType == "json" || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { + return "{}" + } + if dataType == "array" || dataType == "list" || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { + return "[]" + } + + return val +} + +// ===== UNIVERSAL FLOW V3 (APPEND-ONLY) ===== + +type universalOpResponseV3 struct { + InstanceOperation universalOperationV3 `json:"instanceOperation"` +} + +type universalOperationV3 struct { + CfsParams []universalCfsParamV3 `json:"cfsParams"` +} + +type universalCfsParamV3 struct { + SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` + ParamValue *string `json:"paramValue"` + DefaultValue *string `json:"defaultValue"` + DataType string `json:"dataType"` + Name string `json:"name"` + Code string `json:"code"` + Label string `json:"label"` + SvcOperationCfsParam string `json:"svcOperationCfsParam"` +} + +// Deprecated: Use CreateGenericInstanceUniversalV5 instead. +// This method is kept for backward compatibility and will be removed in v3.0. +// CreateGenericInstanceUniversalV3 - расширенная нормализация (label/name/code) +func (c *UniversalClient) CreateGenericInstanceUniversalV3(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { + // 1. Create Placeholder (POST /instances) + instPayload := genericInstanceReq{ + ServiceId: serviceId, + DisplayName: displayName, + Descr: "Created via Terraform Universal Provider", + } + + instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) + if err != nil { + return "", err + } + + var instanceUid string + if loc := instHeaders.Get("Location"); loc != "" { + instanceUid = strings.TrimPrefix(loc, "./") + } + if instanceUid == "" { + var instResult struct { + InstanceUid string `json:"instanceUid"` + } + if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { + instanceUid = instResult.InstanceUid + } else { + var justId string + if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { + instanceUid = justId + } + } + } + if instanceUid == "" { + return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) + } + + // 2. Init Operation (POST /instanceOperations) + opPayload := genericOpReq{ + InstanceUid: instanceUid, + Operation: "create", + } + + opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) + if err != nil { + return "", err + } + + var opUid string + if loc := opHeaders.Get("Location"); loc != "" { + opUid = strings.TrimPrefix(loc, "./") + } + if opUid == "" { + var opResult struct { + InstanceOperationUid string `json:"instanceOperationUid"` + } + if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { + opUid = opResult.InstanceOperationUid + } else { + var justId string + if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { + opUid = justId + } + } + } + if opUid == "" { + return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) + } + + // 3. Get operation details (cfsParams) + opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) + if err != nil { + return "", fmt.Errorf("failed to get operation details: %w", err) + } + var opDetails universalOpResponseV3 + if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { + return "", fmt.Errorf("failed to parse operation details: %w", err) + } + + // Debug log of cfsParams (for type mismatch analysis) + for _, p := range opDetails.InstanceOperation.CfsParams { + debugLog("CFS Param: id=%d dataType=%s name=%s code=%s label=%s svcParam=%s", p.SvcOperationCfsParamId, p.DataType, p.Name, p.Code, p.Label, p.SvcOperationCfsParam) + } + + // 4. Submit explicit params + sent := make(map[int]bool) + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to set param %d: %w", paramId, err) + } + sent[paramId] = true + } + + // 5. Submit defaults for missing params + for _, param := range opDetails.InstanceOperation.CfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + + val := "" + if param.ParamValue != nil { + val = *param.ParamValue + } else if param.DefaultValue != nil { + val = *param.DefaultValue + } + val = normalizeUniversalValueV3(val, param) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: val, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) + } + } + + // 6. Validate (GET /validate-cfs) + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return "", fmt.Errorf("validation failed: %w", err) + } + + // 7. Execute (POST /run) + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return "", fmt.Errorf("execution failed: %w", err) + } + + // 8. Wait for completion (Simple Polling) + time.Sleep(5 * time.Second) + + return instanceUid, nil +} + +func normalizeUniversalValueV3(val string, param universalCfsParamV3) string { + if val != "" { + return val + } + + dataType := strings.ToLower(param.DataType) + nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) + + if dataType == "map" || dataType == "json" || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { + return "{}" + } + if dataType == "array" || dataType == "list" || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { + return "[]" + } + + return val +} + +// ===== UNIVERSAL FLOW V4 (APPEND-ONLY) ===== + +// Deprecated: Use CreateGenericInstanceUniversalV5 instead. +// This method is kept for backward compatibility and will be removed in v3.0. +// CreateGenericInstanceUniversalV4 - нормализация с trim + расширенный разбор dataType +func (c *UniversalClient) CreateGenericInstanceUniversalV4(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { + // 1. Create Placeholder (POST /instances) + instPayload := genericInstanceReq{ + ServiceId: serviceId, + DisplayName: displayName, + Descr: "Created via Terraform Universal Provider", + } + + instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) + if err != nil { + return "", err + } + + var instanceUid string + if loc := instHeaders.Get("Location"); loc != "" { + instanceUid = strings.TrimPrefix(loc, "./") + } + if instanceUid == "" { + var instResult struct { + InstanceUid string `json:"instanceUid"` + } + if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { + instanceUid = instResult.InstanceUid + } else { + var justId string + if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { + instanceUid = justId + } + } + } + if instanceUid == "" { + return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) + } + + // 2. Init Operation (POST /instanceOperations) + opPayload := genericOpReq{ + InstanceUid: instanceUid, + Operation: "create", + } + + opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) + if err != nil { + return "", err + } + + var opUid string + if loc := opHeaders.Get("Location"); loc != "" { + opUid = strings.TrimPrefix(loc, "./") + } + if opUid == "" { + var opResult struct { + InstanceOperationUid string `json:"instanceOperationUid"` + } + if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { + opUid = opResult.InstanceOperationUid + } else { + var justId string + if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { + opUid = justId + } + } + } + if opUid == "" { + return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) + } + + // 3. Get operation details (cfsParams) + opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) + if err != nil { + return "", fmt.Errorf("failed to get operation details: %w", err) + } + var opDetails universalOpResponseV3 + if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { + return "", fmt.Errorf("failed to parse operation details: %w", err) + } + + // Debug log of cfsParams (for type mismatch analysis) + for _, p := range opDetails.InstanceOperation.CfsParams { + debugLog("CFS Param: id=%d dataType=%s name=%s code=%s label=%s svcParam=%s", p.SvcOperationCfsParamId, p.DataType, p.Name, p.Code, p.Label, p.SvcOperationCfsParam) + } + + // 4. Submit explicit params + sent := make(map[int]bool) + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to set param %d: %w", paramId, err) + } + sent[paramId] = true + } + + // 5. Submit defaults for missing params + for _, param := range opDetails.InstanceOperation.CfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + + val := "" + if param.ParamValue != nil { + val = *param.ParamValue + } else if param.DefaultValue != nil { + val = *param.DefaultValue + } + val = normalizeUniversalValueV4(val, param) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: val, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) + } + } + + // 6. Validate (GET /validate-cfs) + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return "", fmt.Errorf("validation failed: %w", err) + } + + // 7. Execute (POST /run) + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return "", fmt.Errorf("execution failed: %w", err) + } + + // 8. Wait for completion (Simple Polling) + time.Sleep(5 * time.Second) + + return instanceUid, nil +} + +func normalizeUniversalValueV4(val string, param universalCfsParamV3) string { + trimmed := strings.TrimSpace(val) + if trimmed != "" { + return trimmed + } + + dataType := strings.ToLower(param.DataType) + nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) + + if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { + return "[]" + } + if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { + return "{}" + } + + return trimmed +} + +// ===== UNIVERSAL FLOW V5 (APPEND-ONLY) ===== + +// CreateGenericInstanceUniversalV5 - добавляет логирование отправляемых значений +func (c *UniversalClient) CreateGenericInstanceUniversalV5(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { + // 1. Create Placeholder (POST /instances) + instPayload := genericInstanceReq{ + ServiceId: serviceId, + DisplayName: displayName, + Descr: "Created via Terraform Universal Provider", + } + + instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) + if err != nil { + return "", err + } + + var instanceUid string + if loc := instHeaders.Get("Location"); loc != "" { + instanceUid = strings.TrimPrefix(loc, "./") + } + if instanceUid == "" { + var instResult struct { + InstanceUid string `json:"instanceUid"` + } + if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { + instanceUid = instResult.InstanceUid + } else { + var justId string + if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { + instanceUid = justId + } + } + } + if instanceUid == "" { + return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) + } + + // 2. Init Operation (POST /instanceOperations) + opPayload := genericOpReq{ + InstanceUid: instanceUid, + Operation: "create", + } + + opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) + if err != nil { + return "", err + } + + var opUid string + if loc := opHeaders.Get("Location"); loc != "" { + opUid = strings.TrimPrefix(loc, "./") + } + if opUid == "" { + var opResult struct { + InstanceOperationUid string `json:"instanceOperationUid"` + } + if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { + opUid = opResult.InstanceOperationUid + } else { + var justId string + if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { + opUid = justId + } + } + } + if opUid == "" { + return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) + } + + // 3. Get operation details (cfsParams) + opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) + if err != nil { + return "", fmt.Errorf("failed to get operation details: %w", err) + } + var opDetails universalOpResponseV3 + if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { + return "", fmt.Errorf("failed to parse operation details: %w", err) + } + + // Debug log of cfsParams (for type mismatch analysis) + for _, p := range opDetails.InstanceOperation.CfsParams { + debugLog("CFS Param: id=%d dataType=%s name=%s code=%s label=%s svcParam=%s", p.SvcOperationCfsParamId, p.DataType, p.Name, p.Code, p.Label, p.SvcOperationCfsParam) + } + + // 4. Submit explicit params + sent := make(map[int]bool) + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to set param %d: %w", paramId, err) + } + sent[paramId] = true + } + + // 5. Submit defaults for missing params + for _, param := range opDetails.InstanceOperation.CfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + + val := "" + if param.ParamValue != nil { + val = *param.ParamValue + } else if param.DefaultValue != nil { + val = *param.DefaultValue + } + normalized := normalizeUniversalValueV5(val, param) + debugLog("CFS Send: id=%d raw=%q normalized=%q", param.SvcOperationCfsParamId, val, normalized) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: normalized, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) + } + } + + // 6. Validate (GET /validate-cfs) + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return "", fmt.Errorf("validation failed: %w", err) + } + + // 7. Execute (POST /run) + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return "", fmt.Errorf("execution failed: %w", err) + } + + // 8. Wait for completion (Simple Polling) + time.Sleep(5 * time.Second) + + return instanceUid, nil +} + +func normalizeUniversalValueV5(val string, param universalCfsParamV3) string { + trimmed := strings.TrimSpace(val) + if strings.EqualFold(trimmed, "null") { + trimmed = "" + } + if trimmed != "" { + return trimmed + } + + dataType := strings.ToLower(param.DataType) + nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) + + if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { + return "[]" + } + if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { + return "{}" + } + + return trimmed +} + +// ===== UNIVERSAL FLOW V6 (APPEND-ONLY) ===== + +// CreateGenericInstanceUniversalV6 - обработка raw="\"\"" для map/json/array/list +func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { + // 1. Create Placeholder (POST /instances) + instPayload := genericInstanceReq{ + ServiceId: serviceId, + DisplayName: displayName, + Descr: "Created via Terraform Universal Provider", + } + + instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) + if err != nil { + return "", err + } + + var instanceUid string + if loc := instHeaders.Get("Location"); loc != "" { + instanceUid = strings.TrimPrefix(loc, "./") + } + if instanceUid == "" { + var instResult struct { + InstanceUid string `json:"instanceUid"` + } + if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { + instanceUid = instResult.InstanceUid + } else { + var justId string + if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { + instanceUid = justId + } + } + } + if instanceUid == "" { + return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) + } + + // 2. Init Operation (POST /instanceOperations) + opPayload := genericOpReq{ + InstanceUid: instanceUid, + Operation: "create", + } + + opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) + if err != nil { + return "", err + } + + var opUid string + if loc := opHeaders.Get("Location"); loc != "" { + opUid = strings.TrimPrefix(loc, "./") + } + if opUid == "" { + var opResult struct { + InstanceOperationUid string `json:"instanceOperationUid"` + } + if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { + opUid = opResult.InstanceOperationUid + } else { + var justId string + if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { + opUid = justId + } + } + } + if opUid == "" { + return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) + } + + // 3. Get operation details (cfsParams) + opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) + if err != nil { + return "", fmt.Errorf("failed to get operation details: %w", err) + } + var opDetails universalOpResponseV3 + if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { + return "", fmt.Errorf("failed to parse operation details: %w", err) + } + + for _, p := range opDetails.InstanceOperation.CfsParams { + debugLog("CFS Param: id=%d dataType=%s name=%s code=%s label=%s svcParam=%s", p.SvcOperationCfsParamId, p.DataType, p.Name, p.Code, p.Label, p.SvcOperationCfsParam) + } + + // 4. Submit explicit params + sent := make(map[int]bool) + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to set param %d: %w", paramId, err) + } + sent[paramId] = true + } + + // 5. Submit defaults for missing params + for _, param := range opDetails.InstanceOperation.CfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + + val := "" + if param.ParamValue != nil { + val = *param.ParamValue + } else if param.DefaultValue != nil { + val = *param.DefaultValue + } + normalized := normalizeUniversalValueV6(val, param) + debugLog("CFS Send: id=%d raw=%q normalized=%q", param.SvcOperationCfsParamId, val, normalized) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: normalized, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) + } + } + + // 6. Validate (GET /validate-cfs) + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return "", fmt.Errorf("validation failed: %w", err) + } + + // 7. Execute (POST /run) + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return "", fmt.Errorf("execution failed: %w", err) + } + + // 8. Wait for completion (Simple Polling) + time.Sleep(5 * time.Second) + + return instanceUid, nil +} + +func normalizeUniversalValueV6(val string, param universalCfsParamV3) string { + trimmed := strings.TrimSpace(val) + if strings.EqualFold(trimmed, "null") { + trimmed = "" + } + // Handle JSON-encoded empty string: "" + if trimmed == "\"\"" { + trimmed = "" + } + if trimmed != "" { + return trimmed + } + + dataType := strings.ToLower(param.DataType) + nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) + + if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { + return "[]" + } + if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { + return "{}" + } + + return trimmed +} diff --git a/internal/core/instance_lookup.go b/internal/core/instance_lookup.go new file mode 100644 index 0000000..5903e20 --- /dev/null +++ b/internal/core/instance_lookup.go @@ -0,0 +1,178 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +type ApiOperation struct { + SvcOperationId int `json:"svcOperationId"` + Operation string `json:"operation"` +} + +type InstanceStateResponse struct { + InstanceUid string `json:"instanceUid"` + ExplainedStatus string `json:"explainedStatus"` + IsDeleted bool `json:"isDeleted"` + OperationIsInProgress bool `json:"operationIsInProgress"` + OperationIsPending bool `json:"operationIsPending"` + AvailableOperations []ApiOperation `json:"availableOperations"` +} + +// FindInstanceByDisplayName finds an instance by display_name for a given serviceId. +// Если найдено больше одного non-deleted инстанса — возвращает ошибку. +func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, serviceId int, displayName string) (*InstanceStateResponse, error) { + all, err := c.FindAllInstancesByDisplayName(ctx, serviceId, displayName) + if err != nil { + return nil, err + } + if len(all) == 0 { + return nil, nil + } + if len(all) == 1 { + return &all[0], nil + } + // Множественные non-deleted инстансы: пробуем выбрать единственный running/suspended + var nonDeleted []InstanceStateResponse + for _, inst := range all { + if !inst.IsDeleted { + nonDeleted = append(nonDeleted, inst) + } + } + if len(nonDeleted) == 0 { + return nil, nil + } + if len(nonDeleted) == 1 { + return &nonDeleted[0], nil + } + // Больше одного — формируем понятное сообщение + details := make([]string, 0, len(nonDeleted)) + for _, inst := range nonDeleted { + details = append(details, fmt.Sprintf(" - %s (статус: %s)", inst.InstanceUid, inst.ExplainedStatus)) + } + return nil, fmt.Errorf( + "обнаружено %d инстансов с именем '%s' (serviceId=%d):\n%s\nНевозможно определить какой adopt-ить. Удалите лишние через ЛК (Управление облаком) или укажите конкретный UUID через 'terraform import'", + len(nonDeleted), displayName, serviceId, strings.Join(details, "\n"), + ) +} + +func (c *UniversalClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { + respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) + if err != nil { + return nil, err + } + + var res struct { + Instance InstanceStateResponse `json:"instance"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + return nil, err + } + + if err := validateInstanceStatus(&res.Instance); err != nil { + return nil, err + } + + return &res.Instance, nil +} + +// GetInstanceStateRaw получает состояние инстанса БЕЗ валидации статуса. +// Используется для проверки ref-параметров: нужно читать даже deleted/suspended инстансы. +func (c *UniversalClient) GetInstanceStateRaw(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { + respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) + if err != nil { + return nil, err + } + + var res struct { + Instance InstanceStateResponse `json:"instance"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + return nil, err + } + + return &res.Instance, nil +} + +// FindAllInstancesByDisplayName находит ВСЕ non-deleted инстансы по display_name и serviceId. +// Возвращает slice, чтобы caller мог обработать дубликаты. +func (c *UniversalClient) FindAllInstancesByDisplayName(ctx context.Context, serviceId int, displayName string) ([]InstanceStateResponse, error) { + var found []InstanceStateResponse + page := 1 + for { + path := fmt.Sprintf("/instances?page=%d&size=100", page) + respBody, _, err := c.doRequest(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + + var res struct { + Results []struct { + InstanceUid string `json:"instanceUid"` + DisplayName string `json:"displayName"` + ServiceId int `json:"serviceId"` + } `json:"results"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + return nil, err + } + + if len(res.Results) == 0 { + break + } + + for _, item := range res.Results { + if item.ServiceId == serviceId && strings.EqualFold(item.DisplayName, displayName) { + state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid) + if err != nil { + return nil, err + } + if state != nil && !isInstanceDeleted(state) { + found = append(found, *state) + } + } + } + + page++ + if page > 100 { + break + } + } + + return found, nil +} + +func isInstanceDeleted(state *InstanceStateResponse) bool { + if state == nil { + return false + } + if state.IsDeleted { + return true + } + return strings.EqualFold(strings.TrimSpace(state.ExplainedStatus), "deleted") +} + +func validateInstanceStatus(state *InstanceStateResponse) error { + if state == nil { + return fmt.Errorf("missing instance state") + } + if state.IsDeleted { + return fmt.Errorf("instance %s is deleted", state.InstanceUid) + } + if state.OperationIsPending || state.OperationIsInProgress { + return fmt.Errorf("instance %s not ready: operation pending", state.InstanceUid) + } + status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) + if strings.Contains(status, "not created") { + return fmt.Errorf("instance %s not created", state.InstanceUid) + } + if strings.Contains(status, "pending") { + return fmt.Errorf("instance %s pending", state.InstanceUid) + } + if strings.Contains(status, "failed") || strings.Contains(status, "error") { + return fmt.Errorf("instance %s failed: %s", state.InstanceUid, state.ExplainedStatus) + } + return nil +} diff --git a/internal/core/instance_ops.go b/internal/core/instance_ops.go new file mode 100644 index 0000000..ec9cf70 --- /dev/null +++ b/internal/core/instance_ops.go @@ -0,0 +1,137 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// RunInstanceOperationUniversal runs an available operation (modify/suspend/delete/resume) if possible. +func (c *UniversalClient) RunInstanceOperationUniversal(ctx context.Context, instanceUid string, action string, params map[int]string) error { + state, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + return err + } + + var opId int + for _, op := range state.AvailableOperations { + if strings.EqualFold(op.Operation, action) { + opId = op.SvcOperationId + break + } + } + if opId == 0 { + return fmt.Errorf("action %s not available for instance %s", action, instanceUid) + } + + payload := map[string]interface{}{ + "instanceUid": instanceUid, + "svcOperationId": opId, + "operation": action, + } + + opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true) + if err != nil { + return fmt.Errorf("failed to create %s operation: %w", action, err) + } + if opUid == "" { + return fmt.Errorf("failed to get operation UID for %s", action) + } + + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return fmt.Errorf("failed to set param %d: %w", paramId, err) + } + } + + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return err + } + + return c.waitForOperationFinish(ctx, opUid, defaultOperationTimeout) +} + +const defaultOperationTimeout = 30 * time.Minute + +type operationStatusResponse struct { + InstanceOperation struct { + DtFinish *string `json:"dtFinish"` + IsSuccessful *bool `json:"isSuccessful"` + ErrorLog *string `json:"errorLog"` + IsInProgress bool `json:"isInProgress"` + IsPending bool `json:"isPending"` + } `json:"instanceOperation"` +} + +func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("operation %s cancelled", opUid) + case <-ticker.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for operation %s to finish", opUid) + } + + respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s", opUid), nil) + if err != nil { + return fmt.Errorf("failed to check operation %s status: %w", opUid, err) + } + + var status operationStatusResponse + if err := json.Unmarshal(respBody, &status); err != nil { + return fmt.Errorf("failed to parse operation %s status: %w", opUid, err) + } + + if status.InstanceOperation.DtFinish != nil && strings.TrimSpace(*status.InstanceOperation.DtFinish) != "" { + if status.InstanceOperation.IsSuccessful != nil && !*status.InstanceOperation.IsSuccessful { + if status.InstanceOperation.ErrorLog != nil && strings.TrimSpace(*status.InstanceOperation.ErrorLog) != "" { + return fmt.Errorf("operation %s failed: %s", opUid, *status.InstanceOperation.ErrorLog) + } + return fmt.Errorf("operation %s failed", opUid) + } + return nil + } + } + } +} + +func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) { + respBody, headers, err := c.doRequest(ctx, "POST", path, payload) + if err != nil { + return "", err + } + + if returnLocation { + if loc := headers.Get("Location"); loc != "" { + return extractUIDFromLocation(loc), nil + } + } + + var justId string + if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" { + return justId, nil + } + + return "", nil +} + +func extractUIDFromLocation(loc string) string { + if loc == "" { + return "" + } + return strings.TrimPrefix(loc, "./") +} diff --git a/internal/generated/bolvan_resource.go b/internal/generated/bolvan_resource.go new file mode 100644 index 0000000..94d98d9 --- /dev/null +++ b/internal/generated/bolvan_resource.go @@ -0,0 +1,112 @@ +package generated + +import ( + "context" + "strconv" + + "terraform-provider-mycloud/internal/core" + // "terraform-provider-mycloud/internal/provider" REMOVED CYCLE + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ========= RESOURCE DEFINITION (Auto-Generated) ========= +// Service: Bolvanka (Dummy) +// Service ID: 1 + +var _ resource.Resource = &BolvankaResource{} + +func NewBolvankaResource() resource.Resource { + return &BolvankaResource{} +} + +type BolvankaResource struct { + client *core.UniversalClient +} + +type BolvankaResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + + // Params + DurationMs types.Int64 `tfsdk:"duration_ms"` // ID 198 + FailAtStart types.Bool `tfsdk:"fail_at_start"` // ID 199 +} + +func (r *BolvankaResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_bolvanka" +} + +func (r *BolvankaResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true}, + "display_name": schema.StringAttribute{Required: true}, + "duration_ms": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "Sleep duration in ms (Param 198)", + }, + "fail_at_start": schema.BoolAttribute{ + Optional: true, + MarkdownDescription: "Fail immediately (Param 199)", + }, + }, + } +} + +func (r *BolvankaResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data BolvankaResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // === MAP PARAMETERS (The Stringifier Logic) === + params := make(map[int]string) + + // 198: durationMs (int -> string) + params[198] = strconv.FormatInt(data.DurationMs.ValueInt64(), 10) + + // 199: failAtStart (bool -> string) + valRef := "false" + if data.FailAtStart.ValueBool() { + valRef = "true" + } + params[199] = valRef + + // Call Core + id, err := r.client.CreateGenericInstance(ctx, 1, data.DisplayName.ValueString(), params) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + + data.ID = types.StringValue(id) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *BolvankaResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Implemented as shim for now +} + +func (r *BolvankaResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +} +func (r *BolvankaResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { +} + +func (r *BolvankaResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + // Assuming Provider passes the correct *UniversalClient or compatible interface + client, ok := req.ProviderData.(*core.UniversalClient) + if !ok { + resp.Diagnostics.AddError("Error", "Wrong client type expected *core.UniversalClient") + return + } + + r.client = client +} diff --git a/internal/generated/bolvan_resource_universal.go b/internal/generated/bolvan_resource_universal.go new file mode 100644 index 0000000..2f2c9ee --- /dev/null +++ b/internal/generated/bolvan_resource_universal.go @@ -0,0 +1,110 @@ +package generated + +import ( + "context" + "strconv" + + "terraform-provider-mycloud/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ========= RESOURCE DEFINITION (Auto-Generated, Universal Flow) ========= +// Service: Bolvanka (Dummy) +// Service ID: 1 + +var _ resource.Resource = &BolvankaUniversalResource{} + +func NewBolvankaUniversalResource() resource.Resource { + return &BolvankaUniversalResource{} +} + +type BolvankaUniversalResource struct { + client *core.UniversalClient +} + +type BolvankaUniversalResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + + // Params + DurationMs types.Int64 `tfsdk:"duration_ms"` // ID 198 + FailAtStart types.Bool `tfsdk:"fail_at_start"` // ID 199 +} + +func (r *BolvankaUniversalResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_bolvanka_universal" +} + +func (r *BolvankaUniversalResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true}, + "display_name": schema.StringAttribute{Required: true}, + "duration_ms": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "Sleep duration in ms (Param 198)", + }, + "fail_at_start": schema.BoolAttribute{ + Optional: true, + MarkdownDescription: "Fail immediately (Param 199)", + }, + }, + } +} + +func (r *BolvankaUniversalResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data BolvankaUniversalResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // === MAP PARAMETERS (The Stringifier Logic) === + params := make(map[int]string) + + // 198: durationMs (int -> string) + params[198] = strconv.FormatInt(data.DurationMs.ValueInt64(), 10) + + // 199: failAtStart (bool -> string) + valRef := "false" + if data.FailAtStart.ValueBool() { + valRef = "true" + } + params[199] = valRef + + // Call Core (Universal Flow V6) + id, err := r.client.CreateGenericInstanceUniversalV6(ctx, 1, data.DisplayName.ValueString(), params) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + + data.ID = types.StringValue(id) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *BolvankaUniversalResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Implemented as shim for now +} + +func (r *BolvankaUniversalResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +} +func (r *BolvankaUniversalResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { +} + +func (r *BolvankaUniversalResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*core.UniversalClient) + if !ok { + resp.Diagnostics.AddError("Error", "Wrong client type expected *core.UniversalClient") + return + } + + r.client = client +} diff --git a/internal/generated/bolvan_resource_universal_lifecycle.go b/internal/generated/bolvan_resource_universal_lifecycle.go new file mode 100644 index 0000000..d67be1a --- /dev/null +++ b/internal/generated/bolvan_resource_universal_lifecycle.go @@ -0,0 +1,192 @@ +package generated + +import ( + "context" + "strconv" + "strings" + + "terraform-provider-mycloud/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ========= RESOURCE DEFINITION (Auto-Generated, Universal Flow + Lifecycle) ========= +// Service: Bolvanka (Dummy) +// Service ID: 1 + +var _ resource.Resource = &BolvankaUniversalLifecycleResource{} + +func NewBolvankaUniversalLifecycleResource() resource.Resource { + return &BolvankaUniversalLifecycleResource{} +} + +type BolvankaUniversalLifecycleResource struct { + client *core.UniversalClient +} + +type BolvankaUniversalLifecycleModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + DurationMs types.Int64 `tfsdk:"duration_ms"` + FailAtStart types.Bool `tfsdk:"fail_at_start"` + DeleteMode types.String `tfsdk:"delete_mode"` + ResumeIfExists types.Bool `tfsdk:"resume_if_exists"` +} + +func (r *BolvankaUniversalLifecycleResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_bolvanka_universal_lifecycle" +} + +func (r *BolvankaUniversalLifecycleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "display_name": schema.StringAttribute{ + Required: true, + }, + "duration_ms": schema.Int64Attribute{ + Required: true, + MarkdownDescription: "Sleep duration in ms (Param 198)", + }, + "fail_at_start": schema.BoolAttribute{ + Optional: true, + MarkdownDescription: "Fail immediately (Param 199)", + }, + "delete_mode": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("state_only"), + MarkdownDescription: "Deletion mode: delete | suspend | state_only", + }, + "resume_if_exists": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + MarkdownDescription: "If true, attempt resume/adopt when instance already exists", + }, + }, + } +} + +func (r *BolvankaUniversalLifecycleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data BolvankaUniversalLifecycleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + displayName := data.DisplayName.ValueString() + + // If resume_if_exists: try to find existing instance by display_name + if data.ResumeIfExists.ValueBool() { + existing, err := r.client.FindInstanceByDisplayName(ctx, 1, displayName) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + if existing != nil { + status := strings.ToLower(existing.ExplainedStatus) + if strings.Contains(status, "suspend") || strings.Contains(status, "suspended") { + if err := r.client.RunInstanceOperationUniversal(ctx, existing.InstanceUid, "resume", nil); err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + } + + data.ID = types.StringValue(existing.InstanceUid) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + } + + params := map[int]string{ + 198: strconv.FormatInt(data.DurationMs.ValueInt64(), 10), + 199: boolToString(data.FailAtStart.ValueBool()), + } + + id, err := r.client.CreateGenericInstanceUniversalV6(ctx, 1, displayName, params) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + + data.ID = types.StringValue(id) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *BolvankaUniversalLifecycleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Implemented as shim for now +} + +func (r *BolvankaUniversalLifecycleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data BolvankaUniversalLifecycleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + params := map[int]string{ + 198: strconv.FormatInt(data.DurationMs.ValueInt64(), 10), + 199: boolToString(data.FailAtStart.ValueBool()), + } + + if err := r.client.RunInstanceOperationUniversal(ctx, data.ID.ValueString(), "modify", params); err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *BolvankaUniversalLifecycleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state BolvankaUniversalLifecycleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + mode := strings.ToLower(state.DeleteMode.ValueString()) + if mode == "" { + mode = "state_only" + } + + switch mode { + case "state_only": + return + case "suspend", "delete": + if err := r.client.RunInstanceOperationUniversal(ctx, state.ID.ValueString(), mode, nil); err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + default: + resp.Diagnostics.AddError("Invalid delete_mode", "Use: delete | suspend | state_only") + return + } +} + +func (r *BolvankaUniversalLifecycleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*core.UniversalClient) + if !ok { + resp.Diagnostics.AddError("Error", "Wrong client type expected *core.UniversalClient") + return + } + + r.client = client +} + +func boolToString(v bool) string { + if v { + return "true" + } + return "false" +} diff --git a/internal/provider/README.md b/internal/provider/README.md new file mode 100644 index 0000000..b4f7c4b --- /dev/null +++ b/internal/provider/README.md @@ -0,0 +1,132 @@ +# Internal Provider — Code-level Index & Developer Guide + +Generated: 2026-01-27 +Purpose: Quick reference for developers and Copilot agents to understand the internal Terraform provider implementation (`internal/provider/`). **Read this file before making changes to provider code.** + +--- + +## Overview ✨ +This folder contains the implementation of the Terraform provider `nubes` (resources and data-sources) and the HTTP client used to interact with the Taffy/Deck API. + +Primary responsibilities: +- Define provider schema and configuration (`provider.go`). +- Implement resources (`*_resource.go`) and data-sources (`*_data_source.go`). +- Encapsulate HTTP interactions in `client_impl.go` (NubesClient) and helper types. +- Implement polling/wait logic and operation submission patterns used across resources. + +--- + +## Files (short mapping) +- `provider.go` — Provider factory, Metadata, Schema, and lists of Resources/DataSources. +- `client_impl.go` — `NubesClient` implementation: request helpers, polling (`WaitForInstanceReady`, `WaitForOperation`), instance state retrieval, and operation submission. +- `edge_resource.go` / `edge_data_source.go` — Edge Gateway resource and data-source implementations. +- `vm_resource.go` — VM resource implementation (create/read/update/delete/import and operation waiting, `waitForVMOperationAndInstanceStatus`). +- `vapp_resource.go` / `vapp_data_source.go` — vApp resource and data-source. +- `vdc_resource.go` / `vdc_data_source.go` — vDC resource and data-source. +- `organization_resource.go` — Organization resource implementation. +- `postgres_resource.go` / `pgadmin_resource.go` — Postgres and PgAdmin resources. +- `s3bucket_resource.go` — S3 bucket resource. +- `quick_start_resource.go` — QuickStart resource (helper for full-stack installs). +- `tubulus_resource.go` / `tubulus_ai.go` — Tubulus resource (AI integrations). Contains `askGemini` usage. + +--- + +## Key Patterns & Conventions 🔧 +1. Resource lifecycle methods follow Terraform SDK conventions: `Metadata`, `Schema`, `Configure`, `Create`, `Read`, `Update`, `Delete`, `ImportState`. +2. Creation pattern: + - Submit a create operation (via `NubesClient` helpers) -> wait for operation state -> wait for instance readiness (if applicable) -> apply post-creation steps (VIP, DNS, FW). +3. Operation polling: + - Each resource implements `waitForOperationAndInstanceStatus` style helpers (e.g., VM uses specialized `waitForVMOperationAndInstanceStatus`) that poll Deck API for operation completion using `GetInstanceOperation` / `GetInstanceState`. +4. Parameter submission: + - Before `Create`, resources collect a model (e.g., `VMResourceModel`) and call `submitVMOperationParams` / `submitOperationParams` which uses `instanceOperationCfsParams` mapping to Deck's `svcOperationCfsParamId`. +5. Error handling: + - If operation stage `dtFinish` is `null` or the stage is stuck on `[PROCESS]`, functions return a timeout/error and propagate to Terraform user. + +--- + +## Important Types & Functions (by file) + +### provider.go +- `New(version string) func() provider.Provider` — Provider factory used by Terraform to instantiate provider. +- `NubesProvider` (type) — implements provider hooks: `Metadata`, `Schema`, `Configure`. +- `Resources()` and `DataSources()` — lists of registered resources and data-sources. + +### client_impl.go +- `type NubesClient struct` — HTTP client wrapper; holds base URL, token provider, logger. +- `GetOperationId(ctx, serviceId, opName)` — Resolve operation numeric ID by name. +- `CreateInstance(ctx, displayName, serviceId, svcOperationId, params)` — Creates instance and returns UIDs. +- `WaitForInstanceReady`, `WaitForOperation` — Polling helpers. +- `GetInstanceStateDetails`, `GetInstanceOperation` — low-level getters for state & operation details. +- `Post`, `postInstance` helpers — handle posting and optionally returning Location/ID from `Location` header. + +### vm_resource.go +- `NewVMResource()` — resource constructor. +- `VMResource` and `VMResourceModel` — model for user-specified parameters and mapping. +- `Create` — builds request model, submits params, runs operation (watching for `Firewall` stage and others), and treats partial success carefully. +- `waitForVMOperationAndInstanceStatus` — VM-specific wait logic (parses stages and handles FW timeouts). + +### edge_resource.go & edge_data_source.go +- `NewEdgeResource()`, `NewEdgeDataSource()` — constructors. +- `EdgeResourceModel`, `readInstance`, `submitOperationParams`, `waitForOperationAndInstanceStatus` — same patterns applied for Edge resource. + +### tubulus_resource.go & tubulus_ai.go +- Integrates AI flows (Gemini) with resource flow. +- `askGemini` — helper which calls AI integration for instruction parsing. + +--- + +## How to build the provider locally (developer workflow) ⚙️ +1. Build the provider binary: + +```bash +# At repo root +go build -o terraform-provider-nubes ./ +``` + +2. Make it available to Terraform for local testing: + +```bash +# Option A: plugin dir +mkdir -p ~/.terraform.d/plugins/local/terraform-provider-nubes +cp terraform-provider-nubes ~/.terraform.d/plugins/local/terraform-provider-nubes/ +# Option B: use plugin-dir during init +terraform init -plugin-dir=./ (not recommended if you have mixed plugins) +``` + +3. Run an example config: + +```bash +cd examples/quick_start +terraform init +terraform apply -var="token=" -auto-approve +``` + +4. Useful commands during development: +- `go vet`, `golangci-lint run` (if configured), `go test ./...`. +- Use `tools/har/*` scripts to replay HAR-based scenarios when testing resource behavior. + +> Note: Examples might perform destructive operations against live environment — prefer dev account and `-auto-approve` only when you expect the run. + +--- + +## Acceptance / Integration Tests +- Use `tests/` scenarios to validate create/modify/delete flows. +- Manual acceptance: run an example on a dev account, inspect `instances` and `operations` via Deck API. +- Test idempotency: apply the same config twice and ensure `plan` shows no changes. + +--- + +## Conventions for Agents and Contributors +- ALWAYS read `/home/naeel/terra/REPO_CONTENTS.md` and this file before making changes to provider code. +- When adding a resource: + - Add a new `*_resource.go` and a `*_data_source.go` if discovery is required. + - Implement `Create` / `Read` / `Update` / `Delete` / import if supported. + - Add tests in `tests/` and examples in `examples/`. + +--- + +## Next steps I can take ✅ +- Generate a function-level index (per-file exported functions and short signatures) as a machine-readable YAML/JSON for other agents. +- Add `internal/provider/DEVELOPMENT.md` with a checklist and `make` targets for build and acceptance test steps. + +If you want the function-level JSON index and a `DEVELOPMENT.md`, say "code-level" and I'll create them and commit to the repo. \ No newline at end of file diff --git a/internal/provider/client_impl.go b/internal/provider/client_impl.go new file mode 100644 index 0000000..61da137 --- /dev/null +++ b/internal/provider/client_impl.go @@ -0,0 +1,924 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Helper struct for parameters +type InstanceParam struct { + SvcOperationCfsParamId int + ParamValue string +} + +type InstanceOperationRequest struct { + Action string `json:"action"` + Params interface{} `json:"params"` +} + +// Implement methods for NubesClient defined in provider.go + +func (c *NubesClient) GetOperationId(ctx context.Context, serviceId int, opName string) (int, error) { + // Logic to fetch operation ID if needed. + // Based on HAR, we might not strictly need this if we pass "operation": "create" + // But let's assume we return a dummy or look it up. + // For now, return 0 as placeholder or implement lookup if API supports it. + return 0, nil +} + +func (c *NubesClient) CreateInstance(ctx context.Context, displayName string, serviceId int, svcOperationId int, params []InstanceParam) (string, string, error) { + // 1. Create Instance Placeholder + // Payload based on pg_admin.har: {"serviceId":96,"displayName":"...","descr":""} + + payload := map[string]interface{}{ + "serviceId": serviceId, + "displayName": displayName, + "descr": "", + } + + instanceUid, err := c.postInstance(ctx, payload) + if err != nil { + return "", "", fmt.Errorf("failed to create instance placeholder: %w", err) + } + tflog.Info(ctx, fmt.Sprintf("Created Instance Placeholder: %s", instanceUid)) + + // 2. Create Operation + opPayload := map[string]interface{}{ + "instanceUid": instanceUid, + "operation": "create", + } + // If svcOperationId is valid (>0), maybe we use it? HAR just said "operation":"create". + // We'll stick to "operation":"create". + + opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true) + if err != nil { + return "", "", fmt.Errorf("failed to create operation: %w", err) + } + tflog.Info(ctx, fmt.Sprintf("Created Operation: %s", opUid)) + + // 3. Get operation details with parameters + opPath := fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid) + var opDetails GetOperationResponse + if err := c.get(ctx, opPath, &opDetails); err != nil { + return "", "", fmt.Errorf("failed to get operation details: %w", err) + } + + // 3a. Submit parameters + // Strategy: + // 1. Send all explicitly provided 'params' (from Terraform config/resource) + // 2. Iterate server-provided defaults (opDetails) for anything we missed and send defaults + + sentParams := make(map[int]bool) + + // Phase 1: Send explicit overrides + for _, p := range params { + valToSend := p.ParamValue + // Simple normalization for empty values if needed + if valToSend == "" { + // Some fields might reject empty string? For now send as is + // or apply the map/list fix if we knew the type. + // But for explicit params, we assume caller knows best. + } + + paramPayload := map[string]interface{}{ + "instanceOperationUid": opUid, + "svcOperationCfsParamId": p.SvcOperationCfsParamId, + "paramValue": valToSend, + } + + _, err := c.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false) + if err != nil { + return "", "", fmt.Errorf("failed to submit explicit param %d: %w", p.SvcOperationCfsParamId, err) + } + sentParams[p.SvcOperationCfsParamId] = true + } + + // Phase 2: Fill in defaults from Server Metadata (if not already sent) + for _, param := range opDetails.InstanceOperation.CfsParams { + if _, sent := sentParams[param.SvcOperationCfsParamId]; sent { + continue // Already sent in Phase 1 + } + + valToSend := "" + if param.ParamValue != nil { + valToSend = *param.ParamValue + } else if param.DefaultValue != nil { + valToSend = *param.DefaultValue + } + + // Fix specific data type formatting (from tubulus example) + if valToSend == "" { + if param.DataType == "map" || param.DataType == "json" { + valToSend = "{}" + } else if param.DataType == "array" || param.DataType == "list" { + valToSend = "[]" + } + } + + paramPayload := map[string]interface{}{ + "instanceOperationUid": opUid, + "svcOperationCfsParamId": param.SvcOperationCfsParamId, + "paramValue": valToSend, + } + _, err := c.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false) + if err != nil { + return "", "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) + } + } + + // 4. Validate (Optional, seen in S3 HAR) + validateUrl := fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid) + tflog.Info(ctx, fmt.Sprintf("Validating operation: %s", validateUrl)) + _ = c.get(ctx, validateUrl, nil) + + // 5. Run Operation + // HAR: POST .../instanceOperations/{ids}/run + runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) + tflog.Info(ctx, fmt.Sprintf("Running operation: %s", runUrl)) + _, err = c.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false) + if err != nil { + return "", "", fmt.Errorf("failed to run operation: %w", err) + } + + return instanceUid, opUid, nil +} + +// RunInstanceOperation is a helper to just fill params and run an existing OP UID +func (c *NubesClient) RunInstanceOperation(ctx context.Context, opUid string, params []InstanceParam) (string, string, error) { + // Add params + for _, param := range params { + valToSend := param.ParamValue + // Simple normalization + if valToSend == "" { + valToSend = "pass" // Default fallback if needed, or empty + } + + paramPayload := map[string]interface{}{ + "instanceOperationUid": opUid, + "svcOperationCfsParamId": param.SvcOperationCfsParamId, + "paramValue": valToSend, + } + _, err := c.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false) + if err != nil { + return "", "", fmt.Errorf("failed to add param %d: %w", param.SvcOperationCfsParamId, err) + } + } + + // Validate + validateUrl := fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid) + _ = c.get(ctx, validateUrl, nil) + + // Run + runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) + _, err := c.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false) + if err != nil { + return "", "", fmt.Errorf("failed to run operation: %w", err) + } + + return "", opUid, nil +} + +func (c *NubesClient) WaitForInstanceStatus(ctx context.Context, instanceUid string, targetStatus string) error { + + timeout := time.After(15 * time.Minute) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout: + return fmt.Errorf("timeout waiting for instance %s to reach status %s", instanceUid, targetStatus) + case <-ticker.C: + inst, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + tflog.Warn(ctx, fmt.Sprintf("Error checking state for %s: %s", instanceUid, err)) + continue + } + + state := inst.ExplainedStatus + tflog.Info(ctx, fmt.Sprintf("Instance %s current status: %s (target: %s, InProgress: %v)", instanceUid, state, targetStatus, inst.OperationIsInProgress)) + + if strings.EqualFold(state, targetStatus) { + return nil + } + + lowerState := strings.ToLower(state) + if strings.Contains(lowerState, "error") || strings.Contains(lowerState, "failed") { + return fmt.Errorf("instance %s in error state: %s", instanceUid, state) + } + + if !inst.OperationIsInProgress && !inst.OperationIsPending { + if strings.EqualFold(state, targetStatus) { + return nil + } + return fmt.Errorf("operation finished but target status %s not reached (current: %s)", targetStatus, state) + } + } + } +} + +// ttyWriter открывает /dev/tty для прямого вывода в терминал пользователя, +// минуя перехват stderr terraform'ом. Fallback на os.Stderr. +func ttyWriter() *os.File { + if f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil { + return f + } + return os.Stderr +} + +func (c *NubesClient) WaitForOperation(ctx context.Context, opUid string) error { + timeout := time.After(15 * time.Minute) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + // отслеживаем уже напечатанные этапы чтобы не дублировать + printedStages := make(map[string]bool) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout: + return fmt.Errorf("timeout waiting for operation %s", opUid) + case <-ticker.C: + op, err := c.GetInstanceOperation(ctx, opUid) + if err != nil { + tflog.Warn(ctx, fmt.Sprintf("Error checking operation %s: %s", opUid, err)) + continue + } + + // Печатаем завершённые этапы + tty := ttyWriter() + defer tty.Close() + for _, stage := range op.Stages { + if printedStages[stage.InstanceOperationStageUid] { + continue + } + if stage.DtFinish == nil || *stage.DtFinish == "" { + continue + } + printedStages[stage.InstanceOperationStageUid] = true + status := "OK " + if !stage.IsSuccessful { + status = "FAIL" + } + fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status, stage.Stage, stage.Duration) + if stage.StageMsg != nil && *stage.StageMsg != "" { + fmt.Fprintf(tty, " %s\n", *stage.StageMsg) + } + } + + // Операция завершена + if !op.IsInProgress && !op.IsPending && op.DtFinish != nil && *op.DtFinish != "" { + if op.IsSuccessful != nil && *op.IsSuccessful { + if op.Duration != nil { + fmt.Fprintf(tty, " [DONE] operation completed in %.1f sec\n", *op.Duration) + } + return nil + } + // Собираем детали ошибки из упавших этапов + var failedStages []string + for _, stage := range op.Stages { + if !stage.IsSuccessful && stage.DtFinish != nil { + msg := stage.Stage + if stage.StageMsg != nil && *stage.StageMsg != "" { + msg += ": " + *stage.StageMsg + } + failedStages = append(failedStages, msg) + } + } + errMsg := "operation failed" + if op.ErrorLog != nil && *op.ErrorLog != "" { + errMsg = *op.ErrorLog + } + if len(failedStages) > 0 { + errMsg += " | failed stages: " + strings.Join(failedStages, "; ") + } + return fmt.Errorf("%s", errMsg) + } + + // Ранний выход если ErrorLog появился раньше dtFinish + if op.ErrorLog != nil && *op.ErrorLog != "" { + return fmt.Errorf("operation failed (early error): %s", *op.ErrorLog) + } + } + } +} + +func (c *NubesClient) get(ctx context.Context, path string, target interface{}) error { + url := fmt.Sprintf("%s%s", c.ApiEndpoint, path) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GET %s failed with status %d: %s", path, resp.StatusCode, string(body)) + } + + if target != nil { + return json.NewDecoder(resp.Body).Decode(target) + } + return nil +} + +func (c *NubesClient) WaitForInstanceReady(ctx context.Context, instanceUid string) error { + // Set timeout + timeout := time.After(15 * time.Minute) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout: + return fmt.Errorf("timeout waiting for instance %s", instanceUid) + case <-ticker.C: + // check status + inst, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + tflog.Warn(ctx, fmt.Sprintf("Error checking state for %s: %s", instanceUid, err)) + continue + } + + state := inst.ExplainedStatus + tflog.Info(ctx, fmt.Sprintf("Instance %s status: %s (InProgress: %v)", instanceUid, state, inst.OperationIsInProgress)) + + // Statuses from HAR: "running", "Active", "deployed", "Deployed" + if strings.EqualFold(state, "Active") || strings.EqualFold(state, "Running") || strings.EqualFold(state, "Deployed") { + return nil + } + + // Detect failure patterns in explainedStatus + lowerState := strings.ToLower(state) + if strings.Contains(lowerState, "error") || + strings.Contains(lowerState, "failed") || + strings.Contains(lowerState, "не удалось") || + strings.Contains(lowerState, "не заполнена") { + return fmt.Errorf("instance %s end status: %s", instanceUid, state) + } + + // If operation finished but we didn't reach success state + if !inst.OperationIsInProgress && !inst.OperationIsPending { + // Second check for success, sometimes status updates slightly after op finishes + if strings.EqualFold(state, "Active") || strings.EqualFold(state, "Running") || strings.EqualFold(state, "Deployed") { + return nil + } + // If still not success, and not in a known transitioning state (like "Processing", "Creating") + if !strings.EqualFold(state, "Creating") && !strings.EqualFold(state, "Processing") && state != "" { + return fmt.Errorf("operation finished but instance %s is in unexpected state: %s", instanceUid, state) + } + } + } + } +} + +func (c *NubesClient) DeleteInstance(ctx context.Context, instanceUid string, opId int) error { + // Create delete operation + opPayload := map[string]interface{}{ + "instanceUid": instanceUid, + "operation": "delete", + } + opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true) + if err != nil { + return err + } + + // Run It + runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) + _, err = c.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false) + return err +} + +// Helpers + +func (c *NubesClient) postInstance(ctx context.Context, payload interface{}) (string, error) { + url := fmt.Sprintf("%s/instances", c.ApiEndpoint) + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(data)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 201 && resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + + // Try to get ID from Location header + loc := resp.Header.Get("Location") + if loc != "" { + parts := strings.Split(loc, "/") + if len(parts) > 0 { + return parts[len(parts)-1], nil + } + } + + // Try body + var res map[string]interface{} + body, _ := io.ReadAll(resp.Body) + if len(body) > 0 { + if err := json.Unmarshal(body, &res); err == nil { + if uid, ok := res["instanceUid"].(string); ok { + return uid, nil + } + } + } + + return "", fmt.Errorf("could not extract instanceUid from response") +} + +func (c *NubesClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocationId bool) (string, error) { + url := fmt.Sprintf("%s%s", c.ApiEndpoint, path) + var reqBody io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + reqBody = bytes.NewBuffer(data) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 201 && resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + + if returnLocationId { + // Extract ID from Location header .../something/{id} + loc := resp.Header.Get("Location") + if loc != "" { + parts := strings.Split(loc, "/") + return parts[len(parts)-1], nil + } + // Fallback to body scan if needed + var res map[string]interface{} + body, _ := io.ReadAll(resp.Body) + if len(body) > 0 { + if err := json.Unmarshal(body, &res); err == nil { + // Try common ID fields + if id, ok := res["instanceOperationUid"].(string); ok { + return id, nil + } + } + } + } + return "", nil +} + +type ApiOperation struct { + SvcOperationId int `json:"svcOperationId"` + Operation string `json:"operation"` +} + +type InstanceStateData struct { + Params map[string]interface{} `json:"params"` +} + +type InstanceStateResponse struct { + InstanceUid string `json:"instanceUid"` + ExplainedStatus string `json:"explainedStatus"` + OperationIsInProgress bool `json:"operationIsInProgress"` + OperationIsPending bool `json:"operationIsPending"` + AvailableOperations []ApiOperation `json:"availableOperations"` + State *InstanceStateData `json:"state"` +} + +func (c *NubesClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { + url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + + var res struct { + Instance InstanceStateResponse `json:"instance"` + } + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + + return &res.Instance, nil +} + +// Post is a helper for generic API calls, used by postgres_resource +func (c *NubesClient) Post(path string, payload interface{}, target interface{}) error { + url := fmt.Sprintf("%s%s", c.ApiEndpoint, path) + var reqBody io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return err + } + reqBody = bytes.NewBuffer(data) + } + + req, err := http.NewRequestWithContext(context.Background(), "POST", url, reqBody) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + + if target != nil { + return json.NewDecoder(resp.Body).Decode(target) + } + return nil +} + +func (c *NubesClient) GetInstanceStateDetails(ctx context.Context, instanceUid string) (map[string]interface{}, error) { + url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + + var res map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + + if inst, ok := res["instance"].(map[string]interface{}); ok { + if state, ok := inst["state"].(map[string]interface{}); ok { + return state, nil + } + } + return nil, fmt.Errorf("could not find instance.state in response") +} + +func (c *NubesClient) GetOperationIdForInstance(ctx context.Context, instanceUid string, opName string) (int, error) { + url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, err + } + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return 0, fmt.Errorf("status %d", resp.StatusCode) + } + + var res map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return 0, err + } + + inst, ok := res["instance"].(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("instance field missing") + } + + availableOps, ok := inst["availableOperations"].([]interface{}) + if !ok { + return 0, fmt.Errorf("availableOperations missing or empty") + } + + for _, item := range availableOps { + opMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + var name string + var id int + + if n, ok := opMap["name"].(string); ok { + name = n + } + if n, ok := opMap["operation"].(string); ok { + name = n + } + if i, ok := opMap["svcOperationId"].(float64); ok { + id = int(i) + } + + if svcOp, ok := opMap["svcOperation"].(map[string]interface{}); ok { + if n, ok := svcOp["name"].(string); ok { + name = n + } + if idFloat, ok := svcOp["id"].(float64); ok { + id = int(idFloat) + } + } + + if strings.EqualFold(name, opName) { + return id, nil + } + } + + return 0, fmt.Errorf("operation '%s' not found in available operations", opName) +} + +type GetOperationResponse struct { + InstanceOperation OperationResponse `json:"instanceOperation"` +} + +// OperationResponse представляет собой полное состояние операции из Deck API. +// Все поля получены путем анализа HAR файлов (dummy.har, vdc.har) и документации платформы. +type OperationResponse struct { + InstanceOperationUid string `json:"instanceOperationUid"` // Уникальный идентификатор операции (UUID) + InstanceUid string `json:"instanceUid"` // Идентификатор инстанса, над которым идет работа + Operation string `json:"operation"` // Имя операции (create, modify, suspend, и т.д.) + IsInProgress bool `json:"isInProgress"` // true, если Jenkins-джоб выполняется в данный момент + IsPending bool `json:"isPending"` // true, если операция стоит в очереди (ждем слота в Jenkins) + IsSuccessful *bool `json:"isSuccessful"` // "Зеленая галка" платформы. Появляется ПОСЛЕ dtFinish. + DtFinish *string `json:"dtFinish"` // Штамп времени окончания (ГГГГ-ММ-ДД...). Не null = ОПЕРАЦИЯ ЗАВЕРШЕНА. + DtCreated *string `json:"dtCreated"` // Время создания записи об операции + DtUpdated *string `json:"dtUpdated"` // Время последнего обновления записи + DtSubmit *string `json:"dtSubmit"` // Когда кнопка была нажата (или API вызван) + DtStart *string `json:"dtStart"` // Когда реально начался Jenkins-джоб + SubmitResult *string `json:"submitResult"` // HTTP-код первичной регистрации (обычно "201") + Duration *float64 `json:"duration"` // Общее время выполнения в секундах + ErrorLog *string `json:"errorLog"` // Текст ошибки, если операция упала + UpdaterId *int `json:"updaterId"` // ID пользователя, запустившего операцию + UpdaterLogin *string `json:"updaterLogin"` // Логин инициатора + UpdaterShortname *string `json:"updaterShortname"` // Инициалы инициатора (например, "Н. Ф.") + DisplayName *string `json:"displayName"` // Имя инстанса на момент операции + ServiceId *int `json:"serviceId"` // ID сервиса (1 - Болванка, 96 - PG Admin и т.д.) + Svc *string `json:"svc"` // Текстовое имя сервиса + SvcOperationId *int `json:"svcOperationId"` // Внутренний ID операции в каталоге + Man *string `json:"man"` // Мануал/описание операции (иногда содержит Markdown) + CfsParams []CfsParam `json:"cfsParams"` // Список всех параметров (конфигурация) + Stages []ApiStage `json:"stages"` // Этапы выполнения джоба (подготовка, секреты...) + State any `json:"state"` // Результирующее состояние (выходные данные джоба) +} + +// ApiStage представляет этап выполнения операции в Jenkins +type ApiStage struct { + InstanceOperationStageUid string `json:"instanceOperationStageUid"` + Stage string `json:"stage"` // Название (например, "Подготовка среды") + IsSuccessful bool `json:"isSuccessful"` // Успех конкретного этапа + DtStart *string `json:"dtStart"` + DtFinish *string `json:"dtFinish"` + Duration float64 `json:"duration"` + StageMsg *string `json:"stageMsg"` // Лог этапа (часто JSON в строке) +} + +type CfsParam struct { + InstanceOperationCfsParamUid string `json:"instanceOperationCfsParamUid"` + SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` + ParamValue *string `json:"paramValue"` + DefaultValue *string `json:"defaultValue"` + DataType string `json:"dataType"` + Name string `json:"name"` + Code string `json:"code"` + SvcOperationCfsParam string `json:"svcOperationCfsParam"` +} + +func (c *NubesClient) GetInstanceOperation(ctx context.Context, opUid string) (*OperationResponse, error) { + // ВАЖНО: Список полей максимально расширен на основе HAR (dummy.har, vdc.har). + // Эти поля позволяют видеть полную картину происходящего на платформе. + fields := "instanceOperationUid,instanceUid,state,stages,isSuccessful,dtCreated,dtUpdated,dtStart,dtFinish,operation,svcOperationId,svc,displayName,submitResult,duration,errorLog,updaterShortname,man,isInProgress,isPending,dtSubmit" + url := fmt.Sprintf("%s/instanceOperations/%s?fields=%s", c.ApiEndpoint, opUid, fields) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + + var res GetOperationResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("json unmarshal failed: %w, body: %s", err, string(body)) + } + + return &res.InstanceOperation, nil +} + +func (c *NubesClient) RunAction(ctx context.Context, instanceUid string, action string) error { + state, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + return err + } + + var opId int + for _, op := range state.AvailableOperations { + if op.Operation == action { + opId = op.SvcOperationId + break + } + } + + if opId == 0 { + return fmt.Errorf("action %s not available for instance %s", action, instanceUid) + } + + // 1. Create Op + payload := map[string]interface{}{ + "instanceUid": instanceUid, + "svcOperationId": opId, + "operation": action, + } + + opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true) + if err != nil { + return fmt.Errorf("failed to create %s operation: %w", action, err) + } + if opUid == "" { + return fmt.Errorf("failed to get operation UID for %s", action) + } + + // 2. Run Op + runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) + _, err = c.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false) + return err +} + +type InstanceSummary struct { + InstanceUid string `json:"instanceUid"` + DisplayName string `json:"displayName"` + ServiceId int `json:"serviceId"` + Svc string `json:"svc"` +} + +type InstancesListResponse struct { + Results []InstanceSummary `json:"results"` +} + +func (c *NubesClient) GetInstances(ctx context.Context) ([]InstanceSummary, error) { + var allInstances []InstanceSummary + page := 1 + + for { + url := fmt.Sprintf("%s/instances?page=%d&size=100", c.ApiEndpoint, page) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + + var res InstancesListResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + + if len(res.Results) == 0 { + break + } + + allInstances = append(allInstances, res.Results...) + page++ + + // Safety break to prevent infinite loops if API behaves weirdly + if page > 100 { + break + } + } + + return allInstances, nil +} + +func (c *NubesClient) GetInstanceFull(ctx context.Context, instanceUid string) (map[string]interface{}, error) { + url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + + var res map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + + if inst, ok := res["instance"].(map[string]interface{}); ok { + return inst, nil + } + return nil, fmt.Errorf("instance field missing in response") +} diff --git a/internal/provider/edge_data_source.go b/internal/provider/edge_data_source.go new file mode 100644 index 0000000..a3dcd8e --- /dev/null +++ b/internal/provider/edge_data_source.go @@ -0,0 +1,144 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSource = &EdgeDataSource{} + +func NewEdgeDataSource() datasource.DataSource { + return &EdgeDataSource{} +} + +type EdgeDataSource struct { + client *NubesClient +} + +type EdgeDataSourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Status types.String `tfsdk:"status"` + Description types.String `tfsdk:"description"` +} + +func (d *EdgeDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edge" +} + +func (d *EdgeDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Data source для получения информации о существующем Edge Gateway по имени", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "UUID Edge Gateway", + Computed: true, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Имя Edge Gateway для поиска", + Required: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Статус Edge Gateway", + Computed: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Описание Edge Gateway", + Computed: true, + }, + }, + } +} + +func (d *EdgeDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + d.client = client +} + +func (d *EdgeDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data EdgeDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "GET", d.client.ApiEndpoint+"/instances", nil) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) + return + } + + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(d.client.ApiToken)) + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := d.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instances: %s", err)) + return + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read response: %s", err)) + return + } + + if httpResp.StatusCode != http.StatusOK { + resp.Diagnostics.AddError("API Error", fmt.Sprintf("GET /instances returned %d: %s", httpResp.StatusCode, string(body))) + return + } + + var instancesResp struct { + Results []struct { + ID string `json:"id"` + DisplayName string `json:"name"` + Status string `json:"status"` + Description string `json:"desc"` + Service string `json:"svc"` + } `json:"results"` + } + + if err := json.Unmarshal(body, &instancesResp); err != nil { + resp.Diagnostics.AddError("Parse Error", fmt.Sprintf("Unable to parse response: %s", err)) + return + } + + searchName := data.DisplayName.ValueString() + for _, instance := range instancesResp.Results { + if instance.Service == "Сетевой шлюз периметра (Edge)" && instance.DisplayName == searchName { + data.ID = types.StringValue(instance.ID) + data.Status = types.StringValue(instance.Status) + data.Description = types.StringValue(instance.Description) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + } + + resp.Diagnostics.AddError( + "Edge Gateway Not Found", + fmt.Sprintf("Edge Gateway с именем '%s' не найден. Проверьте имя или создайте новый Edge.", searchName), + ) +} diff --git a/internal/provider/edge_resource.go b/internal/provider/edge_resource.go new file mode 100644 index 0000000..2f7e82b --- /dev/null +++ b/internal/provider/edge_resource.go @@ -0,0 +1,674 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "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/types" +) + +var _ resource.Resource = &EdgeResource{} +var _ resource.ResourceWithImportState = &EdgeResource{} + +func NewEdgeResource() resource.Resource { + return &EdgeResource{} +} + +type EdgeResource struct { + client *NubesClient +} + +type EdgeResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + VdcUID types.String `tfsdk:"vdc_uid"` + EdgeType types.String `tfsdk:"edge_type"` + EdgeCount types.Int64 `tfsdk:"edge_count"` + EnableAdvanced types.Bool `tfsdk:"enable_advanced"` + ExternalNetwork types.String `tfsdk:"external_network"` + Status types.String `tfsdk:"status"` +} + +func (r *EdgeResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_edge" +} + +func (r *EdgeResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Nubes Edge Gateway resource", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Edge identifier (UUID)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Edge display name", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Edge description", + Optional: true, + }, + "vdc_uid": schema.StringAttribute{ + MarkdownDescription: "VDC UUID (parameter ID 8)", + Required: true, + }, + "edge_type": schema.StringAttribute{ + MarkdownDescription: "Edge type: vdc (parameter ID 621)", + Optional: true, + Computed: true, + }, + "edge_count": schema.Int64Attribute{ + MarkdownDescription: "Number of Edge Gateways (parameter ID 341)", + Optional: true, + Computed: true, + }, + "enable_advanced": schema.BoolAttribute{ + MarkdownDescription: "Enable advanced features (parameter ID 340)", + Optional: true, + Computed: true, + }, + "external_network": schema.StringAttribute{ + MarkdownDescription: "External network name (parameter ID 367)", + Optional: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Current status of the Edge", + Computed: true, + }, + }, + } +} + +func (r *EdgeResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *EdgeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data EdgeResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Set defaults + if data.EdgeType.IsNull() || data.EdgeType.IsUnknown() { + data.EdgeType = types.StringValue("vdc") + } + if data.EdgeCount.IsNull() || data.EdgeCount.IsUnknown() { + data.EdgeCount = types.Int64Value(1) + } + if data.EnableAdvanced.IsNull() || data.EnableAdvanced.IsUnknown() { + data.EnableAdvanced = types.BoolValue(false) + } + + // Step 1: Create instance + createReq := CreateInstanceRequest{ + ServiceId: 22, // Edge service ID + DisplayName: data.DisplayName.ValueString(), + Descr: data.Description.ValueString(), + } + + jsonData, err := json.Marshal(createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location := httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in response") + return + } + instanceId := location[2:] + + data.ID = types.StringValue(instanceId) + + // Step 2: Create operation + operationReq := CreateOperationRequest{ + InstanceUid: instanceId, + Operation: "create", + } + + jsonData, err = json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location = httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in operation response") + return + } + operationId := location[2:] + + // Step 3: Submit operation parameters and run + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + + // Step 4: Wait for operation completion and instance running status + if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 10*time.Minute); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err)) + return + } + + readData, err := r.readInstance(ctx, instanceId) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *EdgeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data EdgeResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) + return + } + + data.Status = types.StringValue(instanceResp.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *EdgeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data EdgeResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "modify", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + } + + time.Sleep(5 * time.Second) + readData, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *EdgeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data EdgeResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "delete", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Delete operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + } + } +} + +func (r *EdgeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *EdgeResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id, nil) + if err != nil { + return nil, err + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var instanceResp InstanceResponse + if err := json.Unmarshal(body, &instanceResp); err != nil { + return nil, err + } + + return &instanceResp, nil +} + +func (r *EdgeResource) submitOperationParams(ctx context.Context, operationUid string, data EdgeResourceModel) error { + // Get operation details + httpReq, err := http.NewRequestWithContext(ctx, "GET", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) + if err != nil { + return fmt.Errorf("unable to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to get operation: %s", err) + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return fmt.Errorf("unable to read response: %s", err) + } + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var getResp GetOperationResponse + if err := json.Unmarshal(body, &getResp); err != nil { + return fmt.Errorf("unable to unmarshal response: %s", err) + } + operationResp := getResp.InstanceOperation + + // Submit each parameter + for _, param := range operationResp.CfsParams { + valToSend := "" + + // Map Edge parameters by ID + switch param.SvcOperationCfsParamId { + case 8: // vdcUid + if !data.VdcUID.IsNull() && !data.VdcUID.IsUnknown() { + valToSend = data.VdcUID.ValueString() + } + case 340: // enableAdvanced + if !data.EnableAdvanced.IsNull() && !data.EnableAdvanced.IsUnknown() { + valToSend = fmt.Sprintf("%t", data.EnableAdvanced.ValueBool()) + } + case 341: // edgeCount + if !data.EdgeCount.IsNull() && !data.EdgeCount.IsUnknown() { + valToSend = fmt.Sprintf("%d", data.EdgeCount.ValueInt64()) + } + case 367: // externalNetwork + if !data.ExternalNetwork.IsNull() && !data.ExternalNetwork.IsUnknown() { + valToSend = data.ExternalNetwork.ValueString() + } + case 621: // edgeType + if !data.EdgeType.IsNull() && !data.EdgeType.IsUnknown() { + valToSend = data.EdgeType.ValueString() + } + case 622: // unknown optional parameter + // Leave empty or use default + default: + // Use existing or default value for unknown parameters + if param.ParamValue != nil { + valToSend = *param.ParamValue + } else if param.DefaultValue != nil { + valToSend = *param.DefaultValue + } + } + + // Fix specific data type formatting + if valToSend == "" || valToSend == "\"\"" { + if param.DataType == "map" || param.DataType == "json" { + valToSend = "{}" + } else if param.DataType == "array" || param.DataType == "list" { + valToSend = "[]" + } + } + + paramReq := CreateCfsParamRequest{ + InstanceOperationUid: operationUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: valToSend, + } + + jsonData, err := json.Marshal(paramReq) + if err != nil { + return fmt.Errorf("unable to marshal param request: %s", err) + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("unable to create param request: %s", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to submit parameter: %s", err) + } + + respBody, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && + httpResp.StatusCode != http.StatusOK && + httpResp.StatusCode != http.StatusNoContent { + return fmt.Errorf("submit parameter id %d failed with status %d: %s", + param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody)) + } + } + + // Run the operation + runReq, err := http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + return fmt.Errorf("unable to create run request: %s", err) + } + + runReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + runResp, err := r.client.HttpClient.Do(runReq) + if err != nil { + return fmt.Errorf("unable to run operation: %s", err) + } + defer runResp.Body.Close() + + if runResp.StatusCode != http.StatusOK && + runResp.StatusCode != http.StatusNoContent && + runResp.StatusCode != http.StatusCreated { + runBody, _ := io.ReadAll(runResp.Body) + return fmt.Errorf("run operation failed with status %d: %s", + runResp.StatusCode, string(runBody)) + } + + return nil +} + +func (r *EdgeResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout) + + // Шаг 1: Ждём завершения операции +operationLoop: + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-ticker.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for operation to complete") + } + + log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId) + + // Проверяем статус операции + httpReq, err := http.NewRequestWithContext(ctx, "GET", + r.client.ApiEndpoint+"/instanceOperations/"+operationId, nil) + if err != nil { + return fmt.Errorf("failed to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to check operation status: %s", err) + } + + body, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("get operation failed with status %d", httpResp.StatusCode) + } + + var opResp struct { + InstanceOperation struct { + IsInProgress bool `json:"isInProgress"` + IsPending bool `json:"isPending"` + } `json:"instanceOperation"` + } + + if err := json.Unmarshal(body, &opResp); err != nil { + return fmt.Errorf("failed to parse operation response: %s", err) + } + + log.Printf("[DEBUG] Operation status: isInProgress=%v, isPending=%v", opResp.InstanceOperation.IsInProgress, opResp.InstanceOperation.IsPending) + + // Операция завершена когда isInProgress=false И isPending=false + if !opResp.InstanceOperation.IsInProgress && !opResp.InstanceOperation.IsPending { + log.Printf("[DEBUG] Operation completed, moving to instance status check") + break operationLoop + } + } + } + + // Шаг 2: Проверяем статус instance + ticker2 := time.NewTicker(5 * time.Second) + defer ticker2.Stop() + + log.Printf("[DEBUG] Starting instance status polling") + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-ticker2.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for instance to become running") + } + + log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId) + +instance, err := r.readInstance(ctx, instanceId) +if err != nil { +return fmt.Errorf("failed to check instance status: %s", err) +} + +if instance.Status == "running" { +return nil +} + +if instance.Status == "error" || instance.Status == "failed" { +return fmt.Errorf("instance entered error state: %s", instance.Status) +} + +// Continue waiting for other statuses +} +} +} diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go new file mode 100644 index 0000000..c29496b --- /dev/null +++ b/internal/provider/organization_resource.go @@ -0,0 +1,517 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ resource.Resource = &OrganizationResource{} + +func NewOrganizationResource() resource.Resource { + return &OrganizationResource{} +} + +type OrganizationResource struct { + client *NubesClient +} + +type OrganizationResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + Platform types.String `tfsdk:"platform"` + OrganizationType types.String `tfsdk:"organization_type"` + ResourceRealm types.String `tfsdk:"resource_realm"` + Status types.String `tfsdk:"status"` +} + +func (r *OrganizationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization" +} + +func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Cloud Director Organization resource - корневая сущность для управления облачной инфраструктурой", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "UUID организации", + }, + "display_name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Отображаемое имя организации", + }, + "description": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Описание организации", + }, + "platform": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Платформа для развертывания (например: ngcloud.ru)", + }, + "organization_type": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Тип организации: iaas (с доступом к Cloud Director) или saas (управляется Nubes)", + }, + "resource_realm": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Realm ресурса (по умолчанию: vcd)", + }, + "status": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Текущий статус организации", + }, + }, + } +} + +func (r *OrganizationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data OrganizationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Set default resource_realm if not provided + if data.ResourceRealm.IsNull() || data.ResourceRealm.IsUnknown() { + data.ResourceRealm = types.StringValue("vcd") + } + + // Step 1: Create instance + createReq := CreateInstanceRequest{ + ServiceId: 19, // Cloud Director Organization + DisplayName: data.DisplayName.ValueString(), + Descr: data.Description.ValueString(), + } + + jsonData, err := json.Marshal(createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal create request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract instance ID from Location header + location := httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in response") + return + } + instanceId := location[2:] // Remove "./" + + data.ID = types.StringValue(instanceId) + + // Step 2: Create operation + operationReq := CreateOperationRequest{ + InstanceUid: instanceId, + Operation: "create", + } + + jsonData, err = json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract operation ID from Location header + location = httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in operation response") + return + } + operationId := location[2:] // Remove "./" + + // Step 3: Submit operation parameters + if err := r.submitOrganizationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + + // Step 4: Wait for completion and read status + time.Sleep(5 * time.Second) + + readData, err := r.readInstance(ctx, instanceId) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data OrganizationResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) + return + } + + data.Status = types.StringValue(instanceResp.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data OrganizationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Trigger modify operation + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "modify", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract operation ID and submit parameters + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + if err := r.submitOrganizationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + } + + time.Sleep(5 * time.Second) + + readData, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data OrganizationResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // First suspend the organization + suspendReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "suspend", + } + + jsonData, err := json.Marshal(suspendReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal suspend request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create suspend request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend organization: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Suspend operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Note: Actual deletion requires 14 days wait after suspend + // For now we just suspend and remove from state + resp.Diagnostics.AddWarning( + "Organization Suspended", + "Organization has been suspended. Actual deletion requires 14 days wait period and must be performed manually.", + ) +} + +func (r *OrganizationResource) readInstance(ctx context.Context, instanceId string) (*InstanceResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+instanceId, nil) + if err != nil { + return nil, err + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var instanceResp InstanceResponse + if err := json.Unmarshal(body, &instanceResp); err != nil { + return nil, err + } + + return &instanceResp, nil +} + +func (r *OrganizationResource) submitOrganizationParams(ctx context.Context, operationUid string, data OrganizationResourceModel) error { + // Step 1: Get operation details with parameters + httpReq, err := http.NewRequestWithContext(ctx, "GET", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) + if err != nil { + return fmt.Errorf("unable to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to get operation: %s", err) + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return fmt.Errorf("unable to read response: %s", err) + } + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var getResp GetOperationResponse + if err := json.Unmarshal(body, &getResp); err != nil { + return fmt.Errorf("unable to unmarshal response: %s", err) + } + operationResp := getResp.InstanceOperation + + // Step 2: Submit each parameter + for _, param := range operationResp.CfsParams { + valToSend := "" + + // Map parameters by ID + switch param.SvcOperationCfsParamId { + case 418: // platform + if !data.Platform.IsNull() && !data.Platform.IsUnknown() { + valToSend = data.Platform.ValueString() + } + case 556: // organizationType + if !data.OrganizationType.IsNull() && !data.OrganizationType.IsUnknown() { + valToSend = data.OrganizationType.ValueString() + } + default: + // Use existing or default value + if param.ParamValue != nil { + valToSend = *param.ParamValue + } else if param.DefaultValue != nil { + valToSend = *param.DefaultValue + } + } + + paramReq := CreateCfsParamRequest{ + InstanceOperationUid: operationUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: valToSend, + } + + jsonData, err := json.Marshal(paramReq) + if err != nil { + return fmt.Errorf("unable to marshal param request: %s", err) + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("unable to create param request: %s", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to submit param: %s", err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + return fmt.Errorf("submit parameter id %d failed with status %d: %s", + param.SvcOperationCfsParamId, httpResp.StatusCode, string(body)) + } + } + + // Step 3: Run the operation + httpReq, err = http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + return fmt.Errorf("unable to create run request: %s", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to run operation: %s", err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK && httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + return fmt.Errorf("run operation failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + return nil +} diff --git a/internal/provider/pgadmin_resource.go b/internal/provider/pgadmin_resource.go new file mode 100644 index 0000000..33f5381 --- /dev/null +++ b/internal/provider/pgadmin_resource.go @@ -0,0 +1,359 @@ +package provider + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ resource.Resource = &PgAdminResource{} +var _ resource.ResourceWithImportState = &PgAdminResource{} + +type PgAdminResource struct { + client *NubesClient +} + +func NewPgAdminResource() resource.Resource { + return &PgAdminResource{} +} + +type PgAdminResourceModel struct { + ID types.String `tfsdk:"id"` + Domain types.String `tfsdk:"domain"` + ResourceRealm types.String `tfsdk:"resource_realm"` + Cpu types.Int64 `tfsdk:"cpu"` + Memory types.Int64 `tfsdk:"memory"` + Disk types.Int64 `tfsdk:"disk"` + Email types.String `tfsdk:"email"` + Password types.String `tfsdk:"password"` + DeletionProtection types.Bool `tfsdk:"deletion_protection"` +} + + + +func (r *PgAdminResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_pgadmin" +} + +func (r *PgAdminResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a pgAdmin instance.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "domain": schema.StringAttribute{ + Required: true, + Description: "Domain name (param 164)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "resource_realm": schema.StringAttribute{ + Required: true, + Description: "Deployment platform (param 169). E.g. k8s-3.ext.nubes.ru", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "cpu": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(200), + Description: "CPU quota in milicores (param 165)", + }, + "memory": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(256), + Description: "Memory quota in MB (param 166)", + }, + "disk": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(1), + Description: "Disk size in GB (param 167)", + }, + "email": schema.StringAttribute{ + Required: true, + Description: "Login email (param 170)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "password": schema.StringAttribute{ + Required: true, + Sensitive: true, + Description: "Login password (param 171)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "deletion_protection": schema.BoolAttribute{ + MarkdownDescription: "If true, the resource will only be removed from Terraform state upon destroy, but will remain in the cloud. If false, destroy will trigger 'suspend' in Nubes.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + }, + } +} + + +func (r *PgAdminResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData)) + return + } + r.client = client +} + +func (r *PgAdminResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan PgAdminResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Creating pgAdmin resource...") + + // Params based on pg_admin.har analysis + params := []InstanceParam{ + {SvcOperationCfsParamId: 164, ParamValue: plan.Domain.ValueString()}, + {SvcOperationCfsParamId: 169, ParamValue: plan.ResourceRealm.ValueString()}, + {SvcOperationCfsParamId: 165, ParamValue: fmt.Sprintf("%d", plan.Cpu.ValueInt64())}, + {SvcOperationCfsParamId: 166, ParamValue: fmt.Sprintf("%d", plan.Memory.ValueInt64())}, + {SvcOperationCfsParamId: 167, ParamValue: fmt.Sprintf("%d", plan.Disk.ValueInt64())}, + {SvcOperationCfsParamId: 170, ParamValue: plan.Email.ValueString()}, + {SvcOperationCfsParamId: 171, ParamValue: plan.Password.ValueString()}, + } + + serviceId := 96 + svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create") + if err != nil { + resp.Diagnostics.AddError("Failed to get create operation ID for pgAdmin", err.Error()) + return + } + + instanceUid, opUid, err := r.client.CreateInstance(ctx, plan.Domain.ValueString(), serviceId, svcOperationId, params) + if err != nil { + resp.Diagnostics.AddError("Error creating pgAdmin", err.Error()) + return + } + + err = r.client.WaitForOperation(ctx, opUid) + if err != nil { + resp.Diagnostics.AddError("Error waiting for pgAdmin ready", err.Error()) + return + } + + plan.ID = types.StringValue(instanceUid) + // plan.DeletionProtection = types.BoolValue(true) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + + + +func (r *PgAdminResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state PgAdminResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + instanceUid := state.ID.ValueString() + // Fetch current state + inst, err := r.client.GetInstanceState(ctx, instanceUid) + if err != nil { + // If 404, remove from state + if strings.Contains(err.Error(), "status 404") { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading pgAdmin state", err.Error()) + return + } + + // Map params + if inst.State != nil && inst.State.Params != nil { + for k, v := range inst.State.Params { + valStr := fmt.Sprintf("%v", v) + // Log for debugging + tflog.Info(ctx, fmt.Sprintf("PGAdmin Param: %s = %s", k, valStr)) + + switch k { + case "resourceCPU": + val, _ := strconv.ParseInt(valStr, 10, 64) + state.Cpu = types.Int64Value(val) + case "resourceMemory": + val, _ := strconv.ParseInt(valStr, 10, 64) + state.Memory = types.Int64Value(val) + case "resourceDisk": + val, _ := strconv.ParseInt(valStr, 10, 64) + state.Disk = types.Int64Value(val) + case "domain": + state.Domain = types.StringValue(valStr) + case "resourceRealm": + state.ResourceRealm = types.StringValue(valStr) + case "login": + state.Email = types.StringValue(valStr) + } + } + } + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *PgAdminResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state PgAdminResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Updating pgAdmin resource...") + + // Find operation ID for 'modify' + // Based on HAR/Screenshots, modify uses standard "modify" operation + opId, err := r.client.GetOperationIdForInstance(ctx, state.ID.ValueString(), "modify") + if err != nil { + resp.Diagnostics.AddError("Failed to find update operation", err.Error()) + return + } + + // Payload for OP creation + opPayload := map[string]interface{}{ + "instanceUid": state.ID.ValueString(), + "svcOperationId": opId, + "operation": "modify", + } + + // Create request + var opUid string + opUid, err = r.client.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true) + if err != nil { + resp.Diagnostics.AddError("Failed to create update operation", err.Error()) + return + } + + // Fetch Op details to get correct CFS Param IDs for 'modify' + opDetails, err := r.client.GetInstanceOperation(ctx, opUid) + if err != nil { + resp.Diagnostics.AddError("Failed to fetch operation details", err.Error()) + return + } + + paramMapping := make(map[string]int) + for _, p := range opDetails.CfsParams { + paramMapping[p.SvcOperationCfsParam] = p.SvcOperationCfsParamId + } + + // Create params list from plan + // We assume standard param names "resourceCPU", "resourceMemory", "resourceDisk" based on screenshot + // But we use mapping to be safe if IDs differ from Create + + type Modification struct { + Name string + Val string + } + + mods := []Modification{ + {"resourceCPU", fmt.Sprintf("%d", plan.Cpu.ValueInt64())}, + {"resourceMemory", fmt.Sprintf("%d", plan.Memory.ValueInt64())}, + {"resourceDisk", fmt.Sprintf("%d", plan.Disk.ValueInt64())}, + } + + params := []InstanceParam{} + for _, m := range mods { + if id, ok := paramMapping[m.Name]; ok { + params = append(params, InstanceParam{SvcOperationCfsParamId: id, ParamValue: m.Val}) + } else { + tflog.Warn(ctx, fmt.Sprintf("Param %s not found in modify operation", m.Name)) + } + } + + // If no params (e.g. only email changed but modify doesn't support it?), skip + if len(params) == 0 { + tflog.Warn(ctx, "No matching parameters found for modify operation. Skipping.") + } else { + // Run op + _, _, err = r.client.RunInstanceOperation(ctx, opUid, params) + if err != nil { + resp.Diagnostics.AddError("Error running modify operation", err.Error()) + return + } + + // Wait + err = r.client.WaitForOperation(ctx, opUid) + if err != nil { + resp.Diagnostics.AddError("Error waiting for modification", err.Error()) + return + } + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + + +func (r *PgAdminResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state PgAdminResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // User requested NO DELETE for pgAdmin (suspend/protection). + // We will simply remove it from Terraform state without calling API delete. + + if state.DeletionProtection.ValueBool() { + tflog.Warn(ctx, "Deletion Protection is ENABLED for PgAdmin. Resource will remain active in Nubes Cloud. Manual cleanup required.") + return + } + + tflog.Info(ctx, "Deletion Protection is DISABLED for PgAdmin. Triggering 'suspend'...") + + instanceId := state.ID.ValueString() + + // Выполняем операцию suspend + err := r.client.RunAction(ctx, instanceId, "suspend") + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend PgAdmin: %s", err)) + return + } + + tflog.Info(ctx, "PgAdmin suspended successfully. It will be permanently deleted from the cloud in 14 days.") +} + + +func (r *PgAdminResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { +resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/postgres_resource.go b/internal/provider/postgres_resource.go new file mode 100644 index 0000000..d0d3746 --- /dev/null +++ b/internal/provider/postgres_resource.go @@ -0,0 +1,1046 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ resource.Resource = &PostgresResource{} +var _ resource.ResourceWithImportState = &PostgresResource{} +var _ resource.ResourceWithModifyPlan = &PostgresResource{} + +type PostgresResource struct { + client *NubesClient +} + +var uuidLikeRegex = 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}$") + +func isUUIDLike(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return false + } + return uuidLikeRegex.MatchString(trimmed) +} + +func (r *PostgresResource) resolveS3Uid(ctx context.Context, value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", nil + } + if isUUIDLike(trimmed) { + return trimmed, nil + } + + return "", fmt.Errorf("s3_uid must be a UUID, got: %s", trimmed) +} + +func NewPostgresResource() resource.Resource { + return &PostgresResource{} +} + +type PostgresResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ResourceRealm types.String `tfsdk:"resource_realm"` + S3Uid types.String `tfsdk:"s3_uid"` + Cpu types.Int64 `tfsdk:"cpu"` + Ram types.Int64 `tfsdk:"ram"` + DiskSize types.Int64 `tfsdk:"disk_size"` + NodesCount types.Int64 `tfsdk:"nodes_count"` + Version types.String `tfsdk:"version"` + BackupSchedule types.String `tfsdk:"backup_schedule"` + BackupRetention types.Int64 `tfsdk:"backup_retention"` + Parameters types.String `tfsdk:"parameters"` + EnablePgPoolerMaster types.Bool `tfsdk:"enable_pgpooler_master"` + EnablePgPoolerSlave types.Bool `tfsdk:"enable_pgpooler_slave"` + AllowNoSSL types.Bool `tfsdk:"allow_no_ssl"` + AutoScale types.Bool `tfsdk:"auto_scale"` + AutoScalePercentage types.Int64 `tfsdk:"auto_scale_percentage"` + AutoScaleTechWindow types.Int64 `tfsdk:"auto_scale_tech_window"` + AutoScaleQuotaGb types.Int64 `tfsdk:"auto_scale_quota_gb"` + EnableExternalMaster types.Bool `tfsdk:"enable_external_master"` + EnableExternalSlave types.Bool `tfsdk:"enable_external_slave"` + IpSpaceMaster types.String `tfsdk:"ip_space_master"` + IpSpaceSlave types.String `tfsdk:"ip_space_slave"` + DeletionProtection types.Bool `tfsdk:"deletion_protection"` + + // Computed + AdminUser types.String `tfsdk:"admin_user"` + AdminPassword types.String `tfsdk:"admin_password"` + StandbyUser types.String `tfsdk:"standby_user"` + StandbyPassword types.String `tfsdk:"standby_password"` + InternalHost types.String `tfsdk:"internal_host"` + VaultUserPath types.String `tfsdk:"vault_user_path"` + VaultUrl types.String `tfsdk:"vault_url"` + MonitoringUrl types.String `tfsdk:"monitoring_url"` + InternalConnectMaster types.String `tfsdk:"internal_connect_master"` + InternalConnectSlave types.String `tfsdk:"internal_connect_slave"` + ExternalConnectMasterIp types.String `tfsdk:"external_connect_master_ip"` + ExternalConnectMasterFqdn types.String `tfsdk:"external_connect_master_fqdn"` + ExternalConnectSlaveIp types.String `tfsdk:"external_connect_slave_ip"` + ExternalConnectSlaveFqdn types.String `tfsdk:"external_connect_slave_fqdn"` +} + +func (r *PostgresResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_postgres" +} + +func (r *PostgresResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Postgres cluster.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Name of the Postgres instance", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "resource_realm": schema.StringAttribute{ + Required: true, + Description: "Deployment platform (param 102). Example: k8s-3.ext.nubes.ru", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "s3_uid": schema.StringAttribute{ + Required: true, + Description: "UID Корневой услуги S3 (Param 23). Только UUID, без displayName. Пример: 6d6061cb-b0c1-44b9-8969-a70f08fe673c", + Validators: []validator.String{ + UUIDLike(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "cpu": schema.Int64Attribute{ + Required: true, + Description: "CPU in millicores (param 82)", + }, + "ram": schema.Int64Attribute{ + Required: true, + Description: "Memory in MB (param 81)", + }, + "disk_size": schema.Int64Attribute{ + Required: true, + Description: "Disk size in GB (param 83)", + }, + "nodes_count": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(1), + Description: "Number of instances (param 80)", + }, + "version": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("17"), + Description: "Postgres Version (param 310)", + }, + "backup_schedule": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("0 0 * * *"), + Description: "Backup Schedule (param 265)", + }, + "backup_retention": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(7), + Description: "Backup Retention Days (param 266)", + }, + "parameters": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("{}"), + Description: "Postgres config as JSON string (param 311)", + }, + "enable_pgpooler_master": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Enable pgPooler for Master (param 312)", + }, + "enable_pgpooler_slave": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Enable pgPooler for Slave (param 313)", + }, + "allow_no_ssl": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Allow No SSL (param 314)", + }, + "auto_scale": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Enable AutoScale (param 329)", + }, + "auto_scale_percentage": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(10), + Description: "AutoScale Percentage (param 330)", + }, + "auto_scale_tech_window": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(0), + Description: "AutoScale Tech Window (param 331)", + }, + "auto_scale_quota_gb": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(1), + Description: "AutoScale Quota GB (param 339)", + }, + "enable_external_master": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Need External IP for Master (param 145)", + }, + "enable_external_slave": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Need External IP for Slave (param 146)", + }, + "ip_space_master": schema.StringAttribute{ + Optional: true, + Description: "IP Space Name for Master (param 337)", + }, + "ip_space_slave": schema.StringAttribute{ + Optional: true, + Description: "IP Space Name for Slave (param 338)", + }, + "deletion_protection": schema.BoolAttribute{ + MarkdownDescription: "If true, the resource will only be removed from Terraform state upon destroy, but will remain in the cloud. If false, destroy will trigger 'suspend' in Nubes.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + + // Output + "admin_user": schema.StringAttribute{ + Computed: true, + }, + "admin_password": schema.StringAttribute{ + Computed: true, + Sensitive: true, + }, + "standby_user": schema.StringAttribute{ + Computed: true, + }, + "standby_password": schema.StringAttribute{ + Computed: true, + Sensitive: true, + }, + "internal_host": schema.StringAttribute{ + Computed: true, + }, + "vault_user_path": schema.StringAttribute{ + Computed: true, + }, + "vault_url": schema.StringAttribute{ + Computed: true, + }, + "monitoring_url": schema.StringAttribute{ + Computed: true, + }, + "internal_connect_master": schema.StringAttribute{ + Computed: true, + }, + "internal_connect_slave": schema.StringAttribute{ + Computed: true, + }, + "external_connect_master_ip": schema.StringAttribute{ + Computed: true, + }, + "external_connect_master_fqdn": schema.StringAttribute{ + Computed: true, + }, + "external_connect_slave_ip": schema.StringAttribute{ + Computed: true, + }, + "external_connect_slave_fqdn": schema.StringAttribute{ + Computed: true, + }, + }, + } +} + +func (r *PostgresResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData)) + return + } + r.client = client +} + +func (r *PostgresResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan PostgresResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Creating Postgres resource...") + + // Attempt to resume suspended instance if exists + tflog.Info(ctx, "Checking for suspended instances to resume...") + var resumedInstanceUid string + instances, err := r.client.GetInstances(ctx) + if err != nil { + tflog.Warn(ctx, fmt.Sprintf("Failed to list instances: %s", err)) + } else { + tflog.Info(ctx, fmt.Sprintf("Scanned %d instances.", len(instances))) + for _, inst := range instances { + if inst.DisplayName == plan.Name.ValueString() && inst.ServiceId == 90 { + tflog.Info(ctx, fmt.Sprintf("Found matching instance name: %s (%s)", inst.DisplayName, inst.InstanceUid)) + details, err := r.client.GetInstanceStateDetails(ctx, inst.InstanceUid) + if err == nil { + isSuspended, _ := details["isSuspended"].(bool) + isDeleted, _ := details["isDeleted"].(bool) + + tflog.Info(ctx, fmt.Sprintf("Instance status isSuspended: %v, isDeleted: %v", isSuspended, isDeleted)) + + if isSuspended { + tflog.Info(ctx, fmt.Sprintf("Found suspended instance %s. Resuming...", inst.InstanceUid)) + if err := r.client.RunAction(ctx, inst.InstanceUid, "resume"); err != nil { + resp.Diagnostics.AddError("Resume Failed", err.Error()) + return + } + // Postgres goes to 'running' state, not 'active' + if err := r.client.WaitForInstanceStatus(ctx, inst.InstanceUid, "running"); err != nil { + resp.Diagnostics.AddError("Wait for Resume Failed", err.Error()) + return + } + resumedInstanceUid = inst.InstanceUid + } else if !isDeleted { + tflog.Info(ctx, fmt.Sprintf("Instance %s is already active. Adopting...", inst.InstanceUid)) + resumedInstanceUid = inst.InstanceUid + } + } + break + } + } + } + + resolvedS3Uid, err := r.resolveS3Uid(ctx, plan.S3Uid.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to resolve s3_uid", err.Error()) + return + } + if resolvedS3Uid != "" { + plan.S3Uid = types.StringValue(resolvedS3Uid) + } + + params := []InstanceParam{ + {SvcOperationCfsParamId: 102, ParamValue: plan.ResourceRealm.ValueString()}, + {SvcOperationCfsParamId: 23, ParamValue: plan.S3Uid.ValueString()}, + {SvcOperationCfsParamId: 82, ParamValue: fmt.Sprintf("%d", plan.Cpu.ValueInt64())}, + {SvcOperationCfsParamId: 81, ParamValue: fmt.Sprintf("%d", plan.Ram.ValueInt64())}, + {SvcOperationCfsParamId: 83, ParamValue: fmt.Sprintf("%d", plan.DiskSize.ValueInt64())}, + {SvcOperationCfsParamId: 80, ParamValue: fmt.Sprintf("%d", plan.NodesCount.ValueInt64())}, + {SvcOperationCfsParamId: 266, ParamValue: fmt.Sprintf("%d", plan.BackupRetention.ValueInt64())}, + {SvcOperationCfsParamId: 265, ParamValue: plan.BackupSchedule.ValueString()}, + {SvcOperationCfsParamId: 310, ParamValue: plan.Version.ValueString()}, + {SvcOperationCfsParamId: 311, ParamValue: plan.Parameters.ValueString()}, + {SvcOperationCfsParamId: 312, ParamValue: strconv.FormatBool(plan.EnablePgPoolerMaster.ValueBool())}, + {SvcOperationCfsParamId: 313, ParamValue: strconv.FormatBool(plan.EnablePgPoolerSlave.ValueBool())}, + {SvcOperationCfsParamId: 314, ParamValue: strconv.FormatBool(plan.AllowNoSSL.ValueBool())}, + {SvcOperationCfsParamId: 329, ParamValue: strconv.FormatBool(plan.AutoScale.ValueBool())}, + {SvcOperationCfsParamId: 330, ParamValue: fmt.Sprintf("%d", plan.AutoScalePercentage.ValueInt64())}, + {SvcOperationCfsParamId: 331, ParamValue: fmt.Sprintf("%d", plan.AutoScaleTechWindow.ValueInt64())}, + {SvcOperationCfsParamId: 339, ParamValue: fmt.Sprintf("%d", plan.AutoScaleQuotaGb.ValueInt64())}, + {SvcOperationCfsParamId: 145, ParamValue: strconv.FormatBool(plan.EnableExternalMaster.ValueBool())}, + {SvcOperationCfsParamId: 146, ParamValue: strconv.FormatBool(plan.EnableExternalSlave.ValueBool())}, + } + + if !plan.IpSpaceMaster.IsNull() { + params = append(params, InstanceParam{SvcOperationCfsParamId: 337, ParamValue: plan.IpSpaceMaster.ValueString()}) + } else { + params = append(params, InstanceParam{SvcOperationCfsParamId: 337, ParamValue: ""}) + } + if !plan.IpSpaceSlave.IsNull() { + params = append(params, InstanceParam{SvcOperationCfsParamId: 338, ParamValue: plan.IpSpaceSlave.ValueString()}) + } else { + params = append(params, InstanceParam{SvcOperationCfsParamId: 338, ParamValue: ""}) + } + + serviceId := 90 + var instanceUid string + + if resumedInstanceUid != "" { + instanceUid = resumedInstanceUid + } else { + svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create") + if err != nil { + resp.Diagnostics.AddError("Failed to get create operation ID", err.Error()) + return + } + + displayName := plan.Name.ValueString() + var opUid string + instanceUid, opUid, err = r.client.CreateInstance(ctx, displayName, serviceId, svcOperationId, params) + if err != nil { + resp.Diagnostics.AddError("Error creating Postgres", err.Error()) + return + } + + // Wait for operation success + err = r.client.WaitForOperation(ctx, opUid) + if err != nil { + resp.Diagnostics.AddError("Error waiting for Postgres creation", err.Error()) + return + } + } + + plan.ID = types.StringValue(instanceUid) + + // Initialize computed fields to avoid "Unknown" errors if API doesn't return them + plan.AdminUser = types.StringNull() + plan.AdminPassword = types.StringNull() + plan.StandbyUser = types.StringNull() + plan.StandbyPassword = types.StringNull() + plan.InternalHost = types.StringNull() + plan.VaultUserPath = types.StringNull() + plan.VaultUrl = types.StringNull() + plan.MonitoringUrl = types.StringNull() + plan.InternalConnectMaster = types.StringNull() + plan.InternalConnectSlave = types.StringNull() + plan.ExternalConnectMasterIp = types.StringNull() + plan.ExternalConnectMasterFqdn = types.StringNull() + plan.ExternalConnectSlaveIp = types.StringNull() + plan.ExternalConnectSlaveFqdn = types.StringNull() + + // Fetch details to populate Computed fields + stateData, err := r.client.GetInstanceStateDetails(ctx, instanceUid) + if err != nil { + resp.Diagnostics.AddWarning("Failed to populate computed fields", err.Error()) + } else { + // Vault + if vault, ok := stateData["vault"].(map[string]interface{}); ok { + if val, ok := vault["userPath"]; ok { + plan.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := vault["url"]; ok { + plan.VaultUrl = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // Out (Connect details) + if out, ok := stateData["out"].(map[string]interface{}); ok { + // Credentials + if val, ok := out["adminUser"]; ok { + plan.AdminUser = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["adminPass"]; ok { + plan.AdminPassword = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["standbyUser"]; ok { + plan.StandbyUser = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["standbyPass"]; ok { + plan.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val)) + } + + // Monitoring + if monitoring, ok := out["monitoring"].(map[string]interface{}); ok { + if val, ok := monitoring["allDashboards"]; ok { + plan.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // Internal Connect + if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok { + if val, ok := internalConnect["master"]; ok && val != "" { + plan.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val)) + plan.InternalHost = types.StringValue(fmt.Sprintf("%v", val)) // Sync with master for now + } + if val, ok := internalConnect["slave"]; ok && val != "" { + plan.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // External Connect + if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok { + if master, ok := externalConnect["master"].(map[string]interface{}); ok { + if val, ok := master["ip"]; ok && val != "" { + plan.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := master["fqdn"]; ok && val != "" { + plan.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val)) + } + } + if slave, ok := externalConnect["slave"].(map[string]interface{}); ok { + if val, ok := slave["ip"]; ok && val != "" { + plan.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := slave["fqdn"]; ok && val != "" { + plan.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val)) + } + } + } + } + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *PostgresResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state PostgresResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if !state.S3Uid.IsNull() && !state.S3Uid.IsUnknown() { + resolvedS3Uid, err := r.resolveS3Uid(ctx, state.S3Uid.ValueString()) + if err == nil && resolvedS3Uid != "" { + state.S3Uid = types.StringValue(resolvedS3Uid) + } else if err != nil { + resp.Diagnostics.AddWarning("Failed to resolve s3_uid", err.Error()) + } + } + + stateData, err := r.client.GetInstanceStateDetails(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error reading Postgres", err.Error()) + return + } + + // Update calculated fields + // Vault + if vault, ok := stateData["vault"].(map[string]interface{}); ok { + if val, ok := vault["userPath"]; ok { + state.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := vault["url"]; ok { + state.VaultUrl = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // Out + if out, ok := stateData["out"].(map[string]interface{}); ok { + // Credentials + if val, ok := out["adminUser"]; ok { + state.AdminUser = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["adminPass"]; ok { + state.AdminPassword = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["standbyUser"]; ok { + state.StandbyUser = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["standbyPass"]; ok { + state.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val)) + } + + // Monitoring + if monitoring, ok := out["monitoring"].(map[string]interface{}); ok { + if val, ok := monitoring["allDashboards"]; ok { + state.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // Internal Connect + if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok { + if val, ok := internalConnect["master"]; ok && val != "" { + state.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val)) + state.InternalHost = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := internalConnect["slave"]; ok && val != "" { + state.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // External Connect + if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok { + if master, ok := externalConnect["master"].(map[string]interface{}); ok { + if val, ok := master["ip"]; ok && val != "" { + state.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := master["fqdn"]; ok && val != "" { + state.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val)) + } + } + if slave, ok := externalConnect["slave"].(map[string]interface{}); ok { + if val, ok := slave["ip"]; ok && val != "" { + state.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := slave["fqdn"]; ok && val != "" { + state.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val)) + } + } + } + } + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *PostgresResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state PostgresResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Updating Postgres resource...") + + // Validation: Verify Disk Size reduction + if plan.DiskSize.ValueInt64() < state.DiskSize.ValueInt64() { + resp.Diagnostics.AddError( + "Invalid Disk Upgrade", + fmt.Sprintf("Decreasing disk size is not supported (Current: %d GB, Requested: %d GB)", state.DiskSize.ValueInt64(), plan.DiskSize.ValueInt64()), + ) + return + } + + // Find operation ID for 'modify' + opId, err := r.client.GetOperationIdForInstance(ctx, state.ID.ValueString(), "modify") + if err != nil { + resp.Diagnostics.AddError("Failed to find update operation", err.Error()) + return + } + + // Create Operation + opPayload := map[string]interface{}{ + "instanceUid": state.ID.ValueString(), + "svcOperationId": opId, + "operation": "modify", + } + + var opUid string + opUid, err = r.client.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true) + if err != nil { + resp.Diagnostics.AddError("Failed to create update operation", err.Error()) + return + } + if opUid == "" { + resp.Diagnostics.AddError("Failed to get operation UID", "Empty UID returned") + return + } + + // Fetch Op details to get correct CFS Param IDs for 'modify' + opDetails, err := r.client.GetInstanceOperation(ctx, opUid) + if err != nil { + resp.Diagnostics.AddError("Failed to fetch operation details", err.Error()) + return + } + + paramMapping := make(map[string]int) + for _, p := range opDetails.CfsParams { + paramMapping[p.SvcOperationCfsParam] = p.SvcOperationCfsParamId + } + + type NamedParam struct { + Name string + Value string + } + namedParams := []NamedParam{ + {"resourceCPU", fmt.Sprintf("%d", plan.Cpu.ValueInt64())}, + {"resourceMemory", fmt.Sprintf("%d", plan.Ram.ValueInt64())}, + {"resourceDisk", fmt.Sprintf("%d", plan.DiskSize.ValueInt64())}, + {"resourceInstances", fmt.Sprintf("%d", plan.NodesCount.ValueInt64())}, + {"ext_BACKUP_NUM_TO_RETAIN", fmt.Sprintf("%d", plan.BackupRetention.ValueInt64())}, + {"ext_BACKUP_SCHEDULE", plan.BackupSchedule.ValueString()}, + {"appVersion", plan.Version.ValueString()}, + {"jsonParameters", plan.Parameters.ValueString()}, + {"enablePgPoolerMaster", strconv.FormatBool(plan.EnablePgPoolerMaster.ValueBool())}, + {"enablePgPoolerSlave", strconv.FormatBool(plan.EnablePgPoolerSlave.ValueBool())}, + {"allowNoSSL", strconv.FormatBool(plan.AllowNoSSL.ValueBool())}, + {"autoScale", strconv.FormatBool(plan.AutoScale.ValueBool())}, + {"autoScalePercentage", fmt.Sprintf("%d", plan.AutoScalePercentage.ValueInt64())}, + {"autoScaleTechWindow", fmt.Sprintf("%d", plan.AutoScaleTechWindow.ValueInt64())}, + {"autoScaleQuotaGb", fmt.Sprintf("%d", plan.AutoScaleQuotaGb.ValueInt64())}, + {"needExternalAddressMaster", strconv.FormatBool(plan.EnableExternalMaster.ValueBool())}, + {"needExternalAddressSlave", strconv.FormatBool(plan.EnableExternalSlave.ValueBool())}, + } + + if !plan.IpSpaceMaster.IsNull() { + namedParams = append(namedParams, NamedParam{"ipSpaceNameMaster", plan.IpSpaceMaster.ValueString()}) + } else { + namedParams = append(namedParams, NamedParam{"ipSpaceNameMaster", ""}) + } + if !plan.IpSpaceSlave.IsNull() { + namedParams = append(namedParams, NamedParam{"ipSpaceNameSlave", plan.IpSpaceSlave.ValueString()}) + } else { + namedParams = append(namedParams, NamedParam{"ipSpaceNameSlave", ""}) + } + + // Add Params using dynamic mapping + for _, np := range namedParams { + id, ok := paramMapping[np.Name] + if !ok { + tflog.Debug(ctx, fmt.Sprintf("Param %s not supported for this operation, skipping", np.Name)) + continue + } + + paramPayload := map[string]interface{}{ + "instanceOperationUid": opUid, + "svcOperationCfsParamId": id, + "paramValue": np.Value, + } + if _, err := r.client.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to add param %s (%d)", np.Name, id), err.Error()) + return + } + } + + // Run + runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) + if _, err := r.client.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false); err != nil { + resp.Diagnostics.AddError("Failed to run update", err.Error()) + return + } + + // Wait for operation success + err = r.client.WaitForOperation(ctx, opUid) + if err != nil { + resp.Diagnostics.AddError("Error waiting for Postgres update", err.Error()) + return + } + + // Fetch details to populate Computed fields + stateData, err := r.client.GetInstanceStateDetails(ctx, state.ID.ValueString()) + if err != nil { + tflog.Warn(ctx, fmt.Sprintf("Failed to refresh state after update: %s", err)) + } + + // Initialize all computed fields to avoid 'unknown value' errors after apply + plan.AdminUser = types.StringNull() + plan.AdminPassword = types.StringNull() + plan.StandbyUser = types.StringNull() + plan.StandbyPassword = types.StringNull() + plan.VaultUserPath = types.StringNull() + plan.VaultUrl = types.StringNull() + plan.MonitoringUrl = types.StringNull() + plan.InternalConnectMaster = types.StringNull() + plan.InternalHost = types.StringNull() + plan.InternalConnectSlave = types.StringNull() + plan.ExternalConnectMasterIp = types.StringNull() + plan.ExternalConnectMasterFqdn = types.StringNull() + plan.ExternalConnectSlaveIp = types.StringNull() + plan.ExternalConnectSlaveFqdn = types.StringNull() + + if err == nil { + // Vault + if vault, ok := stateData["vault"].(map[string]interface{}); ok { + if val, ok := vault["userPath"]; ok { + plan.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := vault["url"]; ok { + plan.VaultUrl = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // Out (Connect details) + if out, ok := stateData["out"].(map[string]interface{}); ok { + // Credentials + if val, ok := out["adminUser"]; ok { + plan.AdminUser = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["adminPass"]; ok { + plan.AdminPassword = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["standbyUser"]; ok { + plan.StandbyUser = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := out["standbyPass"]; ok { + plan.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val)) + } + + // Monitoring + if monitoring, ok := out["monitoring"].(map[string]interface{}); ok { + if val, ok := monitoring["allDashboards"]; ok { + plan.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // Internal Connect + if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok { + if val, ok := internalConnect["master"]; ok && val != "" { + plan.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val)) + plan.InternalHost = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := internalConnect["slave"]; ok && val != "" { + plan.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val)) + } + } + + // External Connect + if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok { + if master, ok := externalConnect["master"].(map[string]interface{}); ok { + if val, ok := master["ip"]; ok && val != "" { + plan.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := master["fqdn"]; ok && val != "" { + plan.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val)) + } + } + if slave, ok := externalConnect["slave"].(map[string]interface{}); ok { + if val, ok := slave["ip"]; ok && val != "" { + plan.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val)) + } + if val, ok := slave["fqdn"]; ok && val != "" { + plan.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val)) + } + } + } + } + } + + // fix: plan.ID is Computed (empty in plan), must preserve existing ID from state + plan.ID = state.ID + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *PostgresResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if r.client == nil { + return + } + var plan PostgresResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + if plan.S3Uid.IsNull() || plan.S3Uid.IsUnknown() { + return + } + + resolvedS3Uid, err := r.resolveS3Uid(ctx, plan.S3Uid.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Invalid s3_uid", err.Error()) + return + } + if resolvedS3Uid == "" || resolvedS3Uid == plan.S3Uid.ValueString() { + return + } + + plan.S3Uid = types.StringValue(resolvedS3Uid) + resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...) +} + +func (r *PostgresResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data PostgresResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if data.DeletionProtection.ValueBool() { + tflog.Warn(ctx, "Deletion Protection is ENABLED for Postgres. Resource will remain active in Nubes Cloud. Manual cleanup required.") + return + } + + tflog.Info(ctx, "Deletion Protection is DISABLED for Postgres. Triggering 'suspend'...") + + instanceId := data.ID.ValueString() + + // Выполняем операцию suspend + // Для Postgres используем тот же механизм Instance Run что и для VDC + err := r.triggerSuspend(ctx, instanceId) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend Postgres: %s", err)) + return + } + + tflog.Info(ctx, "Postgres suspended successfully. It will be permanently deleted from the cloud in 14 days.") +} + +func (r *PostgresResource) triggerSuspend(ctx context.Context, instanceId string) error { + // Выполняем операцию suspend через новый унифицированный метод + err := r.client.RunAction(ctx, instanceId, "suspend") + if err != nil { + return err + } + + // Ждем пока статус изменится на suspended + return r.client.WaitForInstanceStatus(ctx, instanceId, "suspended") +} + +func (r *PostgresResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Retrieve the full instance data + inst, err := r.client.GetInstanceFull(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("Failed to fetch instance for import", err.Error()) + return + } + + var state PostgresResourceModel + state.ID = types.StringValue(req.ID) + + // Top-level fields + if v, ok := inst["displayName"].(string); ok { + state.Name = types.StringValue(v) + } + if v, ok := inst["resourceRealm"].(string); ok { + state.ResourceRealm = types.StringValue(v) + } + + // State params + if stateMap, ok := inst["state"].(map[string]interface{}); ok { + if params, ok := stateMap["params"].(map[string]interface{}); ok { + + // String or Int handling + toInt64 := func(v interface{}) int64 { + switch val := v.(type) { + case float64: + return int64(val) + case int: + return int64(val) + case string: + if i, err := strconv.ParseInt(val, 10, 64); err == nil { + return i + } + } + return 0 + } + + if v, ok := params["resourceCPU"]; ok { + state.Cpu = types.Int64Value(toInt64(v)) + } + if v, ok := params["resourceMemory"]; ok { + state.Ram = types.Int64Value(toInt64(v)) + } + if v, ok := params["resourceDisk"]; ok { + state.DiskSize = types.Int64Value(toInt64(v)) + } + if v, ok := params["resourceInstances"]; ok { + state.NodesCount = types.Int64Value(toInt64(v)) + } + + if v, ok := params["s3Uid"]; ok { + raw := fmt.Sprintf("%v", v) + resolvedS3Uid, err := r.resolveS3Uid(ctx, raw) + if err != nil { + resp.Diagnostics.AddWarning("Failed to resolve s3_uid", err.Error()) + state.S3Uid = types.StringValue(raw) + } else if resolvedS3Uid != "" { + state.S3Uid = types.StringValue(resolvedS3Uid) + } + } + if v, ok := params["appVersion"]; ok { + state.Version = types.StringValue(fmt.Sprintf("%v", v)) + } + + if v, ok := params["ext_BACKUP_SCHEDULE"]; ok { + state.BackupSchedule = types.StringValue(fmt.Sprintf("%v", v)) + } + if v, ok := params["ext_BACKUP_NUM_TO_RETAIN"]; ok { + state.BackupRetention = types.Int64Value(toInt64(v)) + } + + // Booleans + toBool := func(v interface{}) bool { + switch val := v.(type) { + case bool: + return val + case string: + return val == "true" + } + return false + } + + if v, ok := params["enablePgPoolerMaster"]; ok { + state.EnablePgPoolerMaster = types.BoolValue(toBool(v)) + } + if v, ok := params["enablePgPoolerSlave"]; ok { + state.EnablePgPoolerSlave = types.BoolValue(toBool(v)) + } + if v, ok := params["allowNoSSL"]; ok { + state.AllowNoSSL = types.BoolValue(toBool(v)) + } + if v, ok := params["autoScale"]; ok { + state.AutoScale = types.BoolValue(toBool(v)) + } + if v, ok := params["needExternalAddressMaster"]; ok { + state.EnableExternalMaster = types.BoolValue(toBool(v)) + } + if v, ok := params["needExternalAddressSlave"]; ok { + state.EnableExternalSlave = types.BoolValue(toBool(v)) + } + + if v, ok := params["ipSpaceNameMaster"]; ok { + if s := fmt.Sprintf("%v", v); s != "" { + state.IpSpaceMaster = types.StringValue(s) + } + } + if v, ok := params["ipSpaceNameSlave"]; ok { + if s := fmt.Sprintf("%v", v); s != "" { + state.IpSpaceSlave = types.StringValue(s) + } + } + + // Autoscale details + if v, ok := params["autoScalePercentage"]; ok { + state.AutoScalePercentage = types.Int64Value(toInt64(v)) + } + if v, ok := params["autoScaleTechWindow"]; ok { + state.AutoScaleTechWindow = types.Int64Value(toInt64(v)) + } + if v, ok := params["autoScaleQuotaGb"]; ok { + state.AutoScaleQuotaGb = types.Int64Value(toInt64(v)) + } + + // JSON Parameters (map -> json string) + // Нормализуем через json.Compact чтобы совпадало с планом (jsonencode тоже compact) + if v, ok := params["jsonParameters"]; ok { + if jsonBytes, err := json.Marshal(v); err == nil { + var buf bytes.Buffer + if err2 := json.Compact(&buf, jsonBytes); err2 == nil { + state.Parameters = types.StringValue(buf.String()) + } else { + state.Parameters = types.StringValue(string(jsonBytes)) + } + } + } + } + } + + state.DeletionProtection = types.BoolValue(true) + + diags := resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..07d757d --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,197 @@ +package provider + +import ( + "context" + "crypto/tls" + "net/http" + "os" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-mycloud/internal/core" + "terraform-provider-mycloud/internal/generated" +) + +var _ provider.Provider = &NubesProvider{} + +type NubesProvider struct { + version string +} + +// NubesProviderModel — конфигурация провайдера из HCL-блока provider {}. +// Каждое поле соответствует атрибуту в Schema() ниже. +// Поля Optional: если не указаны — берутся из env-переменных или defaults. +type NubesProviderModel struct { + ApiEndpoint types.String `tfsdk:"api_endpoint"` + ApiToken types.String `tfsdk:"api_token"` + // Insecure отключает проверку TLS-сертификата сервера. + // НЕ использовать в продакшн! Только для dev-стендов с самоподписанным сертом. + // Может быть задан также через env NUBES_INSECURE=true. + Insecure types.Bool `tfsdk:"insecure"` + // LogLevel задаёт уровень вывода этапов операций: "none" (default) | "info" | "debug". + // Может быть переопределён на уровне ресурса через атрибут log_level ресурса. + LogLevel types.String `tfsdk:"log_level"` +} + +type NubesClient struct { + HttpClient *http.Client + ApiEndpoint string + ApiToken string +} + +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &NubesProvider{ + version: version, + } + } +} + +func (p *NubesProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "nubes" + resp.Version = p.version +} + +func (p *NubesProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "api_endpoint": schema.StringAttribute{ + MarkdownDescription: "API Gateway endpoint for Nubes Cloud", + Optional: true, + }, + "api_token": schema.StringAttribute{ + MarkdownDescription: "API authentication token", + Optional: true, + Sensitive: true, + }, + // insecure: отключает проверку TLS-сертификата Nubes API. + // По умолчанию false — сертификат проверяется (безопасно). + // Устанавливать true только на dev-стенде с самоподписанным сертом. + // Альтернатива: env NUBES_INSECURE=true (не требует правки .tf файлов). + "insecure": schema.BoolAttribute{ + MarkdownDescription: "Disable TLS certificate verification. Use only for dev environments with self-signed certs. Can also be set via NUBES_INSECURE env var.", + Optional: true, + }, + // log_level: уровень вывода этапов операций во время terraform apply/destroy. + // "none" (default) — не выводить ничего. + // "info" — выводить строку на каждый этап: [OK ] Валидация — 74.8 sec + // "debug" — как info + детали каждого этапа без timestamp-мусора. + // Может быть переопределён на уровне ресурса через атрибут log_level. + "log_level": schema.StringAttribute{ + MarkdownDescription: "Operation stages log level: none (default), info, debug.", + Optional: true, + }, + }, + } +} + +func (p *NubesProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config NubesProviderModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + // Default values + apiEndpoint := "https://deck-api.ngcloud.ru/api/v1/index.cfm" + apiToken := "" + + if !config.ApiEndpoint.IsNull() { + apiEndpoint = config.ApiEndpoint.ValueString() + } + + if !config.ApiToken.IsNull() { + apiToken = strings.TrimSpace(config.ApiToken.ValueString()) + } else { + // Try to get from environment + if token := os.Getenv("NUBES_API_TOKEN"); token != "" { + apiToken = strings.TrimSpace(token) + } else { + // Try to read from ~/.nubes_token file + homeDir, err := os.UserHomeDir() + if err == nil { + tokenFile := homeDir + "/.nubes_token" + if data, err := os.ReadFile(tokenFile); err == nil { + apiToken = strings.TrimSpace(string(data)) + } + } + } + } + + // --- TLS: определяем нужно ли пропустить проверку сертификата --- + // Приоритет: config.insecure > env NUBES_INSECURE > false (безопасный default). + // Nubes Cloud API использует валидный TLS-сертификат от доверенного CA, + // поэтому в продакшн InsecureSkipVerify должен быть false. + // true оставлен только для совместимости с dev-стендами без нормального сертификата. + insecureSkipVerify := false + if !config.Insecure.IsNull() && !config.Insecure.IsUnknown() { + // Явно задано в provider {} блоке HCL + insecureSkipVerify = config.Insecure.ValueBool() + } else if os.Getenv("NUBES_INSECURE") == "true" { + // Задано через переменную окружения (удобно для CI/CD без правки .tf файлов) + insecureSkipVerify = true + } + + // Custom transport based on DefaultTransport. + // Клонируем DefaultTransport чтобы сохранить все системные настройки (proxy, timeouts). + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSHandshakeTimeout = 60 * time.Second + + // Force HTTP/1.1: Nubes API не поддерживает HTTP/2, принудительно отключаем. + // MinVersion TLS 1.2 — минимально безопасная версия TLS. + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: insecureSkipVerify, // false в prod, true только для dev + NextProtos: []string{"http/1.1"}, + MinVersion: tls.VersionTLS12, + } + transport.ForceAttemptHTTP2 = false + + // Use Core Universal Client + client := &core.UniversalClient{ + HttpClient: &http.Client{ + Transport: transport, + Timeout: 300 * time.Second, + }, + ApiEndpoint: apiEndpoint, + ApiToken: apiToken, + LogLevel: config.LogLevel.ValueString(), + } + + resp.DataSourceData = client + resp.ResourceData = client +} + +func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewTubulusResource, + NewOrganizationResource, + NewVMResource, + NewVDCResource, + NewEdgeResource, + NewVAppResource, + NewQuickStartResource, + NewPostgresResource, + NewS3BucketResource, + NewPgAdminResource, + // Generated Resources + generated.NewBolvankaResource, + generated.NewBolvankaUniversalResource, + generated.NewBolvankaUniversalLifecycleResource, + } +} + +func (p *NubesProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + NewVDCDataSource, + NewEdgeDataSource, + NewVAppDataSource, + NewServiceInstanceDataSource, + } +} diff --git a/internal/provider/quick_start_resource.go b/internal/provider/quick_start_resource.go new file mode 100644 index 0000000..a6067d7 --- /dev/null +++ b/internal/provider/quick_start_resource.go @@ -0,0 +1,491 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "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/types" +) + +var _ resource.Resource = &QuickStartResource{} +var _ resource.ResourceWithImportState = &QuickStartResource{} + +func NewQuickStartResource() resource.Resource { + return &QuickStartResource{} +} + +type QuickStartResource struct { + client *NubesClient +} + +type QuickStartResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + Status types.String `tfsdk:"status"` + + // Параметры из HAR файла + OrgDomain types.String `tfsdk:"org_domain"` // 332: ngcloud.ru + VAppName types.String `tfsdk:"vapp_name"` // 334: vappa + ProviderVDC types.String `tfsdk:"provider_vdc"` // 358: sandbox-v1cl1-pvdc + EdgeCount types.Int64 `tfsdk:"edge_count"` // 359: 1 + ExternalNetwork types.String `tfsdk:"external_network"` // 360: internet-ipv4-v1 + StorageProfiles types.String `tfsdk:"storage_profiles"` // 399: [{"name": "", "size": 1024}] + NetworkPool types.String `tfsdk:"network_pool"` // 400: nsxt-sandbox-geneve-np + CPUAllocationPercent types.Int64 `tfsdk:"cpu_allocation_percent"` // 401: 20 + RAMAllocationPercent types.Int64 `tfsdk:"ram_allocation_percent"` // 402: 20 + ServiceEngineGroup types.String `tfsdk:"service_engine_group"` // 403: SEGROUP-SANDBOX-CL1-SHARED-01 + ThinProvisioning types.Bool `tfsdk:"thin_provisioning"` // 404: true + FastProvisioning types.Bool `tfsdk:"fast_provisioning"` // 405: false + VDCNetworkQuota types.Int64 `tfsdk:"vdc_network_quota"` // 566: 1 + CPUQuotaMhz types.Int64 `tfsdk:"cpu_quota_mhz"` // 567: 10 + RAMQuotaGb types.Int64 `tfsdk:"ram_quota_gb"` // 568: 1 +} + +func (r *QuickStartResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_quick_start" +} + +func (r *QuickStartResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Nubes Quick Start - создаёт полное окружение (Org + VDC + Edge + vApp)", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Quick Start identifier (UUID)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Display name", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Description", + Optional: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Current status", + Computed: true, + }, + "org_domain": schema.StringAttribute{ + MarkdownDescription: "Organization domain (например: ngcloud.ru)", + Required: true, + }, + "vapp_name": schema.StringAttribute{ + MarkdownDescription: "vApp name", + Required: true, + }, + "provider_vdc": schema.StringAttribute{ + MarkdownDescription: "Provider VDC name", + Required: true, + }, + "edge_count": schema.Int64Attribute{ + MarkdownDescription: "Number of Edge gateways", + Required: true, + }, + "external_network": schema.StringAttribute{ + MarkdownDescription: "External network name", + Required: true, + }, + "storage_profiles": schema.StringAttribute{ + MarkdownDescription: "Storage profiles JSON", + Required: true, + }, + "network_pool": schema.StringAttribute{ + MarkdownDescription: "Network pool name", + Required: true, + }, + "cpu_allocation_percent": schema.Int64Attribute{ + MarkdownDescription: "CPU allocation percent", + Required: true, + }, + "ram_allocation_percent": schema.Int64Attribute{ + MarkdownDescription: "RAM allocation percent", + Required: true, + }, + "service_engine_group": schema.StringAttribute{ + MarkdownDescription: "Service Engine Group", + Required: true, + }, + "thin_provisioning": schema.BoolAttribute{ + MarkdownDescription: "Enable thin provisioning", + Required: true, + }, + "fast_provisioning": schema.BoolAttribute{ + MarkdownDescription: "Enable fast provisioning", + Required: true, + }, + "vdc_network_quota": schema.Int64Attribute{ + MarkdownDescription: "VDC network quota", + Required: true, + }, + "cpu_quota_mhz": schema.Int64Attribute{ + MarkdownDescription: "CPU quota in MHz", + Required: true, + }, + "ram_quota_gb": schema.Int64Attribute{ + MarkdownDescription: "RAM quota in GB", + Required: true, + }, + }, + } +} + +func (r *QuickStartResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *QuickStartResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data QuickStartResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Step 1: Create instance + instanceReq := CreateInstanceRequest{ + ServiceId: 113, // Quick Start service ID + DisplayName: data.DisplayName.ValueString(), + Descr: data.Description.ValueString(), + } + + jsonData, err := json.Marshal(instanceReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal instance request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError("API Error", fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body))) + return + } + + location := httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in response") + return + } + instanceId := location[2:] + data.ID = types.StringValue(instanceId) + + // Step 2: Create operation + operationReq := CreateOperationRequest{ + InstanceUid: instanceId, + Operation: "create", + } + + jsonData, err = json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError("API Error", fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body))) + return + } + + location = httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in operation response") + return + } + operationId := location[2:] + + // Step 3: Submit all parameters + params := map[int]string{ + 332: data.OrgDomain.ValueString(), + 334: data.VAppName.ValueString(), + 358: data.ProviderVDC.ValueString(), + 359: fmt.Sprintf("%d", data.EdgeCount.ValueInt64()), + 360: data.ExternalNetwork.ValueString(), + 399: data.StorageProfiles.ValueString(), + 400: data.NetworkPool.ValueString(), + 401: fmt.Sprintf("%d", data.CPUAllocationPercent.ValueInt64()), + 402: fmt.Sprintf("%d", data.RAMAllocationPercent.ValueInt64()), + 403: data.ServiceEngineGroup.ValueString(), + 404: fmt.Sprintf("%t", data.ThinProvisioning.ValueBool()), + 405: fmt.Sprintf("%t", data.FastProvisioning.ValueBool()), + 566: fmt.Sprintf("%d", data.VDCNetworkQuota.ValueInt64()), + 567: fmt.Sprintf("%d", data.CPUQuotaMhz.ValueInt64()), + 568: fmt.Sprintf("%d", data.RAMQuotaGb.ValueInt64()), + } + + for paramId, paramValue := range params { + paramReq := CreateCfsParamRequest{ + InstanceOperationUid: operationId, + SvcOperationCfsParamId: paramId, + ParamValue: paramValue, + } + + jsonData, err = json.Marshal(paramReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal param request: %s", err)) + return + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create param request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit parameter: %s", err)) + return + } + + respBody, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusOK { + resp.Diagnostics.AddError("API Error", fmt.Sprintf("Submit parameter %d failed with status %d: %s", paramId, httpResp.StatusCode, string(respBody))) + return + } + } + + // Step 4: Run operation + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create run request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to run operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK && httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError("API Error", fmt.Sprintf("Run operation failed with status %d: %s", httpResp.StatusCode, string(body))) + return + } + + // Wait and read status + time.Sleep(5 * time.Second) + + readData, err := r.readInstance(ctx, instanceId) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *QuickStartResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data QuickStartResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) + return + } + + data.Status = types.StringValue(instanceResp.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *QuickStartResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Not Supported", "Quick Start resource cannot be updated") +} + +func (r *QuickStartResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data QuickStartResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "delete", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create delete operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError("API Error", fmt.Sprintf("Create delete operation failed with status %d: %s", httpResp.StatusCode, string(body))) + return + } + + location := httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in operation response") + return + } + operationId := location[2:] + + // Run delete operation + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create run request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to run delete operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError("API Error", fmt.Sprintf("Run delete operation failed with status %d: %s", httpResp.StatusCode, string(body))) + return + } +} + +func (r *QuickStartResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *QuickStartResource) readInstance(ctx context.Context, instanceId string) (*InstanceResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+instanceId, nil) + if err != nil { + return nil, err + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var instanceResp InstanceResponse + if err := json.Unmarshal(body, &instanceResp); err != nil { + return nil, err + } + + return &instanceResp, nil +} diff --git a/internal/provider/s3bucket_resource.go b/internal/provider/s3bucket_resource.go new file mode 100644 index 0000000..4ab6358 --- /dev/null +++ b/internal/provider/s3bucket_resource.go @@ -0,0 +1,255 @@ +package provider + +// [НЕ ИЗМЕНЯТЬ !!!!] +// Данный ресурс реализует паттерн "Nubes Flow" для S3 бакета. +// ВАЖНО: Операция Update (изменение) для S3 бакетов в API Nubes отсутствует. +// Любое изменение атрибутов в Terraform приведет к пересозданию ресурса (RequiresReplace). + +import ( + "context" + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ resource.Resource = &S3BucketResource{} + +type S3BucketResource struct { + client *NubesClient +} + +func NewS3BucketResource() resource.Resource { + return &S3BucketResource{} +} + +type S3BucketResourceModel struct { + ID types.String `tfsdk:"id"` + S3UserUid types.String `tfsdk:"s3_user_uid"` + BucketName types.String `tfsdk:"bucket_name"` + MaxSize types.Int64 `tfsdk:"max_size"` + ReadAll types.Bool `tfsdk:"read_all"` + ListAll types.Bool `tfsdk:"list_all"` + CorsAll types.Bool `tfsdk:"cors_all"` + Placement types.String `tfsdk:"placement"` +} + +func (r *S3BucketResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_s3_bucket" +} + +func (r *S3BucketResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages an S3 Bucket resource.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "s3_user_uid": schema.StringAttribute{ + Required: true, + Description: "UID Корневой услуги S3 (Param 124). Это UUID экземпляра услуги S3 Object Storage (svcId 12). Например: 6d6061cb-b0c1-44b9-8969-a70f08fe673c", + Validators: []validator.String{ + UUIDLike(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "bucket_name": schema.StringAttribute{ + Required: true, + Description: "The name of the bucket (param 125)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "max_size": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(-1), + Description: "Max size of the bucket, -1 for unlimited (param 126)", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "read_all": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Enable read all (param 127)", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "list_all": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Enable list all (param 128)", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "cors_all": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Enable CORS all (param 129)", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "placement": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("HOT"), + Description: "Placement strategy (HOT/COLD) (param 130)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *S3BucketResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *NubesClient, got: %T. Please report this issue to the provider developers.", req.ProviderData)) + return + } + r.client = client +} + +func (r *S3BucketResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan S3BucketResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Creating S3 Bucket resource...") + + // 1. Подготовка параметров для создания. + // Каждый параметр соответствует svcOperationCfsParamId из API. + // Эти ID фиксированы для услуги S3 (ServiceId 13). + params := []InstanceParam{ + {SvcOperationCfsParamId: 124, ParamValue: plan.S3UserUid.ValueString()}, + {SvcOperationCfsParamId: 125, ParamValue: plan.BucketName.ValueString()}, + {SvcOperationCfsParamId: 126, ParamValue: fmt.Sprintf("%d", plan.MaxSize.ValueInt64())}, + {SvcOperationCfsParamId: 127, ParamValue: strconv.FormatBool(plan.ReadAll.ValueBool())}, + {SvcOperationCfsParamId: 128, ParamValue: strconv.FormatBool(plan.ListAll.ValueBool())}, + {SvcOperationCfsParamId: 129, ParamValue: strconv.FormatBool(plan.CorsAll.ValueBool())}, + {SvcOperationCfsParamId: 130, ParamValue: plan.Placement.ValueString()}, + } + + // Service ID 13 для S3 (бакеты) + serviceId := 13 + + // Динамический поиск ID операции "create". + // В Nubes для каждого сервиса свой набор ID операций. + svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create") + if err != nil { + resp.Diagnostics.AddError("Failed to get create operation ID", err.Error()) + return + } + + // CreateInstance выполняет полный цикл "Nubes Flow": + // 1. POST /instances (создание объекта) + // 2. POST /instanceOperations (инициализация визарда) + // 3. GET /instanceOperations?fields=cfsParams (получение списка ожидаемых параметров) + // 4. POST /instanceOperationCfsParams (синхронизация значений) + // 5. POST /run {BODY: {}} (запуск выполнения) + instanceUid, opUid, err := r.client.CreateInstance(ctx, plan.BucketName.ValueString(), serviceId, svcOperationId, params) + if err != nil { + resp.Diagnostics.AddError("Error creating S3 bucket", err.Error()) + return + } + + // Ожидание завершения операции. + err = r.client.WaitForOperation(ctx, opUid) + if err != nil { + resp.Diagnostics.AddError("Error waiting for S3 bucket ready", err.Error()) + return + } + + plan.ID = types.StringValue(instanceUid) + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *S3BucketResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state S3BucketResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Implement Read if necessary. For now, just maintain state. + // Ideally, fetch instance details and update state. + // We can use r.client.GetInstance(ctx, state.ID.ValueString()) to check if it exists or is deleted. + + /* + instance, err := r.client.GetInstance(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error reading S3 bucket", err.Error()) + return + } + if instance == nil || instance.IsDeleted { + resp.State.RemoveResource(ctx) + return + } + // Update fields if needed + */ + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *S3BucketResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // [НЕ ИЗМЕНЯТЬ !!!!] + // API Nubes не поддерживает операцию Modify для S3 бакетов. + // Этот метод оставлен пустым, так как RequiresReplace в схеме должен предотвращать его вызов для критичных полей. +} + +func (r *S3BucketResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state S3BucketResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Assume delete operation exists + // Get "delete" operation ID + serviceId := 13 + svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "delete") + if err != nil { + resp.Diagnostics.AddError("Failed to get delete operation ID", err.Error()) + return + } + + err = r.client.DeleteInstance(ctx, state.ID.ValueString(), svcOperationId) + if err != nil { + resp.Diagnostics.AddError("Error deleting S3 bucket", err.Error()) + return + } +} diff --git a/internal/provider/service_instance_data_source.go b/internal/provider/service_instance_data_source.go new file mode 100644 index 0000000..0430227 --- /dev/null +++ b/internal/provider/service_instance_data_source.go @@ -0,0 +1,120 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSource = &ServiceInstanceDataSource{} + +func NewServiceInstanceDataSource() datasource.DataSource { + return &ServiceInstanceDataSource{} +} + +type ServiceInstanceDataSource struct { + client *NubesClient +} + +type ServiceInstanceDataSourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + ServiceId types.Int64 `tfsdk:"service_id"` + ServiceName types.String `tfsdk:"service_name"` +} + +func (d *ServiceInstanceDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_instance" +} + +func (d *ServiceInstanceDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Data source для получения UUID сервиса (Instance) по его имени (DisplayName). Позволяет использовать human-readable имена (например 's3-111805') вместо UUID.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "UUID найденного сервиса (instanceUid)", + Computed: true, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Имя (DisplayName) для поиска. Например 's3-111805'", + Required: true, + }, + "service_id": schema.Int64Attribute{ + MarkdownDescription: "ID типа сервиса для дополнительной фильтрации (например 12 для S3). Опционально.", + Optional: true, + }, + "service_name": schema.StringAttribute{ + MarkdownDescription: "Название типа сервиса (например 'S3 Object Storage')", + Computed: true, + }, + }, + } +} + +func (d *ServiceInstanceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + d.client = client +} + +func (d *ServiceInstanceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data ServiceInstanceDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + displayName := data.DisplayName.ValueString() + + instances, err := d.client.GetInstances(ctx) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to fetch instances list: %s", err)) + return + } + + var foundInstance *InstanceSummary + + // Поиск по DisplayName + for _, inst := range instances { + if strings.TrimSpace(inst.DisplayName) == strings.TrimSpace(displayName) { + // Если задан service_id, проверяем и его + if !data.ServiceId.IsNull() && !data.ServiceId.IsUnknown() { + if int64(inst.ServiceId) != data.ServiceId.ValueInt64() { + continue + } + } + foundInstance = &inst + break + } + } + + if foundInstance == nil { + resp.Diagnostics.AddError( + "Instance Not Found", + fmt.Sprintf("Could not find instance with DisplayName '%s'", displayName), + ) + return + } + + data.ID = types.StringValue(foundInstance.InstanceUid) + data.ServiceName = types.StringValue(foundInstance.Svc) + data.ServiceId = types.Int64Value(int64(foundInstance.ServiceId)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/tubulus_ai.go b/internal/provider/tubulus_ai.go new file mode 100644 index 0000000..0a6f211 --- /dev/null +++ b/internal/provider/tubulus_ai.go @@ -0,0 +1,133 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "github.com/google/generative-ai-go/genai" + "google.golang.org/api/option" +) + +type AIConfig struct { + DurationMs *int64 `json:"duration_ms,omitempty"` + FailAtStart *bool `json:"fail_at_start,omitempty"` + FailInProgress *bool `json:"fail_in_progress,omitempty"` + WhereFail *int64 `json:"where_fail,omitempty"` + BodyMessage *string `json:"body_message,omitempty"` + ResourceRealm *string `json:"resource_realm,omitempty"` + MapExample *string `json:"map_example,omitempty"` + JsonExample *string `json:"json_example,omitempty"` + YamlExample *string `json:"yaml_example,omitempty"` +} + +func (r *TubulusResource) askGemini(ctx context.Context, instruction string) (*AIConfig, error) { + // Приоритет: переменная окружения → файл → ошибка + apiKey := os.Getenv("GEMINI_API_KEY") + if apiKey == "" { + // Пробуем прочитать из файла (для локальной разработки) + keyBytes, err := os.ReadFile("gemini_api_key.txt") + if err == nil { + apiKey = strings.TrimSpace(string(keyBytes)) + } + } + if apiKey == "" { + return nil, fmt.Errorf("GEMINI_API_KEY not found (set env var or create gemini_api_key.txt)") + } + + client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey)) + if err != nil { + return nil, err + } + defer client.Close() + + model := client.GenerativeModel("gemini-2.0-flash") + + // Настройка модели: заставляем её возвращать строго JSON + model.ResponseMIMEType = "application/json" + + systemPrompt := `Ты — эксперт по инфраструктуре и помощник для Terraform провайдера Nubes. +Твоя задача — распарсить пожелания пользователя (даже самые простые и неточные) и превратить их в JSON-конфигурацию для тестового ресурса Tubulus. + +=== ПОЛЯ JSON (ВСЕ обязательны!) === + +1. "duration_ms" (integer): Сколько миллисекунд работает ресурс. + Примеры: "5 секунд" → 5000, "минута" → 60000, "быстро" → 1000, "долго" → 120000, "очень долго" → 300000 + Дефолт если не указано: 5000 + +2. "fail_at_start" (boolean): Сломаться ли сразу при запуске? + Примеры: "сломай сразу" → true, "упади в начале" → true + Дефолт: false + +3. "fail_in_progress" (boolean): Сломаться ли в процессе работы? + Примеры: "упади в середине" → true, "сломай в процессе" → true, "упадет на втором этапе" → true + Дефолт: false + +4. "where_fail" (integer): На каком этапе сломаться? ТОЛЬКО [0, 1, 2, 3] + 0 = не ломается + 1 = подготовка (prepare) + 2 = заполнение данных (data_fill) — ДЕФОЛТ если fail_in_progress=true + 3 = после записи в Vault + Примеры: "первый этап" → 1, "второй этап" → 2, "последний" → 3 + Дефолт: 0, но если fail_in_progress=true, то 2 + +5. "body_message" (string): Текст для записи в Vault (как секрет/пароль). + Примеры: "напиши hello" → "hello", "секрет 123" → "секрет 123", "привет мир" → "привет мир" + Если НЕ указан текст явно — оставь null (не "ai_generated") + Дефолт: null + +6. "resource_realm" (string): Окружение. Всегда "dummy". + Дефолт: "dummy" + +=== ВАЖНЫЕ ПРАВИЛА === +• Возвращай СТРОГО JSON с ВСЕМИ 6 полями +• Понимай разговорный язык: "сделай быстро" = duration_ms: 1000, "пусть долго работает" = duration_ms: 120000 +• Если пользователь написал текст для Vault ("напиши X", "положи Y") — используй его в body_message, иначе null +• Ответ БЕЗ пояснений, только {"duration_ms": ..., "fail_at_start": ..., ...} + +=== ПРИМЕРЫ === +Запрос: "сделай быстро" +→ {"duration_ms": 1000, "fail_at_start": false, "fail_in_progress": false, "where_fail": 0, "body_message": null, "resource_realm": "dummy"} + +Запрос: "пусть работает минуту и напиши привет" +→ {"duration_ms": 60000, "fail_at_start": false, "fail_in_progress": false, "where_fail": 0, "body_message": "привет", "resource_realm": "dummy"} + +Запрос: "сломай на втором этапе" +→ {"duration_ms": 5000, "fail_at_start": false, "fail_in_progress": true, "where_fail": 2, "body_message": null, "resource_realm": "dummy"} + +Инструкция: ` + instruction + + resp, err := model.GenerateContent(ctx, genai.Text(systemPrompt)) + if err != nil { + return nil, err + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return nil, fmt.Errorf("gemini returned no content") + } + + part := resp.Candidates[0].Content.Parts[0] + text, ok := part.(genai.Text) + if !ok { + return nil, fmt.Errorf("gemini returned non-text content") + } + + log.Printf("[DEBUG] Gemini response: %s", string(text)) + + // Some Gemini versions return an array with one object when constrained to JSON + // Let's try to unmarshal as object first, then as array if it fails + var config AIConfig + if err := json.Unmarshal([]byte(text), &config); err != nil { + var configs []AIConfig + if errArray := json.Unmarshal([]byte(text), &configs); errArray == nil && len(configs) > 0 { + config = configs[0] + } else { + return nil, fmt.Errorf("failed to unmarshal gemini response: %w", err) + } + } + + return &config, nil +} diff --git a/internal/provider/tubulus_resource.go b/internal/provider/tubulus_resource.go new file mode 100644 index 0000000..dafe6b2 --- /dev/null +++ b/internal/provider/tubulus_resource.go @@ -0,0 +1,1049 @@ +package provider + +// [НЕ ИЗМЕНЯТЬ !!!!] +// Данный ресурс реализует паттерн "Nubes Flow" для тестовой болванки (Tubulus). +// ВАЖНО: Ресурс предназначен для тестирования жизненного цикла и не имеет операции Update. +// Любое изменение параметров приведет к пересозданию инстанса. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" // Added strings + "time" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ resource.Resource = &TubulusResource{} +var _ resource.ResourceWithImportState = &TubulusResource{} +var _ resource.ResourceWithModifyPlan = &TubulusResource{} + +func NewTubulusResource() resource.Resource { + return &TubulusResource{} +} + +type TubulusResource struct { + client *NubesClient +} + +type TubulusResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + Status types.String `tfsdk:"status"` + DurationMs types.Int64 `tfsdk:"duration_ms"` + FailAtStart types.Bool `tfsdk:"fail_at_start"` + FailInProgress types.Bool `tfsdk:"fail_in_progress"` + WhereFail types.Int64 `tfsdk:"where_fail"` + BodyMessage types.String `tfsdk:"body_message"` + ResourceRealm types.String `tfsdk:"resource_realm"` + MapExample types.String `tfsdk:"map_example"` + JsonExample types.String `tfsdk:"json_example"` + YamlExample types.String `tfsdk:"yaml_example"` +} + +type CreateInstanceRequest struct { + ServiceId int `json:"serviceId"` + DisplayName string `json:"displayName"` + Descr string `json:"descr,omitempty"` +} + +type CreateOperationRequest struct { + InstanceUid string `json:"instanceUid"` + Operation string `json:"operation"` +} + +type InstanceResponse struct { + Uid string `json:"instanceUid"` + DisplayName string `json:"displayName"` + Descr string `json:"descr"` + Status string `json:"explainedStatus"` // Правильное поле из API +} + +type GetInstanceResponse struct { + Instance InstanceResponse `json:"instance"` +} + +// GetOperationResponse and associated types removed as they are now in client_impl.go + +type CreateCfsParamRequest struct { + InstanceOperationCfsParamUid string `json:"instanceOperationCfsParamUid,omitempty"` + InstanceOperationUid string `json:"instanceOperationUid"` + SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` + ParamValue string `json:"paramValue"` +} + +func (r *TubulusResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tubulus_instance" +} + +func (r *TubulusResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Nubes Tubulus Instance resource with Gemini AI integration", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Instance identifier (UUID)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Instance display name", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Instance description", + Optional: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Current status of the instance", + Computed: true, + }, + "duration_ms": schema.Int64Attribute{ + MarkdownDescription: "Duration in ms (sleep before completion)", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "fail_at_start": schema.BoolAttribute{ + MarkdownDescription: "If true, job fails immediately", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "fail_in_progress": schema.BoolAttribute{ + MarkdownDescription: "If true, job fails during execution", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "where_fail": schema.Int64Attribute{ + MarkdownDescription: "1=prepare, 2=data_fill, 3=after_vault", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "body_message": schema.StringAttribute{ + MarkdownDescription: "Message to be sent to Vault", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "resource_realm": schema.StringAttribute{ + MarkdownDescription: "Platform realm (e.g. dummy)", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "map_example": schema.StringAttribute{ + MarkdownDescription: "Example map input (JSON string)", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "json_example": schema.StringAttribute{ + MarkdownDescription: "Example JSON input (JSON string)", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "yaml_example": schema.StringAttribute{ + MarkdownDescription: "Example YAML input", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *TubulusResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + // AI Logic disabled as per user instruction. +} + +func (r *TubulusResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + r.client = client +} + +// doRequestWithRetry executes an HTTP request with retries for transient errors. +func (r *TubulusResource) doRequestWithRetry(ctx context.Context, httpReq *http.Request) (*http.Response, error) { + var resp *http.Response + var err error + + maxRetries := 3 + baseWait := 2 * time.Second + + for i := 0; i <= maxRetries; i++ { + // Reset body for retry if needed + if i > 0 && httpReq.GetBody != nil { + body, err := httpReq.GetBody() + if err != nil { + log.Printf("[ERROR] Failed to get request body for retry: %s", err) + return nil, err + } + httpReq.Body = body + } + + resp, err = r.client.HttpClient.Do(httpReq) + + // Case 1: Client/Network error + if err != nil { + // Check context cancellation first + if ctx.Err() != nil { + return nil, ctx.Err() + } + + errMsg := err.Error() + if i < maxRetries && (strings.Contains(errMsg, "TLS handshake timeout") || + strings.Contains(errMsg, "connection refused") || + strings.Contains(errMsg, "connection reset") || + strings.Contains(errMsg, "timeout") || + strings.Contains(errMsg, "EOF")) { + + log.Printf("[WARN] Network error '%s' (attempt %d/%d). Retrying in %s...", errMsg, i+1, maxRetries+1, baseWait*time.Duration(i+1)) + time.Sleep(baseWait * time.Duration(i+1)) + continue + } + return nil, err + } + + // Case 2: Server error (5xx) + if i < maxRetries && resp.StatusCode >= 500 && resp.StatusCode < 600 { + log.Printf("[WARN] Server error %d (attempt %d/%d). Retrying in %s...", resp.StatusCode, i+1, maxRetries+1, baseWait*time.Duration(i+1)) + resp.Body.Close() + time.Sleep(baseWait * time.Duration(i+1)) + continue + } + + // If success or 4xx, return + return resp, nil + } + + return resp, err +} + +func (r *TubulusResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data TubulusResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // [NUBES SOFT DELETE LOGIC] + // Сначала проверяем, нет ли уже такого инстанса в статусе suspended. + // Если есть — выполняем resume вместо создания нового. + existingInstance, _ := r.findInstance(ctx, data.DisplayName.ValueString()) + if existingInstance != nil && existingInstance.Status == "suspended" { + log.Printf("[INFO] Found existing suspended instance %s. Triggering RESUME.", existingInstance.Uid) + instanceId := existingInstance.Uid + data.ID = types.StringValue(instanceId) + + operationReq := CreateOperationRequest{ + InstanceUid: instanceId, + Operation: "resume", + } + + jsonData, err := json.Marshal(operationReq) + if err == nil { + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err == nil { + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + httpResp, _ := r.doRequestWithRetry(ctx, httpReq) + if httpResp != nil && (httpResp.StatusCode == http.StatusCreated || httpResp.StatusCode == http.StatusOK) { + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + + // [FIX 2026-01-27]: Ensure params are submitted even for RESUME if needed. + // Use "data" model which contains current plan values. + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + log.Printf("[WARN] Failed to submit params for resume operation (might be non-fatal): %s", err) + } + + // [FIX] User requirement: Bolvanka resume must be fast (< 1 min). + // Increased to 3m as sometimes cloud is slow. + if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 3*time.Minute); err == nil { + data.Status = types.StringValue("running") + + // Ensure computed optional fields are checked for Unknown and set to Null if needed + if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() } + if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() } + if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() } + if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() } + if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() } + if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() } + if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() } + if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() } + if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + // CRITICAL FIX: Return immediately after successful resume! + // Do not fall through to Create logic. + return + } else { + // If wait failed (timeout or error), report it. + resp.Diagnostics.AddError("Resume Failed", fmt.Sprintf("Failed to resume existing instance: %s", err)) + return + } + } + httpResp.Body.Close() + } + } + } + // Если resume не удался, попробуем обычное создание (может имя освободилось) + } + + // Step 1: Create instance + createReq := CreateInstanceRequest{ + ServiceId: 1, // Болванка + DisplayName: data.DisplayName.ValueString(), + Descr: data.Description.ValueString(), + } + + jsonData, err := json.Marshal(createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.doRequestWithRetry(ctx, httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract instance ID from Location header + location := httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in response") + return + } + // Location format: "./UUID" + instanceId := location[2:] // Remove "./" + + data.ID = types.StringValue(instanceId) + + // Step 2: Create operation + // Инициализируем операцию 'create'. Это создает "визард" в Deck API. + operationReq := CreateOperationRequest{ + InstanceUid: instanceId, + Operation: "create", + } + + jsonData, err = json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.doRequestWithRetry(ctx, httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract operation ID from Location header + location = httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in operation response") + return + } + operationId := location[2:] // Remove "./" + + // Step 3: Submit operation parameters to trigger execution + // Синхронизация параметров и запуск выполнения (Full Sync + Run). + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + + // Step 4: Wait for completion and read status + // [MODIFIED BY USER REQUEST]: Timeout reduced to 1 minute. + if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 1*time.Minute); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err)) + return + } + + readData, err := r.readInstance(ctx, instanceId) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + // Ensure computed optional fields are checked for Unknown and set to Null if needed + if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() } + if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() } + if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() } + if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() } + if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() } + if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() } + if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() } + if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() } + if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *TubulusResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data TubulusResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) + return + } + + data.Status = types.StringValue(instanceResp.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *TubulusResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // [НЕ ИЗМЕНЯТЬ !!!!] + // Ресурс считается иммутабельным в рамках текущих тестов. + // Метод сохранен для совместимости, но использование 'modify' в Tubulus часто не дает результата на бэкенде. + + var data TubulusResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Trigger modify operation + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "modify", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.doRequestWithRetry(ctx, httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract operation ID and submit parameters + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + + // Wait for modify to complete using strict logic + if err := r.waitForOperationAndInstanceStatus(ctx, operationId, data.ID.ValueString(), 1*time.Minute); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to complete modify operation: %s", err)) + return + } + } + + // Read updated state (no sleep needed after strict wait) + readData, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + // Ensure computed optional fields are checked for Unknown and set to Null if needed + if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() } + if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() } + if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() } + if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() } + if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() } + if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() } + if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() } + if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() } + if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *TubulusResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data TubulusResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // [NUBES SOFT DELETE] + // Вместо 'delete' отправляем 'suspend'. Ресурс перейдет в 14-дневный период удержания. + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "suspend", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.doRequestWithRetry(ctx, httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Suspend operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract operation ID and wait for completion (without parameters) + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + // Run suspend operation (no params needed usually) + runReq, _ := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}"))) + runReq.Header.Set("Content-Type", "application/json") + runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + r.doRequestWithRetry(ctx, runReq) + + // Wait for suspend to complete using strict logic (ignore status check) + r.waitForOperationAndInstanceStatus(ctx, operationId, data.ID.ValueString(), 1*time.Minute) + } +} + +func (r *TubulusResource) findInstance(ctx context.Context, name string) (*InstanceResponse, error) { + page := 1 + pageSize := 100 + + for { + url := fmt.Sprintf("%s/instances?page=%d&size=%d", r.client.ApiEndpoint, page, pageSize) + httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.doRequestWithRetry(ctx, httpReq) + if err != nil { + return nil, err + } + + var instancesResp struct { + Total int `json:"total"` + Results []struct { + ID string `json:"instanceUid"` + Name string `json:"displayName"` + Status string `json:"explainedStatus"` + Svc string `json:"svc"` + } `json:"results"` + } + + err = json.NewDecoder(httpResp.Body).Decode(&instancesResp) + httpResp.Body.Close() + + if err != nil { + return nil, err + } + + for _, instance := range instancesResp.Results { + // [FIX] Checking both "Bolvanka" and "Болванка" just in case, but usually it's "Болванка" + if instance.Name == name && (instance.Svc == "Болванка" || instance.Svc == "Bolvanka") { + return &InstanceResponse{ + Uid: instance.ID, + DisplayName: instance.Name, + Status: instance.Status, + }, nil + } + } + + if len(instancesResp.Results) == 0 || page*pageSize >= instancesResp.Total { + break + } + page++ + } + + return nil, fmt.Errorf("instance not found") +} + +func (r *TubulusResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *TubulusResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { + // [FIX 2026-01-27]: Explicitly request explainedStatus to ensure we get the correct status + // Default response might not include computed/expensive fields. + httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id+"?fields=instanceUid,displayName,descr,explainedStatus", nil) + if err != nil { + return nil, err + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.doRequestWithRetry(ctx, httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var instanceResp GetInstanceResponse + if err := json.Unmarshal(body, &instanceResp); err != nil { + return nil, err + } + + return &instanceResp.Instance, nil +} + +func (r *TubulusResource) submitOperationParams(ctx context.Context, operationUid string, data TubulusResourceModel) error { + // Step 1: Get operation details with parameters + httpReq, err := http.NewRequestWithContext(ctx, "GET", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) + if err != nil { + return fmt.Errorf("unable to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.doRequestWithRetry(ctx, httpReq) + if err != nil { + return fmt.Errorf("unable to get operation: %s", err) + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return fmt.Errorf("unable to read response: %s", err) + } + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var getResp GetOperationResponse + if err := json.Unmarshal(body, &getResp); err != nil { + return fmt.Errorf("unable to unmarshal response: %s", err) + } + operationResp := getResp.InstanceOperation + + // Step 2: Submit each parameter to ensure they exist (populate UIDs) + // ВАЖНО: Мы ДОЛЖНЫ пройтись по всем параметрам, которые вернул API. + // Бэкенд Nubes инициализирует значения параметров только после их явной отправки (POST). + // Если пропустить параметр, Deck может не разрешить выполнение (Run). + for _, param := range operationResp.CfsParams { + // Determine value to send: existing value > default value > empty string + valToSend := "" + + // Try to match parameter by Code or Name with Terraform data + // Assuming param.Code or param.Name matches the keys we know + // [FIX 2026-01-27]: Added SvcOperationCfsParam as fallback key + key := param.Code + if key == "" { + key = param.Name + } + if key == "" { + key = param.SvcOperationCfsParam + } + + // Helper to override valToSend if TF attribute is set + if key == "durationMs" && !data.DurationMs.IsNull() && !data.DurationMs.IsUnknown() { + valToSend = fmt.Sprintf("%d", data.DurationMs.ValueInt64()) + } else if key == "failAtStart" && !data.FailAtStart.IsNull() && !data.FailAtStart.IsUnknown() { + valToSend = fmt.Sprintf("%t", data.FailAtStart.ValueBool()) + } else if key == "failInProgress" && !data.FailInProgress.IsNull() && !data.FailInProgress.IsUnknown() { + valToSend = fmt.Sprintf("%t", data.FailInProgress.ValueBool()) + } else if key == "whereFail" && !data.WhereFail.IsNull() && !data.WhereFail.IsUnknown() { + valToSend = fmt.Sprintf("%d", data.WhereFail.ValueInt64()) + } else if key == "bodymessage" && !data.BodyMessage.IsNull() && !data.BodyMessage.IsUnknown() { + valToSend = data.BodyMessage.ValueString() + } else if key == "resourceRealm" && !data.ResourceRealm.IsNull() && !data.ResourceRealm.IsUnknown() { + valToSend = data.ResourceRealm.ValueString() + } else if key == "mapExample" && !data.MapExample.IsNull() && !data.MapExample.IsUnknown() { + valToSend = data.MapExample.ValueString() + } else if key == "jsonExample" && !data.JsonExample.IsNull() && !data.JsonExample.IsUnknown() { + valToSend = data.JsonExample.ValueString() + } else if key == "yamlExample" && !data.YamlExample.IsNull() && !data.YamlExample.IsUnknown() { + valToSend = data.YamlExample.ValueString() + } else { + // Fallback to existing logic if not set in TF + if param.ParamValue != nil { + valToSend = *param.ParamValue + } else if param.DefaultValue != nil { + valToSend = *param.DefaultValue + } + } + + // Fix specific data type formatting + // [CRITICAL WARNING]: DO NOT CHANGE THIS LOGIC for MAP/JSON/ARRAY! + // Sending empty string "" for map/json/array triggers 400 Bad Request (invalid format). + // Always send "{}" for empty maps/jsons and "[]" for empty lists. + if valToSend == "" || valToSend == "\"\"" { + if param.DataType == "map" || param.DataType == "json" { + valToSend = "{}" + } else if param.DataType == "array" || param.DataType == "list" { + valToSend = "[]" + } + } + + + // [FIX 2026-01-27]: Если значение не менялось (пустое или дефолтное) и это не обязательный параметр, + // всё равно отправляем POST, чтобы инициализировать параметр на бэкенде. + // НО: для корректного формирования JSON строки, нужно убедиться, что кавычки экранированы, если это JSON внутри JSON. + // В простом случае мы шлем строку. + + paramReq := CreateCfsParamRequest{ + InstanceOperationUid: operationUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: valToSend, + } + + jsonData, err := json.Marshal(paramReq) + if err != nil { + return fmt.Errorf("unable to marshal param request: %s", err) + } + + // USE POST to create/set the parameter + httpReq, err = http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("unable to create param request: %s", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.doRequestWithRetry(ctx, httpReq) + if err != nil { + return fmt.Errorf("unable to submit parameter: %s", err) + } + + respBody, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && + httpResp.StatusCode != http.StatusOK && + httpResp.StatusCode != http.StatusNoContent { + // Log but maybe continue? No, if we fail to set param, run will likely fail. + return fmt.Errorf("submit parameter id %d failed with status %d: %s", + param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody)) + } + } + + // Step 3: Run the operation + runReq, err := http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + return fmt.Errorf("unable to create run request: %s", err) + } + + runReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + runResp, err := r.doRequestWithRetry(ctx, runReq) + if err != nil { + return fmt.Errorf("unable to run operation: %s", err) + } + defer runResp.Body.Close() + + if runResp.StatusCode != http.StatusOK && + runResp.StatusCode != http.StatusNoContent && + runResp.StatusCode != http.StatusCreated { + runBody, _ := io.ReadAll(runResp.Body) + // debugJSON removed from scope, simplified error + return fmt.Errorf("run operation failed with status %d: %s", + runResp.StatusCode, string(runBody)) + } + + return nil +} + +func (r *TubulusResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout) + + // Шаг 1: Ждём завершения операции +operationLoop: + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-ticker.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for operation to complete") + } + + log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId) + + // Проверяем статус операции через клиент + opResp, err := r.client.GetInstanceOperation(ctx, operationId) + if err != nil { + log.Printf("[WARN] Failed to get operation status: %s", err) + continue // Пробуем еще раз на следующем тике + } + + // Вывод для логов, чтобы видеть что реально приходит + if opResp.DtFinish != nil { + log.Printf("[DEBUG] dtFinish: %s", *opResp.DtFinish) + } + if opResp.IsSuccessful != nil { + log.Printf("[DEBUG] isSuccessful: %v", *opResp.IsSuccessful) + } + + // Извлекаем все доступные данные из ответа (максимальная диагностика) + isSucc := "N/A" + if opResp.IsSuccessful != nil { + isSucc = fmt.Sprintf("%v", *opResp.IsSuccessful) + } + dtFin := "not finished" + if opResp.DtFinish != nil { + dtFin = *opResp.DtFinish + } + submitCode := "N/A" + if opResp.SubmitResult != nil { + submitCode = *opResp.SubmitResult + } + dur := 0.0 + if opResp.Duration != nil { + dur = *opResp.Duration + } + who := "system" + if opResp.UpdaterShortname != nil { + who = *opResp.UpdaterShortname + } + + log.Printf("[DEBUG] Polling: OpID=%s, Status=%s/%s, Success=%s, Finish=%s, SubmitResult=%s, Dur=%.2fs, Initiator=%s", + operationId, + map[bool]string{true: "PENDING", false: "ACTIVE"}[opResp.IsPending], + map[bool]string{true: "WORKING", false: "IDLE"}[opResp.IsInProgress], + isSucc, dtFin, submitCode, dur, who) + + // [FIX 2026-01-27]: STRICT SUCCESS LOGIC + // [CRITICAL WARNING]: DO NOT CHANGE THIS LOGIC! + // This logic is battle-tested against 14+ HAR files. + // 1. dtFinish IS THE ONLY SOURCE OF TRUTH for completion. + // 2. Do NOT rely on status, isInProgress, or isPending for completion check. + // 3. If dtFinish is set -> The operation is DONE. We stop waiting IMMEDIATELY. + // 4. If it is done, we check Success: + // - isSuccessful == true => OK (break loop) + // - isSuccessful == false OR null => ERROR (return error) + // ANY DEVIATION FROM THIS WILL CAUSE TIMEOUTS OR FALSE POSITIVES. + if opResp.DtFinish != nil { + log.Printf("[DEBUG] dtFinish detected: %s. Operation is FINISHED.", *opResp.DtFinish) + + if opResp.IsSuccessful != nil && *opResp.IsSuccessful { + log.Printf("[DEBUG] Operation SUCCESS detected (isSuccessful=true).") + break operationLoop + } + + // If we are here, it means finished but NOT successful (false or null) + errNote := "Operation finished but marked as UNSUCCESSFUL" + if opResp.ErrorLog != nil { + errNote = fmt.Sprintf("%s. LOG: %s", errNote, *opResp.ErrorLog) + } else { + errNote = fmt.Sprintf("%s. (isSuccessful=%s, dtFinish=%s)", errNote, isSucc, *opResp.DtFinish) + } + return fmt.Errorf("%s (initiator: %s, duration: %.2fs)", errNote, who, dur) + } + + // If dtFinish is NOT set, we continue polling until timeout. + // Reordered check: We trust dtFinish as the primary signal of completion. + + // Legacy check for "Cold Start" or weird states where flags are false but nothing happened yet + if !opResp.IsInProgress && !opResp.IsPending && opResp.SubmitResult == nil { + log.Printf("[DEBUG] Operation uninitialized (cold start or slow pending). Waiting...") + continue + } + } + } + + // Шаг 2: Проверяем статус instance + ticker2 := time.NewTicker(5 * time.Second) + defer ticker2.Stop() + + log.Printf("[DEBUG] Starting instance status polling") + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-ticker2.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for instance to become running") + } + + log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId) + + instance, err := r.readInstance(ctx, instanceId) + if err != nil { + return fmt.Errorf("failed to check instance status: %s", err) + } + + if instance.Status == "running" { + return nil + } + + if instance.Status == "error" || instance.Status == "failed" { + return fmt.Errorf("instance entered error state: %s", instance.Status) + } + } + } +} diff --git a/internal/provider/validators.go b/internal/provider/validators.go new file mode 100644 index 0000000..5efa8fd --- /dev/null +++ b/internal/provider/validators.go @@ -0,0 +1,155 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// stringOneOfValidator validates that a string value is one of the allowed values. +type stringOneOfValidator struct { + values []string +} + +func (v stringOneOfValidator) Description(ctx context.Context) string { + return fmt.Sprintf("value must be one of: %v", v.values) +} + +func (v stringOneOfValidator) MarkdownDescription(ctx context.Context) string { + return fmt.Sprintf("value must be one of: %v", v.values) +} + +func (v stringOneOfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + for _, allowed := range v.values { + if value == allowed { + return + } + } + + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid value", + fmt.Sprintf("Value must be one of %v, got: %s", v.values, value), + ) +} + +func StringOneOf(values ...string) validator.String { + return stringOneOfValidator{ + values: values, + } +} + +// validJSONArrayValidator проверяет, что строка является валидным JSON массивом +type validJSONArrayValidator struct{} + +func (v validJSONArrayValidator) Description(ctx context.Context) string { + return "value must be a valid JSON array (e.g., [\"item1\", \"item2\"])" +} + +func (v validJSONArrayValidator) MarkdownDescription(ctx context.Context) string { + return "value must be a valid JSON array (e.g., `[\"item1\", \"item2\"]`)" +} + +func (v validJSONArrayValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + if value == "" { + // Пустая строка не валидна - должен быть либо null, либо JSON массив + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid JSON Array", + "Empty string is not a valid JSON array. Use jsonencode([...]) or omit the attribute.", + ) + return + } + + var arr []interface{} + if err := json.Unmarshal([]byte(value), &arr); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid JSON Array", + fmt.Sprintf("Value must be a valid JSON array: %s", err), + ) + } +} + +func ValidJSONArray() validator.String { + return validJSONArrayValidator{} +} + +// validJSONArrayOrEmptyValidator проверяет JSON массив или разрешает пустую строку +// Используется для accessIpList где "" означает "доступ отовсюду" +type validJSONArrayOrEmptyValidator struct{} + +func (v validJSONArrayOrEmptyValidator) Description(ctx context.Context) string { + return "value must be a valid JSON array or empty string (empty = access from anywhere)" +} + +func (v validJSONArrayOrEmptyValidator) MarkdownDescription(ctx context.Context) string { + return "value must be a valid JSON array or empty string (empty = access from anywhere)" +} + +func (v validJSONArrayOrEmptyValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + if value == "" { + // Пустая строка разрешена - означает доступ отовсюду + return + } + + var arr []interface{} + if err := json.Unmarshal([]byte(value), &arr); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid JSON Array", + fmt.Sprintf("Value must be a valid JSON array or empty string: %s", err), + ) + } +} + +func ValidJSONArrayOrEmpty() validator.String { + return validJSONArrayOrEmptyValidator{} +} + +type uuidLikeValidator struct{} + +func (v uuidLikeValidator) Description(ctx context.Context) string { + return "value must be a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" +} + +func (v uuidLikeValidator) MarkdownDescription(ctx context.Context) string { + return "value must be a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" +} + +func (v uuidLikeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := strings.TrimSpace(req.ConfigValue.ValueString()) + if value == "" || !uuidLikeRegex.MatchString(value) { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid UUID", + fmt.Sprintf("Value must be a UUID, got: %q", req.ConfigValue.ValueString()), + ) + } +} + +func UUIDLike() validator.String { + return uuidLikeValidator{} +} diff --git a/internal/provider/vapp_data_source.go b/internal/provider/vapp_data_source.go new file mode 100644 index 0000000..236c09a --- /dev/null +++ b/internal/provider/vapp_data_source.go @@ -0,0 +1,144 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSource = &VAppDataSource{} + +func NewVAppDataSource() datasource.DataSource { + return &VAppDataSource{} +} + +type VAppDataSource struct { + client *NubesClient +} + +type VAppDataSourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Status types.String `tfsdk:"status"` + Description types.String `tfsdk:"description"` +} + +func (d *VAppDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vapp" +} + +func (d *VAppDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Data source для получения информации о существующем vApp по имени", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "UUID vApp", + Computed: true, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Имя vApp для поиска", + Required: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Статус vApp", + Computed: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Описание vApp", + Computed: true, + }, + }, + } +} + +func (d *VAppDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + d.client = client +} + +func (d *VAppDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data VAppDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "GET", d.client.ApiEndpoint+"/instances", nil) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) + return + } + + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(d.client.ApiToken)) + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := d.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instances: %s", err)) + return + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read response: %s", err)) + return + } + + if httpResp.StatusCode != http.StatusOK { + resp.Diagnostics.AddError("API Error", fmt.Sprintf("GET /instances returned %d: %s", httpResp.StatusCode, string(body))) + return + } + + var instancesResp struct { + Results []struct { + ID string `json:"id"` + DisplayName string `json:"name"` + Status string `json:"status"` + Description string `json:"desc"` + Service string `json:"svc"` + } `json:"results"` + } + + if err := json.Unmarshal(body, &instancesResp); err != nil { + resp.Diagnostics.AddError("Parse Error", fmt.Sprintf("Unable to parse response: %s", err)) + return + } + + searchName := data.DisplayName.ValueString() + for _, instance := range instancesResp.Results { + if instance.Service == "Виртуальное приложение (vApp)" && instance.DisplayName == searchName { + data.ID = types.StringValue(instance.ID) + data.Status = types.StringValue(instance.Status) + data.Description = types.StringValue(instance.Description) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + } + + resp.Diagnostics.AddError( + "vApp Not Found", + fmt.Sprintf("vApp с именем '%s' не найден. Проверьте имя или создайте новый vApp.", searchName), + ) +} diff --git a/internal/provider/vapp_resource.go b/internal/provider/vapp_resource.go new file mode 100644 index 0000000..54bf112 --- /dev/null +++ b/internal/provider/vapp_resource.go @@ -0,0 +1,639 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "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/types" +) + +var _ resource.Resource = &VAppResource{} +var _ resource.ResourceWithImportState = &VAppResource{} + +func NewVAppResource() resource.Resource { + return &VAppResource{} +} + +type VAppResource struct { + client *NubesClient +} + +type VAppResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + EdgeUID types.String `tfsdk:"edge_uid"` + VAppName types.String `tfsdk:"vapp_name"` + VdcUID types.String `tfsdk:"vdc_uid"` + Status types.String `tfsdk:"status"` +} + +func (r *VAppResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vapp" +} + +func (r *VAppResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Nubes vApp (Virtual Application Catalog) resource", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "vApp identifier (UUID)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "vApp display name", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "vApp description", + Optional: true, + }, + "edge_uid": schema.StringAttribute{ + MarkdownDescription: "Edge Gateway UUID (parameter ID 190)", + Required: true, + }, + "vapp_name": schema.StringAttribute{ + MarkdownDescription: "vApp name in Cloud Director (parameter ID 191)", + Required: true, + }, + "vdc_uid": schema.StringAttribute{ + MarkdownDescription: "VDC UUID (parameter ID 623)", + Required: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Current status of the vApp", + Computed: true, + }, + }, + } +} + +func (r *VAppResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *VAppResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data VAppResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Step 1: Create instance + createReq := CreateInstanceRequest{ + ServiceId: 26, // VApp service ID + DisplayName: data.DisplayName.ValueString(), + Descr: data.Description.ValueString(), + } + + jsonData, err := json.Marshal(createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location := httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in response") + return + } + instanceId := location[2:] + + data.ID = types.StringValue(instanceId) + + // Step 2: Create operation + operationReq := CreateOperationRequest{ + InstanceUid: instanceId, + Operation: "create", + } + + jsonData, err = json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location = httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in operation response") + return + } + operationId := location[2:] + + // Step 3: Submit operation parameters and run + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + + // Step 4: Wait for operation completion and instance running status + if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 10*time.Minute); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err)) + return + } + + readData, err := r.readInstance(ctx, instanceId) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *VAppResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data VAppResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) + return + } + + data.Status = types.StringValue(instanceResp.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *VAppResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data VAppResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "modify", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + } + + time.Sleep(5 * time.Second) + readData, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *VAppResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data VAppResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "delete", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Delete operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + } + } +} + +func (r *VAppResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *VAppResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id, nil) + if err != nil { + return nil, err + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var instanceResp InstanceResponse + if err := json.Unmarshal(body, &instanceResp); err != nil { + return nil, err + } + + return &instanceResp, nil +} + +func (r *VAppResource) submitOperationParams(ctx context.Context, operationUid string, data VAppResourceModel) error { + // Get operation details + httpReq, err := http.NewRequestWithContext(ctx, "GET", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) + if err != nil { + return fmt.Errorf("unable to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to get operation: %s", err) + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return fmt.Errorf("unable to read response: %s", err) + } + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var getResp GetOperationResponse + if err := json.Unmarshal(body, &getResp); err != nil { + return fmt.Errorf("unable to unmarshal response: %s", err) + } + operationResp := getResp.InstanceOperation + + // Submit each parameter + for _, param := range operationResp.CfsParams { + valToSend := "" + + // Map vApp parameters by ID + switch param.SvcOperationCfsParamId { + case 190: // edgeUid + if !data.EdgeUID.IsNull() && !data.EdgeUID.IsUnknown() { + valToSend = data.EdgeUID.ValueString() + } + case 191: // vappName + if !data.VAppName.IsNull() && !data.VAppName.IsUnknown() { + valToSend = data.VAppName.ValueString() + } + case 623: // vdcUid + if !data.VdcUID.IsNull() && !data.VdcUID.IsUnknown() { + valToSend = data.VdcUID.ValueString() + } + default: + // Use existing or default value for unknown parameters + if param.ParamValue != nil { + valToSend = *param.ParamValue + } else if param.DefaultValue != nil { + valToSend = *param.DefaultValue + } + } + + // Fix specific data type formatting + if valToSend == "" || valToSend == "\"\"" { + if param.DataType == "map" || param.DataType == "json" { + valToSend = "{}" + } else if param.DataType == "array" || param.DataType == "list" { + valToSend = "[]" + } + } + + paramReq := CreateCfsParamRequest{ + InstanceOperationUid: operationUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: valToSend, + } + + + jsonData, err := json.Marshal(paramReq) + if err != nil { + return fmt.Errorf("unable to marshal param request: %s", err) + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("unable to create param request: %s", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to submit parameter: %s", err) + } + + respBody, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && + httpResp.StatusCode != http.StatusOK && + httpResp.StatusCode != http.StatusNoContent { + return fmt.Errorf("submit parameter id %d failed with status %d: %s", + param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody)) + } + } + + // Run the operation + runReq, err := http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + return fmt.Errorf("unable to create run request: %s", err) + } + + runReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + runResp, err := r.client.HttpClient.Do(runReq) + if err != nil { + return fmt.Errorf("unable to run operation: %s", err) + } + defer runResp.Body.Close() + + if runResp.StatusCode != http.StatusOK && + runResp.StatusCode != http.StatusNoContent && + runResp.StatusCode != http.StatusCreated { + runBody, _ := io.ReadAll(runResp.Body) + return fmt.Errorf("run operation failed with status %d: %s", + runResp.StatusCode, string(runBody)) + } + + return nil +} + +func (r *VAppResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout) + + // Шаг 1: Ждём завершения операции +operationLoop: + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-ticker.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for operation to complete") + } + + log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId) + + // Проверяем статус операции + httpReq, err := http.NewRequestWithContext(ctx, "GET", + r.client.ApiEndpoint+"/instanceOperations/"+operationId, nil) + if err != nil { + return fmt.Errorf("failed to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to check operation status: %s", err) + } + + body, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("get operation failed with status %d", httpResp.StatusCode) + } + + var opResp struct { + InstanceOperation struct { + IsInProgress bool `json:"isInProgress"` + IsPending bool `json:"isPending"` + } `json:"instanceOperation"` + } + + if err := json.Unmarshal(body, &opResp); err != nil { + return fmt.Errorf("failed to parse operation response: %s", err) + } + + log.Printf("[DEBUG] Operation status: isInProgress=%v, isPending=%v", opResp.InstanceOperation.IsInProgress, opResp.InstanceOperation.IsPending) + + // Операция завершена когда isInProgress=false И isPending=false + if !opResp.InstanceOperation.IsInProgress && !opResp.InstanceOperation.IsPending { + log.Printf("[DEBUG] Operation completed, moving to instance status check") + break operationLoop + } + } + } + + // Шаг 2: Проверяем статус instance + ticker2 := time.NewTicker(5 * time.Second) + defer ticker2.Stop() + + log.Printf("[DEBUG] Starting instance status polling") + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-ticker2.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for instance to become running") + } + + log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId) + +instance, err := r.readInstance(ctx, instanceId) +if err != nil { +return fmt.Errorf("failed to check instance status: %s", err) +} + +if instance.Status == "running" { +return nil +} + +if instance.Status == "error" || instance.Status == "failed" { +return fmt.Errorf("instance entered error state: %s", instance.Status) +} +} +} +} diff --git a/internal/provider/vdc_data_source.go b/internal/provider/vdc_data_source.go new file mode 100644 index 0000000..de12322 --- /dev/null +++ b/internal/provider/vdc_data_source.go @@ -0,0 +1,146 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSource = &VDCDataSource{} + +func NewVDCDataSource() datasource.DataSource { + return &VDCDataSource{} +} + +type VDCDataSource struct { + client *NubesClient +} + +type VDCDataSourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Status types.String `tfsdk:"status"` + Description types.String `tfsdk:"description"` +} + +func (d *VDCDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vdc" +} + +func (d *VDCDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Data source для получения информации о существующем VDC по имени", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "UUID VDC", + Computed: true, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Имя VDC для поиска", + Required: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Статус VDC (running, suspended, etc.)", + Computed: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Описание VDC", + Computed: true, + }, + }, + } +} + +func (d *VDCDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + d.client = client +} + +func (d *VDCDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data VDCDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Поиск VDC по имени + httpReq, err := http.NewRequestWithContext(ctx, "GET", d.client.ApiEndpoint+"/instances", nil) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) + return + } + + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(d.client.ApiToken)) + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := d.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instances: %s", err)) + return + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read response: %s", err)) + return + } + + if httpResp.StatusCode != http.StatusOK { + resp.Diagnostics.AddError("API Error", fmt.Sprintf("GET /instances returned %d: %s", httpResp.StatusCode, string(body))) + return + } + + var instancesResp struct { + Results []struct { + ID string `json:"id"` + DisplayName string `json:"name"` + Status string `json:"status"` + Description string `json:"desc"` + Service string `json:"svc"` + } `json:"results"` + } + + if err := json.Unmarshal(body, &instancesResp); err != nil { + resp.Diagnostics.AddError("Parse Error", fmt.Sprintf("Unable to parse response: %s", err)) + return + } + + // Поиск VDC с указанным именем + searchName := data.DisplayName.ValueString() + for _, instance := range instancesResp.Results { + if instance.Service == "Виртуальный датацентр (vDC)" && instance.DisplayName == searchName { + data.ID = types.StringValue(instance.ID) + data.Status = types.StringValue(instance.Status) + data.Description = types.StringValue(instance.Description) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + } + + resp.Diagnostics.AddError( + "VDC Not Found", + fmt.Sprintf("VDC с именем '%s' не найден. Проверьте имя или создайте новый VDC.", searchName), + ) +} diff --git a/internal/provider/vdc_resource.go b/internal/provider/vdc_resource.go new file mode 100644 index 0000000..3711f87 --- /dev/null +++ b/internal/provider/vdc_resource.go @@ -0,0 +1,766 @@ +package provider + +// [НЕ ИЗМЕНЯТЬ !!!!] +// Данный ресурс реализует паттерн "Nubes Flow" для Virtual Data Center (VDC). +// ВАЖНО: VDC является фундаментальным ресурсом инфраструктуры. +// +// ЛОГИКА УДАЛЕНИЯ (Delete): +// Автоматическое удаление через API ОТКЛЮЧЕНО для защиты от случайной потери данных. +// При вызове 'terraform destroy' ресурс просто УДАЛЯЕТСЯ ИЗ СТЕЙТА Terraform, но остается в облаке. +// Для реального удаления юзер должен вручную перевести инстанс в 'suspend' через UI и дождаться удаления (14 дней). +// +// ЛОГИКА ИЗМЕНЕНИЯ (Modify): +// Если ресурс находится в стейте, выполнение 'terraform apply' вызовет метод Update, +// который запустит операцию 'modify' для обновления параметров (квот и т.д.). +// ВНИМАНИЕ: Если вы уже выполнили 'destroy' (удалили из стейта), но ресурс остался в облаке, +// то для его изменения через Terraform вам придется сначала выполнить 'terraform import'. +// +// TODO: Протестировать VDC 'modify' позже. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" +) + +var _ resource.Resource = &VDCResource{} +var _ resource.ResourceWithImportState = &VDCResource{} + +func NewVDCResource() resource.Resource { + return &VDCResource{} +} + +type VDCResource struct { + client *NubesClient +} + +type VDCResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + OrganizationUID types.String `tfsdk:"organization_uid"` + ProviderVDC types.String `tfsdk:"provider_vdc"` + StorageProfiles types.String `tfsdk:"storage_profiles"` + NetworkPool types.String `tfsdk:"network_pool"` + CpuAllocationPct types.Int64 `tfsdk:"cpu_allocation_pct"` + RamAllocationPct types.Int64 `tfsdk:"ram_allocation_pct"` + CpuQuota types.Int64 `tfsdk:"cpu_quota"` + RamQuota types.Int64 `tfsdk:"ram_quota"` + DeletionProtection types.Bool `tfsdk:"deletion_protection"` + Status types.String `tfsdk:"status"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +func (r *VDCResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vdc" +} + +func (r *VDCResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Nubes VDC (Virtual Data Center) resource", + Blocks: map[string]schema.Block{ + "timeouts": timeouts.Block(ctx, timeouts.Opts{ + Create: true, + Update: true, + Delete: true, + }), + }, + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "VDC identifier (UUID)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "VDC display name", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "VDC description", + Optional: true, + }, + "organization_uid": schema.StringAttribute{ + MarkdownDescription: "Organization UUID (parameter ID 30)", + Required: true, + }, + "provider_vdc": schema.StringAttribute{ + MarkdownDescription: "Provider VDC name in Cloud Director (parameter ID 335)", + Required: true, + }, + "storage_profiles": schema.StringAttribute{ + MarkdownDescription: "Storage profiles JSON array, e.g. [{\"name\":\"vsan\",\"size\":\"100\"}] (parameter ID 361)", + Required: true, + }, + "network_pool": schema.StringAttribute{ + MarkdownDescription: "Network Pool name in Cloud Director (parameter ID 366)", + Required: true, + }, + "cpu_allocation_pct": schema.Int64Attribute{ + MarkdownDescription: "CPU allocation percentage (parameter ID 397)", + Required: true, + }, + "ram_allocation_pct": schema.Int64Attribute{ + MarkdownDescription: "RAM allocation percentage (parameter ID 398)", + Required: true, + }, + "cpu_quota": schema.Int64Attribute{ + MarkdownDescription: "CPU quota (parameter ID 557)", + Optional: true, + }, + "ram_quota": schema.Int64Attribute{ + MarkdownDescription: "RAM quota (parameter ID 558)", + Optional: true, + }, + "deletion_protection": schema.BoolAttribute{ + MarkdownDescription: "If true, the resource will only be removed from Terraform state upon destroy, but will remain in the cloud. If false, destroy will trigger 'suspend' in Nubes.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Current status of the VDC", + Computed: true, + }, + }, + } +} + +func (r *VDCResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *VDCResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // [ЛОГИКА VDC FLOW] + // Ресурс VDC создается по стандартному 7-шаговому алгоритму Nubes. + // Особое внимание уделяется параметрам квот (CPU/RAM) и сетевым пулам. + + var data VDCResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Apply timeout + createTimeout, diags := data.Timeouts.Create(ctx, 3*time.Minute) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + ctx, cancel := context.WithTimeout(ctx, createTimeout) + defer cancel() + + // Step 1: Create instance + createReq := CreateInstanceRequest{ + ServiceId: 21, // VDC service ID + DisplayName: data.DisplayName.ValueString(), + Descr: data.Description.ValueString(), + } + + jsonData, err := json.Marshal(createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location := httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in response") + return + } + instanceId := location[2:] + + data.ID = types.StringValue(instanceId) + + // Step 2: Create operation + // Deck API требует явного создания операции 'create' для инстанса. + // Это переводит инстанс в состояние "wizard", где можно настраивать параметры. + operationReq := CreateOperationRequest{ + InstanceUid: instanceId, + Operation: "create", + } + + jsonData, err = json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location = httpResp.Header.Get("Location") + if location == "" { + resp.Diagnostics.AddError("API Error", "No Location header in operation response") + return + } + operationId := location[2:] + + // Step 3: Submit operation parameters and run + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + + // Step 4: Wait for operation to complete, then check instance status + if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 10*time.Minute); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err)) + return + } + + readData, err := r.readInstance(ctx, instanceId) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *VDCResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data VDCResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) + return + } + + data.Status = types.StringValue(instanceResp.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *VDCResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // [НЕ ИЗМЕНЯТЬ !!!!] + // VDC поддерживает изменение параметров через операцию 'modify'. + // Это позволяет обновлять квоты CPU, RAM и другие параметры без пересоздания ресурса. + + var data VDCResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "modify", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + if err := r.submitOperationParams(ctx, operationId, data); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + } + + time.Sleep(5 * time.Second) + readData, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) + return + } + + data.Status = types.StringValue(readData.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *VDCResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // ЛОГИКА УДАЛЕНИЯ VDC: + // В зависимости от флага deletion_protection: + // 1. Если true (по умолчанию): Только удаляем из стейта. VDC остается работать. + // 2. Если false: Вызываем операцию 'suspend' через API. + // Доступа к VDC больше не будет, данные сохраняются 14 дней, затем авто-удаление. + + var data VDCResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if data.DeletionProtection.ValueBool() { + tflog.Warn(ctx, "Deletion Protection is ENABLED. VDC will remain active in Nubes Cloud. Manual cleanup required.") + return + } + + tflog.Info(ctx, "Deletion Protection is DISABLED. Triggering 'suspend' for VDC...") + + instanceId := data.ID.ValueString() + + // Выполняем операцию suspend + err := r.triggerOperation(ctx, instanceId, "suspend") + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend VDC: %s", err)) + return + } + + tflog.Info(ctx, "VDC suspended successfully. It will be permanently deleted from the cloud in 14 days.") +} + +func (r *VDCResource) triggerOperation(ctx context.Context, instanceId string, operationName string) error { + opReq := InstanceOperationRequest{ + Action: operationName, + Params: struct{}{}, + } + + jsonData, err := json.Marshal(opReq) + if err != nil { + return fmt.Errorf("unable to marshal request: %s", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances/"+instanceId+"/run", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("unable to create request: %s", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to trigger operation: %s", err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK && httpResp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(httpResp.Body) + return fmt.Errorf("operation failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + // Ждем пока статус изменится на целевой (например, suspended) + targetStatus := "running" + if operationName == "suspend" { + targetStatus = "suspended" + } + + return r.client.WaitForInstanceStatus(ctx, instanceId, targetStatus) +} + +func (r *VDCResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *VDCResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id, nil) + if err != nil { + return nil, err + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var instanceResp InstanceResponse + if err := json.Unmarshal(body, &instanceResp); err != nil { + return nil, err + } + + return &instanceResp, nil +} + +func (r *VDCResource) submitOperationParams(ctx context.Context, operationUid string, data VDCResourceModel) error { + // [ЛОГИКА СИНХРОНИЗАЦИИ ПАРАМЕТРОВ] + // 1. Получаем список параметров операции (GET /instanceOperations/{uid}?fields=cfsParams). + // 2. Для каждого параметра из списка находим значение в Terraform или используем дефолт. + // 3. Отправляем значение обратно в API (POST /instanceOperationCfsParams). + // 4. После синхронизации всех параметров вызываем RUN. + + // Get operation details + httpReq, err := http.NewRequestWithContext(ctx, "GET", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) + if err != nil { + return fmt.Errorf("unable to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to get operation: %s", err) + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return fmt.Errorf("unable to read response: %s", err) + } + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var getResp GetOperationResponse + if err := json.Unmarshal(body, &getResp); err != nil { + return fmt.Errorf("unable to unmarshal response: %s", err) + } + operationResp := getResp.InstanceOperation + + // Submit each parameter + for _, param := range operationResp.CfsParams { + valToSend := "" + + // Map VDC parameters by ID + switch param.SvcOperationCfsParamId { + case 30: // organizationUid + if !data.OrganizationUID.IsNull() && !data.OrganizationUID.IsUnknown() { + valToSend = data.OrganizationUID.ValueString() + } + case 335: // providerVdc + if !data.ProviderVDC.IsNull() && !data.ProviderVDC.IsUnknown() { + valToSend = data.ProviderVDC.ValueString() + } + case 361: // storageProfiles (JSON) + if !data.StorageProfiles.IsNull() && !data.StorageProfiles.IsUnknown() { + valToSend = data.StorageProfiles.ValueString() + } + case 366: // networkPool + if !data.NetworkPool.IsNull() && !data.NetworkPool.IsUnknown() { + valToSend = data.NetworkPool.ValueString() + } + case 397: // cpuAllocationPct + if !data.CpuAllocationPct.IsNull() && !data.CpuAllocationPct.IsUnknown() { + valToSend = fmt.Sprintf("%d", data.CpuAllocationPct.ValueInt64()) + } + case 398: // ramAllocationPct + if !data.RamAllocationPct.IsNull() && !data.RamAllocationPct.IsUnknown() { + valToSend = fmt.Sprintf("%d", data.RamAllocationPct.ValueInt64()) + } + case 557: // cpuQuota + if !data.CpuQuota.IsNull() && !data.CpuQuota.IsUnknown() { + valToSend = fmt.Sprintf("%d", data.CpuQuota.ValueInt64()) + } + case 558: // ramQuota + if !data.RamQuota.IsNull() && !data.RamQuota.IsUnknown() { + valToSend = fmt.Sprintf("%d", data.RamQuota.ValueInt64()) + } + default: + // Use existing or default value for unknown parameters + if param.ParamValue != nil { + valToSend = *param.ParamValue + } else if param.DefaultValue != nil { + valToSend = *param.DefaultValue + } + } + + // Fix specific data type formatting + if valToSend == "" || valToSend == "\"\"" { + if param.DataType == "map" || param.DataType == "json" { + valToSend = "{}" + } else if param.DataType == "array" || param.DataType == "list" { + valToSend = "[]" + } + } + + paramReq := CreateCfsParamRequest{ + InstanceOperationUid: operationUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: valToSend, + } + + jsonData, err := json.Marshal(paramReq) + if err != nil { + return fmt.Errorf("unable to marshal param request: %s", err) + } + + httpReq, err = http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("unable to create param request: %s", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err = r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to submit parameter: %s", err) + } + + respBody, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && + httpResp.StatusCode != http.StatusOK && + httpResp.StatusCode != http.StatusNoContent { + return fmt.Errorf("submit parameter id %d failed with status %d: %s", + param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody)) + } + } + + // Run the operation + runReq, err := http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + return fmt.Errorf("unable to create run request: %s", err) + } + + runReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + runResp, err := r.client.HttpClient.Do(runReq) + if err != nil { + return fmt.Errorf("unable to run operation: %s", err) + } + defer runResp.Body.Close() + + if runResp.StatusCode != http.StatusOK && + runResp.StatusCode != http.StatusNoContent && + runResp.StatusCode != http.StatusCreated { + runBody, _ := io.ReadAll(runResp.Body) + return fmt.Errorf("run operation failed with status %d: %s", + runResp.StatusCode, string(runBody)) + } + + return nil +} + +func (r *VDCResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout) + + // Шаг 1: Ждём завершения операции +operationLoop: + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-ticker.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for operation to complete") + } + + log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId) + + // Проверяем статус операции + httpReq, err := http.NewRequestWithContext(ctx, "GET", + r.client.ApiEndpoint+"/instanceOperations/"+operationId, nil) + if err != nil { + return fmt.Errorf("failed to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to check operation status: %s", err) + } + + body, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("get operation failed with status %d", httpResp.StatusCode) + } + + var opResp struct { + InstanceOperation struct { + IsInProgress bool `json:"isInProgress"` + IsPending bool `json:"isPending"` + } `json:"instanceOperation"` + } + + if err := json.Unmarshal(body, &opResp); err != nil { + return fmt.Errorf("failed to parse operation response: %s", err) + } + + log.Printf("[DEBUG] Operation status: isInProgress=%v, isPending=%v", opResp.InstanceOperation.IsInProgress, opResp.InstanceOperation.IsPending) + + // Операция завершена когда isInProgress=false И isPending=false + if !opResp.InstanceOperation.IsInProgress && !opResp.InstanceOperation.IsPending { + log.Printf("[DEBUG] Operation completed, moving to instance status check") + break operationLoop + } + } + } + + // Шаг 2: Проверяем статус instance + ticker2 := time.NewTicker(5 * time.Second) + defer ticker2.Stop() + + log.Printf("[DEBUG] Starting instance status polling") + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-ticker2.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for instance to become running") + } + + log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId) + + instance, err := r.readInstance(ctx, instanceId) + if err != nil { + return fmt.Errorf("failed to check instance status: %s", err) + } + + log.Printf("[DEBUG] Instance status: %s", instance.Status) + + if instance.Status == "running" { + log.Printf("[DEBUG] Instance is running, success!") + return nil + } + + if instance.Status == "error" || instance.Status == "failed" { + return fmt.Errorf("instance entered error state: %s", instance.Status) + } + } + } +} + diff --git a/internal/provider/vm_resource.go b/internal/provider/vm_resource.go new file mode 100644 index 0000000..ec6ad4e --- /dev/null +++ b/internal/provider/vm_resource.go @@ -0,0 +1,939 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "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" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ resource.Resource = &VMResource{} +var _ resource.ResourceWithImportState = &VMResource{} + +func NewVMResource() resource.Resource { + return &VMResource{} +} + +type VMResource struct { + client *NubesClient +} + +type VMResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + Status types.String `tfsdk:"status"` + VappUid types.String `tfsdk:"vapp_uid"` + VmName types.String `tfsdk:"vm_name"` + VmCpu types.Int64 `tfsdk:"vm_cpu"` + VmRam types.Int64 `tfsdk:"vm_ram"` + AccessPortList types.String `tfsdk:"access_port_list"` + ImageVm types.String `tfsdk:"image_vm"` + UserLogin types.String `tfsdk:"user_login"` + UserPublicKey types.String `tfsdk:"user_public_key"` + NeedAddZabbixTemplate types.Bool `tfsdk:"need_add_zabbix_template"` + AccessIpList types.String `tfsdk:"access_ip_list"` + VmDisk types.Int64 `tfsdk:"vm_disk"` + IpSpaceName types.String `tfsdk:"ip_space_name"` + CloudInit types.String `tfsdk:"cloud_init"` + ResourceRealm types.String `tfsdk:"resource_realm"` + DeletionProtection types.Bool `tfsdk:"deletion_protection"` +} + +func (r *VMResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vm_instance" +} + +func (r *VMResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Nubes VM Instance resource", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Instance identifier (UUID)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Instance display name", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Instance description", + Optional: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "Current status of the instance", + Computed: true, + }, + "vapp_uid": schema.StringAttribute{ + MarkdownDescription: "UUID vApp, в которой будет создаваться виртуальная машина", + Required: true, + }, + "vm_name": schema.StringAttribute{ + MarkdownDescription: "Уникальное имя виртуальной машины", + Required: true, + }, + "vm_cpu": schema.Int64Attribute{ + MarkdownDescription: "Количество ядер процессора. Должно быть больше 0", + Required: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "vm_ram": schema.Int64Attribute{ + MarkdownDescription: "Объем оперативной памяти в гигабайтах. Должно быть больше 0", + Required: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "access_port_list": schema.StringAttribute{ + MarkdownDescription: "Белый список портов для доступа к виртуальной машине извне в формате JSON. Пример: jsonencode([\"22\", \"80\"])", + Required: true, + Validators: []validator.String{ + ValidJSONArray(), + }, + }, + "image_vm": schema.StringAttribute{ + MarkdownDescription: "Образ операционной системы для развёртывания (RockyLinux_9-16G-cloudinit, Ubuntu_22-20G, Debian_13-20G)", + Required: true, + }, + "user_login": schema.StringAttribute{ + MarkdownDescription: "Логин пользователя для SSH-доступа", + Required: true, + }, + "user_public_key": schema.StringAttribute{ + MarkdownDescription: "Публичный SSH-ключ пользователя в формате OpenSSH", + Required: true, + }, + "need_add_zabbix_template": schema.BoolAttribute{ + MarkdownDescription: "Значение истина\\ложь, определяющее будет ли добавлен хост в zabbix", + Required: true, + }, + "access_ip_list": schema.StringAttribute{ + MarkdownDescription: "Белый список IP-адресов для доступа к виртуальной машине в формате JSON. Пример: jsonencode([\"1.2.3.4\", \"10.0.0.0/8\"]). Пустая строка или не указан = доступ отовсюду", + Optional: true, + Computed: true, + Validators: []validator.String{ + ValidJSONArrayOrEmpty(), + }, + }, + "vm_disk": schema.Int64Attribute{ + MarkdownDescription: "Размер дополнительного диска в гигабайтах. Должно быть больше 0", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "ip_space_name": schema.StringAttribute{ + MarkdownDescription: "Внешний IP-адрес. Если не требуется — укажите значение 'no-needed'", + Optional: true, + }, + "cloud_init": schema.StringAttribute{ + MarkdownDescription: "YAML-скрипт для кастомизации системы через cloud-init", + Optional: true, + }, + "resource_realm": schema.StringAttribute{ + MarkdownDescription: "Платформа ресурса (например, vcd, openstack)", + Optional: true, + }, + "deletion_protection": schema.BoolAttribute{ + MarkdownDescription: "Если true, при удалении ресурса из Terraform он просто удаляется из стейта. Если false, отправляется команда suspend (карантин 14 дней).", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + }, + } +} + +func (r *VMResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*NubesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *VMResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data VMResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Get service operation ID for 'create' + serviceId := 28 // VM service + + var instanceUid string + + svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create") + if err != nil { + resp.Diagnostics.AddError("Failed to get create operation ID", err.Error()) + return + } + + // Prepare parameters + params := r.prepareVMParams(&data) + + // Use client.CreateInstance like Postgres does + displayName := data.DisplayName.ValueString() + var opUid string + instanceUid, opUid, err = r.client.CreateInstance(ctx, displayName, serviceId, svcOperationId, params) + if err != nil { + resp.Diagnostics.AddError("Error creating VM", err.Error()) + return + } + + // Wait for operation success (like Postgres) + err = r.client.WaitForOperation(ctx, opUid) + if err != nil { + resp.Diagnostics.AddError("Error waiting for VM creation", err.Error()) + return + } + + // Wait for instance to be running (like Postgres does) + err = r.client.WaitForInstanceStatus(ctx, instanceUid, "running") + if err != nil { + resp.Diagnostics.AddError("Error waiting for VM running status", err.Error()) + return + } + + data.ID = types.StringValue(instanceUid) + + // Read final state + readData, err := r.readInstance(ctx, instanceUid) + if err != nil { + resp.Diagnostics.AddWarning("Unable to read instance after creation", err.Error()) + } else { + data.Status = types.StringValue(readData.Status) + } + + // Fix "Provider returned invalid result object" for Computed fields + // AccessIpList is Computed + Optional. If it was not provided, we sent "", so we must set it to "" + if data.AccessIpList.IsNull() || data.AccessIpList.IsUnknown() { + data.AccessIpList = types.StringValue("") + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +// prepareVMParams собирает параметры для VM (выделено в отдельную функцию для переиспользования) +func (r *VMResource) prepareVMParams(data *VMResourceModel) []InstanceParam { + params := []InstanceParam{} + + // Hardcoded parameter mapping (от HAR анализа) + paramMapping := map[string]int{ + "vappUid": 407, // vApp UUID + "vmName": 408, // VM name + "vmCpu": 409, // CPU count + "vmRam": 410, // RAM in GB + "vmDisk": 411, // Additional disk in GB + "ipSpaceName": 412, // IP space name + "accessIpList": 413, // JSON array of allowed IPs (or empty string for all) + "imageVm": 414, // OS image name + "cloudInit": 415, // Cloud-init script + "userLogin": 416, // SSH username + "userPublicKey": 417, // SSH public key + "accessPortList": 448, // JSON array of ports + "needAddZabbixTemplate": 449, // Boolean for Zabbix monitoring + } + + // Собираем все параметры в том же порядке что и UI (по возрастанию ID) + // vappUid (407) + if !data.VappUid.IsNull() && !data.VappUid.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vappUid"], ParamValue: data.VappUid.ValueString()}) + } + // vmName (408) + if !data.VmName.IsNull() && !data.VmName.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmName"], ParamValue: data.VmName.ValueString()}) + } + // vmCpu (409) + if !data.VmCpu.IsNull() && !data.VmCpu.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmCpu"], ParamValue: fmt.Sprintf("%d", data.VmCpu.ValueInt64())}) + } + // vmRam (410) + if !data.VmRam.IsNull() && !data.VmRam.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmRam"], ParamValue: fmt.Sprintf("%d", data.VmRam.ValueInt64())}) + } + // vmDisk (411) - ВСЕГДА отправляем (пустая строка если не задан) + if !data.VmDisk.IsNull() && !data.VmDisk.IsUnknown() && data.VmDisk.ValueInt64() > 0 { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmDisk"], ParamValue: fmt.Sprintf("%d", data.VmDisk.ValueInt64())}) + } else { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmDisk"], ParamValue: ""}) + } + // ipSpaceName (412) + if !data.IpSpaceName.IsNull() && !data.IpSpaceName.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["ipSpaceName"], ParamValue: data.IpSpaceName.ValueString()}) + } + // accessIpList (413) - ВСЕГДА отправляем (пустая строка если не задан) + if !data.AccessIpList.IsNull() && !data.AccessIpList.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["accessIpList"], ParamValue: data.AccessIpList.ValueString()}) + } else { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["accessIpList"], ParamValue: ""}) + } + // imageVm (414) + if !data.ImageVm.IsNull() && !data.ImageVm.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["imageVm"], ParamValue: data.ImageVm.ValueString()}) + } + // cloudInit (415) - ВСЕГДА отправляем (пустая строка если не задан) + if !data.CloudInit.IsNull() && !data.CloudInit.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["cloudInit"], ParamValue: data.CloudInit.ValueString()}) + } else { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["cloudInit"], ParamValue: ""}) + } + // userLogin (416) + if !data.UserLogin.IsNull() && !data.UserLogin.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["userLogin"], ParamValue: data.UserLogin.ValueString()}) + } + // userPublicKey (417) + if !data.UserPublicKey.IsNull() && !data.UserPublicKey.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["userPublicKey"], ParamValue: data.UserPublicKey.ValueString()}) + } + // accessPortList (448) + if !data.AccessPortList.IsNull() && !data.AccessPortList.IsUnknown() { + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["accessPortList"], ParamValue: data.AccessPortList.ValueString()}) + } + // needAddZabbixTemplate (449) - ВСЕГДА отправляем "true" (как в UI) + params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["needAddZabbixTemplate"], ParamValue: "true"}) + + return params +} + +func (r *VMResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data VMResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) + return + } + + data.Status = types.StringValue(instanceResp.Status) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *VMResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan VMResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state VMResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Trigger modify operation + operationReq := CreateOperationRequest{ + InstanceUid: plan.ID.ValueString(), + Operation: "modify", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract operation ID and submit parameters + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + + // Wait for backend to possibly populate params + tflog.Info(ctx, "Waiting 5 seconds for operation parameters to initialize...") + time.Sleep(5 * time.Second) + + // Discovery parameters for modify to debug 400 error + tflog.Info(ctx, fmt.Sprintf("Discovering parameters for operation %s", operationId)) + op, err := r.client.GetInstanceOperation(ctx, operationId) + if err == nil { + for _, p := range op.CfsParams { + tflog.Info(ctx, fmt.Sprintf("Allowed Param: %s (ID: %d)", p.SvcOperationCfsParam, p.SvcOperationCfsParamId)) + } + } + + if err := r.submitVMOperationParams(ctx, operationId, plan, state); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) + return + } + } + + // Read updated state + time.Sleep(5 * time.Second) + readData, err := r.readInstance(ctx, plan.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) + return + } + + plan.Status = types.StringValue(readData.Status) + + // Fix "Provider returned invalid result object" for Computed fields + if plan.AccessIpList.IsNull() || plan.AccessIpList.IsUnknown() { + plan.AccessIpList = types.StringValue("") + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *VMResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data VMResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Deletion Logic: + // If DeletionProtection is TRUE (default) -> Remove from state, do not touch cloud resource. + // If DeletionProtection is FALSE -> Send 'suspend' operation (quarantine) + remove from state. + + if !data.DeletionProtection.IsUnknown() && data.DeletionProtection.ValueBool() { + tflog.Info(ctx, "DeletionProtection is enabled. Resource will be removed from state but kept in cloud.") + return + } + + tflog.Info(ctx, "DeletionProtection is disabled. Initiating 'suspend' operation (quarantine).") + + // Trigger suspend operation (instead of delete) + operationReq := CreateOperationRequest{ + InstanceUid: data.ID.ValueString(), + Operation: "suspend", + } + + jsonData, err := json.Marshal(operationReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) + return + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend instance: %s", err)) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(httpResp.Body) + resp.Diagnostics.AddError( + "API Error", + fmt.Sprintf("Suspend operation failed with status %d: %s", httpResp.StatusCode, string(body)), + ) + return + } + + // Extract operation ID and run operation + location := httpResp.Header.Get("Location") + if location != "" { + operationId := location[2:] + + // For suspend, we typically don't need parameters. Just Run. + tflog.Info(ctx, fmt.Sprintf("Running suspend operation %s", operationId)) + + runReq, err := http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create run request: %s", err)) + return + } + + runReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + runResp, err := r.client.HttpClient.Do(runReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to run run request: %s", err)) + return + } + defer runResp.Body.Close() + + if runResp.StatusCode != http.StatusOK && runResp.StatusCode != http.StatusNoContent && runResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(runResp.Body) + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Run suspend failed with status %d: %s", runResp.StatusCode, string(body))) + } + } +} + +func (r *VMResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *VMResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id, nil) + if err != nil { + return nil, err + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) + } + + var response struct { + Instance InstanceResponse `json:"instance"` + } + if err := json.Unmarshal(body, &response); err != nil { + tflog.Error(ctx, fmt.Sprintf("Failed to unmarshal instance details: %s, Body: %s", err, string(body))) + return nil, err + } + + tflog.Info(ctx, fmt.Sprintf("Read Instance %s: Status='%s', ExplainedStatus='%s'", id, response.Instance.Status, response.Instance.Status)) + return &response.Instance, nil +} + +func (r *VMResource) submitVMOperationParams(ctx context.Context, operationUid string, plan VMResourceModel, state VMResourceModel) error { + // Step 1: Get operation details with parameters to build dynamic mapping + op, err := r.client.GetInstanceOperation(ctx, operationUid) + if err != nil { + return fmt.Errorf("unable to get operation parameters: %s", err) + } + + // Step 2: Build parameter mapping dynamically + type ParamInfo struct { + Id int + Uid string + CurrentValue string + } + paramMapping := make(map[string]ParamInfo) + for _, p := range op.CfsParams { + // Map param name (e.g. "vmCpu") to info (ID + InstanceParamUID) + curVal := "" + if p.ParamValue != nil { + curVal = *p.ParamValue + } + paramMapping[p.SvcOperationCfsParam] = ParamInfo{ + Id: p.SvcOperationCfsParamId, + Uid: p.InstanceOperationCfsParamUid, + CurrentValue: curVal, + } + } + + tflog.Info(ctx, fmt.Sprintf("Built dynamic parameter mapping for operation %s: %v", operationUid, paramMapping)) + + // Step 3: Build named parameters list + type NamedParam struct { + Name string + Value string // Must be strictly string + } + + namedParams := []NamedParam{} + + // Add ALL potential parameters. The mapping check will filter out those not applicable to the current operation. + + // vmName + /* + if !plan.VmName.IsNull() && !plan.VmName.IsUnknown() { + // Only send if changed + if !plan.VmName.Equal(state.VmName) { + namedParams = append(namedParams, NamedParam{"vmName", plan.VmName.ValueString()}) + } + } + */ + + // vmCpu + if !plan.VmCpu.IsNull() && !plan.VmCpu.IsUnknown() { + if !plan.VmCpu.Equal(state.VmCpu) { + namedParams = append(namedParams, NamedParam{"vmCpu", fmt.Sprintf("%d", plan.VmCpu.ValueInt64())}) + } + } + + // vmRam + /* + if !plan.VmRam.IsNull() && !plan.VmRam.IsUnknown() { + if !plan.VmRam.Equal(state.VmRam) { + namedParams = append(namedParams, NamedParam{"vmRam", fmt.Sprintf("%d", plan.VmRam.ValueInt64())}) + } + } + + // vmDisk + if !plan.VmDisk.IsNull() && !plan.VmDisk.IsUnknown() { + val := plan.VmDisk.ValueInt64() + if val > 0 && !plan.VmDisk.Equal(state.VmDisk) { + namedParams = append(namedParams, NamedParam{"vmDisk", fmt.Sprintf("%d", val)}) + } + } + + // ipSpaceName + if !plan.IpSpaceName.IsNull() && !plan.IpSpaceName.IsUnknown() { + if !plan.IpSpaceName.Equal(state.IpSpaceName) { + namedParams = append(namedParams, NamedParam{"ipSpaceName", plan.IpSpaceName.ValueString()}) + } + } + */ + + // accessIpList + /* + if !plan.AccessIpList.IsNull() && !plan.AccessIpList.IsUnknown() { + val := plan.AccessIpList.ValueString() + + // If ipSpaceName is "no-needed", SKIP sending this parameter + isNoNeeded := !plan.IpSpaceName.IsNull() && plan.IpSpaceName.ValueString() == "no-needed" + if !isNoNeeded { + if !plan.AccessIpList.Equal(state.AccessIpList) { + namedParams = append(namedParams, NamedParam{"accessIpList", val}) + } + } + } + + // accessPortList + if !plan.AccessPortList.IsNull() && !plan.AccessPortList.IsUnknown() { + val := plan.AccessPortList.ValueString() + + // If ipSpaceName is "no-needed", SKIP sending this parameter + isNoNeeded := !plan.IpSpaceName.IsNull() && plan.IpSpaceName.ValueString() == "no-needed" + if !isNoNeeded { + if !plan.AccessPortList.Equal(state.AccessPortList) { + namedParams = append(namedParams, NamedParam{"accessPortList", val}) + } + } + } + */ + + // needAddZabbixTemplate + // Skip sending to avoid duplicates unless we really know it changed. + // Since we don't track it in state (usually), we omit it. + + // Create-only params (will be filtered out for Modify automatically but good to skip anyway) + // Generally they shouldn't change in Modify. + // imageVm, cloudInit, userLogin, userPublicKey -> usually ForceNew? + // If they are not ForceNew, check change. + // Assuming ForceNew behavior for image/user details, but if they are Updateable: + /* + if !plan.ImageVm.IsNull() && !plan.ImageVm.IsUnknown() && !plan.ImageVm.Equal(state.ImageVm) { + namedParams = append(namedParams, NamedParam{"imageVm", plan.ImageVm.ValueString()}) + } + if !plan.CloudInit.IsNull() && !plan.CloudInit.IsUnknown() && !plan.CloudInit.Equal(state.CloudInit) { + namedParams = append(namedParams, NamedParam{"cloudInit", plan.CloudInit.ValueString()}) + } + if !plan.UserLogin.IsNull() && !plan.UserLogin.IsUnknown() && !plan.UserLogin.Equal(state.UserLogin) { + namedParams = append(namedParams, NamedParam{"userLogin", plan.UserLogin.ValueString()}) + } + if !plan.UserPublicKey.IsNull() && !plan.UserPublicKey.IsUnknown() && !plan.UserPublicKey.Equal(state.UserPublicKey) { + namedParams = append(namedParams, NamedParam{"userPublicKey", plan.UserPublicKey.ValueString()}) + } + */ + + // Step 4: Submit parameters + if len(namedParams) == 0 { + tflog.Info(ctx, "No parameters to submit. Skipping parameter submission.") + return nil + } + + // Submit parameters + for i := len(namedParams) - 1; i >= 0; i-- { + np := namedParams[i] + info, ok := paramMapping[np.Name] + if !ok { + // Parameter not allowed in this operation + continue + } + + // Check if value is unchanged + if np.Value == info.CurrentValue { + tflog.Info(ctx, fmt.Sprintf("Skipping parameter %s (ID %d) as value '%s' is unchanged", np.Name, info.Id, np.Value)) + continue + } + + tflog.Info(ctx, fmt.Sprintf("Submitting parameter: %s (ID %d) = %v [ParamUID: %s]", np.Name, info.Id, np.Value, info.Uid)) + + var method string + var payload map[string]interface{} + + if info.Uid != "" { + // Update existing parameter -> PUT + method = "PUT" + payload = map[string]interface{}{ + "instanceOperationCfsParamUid": info.Uid, + "svcOperationCfsParamId": info.Id, + "instanceOperationUid": operationUid, + "paramValue": np.Value, + } + } else { + // Create new parameter -> POST + method = "POST" + payload = map[string]interface{}{ + "instanceOperationUid": operationUid, + "svcOperationCfsParamId": info.Id, + "paramValue": np.Value, + } + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("unable to marshal param request: %s", err) + } + + tflog.Info(ctx, fmt.Sprintf("Param Request Payload (%s): %s", method, string(jsonData))) + + url := r.client.ApiEndpoint + "/instanceOperationCfsParams" + httpReq, err := http.NewRequestWithContext(ctx, method, url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("unable to create param request: %s", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("unable to submit parameter: %s", err) + } + + respBody, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusCreated && + httpResp.StatusCode != http.StatusOK && + httpResp.StatusCode != http.StatusNoContent { + return fmt.Errorf("submit parameter %s (id %d) failed with status %d: %s", + np.Name, info.Id, httpResp.StatusCode, string(respBody)) + } + } + + // Step 5: Run the operation + runReq, err := http.NewRequestWithContext(ctx, "POST", + r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) + if err != nil { + return fmt.Errorf("unable to create run request: %s", err) + } + + runReq.Header.Set("Content-Type", "application/json") + if r.client.ApiToken != "" { + runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + runResp, err := r.client.HttpClient.Do(runReq) + if err != nil { + return fmt.Errorf("unable to run operation: %s", err) + } + defer runResp.Body.Close() + + if runResp.StatusCode != http.StatusOK && + runResp.StatusCode != http.StatusNoContent && + runResp.StatusCode != http.StatusCreated { + runBody, _ := io.ReadAll(runResp.Body) + return fmt.Errorf("run operation failed with status %d: %s", + runResp.StatusCode, string(runBody)) + } + + // Step 6: Wait for operation completion + tflog.Info(ctx, fmt.Sprintf("Waiting for operation %s to complete...", operationUid)) + if err := r.client.WaitForOperation(ctx, operationUid); err != nil { + return fmt.Errorf("wait for operation %s failed: %s", operationUid, err) + } + + return nil +} + +type VMOperationStage struct { + Name string `json:"stage"` + IsSuccessful bool `json:"isSuccessful"` +} + +type VMOperation struct { + IsInProgress bool `json:"isInProgress"` + IsPending bool `json:"isPending"` + IsSuccessful *bool `json:"isSuccessful"` + DtFinish *string `json:"dtFinish"` + ErrorLog *string `json:"errorLog"` + Stages []VMOperationStage `json:"stages"` +} + +type GetVMOperationResponse struct { + InstanceOperation VMOperation `json:"instanceOperation"` +} + +func (r *VMResource) waitForVMOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + // Phase 1: Wait for operation completion + opTicker := time.NewTicker(5 * time.Second) + defer opTicker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-opTicker.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for operation %s", operationId) + } + + // Check operation status + httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instanceOperations/"+operationId, nil) + if err != nil { + return fmt.Errorf("failed to create request: %s", err) + } + + if r.client.ApiToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) + } + + httpResp, err := r.client.HttpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to check operation status: %s", err) + } + + body, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + return fmt.Errorf("check operation failed: %d", httpResp.StatusCode) + } + + var opResp GetVMOperationResponse + if err := json.Unmarshal(body, &opResp); err != nil { + return fmt.Errorf("failed to parse operation response: %s", err) + } + + op := opResp.InstanceOperation + + // Detect Failure + if op.IsSuccessful != nil && !*op.IsSuccessful { + // Scan stages for detail + failedStage := "unknown" + for _, stage := range op.Stages { + if !stage.IsSuccessful { + failedStage = stage.Name + } + } + return fmt.Errorf("operation failed at stage '%s' (isSuccessful=false)", failedStage) + } + + // Detect Success (как в client_impl.go WaitForOperation) + if !op.IsInProgress && !op.IsPending && op.DtFinish != nil && *op.DtFinish != "" { + if op.IsSuccessful != nil && *op.IsSuccessful { + // Operation finished successfully + // Move to Phase 2 + goto Phase2 + } + // Если не successful но finished - это ошибка (уже обработана выше) + } + + // Still running, continue waiting... + } + } + +Phase2: + // Phase 2: Wait for Instance to be Running + instTicker := time.NewTicker(10 * time.Second) + defer instTicker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled") + case <-instTicker.C: + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for instance running state") + } + + instance, err := r.readInstance(ctx, instanceId) + if err != nil { + return fmt.Errorf("failed to check instance: %s", err) + } + + if instance.Status == "running" { + return nil + } + + if instance.Status == "error" || instance.Status == "failed" { + return fmt.Errorf("instance entered error state: %s", instance.Status) + } + } + } +}