diff --git a/doc/thinking/2026-04-20.md b/doc/thinking/2026-04-20.md new file mode 100644 index 0000000..3990aaf --- /dev/null +++ b/doc/thinking/2026-04-20.md @@ -0,0 +1,85 @@ +# 2026-04-20 — fission_simple_function + +## Задача + +Добавить составной ресурс `fission_simple_function`, который позволяет описать serverless-функцию одним HCL-блоком вместо четырёх отдельных ресурсов. + +## Мотивация + +До: пользователь обязан вручную объявлять `fission_environment` + `fission_package` + `fission_function` + `fission_http_trigger`. + +После: достаточно одного блока: + +```hcl +resource "fission_simple_function" "health" { + name = "health" + runtime = "nodejs" + code_dir = "./code/health" + url = "/ecom/health" + methods = ["GET"] +} +``` + +## Реализация + +**Файл:** `terraform/provider/internal/resources/simple_function_resource.go` + +### Схема ресурса + +| Атрибут | Тип | Поведение | +|---------|-----|-----------| +| `name` | required string | RequiresReplace | +| `runtime` | required string | RequiresReplace; допустимые: nodejs, python, go, ruby, php, perl | +| `code_dir` | required string | путь к директории с исходниками | +| `url` | optional string | если задан — создаётся HTTPTrigger | +| `methods` | optional+computed list(string) | дефолт `["GET"]` | +| `namespace` | optional+computed string | дефолт из провайдера | +| `environment_name` | computed string | имя созданного/найденного env | +| `package_name` | computed string | `-pkg` | +| `trigger_name` | computed string | `-trigger` или "" | +| `code_hash` | computed string | SHA-256 кода из code_dir | + +### Логика Create + +1. `findOrCreateSimpleEnv` — ищет `simple--env`; если нет — создаёт с параметрами из `runtimeRegistry`. +2. `loadPackageContent(code_dir, "", deployType)` — загружает код (JS получает ESM zip, Go — source zip, остальные — literal bytes). +3. `CreatePackage` → `CreateFunction` → `CreateHTTPTrigger` (опционально). +4. Rollback при ошибке: если Function не создалась — удаляет Package; если Trigger — удаляет Function + Package. + +### runtimeRegistry + +| Runtime | deployType | entrypoint | builderImage | +|---------|-----------|------------|-------------| +| nodejs | literal | "" (default export) | — | +| python | literal | main.main | — | +| go | source | Handler | ghcr.io/fission/go-builder:latest | +| ruby | literal | main.main | — | +| php | literal | main.main | — | +| perl | literal | main.main | — | + +### ModifyPlan + +Пересчитывает `code_hash` при каждом plan, чтобы Terraform обнаруживал изменения кода без force-replace. + +### Update + +- Если `code_hash` изменился → `UpdatePackage` (patch content, preserve resourceVersion). +- URL добавлен/удалён → `CreateHTTPTrigger` / `DeleteHTTPTrigger`. +- URL изменился → `UpdateHTTPTrigger`. + +### Delete + +Порядок: HTTPTrigger → Function → Package. Environment намеренно НЕ удаляется (shared). + +### Import + +Формат: `namespace/name`. После import читается из Kubernetes через `Read`. + +## Регистрация + +`terraform/provider/internal/provider/provider.go` — добавлено `resources.NewSimpleFunctionResource` в `Resources()`. + +## Статус + +- [x] Реализация написана и скомпилирована (`go build ./...` — OK) +- [ ] Тест на real cluster diff --git a/examples/big-suite/code/go-processor/main.go b/examples/big-suite/code/go-processor/main.go new file mode 100644 index 0000000..3ef472b --- /dev/null +++ b/examples/big-suite/code/go-processor/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "net/http" +) + +// Handler — точка входа для fission go-env. +func Handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"processed"}`)) +} diff --git a/examples/big-suite/code/python-metrics/main.py b/examples/big-suite/code/python-metrics/main.py new file mode 100644 index 0000000..340beca --- /dev/null +++ b/examples/big-suite/code/python-metrics/main.py @@ -0,0 +1,2 @@ +def main(): + return {"status": "ok", "metrics": {}} diff --git a/examples/big-suite/simple_functions.tf b/examples/big-suite/simple_functions.tf new file mode 100644 index 0000000..dec31dc --- /dev/null +++ b/examples/big-suite/simple_functions.tf @@ -0,0 +1,34 @@ +# ────────────────────────────────────────────────────────────────────────────── +# simple_functions.tf — примеры fission_simple_function (добавлено 2026-04-20) +# +# Демонстрирует использование нового составного ресурса, который заменяет +# отдельные fission_environment + fission_package + fission_function + fission_http_trigger +# одним блоком. +# +# Environment simple--env создаётся автоматически при первом apply. +# ────────────────────────────────────────────────────────────────────────────── + +# ─── health-check (nodejs, с HTTPTrigger) ──────────────────────────────────── +resource "fission_simple_function" "health" { + name = "ecom-health" + runtime = "nodejs" + code_dir = "${path.module}/code/node-health" + url = "/ecom/health" + methods = ["GET"] +} + +# ─── metrics (python, без HTTPTrigger) ─────────────────────────────────────── +resource "fission_simple_function" "metrics" { + name = "ecom-metrics" + runtime = "python" + code_dir = "${path.module}/code/python-metrics" +} + +# ─── go-processor (go, с HTTPTrigger, PUT/POST) ─────────────────────────────── +resource "fission_simple_function" "processor" { + name = "ecom-processor" + runtime = "go" + code_dir = "${path.module}/code/go-processor" + url = "/ecom/process" + methods = ["POST", "PUT"] +} diff --git a/terraform/provider/internal/provider/provider.go b/terraform/provider/internal/provider/provider.go index eae0069..02797cc 100644 --- a/terraform/provider/internal/provider/provider.go +++ b/terraform/provider/internal/provider/provider.go @@ -98,6 +98,7 @@ func (p *FissionProvider) Resources(_ context.Context) []func() resource.Resourc resources.NewPackageResource, resources.NewFunctionResource, resources.NewHTTPTriggerResource, + resources.NewSimpleFunctionResource, } } diff --git a/terraform/provider/internal/resources/simple_function_resource.go b/terraform/provider/internal/resources/simple_function_resource.go new file mode 100644 index 0000000..dba42de --- /dev/null +++ b/terraform/provider/internal/resources/simple_function_resource.go @@ -0,0 +1,660 @@ +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: "ghcr.io/fission/go-builder:latest", + 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 +}