feat: complete MVP resources and validate end-to-end terraform flow
This commit is contained in:
@@ -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`, многофайловые архивы и т.д.) будут расширены в следующих этапах.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 на кластере.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
def main():
|
||||||
|
return "hello from fission via terraform"
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user