Files
fission-console/terraform/provider/internal/resources/simple_function_resource.go
T

661 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-<runtime>-env, если не найден — создаёт.
// 2. Создаёт Package (<name>-pkg) с кодом из code_dir.
// 3. Создаёт Function (<name>).
// 4. Создаёт HTTPTrigger (<name>-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-<runtime>-env).",
},
"package_name": schema.StringAttribute{
Computed: true,
Description: "Имя созданного Package (<name>-pkg).",
},
"trigger_name": schema.StringAttribute{
Computed: true,
Description: "Имя созданного HTTPTrigger (<name>-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-<runtime>-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
}