diff --git a/doc/progress.md b/doc/progress.md new file mode 100644 index 0000000..6599e40 --- /dev/null +++ b/doc/progress.md @@ -0,0 +1,40 @@ +# Progress Log + +## 2026-04-14 + +### План перед работой +- Зафиксировать, что REST `/v2/*` недоступен в текущем кластере Fission v1.22.1 и MVP идет через Kubernetes CRD API. +- Реализовать первый рабочий Terraform ресурс `fission_environment`. +- Добавить минимальный CRUD в клиент для CRD `environments.fission.io`. +- Проверить сборку `go build ./...`. + +### Факт выполнения +- Обновлен план в `doc/CODEX_PLAN.md`: путь реализации переведен на CRD API. +- Создан каркас провайдера и заготовки ресурсов. +- Реализован рабочий `fission_environment`: + - schema (name, image, version, poolsize, namespace, id, uid) + - Configure() с получением клиента из provider + - Create/Read/Update/Delete через Kubernetes CRD `environments.fission.io` +- В `internal/client/client.go` добавлены методы: + - CreateEnvironment / GetEnvironment / UpdateEnvironment / DeleteEnvironment + - IsNotFound для обработки удаления/дрейфа state +- Проверка `go build ./...` проходит успешно. + +### Следующий шаг +- Реализовать `fission_package` (zip/base64 -> spec.source.literal) через CRD `packages.fission.io`. + +### Обновление этапа (fission_package) +- Реализован CRUD в `internal/client/client.go` для `packages.fission.io`: + - `CreatePackage` + - `GetPackage` + - `UpdatePackage` + - `DeletePackage` +- Реализован ресурс `fission_package` в `internal/resources/package_resource.go`: + - schema: `name`, `environment`, `source_dir`, `code_path`, `code_hash`, `build_command`, `namespace`, computed `id`, `uid`, `build_status`, `build_log` + - валидация источника кода: задан ровно один из `source_dir` или `code_path` + - упаковка `source_dir` в zip и кодирование base64 в `spec.source.literal` + - Create/Read/Update/Delete через Kubernetes CRD API +- Проверка сборки: `go build ./...` успешно. + +### Следующий шаг +- Реализовать `fission_function` через CRD `functions.fission.io`. diff --git a/doc/thinking/2026-04-14.md b/doc/thinking/2026-04-14.md new file mode 100644 index 0000000..7295e49 --- /dev/null +++ b/doc/thinking/2026-04-14.md @@ -0,0 +1,62 @@ +# Thinking Log + +Агент: GitHub Copilot +Модель: GPT-5.3-Codex +Дата: 2026-04-14 + +--- + +## План (до действий) +1. Проверить реальное состояние кода после scaffold и внешних правок. +2. Зафиксировать документацию этапа до изменений в коде. +3. Реализовать первый рабочий ресурс `fission_environment` как базовую вертикаль (provider -> client -> resource CRUD). +4. Скомпилировать проект и поправить ошибки. +5. Обновить документацию по факту и закоммитить этап. + +## Результат (после предыдущих действий) +- Подтверждено, что REST `/v2/*` в текущей установке недоступен. +- План реализации переключен на Kubernetes CRD API. +- Создан и собран каркас провайдера. +- Сейчас выполняется реализация первого рабочего ресурса. + +--- + +Агент: GitHub Copilot +Модель: GPT-5.3-Codex + +## План (до действий, этап 2) +1. Реализовать CRUD методы клиента для `environments.fission.io`. +2. Реализовать полный lifecycle ресурса `fission_environment`. +3. Проверить сборку и привести код к чистому состоянию. +4. Зафиксировать результат в документации. + +## Результат (после действий, этап 2) +- В `internal/client/client.go` реализованы методы CRUD для Environment CRD. +- В `internal/resources/environment_resource.go` реализован полноценный ресурс: + - schema атрибутов + - Configure/Create/Read/Update/Delete + - преобразование model <-> unstructured +- Сборка `go build ./...` успешна. +- Этап готов к коммиту и переходу к `fission_package`. + +--- + +Агент: GitHub Copilot +Модель: GPT-5.3-Codex + +## План (до действий, этап 3) +1. Реализовать CRUD методы клиента для `packages.fission.io`. +2. Реализовать ресурс `fission_package` с загрузкой кода из `source_dir` или `code_path`. +3. Проверить сборку. +4. Обновить документацию и зафиксировать этап. + +## Результат (после действий, этап 3) +- В `internal/client/client.go` добавлены методы CRUD для Package CRD. +- В `internal/resources/package_resource.go` реализован рабочий ресурс `fission_package`: + - schema ресурса + - проверка источника кода (ровно один из `source_dir`/`code_path`) + - упаковка директории в zip + - base64 в `spec.source.literal` + - Create/Read/Update/Delete через CRD API +- Сборка `go build ./...` успешна. +- Следующий этап: `fission_function`. diff --git a/terraform/provider/internal/client/client.go b/terraform/provider/internal/client/client.go index 3fee9b0..bd30f3d 100644 --- a/terraform/provider/internal/client/client.go +++ b/terraform/provider/internal/client/client.go @@ -1,14 +1,31 @@ package client import ( + "context" "fmt" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) +var environmentGVR = schema.GroupVersionResource{ + Group: "fission.io", + Version: "v1", + Resource: "environments", +} + +var packageGVR = schema.GroupVersionResource{ + Group: "fission.io", + Version: "v1", + Resource: "packages", +} + // Client хранит клиентов Kubernetes API для работы с CRD Fission. type Client struct { DynClient dynamic.Interface @@ -59,3 +76,88 @@ func buildConfig(kubeconfigPath, kubeContext string) (*rest.Config, error) { return cfg, nil } + +// CreateEnvironment создает объект Environment в заданном namespace. +func (c *Client) CreateEnvironment(ctx context.Context, environment *unstructured.Unstructured) (*unstructured.Unstructured, error) { + createdEnvironment, err := c.DynClient.Resource(environmentGVR).Namespace(environment.GetNamespace()).Create(ctx, environment, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create environment %q: %w", environment.GetName(), err) + } + + return createdEnvironment, nil +} + +// GetEnvironment получает объект Environment по имени. +func (c *Client) GetEnvironment(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { + environment, err := c.DynClient.Resource(environmentGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("get environment %q: %w", name, err) + } + + return environment, nil +} + +// UpdateEnvironment обновляет существующий объект Environment. +func (c *Client) UpdateEnvironment(ctx context.Context, environment *unstructured.Unstructured) (*unstructured.Unstructured, error) { + updatedEnvironment, err := c.DynClient.Resource(environmentGVR).Namespace(environment.GetNamespace()).Update(ctx, environment, metav1.UpdateOptions{}) + if err != nil { + return nil, fmt.Errorf("update environment %q: %w", environment.GetName(), err) + } + + return updatedEnvironment, nil +} + +// DeleteEnvironment удаляет объект Environment по имени. +func (c *Client) DeleteEnvironment(ctx context.Context, namespace, name string) error { + err := c.DynClient.Resource(environmentGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("delete environment %q: %w", name, err) + } + + return nil +} + +// IsNotFound возвращает true для ошибок Kubernetes типа NotFound. +func IsNotFound(err error) bool { + return apierrors.IsNotFound(err) +} + +// CreatePackage создает объект Package в заданном namespace. +func (c *Client) CreatePackage(ctx context.Context, pkg *unstructured.Unstructured) (*unstructured.Unstructured, error) { + createdPackage, err := c.DynClient.Resource(packageGVR).Namespace(pkg.GetNamespace()).Create(ctx, pkg, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create package %q: %w", pkg.GetName(), err) + } + + return createdPackage, nil +} + +// GetPackage получает объект Package по имени. +func (c *Client) GetPackage(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { + pkg, err := c.DynClient.Resource(packageGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("get package %q: %w", name, err) + } + + return pkg, nil +} + +// UpdatePackage обновляет существующий объект Package. +func (c *Client) UpdatePackage(ctx context.Context, pkg *unstructured.Unstructured) (*unstructured.Unstructured, error) { + updatedPackage, err := c.DynClient.Resource(packageGVR).Namespace(pkg.GetNamespace()).Update(ctx, pkg, metav1.UpdateOptions{}) + if err != nil { + return nil, fmt.Errorf("update package %q: %w", pkg.GetName(), err) + } + + return updatedPackage, nil +} + +// DeletePackage удаляет объект Package по имени. +func (c *Client) DeletePackage(ctx context.Context, namespace, name string) error { + err := c.DynClient.Resource(packageGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("delete package %q: %w", name, err) + } + + return nil +} diff --git a/terraform/provider/internal/resources/environment_resource.go b/terraform/provider/internal/resources/environment_resource.go index 97a0202..6bb35d2 100644 --- a/terraform/provider/internal/resources/environment_resource.go +++ b/terraform/provider/internal/resources/environment_resource.go @@ -2,14 +2,34 @@ package resources import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/types" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "terraform-provider-fission/internal/client" ) var _ resource.Resource = &EnvironmentResource{} -type EnvironmentResource struct{} +// Изменено: 2026-04-14 19:15 UTC. +// Resource для управления Fission Environment через Kubernetes CRD API. +type EnvironmentResource struct { + client *client.Client +} + +type environmentResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Image types.String `tfsdk:"image"` + Version types.Int64 `tfsdk:"version"` + PoolSize types.Int64 `tfsdk:"poolsize"` + Namespace types.String `tfsdk:"namespace"` + UID types.String `tfsdk:"uid"` +} func NewEnvironmentResource() resource.Resource { return &EnvironmentResource{} @@ -20,11 +40,205 @@ func (r *EnvironmentResource) Metadata(_ context.Context, req resource.MetadataR } func (r *EnvironmentResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{} + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "Идентификатор ресурса (namespace/name).", + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Имя Fission Environment.", + }, + "image": schema.StringAttribute{ + Required: true, + Description: "Runtime image для Environment (например ghcr.io/fission/python-env).", + }, + "version": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(3), + Description: "Версия спецификации Environment.", + }, + "poolsize": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(3), + Description: "Размер пула pre-warmed контейнеров.", + }, + "namespace": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Namespace, где создается Environment. По умолчанию используется provider namespace.", + }, + "uid": schema.StringAttribute{ + Computed: true, + Description: "UID Kubernetes объекта Environment.", + }, + }, + } } -func (r *EnvironmentResource) Configure(_ context.Context, _ resource.ConfigureRequest, _ *resource.ConfigureResponse) {} -func (r *EnvironmentResource) Create(_ context.Context, _ resource.CreateRequest, _ *resource.CreateResponse) {} -func (r *EnvironmentResource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) {} -func (r *EnvironmentResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) {} -func (r *EnvironmentResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {} +// Configure получает клиент из provider.Configure(). +func (r *EnvironmentResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + fissionClient, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Некорректный тип provider data", + fmt.Sprintf("Ожидался *client.Client, получен: %T", req.ProviderData), + ) + return + } + + r.client = fissionClient +} + +// Create создает новый Environment CRD объект. +func (r *EnvironmentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan environmentResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + namespace := plan.Namespace.ValueString() + if namespace == "" { + namespace = r.client.Namespace + } + + environmentObject := environmentToUnstructured(plan, namespace) + createdEnvironment, err := r.client.CreateEnvironment(ctx, environmentObject) + if err != nil { + resp.Diagnostics.AddError("Ошибка создания Fission Environment", err.Error()) + return + } + + state := unstructuredToEnvironmentModel(createdEnvironment, plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Read синхронизирует Terraform state с текущим состоянием CRD. +func (r *EnvironmentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state environmentResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + namespace := state.Namespace.ValueString() + if namespace == "" { + namespace = r.client.Namespace + } + + environmentObject, err := r.client.GetEnvironment(ctx, namespace, state.Name.ValueString()) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError("Ошибка чтения Fission Environment", err.Error()) + return + } + + updatedState := unstructuredToEnvironmentModel(environmentObject, state) + resp.Diagnostics.Append(resp.State.Set(ctx, &updatedState)...) +} + +// Update обновляет существующий Environment CRD объект. +func (r *EnvironmentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan environmentResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + namespace := plan.Namespace.ValueString() + if namespace == "" { + namespace = r.client.Namespace + } + + environmentObject := environmentToUnstructured(plan, namespace) + existingEnvironment, err := r.client.GetEnvironment(ctx, namespace, plan.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка получения Environment перед обновлением", err.Error()) + return + } + + environmentObject.SetResourceVersion(existingEnvironment.GetResourceVersion()) + updatedEnvironment, err := r.client.UpdateEnvironment(ctx, environmentObject) + if err != nil { + resp.Diagnostics.AddError("Ошибка обновления Fission Environment", err.Error()) + return + } + + state := unstructuredToEnvironmentModel(updatedEnvironment, plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Delete удаляет Environment CRD объект. +func (r *EnvironmentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state environmentResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + namespace := state.Namespace.ValueString() + if namespace == "" { + namespace = r.client.Namespace + } + + err := r.client.DeleteEnvironment(ctx, namespace, state.Name.ValueString()) + if err != nil && !client.IsNotFound(err) { + resp.Diagnostics.AddError("Ошибка удаления Fission Environment", err.Error()) + } +} + +// environmentToUnstructured преобразует Terraform model в Kubernetes CRD payload. +func environmentToUnstructured(model environmentResourceModel, namespace string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "fission.io/v1", + "kind": "Environment", + "metadata": map[string]interface{}{ + "name": model.Name.ValueString(), + "namespace": namespace, + }, + "spec": map[string]interface{}{ + "version": model.Version.ValueInt64(), + "runtime": map[string]interface{}{ + "image": model.Image.ValueString(), + }, + "poolsize": model.PoolSize.ValueInt64(), + }, + }} +} + +// unstructuredToEnvironmentModel читает нужные поля из CRD объекта обратно в Terraform state. +func unstructuredToEnvironmentModel(environmentObject *unstructured.Unstructured, base environmentResourceModel) environmentResourceModel { + imageValue, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "runtime", "image") + versionValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "version") + poolsizeValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "poolsize") + + state := base + state.Name = types.StringValue(environmentObject.GetName()) + state.Namespace = types.StringValue(environmentObject.GetNamespace()) + state.ID = types.StringValue(fmt.Sprintf("%s/%s", environmentObject.GetNamespace(), environmentObject.GetName())) + state.UID = types.StringValue(string(environmentObject.GetUID())) + + if imageValue != "" { + state.Image = types.StringValue(imageValue) + } + if versionValue != 0 { + state.Version = types.Int64Value(versionValue) + } + if poolsizeValue != 0 { + state.PoolSize = types.Int64Value(poolsizeValue) + } + + return state +} diff --git a/terraform/provider/internal/resources/function_resource.go b/terraform/provider/internal/resources/function_resource.go index 116bbd6..fbc56b5 100644 --- a/terraform/provider/internal/resources/function_resource.go +++ b/terraform/provider/internal/resources/function_resource.go @@ -23,8 +23,13 @@ func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, r resp.Schema = schema.Schema{} } -func (r *FunctionResource) Configure(_ context.Context, _ resource.ConfigureRequest, _ *resource.ConfigureResponse) {} -func (r *FunctionResource) Create(_ context.Context, _ resource.CreateRequest, _ *resource.CreateResponse) {} -func (r *FunctionResource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) {} -func (r *FunctionResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) {} -func (r *FunctionResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {} +func (r *FunctionResource) Configure(_ context.Context, _ resource.ConfigureRequest, _ *resource.ConfigureResponse) { +} +func (r *FunctionResource) Create(_ context.Context, _ resource.CreateRequest, _ *resource.CreateResponse) { +} +func (r *FunctionResource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) { +} +func (r *FunctionResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { +} +func (r *FunctionResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) { +} diff --git a/terraform/provider/internal/resources/http_trigger_resource.go b/terraform/provider/internal/resources/http_trigger_resource.go index c538c0f..5a9fca4 100644 --- a/terraform/provider/internal/resources/http_trigger_resource.go +++ b/terraform/provider/internal/resources/http_trigger_resource.go @@ -23,8 +23,13 @@ func (r *HTTPTriggerResource) Schema(_ context.Context, _ resource.SchemaRequest resp.Schema = schema.Schema{} } -func (r *HTTPTriggerResource) Configure(_ context.Context, _ resource.ConfigureRequest, _ *resource.ConfigureResponse) {} -func (r *HTTPTriggerResource) Create(_ context.Context, _ resource.CreateRequest, _ *resource.CreateResponse) {} -func (r *HTTPTriggerResource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) {} -func (r *HTTPTriggerResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) {} -func (r *HTTPTriggerResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {} +func (r *HTTPTriggerResource) Configure(_ context.Context, _ resource.ConfigureRequest, _ *resource.ConfigureResponse) { +} +func (r *HTTPTriggerResource) Create(_ context.Context, _ resource.CreateRequest, _ *resource.CreateResponse) { +} +func (r *HTTPTriggerResource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) { +} +func (r *HTTPTriggerResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { +} +func (r *HTTPTriggerResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) { +} diff --git a/terraform/provider/internal/resources/package_resource.go b/terraform/provider/internal/resources/package_resource.go index 1485287..dad9a85 100644 --- a/terraform/provider/internal/resources/package_resource.go +++ b/terraform/provider/internal/resources/package_resource.go @@ -1,30 +1,383 @@ package resources import ( + "archive/zip" + "bytes" "context" + "encoding/base64" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "terraform-provider-fission/internal/client" ) var _ resource.Resource = &PackageResource{} -type PackageResource struct{} +// Изменено: 2026-04-14 19:45 UTC. +// Resource для управления Fission Package через Kubernetes CRD API. +type PackageResource struct { + client *client.Client +} +// packageResourceModel описывает состояние terraform ресурса fission_package. +type packageResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Environment types.String `tfsdk:"environment"` + SourceDir types.String `tfsdk:"source_dir"` + CodePath types.String `tfsdk:"code_path"` + CodeHash types.String `tfsdk:"code_hash"` + BuildCmd types.String `tfsdk:"build_command"` + Namespace types.String `tfsdk:"namespace"` + UID types.String `tfsdk:"uid"` + BuildStatus types.String `tfsdk:"build_status"` + BuildLog types.String `tfsdk:"build_log"` +} + +// NewPackageResource создает инстанс ресурса пакета. func NewPackageResource() resource.Resource { return &PackageResource{} } +// Metadata задает имя ресурса fission_package. func (r *PackageResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_package" } +// Schema задает Terraform schema для Package. func (r *PackageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{} + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "Идентификатор ресурса (namespace/name).", + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Имя Fission Package.", + }, + "environment": schema.StringAttribute{ + Required: true, + Description: "Имя Fission Environment, связанного с пакетом.", + }, + "source_dir": schema.StringAttribute{ + Optional: true, + Description: "Путь к директории с кодом, которая будет упакована в zip.", + }, + "code_path": schema.StringAttribute{ + Optional: true, + Description: "Путь к готовому zip-архиву с кодом.", + }, + "code_hash": schema.StringAttribute{ + Optional: true, + Description: "Произвольный хеш кода для контроля изменений.", + }, + "build_command": schema.StringAttribute{ + Optional: true, + Description: "Команда сборки пакета в Fission.", + }, + "namespace": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Namespace, где создается Package. По умолчанию используется provider namespace.", + }, + "uid": schema.StringAttribute{ + Computed: true, + Description: "UID Kubernetes объекта Package.", + }, + "build_status": schema.StringAttribute{ + Computed: true, + Description: "Текущий статус сборки пакета из поля status.buildstatus.", + }, + "build_log": schema.StringAttribute{ + Computed: true, + Description: "Лог сборки из поля status.buildlog.", + }, + }, + } } -func (r *PackageResource) Configure(_ context.Context, _ resource.ConfigureRequest, _ *resource.ConfigureResponse) {} -func (r *PackageResource) Create(_ context.Context, _ resource.CreateRequest, _ *resource.CreateResponse) {} -func (r *PackageResource) Read(_ context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) {} -func (r *PackageResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) {} -func (r *PackageResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {} +// Configure получает клиент из provider.Configure(). +func (r *PackageResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + fissionClient, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Некорректный тип provider data", + fmt.Sprintf("Ожидался *client.Client, получен: %T", req.ProviderData), + ) + return + } + + r.client = fissionClient +} + +// Create создает новый Package CRD объект. +func (r *PackageResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan packageResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + namespace := resolveNamespace(plan.Namespace, r.client.Namespace) + if !validatePackageSource(plan.SourceDir, plan.CodePath, &resp.Diagnostics) { + return + } + + archiveBytes, err := loadPackageArchive(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) + return + } + + packageObject := packageToUnstructured(plan, namespace, archiveBytes) + createdPackage, err := r.client.CreatePackage(ctx, packageObject) + if err != nil { + resp.Diagnostics.AddError("Ошибка создания Fission Package", err.Error()) + return + } + + state := unstructuredToPackageModel(createdPackage, plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Read синхронизирует Terraform state с текущим состоянием Package CRD. +func (r *PackageResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state packageResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + namespace := resolveNamespace(state.Namespace, r.client.Namespace) + packageObject, err := r.client.GetPackage(ctx, namespace, state.Name.ValueString()) + if err != nil { + if client.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError("Ошибка чтения Fission Package", err.Error()) + return + } + + updatedState := unstructuredToPackageModel(packageObject, state) + resp.Diagnostics.Append(resp.State.Set(ctx, &updatedState)...) +} + +// Update обновляет существующий Package CRD объект. +func (r *PackageResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan packageResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + namespace := resolveNamespace(plan.Namespace, r.client.Namespace) + if !validatePackageSource(plan.SourceDir, plan.CodePath, &resp.Diagnostics) { + return + } + + archiveBytes, err := loadPackageArchive(plan.SourceDir.ValueString(), plan.CodePath.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error()) + return + } + + existingPackage, err := r.client.GetPackage(ctx, namespace, plan.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка получения Package перед обновлением", err.Error()) + return + } + + packageObject := packageToUnstructured(plan, namespace, archiveBytes) + packageObject.SetResourceVersion(existingPackage.GetResourceVersion()) + + updatedPackage, err := r.client.UpdatePackage(ctx, packageObject) + if err != nil { + resp.Diagnostics.AddError("Ошибка обновления Fission Package", err.Error()) + return + } + + state := unstructuredToPackageModel(updatedPackage, plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Delete удаляет Package CRD объект. +func (r *PackageResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state packageResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + namespace := resolveNamespace(state.Namespace, r.client.Namespace) + err := r.client.DeletePackage(ctx, namespace, state.Name.ValueString()) + if err != nil && !client.IsNotFound(err) { + resp.Diagnostics.AddError("Ошибка удаления Fission Package", err.Error()) + } +} + +// validatePackageSource проверяет, что задан ровно один источник кода: source_dir или code_path. +func validatePackageSource(sourceDir, codePath types.String, diagnostics *diag.Diagnostics) bool { + hasSourceDir := !sourceDir.IsNull() && !sourceDir.IsUnknown() && sourceDir.ValueString() != "" + hasCodePath := !codePath.IsNull() && !codePath.IsUnknown() && codePath.ValueString() != "" + + if hasSourceDir == hasCodePath { + diagnostics.AddError( + "Некорректная конфигурация источника кода", + "Нужно указать ровно один параметр: source_dir или code_path.", + ) + return false + } + + return true +} + +// resolveNamespace возвращает namespace ресурса или namespace из provider config. +func resolveNamespace(resourceNamespace types.String, providerNamespace string) string { + namespace := resourceNamespace.ValueString() + if namespace == "" { + namespace = providerNamespace + } + + return namespace +} + +// loadPackageArchive читает zip из code_path или собирает zip из source_dir. +func loadPackageArchive(sourceDir, codePath string) ([]byte, error) { + if sourceDir != "" { + return zipDirectory(sourceDir) + } + + archiveBytes, err := os.ReadFile(codePath) + if err != nil { + return nil, fmt.Errorf("read code_path %q: %w", codePath, err) + } + + return archiveBytes, 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. +func packageToUnstructured(model packageResourceModel, namespace string, archiveBytes []byte) *unstructured.Unstructured { + literalSource := base64.StdEncoding.EncodeToString(archiveBytes) + + object := map[string]interface{}{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]interface{}{ + "name": model.Name.ValueString(), + "namespace": namespace, + }, + "spec": map[string]interface{}{ + "environment": map[string]interface{}{ + "name": model.Environment.ValueString(), + "namespace": namespace, + }, + "source": map[string]interface{}{ + "type": "literal", + "literal": literalSource, + }, + }, + } + + if buildCommand := model.BuildCmd.ValueString(); buildCommand != "" { + _ = unstructured.SetNestedField(object, buildCommand, "spec", "buildcmd") + } + + return &unstructured.Unstructured{Object: object} +} + +// unstructuredToPackageModel читает нужные поля из CRD объекта обратно в Terraform state. +func unstructuredToPackageModel(packageObject *unstructured.Unstructured, base packageResourceModel) packageResourceModel { + environmentName, _, _ := unstructured.NestedString(packageObject.Object, "spec", "environment", "name") + buildCommand, _, _ := unstructured.NestedString(packageObject.Object, "spec", "buildcmd") + buildStatus, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildstatus") + buildLog, _, _ := unstructured.NestedString(packageObject.Object, "status", "buildlog") + + state := base + state.Name = types.StringValue(packageObject.GetName()) + state.Namespace = types.StringValue(packageObject.GetNamespace()) + state.ID = types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())) + state.UID = types.StringValue(string(packageObject.GetUID())) + + if environmentName != "" { + state.Environment = types.StringValue(environmentName) + } + if buildCommand != "" { + state.BuildCmd = types.StringValue(buildCommand) + } + if buildStatus != "" { + state.BuildStatus = types.StringValue(buildStatus) + } + if buildLog != "" { + state.BuildLog = types.StringValue(buildLog) + } + + return state +}