Files
fission-console/terraform/provider/internal/resources/package_resource.go
T

358 lines
13 KiB
Go

package resources
import (
"context"
"encoding/base64"
"fmt"
"os"
"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"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"terraform-provider-fission/internal/client"
)
var _ resource.Resource = &PackageResource{}
var _ resource.ResourceWithImportState = &PackageResource{}
// Изменено: 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{
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.",
},
},
}
}
// 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
}
literalBytes, err := loadPackageLiteral(plan.SourceDir.ValueString(), plan.CodePath.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return
}
packageObject := packageToUnstructured(plan, namespace, literalBytes)
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
}
literalBytes, err := loadPackageLiteral(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, literalBytes)
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())
}
}
// 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() != ""
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
}
// loadPackageLiteral читает bytes для spec.deployment.literal.
func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) {
if 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
}
literalBytes, err := os.ReadFile(codePath)
if err != nil {
return nil, fmt.Errorf("read code_path %q: %w", codePath, err)
}
return literalBytes, nil
}
// packageToUnstructured преобразует Terraform model в Kubernetes CRD payload.
func packageToUnstructured(model packageResourceModel, namespace string, literalBytes []byte) *unstructured.Unstructured {
literalSource := base64.StdEncoding.EncodeToString(literalBytes)
object := map[string]interface{}{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]interface{}{
"name": model.Name.ValueString(),
"namespace": namespace,
},
"spec": map[string]interface{}{
"deployment": map[string]interface{}{
"type": "literal",
"literal": literalSource,
},
"environment": map[string]interface{}{
"name": model.Environment.ValueString(),
"namespace": namespace,
},
"source": map[string]interface{}{},
},
}
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 := packageResourceModel{
ID: types.StringValue(fmt.Sprintf("%s/%s", packageObject.GetNamespace(), packageObject.GetName())),
Name: types.StringValue(packageObject.GetName()),
Environment: base.Environment,
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 != "" {
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
}