feat: add import support and unit tests for provider resources
This commit is contained in:
@@ -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` в корне указанной директории.
|
||||
|
||||
@@ -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-флагу.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() != ""
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user