From 81852c28b1a5d811b41c11bff1d5d8e5e9f1a1d3 Mon Sep 17 00:00:00 2001 From: Naeel Date: Tue, 14 Apr 2026 23:00:46 +0300 Subject: [PATCH] feat: add import support and unit tests for provider resources --- README.md | 20 +++ doc/progress.md | 20 +++ doc/thinking/2026-04-14.md | 19 +++ .../resources/environment_resource.go | 15 ++ .../resources/environment_resource_test.go | 35 +++++ .../internal/resources/function_resource.go | 15 ++ .../resources/function_resource_test.go | 38 +++++ .../resources/http_trigger_resource.go | 15 ++ .../internal/resources/import_helpers.go | 16 +++ .../internal/resources/import_helpers_test.go | 23 +++ .../internal/resources/package_resource.go | 15 ++ .../resources/package_resource_test.go | 135 ++++++++++++++++++ 12 files changed, 366 insertions(+) create mode 100644 terraform/provider/internal/resources/environment_resource_test.go create mode 100644 terraform/provider/internal/resources/function_resource_test.go create mode 100644 terraform/provider/internal/resources/import_helpers.go create mode 100644 terraform/provider/internal/resources/import_helpers_test.go create mode 100644 terraform/provider/internal/resources/package_resource_test.go diff --git a/README.md b/README.md index 6691a44..6a51379 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ - `fission_function` - `fission_http_trigger` +Все ресурсы поддерживают `terraform import` в формате `namespace/name`. + ## Быстрый запуск (dev override) 1. Собрать бинарник провайдера: @@ -41,6 +43,24 @@ TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform plan TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform apply -auto-approve ``` +## Тесты + +Unit-тесты: + +```bash +cd terraform/provider +go test ./... +``` + +Импорт существующего ресурса (пример): + +```bash +terraform import fission_environment.python default/tf-python-env +terraform import fission_package.hello default/tf-hello-pkg +terraform import fission_function.hello default/tf-hello-fn +terraform import fission_http_trigger.hello default/tf-hello-route +``` + ## Ограничения MVP - `fission_package.source_dir` в текущей реализации ожидает файл `main.py` в корне указанной директории. diff --git a/doc/progress.md b/doc/progress.md index f3d78f5..5810885 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -108,3 +108,23 @@ - список реализованных ресурсов MVP - запуск через Terraform dev override - текущие ограничения MVP + +### Обновление этапа (import + тесты) +- Добавлена поддержка `terraform import` для всех 4 ресурсов: + - `fission_environment` + - `fission_package` + - `fission_function` + - `fission_http_trigger` +- Формат import ID: `namespace/name`. +- Добавлены unit-тесты в `terraform/provider/internal/resources/*_test.go`: + - парсинг import ID + - конвертация model <-> unstructured + - валидация package source + - базовая проверка HTTP trigger round-trip +- Прогон валидации: + - `go test ./...` — успешно + - smoke `terraform plan/apply` — успешно + - endpoint `GET /tf-hello` — `hello from fission via terraform` + +### Следующий шаг +- Добавить acceptance-тесты с опциональным запуском по env-флагу. diff --git a/doc/thinking/2026-04-14.md b/doc/thinking/2026-04-14.md index 060c3c5..5f299b8 100644 --- a/doc/thinking/2026-04-14.md +++ b/doc/thinking/2026-04-14.md @@ -137,3 +137,22 @@ - После фиксов выполнен повторный apply и проверка endpoint: - `https://fission.kube5s.ru/tf-hello` -> `hello from fission via terraform`. - MVP-цепочка ресурсов работает end-to-end на кластере. + +--- + +Агент: GitHub Copilot +Модель: GPT-5.3-Codex + +## План (до действий, этап 8) +1. Добавить поддержку `terraform import` для всех реализованных ресурсов. +2. Добавить unit-тесты на критичную логику конвертации и валидации. +3. Прогнать `go test` и повторный smoke `terraform plan/apply`. + +## Результат (после действий, этап 8) +- Добавлен общий helper парсинга import ID (`namespace/name`). +- Добавлен `ImportState` для ресурсов Environment/Package/Function/HTTPTrigger. +- Добавлены unit-тесты для `internal/resources`. +- Прогон `go test ./...` успешен. +- Повторный `terraform apply` успешен. +- Финальная проверка endpoint: + - `GET /tf-hello` -> `hello from fission via terraform`. diff --git a/terraform/provider/internal/resources/environment_resource.go b/terraform/provider/internal/resources/environment_resource.go index 6bb35d2..875329c 100644 --- a/terraform/provider/internal/resources/environment_resource.go +++ b/terraform/provider/internal/resources/environment_resource.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "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/int64default" @@ -14,6 +15,7 @@ import ( ) var _ resource.Resource = &EnvironmentResource{} +var _ resource.ResourceWithImportState = &EnvironmentResource{} // Изменено: 2026-04-14 19:15 UTC. // Resource для управления Fission Environment через Kubernetes CRD API. @@ -199,6 +201,19 @@ func (r *EnvironmentResource) Delete(ctx context.Context, req resource.DeleteReq } } +// ImportState поддерживает импорт ресурса в формате namespace/name. +func (r *EnvironmentResource) 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)...) +} + // environmentToUnstructured преобразует Terraform model в Kubernetes CRD payload. func environmentToUnstructured(model environmentResourceModel, namespace string) *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]interface{}{ diff --git a/terraform/provider/internal/resources/environment_resource_test.go b/terraform/provider/internal/resources/environment_resource_test.go new file mode 100644 index 0000000..33ebb44 --- /dev/null +++ b/terraform/provider/internal/resources/environment_resource_test.go @@ -0,0 +1,35 @@ +package resources + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestEnvironmentToUnstructuredAndBack(t *testing.T) { + input := environmentResourceModel{ + Name: types.StringValue("env-a"), + Image: types.StringValue("ghcr.io/fission/python-env"), + Version: types.Int64Value(3), + PoolSize: types.Int64Value(5), + } + + obj := environmentToUnstructured(input, "default") + state := unstructuredToEnvironmentModel(obj, input) + + if state.Name.ValueString() != "env-a" { + t.Fatalf("unexpected name: %q", state.Name.ValueString()) + } + if state.Namespace.ValueString() != "default" { + t.Fatalf("unexpected namespace: %q", state.Namespace.ValueString()) + } + if state.Image.ValueString() != "ghcr.io/fission/python-env" { + t.Fatalf("unexpected image: %q", state.Image.ValueString()) + } + if state.Version.ValueInt64() != 3 { + t.Fatalf("unexpected version: %d", state.Version.ValueInt64()) + } + if state.PoolSize.ValueInt64() != 5 { + t.Fatalf("unexpected poolsize: %d", state.PoolSize.ValueInt64()) + } +} diff --git a/terraform/provider/internal/resources/function_resource.go b/terraform/provider/internal/resources/function_resource.go index 8a20d24..952067b 100644 --- a/terraform/provider/internal/resources/function_resource.go +++ b/terraform/provider/internal/resources/function_resource.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "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/types" @@ -13,6 +14,7 @@ import ( ) var _ resource.Resource = &FunctionResource{} +var _ resource.ResourceWithImportState = &FunctionResource{} // Изменено: 2026-04-14 20:00 UTC. // Resource для управления Fission Function через Kubernetes CRD API. @@ -184,6 +186,19 @@ func (r *FunctionResource) Delete(ctx context.Context, req resource.DeleteReques } } +// ImportState поддерживает импорт ресурса в формате namespace/name. +func (r *FunctionResource) 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)...) +} + // functionToUnstructured преобразует Terraform model в Kubernetes CRD payload. func functionToUnstructured(model functionResourceModel, namespace string) *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]interface{}{ diff --git a/terraform/provider/internal/resources/function_resource_test.go b/terraform/provider/internal/resources/function_resource_test.go new file mode 100644 index 0000000..9256cfd --- /dev/null +++ b/terraform/provider/internal/resources/function_resource_test.go @@ -0,0 +1,38 @@ +package resources + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestFunctionToUnstructuredAndBack(t *testing.T) { + input := functionResourceModel{ + Name: types.StringValue("fn-a"), + Environment: types.StringValue("env-a"), + PackageName: types.StringValue("pkg-a"), + Entrypoint: types.StringValue("main.main"), + } + + obj := functionToUnstructured(input, "default") + state := unstructuredToFunctionModel(obj, input) + + if state.Name.ValueString() != "fn-a" { + t.Fatalf("unexpected name: %q", state.Name.ValueString()) + } + if state.Environment.ValueString() != "env-a" { + t.Fatalf("unexpected environment: %q", state.Environment.ValueString()) + } + if state.PackageName.ValueString() != "pkg-a" { + t.Fatalf("unexpected package_name: %q", state.PackageName.ValueString()) + } + if state.Entrypoint.ValueString() != "main.main" { + t.Fatalf("unexpected entrypoint: %q", state.Entrypoint.ValueString()) + } + + invoke, found, err := unstructured.NestedMap(obj.Object, "spec", "InvokeStrategy") + if err != nil || !found || len(invoke) == 0 { + t.Fatalf("InvokeStrategy not set correctly") + } +} diff --git a/terraform/provider/internal/resources/http_trigger_resource.go b/terraform/provider/internal/resources/http_trigger_resource.go index 46529ef..708221c 100644 --- a/terraform/provider/internal/resources/http_trigger_resource.go +++ b/terraform/provider/internal/resources/http_trigger_resource.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "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/booldefault" @@ -14,6 +15,7 @@ import ( ) var _ resource.Resource = &HTTPTriggerResource{} +var _ resource.ResourceWithImportState = &HTTPTriggerResource{} // Изменено: 2026-04-14 20:15 UTC. // Resource для управления Fission HTTPTrigger через Kubernetes CRD API. @@ -207,6 +209,19 @@ func (r *HTTPTriggerResource) Delete(ctx context.Context, req resource.DeleteReq } } +// ImportState поддерживает импорт ресурса в формате namespace/name. +func (r *HTTPTriggerResource) 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)...) +} + // httpTriggerToUnstructured преобразует Terraform model в Kubernetes CRD payload. func httpTriggerToUnstructured(ctx context.Context, model httpTriggerResourceModel, namespace string) (*unstructured.Unstructured, error) { methods := []string{"GET"} diff --git a/terraform/provider/internal/resources/import_helpers.go b/terraform/provider/internal/resources/import_helpers.go new file mode 100644 index 0000000..78ec073 --- /dev/null +++ b/terraform/provider/internal/resources/import_helpers.go @@ -0,0 +1,16 @@ +package resources + +import ( + "fmt" + "strings" +) + +// parseImportID разбирает импорт в формате namespace/name. +func parseImportID(importID string) (string, string, error) { + parts := strings.Split(importID, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid import id %q, expected format namespace/name", importID) + } + + return parts[0], parts[1], nil +} diff --git a/terraform/provider/internal/resources/import_helpers_test.go b/terraform/provider/internal/resources/import_helpers_test.go new file mode 100644 index 0000000..26c3ee7 --- /dev/null +++ b/terraform/provider/internal/resources/import_helpers_test.go @@ -0,0 +1,23 @@ +package resources + +import "testing" + +func TestParseImportID(t *testing.T) { + namespace, name, err := parseImportID("default/my-resource") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if namespace != "default" || name != "my-resource" { + t.Fatalf("unexpected parsed values: namespace=%q name=%q", namespace, name) + } +} + +func TestParseImportIDInvalid(t *testing.T) { + invalidIDs := []string{"", "default", "default/", "/name", "a/b/c"} + for _, importID := range invalidIDs { + _, _, err := parseImportID(importID) + if err == nil { + t.Fatalf("expected error for import id %q", importID) + } + } +} diff --git a/terraform/provider/internal/resources/package_resource.go b/terraform/provider/internal/resources/package_resource.go index 51d9888..0c57210 100644 --- a/terraform/provider/internal/resources/package_resource.go +++ b/terraform/provider/internal/resources/package_resource.go @@ -8,6 +8,7 @@ import ( "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/types" @@ -17,6 +18,7 @@ import ( ) var _ resource.Resource = &PackageResource{} +var _ resource.ResourceWithImportState = &PackageResource{} // Изменено: 2026-04-14 19:45 UTC. // Resource для управления Fission Package через Kubernetes CRD API. @@ -227,6 +229,19 @@ func (r *PackageResource) Delete(ctx context.Context, req resource.DeleteRequest } } +// 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() != "" diff --git a/terraform/provider/internal/resources/package_resource_test.go b/terraform/provider/internal/resources/package_resource_test.go new file mode 100644 index 0000000..c1478a1 --- /dev/null +++ b/terraform/provider/internal/resources/package_resource_test.go @@ -0,0 +1,135 @@ +package resources + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestValidatePackageSource(t *testing.T) { + var diagnostics diag.Diagnostics + ok := validatePackageSource(types.StringValue("code"), types.StringNull(), &diagnostics) + if !ok || diagnostics.HasError() { + t.Fatalf("expected valid source_dir-only config") + } + + diagnostics = diag.Diagnostics{} + ok = validatePackageSource(types.StringNull(), types.StringValue("main.zip"), &diagnostics) + if !ok || diagnostics.HasError() { + t.Fatalf("expected valid code_path-only config") + } + + diagnostics = diag.Diagnostics{} + ok = validatePackageSource(types.StringNull(), types.StringNull(), &diagnostics) + if ok || !diagnostics.HasError() { + t.Fatalf("expected invalid config when both values are empty") + } +} + +func TestLoadPackageLiteralFromSourceDir(t *testing.T) { + tempDir := t.TempDir() + mainPath := filepath.Join(tempDir, "main.py") + content := []byte("def main():\n return 'ok'\n") + if err := os.WriteFile(mainPath, content, 0o600); err != nil { + t.Fatalf("write temp main.py: %v", err) + } + + loaded, err := loadPackageLiteral(tempDir, "") + if err != nil { + t.Fatalf("loadPackageLiteral returned error: %v", err) + } + if string(loaded) != string(content) { + t.Fatalf("unexpected loaded content") + } +} + +func TestPackageToUnstructured(t *testing.T) { + input := packageResourceModel{ + Name: types.StringValue("pkg-a"), + Environment: types.StringValue("env-a"), + } + + obj := packageToUnstructured(input, "default", []byte("print('hi')")) + literal, found, err := unstructured.NestedString(obj.Object, "spec", "deployment", "literal") + if err != nil || !found { + t.Fatalf("deployment.literal not found") + } + + decoded, err := base64.StdEncoding.DecodeString(literal) + if err != nil { + t.Fatalf("decode literal: %v", err) + } + if string(decoded) != "print('hi')" { + t.Fatalf("unexpected decoded literal: %q", string(decoded)) + } +} + +func TestResolveNamespace(t *testing.T) { + if got := resolveNamespace(types.StringValue("custom"), "default"); got != "custom" { + t.Fatalf("unexpected namespace: %q", got) + } + if got := resolveNamespace(types.StringNull(), "default"); got != "default" { + t.Fatalf("unexpected namespace fallback: %q", got) + } +} + +func TestUnstructuredToPackageModelSetsNullComputed(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]interface{}{ + "name": "pkg-a", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "environment": map[string]interface{}{"name": "env-a"}, + }, + }} + + state := unstructuredToPackageModel(obj, packageResourceModel{}) + if !state.BuildStatus.IsNull() { + t.Fatalf("expected build_status to be null when status is absent") + } + if !state.BuildLog.IsNull() { + t.Fatalf("expected build_log to be null when status is absent") + } +} + +func TestHTTPTriggerRoundTrip(t *testing.T) { + ctx := context.Background() + methods, diagnostics := types.ListValueFrom(ctx, types.StringType, []string{"GET", "POST"}) + if diagnostics.HasError() { + t.Fatalf("failed to create methods list") + } + + model := httpTriggerResourceModel{ + Name: types.StringValue("tr-a"), + Function: types.StringValue("fn-a"), + URL: types.StringValue("/hello"), + Methods: methods, + CreateIngress: types.BoolValue(true), + Host: types.StringValue("fission.kube5s.ru"), + } + + obj, err := httpTriggerToUnstructured(ctx, model, "default") + if err != nil { + t.Fatalf("httpTriggerToUnstructured error: %v", err) + } + + state := unstructuredToHTTPTriggerModel(ctx, obj, model) + if state.Name.ValueString() != "tr-a" { + t.Fatalf("unexpected trigger name: %q", state.Name.ValueString()) + } + if state.Function.ValueString() != "fn-a" { + t.Fatalf("unexpected function name: %q", state.Function.ValueString()) + } + if state.URL.ValueString() != "/hello" { + t.Fatalf("unexpected url: %q", state.URL.ValueString()) + } +}