fix(provider): detect source code drift and validate package/function references

This commit is contained in:
Naeel
2026-04-15 10:51:40 +03:00
parent e2b6a4472e
commit 1b8bff8364
6 changed files with 252 additions and 0 deletions
+19
View File
@@ -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-слой валидации и обнаружения изменений.
@@ -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
}
@@ -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")
}
}
@@ -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)
}
@@ -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",
@@ -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
}