package resources import ( "archive/zip" "bytes" "context" "encoding/base64" "fmt" "io" "io/fs" "os" "path/filepath" "github.com/hashicorp/terraform-plugin-framework/diag" "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{} // Изменено: 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 } archiveBytes, err := loadPackageArchive(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) return } packageObject := packageToUnstructured(plan, namespace, archiveBytes) 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 } archiveBytes, err := loadPackageArchive(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, archiveBytes) 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()) } } // 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 } // loadPackageArchive читает zip из code_path или собирает zip из source_dir. func loadPackageArchive(sourceDir, codePath string) ([]byte, error) { if sourceDir != "" { return zipDirectory(sourceDir) } archiveBytes, err := os.ReadFile(codePath) if err != nil { return nil, fmt.Errorf("read code_path %q: %w", codePath, err) } return archiveBytes, nil } // zipDirectory собирает zip-архив из директории для передачи в spec.source.literal. func zipDirectory(sourceDir string) ([]byte, error) { buffer := &bytes.Buffer{} zipWriter := zip.NewWriter(buffer) walkError := filepath.WalkDir(sourceDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() { return nil } relativePath, err := filepath.Rel(sourceDir, path) if err != nil { return fmt.Errorf("build relative path for %q: %w", path, err) } zipPath := filepath.ToSlash(relativePath) fileWriter, err := zipWriter.Create(zipPath) if err != nil { return fmt.Errorf("create zip entry %q: %w", zipPath, err) } fileHandle, err := os.Open(path) if err != nil { return fmt.Errorf("open source file %q: %w", path, err) } if _, err = io.Copy(fileWriter, fileHandle); err != nil { _ = fileHandle.Close() return fmt.Errorf("write zip entry %q: %w", zipPath, err) } if err = fileHandle.Close(); err != nil { return fmt.Errorf("close source file %q: %w", path, err) } return nil }) if walkError != nil { _ = zipWriter.Close() return nil, fmt.Errorf("zip source_dir %q: %w", sourceDir, walkError) } if err := zipWriter.Close(); err != nil { return nil, fmt.Errorf("finalize zip archive: %w", err) } return buffer.Bytes(), nil } // packageToUnstructured преобразует Terraform model в Kubernetes CRD payload. func packageToUnstructured(model packageResourceModel, namespace string, archiveBytes []byte) *unstructured.Unstructured { literalSource := base64.StdEncoding.EncodeToString(archiveBytes) object := map[string]interface{}{ "apiVersion": "fission.io/v1", "kind": "Package", "metadata": map[string]interface{}{ "name": model.Name.ValueString(), "namespace": namespace, }, "spec": map[string]interface{}{ "environment": map[string]interface{}{ "name": model.Environment.ValueString(), "namespace": namespace, }, "source": map[string]interface{}{ "type": "literal", "literal": literalSource, }, }, } 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 := base state.Name = types.StringValue(packageObject.GetName()) state.Namespace = types.StringValue(packageObject.GetNamespace()) state.ID = types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())) state.UID = types.StringValue(string(packageObject.GetUID())) 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 }