Compare commits

..
3 Commits
7 changed files with 490 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-слой валидации и обнаружения изменений.
+238
View File
@@ -0,0 +1,238 @@
# Bug Report: Fission + Terraform Integration Testing (2026-04-15)
## Executive Summary
- **34 functions deployed**, **17 working correctly**
- **6 CRITICAL/HIGH bugs identified**
- **3 limitations/quirks documented**
---
## 🔴 CRITICAL BUGS
### BUG #1: Hanging on cold start with syntax errors
**Severity:** CRITICAL
**Scope:** Fission runtime
**Symptoms:**
- Function with syntax error in `main.py` → curl times out 60+ sec without response
- Function without `main()` entrypoint → same behavior
- Function with broken import → same behavior
- Router never returns 500/400, just silently hangs
**Evidence:**
```
$ curl /neg/syntax → timeout (exit 28, HTTP 000)
$ curl /neg/nomain → timeout (exit 28, HTTP 000)
$ curl /neg/badimport → hangs indefinitely
```
**Root Cause:** Pool Manager has no timeout on code loading/importing; python-env container hangs when trying to import broken module.
**Impact:** Broken functions make router unavailable for other functions (all requests on same pod hang or queue up).
---
### BUG #2: Terraform provider ignores code changes
**Severity:** HIGH
**Scope:** Terraform provider
**Symptoms:**
- Modified `code/main.py` on disk
- Ran `terraform plan``No changes needed`
- Ran `terraform apply` → nothing recreated
- `curl` still returns OLD code
**Evidence:**
```
$ sed 's/v2/v3/' code/ok/main.py
$ terraform apply
→ "no changes needed"
$ curl /auto/ok
→ "ok-auto-func-UPDATED-v2" (old version!)
```
**Root Cause:** Provider does not recalculate `code_hash` when source files change. Likely uses mtime check incorrectly or doesn't hash at all.
**Impact:** Developers cannot update function code without manually tweaking other parameters or destroying/recreating resource.
**Workaround:** Manually trigger by changing environment version or add explicit `code_hash` parameter.
---
### BUG #3: Race condition during concurrent package update + invoke
**Severity:** HIGH
**Scope:** Kubernetes + Fission runtime
**Symptoms:**
- Started 30 parallel invokes
- Simultaneously modified code and ran `terraform apply`
- Result: **11 out of 30 invokes lost** (no response returned)
**Evidence:**
```
$ for i in {1..30}; do curl /auto/echo & done &
$ terraform apply # simultaneously
→ HTTP codes: 19 success, 11 lost/timeout
```
**Root Cause:** No coordination between Terraform provider package CRD updates and live pods using old code versions.
**Impact:** Request loss (503/timeout), potential data loss.
---
### BUG #4: No timeout on function execution
**Severity:** HIGH
**Scope:** Fission runtime
**Symptoms:**
- Function with very long operation (fib(100)) → curl times out after 30 sec
- No HTTP 504 or 408 sent by router
- Pod continues computation until client disconnects
**Evidence:**
```
$ curl --max-time 30 /deep-recursion
→ timeout (exit 28, HTTP 000)
```
**Root Cause:** Fission router has no timeout on downstream pod request; Python environment has no built-in execution timeout.
**Impact:** Blocking requests on slow functions can exhaust pod pool and block other functions.
---
### BUG #5: No foreign key validation on deploy
**Severity:** MEDIUM
**Scope:** Terraform provider + Fission CRD validation
**Symptoms:**
- Created package/function referencing non-existent environment
- Terraform applied successfully
- Function only fails at invoke time (too late)
**Evidence:**
```
$ tf apply (package references "nonexistent-env")
→ Apply complete! Resources added successfully
$ curl /missing-ref
→ 404 or timeout (errors caught too late)
```
**Root Cause:** Provider does not validate environment/package references before creating CRDs. K8s CRD accepts any string value.
**Impact:** Bad manifests deploy silently, errors only surface during invocation.
---
### BUG #6: Invalid entrypoint not validated until invoke
**Severity:** MEDIUM
**Scope:** Fission runtime
**Symptoms:**
- Entrypoint references nonexistent function in code
- Terraform/Fission accept it
- First invoke hangs/times out (same as syntax error)
**Evidence:**
```
$ entrypoint = "main.nonexistent_function"
$ curl /bad-entrypoint
→ timeout (HTTP 000)
```
**Root Cause:** No pre-flight validation of entrypoint. Only caught during cold start import.
**Impact:** Same as БАГ #1 — hangs entire pod until timeout.
---
### Limitation #1: Upload payload size limit
**Severity:** MEDIUM
**Symptoms:** Uploading ~1MB+ payload to function endpoint hangs connection
**Evidence:**
```
$ dd if=/dev/zero bs=1M count=1 | curl --data-binary @- /auto/ok
→ timeout
```
**Root Cause:** Likely nginx ingress `client_max_body_size` limit (default ~1MB).
**Impact:** Cannot send large payloads to functions via HTTP.
---
### Limitation #2: Cold start depends on image pull time
**Severity:** LOW
**Symptoms:** First invoke can be slow, especially for new image versions
**Evidence:** Examples with new python-env versions took 5-10 sec on first invoke.
---
### Limitation #3: No function versioning (v1, v2, canary)
**Severity:** LOW
**Symptoms:** No way to specify version in Terraform/API
**Impact:** Cannot safely update functions with gradual rollout strategy.
---
## ✅ WHAT WORKS WELL
- Parallel invokes (50+) → all pass
- State consistency between Terraform and K8s
- Orphaning recovery (manual CRD delete → Terraform recreates)
- Console API (CRUD, invoke, delete)
- Auth validation (401 on missing JWT)
- HTTP method validation (405 on POST to GET-only function)
- 404 on nonexistent endpoints
- Package + trigger + function CRUD integration
---
## 📋 RECOMMENDATIONS
1. **CRITICAL:** Add execution timeout in router (~60 sec default, configurable)
2. **CRITICAL:** Add timeout + graceful shutdown in Pool Manager during code loading
3. **HIGH:** Fix Terraform provider to recalculate code_hash on source changes
4. **HIGH:** Add coordination between package updates and live pods (graceful drain/reload)
5. **HIGH:** Add foreign key validation (environment/package references must exist)
6. **HIGH:** Add entrypoint validation during deploy (check function exists in code)
7. **MEDIUM:** Document payload size limits and how to adjust
8. **MEDIUM:** Add pre-flight code validation (syntax check) on deploy
9. **LOW:** Implement function versioning/canary deployment support
---
## 📊 TESTING STATISTICS
- Functions deployed: **34**
- Working correctly (5 sec response): **17**
- Hanging indefinitely: **4** (syntax-error, no-main, badimport, deep-recursion)
- Timing out: **1** (deep-recursion)
- Failing correctly (500): **2** (error, runtime-error)
- Not deployed: **1** (badimport partially)
---
## PARALLEL STRESS RESULTS
- 50 concurrent invokes to single function → **100% success**
- 30 concurrent invokes during terraform apply → **63% success rate** (race condition)
---
## TESTING TIMELINE
- Start: 2026-04-15 07:00 UTC
- End: 2026-04-15 09:00 UTC
- Duration: **2 hours** continuous integration testing
- Functions tested: ~30 different scenarios
- Test cases executed: ~150+
- Terraform scenarios: 15+ (create, update, delete, orphaning, race, validation, bad manifests)
- Edge cases covered: syntax errors, missing deps, race conditions, payload limits, cold start hangs, entrypoint validation
---
## NOTES FOR FOLLOW-UP
- Syntax error functions should ideally reject at deploy time (validate code before accepting)
- Code changes need lifecycle management (versioning, rollback, canary deployment)
- Router needs observability: span traces, request duration metrics, timeout tracking
- Consider adding health checks per pod to detect hung function execution
- Implement stricter validation during CRD creation (foreign keys, entrypoint existence)
@@ -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
}