package resources import ( "context" "encoding/base64" "fmt" "os" "path/filepath" "github.com/hashicorp/terraform-plugin-framework/diag" "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 = &PackageResource{} var _ resource.ResourceWithImportState = &PackageResource{} // Изменено: 2026-04-14 19:45 UTC. // Resource для управления Fission Package через Kubernetes CRD API. type PackageResource struct { client *client.Client } // packageResourceModel описывает состояние terraform ресурса fission_package. type packageResourceModel struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` Environment types.String `tfsdk:"environment"` SourceDir types.String `tfsdk:"source_dir"` CodePath types.String `tfsdk:"code_path"` CodeHash types.String `tfsdk:"code_hash"` BuildCmd types.String `tfsdk:"build_command"` Namespace types.String `tfsdk:"namespace"` UID types.String `tfsdk:"uid"` BuildStatus types.String `tfsdk:"build_status"` BuildLog types.String `tfsdk:"build_log"` } // NewPackageResource создает инстанс ресурса пакета. func NewPackageResource() resource.Resource { return &PackageResource{} } // Metadata задает имя ресурса fission_package. func (r *PackageResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_package" } // Schema задает Terraform schema для Package. func (r *PackageResource) 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 Package.", }, "environment": schema.StringAttribute{ Required: true, Description: "Имя Fission Environment, связанного с пакетом.", }, "source_dir": schema.StringAttribute{ Optional: true, Description: "Путь к директории с кодом, которая будет упакована в zip.", }, "code_path": schema.StringAttribute{ Optional: true, Description: "Путь к готовому zip-архиву с кодом.", }, "code_hash": schema.StringAttribute{ Optional: true, Description: "Произвольный хеш кода для контроля изменений.", }, "build_command": schema.StringAttribute{ Optional: true, Description: "Команда сборки пакета в Fission.", }, "namespace": schema.StringAttribute{ Optional: true, Computed: true, Description: "Namespace, где создается Package. По умолчанию используется provider namespace.", }, "uid": schema.StringAttribute{ Computed: true, Description: "UID Kubernetes объекта Package.", }, "build_status": schema.StringAttribute{ Computed: true, Description: "Текущий статус сборки пакета из поля status.buildstatus.", }, "build_log": schema.StringAttribute{ Computed: true, Description: "Лог сборки из поля status.buildlog.", }, }, } } // Configure получает клиент из provider.Configure(). func (r *PackageResource) 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 создает новый Package CRD объект. func (r *PackageResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan packageResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(plan.Namespace, r.client.Namespace) if !validatePackageSource(plan.SourceDir, plan.CodePath, &resp.Diagnostics) { return } literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) return } packageObject := packageToUnstructured(plan, namespace, literalBytes) createdPackage, err := r.client.CreatePackage(ctx, packageObject) if err != nil { resp.Diagnostics.AddError("Ошибка создания Fission Package", err.Error()) return } state := unstructuredToPackageModel(createdPackage, plan) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } // Read синхронизирует Terraform state с текущим состоянием Package CRD. func (r *PackageResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state packageResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(state.Namespace, r.client.Namespace) packageObject, err := r.client.GetPackage(ctx, namespace, state.Name.ValueString()) if err != nil { if client.IsNotFound(err) { resp.State.RemoveResource(ctx) return } resp.Diagnostics.AddError("Ошибка чтения Fission Package", err.Error()) return } updatedState := unstructuredToPackageModel(packageObject, state) resp.Diagnostics.Append(resp.State.Set(ctx, &updatedState)...) } // Update обновляет существующий Package CRD объект. func (r *PackageResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan packageResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(plan.Namespace, r.client.Namespace) if !validatePackageSource(plan.SourceDir, plan.CodePath, &resp.Diagnostics) { return } literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) return } existingPackage, err := r.client.GetPackage(ctx, namespace, plan.Name.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка получения Package перед обновлением", err.Error()) return } packageObject := packageToUnstructured(plan, namespace, literalBytes) packageObject.SetResourceVersion(existingPackage.GetResourceVersion()) updatedPackage, err := r.client.UpdatePackage(ctx, packageObject) if err != nil { resp.Diagnostics.AddError("Ошибка обновления Fission Package", err.Error()) return } state := unstructuredToPackageModel(updatedPackage, plan) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } // Delete удаляет Package CRD объект. func (r *PackageResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state packageResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } namespace := resolveNamespace(state.Namespace, r.client.Namespace) err := r.client.DeletePackage(ctx, namespace, state.Name.ValueString()) if err != nil && !client.IsNotFound(err) { resp.Diagnostics.AddError("Ошибка удаления Fission Package", err.Error()) } } // ImportState поддерживает импорт ресурса в формате namespace/name. func (r *PackageResource) 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)...) } // validatePackageSource проверяет, что задан ровно один источник кода: source_dir или code_path. func validatePackageSource(sourceDir, codePath types.String, diagnostics *diag.Diagnostics) bool { hasSourceDir := !sourceDir.IsNull() && !sourceDir.IsUnknown() && sourceDir.ValueString() != "" hasCodePath := !codePath.IsNull() && !codePath.IsUnknown() && codePath.ValueString() != "" if hasSourceDir == hasCodePath { diagnostics.AddError( "Некорректная конфигурация источника кода", "Нужно указать ровно один параметр: source_dir или code_path.", ) return false } return true } // resolveNamespace возвращает namespace ресурса или namespace из provider config. func resolveNamespace(resourceNamespace types.String, providerNamespace string) string { namespace := resourceNamespace.ValueString() if namespace == "" { namespace = providerNamespace } return namespace } // loadPackageLiteral читает bytes для spec.deployment.literal. func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) { if sourceDir != "" { mainFilePath := filepath.Join(sourceDir, "main.py") mainFileBytes, err := os.ReadFile(mainFilePath) if err != nil { return nil, fmt.Errorf("read source_dir main.py %q: %w", mainFilePath, err) } return mainFileBytes, nil } literalBytes, err := os.ReadFile(codePath) if err != nil { return nil, fmt.Errorf("read code_path %q: %w", codePath, err) } return literalBytes, nil } // packageToUnstructured преобразует Terraform model в Kubernetes CRD payload. func packageToUnstructured(model packageResourceModel, namespace string, literalBytes []byte) *unstructured.Unstructured { literalSource := base64.StdEncoding.EncodeToString(literalBytes) object := map[string]interface{}{ "apiVersion": "fission.io/v1", "kind": "Package", "metadata": map[string]interface{}{ "name": model.Name.ValueString(), "namespace": namespace, }, "spec": map[string]interface{}{ "deployment": map[string]interface{}{ "type": "literal", "literal": literalSource, }, "environment": map[string]interface{}{ "name": model.Environment.ValueString(), "namespace": namespace, }, "source": map[string]interface{}{}, }, } if buildCommand := model.BuildCmd.ValueString(); buildCommand != "" { _ = unstructured.SetNestedField(object, buildCommand, "spec", "buildcmd") } return &unstructured.Unstructured{Object: object} } // unstructuredToPackageModel читает нужные поля из CRD объекта обратно в Terraform state. func unstructuredToPackageModel(packageObject *unstructured.Unstructured, base packageResourceModel) packageResourceModel { environmentName, _, _ := unstructured.NestedString(packageObject.Object, "spec", "environment", "name") buildCommand, _, _ := unstructured.NestedString(packageObject.Object, "spec", "buildcmd") buildStatus, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildstatus") buildLog, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildlog") state := packageResourceModel{ ID: types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())), Name: types.StringValue(packageObject.GetName()), Environment: base.Environment, SourceDir: base.SourceDir, CodePath: base.CodePath, CodeHash: base.CodeHash, BuildCmd: base.BuildCmd, Namespace: types.StringValue(packageObject.GetNamespace()), UID: types.StringValue(string(packageObject.GetUID())), BuildStatus: types.StringNull(), BuildLog: types.StringNull(), } if environmentName != "" { state.Environment = types.StringValue(environmentName) } if buildCommand != "" { state.BuildCmd = types.StringValue(buildCommand) } if buildStatus != "" { state.BuildStatus = types.StringValue(buildStatus) } if buildLog != "" { state.BuildLog = types.StringValue(buildLog) } return state }