add: internal package (provider, core, registrykeys)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "./")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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=<YOUR_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.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)...)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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{}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user