package resources import ( "context" "encoding/base64" "fmt" "strings" "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/stringdefault" "github.com/hashicorp/terraform-plugin-framework/types" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "terraform-provider-fission/internal/client" ) var _ resource.Resource = &FunctionResource{} var _ resource.ResourceWithImportState = &FunctionResource{} // Изменено: 2026-04-14 20:00 UTC. // Resource для управления Fission Function через Kubernetes CRD API. type FunctionResource struct { client *client.Client } // functionResourceModel описывает состояние terraform ресурса fission_function. type functionResourceModel struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` Environment types.String `tfsdk:"environment"` PackageName types.String `tfsdk:"package_name"` Entrypoint types.String `tfsdk:"entrypoint"` ExecutorType types.String `tfsdk:"executor_type"` FunctionTimeout types.Int64 `tfsdk:"function_timeout"` IdleTimeout types.Int64 `tfsdk:"idle_timeout"` MinScale types.Int64 `tfsdk:"min_scale"` MaxScale types.Int64 `tfsdk:"max_scale"` Namespace types.String `tfsdk:"namespace"` UID types.String `tfsdk:"uid"` } // NewFunctionResource создает инстанс ресурса функции. func NewFunctionResource() resource.Resource { return &FunctionResource{} } // Metadata задает имя ресурса fission_function. func (r *FunctionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_function" } // Schema задает Terraform schema для Function. func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, Description: "Идентификатор ресурса (namespace/name).", }, "name": schema.StringAttribute{ Required: true, Description: "Имя Fission Function.", }, "environment": schema.StringAttribute{ Required: true, Description: "Имя Environment, в котором выполняется функция.", }, "package_name": schema.StringAttribute{ Required: true, Description: "Имя Fission Package с кодом функции.", }, "entrypoint": schema.StringAttribute{ Required: true, Description: "Имя точки входа в пакете (например main.main).", }, "executor_type": schema.StringAttribute{ Optional: true, Computed: true, Default: stringdefault.StaticString("poolmgr"), Description: "Тип executor: poolmgr (default), newdeploy или container.", }, "function_timeout": schema.Int64Attribute{ Optional: true, Description: "Таймаут выполнения функции в секундах (Fission default: 60).", }, "idle_timeout": schema.Int64Attribute{ Optional: true, Description: "Время простоя до scale-to-zero в секундах (Fission default: 120).", }, "min_scale": schema.Int64Attribute{ Optional: true, Description: "Минимальное число реплик (для newdeploy/container).", }, "max_scale": schema.Int64Attribute{ Optional: true, Description: "Максимальное число реплик (для newdeploy/container).", }, "namespace": schema.StringAttribute{ Optional: true, Computed: true, Description: "Namespace, где создается Function. По умолчанию используется provider namespace.", }, "uid": schema.StringAttribute{ Computed: true, Description: "UID Kubernetes объекта Function.", }, }, } } // Configure получает клиент из provider.Configure(). func (r *FunctionResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return } fissionClient, ok := req.ProviderData.(*client.Client) if !ok { resp.Diagnostics.AddError( "Некорректный тип provider data", fmt.Sprintf("Ожидался *client.Client, получен: %T", req.ProviderData), ) return } r.client = fissionClient } // Create создает новый Function CRD объект. func (r *FunctionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan functionResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(plan.Namespace, r.client.Namespace) if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil { resp.Diagnostics.AddError("Ошибка валидации Environment для Function", err.Error()) return } pkg, err := ensurePackageExists(ctx, r.client, namespace, plan.PackageName.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка валидации Package для Function", err.Error()) return } if err := validateEntrypointAgainstPackageSource(plan.Entrypoint.ValueString(), pkg); err != nil { resp.Diagnostics.AddError("Ошибка валидации entrypoint", err.Error()) return } functionObject := functionToUnstructured(plan, namespace) createdFunction, err := r.client.CreateFunction(ctx, functionObject) if err != nil { resp.Diagnostics.AddError("Ошибка создания Fission Function", err.Error()) return } state := unstructuredToFunctionModel(createdFunction, plan) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } // Read синхронизирует Terraform state с текущим состоянием Function CRD. func (r *FunctionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state functionResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(state.Namespace, r.client.Namespace) functionObject, err := r.client.GetFunction(ctx, namespace, state.Name.ValueString()) if err != nil { if client.IsNotFound(err) { resp.State.RemoveResource(ctx) return } resp.Diagnostics.AddError("Ошибка чтения Fission Function", err.Error()) return } updatedState := unstructuredToFunctionModel(functionObject, state) resp.Diagnostics.Append(resp.State.Set(ctx, &updatedState)...) } // Update обновляет существующий Function CRD объект. func (r *FunctionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan functionResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(plan.Namespace, r.client.Namespace) if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil { resp.Diagnostics.AddError("Ошибка валидации Environment для Function", err.Error()) return } pkg, err := ensurePackageExists(ctx, r.client, namespace, plan.PackageName.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка валидации Package для Function", err.Error()) return } if err := validateEntrypointAgainstPackageSource(plan.Entrypoint.ValueString(), pkg); err != nil { resp.Diagnostics.AddError("Ошибка валидации entrypoint", err.Error()) return } existingFunction, err := r.client.GetFunction(ctx, namespace, plan.Name.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка получения Function перед обновлением", err.Error()) return } functionObject := functionToUnstructured(plan, namespace) functionObject.SetResourceVersion(existingFunction.GetResourceVersion()) updatedFunction, err := r.client.UpdateFunction(ctx, functionObject) if err != nil { resp.Diagnostics.AddError("Ошибка обновления Fission Function", err.Error()) return } state := unstructuredToFunctionModel(updatedFunction, plan) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } // Delete удаляет Function CRD объект. func (r *FunctionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state functionResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(state.Namespace, r.client.Namespace) err := r.client.DeleteFunction(ctx, namespace, state.Name.ValueString()) if err != nil && !client.IsNotFound(err) { resp.Diagnostics.AddError("Ошибка удаления Fission Function", err.Error()) } } // ImportState поддерживает импорт ресурса в формате namespace/name. func (r *FunctionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { namespace, name, err := parseImportID(req.ID) if err != nil { resp.Diagnostics.AddError("Некорректный формат import ID", err.Error()) return } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("namespace"), namespace)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...) } // functionToUnstructured преобразует Terraform model в Kubernetes CRD payload. func functionToUnstructured(model functionResourceModel, namespace string) *unstructured.Unstructured { executorType := "poolmgr" if !model.ExecutorType.IsNull() && !model.ExecutorType.IsUnknown() && model.ExecutorType.ValueString() != "" { executorType = model.ExecutorType.ValueString() } executionStrategy := map[string]interface{}{ "ExecutorType": executorType, } if !model.MinScale.IsNull() && !model.MinScale.IsUnknown() { executionStrategy["MinScale"] = model.MinScale.ValueInt64() } if !model.MaxScale.IsNull() && !model.MaxScale.IsUnknown() { executionStrategy["MaxScale"] = model.MaxScale.ValueInt64() } spec := map[string]interface{}{ "environment": map[string]interface{}{ "name": model.Environment.ValueString(), "namespace": namespace, }, "InvokeStrategy": map[string]interface{}{ "ExecutionStrategy": executionStrategy, "StrategyType": "execution", }, "package": map[string]interface{}{ "packageref": map[string]interface{}{ "name": model.PackageName.ValueString(), "namespace": namespace, }, "functionName": model.Entrypoint.ValueString(), }, } if !model.FunctionTimeout.IsNull() && !model.FunctionTimeout.IsUnknown() { spec["functionTimeout"] = model.FunctionTimeout.ValueInt64() } if !model.IdleTimeout.IsNull() && !model.IdleTimeout.IsUnknown() { spec["idletimeout"] = model.IdleTimeout.ValueInt64() } return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "fission.io/v1", "kind": "Function", "metadata": map[string]interface{}{ "name": model.Name.ValueString(), "namespace": namespace, }, "spec": spec, }} } // unstructuredToFunctionModel читает нужные поля из CRD объекта обратно в Terraform state. func unstructuredToFunctionModel(functionObject *unstructured.Unstructured, base functionResourceModel) functionResourceModel { environmentName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "environment", "name") packageName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "packageref", "name") entrypoint, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "functionName") executorType, _, _ := unstructured.NestedString(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "ExecutorType") functionTimeout, foundFT, _ := unstructured.NestedInt64(functionObject.Object, "spec", "functionTimeout") idleTimeout, foundIT, _ := unstructured.NestedInt64(functionObject.Object, "spec", "idletimeout") minScale, foundMin, _ := unstructured.NestedInt64(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "MinScale") maxScale, foundMax, _ := unstructured.NestedInt64(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "MaxScale") state := base state.Name = types.StringValue(functionObject.GetName()) state.Namespace = types.StringValue(functionObject.GetNamespace()) state.ID = types.StringValue(fmt.Sprintf("%s/%s", functionObject.GetNamespace(), functionObject.GetName())) state.UID = types.StringValue(string(functionObject.GetUID())) if environmentName != "" { state.Environment = types.StringValue(environmentName) } if packageName != "" { state.PackageName = types.StringValue(packageName) } if entrypoint != "" { state.Entrypoint = types.StringValue(entrypoint) } if executorType != "" { state.ExecutorType = types.StringValue(executorType) } if foundFT { state.FunctionTimeout = types.Int64Value(functionTimeout) } if foundIT { state.IdleTimeout = types.Int64Value(idleTimeout) } if foundMin { state.MinScale = types.Int64Value(minScale) } if foundMax { state.MaxScale = types.Int64Value(maxScale) } return state } func validateEntrypointAgainstPackageSource(entrypoint string, pkg *unstructured.Unstructured) error { if entrypoint == "" { return fmt.Errorf("entrypoint не может быть пустым") } // Для Python/Go/JS: валидируем формат module.function и наличие функции в исходнике. // Для PHP (module::function), Ruby (function), Perl (function) — допускаем любой непустой формат. parts := strings.Split(entrypoint, ".") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { // Не стандартный module.function — допускаем (PHP, Ruby, Perl и др.) return nil } if parts[0] != "main" { return nil } literalSource, found, err := unstructured.NestedString(pkg.Object, "spec", "deployment", "literal") if err != nil || !found || literalSource == "" { return nil } literalBytes, err := base64.StdEncoding.DecodeString(literalSource) if err != nil { return nil } source := string(literalBytes) if looksLikePythonSource(source) { signature := fmt.Sprintf("def %s(", parts[1]) if !strings.Contains(source, signature) { return fmt.Errorf("entrypoint %q не найден в Python исходнике пакета (ожидался %q)", entrypoint, signature) } } return nil } func looksLikePythonSource(source string) bool { trimmed := strings.TrimSpace(source) if strings.HasPrefix(trimmed, "def ") || strings.Contains(source, "\ndef ") { return true } if strings.Contains(source, "import ") && !strings.Contains(source, "func ") && !strings.Contains(source, "module.exports") { return true } return false }