package resources // simple_function_resource.go — составной ресурс fission_simple_function. // // Позволяет объявить serverless-функцию одним блоком HCL: // // resource "fission_simple_function" "health" { // name = "health" // runtime = "nodejs" // code_dir = "./code/health" // url = "/ecom/health" // methods = ["GET"] // } // // Провайдер сам: // 1. Ищет environment simple--env, если не найден — создаёт. // 2. Создаёт Package (-pkg) с кодом из code_dir. // 3. Создаёт Function (). // 4. Создаёт HTTPTrigger (-trigger), если указан url. // // Добавлено: 2026-04-20 import ( "context" "crypto/sha256" "encoding/base64" "fmt" "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" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "terraform-provider-fission/internal/client" ) var _ resource.Resource = &SimpleFunctionResource{} var _ resource.ResourceWithImportState = &SimpleFunctionResource{} var _ resource.ResourceWithModifyPlan = &SimpleFunctionResource{} // runtimeMeta содержит параметры окружения для каждого поддерживаемого runtime. type runtimeMeta struct { image string // образ среды выполнения builderImage string // образ сборщика (пустая строка если не нужен) deployType string // "literal" или "source" entrypoint string // точка входа функции poolSize int64 // размер пула } // runtimeRegistry — маппинг имени runtime → параметры среды выполнения. var runtimeRegistry = map[string]runtimeMeta{ "nodejs": { image: "ghcr.io/fission/node-env:latest", builderImage: "", deployType: "literal", entrypoint: "", // JS runtime использует default export (пустая строка) poolSize: 3, }, "python": { image: "ghcr.io/fission/python-env:latest", builderImage: "", deployType: "literal", entrypoint: "main.main", poolSize: 3, }, "go": { image: "ghcr.io/fission/go-env:latest", builderImage: "naeel/go-builder-fast:v1", deployType: "source", entrypoint: "Handler", poolSize: 3, }, "ruby": { image: "ghcr.io/fission/ruby-env:latest", builderImage: "", deployType: "literal", entrypoint: "main.main", poolSize: 3, }, "php": { image: "ghcr.io/fission/php-env:latest", builderImage: "", deployType: "literal", entrypoint: "main.main", poolSize: 3, }, "perl": { image: "ghcr.io/fission/perl-env:latest", builderImage: "", deployType: "literal", entrypoint: "main.main", poolSize: 3, }, } // SimpleFunctionResource — составной ресурс: Environment + Package + Function + HTTPTrigger. type SimpleFunctionResource struct { client *client.Client } // simpleFunctionModel описывает Terraform state ресурса fission_simple_function. type simpleFunctionModel struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` Runtime types.String `tfsdk:"runtime"` CodeDir types.String `tfsdk:"code_dir"` URL types.String `tfsdk:"url"` Methods types.List `tfsdk:"methods"` Namespace types.String `tfsdk:"namespace"` EnvironmentName types.String `tfsdk:"environment_name"` PackageName types.String `tfsdk:"package_name"` TriggerName types.String `tfsdk:"trigger_name"` CodeHash types.String `tfsdk:"code_hash"` } // NewSimpleFunctionResource создаёт инстанс составного ресурса. func NewSimpleFunctionResource() resource.Resource { return &SimpleFunctionResource{} } func (r *SimpleFunctionResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_simple_function" } func (r *SimpleFunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: "Составной ресурс: создаёт Environment (при необходимости), Package, Function и HTTPTrigger одним блоком.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, Description: "Идентификатор ресурса (namespace/name).", }, "name": schema.StringAttribute{ Required: true, Description: "Имя функции. Используется как база для имён Package и HTTPTrigger.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "runtime": schema.StringAttribute{ Required: true, Description: "Runtime функции: nodejs, python, go, ruby, php, perl.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "code_dir": schema.StringAttribute{ Required: true, Description: "Путь к директории с исходным кодом (main.js, main.py, main.go и т.д.).", }, "url": schema.StringAttribute{ Optional: true, Description: "Относительный URL для HTTPTrigger. Если не задан — HTTPTrigger не создаётся.", }, "methods": schema.ListAttribute{ Optional: true, Computed: true, ElementType: types.StringType, Description: "HTTP методы для HTTPTrigger. По умолчанию [\"GET\"].", }, "namespace": schema.StringAttribute{ Optional: true, Computed: true, Description: "Namespace для ресурсов. По умолчанию используется namespace провайдера.", }, "environment_name": schema.StringAttribute{ Computed: true, Description: "Имя Environment, который был использован или создан (simple--env).", }, "package_name": schema.StringAttribute{ Computed: true, Description: "Имя созданного Package (-pkg).", }, "trigger_name": schema.StringAttribute{ Computed: true, Description: "Имя созданного HTTPTrigger (-trigger). Пусто, если url не задан.", }, "code_hash": schema.StringAttribute{ Computed: true, Description: "SHA-256 хеш кода из code_dir. Terraform обнаруживает изменения кода по этому полю.", }, }, } } // ModifyPlan пересчитывает code_hash из code_dir, чтобы Terraform видел изменения кода. func (r *SimpleFunctionResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { if req.Plan.Raw.IsNull() { return } var plan simpleFunctionModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } if plan.CodeDir.IsNull() || plan.CodeDir.IsUnknown() || plan.CodeDir.ValueString() == "" { return } deployType := "literal" if !plan.Runtime.IsNull() && !plan.Runtime.IsUnknown() { if meta, ok := runtimeRegistry[plan.Runtime.ValueString()]; ok { deployType = meta.deployType } } codeBytes, err := loadPackageContent(plan.CodeDir.ValueString(), "", deployType) if err != nil { // Файл может отсутствовать при первом plan — не ошибка. return } sum := sha256.Sum256(codeBytes) plan.CodeHash = types.StringValue(fmt.Sprintf("%x", sum)) resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...) } // Configure получает клиент из provider.Configure(). func (r *SimpleFunctionResource) 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 создаёт Environment (при необходимости), Package, Function и HTTPTrigger. func (r *SimpleFunctionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan simpleFunctionModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(plan.Namespace, r.client.Namespace) meta, ok := runtimeRegistry[plan.Runtime.ValueString()] if !ok { resp.Diagnostics.AddError( "Неподдерживаемый runtime", fmt.Sprintf("Runtime %q не поддерживается. Допустимые значения: nodejs, python, go, ruby, php, perl.", plan.Runtime.ValueString()), ) return } // 1. Найти или создать environment. envName, err := findOrCreateSimpleEnv(ctx, r.client, namespace, plan.Runtime.ValueString(), meta) if err != nil { resp.Diagnostics.AddError("Ошибка подготовки Environment", err.Error()) return } // 2. Загрузить код из code_dir. codeBytes, err := loadPackageContent(plan.CodeDir.ValueString(), "", meta.deployType) if err != nil { resp.Diagnostics.AddError("Ошибка чтения исходного кода", err.Error()) return } sum := sha256.Sum256(codeBytes) codeHash := fmt.Sprintf("%x", sum) // 3. Создать Package. pkgName := plan.Name.ValueString() + "-pkg" pkgObj := buildSimplePackage(pkgName, envName, namespace, codeBytes, meta.deployType) if _, err = r.client.CreatePackage(ctx, pkgObj); err != nil { resp.Diagnostics.AddError("Ошибка создания Package", err.Error()) return } // 4. Создать Function. fnObj := buildSimpleFunction(plan.Name.ValueString(), envName, pkgName, meta.entrypoint, namespace) if _, err = r.client.CreateFunction(ctx, fnObj); err != nil { _ = r.client.DeletePackage(ctx, namespace, pkgName) resp.Diagnostics.AddError("Ошибка создания Function", err.Error()) return } // 5. Создать HTTPTrigger (только если задан url). triggerName := "" if url := plan.URL.ValueString(); url != "" { triggerName = plan.Name.ValueString() + "-trigger" methods := simpleMethods(ctx, plan.Methods) triggerObj := buildSimpleTrigger(triggerName, plan.Name.ValueString(), url, methods, namespace) if _, err = r.client.CreateHTTPTrigger(ctx, triggerObj); err != nil { _ = r.client.DeleteFunction(ctx, namespace, plan.Name.ValueString()) _ = r.client.DeletePackage(ctx, namespace, pkgName) resp.Diagnostics.AddError("Ошибка создания HTTPTrigger", err.Error()) return } } defaultMethods, _ := types.ListValueFrom(ctx, types.StringType, []string{"GET"}) methods := plan.Methods if methods.IsNull() || methods.IsUnknown() { methods = defaultMethods } state := simpleFunctionModel{ ID: types.StringValue(fmt.Sprintf("%s/%s", namespace, plan.Name.ValueString())), Name: plan.Name, Runtime: plan.Runtime, CodeDir: plan.CodeDir, URL: plan.URL, Methods: methods, Namespace: types.StringValue(namespace), EnvironmentName: types.StringValue(envName), PackageName: types.StringValue(pkgName), TriggerName: types.StringValue(triggerName), CodeHash: types.StringValue(codeHash), } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } // Read синхронизирует state с Kubernetes. func (r *SimpleFunctionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state simpleFunctionModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(state.Namespace, r.client.Namespace) // Основной ресурс — Function. if _, err := r.client.GetFunction(ctx, namespace, state.Name.ValueString()); err != nil { if client.IsNotFound(err) { resp.State.RemoveResource(ctx) return } resp.Diagnostics.AddError("Ошибка чтения Function", err.Error()) return } // Синхронизировать url/methods из реального состояния HTTPTrigger. if triggerName := state.TriggerName.ValueString(); triggerName != "" { triggerObj, err := r.client.GetHTTPTrigger(ctx, namespace, triggerName) if err != nil && !client.IsNotFound(err) { resp.Diagnostics.AddError("Ошибка чтения HTTPTrigger", err.Error()) return } if err == nil { if urlVal, _, _ := unstructured.NestedString(triggerObj.Object, "spec", "relativeurl"); urlVal != "" { state.URL = types.StringValue(urlVal) } if methodsVal, _, _ := unstructured.NestedStringSlice(triggerObj.Object, "spec", "methods"); len(methodsVal) > 0 { methodsList, _ := types.ListValueFrom(ctx, types.StringType, methodsVal) state.Methods = methodsList } } } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } // Update обновляет Package (если код изменился) и HTTPTrigger (если url/methods изменились). func (r *SimpleFunctionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan simpleFunctionModel var state simpleFunctionModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(plan.Namespace, r.client.Namespace) meta := runtimeRegistry[plan.Runtime.ValueString()] // Обновить Package, если изменился код. newCodeBytes, err := loadPackageContent(plan.CodeDir.ValueString(), "", meta.deployType) if err != nil { resp.Diagnostics.AddError("Ошибка чтения исходного кода", err.Error()) return } sum := sha256.Sum256(newCodeBytes) newHash := fmt.Sprintf("%x", sum) if newHash != state.CodeHash.ValueString() { pkgName := state.PackageName.ValueString() existingPkg, err := r.client.GetPackage(ctx, namespace, pkgName) if err != nil { resp.Diagnostics.AddError("Ошибка получения Package перед обновлением", err.Error()) return } newPkg := buildSimplePackage(pkgName, state.EnvironmentName.ValueString(), namespace, newCodeBytes, meta.deployType) newPkg.SetResourceVersion(existingPkg.GetResourceVersion()) if _, err = r.client.UpdatePackage(ctx, newPkg); err != nil { resp.Diagnostics.AddError("Ошибка обновления Package", err.Error()) return } } // Обновить HTTPTrigger при изменении url или methods. oldURL := state.URL.ValueString() newURL := plan.URL.ValueString() triggerName := state.TriggerName.ValueString() switch { case oldURL == "" && newURL != "": triggerName = plan.Name.ValueString() + "-trigger" methods := simpleMethods(ctx, plan.Methods) triggerObj := buildSimpleTrigger(triggerName, plan.Name.ValueString(), newURL, methods, namespace) if _, err = r.client.CreateHTTPTrigger(ctx, triggerObj); err != nil { resp.Diagnostics.AddError("Ошибка создания HTTPTrigger", err.Error()) return } case oldURL != "" && newURL == "": if triggerName != "" { if err := r.client.DeleteHTTPTrigger(ctx, namespace, triggerName); err != nil && !client.IsNotFound(err) { resp.Diagnostics.AddError("Ошибка удаления HTTPTrigger", err.Error()) return } } triggerName = "" case oldURL != "" && newURL != "": if triggerName == "" { triggerName = plan.Name.ValueString() + "-trigger" } methods := simpleMethods(ctx, plan.Methods) existing, err := r.client.GetHTTPTrigger(ctx, namespace, triggerName) if err != nil { resp.Diagnostics.AddError("Ошибка получения HTTPTrigger перед обновлением", err.Error()) return } newTrigger := buildSimpleTrigger(triggerName, plan.Name.ValueString(), newURL, methods, namespace) newTrigger.SetResourceVersion(existing.GetResourceVersion()) if _, err = r.client.UpdateHTTPTrigger(ctx, newTrigger); err != nil { resp.Diagnostics.AddError("Ошибка обновления HTTPTrigger", err.Error()) return } } defaultMethods, _ := types.ListValueFrom(ctx, types.StringType, []string{"GET"}) methods := plan.Methods if methods.IsNull() || methods.IsUnknown() { methods = defaultMethods } newState := state newState.CodeDir = plan.CodeDir newState.URL = plan.URL newState.Methods = methods newState.CodeHash = types.StringValue(newHash) newState.TriggerName = types.StringValue(triggerName) resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...) } // Delete удаляет HTTPTrigger, Function и Package (Environment не удаляется). func (r *SimpleFunctionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state simpleFunctionModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(state.Namespace, r.client.Namespace) if triggerName := state.TriggerName.ValueString(); triggerName != "" { if err := r.client.DeleteHTTPTrigger(ctx, namespace, triggerName); err != nil && !client.IsNotFound(err) { resp.Diagnostics.AddError("Ошибка удаления HTTPTrigger", err.Error()) return } } if err := r.client.DeleteFunction(ctx, namespace, state.Name.ValueString()); err != nil && !client.IsNotFound(err) { resp.Diagnostics.AddError("Ошибка удаления Function", err.Error()) return } if pkgName := state.PackageName.ValueString(); pkgName != "" { if err := r.client.DeletePackage(ctx, namespace, pkgName); err != nil && !client.IsNotFound(err) { resp.Diagnostics.AddError("Ошибка удаления Package", err.Error()) } } } // ImportState поддерживает импорт в формате namespace/name. func (r *SimpleFunctionResource) 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)...) } // --------------------------------------------------------------------------- // Вспомогательные функции // --------------------------------------------------------------------------- // findOrCreateSimpleEnv ищет environment simple--env; при отсутствии создаёт. func findOrCreateSimpleEnv(ctx context.Context, fissionClient *client.Client, namespace, runtime string, meta runtimeMeta) (string, error) { envName := fmt.Sprintf("simple-%s-env", runtime) _, err := fissionClient.GetEnvironment(ctx, namespace, envName) if err == nil { return envName, nil } if !client.IsNotFound(err) { return "", fmt.Errorf("проверка environment %q: %w", envName, err) } spec := map[string]interface{}{ "version": int64(2), "runtime": map[string]interface{}{ "image": meta.image, }, "poolsize": meta.poolSize, } if meta.builderImage != "" { spec["builder"] = map[string]interface{}{ "image": meta.builderImage, } } envObj := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "fission.io/v1", "kind": "Environment", "metadata": map[string]interface{}{ "name": envName, "namespace": namespace, }, "spec": spec, }} if _, err = fissionClient.CreateEnvironment(ctx, envObj); err != nil { return "", fmt.Errorf("создание environment %q: %w", envName, err) } return envName, nil } // buildSimplePackage формирует CRD объект Package с кодом в base64. func buildSimplePackage(pkgName, envName, namespace string, codeBytes []byte, deployType string) *unstructured.Unstructured { literalSource := base64.StdEncoding.EncodeToString(codeBytes) spec := map[string]interface{}{ "environment": map[string]interface{}{ "name": envName, "namespace": namespace, }, } if deployType == "source" { spec["source"] = map[string]interface{}{ "type": "literal", "literal": literalSource, } } else { spec["deployment"] = map[string]interface{}{ "type": "literal", "literal": literalSource, } } return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "fission.io/v1", "kind": "Package", "metadata": map[string]interface{}{ "name": pkgName, "namespace": namespace, }, "spec": spec, }} } // buildSimpleFunction формирует CRD объект Function. func buildSimpleFunction(name, envName, pkgName, entrypoint, namespace string) *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "fission.io/v1", "kind": "Function", "metadata": map[string]interface{}{ "name": name, "namespace": namespace, }, "spec": map[string]interface{}{ "environment": map[string]interface{}{ "name": envName, "namespace": namespace, }, "InvokeStrategy": map[string]interface{}{ "ExecutionStrategy": map[string]interface{}{ "ExecutorType": "poolmgr", }, "StrategyType": "execution", }, "package": map[string]interface{}{ "packageref": map[string]interface{}{ "name": pkgName, "namespace": namespace, }, "functionName": entrypoint, }, }, }} } // buildSimpleTrigger формирует CRD объект HTTPTrigger. func buildSimpleTrigger(triggerName, functionName, url string, methods []string, namespace string) *unstructured.Unstructured { if len(methods) == 0 { methods = []string{"GET"} } return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "fission.io/v1", "kind": "HTTPTrigger", "metadata": map[string]interface{}{ "name": triggerName, "namespace": namespace, }, "spec": map[string]interface{}{ "relativeurl": url, "methods": methods, "functionref": map[string]interface{}{ "type": "name", "name": functionName, }, "createingress": false, }, }} } // simpleMethods извлекает []string из types.List, возвращая ["GET"] по умолчанию. func simpleMethods(ctx context.Context, methodsList types.List) []string { if methodsList.IsNull() || methodsList.IsUnknown() || len(methodsList.Elements()) == 0 { return []string{"GET"} } var methods []string if diags := methodsList.ElementsAs(ctx, &methods, false); diags.HasError() { return []string{"GET"} } if len(methods) == 0 { return []string{"GET"} } return methods }