package resources import ( "archive/zip" "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "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/resource/schema/stringdefault" "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{} var _ resource.ResourceWithModifyPlan = &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"` DeployType types.String `tfsdk:"deploy_type"` 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: "Путь к директории с кодом (поддерживаются main.py, main.js, main.go, main.php, main.rb, main.pl).", }, "code_path": schema.StringAttribute{ Optional: true, Description: "Путь к готовому файлу исходника для literal deployment.", }, "code_hash": schema.StringAttribute{ Optional: true, Computed: true, Description: "Произвольный хеш кода для контроля изменений.", }, "build_command": schema.StringAttribute{ Optional: true, Description: "Команда сборки пакета в Fission.", }, "deploy_type": schema.StringAttribute{ Optional: true, Computed: true, Default: stringdefault.StaticString("literal"), Description: "Тип деплоя: 'literal' (default) — код в deployment.literal, 'source' — код в source.literal (для Go и языков с build step).", }, "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.", }, }, } } // ModifyPlan пересчитывает code_hash по локальному коду, чтобы terraform видел изменения source_dir/code_path. func (r *PackageResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { if req.Plan.Raw.IsNull() { return } var plan packageResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } var config packageResourceModel resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } if hasManualCodeHash(config.CodeHash) { return } if plan.SourceDir.IsUnknown() || plan.CodePath.IsUnknown() { return } if !validatePackageSource(plan.SourceDir, plan.CodePath, &resp.Diagnostics) { return } literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) return } plan.CodeHash = types.StringValue(calculateCodeHash(literalBytes)) resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...) } // 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 } if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil { resp.Diagnostics.AddError("Ошибка валидации Environment для Package", err.Error()) return } literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) return } if !hasManualCodeHash(plan.CodeHash) { plan.CodeHash = types.StringValue(calculateCodeHash(literalBytes)) } 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 } if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil { resp.Diagnostics.AddError("Ошибка валидации Environment для Package", err.Error()) return } literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString()) if err != nil { resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) return } if !hasManualCodeHash(plan.CodeHash) { plan.CodeHash = types.StringValue(calculateCodeHash(literalBytes)) } 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 для literal deployment (один файл). func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) { if sourceDir != "" { mainFilePath, err := resolveMainSourceFile(sourceDir) if err != nil { return nil, err } mainFileBytes, err := os.ReadFile(mainFilePath) if err != nil { return nil, fmt.Errorf("read source_dir source file %q: %w", mainFilePath, err) } // Node.js runtime ожидает ZIP с package.json + main.js (ESM wrapper). if filepath.Ext(mainFilePath) == ".js" { return buildJSDeployZip(string(mainFileBytes)) } 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 } // buildJSDeployZip wraps Node.js user code into a ZIP with package.json (ESM) + main.js wrapper. // This matches the format expected by ghcr.io/fission/node-env runtime. func buildJSDeployZip(code string) ([]byte, error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) pkgfw, err := zw.Create("package.json") if err != nil { return nil, err } if _, err := pkgfw.Write([]byte(`{"type":"module"}`)); err != nil { return nil, err } codeJSON, err := json.Marshal(code) if err != nil { return nil, fmt.Errorf("marshal user code: %w", err) } wrapper := fmt.Sprintf(`const __mod = { exports: {} }; (new Function('module', 'exports', %s))(__mod, __mod.exports); const _fn = __mod.exports; export default async function(ctx) { const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main); if (!fn) throw new Error('no exported function found in user code'); const result = await fn(ctx); if (!result) return { status: 200, body: '' }; if (typeof result.status !== 'undefined') return result; return { status: 200, ...result }; } `, string(codeJSON)) fw, err := zw.Create("main.js") if err != nil { return nil, err } if _, err := fw.Write([]byte(wrapper)); err != nil { return nil, err } if err := zw.Close(); err != nil { return nil, err } return buf.Bytes(), nil } // loadPackageSourceArchive создает zip-архив из source_dir для builder pipeline. func loadPackageSourceArchive(sourceDir string) ([]byte, error) { var buf bytes.Buffer zipWriter := zip.NewWriter(&buf) err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } relPath, err := filepath.Rel(sourceDir, path) if err != nil { return fmt.Errorf("compute relative path for %q: %w", path, err) } writer, err := zipWriter.Create(relPath) if err != nil { return fmt.Errorf("create zip entry %q: %w", relPath, err) } file, err := os.Open(path) if err != nil { return fmt.Errorf("open file %q: %w", path, err) } defer file.Close() _, err = io.Copy(writer, file) return err }) if err != nil { return nil, fmt.Errorf("zip source_dir %q: %w", sourceDir, err) } if err := zipWriter.Close(); err != nil { return nil, fmt.Errorf("close zip writer: %w", err) } return buf.Bytes(), nil } // loadPackageContent загружает содержимое пакета в зависимости от deploy_type. func loadPackageContent(sourceDir, codePath, deployType string) ([]byte, error) { if deployType == "source" && sourceDir != "" { return loadPackageSourceArchive(sourceDir) } return loadPackageLiteral(sourceDir, codePath) } // resolveMainSourceFile выбирает основной файл исходника из source_dir. func resolveMainSourceFile(sourceDir string) (string, error) { candidates := []string{"main.py", "main.js", "main.go", "main.php", "main.rb", "main.pl"} for _, candidate := range candidates { candidatePath := filepath.Join(sourceDir, candidate) fileInfo, err := os.Stat(candidatePath) if err == nil && !fileInfo.IsDir() { return candidatePath, nil } } return "", fmt.Errorf("source_dir %q must contain one of: main.py, main.js, main.go, main.php, main.rb, main.pl", sourceDir) } // packageToUnstructured преобразует Terraform model в Kubernetes CRD payload. func packageToUnstructured(model packageResourceModel, namespace string, literalBytes []byte) *unstructured.Unstructured { literalSource := base64.StdEncoding.EncodeToString(literalBytes) deployType := "literal" if !model.DeployType.IsNull() && !model.DeployType.IsUnknown() && model.DeployType.ValueString() != "" { deployType = model.DeployType.ValueString() } spec := map[string]interface{}{ "environment": map[string]interface{}{ "name": model.Environment.ValueString(), "namespace": namespace, }, } if deployType == "source" { // Source mode: код в spec.source (для builder pipeline — Go и др.) spec["source"] = map[string]interface{}{ "type": "literal", "literal": literalSource, } } else { // Literal/deployment mode: код в spec.deployment (Python, Node, PHP, Ruby, Perl) spec["deployment"] = map[string]interface{}{ "type": "literal", "literal": literalSource, } } if buildCommand := model.BuildCmd.ValueString(); buildCommand != "" { spec["buildcmd"] = buildCommand } object := map[string]interface{}{ "apiVersion": "fission.io/v1", "kind": "Package", "metadata": map[string]interface{}{ "name": model.Name.ValueString(), "namespace": namespace, }, "spec": spec, } 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") deploymentLiteral, _, _ := unstructured.NestedString(packageObject.Object, "spec", "deployment", "literal") sourceLiteral, _, _ := unstructured.NestedString(packageObject.Object, "spec", "source", "literal") 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, DeployType: base.DeployType, 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) } // Определить hash из содержимого (deployment или source) literalForHash := deploymentLiteral if literalForHash == "" { literalForHash = sourceLiteral } if literalForHash != "" { if literalBytes, err := base64.StdEncoding.DecodeString(literalForHash); err == nil { state.CodeHash = types.StringValue(calculateCodeHash(literalBytes)) } } return state } func hasManualCodeHash(codeHash types.String) bool { return !codeHash.IsNull() && !codeHash.IsUnknown() && codeHash.ValueString() != "" } func calculateCodeHash(literalBytes []byte) string { sum := sha256.Sum256(literalBytes) return fmt.Sprintf("%x", sum) }