- PHP: ghcr.io/fission/php-env, entrypoint main.php::handler
- Ruby: ghcr.io/fission/ruby-env, entrypoint handler (single-word)
- Perl: ghcr.io/fission/perl-env (version=1), sub {} return value
- Go: ghcr.io/fission/binary-env with pre-compiled binary (shell script placeholder)
Provider changes:
- package_resource: added main.php, main.rb, main.pl to source_dir candidates
- function_resource: relaxed entrypoint validation to allow PHP (::), Ruby, Perl (single-word)
- function_resource_test: updated BadFormat test to reflect new valid formats
342 lines
12 KiB
Go
342 lines
12 KiB
Go
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/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"`
|
||
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).",
|
||
},
|
||
"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 {
|
||
return &unstructured.Unstructured{Object: map[string]interface{}{
|
||
"apiVersion": "fission.io/v1",
|
||
"kind": "Function",
|
||
"metadata": map[string]interface{}{
|
||
"name": model.Name.ValueString(),
|
||
"namespace": namespace,
|
||
},
|
||
"spec": map[string]interface{}{
|
||
"environment": map[string]interface{}{
|
||
"name": model.Environment.ValueString(),
|
||
"namespace": namespace,
|
||
},
|
||
"InvokeStrategy": map[string]interface{}{
|
||
"ExecutionStrategy": map[string]interface{}{
|
||
"ExecutorType": "poolmgr",
|
||
},
|
||
"StrategyType": "execution",
|
||
},
|
||
"package": map[string]interface{}{
|
||
"packageref": map[string]interface{}{
|
||
"name": model.PackageName.ValueString(),
|
||
"namespace": namespace,
|
||
},
|
||
"functionName": model.Entrypoint.ValueString(),
|
||
},
|
||
},
|
||
}}
|
||
}
|
||
|
||
// 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")
|
||
|
||
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)
|
||
}
|
||
|
||
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
|
||
}
|