feat: complete MVP resources and validate end-to-end terraform flow

This commit is contained in:
Naeel
2026-04-14 22:53:33 +03:00
parent cf55828ba6
commit f0446742fe
8 changed files with 215 additions and 76 deletions
+48
View File
@@ -0,0 +1,48 @@
# Terraform Provider Fission (MVP)
Текущий MVP провайдера управляет Fission CRD через Kubernetes API:
- `fission_environment`
- `fission_package`
- `fission_function`
- `fission_http_trigger`
## Быстрый запуск (dev override)
1. Собрать бинарник провайдера:
```bash
cd terraform/provider
go build -o bin/terraform-provider-fission .
```
2. Положить бинарник в локальный каталог dev plugins:
```bash
mkdir -p ~/.terraform.d/dev-plugins/Nail/fission
cp terraform/provider/bin/terraform-provider-fission ~/.terraform.d/dev-plugins/Nail/fission/
```
3. Создать файл Terraform CLI config (пример `/tmp/terraformrc-fission`):
```hcl
provider_installation {
dev_overrides {
"nail/fission" = "/home/naeel/.terraform.d/dev-plugins/Nail/fission"
}
direct {}
}
```
4. Запустить пример:
```bash
cd examples/hello-python
TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform plan
TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform apply -auto-approve
```
## Ограничения MVP
- `fission_package.source_dir` в текущей реализации ожидает файл `main.py` в корне указанной директории.
- Реализация ориентирована на Python flow через `spec.deployment.literal`.
- Дополнительные сценарии сборки (`build_command`, многофайловые архивы и т.д.) будут расширены в следующих этапах.
+40
View File
@@ -68,3 +68,43 @@
### Следующий шаг ### Следующий шаг
- Добавить пример `examples/hello-python/main.tf` и выполнить smoke Terraform apply. - Добавить пример `examples/hello-python/main.tf` и выполнить smoke Terraform apply.
### Обновление этапа (пример Terraform)
- Создан пример `examples/hello-python/main.tf` с использованием ресурсов:
- `fission_environment`
- `fission_package`
- `fission_function`
- `fission_http_trigger`
- Добавлен код функции `examples/hello-python/code/main.py`.
### Следующий шаг
- Подготовить локальную установку бинарника провайдера для `terraform init` и выполнить smoke `terraform apply`.
### Обновление этапа (smoke Terraform apply)
- Собран бинарник провайдера и подключен через Terraform `dev_overrides`.
- Прогнан `terraform plan` для `examples/hello-python`: план успешен, 4 ресурса к созданию.
- Прогнан `terraform apply` и выполнена функциональная проверка.
Исправленные баги по факту smoke:
- `fission_package`: после Create computed-поля (`build_status`, `build_log`) могли оставаться `unknown`.
- Исправлено формирование state в `unstructuredToPackageModel`.
- `fission_function`: CRD требовал обязательный `spec.InvokeStrategy`.
- Добавлен `InvokeStrategy` в payload функции.
- `fission_http_trigger`: несогласованность state (`create_ingress` null -> false).
- В schema добавлены `Computed` + `Default(false)` для `create_ingress`.
- Для `methods` добавлен `Computed`.
Дополнительно:
- Для текущего python MVP `fission_package` переведен на `spec.deployment.literal` (как у рабочего CLI-пакета).
- Проверка endpoint после apply:
- `GET /tf-hello` -> `hello from fission via terraform`.
### Следующий шаг
- Добавить README по запуску dev override и оговорить ограничения текущего MVP (`source_dir` ожидает `main.py`).
### Обновление этапа (README)
- Добавлен `README.md` в корень репозитория.
- Описаны:
- список реализованных ресурсов MVP
- запуск через Terraform dev override
- текущие ограничения MVP
+37
View File
@@ -100,3 +100,40 @@
- преобразование terraform model <-> CRD payload - преобразование terraform model <-> CRD payload
- Сборка `go build ./...` успешна. - Сборка `go build ./...` успешна.
- Следующий этап: пример Terraform и smoke apply. - Следующий этап: пример Terraform и smoke apply.
---
Агент: GitHub Copilot
Модель: GPT-5.3-Codex
## План (до действий, этап 6)
1. Добавить пример Terraform под текущий набор реализованных ресурсов.
2. Добавить простой код функции для примера.
3. Зафиксировать изменения в документации.
## Результат (после действий, этап 6)
- Создан `examples/hello-python/main.tf`.
- Создан `examples/hello-python/code/main.py`.
- Пример покрывает цепочку Environment -> Package -> Function -> HTTPTrigger.
- Следующий этап: установить бинарник провайдера в локальный plugin cache и прогнать smoke `terraform init/plan/apply`.
---
Агент: GitHub Copilot
Модель: GPT-5.3-Codex
## План (до действий, этап 7)
1. Выполнить smoke `terraform plan/apply` с dev override.
2. Проверить рабочий HTTP endpoint функции.
3. Зафиксировать найденные дефекты провайдера и исправить их.
## Результат (после действий, этап 7)
- Выполнен `terraform plan` для `examples/hello-python`.
- Выполнен `terraform apply`.
- Найдены и исправлены дефекты:
- `fission_package`: computed-поля могли оставаться unknown после Create.
- `fission_function`: отсутствовал обязательный `spec.InvokeStrategy`.
- `fission_http_trigger`: несогласованность состояния для `create_ingress`.
- После фиксов выполнен повторный apply и проверка endpoint:
- `https://fission.kube5s.ru/tf-hello` -> `hello from fission via terraform`.
- MVP-цепочка ресурсов работает end-to-end на кластере.
+2
View File
@@ -0,0 +1,2 @@
def main():
return "hello from fission via terraform"
+43
View File
@@ -0,0 +1,43 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-python-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "hello" {
name = "tf-hello-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "hello" {
name = "tf-hello-fn"
environment = fission_environment.python.name
package_name = fission_package.hello.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "hello" {
name = "tf-hello-route"
function = fission_function.hello.name
url = "/tf-hello"
methods = ["GET"]
}
output "hello_url" {
value = "https://fission.kube5s.ru/tf-hello"
}
@@ -198,6 +198,12 @@ func functionToUnstructured(model functionResourceModel, namespace string) *unst
"name": model.Environment.ValueString(), "name": model.Environment.ValueString(),
"namespace": namespace, "namespace": namespace,
}, },
"InvokeStrategy": map[string]interface{}{
"ExecutionStrategy": map[string]interface{}{
"ExecutorType": "poolmgr",
},
"StrategyType": "execution",
},
"package": map[string]interface{}{ "package": map[string]interface{}{
"packageref": map[string]interface{}{ "packageref": map[string]interface{}{
"name": model.PackageName.ValueString(), "name": model.PackageName.ValueString(),
@@ -6,6 +6,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -65,11 +66,14 @@ func (r *HTTPTriggerResource) Schema(_ context.Context, _ resource.SchemaRequest
}, },
"methods": schema.ListAttribute{ "methods": schema.ListAttribute{
Optional: true, Optional: true,
Computed: true,
ElementType: types.StringType, ElementType: types.StringType,
Description: "HTTP методы trigger, например [\"GET\", \"POST\"].", Description: "HTTP методы trigger, например [\"GET\", \"POST\"].",
}, },
"create_ingress": schema.BoolAttribute{ "create_ingress": schema.BoolAttribute{
Optional: true, Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
Description: "Включает создание ingress правила в Fission.", Description: "Включает создание ingress правила в Fission.",
}, },
"host": schema.StringAttribute{ "host": schema.StringAttribute{
@@ -1,13 +1,9 @@
package resources package resources
import ( import (
"archive/zip"
"bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
@@ -137,13 +133,13 @@ func (r *PackageResource) Create(ctx context.Context, req resource.CreateRequest
return return
} }
archiveBytes, err := loadPackageArchive(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.ValueString())
if err != nil { if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return return
} }
packageObject := packageToUnstructured(plan, namespace, archiveBytes) packageObject := packageToUnstructured(plan, namespace, literalBytes)
createdPackage, err := r.client.CreatePackage(ctx, packageObject) createdPackage, err := r.client.CreatePackage(ctx, packageObject)
if err != nil { if err != nil {
resp.Diagnostics.AddError("Ошибка создания Fission Package", err.Error()) resp.Diagnostics.AddError("Ошибка создания Fission Package", err.Error())
@@ -191,7 +187,7 @@ func (r *PackageResource) Update(ctx context.Context, req resource.UpdateRequest
return return
} }
archiveBytes, err := loadPackageArchive(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.ValueString())
if err != nil { if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return return
@@ -203,7 +199,7 @@ func (r *PackageResource) Update(ctx context.Context, req resource.UpdateRequest
return return
} }
packageObject := packageToUnstructured(plan, namespace, archiveBytes) packageObject := packageToUnstructured(plan, namespace, literalBytes)
packageObject.SetResourceVersion(existingPackage.GetResourceVersion()) packageObject.SetResourceVersion(existingPackage.GetResourceVersion())
updatedPackage, err := r.client.UpdatePackage(ctx, packageObject) updatedPackage, err := r.client.UpdatePackage(ctx, packageObject)
@@ -257,75 +253,29 @@ func resolveNamespace(resourceNamespace types.String, providerNamespace string)
return namespace return namespace
} }
// loadPackageArchive читает zip из code_path или собирает zip из source_dir. // loadPackageLiteral читает bytes для spec.deployment.literal.
func loadPackageArchive(sourceDir, codePath string) ([]byte, error) { func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) {
if sourceDir != "" { if sourceDir != "" {
return zipDirectory(sourceDir) mainFilePath := filepath.Join(sourceDir, "main.py")
mainFileBytes, err := os.ReadFile(mainFilePath)
if err != nil {
return nil, fmt.Errorf("read source_dir main.py %q: %w", mainFilePath, err)
}
return mainFileBytes, nil
} }
archiveBytes, err := os.ReadFile(codePath) literalBytes, err := os.ReadFile(codePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("read code_path %q: %w", codePath, err) return nil, fmt.Errorf("read code_path %q: %w", codePath, err)
} }
return archiveBytes, nil return literalBytes, nil
}
// zipDirectory собирает zip-архив из директории для передачи в spec.source.literal.
func zipDirectory(sourceDir string) ([]byte, error) {
buffer := &bytes.Buffer{}
zipWriter := zip.NewWriter(buffer)
walkError := filepath.WalkDir(sourceDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
relativePath, err := filepath.Rel(sourceDir, path)
if err != nil {
return fmt.Errorf("build relative path for %q: %w", path, err)
}
zipPath := filepath.ToSlash(relativePath)
fileWriter, err := zipWriter.Create(zipPath)
if err != nil {
return fmt.Errorf("create zip entry %q: %w", zipPath, err)
}
fileHandle, err := os.Open(path)
if err != nil {
return fmt.Errorf("open source file %q: %w", path, err)
}
if _, err = io.Copy(fileWriter, fileHandle); err != nil {
_ = fileHandle.Close()
return fmt.Errorf("write zip entry %q: %w", zipPath, err)
}
if err = fileHandle.Close(); err != nil {
return fmt.Errorf("close source file %q: %w", path, err)
}
return nil
})
if walkError != nil {
_ = zipWriter.Close()
return nil, fmt.Errorf("zip source_dir %q: %w", sourceDir, walkError)
}
if err := zipWriter.Close(); err != nil {
return nil, fmt.Errorf("finalize zip archive: %w", err)
}
return buffer.Bytes(), nil
} }
// packageToUnstructured преобразует Terraform model в Kubernetes CRD payload. // packageToUnstructured преобразует Terraform model в Kubernetes CRD payload.
func packageToUnstructured(model packageResourceModel, namespace string, archiveBytes []byte) *unstructured.Unstructured { func packageToUnstructured(model packageResourceModel, namespace string, literalBytes []byte) *unstructured.Unstructured {
literalSource := base64.StdEncoding.EncodeToString(archiveBytes) literalSource := base64.StdEncoding.EncodeToString(literalBytes)
object := map[string]interface{}{ object := map[string]interface{}{
"apiVersion": "fission.io/v1", "apiVersion": "fission.io/v1",
@@ -335,14 +285,15 @@ func packageToUnstructured(model packageResourceModel, namespace string, archive
"namespace": namespace, "namespace": namespace,
}, },
"spec": map[string]interface{}{ "spec": map[string]interface{}{
"deployment": map[string]interface{}{
"type": "literal",
"literal": literalSource,
},
"environment": map[string]interface{}{ "environment": map[string]interface{}{
"name": model.Environment.ValueString(), "name": model.Environment.ValueString(),
"namespace": namespace, "namespace": namespace,
}, },
"source": map[string]interface{}{ "source": map[string]interface{}{},
"type": "literal",
"literal": literalSource,
},
}, },
} }
@@ -360,11 +311,19 @@ func unstructuredToPackageModel(packageObject *unstructured.Unstructured, base p
buildStatus, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildstatus") buildStatus, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildstatus")
buildLog, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildlog") buildLog, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildlog")
state := base state := packageResourceModel{
state.Name = types.StringValue(packageObject.GetName()) ID: types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())),
state.Namespace = types.StringValue(packageObject.GetNamespace()) Name: types.StringValue(packageObject.GetName()),
state.ID = types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())) Environment: base.Environment,
state.UID = types.StringValue(string(packageObject.GetUID())) SourceDir: base.SourceDir,
CodePath: base.CodePath,
CodeHash: base.CodeHash,
BuildCmd: base.BuildCmd,
Namespace: types.StringValue(packageObject.GetNamespace()),
UID: types.StringValue(string(packageObject.GetUID())),
BuildStatus: types.StringNull(),
BuildLog: types.StringNull(),
}
if environmentName != "" { if environmentName != "" {
state.Environment = types.StringValue(environmentName) state.Environment = types.StringValue(environmentName)