From 1b8bff836465e0410fe4410f26900dacfebb9527 Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 15 Apr 2026 10:51:40 +0300 Subject: [PATCH] fix(provider): detect source code drift and validate package/function references --- doc/progress.md | 19 +++++ .../internal/resources/function_resource.go | 78 +++++++++++++++++++ .../resources/function_resource_test.go | 38 +++++++++ .../internal/resources/package_resource.go | 77 ++++++++++++++++++ .../resources/package_resource_test.go | 12 +++ .../internal/resources/validation_helpers.go | 28 +++++++ 6 files changed, 252 insertions(+) create mode 100644 terraform/provider/internal/resources/validation_helpers.go diff --git a/doc/progress.md b/doc/progress.md index 674f1a4..b5eb1c3 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -266,3 +266,22 @@ - UI: автоматическое обновление дашборда после create/delete - Рассмотреть добавление логов функций (kubectl logs) - Рассмотреть добавление time triggers в UI + +--- + +## 2026-04-15 (дополнение) — Fix по результатам баг-репорта + +### Что исправлено в Terraform provider +- `fission_package`: добавлен `ModifyPlan`, который автоматически пересчитывает `code_hash` по локальному коду (`source_dir`/`code_path`). +- `fission_package`: добавлена валидация существования `environment` до создания/обновления package. +- `fission_function`: добавлена валидация существования `environment` и `package` до create/update. +- `fission_function`: добавлена pre-flight валидация `entrypoint` для Python-исходника (`main.func` должен существовать как `def func(`). +- Добавлены unit-тесты на новый функционал (`code_hash`, entrypoint validation). + +### Проверка на живом кластере +- После изменения `examples/hello-python/code/main.py` `terraform plan` теперь показывает `fission_package.hello will be updated in-place` с изменением `code_hash`. +- Конфигурация с несуществующим `environment` теперь падает на этапе apply с ошибкой валидации. +- Конфигурация с неверным `entrypoint` теперь падает на этапе apply с ошибкой валидации. + +### Ограничения +- Runtime-проблемы Fission (зависания на cold start/таймауты выполнения) в этом изменении не трогались: исправлен только provider-слой валидации и обнаружения изменений. diff --git a/terraform/provider/internal/resources/function_resource.go b/terraform/provider/internal/resources/function_resource.go index 952067b..33fac2c 100644 --- a/terraform/provider/internal/resources/function_resource.go +++ b/terraform/provider/internal/resources/function_resource.go @@ -2,7 +2,9 @@ package resources import ( "context" + "encoding/base64" "fmt" + "strings" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -107,6 +109,22 @@ func (r *FunctionResource) Create(ctx context.Context, req resource.CreateReques } 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) @@ -152,6 +170,22 @@ func (r *FunctionResource) Update(ctx context.Context, req resource.UpdateReques } 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()) @@ -254,3 +288,47 @@ func unstructuredToFunctionModel(functionObject *unstructured.Unstructured, base return state } + +func validateEntrypointAgainstPackageSource(entrypoint string, pkg *unstructured.Unstructured) error { + parts := strings.Split(entrypoint, ".") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("entrypoint %q должен иметь формат module.function", entrypoint) + } + + 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 +} diff --git a/terraform/provider/internal/resources/function_resource_test.go b/terraform/provider/internal/resources/function_resource_test.go index 9256cfd..0996d61 100644 --- a/terraform/provider/internal/resources/function_resource_test.go +++ b/terraform/provider/internal/resources/function_resource_test.go @@ -1,6 +1,7 @@ package resources import ( + "encoding/base64" "testing" "github.com/hashicorp/terraform-plugin-framework/types" @@ -36,3 +37,40 @@ func TestFunctionToUnstructuredAndBack(t *testing.T) { t.Fatalf("InvokeStrategy not set correctly") } } + +func TestValidateEntrypointAgainstPackageSourcePythonOK(t *testing.T) { + source := "def main():\n return 'ok'\n" + pkg := &unstructured.Unstructured{Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "deployment": map[string]interface{}{ + "literal": base64.StdEncoding.EncodeToString([]byte(source)), + }, + }, + }} + + if err := validateEntrypointAgainstPackageSource("main.main", pkg); err != nil { + t.Fatalf("expected valid entrypoint, got error: %v", err) + } +} + +func TestValidateEntrypointAgainstPackageSourcePythonMissing(t *testing.T) { + source := "def another():\n return 'ok'\n" + pkg := &unstructured.Unstructured{Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "deployment": map[string]interface{}{ + "literal": base64.StdEncoding.EncodeToString([]byte(source)), + }, + }, + }} + + if err := validateEntrypointAgainstPackageSource("main.main", pkg); err == nil { + t.Fatalf("expected validation error for missing python function") + } +} + +func TestValidateEntrypointAgainstPackageSourceBadFormat(t *testing.T) { + pkg := &unstructured.Unstructured{} + if err := validateEntrypointAgainstPackageSource("main", pkg); err == nil { + t.Fatalf("expected validation error for bad entrypoint format") + } +} diff --git a/terraform/provider/internal/resources/package_resource.go b/terraform/provider/internal/resources/package_resource.go index 5744a85..8a27051 100644 --- a/terraform/provider/internal/resources/package_resource.go +++ b/terraform/provider/internal/resources/package_resource.go @@ -2,6 +2,7 @@ package resources import ( "context" + "crypto/sha256" "encoding/base64" "fmt" "os" @@ -19,6 +20,7 @@ import ( var _ resource.Resource = &PackageResource{} var _ resource.ResourceWithImportState = &PackageResource{} +var _ resource.ResourceWithModifyPlan = &PackageResource{} // Изменено: 2026-04-14 19:45 UTC. // Resource для управления Fission Package через Kubernetes CRD API. @@ -77,6 +79,7 @@ func (r *PackageResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, "code_hash": schema.StringAttribute{ Optional: true, + Computed: true, Description: "Произвольный хеш кода для контроля изменений.", }, "build_command": schema.StringAttribute{ @@ -104,6 +107,46 @@ func (r *PackageResource) Schema(_ context.Context, _ resource.SchemaRequest, re } } +// 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 := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.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 { @@ -135,12 +178,21 @@ func (r *PackageResource) Create(ctx context.Context, req resource.CreateRequest return } + if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil { + resp.Diagnostics.AddError("Ошибка валидации Environment для Package", err.Error()) + return + } + literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.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 { @@ -189,12 +241,21 @@ func (r *PackageResource) Update(ctx context.Context, req resource.UpdateRequest return } + if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil { + resp.Diagnostics.AddError("Ошибка валидации Environment для Package", err.Error()) + return + } + literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.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()) @@ -343,6 +404,7 @@ func unstructuredToPackageModel(packageObject *unstructured.Unstructured, base p 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") state := packageResourceModel{ ID: types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())), @@ -371,5 +433,20 @@ func unstructuredToPackageModel(packageObject *unstructured.Unstructured, base p state.BuildLog = types.StringValue(buildLog) } + if deploymentLiteral != "" { + if literalBytes, err := base64.StdEncoding.DecodeString(deploymentLiteral); 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) +} diff --git a/terraform/provider/internal/resources/package_resource_test.go b/terraform/provider/internal/resources/package_resource_test.go index 33d8d5d..1ccd804 100644 --- a/terraform/provider/internal/resources/package_resource_test.go +++ b/terraform/provider/internal/resources/package_resource_test.go @@ -2,7 +2,9 @@ package resources import ( "context" + "crypto/sha256" "encoding/base64" + "fmt" "os" "path/filepath" "testing" @@ -113,6 +115,16 @@ func TestResolveNamespace(t *testing.T) { } } +func TestCalculateCodeHash(t *testing.T) { + input := []byte("def main():\n return 'ok'\n") + got := calculateCodeHash(input) + + expected := fmt.Sprintf("%x", sha256.Sum256(input)) + if got != expected { + t.Fatalf("unexpected code hash: got %q want %q", got, expected) + } +} + func TestUnstructuredToPackageModelSetsNullComputed(t *testing.T) { obj := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "fission.io/v1", diff --git a/terraform/provider/internal/resources/validation_helpers.go b/terraform/provider/internal/resources/validation_helpers.go new file mode 100644 index 0000000..da0d1d3 --- /dev/null +++ b/terraform/provider/internal/resources/validation_helpers.go @@ -0,0 +1,28 @@ +package resources + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "terraform-provider-fission/internal/client" +) + +func ensureEnvironmentExists(ctx context.Context, fissionClient *client.Client, namespace, name string) error { + _, err := fissionClient.GetEnvironment(ctx, namespace, name) + if err != nil { + return fmt.Errorf("environment %q не найден в namespace %q: %w", name, namespace, err) + } + + return nil +} + +func ensurePackageExists(ctx context.Context, fissionClient *client.Client, namespace, name string) (*unstructured.Unstructured, error) { + pkg, err := fissionClient.GetPackage(ctx, namespace, name) + if err != nil { + return nil, fmt.Errorf("package %q не найден в namespace %q: %w", name, namespace, err) + } + + return pkg, nil +}